""" Coastal analyses on Digital Earth Africa data. """ # Import required packages # Force GeoPandas to use Shapely instead of PyGEOS # In a future release, GeoPandas will switch to using Shapely by default. import os os.environ['USE_PYGEOS'] = '0' import requests import numpy as np import xarray as xr import pandas as pd import geopandas as gpd import matplotlib.pyplot as plt from scipy import stats from otps import TimePoint from otps import predict_tide from shapely.geometry import box from datacube.utils.geometry import CRS from owslib.wfs import WebFeatureService from deafrica_tools.datahandling import parallel_apply # Fix converters for tidal plot from pandas.plotting import register_matplotlib_converters register_matplotlib_converters() # URL for the DE Africa Coastlines data on Geoserver. WFS_ADDRESS = "https://geoserver.digitalearth.africa/geoserver/wfs" def model_tides( x, y, time, model="FES2014", directory="/var/share/tide_models", epsg=4326, method="bilinear", extrapolate=True, cutoff=10.0, ): """ Compute tides at points and times using tidal harmonics. If multiple x, y points are provided, tides will be computed for all timesteps at each point. This function supports any tidal model supported by `pyTMD`, including the FES2014 Finite Element Solution tide model, and the TPXO8-atlas and TPXO9-atlas-v5 TOPEX/POSEIDON global tide models. This function requires access to tide model data files to work. These should be placed in a folder with subfolders matching the formats specified by `pyTMD`: https://pytmd.readthedocs.io/en/latest/getting_started/Getting-Started.html#directories For FES2014 (https://www.aviso.altimetry.fr/es/data/products/auxiliary-products/global-tide-fes/description-fes2014.html): - {directory}/fes2014/ocean_tide/ {directory}/fes2014/load_tide/ For TPXO8-atlas (https://www.tpxo.net/tpxo-products-and-registration): - {directory}/tpxo8_atlas/ For TPXO9-atlas-v5 (https://www.tpxo.net/tpxo-products-and-registration): - {directory}/TPXO9_atlas_v5/ This function is a minor modification of the `pyTMD` package's `compute_tide_corrections` function, adapted to process multiple timesteps for multiple input point locations. For more info: https://pytmd.readthedocs.io/en/stable/user_guide/compute_tide_corrections.html Parameters: ----------- x, y : float or list of floats One or more x and y coordinates used to define the location at which to model tides. By default these coordinates should be lat/lon; use `epsg` if they are in a custom coordinate reference system. time : A datetime array or pandas.DatetimeIndex An array containing 'datetime64[ns]' values or a 'pandas.DatetimeIndex' providing the times at which to model tides in UTC time. model : string The tide model used to model tides. Options include: - "FES2014" (only pre-configured option on DEA Sandbox) - "TPXO8-atlas" - "TPXO9-atlas-v5" directory : string The directory containing tide model data files. These data files should be stored in sub-folders for each model that match the structure provided by `pyTMD`: https://pytmd.readthedocs.io/en/latest/getting_started/Getting-Started.html#directories For example: - {directory}/fes2014/ocean_tide/ {directory}/fes2014/load_tide/ - {directory}/tpxo8_atlas/ - {directory}/TPXO9_atlas_v5/ epsg : int Input coordinate system for 'x' and 'y' coordinates. Defaults to 4326 (WGS84). method : string Method used to interpolate tidal contsituents from model files. Options include: - bilinear: quick bilinear interpolation - spline: scipy bivariate spline interpolation - linear, nearest: scipy regular grid interpolations extrapolate : bool Whether to extrapolate tides for locations outside of the tide modelling domain using nearest-neighbor cutoff : int or float Extrapolation cutoff in kilometers. Set to `np.inf` to extrapolate for all points. Returns ------- A pandas.DataFrame containing tide heights for every combination of time and point coordinates. """ import os import pyproj import numpy as np import pyTMD.time import pyTMD.model import pyTMD.utilities from pyTMD.calc_delta_time import calc_delta_time from pyTMD.infer_minor_corrections import infer_minor_corrections from pyTMD.predict_tide_drift import predict_tide_drift from pyTMD.read_tide_model import extract_tidal_constants from pyTMD.read_netcdf_model import extract_netcdf_constants from pyTMD.read_GOT_model import extract_GOT_constants from pyTMD.read_FES_model import extract_FES_constants # Check that tide directory is accessible try: os.access(directory, os.F_OK) except: raise FileNotFoundError("Invalid tide directory") # Get parameters for tide model model = pyTMD.model(directory, format="netcdf", compressed=False).elevation(model) # If time passed as a single Timestamp, convert to datetime64 if isinstance(time, pd.Timestamp): time = time.to_datetime64() # Handle numeric or array inputs x = np.atleast_1d(x) y = np.atleast_1d(y) time = np.atleast_1d(time) # Determine point and time counts assert len(x) == len(y), "x and y must be the same length" n_points = len(x) n_times = len(time) # Converting x,y from EPSG to latitude/longitude try: # EPSG projection code string or int crs1 = pyproj.CRS.from_string("epsg:{0:d}".format(int(epsg))) except (ValueError, pyproj.exceptions.CRSError): # Projection SRS string crs1 = pyproj.CRS.from_string(epsg) crs2 = pyproj.CRS.from_string("epsg:{0:d}".format(4326)) transformer = pyproj.Transformer.from_crs(crs1, crs2, always_xy=True) lon, lat = transformer.transform(x.flatten(), y.flatten()) # Assert delta time is an array and convert datetime time = np.atleast_1d(time) t = pyTMD.time.convert_datetime(time, epoch=(1992, 1, 1, 0, 0, 0)) / 86400.0 # Delta time (TT - UT1) file delta_file = pyTMD.utilities.get_data_path(["data", "merged_deltat.data"]) # Read tidal constants and interpolate to grid points if model.format in ("OTIS", "ATLAS"): amp, ph, D, c = extract_tidal_constants( lon, lat, model.grid_file, model.model_file, model.projection, TYPE=model.type, METHOD=method, EXTRAPOLATE=extrapolate, CUTOFF=cutoff, GRID=model.format, ) deltat = np.zeros_like(t) elif model.format == "netcdf": amp, ph, D, c = extract_netcdf_constants( lon, lat, model.grid_file, model.model_file, TYPE=model.type, METHOD=method, EXTRAPOLATE=extrapolate, CUTOFF=cutoff, SCALE=model.scale, GZIP=model.compressed, ) deltat = np.zeros_like(t) elif model.format == "GOT": amp, ph, c = extract_GOT_constants( lon, lat, model.model_file, METHOD=method, EXTRAPOLATE=extrapolate, CUTOFF=cutoff, SCALE=model.scale, GZIP=model.compressed, ) # Interpolate delta times from calendar dates to tide time deltat = calc_delta_time(delta_file, t) elif model.format == "FES": amp, ph = extract_FES_constants( lon, lat, model.model_file, TYPE=model.type, VERSION=model.version, METHOD=method, EXTRAPOLATE=extrapolate, CUTOFF=cutoff, SCALE=model.scale, GZIP=model.compressed, ) # Available model constituents c = model.constituents # Interpolate delta times from calendar dates to tide time deltat = calc_delta_time(delta_file, t) # Calculate complex phase in radians for Euler's cph = -1j * ph * np.pi / 180.0 # Calculate constituent oscillation hc = amp * np.exp(cph) # Repeat constituents to length of time and number of input # coords before passing to `predict_tide_drift` t, hc, deltat = ( np.tile(t, n_points), hc.repeat(n_times, axis=0), np.tile(deltat, n_points), ) # Predict tidal elevations at time and infer minor corrections npts = len(t) tide = np.ma.zeros((npts), fill_value=np.nan) tide.mask = np.any(hc.mask, axis=1) # Depending on pyTMD version (<=1.06 vs > 1.06), use different params # TODO: Remove once Sandbox is updated to use pyTMD version 1.0.9 try: tide.data[:] = predict_tide_drift( t, hc, c, deltat=deltat, corrections=model.format ) minor = infer_minor_corrections( t, hc, c, deltat=deltat, corrections=model.format ) except: tide.data[:] = predict_tide_drift( t, hc, c, DELTAT=deltat, CORRECTIONS=model.format ) minor = infer_minor_corrections( t, hc, c, DELTAT=deltat, CORRECTIONS=model.format ) tide.data[:] += minor.data[:] # Replace invalid values with fill value tide.data[tide.mask] = tide.fill_value # Export data as a dataframe return pd.DataFrame( { "time": np.tile(time, n_points), "x": np.repeat(x, n_times), "y": np.repeat(y, n_times), "tide_m": tide, } ).set_index("time") def pixel_tides( ds, times=None, resample=True, calculate_quantiles=None, resolution=None, buffer=None, resample_method="bilinear", **model_tides_kwargs, ): """ Obtain tide heights for each pixel in a dataset by modelling tides into a low-resolution grid surrounding the dataset, then (optionally) spatially resample this low-res data back into the original higher resolution dataset extent and resolution. Parameters: ----------- ds : xarray.Dataset A dataset whose geobox (`ds.odc.geobox`) will be used to define the spatial extent of the low resolution tide modelling grid. times : pandas.DatetimeIndex or list of pandas.Timestamps, optional By default, the function will model tides using the times contained in the `time` dimension of `ds`. Alternatively, this param can be used to model tides for a custom set of times instead. For example: `times=pd.date_range(start="2000", end="2001", freq="5h")` resample : bool, optional Whether to resample low resolution tides back into `ds`'s original higher resolution grid. Set this to `False` if you do not want low resolution tides to be re-projected back to higher resolution. calculate_quantiles : list or np.array, optional Rather than returning all individual tides, low-resolution tides can be first aggregated using a quantile calculation by passing in a list or array of quantiles to compute. For example, this could be used to calculate the min/max tide across all times: `calculate_quantiles=[0.0, 1.0]`. resolution: int, optional The desired resolution of the low-resolution grid used for tide modelling. The default None will create a 5000 m resolution grid if `ds` has a projected CRS (i.e. metre units), or a 0.05 degree resolution grid if `ds` has a geographic CRS (e.g. degree units). Note: higher resolutions do not necessarily provide better tide modelling performance, as results will be limited by the resolution of the underlying global tide model (e.g. 1/16th degree / ~5 km resolution grid for FES2014). buffer : int, optional The amount by which to buffer the higher resolution grid extent when creating the new low resolution grid. This buffering is important as it ensures that ensure pixel-based tides are seamless across dataset boundaries. This buffer will eventually be clipped away when the low-resolution data is re-projected back to the resolution and extent of the higher resolution dataset. To ensure that at least two pixels occur outside of the dataset bounds, the default None applies a 12000 m buffer if `ds` has a projected CRS (i.e. metre units), or a 0.12 degree buffer if `ds` has a geographic CRS (e.g. degree units). resample_method : string, optional If resampling is requested (see `resample` above), use this resampling method when converting from low resolution to high resolution pixels. Defaults to "bilinear"; valid options include "nearest", "cubic", "min", "max", "average" etc. **model_tides_kwargs : Optional parameters passed to the `dea_tools.coastal.model_tides` function. Important parameters include "model" and "directory", used to specify the tide model to use and the location of its files. Returns: -------- If `resample` is True: tides_lowres : xr.DataArray A low resolution data array giving either tide heights every timestep in `ds` (if `times` is None), tide heights at every time in `times` (if `times` is not None), or tide height quantiles for every quantile provided by `calculate_quantiles`. If `resample` is False: tides_highres, tides_lowres : tuple of xr.DataArrays In addition to `tides_lowres` (see above), a high resolution array of tide heights will be generated that matches the exact spatial resolution and extent of `ds`. This will contain either tide heights every timestep in `ds` (if `times` is None), tide heights at every time in `times` (if `times` is not None), or tide height quantiles for every quantile provided by `calculate_quantiles`. """ import odc.geo.xr from odc.geo.geobox import GeoBox # First test if no time dimension and nothing passed to `times` if ('time' not in ds.dims) & (times is None): raise ValueError( "`ds` does not contain a 'time' dimension. Times are required " "for modelling tides: please pass in a set of custom tides " "using the `times` parameter. For example: " "`times=pd.date_range(start='2000', end='2001', freq='5h')`" ) # If custom times are provided, convert them to a consistent # pandas.DatatimeIndex format if times is not None: if isinstance(times, list): time_coords = pd.DatetimeIndex(times) elif isinstance(times, pd.Timestamp): time_coords = pd.DatetimeIndex([times]) else: time_coords = times # Otherwise, use times from `ds` directly else: time_coords = ds.coords["time"] # Determine spatial dimensions y_dim, x_dim = ds.odc.spatial_dims # Determine resolution and buffer, using different defaults for # geographic (i.e. degrees) and projected (i.e. metres) CRSs: crs_units = ds.odc.geobox.crs.units[0][0:6] if ds.odc.geobox.crs.geographic: if resolution is None: resolution = 0.05 elif resolution > 360: raise ValueError(f"A resolution of greater than 360 was " f"provided, but `ds` has a geographic CRS " f"in {crs_units} units. Did you accidently " f"provide a resolution in projected " f"(i.e. metre) units?") if buffer is None: buffer = 0.12 else: if resolution is None: resolution = 5000 elif resolution < 1: raise ValueError(f"A resolution of less than 1 was provided, " f"but `ds` has a projected CRS in " f"{crs_units} units. Did you accidently " f"provide a resolution in geographic " f"(degree) units?") if buffer is None: buffer = 12000 # Raise error if resolution is less than dataset resolution dataset_res = ds.odc.geobox.resolution.x if resolution < dataset_res: raise ValueError(f"The resolution of the low-resolution tide " f"modelling grid ({resolution:.2f}) is less " f"than `ds`'s pixel resolution ({dataset_res:.2f}). " f"This can cause extremely slow tide modelling " f"performance. Please select provide a resolution " f"greater than {dataset_res:.2f} using " f"`pixel_tides`'s 'resolution' parameter.") # Create a new reduced resolution tide modelling grid after # first buffering the grid print(f"Creating reduced resolution {resolution} x {resolution} " f"{crs_units} tide modelling array") buffered_geobox = ds.odc.geobox.buffered(buffer) rescaled_geobox = GeoBox.from_bbox( bbox=buffered_geobox.boundingbox, resolution=resolution ) rescaled_ds = odc.geo.xr.xr_zeros(rescaled_geobox) # Flatten grid to 1D, then add time dimension flattened_ds = rescaled_ds.stack(z=(x_dim, y_dim)) flattened_ds = flattened_ds.expand_dims(dim={"time": time_coords.values}) # Model tides for each timestep model = ( "FES2014" if "model" not in model_tides_kwargs else model_tides_kwargs["model"] ) print(f"Modelling tides using {model} tide model") tide_df = model_tides( x=flattened_ds[x_dim], y=flattened_ds[y_dim], time=flattened_ds.time, epsg=ds.odc.geobox.crs.epsg, **model_tides_kwargs, ) # Rename x and y coordinates to match satellite array tide_df = tide_df.rename({"x": x_dim, "y": y_dim}, axis=1) # Insert modelled tide values back into flattened array, then unstack # back to 3D (y, x, time) tides_lowres = ( # Convert dataframe to xarray format tide_df.set_index([x_dim, y_dim], append=True) .to_xarray() # Re-index and transpose back into 3D .tide_m.reindex_like(rescaled_ds) .transpose("time", y_dim, x_dim) .astype(np.float32) ) # Optionally calculate and return quantiles rather than raw data if calculate_quantiles is not None: print("Computing tide quantiles") tides_lowres = tides_lowres.quantile(q=calculate_quantiles, dim="time") reproject_dim = "quantile" else: reproject_dim = "time" # Ensure CRS is present tides_lowres = tides_lowres.odc.assign_crs(ds.odc.geobox.crs) # Reproject each timestep into original high resolution grid if resample: print("Reprojecting tides into original array") tides_highres = parallel_apply( tides_lowres, reproject_dim, odc.algo.xr_reproject, ds.odc.geobox.compat, resample_method, ) return tides_highres, tides_lowres else: print("Returning low resolution tide array") return tides_lowres def tidal_tag( ds, ebb_flow=False, swap_dims=False, tidepost_lat=None, tidepost_lon=None, return_tideposts=False, **model_tides_kwargs, ): """ Takes an xarray.Dataset and returns the same dataset with a new `tide_m` variable giving the height of the tide at the exact moment of each satellite acquisition. The function models tides at the centroid of the dataset by default, but a custom tidal modelling location can be specified using `tidepost_lat` and `tidepost_lon`. The default settings use the FES2014 global tidal model, implemented using the pyTMD Python package. FES2014 was produced by NOVELTIS, LEGOS, CLS Space Oceanography Division and CNES. It is distributed by AVISO, with support from CNES (http://www.aviso.altimetry.fr/). Parameters ---------- ds : xarray.Dataset An xarray.Dataset object with x, y and time dimensions ebb_flow : bool, optional An optional boolean indicating whether to compute if the tide phase was ebbing (falling) or flowing (rising) for each observation. The default is False; if set to True, a new `ebb_flow` variable will be added to the dataset with each observation labelled with 'Ebb' or 'Flow'. swap_dims : bool, optional An optional boolean indicating whether to swap the `time` dimension in the original xarray.Dataset to the new `tide_m` variable. Defaults to False. tidepost_lat, tidepost_lon : float or int, optional Optional coordinates used to model tides. The default is None, which uses the centroid of the dataset as the tide modelling location. return_tideposts : bool, optional An optional boolean indicating whether to return the `tidepost_lat` and `tidepost_lon` location used to model tides in addition to the xarray.Dataset. Defaults to False. **model_tides_kwargs : Optional parameters passed to the `dea_tools.coastal.model_tides` function. Important parameters include "model" and "directory", used to specify the tide model to use and the location of its files. Returns ------- The original xarray.Dataset with a new `tide_m` variable giving the height of the tide (and optionally, its ebb-flow phase) at the exact moment of each satellite acquisition (if `return_tideposts=True`, the function will also return the `tidepost_lon` and `tidepost_lat` location used in the analysis). """ import odc.geo.xr # If custom tide modelling locations are not provided, use the # dataset centroid if not tidepost_lat or not tidepost_lon: tidepost_lon, tidepost_lat = ds.odc.geobox.geographic_extent.centroid.coords[0] print( f"Setting tide modelling location from dataset centroid: " f"{tidepost_lon:.2f}, {tidepost_lat:.2f}" ) else: print( f"Using user-supplied tide modelling location: " f"{tidepost_lon:.2f}, {tidepost_lat:.2f}" ) # Use tidal model to compute tide heights for each observation: model = ( "FES2014" if "model" not in model_tides_kwargs else model_tides_kwargs["model"] ) print(f"Modelling tides using {model} tidal model") tide_df = model_tides( x=tidepost_lon, y=tidepost_lat, time=ds.time, epsg="EPSG:4326", **model_tides_kwargs, ) # If tides cannot be successfully modeled (e.g. if the centre of the # xarray dataset is located is over land), raise an exception if tide_df.tide_m.isnull().all(): raise ValueError( f"Tides could not be modelled for dataset centroid located " f"at {tidepost_lon:.2f}, {tidepost_lat:.2f}. This can occur if " f"this coordinate occurs over land. Please manually specify " f"a tide modelling location located over water using the " f"`tidepost_lat` and `tidepost_lon` parameters." ) # Assign tide heights to the dataset as a new variable ds["tide_m"] = xr.DataArray(tide_df.tide_m, coords=[ds.time]) # Optionally calculate the tide phase for each observation if ebb_flow: # Model tides for a time 15 minutes prior to each previously # modelled satellite acquisition time. This allows us to compare # tide heights to see if they are rising or falling. print("Modelling tidal phase (e.g. ebb or flow)") tide_pre_df = model_tides( x=tidepost_lon, y=tidepost_lat, time=(ds.time - pd.Timedelta("15 min")), epsg="EPSG:4326", **model_tides_kwargs, ) # Compare tides computed for each timestep. If the previous tide # was higher than the current tide, the tide is 'ebbing'. If the # previous tide was lower, the tide is 'flowing' tidal_phase = [ "Ebb" if i else "Flow" for i in tide_pre_df.tide_m.values > tide_df.tide_m.values ] # Assign tide phase to the dataset as a new variable ds["ebb_flow"] = xr.DataArray(tidal_phase, coords=[ds.time]) # If swap_dims = True, make tide height the primary dimension # instead of time if swap_dims: # Swap dimensions and sort by tide height ds = ds.swap_dims({"time": "tide_m"}) ds = ds.sortby("tide_m") ds = ds.drop_vars("time") if return_tideposts: return ds, tidepost_lon, tidepost_lat else: return ds def tidal_stats( ds, tidepost_lat=None, tidepost_lon=None, plain_english=True, plot=True, modelled_freq="2h", linear_reg=False, round_stats=3, **model_tides_kwargs, ): """ Takes an xarray.Dataset and statistically compares the tides modelled for each satellite observation against the full modelled tidal range. This comparison can be used to evaluate whether the tides observed by satellites (e.g. Landsat) are biased compared to the natural tidal range (e.g. fail to observe either the highest or lowest tides etc). For more information about the tidal statistics computed by this function, refer to Figure 8 in Bishop-Taylor et al. 2018: https://www.sciencedirect.com/science/article/pii/S0272771418308783#fig8 The function models tides at the centroid of the dataset by default, but a custom tidal modelling location can be specified using `tidepost_lat` and `tidepost_lon`. The default settings use the FES2014 global tidal model, implemented using the pyTMD Python package. FES2014 was produced by NOVELTIS, LEGOS, CLS Space Oceanography Division and CNES. It is distributed by AVISO, with support from CNES (http://www.aviso.altimetry.fr/). Parameters ---------- ds : xarray.Dataset An xarray.Dataset object with x, y and time dimensions tidepost_lat, tidepost_lon : float or int, optional Optional coordinates used to model tides. The default is None, which uses the centroid of the dataset as the tide modelling location. plain_english : bool, optional An optional boolean indicating whether to print a plain english version of the tidal statistics to the screen. Defaults to True. plot : bool, optional An optional boolean indicating whether to plot how satellite- observed tide heights compare against the full tidal range. Defaults to True. modelled_freq : str, optional An optional string giving the frequency at which to model tides when computing the full modelled tidal range. Defaults to '2h', which computes a tide height for every two hours across the temporal extent of `ds`. linear_reg: bool, optional Experimental: whether to return linear regression stats that assess whether dstellite-observed and all available tides show any decreasing or increasing trends over time. Not currently recommended as all observed regressions always return as significant due to far larger sample size. round_stats : int, optional The number of decimal places used to round the output statistics. Defaults to 3. **model_tides_kwargs : Optional parameters passed to the `dea_tools.coastal.model_tides` function. Important parameters include "model" and "directory", used to specify the tide model to use and the location of its files. Returns ------- A pandas.Series object containing the following statistics: tidepost_lat: latitude used for modelling tide heights tidepost_lon: longitude used for modelling tide heights observed_min_m: minimum tide height observed by the satellite all_min_m: minimum tide height from all available tides observed_max_m: maximum tide height observed by the satellite all_max_m: maximum tide height from all available tides observed_range_m: tidal range observed by the satellite all_range_m: full astronomical tidal range based on all available tides spread_m: proportion of the full astronomical tidal range observed by the satellite (see Bishop-Taylor et al. 2018) low_tide_offset: proportion of the lowest tides never observed by the satellite (see Bishop-Taylor et al. 2018) high_tide_offset: proportion of the highest tides never observed by the satellite (see Bishop-Taylor et al. 2018) If `linear_reg = True`, the output will also contain: observed_slope: slope of any relationship between observed tide heights and time all_slope: slope of any relationship between all available tide heights and time observed_pval: significance/p-value of any relationship between observed tide heights and time all_pval: significance/p-value of any relationship between all available tide heights and time """ # Model tides for each observation in the supplied xarray object ds_tides, tidepost_lon, tidepost_lat = tidal_tag( ds, tidepost_lat=tidepost_lat, tidepost_lon=tidepost_lon, return_tideposts=True, **model_tides_kwargs, ) # Drop spatial ref for nicer plotting if "spatial_ref" in ds_tides: ds_tides = ds_tides.drop_vars("spatial_ref") # Generate range of times covering entire period of satellite record all_timerange = pd.date_range( start=ds_tides.time.min().item(), end=ds_tides.time.max().item(), freq=modelled_freq, ) # Model tides for each timestep all_tides_df = model_tides( x=tidepost_lon, y=tidepost_lat, time=all_timerange, epsg="EPSG:4326", **model_tides_kwargs, ) # Get coarse statistics on all and observed tidal ranges obs_mean = ds_tides.tide_m.mean().item() all_mean = all_tides_df.tide_m.mean() obs_min, obs_max = ds_tides.tide_m.quantile([0.0, 1.0]).values all_min, all_max = all_tides_df.tide_m.quantile([0.0, 1.0]).values # Calculate tidal range obs_range = obs_max - obs_min all_range = all_max - all_min # Calculate Bishop-Taylor et al. 2018 tidal metrics spread = obs_range / all_range low_tide_offset = abs(all_min - obs_min) / all_range high_tide_offset = abs(all_max - obs_max) / all_range # Extract x (time in decimal years) and y (distance) values all_x = ( all_tides_df.index.year + ((all_tides_df.index.dayofyear - 1) / 365) + ((all_tides_df.index.hour - 1) / 24) ) all_y = all_tides_df.tide_m.values.astype(np.float32) time_period = all_x.max() - all_x.min() # Extract x (time in decimal years) and y (distance) values obs_x = ( ds_tides.time.dt.year + ((ds_tides.time.dt.dayofyear - 1) / 365) + ((ds_tides.time.dt.hour - 1) / 24) ) obs_y = ds_tides.tide_m.values.astype(np.float32) # Compute linear regression obs_linreg = stats.linregress(x=obs_x, y=obs_y) all_linreg = stats.linregress(x=all_x, y=all_y) if plain_english: print( f"\n{spread:.0%} of the {all_range:.2f} m modelled astronomical " f"tidal range is observed at this location.\nThe lowest " f"{low_tide_offset:.0%} and highest {high_tide_offset:.0%} " f"of astronomical tides are never observed.\n" ) if linear_reg: if obs_linreg.pvalue > 0.05: print( f"Observed tides show no significant trends " f"over the ~{time_period:.0f} year period." ) else: obs_slope_desc = "decrease" if obs_linreg.slope < 0 else "increase" print( f"Observed tides {obs_slope_desc} significantly " f"(p={obs_linreg.pvalue:.3f}) over time by " f"{obs_linreg.slope:.03f} m per year (i.e. a " f"~{time_period * obs_linreg.slope:.2f} m " f"{obs_slope_desc} over the ~{time_period:.0f} year period)." ) if all_linreg.pvalue > 0.05: print( f"All tides show no significant trends " f"over the ~{time_period:.0f} year period." ) else: all_slope_desc = "decrease" if all_linreg.slope < 0 else "increase" print( f"All tides {all_slope_desc} significantly " f"(p={all_linreg.pvalue:.3f}) over time by " f"{all_linreg.slope:.03f} m per year (i.e. a " f"~{time_period * all_linreg.slope:.2f} m " f"{all_slope_desc} over the ~{time_period:.0f} year period)." ) if plot: # Create plot and add all time and observed tide data fig, ax = plt.subplots(figsize=(10, 5)) all_tides_df.tide_m.plot(ax=ax, alpha=0.4) ds_tides.tide_m.plot.line( ax=ax, marker="o", linewidth=0.0, color="black", markersize=2 ) # Add horizontal lines for spread/offsets ax.axhline(obs_min, color="black", linestyle=":", linewidth=1) ax.axhline(obs_max, color="black", linestyle=":", linewidth=1) ax.axhline(all_min, color="black", linestyle=":", linewidth=1) ax.axhline(all_max, color="black", linestyle=":", linewidth=1) # Add text annotations for spread/offsets ax.annotate( f" High tide\n offset ({high_tide_offset:.0%})", xy=(all_timerange.max(), np.mean([all_max, obs_max])), va="center", ) ax.annotate( f" Spread\n ({spread:.0%})", xy=(all_timerange.max(), np.mean([obs_min, obs_max])), va="center", ) ax.annotate( f" Low tide\n offset ({low_tide_offset:.0%})", xy=(all_timerange.max(), np.mean([all_min, obs_min])), ) # Remove top right axes and add labels ax.spines["right"].set_visible(False) ax.spines["top"].set_visible(False) ax.set_ylabel("Tide height (m)") ax.set_xlabel("") ax.margins(x=0.015) # Export pandas.Series containing tidal stats output_stats = { "tidepost_lat": tidepost_lat, "tidepost_lon": tidepost_lon, "observed_mean_m": obs_mean, "all_mean_m": all_mean, "observed_min_m": obs_min, "all_min_m": all_min, "observed_max_m": obs_max, "all_max_m": all_max, "observed_range_m": obs_range, "all_range_m": all_range, "spread": spread, "low_tide_offset": low_tide_offset, "high_tide_offset": high_tide_offset, } if linear_reg: output_stats.update( { "observed_slope": obs_linreg.slope, "all_slope": all_linreg.slope, "observed_pval": obs_linreg.pvalue, "all_pval": all_linreg.pvalue, } ) return pd.Series(output_stats).round(round_stats) def transect_distances(transects_gdf, lines_gdf, mode='distance'): """ Take a set of transects (e.g. shore-normal beach survey lines), and determine the distance along the transect to each object in a set of lines (e.g. shorelines). Distances are measured in the CRS of the input datasets. For coastal applications, transects should be drawn from land to water (with the first point being on land so that it can be used as a consistent location from which to measure distances. The distance calculation can be performed using two modes: - 'distance': Distances are measured from the start of the transect to where it intersects with each line. Any transect that intersects a line more than once is ignored. This mode is useful for measuring e.g. the distance to the shoreline over time from a consistent starting location. - 'width' Distances are measured between the first and last intersection between a transect and each line. Any transect that intersects a line only once is ignored. This is useful for e.g. measuring the width of a narrow area of coastline over time, e.g. the neck of a spit or tombolo. Parameters ---------- transects_gdf : geopandas.GeoDataFrame A GeoDataFrame containing one or multiple vector profile lines. The GeoDataFrame's index column will be used to name the rows in the output distance table. lines_gdf : geopandas.GeoDataFrame A GeoDataFrame containing one or multiple vector line features that intersect the profile lines supplied to `transects_gdf`. The GeoDataFrame's index column will be used to name the columns in the output distance table. mode : string, optional Whether to use 'distance' (for measuring distances from the start of a profile) or 'width' mode (for measuring the width between two profile intersections). See docstring above for more info; defaults to 'distance'. Returns ------- distance_df : pandas.DataFrame A DataFrame containing distance measurements for each profile line (rows) and line feature (columns). """ import warnings from shapely.errors import ShapelyDeprecationWarning from shapely.geometry import Point def _intersect_dist(transect_gdf, lines_gdf, mode=mode): """ Take an individual transect, and determine the distance along the transect to each object in a set of lines (e.g. shorelines). """ # Identify intersections between transects and lines intersect_points = lines_gdf.apply( lambda x: x.geometry.intersection(transect_gdf.geometry), axis=1) # In distance mode, identify transects with one intersection only, # and use this as the end point and the start of the transect as the # start point when measuring distances if mode == 'distance': start_point = Point(transect_gdf.geometry.coords[0]) point_df = intersect_points.apply( lambda x: pd.Series({'start': start_point, 'end': x}) if x.type == 'Point' else pd.Series({'start': None, 'end': None})) # In width mode, identify transects with multiple intersections, and # use the first intersection as the start point and the second # intersection for the end point when measuring distances if mode == 'width': point_df = intersect_points.apply( lambda x: pd.Series({'start': x.geoms[0], 'end': x.geoms[-1]}) if x.type == 'MultiPoint' else pd.Series({'start': None, 'end': None})) # Calculate distances between valid start and end points distance_df = point_df.apply( lambda x: x.start.distance(x.end) if x.start else None, axis=1) return distance_df # Run code after ignoring Shapely pre-v2.0 warnings with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=ShapelyDeprecationWarning) # Assert that both datasets use the same CRS assert transects_gdf.crs == lines_gdf.crs, ('Please ensure both ' 'input datasets use the same CRS.') # Run distance calculations distance_df = transects_gdf.apply( lambda x: _intersect_dist(x, lines_gdf), axis=1) return pd.DataFrame(distance_df) def get_coastlines(bbox: tuple, crs="EPSG:4326", layer="shorelines", drop_wms=True) -> gpd.GeoDataFrame: """ Get DE Africa Coastlines data for a provided bounding box using WFS. For a full description of the DE Africa Coastlines dataset, refer to the official Digital Earth Africa product description: Parameters ---------- bbox : (xmin, ymin, xmax, ymax), or geopandas object Bounding box expressed as a tuple. Alternatively, a bounding box can be automatically extracted by suppling a geopandas.GeoDataFrame or geopandas.GeoSeries. crs : str, optional Optional CRS for the bounding box. This is ignored if `bbox` is provided as a geopandas object. layer : str, optional Which DE Africa Coastlines layer to load. Options include the annual shoreline vectors ("shorelines") and the rates of change statistics points ("statistics"). Defaults to "shorelines". drop_wms : bool, optional Whether to drop WMS-specific attribute columns from the data. These columns are used for visualising the dataset on DE Africa Maps, and are unlikely to be useful for scientific analysis. Defaults to True. Returns ------- gpd.GeoDataFrame A GeoDataFrame containing shoreline or point features and associated metadata. """ # If bbox is a geopandas object, convert to bbox. try: crs = str(bbox.crs) bbox = bbox.total_bounds except: pass # Get the available layers in the coastlines:DEAfrica_Coastlines group. describe_layer_url = "https://geoserver.digitalearth.africa/geoserver/wms?service=WMS&version=1.1.1&request=DescribeLayer&layers=coastlines:DEAfrica_Coastlines&outputFormat=application/json" describe_layer_response = requests.get(describe_layer_url).json() available_layers = [layer["layerName"] for layer in describe_layer_response['layerDescriptions']] # Get the layer name. if layer == "shorelines": layer_name = [i for i in available_layers if "shorelines" in i] else: layer_name = [i for i in available_layers if "rates_of_change" in i] # Query WFS. wfs = WebFeatureService(url=WFS_ADDRESS, version="1.1.0") response = wfs.getfeature(typename=layer_name, bbox=tuple(bbox) + (crs,), outputFormat="json") # Load data as a geopandas.GeoDataFrame. coastlines_gdf = gpd.read_file(response) # Clip to extent of bounding box. extent = gpd.GeoSeries(box(*bbox), crs=crs).to_crs(coastlines_gdf.crs) coastlines_gdf = coastlines_gdf.clip(extent) # Optionally drop WMS-specific columns. if drop_wms: coastlines_gdf = coastlines_gdf.loc[:, ~coastlines_gdf.columns.str.contains("wms_")] return coastlines_gdf