You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

428 lines
14 KiB
Python

import os
from multiprocessing import Pool
import click
import numpy as np
import pandas as pd
from scipy import stats
from analysis import runup_models
from utils import crossings
from logs import setup_logging
logger = setup_logging()
MULTIPROCESS_THREADS = int(os.environ.get("MULTIPROCESS_THREADS", 4))
def forecast_twl(
df_tides,
df_profiles,
df_waves,
df_profile_features,
df_grain_size,
runup_function,
n_processes=MULTIPROCESS_THREADS,
slope="foreshore",
profile_type="prestorm",
):
# Use df_waves as a base
df_twl = df_waves.copy()
# Merge tides
logger.info("Merging tides")
df_twl = df_twl.merge(df_tides, left_index=True, right_index=True)
# Estimate foreshore slope. Do the analysis per site_id. This is so we only have to query the x and z
# cross-section profiles once per site.
site_ids = df_twl.index.get_level_values("site_id").unique()
if slope == "foreshore":
logger.info("Calculating foreshore slopes")
# Process each site_id with a different process and combine results at the end
with Pool(processes=n_processes) as pool:
results = pool.starmap(
foreshore_slope_for_site_id,
[(site_id, df_twl, df_profiles) for site_id in site_ids],
)
df_twl["beta"] = pd.concat(results)
elif slope == "mean":
logger.info("Calculating mean (dune toe to MHW) slopes")
btm_z = 0.5 # m AHD
# When calculating mean slope, we go from the dune toe to mhw. However, in some profiles, the dune toe is not
# defined. In these cases, we should go to the dune crest. Let's make a temporary dataframe which has this
# already calculated.
df_top_ele = df_profile_features.xs(profile_type, level="profile_type").copy()
df_top_ele.loc[:, "top_ele"] = df_top_ele.dune_toe_z
df_top_ele.loc[
df_top_ele.top_ele.isnull().values, "top_ele"
] = df_top_ele.dune_crest_z
n_no_top_ele = len(df_top_ele[df_top_ele.top_ele.isnull()].index)
if n_no_top_ele != 0:
logger.warning(
"{} sites do not have dune toes/crests to calculate mean slope".format(
n_no_top_ele
)
)
df_slopes = (
df_profiles.xs(profile_type, level="profile_type")
.dropna(subset=["z"])
.groupby("site_id")
.apply(
lambda x: slope_from_profile(
profile_x=x.index.get_level_values("x").tolist(),
profile_z=x.z.tolist(),
top_elevation=df_top_ele.loc[x.index[0][0], :].top_ele,
btm_elevation=btm_z,
method="least_squares",
)
)
.rename("beta")
.to_frame()
)
# Merge calculated slopes onto each twl timestep
df_twl = df_twl.merge(df_slopes, left_index=True, right_index=True)
elif slope == "intertidal":
logger.info("Calculating intertidal slopes")
top_z = 1.15 # m AHD = HAT from MHL annual ocean tides summary report
btm_z = -0.9 # m AHD = HAT from MHL annual ocean tides summary report
# Calculate slopes for each profile
df_slopes = (
df_profiles.xs(profile_type, level="profile_type")
.dropna(subset=["z"])
.groupby("site_id")
.apply(
lambda x: slope_from_profile(
profile_x=x.index.get_level_values("x").tolist(),
profile_z=x.z.tolist(),
top_elevation=top_z,
btm_elevation=max(min(x.z), btm_z),
method="least_squares",
)
)
.rename("beta")
.to_frame()
)
# Merge calculated slopes onto each twl timestep
df_twl = df_twl.merge(df_slopes, left_index=True, right_index=True)
# Estimate runup
R2, setup, S_total, S_inc, S_ig = runup_function(
Hs0=df_twl["Hs0"].tolist(),
Tp=df_twl["Tp"].tolist(),
beta=df_twl["beta"].tolist(),
r=df_twl.merge(df_grain_size, on="site_id").r.tolist(),
)
df_twl["R2"] = R2
df_twl["setup"] = setup
df_twl["S_total"] = S_total
# Estimate TWL
df_twl["R_high"] = df_twl["tide"] + df_twl["R2"]
df_twl["R_low"] = (
df_twl["tide"] + 1.1 * df_twl["setup"] - 1.1 / 2 * df_twl["S_total"]
)
# Drop unneeded columns
# df_twl.drop(columns=["E", "Exs", "P", "Pxs", "dir"], inplace=True, errors="ignore")
return df_twl
def mean_slope_for_site_id(
site_id,
df_twl,
df_profiles,
top_elevation_col,
top_x_col,
btm_elevation_col,
profile_type="prestorm",
):
"""
Calculates the foreshore slope values a given site_id. Returns a series (with same indicies as df_twl) of
foreshore slopes. This function is used to parallelize getting foreshore slopes as it is computationally
expensive, given the need to iterate for the foreshore slope.
:param site_id:
:param df_twl:
:param df_profiles:
:return: A dataframe with slope values calculated
"""
# Get the prestorm beach profile
profile = df_profiles.loc[(site_id, profile_type)]
profile_x = profile.index.get_level_values("x").tolist()
profile_z = profile.z.tolist()
idx = pd.IndexSlice
df_twl_site = df_twl.loc[idx[site_id, :], :]
df_beta = df_twl_site.apply(
lambda row: slope_from_profile(
profile_x=profile_x,
profile_z=profile_z,
top_elevation=row[top_elevation_col],
btm_elevation=row[btm_elevation_col],
method="least_squares",
top_x=row[top_x_col],
),
axis=1,
)
return df_beta
def foreshore_slope_for_site_id(site_id, df_twl, df_profiles):
"""
Calculates the foreshore slope values a given site_id. Returns a series (with same indicies as df_twl) of
foreshore slopes. This function is used to parallelize getting foreshore slopes as it is computationally
expensive, given the need to iterate for the foreshore slope.
:param site_id:
:param df_twl:
:param df_profiles:
:return: A dataframe with slope values calculated
"""
# Get the prestorm beach profile
profile = df_profiles.query(
"site_id =='{}' and profile_type == 'prestorm'".format(site_id)
)
profile_x = profile.index.get_level_values("x").tolist()
profile_z = profile.z.tolist()
df_twl_site = df_twl.query("site_id == '{}'".format(site_id))
df_beta = df_twl_site.apply(
lambda row: foreshore_slope_from_profile(
profile_x=profile_x,
profile_z=profile_z,
tide=row.tide,
runup_function=runup_models.sto06,
Hs0=row.Hs0,
Tp=row.Tp,
),
axis=1,
)
return df_beta
def foreshore_slope_from_profile(profile_x, profile_z, tide, runup_function, **kwargs):
"""
Returns the foreshore slope given the beach profile, water level (tide) and runup_function. Since foreshore slope is
dependant on the setup elevation and swash magnitude, which in tern is dependant on the foreshore slope, the process
requires iteration to solve.
:param profile_x:
:param profile_z:
:param tide:
:param runup_function: The name of a function which will return runup values (refer to runup_models.py)
:param kwargs: Additional keyword arguments which will be passed to the runup_function (usually Hs0, Tp).
:return:
"""
# Sometimes there is no tide value for a record, so return None
if np.isnan(tide):
return None
# Initalize estimates
max_number_iterations = 30
iteration_count = 0
averaged_accuracy = (
0.03
) # if slopes within this amount, average after max number of iterations
acceptable_accuracy = (
0.01
) # if slopes within this amount, accept after max number of iterations
preferred_accuracy = 0.001 # if slopes within this amount, accept
beta = 0.05
while True:
R2, setup, S_total, _, _ = runup_function(beta=beta, **kwargs)
beta_new = slope_from_profile(
profile_x=profile_x,
profile_z=profile_z,
method="least_squares",
top_elevation=tide + setup + S_total / 2,
btm_elevation=tide + setup - S_total / 2,
)
# Return None if we can't find a slope, usually because the elevations we've specified are above/below our
# profile x and z coordinates.
if beta_new is None:
return None
# If slopes do not change much between interactions, return the slope
if abs(beta_new - beta) < preferred_accuracy:
return beta
# If we can't converge a solution, return None
if iteration_count > max_number_iterations:
if abs(beta_new - beta) < acceptable_accuracy:
return beta
elif abs(beta_new - beta) < averaged_accuracy:
return (beta_new + beta) / 2
else:
return None
beta = beta_new
iteration_count += 1
def slope_from_profile(
profile_x,
profile_z,
top_elevation,
btm_elevation,
method="end_points",
top_x=None,
btm_x=None,
):
"""
Returns a slope (beta) from a bed profile, given the top and bottom elevations of where the slope should be taken.
:param x: List of x bed profile coordinates
:param z: List of z bed profile coordinates
:param top_elevation: Top elevation of where to take the slope
:param btm_elevation: Bottom elevation of where to take the slope
:param method: Method used to calculate slope (end_points or least_squares)
:param top_x: x-coordinate of the top end point. May be needed, as there may be multiple crossings of the
top_elevation.
:param btm_x: x-coordinate of the bottom end point
:return:
"""
# Need all data to get the slope
if any([x is None for x in [profile_x, profile_z, top_elevation, btm_elevation]]):
return None
end_points = {"top": {"z": top_elevation}, "btm": {"z": btm_elevation}}
for end_type in end_points.keys():
# Add x coordinates if they are specified
if top_x and end_type == "top":
end_points["top"]["x"] = top_x
continue
if btm_x and end_type == "top":
end_points["btm"]["x"] = btm_x
continue
elevation = end_points[end_type]["z"]
intersection_x = crossings(profile_x, profile_z, elevation)
# No intersections found
if len(intersection_x) == 0:
return None
# One intersection
elif len(intersection_x) == 1:
end_points[end_type]["x"] = intersection_x[0]
# More than on intersection
else:
if end_type == "top":
# For top elevation, take most seaward intersection
end_points[end_type]["x"] = intersection_x[-1]
else:
# For bottom elevation, take most landward intersection that is seaward of top elevation
end_point_btm = [
x for x in intersection_x if x > end_points["top"]["x"]
]
if len(end_point_btm) == 0:
# If there doesn't seem to be an intersection seaward of the top elevation, return none.
logger.warning("No intersections found seaward of top elevation")
return None
else:
end_points[end_type]["x"] = end_point_btm[0]
# Ensure that top point is landward of bottom point
if end_points["top"]["x"] > end_points["btm"]["x"]:
logger.warning("Top point is not landward of bottom point")
return None
if method == "end_points":
x_top = end_points["top"]["x"]
x_btm = end_points["btm"]["x"]
z_top = end_points["top"]["z"]
z_btm = end_points["btm"]["z"]
return -(z_top - z_btm) / (x_top - x_btm)
elif method == "least_squares":
profile_mask = [
True if end_points["top"]["x"] < pts < end_points["btm"]["x"] else False
for pts in profile_x
]
slope_x = np.array(profile_x)[profile_mask].tolist()
slope_z = np.array(profile_z)[profile_mask].tolist()
slope, _, _, _, _ = stats.linregress(slope_x, slope_z)
return -slope
@click.command()
@click.option("--waves-csv", required=True, help="")
@click.option("--tides-csv", required=True, help="")
@click.option("--profiles-csv", required=True, help="")
@click.option("--profile-features-csv", required=True, help="")
@click.option(
"--runup-function",
required=True,
help="",
type=click.Choice(["sto06", "hol86", "nie91", "pow18"]),
)
@click.option(
"--slope",
required=True,
help="",
type=click.Choice(["foreshore", "mean", "intertidal"]),
)
@click.option(
"--profile-type",
required=True,
help="",
type=click.Choice(["prestorm", "poststorm"]),
)
@click.option("--output-file", required=True, help="")
@click.option("--grain-size-csv", required=False, help="")
def create_twl_forecast(
waves_csv,
tides_csv,
profiles_csv,
profile_features_csv,
runup_function,
slope,
profile_type,
output_file,
grain_size_csv,
):
logger.info("Creating forecast of total water levels")
logger.info("Importing data")
df_waves = pd.read_csv(waves_csv, index_col=[0, 1])
df_tides = pd.read_csv(tides_csv, index_col=[0, 1])
df_profiles = pd.read_csv(profiles_csv, index_col=[0, 1, 2])
df_profile_features = pd.read_csv(profile_features_csv, index_col=[0, 1])
df_grain_size = pd.read_csv(grain_size_csv, index_col=[0])
logger.info("Forecasting TWL")
df_twl = forecast_twl(
df_tides,
df_profiles,
df_waves,
df_profile_features,
df_grain_size,
runup_function=getattr(runup_models, runup_function),
slope=slope,
profile_type=profile_type,
)
df_twl.to_csv(output_file)
logger.info("Saved to %s", output_file)
logger.info("Done!")