eo-tides 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
eo_tides/model.py CHANGED
@@ -1,6 +1,7 @@
1
1
  # Used to postpone evaluation of type annotations
2
2
  from __future__ import annotations
3
3
 
4
+ import datetime
4
5
  import os
5
6
  import pathlib
6
7
  import textwrap
@@ -8,7 +9,7 @@ import warnings
8
9
  from concurrent.futures import ProcessPoolExecutor
9
10
  from concurrent.futures.process import BrokenProcessPool
10
11
  from functools import partial
11
- from typing import TYPE_CHECKING
12
+ from typing import TYPE_CHECKING, List, Union
12
13
 
13
14
  # Only import if running type checking
14
15
  if TYPE_CHECKING:
@@ -25,8 +26,13 @@ from tqdm import tqdm
25
26
 
26
27
  from .utils import idw
27
28
 
29
+ # Type alias for all possible inputs to "time" params
30
+ DatetimeLike = Union[np.ndarray, pd.DatetimeIndex, pd.Timestamp, datetime.datetime, str, List[str]]
28
31
 
29
- def _set_directory(directory):
32
+
33
+ def _set_directory(
34
+ directory: str | os.PathLike | None = None,
35
+ ) -> os.PathLike:
30
36
  """
31
37
  Set tide modelling files directory. If no custom
32
38
  path is provided, try global environmental variable
@@ -50,6 +56,25 @@ def _set_directory(directory):
50
56
  return directory
51
57
 
52
58
 
59
+ def _standardise_time(
60
+ time: DatetimeLike | None,
61
+ ) -> np.ndarray | None:
62
+ """
63
+ Accept any time format accepted by `pd.to_datetime`,
64
+ and return a datetime64 ndarray. Return None if None
65
+ passed.
66
+ """
67
+ # Return time as-is if None
68
+ if time is None:
69
+ return None
70
+
71
+ # Use pd.to_datetime for conversion, then convert to numpy array
72
+ time = pd.to_datetime(time).to_numpy().astype("datetime64[ns]")
73
+
74
+ # Ensure that data has at least one dimension
75
+ return np.atleast_1d(time)
76
+
77
+
53
78
  def list_models(
54
79
  directory: str | os.PathLike | None = None,
55
80
  show_available: bool = True,
@@ -57,12 +82,12 @@ def list_models(
57
82
  raise_error: bool = False,
58
83
  ) -> tuple[list[str], list[str]]:
59
84
  """
60
- List all tide models available for tide modelling, and
61
- all models supported by `eo-tides` and `pyTMD`.
85
+ List all tide models available for tide modelling.
62
86
 
63
87
  This function scans the specified tide model directory
64
88
  and returns a list of models that are available in the
65
- directory as well as the full list of all supported models.
89
+ directory as well as the full list of all models supported
90
+ by `eo-tides` and `pyTMD`.
66
91
 
67
92
  For instructions on setting up tide models, see:
68
93
  <https://geoscienceaustralia.github.io/eo-tides/setup/>
@@ -188,117 +213,41 @@ def _model_tides(
188
213
  # Obtain model details
189
214
  pytmd_model = pyTMD.io.model(directory).elevation(model)
190
215
 
191
- # Convert x, y to latitude/longitude
216
+ # Reproject x, y to latitude/longitude
192
217
  transformer = pyproj.Transformer.from_crs(crs, "EPSG:4326", always_xy=True)
193
218
  lon, lat = transformer.transform(x.flatten(), y.flatten())
194
219
 
195
220
  # Convert datetime
196
221
  timescale = pyTMD.time.timescale().from_datetime(time.flatten())
197
222
 
198
- # Calculate bounds for cropping
199
- buffer = 1 # one degree on either side
200
- bounds = [
201
- lon.min() - buffer,
202
- lon.max() + buffer,
203
- lat.min() - buffer,
204
- lat.max() + buffer,
205
- ]
206
-
207
223
  try:
208
224
  # Read tidal constants and interpolate to grid points
209
- if pytmd_model.format in ("OTIS", "ATLAS-compact", "TMD3"):
210
- amp, ph, D, c = pyTMD.io.OTIS.extract_constants(
211
- lon,
212
- lat,
213
- pytmd_model.grid_file,
214
- pytmd_model.model_file,
215
- pytmd_model.projection,
216
- type=pytmd_model.type,
217
- grid=pytmd_model.file_format,
218
- crop=crop,
219
- bounds=bounds,
220
- method=method,
221
- extrapolate=extrapolate,
222
- cutoff=cutoff,
223
- )
224
-
225
- # Use delta time at 2000.0 to match TMD outputs
226
- deltat = np.zeros((len(timescale)), dtype=np.float64)
227
-
228
- elif pytmd_model.format in ("ATLAS-netcdf",):
229
- amp, ph, D, c = pyTMD.io.ATLAS.extract_constants(
230
- lon,
231
- lat,
232
- pytmd_model.grid_file,
233
- pytmd_model.model_file,
234
- type=pytmd_model.type,
235
- crop=crop,
236
- bounds=bounds,
237
- method=method,
238
- extrapolate=extrapolate,
239
- cutoff=cutoff,
240
- scale=pytmd_model.scale,
241
- compressed=pytmd_model.compressed,
242
- )
243
-
244
- # Use delta time at 2000.0 to match TMD outputs
245
- deltat = np.zeros((len(timescale)), dtype=np.float64)
246
-
247
- elif pytmd_model.format in ("GOT-ascii", "GOT-netcdf"):
248
- amp, ph, c = pyTMD.io.GOT.extract_constants(
249
- lon,
250
- lat,
251
- pytmd_model.model_file,
252
- grid=pytmd_model.file_format,
253
- crop=crop,
254
- bounds=bounds,
255
- method=method,
256
- extrapolate=extrapolate,
257
- cutoff=cutoff,
258
- scale=pytmd_model.scale,
259
- compressed=pytmd_model.compressed,
260
- )
261
-
262
- # Delta time (TT - UT1)
263
- deltat = timescale.tt_ut1
264
-
265
- elif pytmd_model.format in ("FES-ascii", "FES-netcdf"):
266
- amp, ph = pyTMD.io.FES.extract_constants(
267
- lon,
268
- lat,
269
- pytmd_model.model_file,
270
- type=pytmd_model.type,
271
- version=pytmd_model.version,
272
- crop=crop,
273
- bounds=bounds,
274
- method=method,
275
- extrapolate=extrapolate,
276
- cutoff=cutoff,
277
- scale=pytmd_model.scale,
278
- compressed=pytmd_model.compressed,
279
- )
280
-
281
- # Available model constituents
282
- c = pytmd_model.constituents
225
+ amp, ph, c = pytmd_model.extract_constants(
226
+ lon,
227
+ lat,
228
+ type=pytmd_model.type,
229
+ crop=crop,
230
+ bounds=None,
231
+ method=method,
232
+ extrapolate=extrapolate,
233
+ cutoff=cutoff,
234
+ append_node=False,
235
+ # append_node=True,
236
+ )
283
237
 
284
- # Delta time (TT - UT1)
285
- deltat = timescale.tt_ut1
286
- else:
287
- raise Exception(
288
- f"Unsupported model format ({pytmd_model.format}). This may be due to an incompatible version of `pyTMD`."
289
- )
238
+ # TODO: Return constituents
239
+ # print(amp.shape, ph.shape, c)
240
+ # print(pd.DataFrame({"amplitude": amp}))
290
241
 
291
242
  # Raise error if constituent files no not cover analysis extent
292
- except IndexError:
293
- error_msg = textwrap.dedent(
294
- f"""
295
- The {model} tide model constituent files do not cover the requested analysis extent.
296
- This can occur if you are using clipped model files to improve run times.
297
- Consider using model files that cover your entire analysis area, or set `crop=False`
298
- to reduce the extent of tide model constituent files that is loaded.
299
- """
300
- ).strip()
301
- raise Exception(error_msg)
243
+ except IndexError as e:
244
+ error_msg = f"""
245
+ The {model} tide model constituent files do not cover the requested analysis extent.
246
+ This can occur if you are using clipped model files to improve run times.
247
+ Consider using model files that cover your entire analysis area, or set `crop=False`
248
+ to reduce the extent of tide model constituent files that is loaded.
249
+ """
250
+ raise Exception(textwrap.dedent(error_msg).strip()) from None
302
251
 
303
252
  # Calculate complex phase in radians for Euler's
304
253
  cph = -1j * ph * np.pi / 180.0
@@ -306,30 +255,42 @@ def _model_tides(
306
255
  # Calculate constituent oscillation
307
256
  hc = amp * np.exp(cph)
308
257
 
258
+ # Compute deltat based on model
259
+ if pytmd_model.corrections in ("OTIS", "ATLAS", "TMD3", "netcdf"):
260
+ # Use delta time at 2000.0 to match TMD outputs
261
+ deltat = np.zeros_like(timescale.tt_ut1)
262
+ else:
263
+ # Use interpolated delta times
264
+ deltat = timescale.tt_ut1
265
+
309
266
  # Determine the number of points and times to process. If in
310
267
  # "one-to-many" mode, these counts are used to repeat our extracted
311
268
  # constituents and timesteps so we can extract tides for all
312
269
  # combinations of our input times and tide modelling points.
270
+ # If in "one-to-many" mode, repeat constituents to length of time
271
+ # and number of input coords before passing to `predict_tide_drift`
313
272
  # If in "one-to-one" mode, we avoid this step by setting counts to 1
314
273
  # (e.g. "repeat 1 times")
315
274
  points_repeat = len(x) if mode == "one-to-many" else 1
316
275
  time_repeat = len(time) if mode == "one-to-many" else 1
317
-
318
- # If in "one-to-many" mode, repeat constituents to length of time
319
- # and number of input coords before passing to `predict_tide_drift`
320
276
  t, hc, deltat = (
321
277
  np.tile(timescale.tide, points_repeat),
322
278
  hc.repeat(time_repeat, axis=0),
323
279
  np.tile(deltat, points_repeat),
324
280
  )
325
281
 
326
- # Predict tidal elevations at time and infer minor corrections
327
- npts = len(t)
328
- tide = np.ma.zeros((npts), fill_value=np.nan)
282
+ # Create arrays to hold outputs
283
+ tide = np.ma.zeros((len(t)), fill_value=np.nan)
329
284
  tide.mask = np.any(hc.mask, axis=1)
330
285
 
331
- # Predict tides
332
- tide.data[:] = pyTMD.predict.drift(t, hc, c, deltat=deltat, corrections=pytmd_model.corrections)
286
+ # Predict tidal elevations at time and infer minor corrections
287
+ tide.data[:] = pyTMD.predict.drift(
288
+ t,
289
+ hc,
290
+ c,
291
+ deltat=deltat,
292
+ corrections=pytmd_model.corrections,
293
+ )
333
294
  minor = pyTMD.predict.infer_minor(
334
295
  t,
335
296
  hc,
@@ -532,7 +493,7 @@ def _ensemble_model(
532
493
  def model_tides(
533
494
  x: float | list[float] | xr.DataArray,
534
495
  y: float | list[float] | xr.DataArray,
535
- time: np.ndarray | pd.DatetimeIndex,
496
+ time: DatetimeLike,
536
497
  model: str | list[str] = "EOT20",
537
498
  directory: str | os.PathLike | None = None,
538
499
  crs: str = "EPSG:4326",
@@ -578,10 +539,11 @@ def model_tides(
578
539
  the location at which to model tides. By default these
579
540
  coordinates should be lat/lon; use "crs" if they
580
541
  are in a custom coordinate reference system.
581
- time : Numpy datetime array or pandas.DatetimeIndex
582
- An array containing `datetime64[ns]` values or a
583
- `pandas.DatetimeIndex` providing the times at which to
584
- model tides in UTC time.
542
+ time : DatetimeLike
543
+ Times at which to model tide heights (in UTC). Accepts
544
+ any format that can be converted by `pandas.to_datetime()`;
545
+ e.g. np.ndarray[datetime64], pd.DatetimeIndex, pd.Timestamp,
546
+ datetime.datetime and strings (e.g. "2020-01-01 23:00").
585
547
  model : str or list of str, optional
586
548
  The tide model (or models) to use to model tides.
587
549
  Defaults to "EOT20"; for a full list of available/supported
@@ -674,9 +636,10 @@ def model_tides(
674
636
  models_requested = list(np.atleast_1d(model))
675
637
  x = np.atleast_1d(x)
676
638
  y = np.atleast_1d(y)
677
- time = np.atleast_1d(time)
639
+ time = _standardise_time(time)
678
640
 
679
641
  # Validate input arguments
642
+ assert time is not None, "Times for modelling tides muyst be provided via `time`."
680
643
  assert method in ("bilinear", "spline", "linear", "nearest")
681
644
  assert output_units in (
682
645
  "m",
@@ -695,10 +658,6 @@ def model_tides(
695
658
  "you intended to model multiple timesteps at each point."
696
659
  )
697
660
 
698
- # If time passed as a single Timestamp, convert to datetime64
699
- if isinstance(time, pd.Timestamp):
700
- time = time.to_datetime64()
701
-
702
661
  # Set tide modelling files directory. If no custom path is
703
662
  # provided, try global environment variable.
704
663
  directory = _set_directory(directory)
@@ -854,3 +813,135 @@ def model_tides(
854
813
  tide_df = tide_df.reindex(output_indices)
855
814
 
856
815
  return tide_df
816
+
817
+
818
+ def model_phases(
819
+ x: float | list[float] | xr.DataArray,
820
+ y: float | list[float] | xr.DataArray,
821
+ time: DatetimeLike,
822
+ model: str | list[str] = "EOT20",
823
+ directory: str | os.PathLike | None = None,
824
+ time_offset: str = "15 min",
825
+ return_tides: bool = False,
826
+ **model_tides_kwargs,
827
+ ) -> pd.DataFrame:
828
+ """
829
+ Model tide phases (low-flow, high-flow, high-ebb, low-ebb)
830
+ at multiple coordinates and/or timesteps using using one
831
+ or more ocean tide models.
832
+
833
+ Ebb and low phases are calculated by running the
834
+ `eo_tides.model.model_tides` function twice, once for
835
+ the requested timesteps, and again after subtracting a
836
+ small time offset (by default, 15 minutes). If tides
837
+ increased over this period, they are assigned as "flow";
838
+ if they decreased, they are assigned as "ebb".
839
+ Tides are considered "high" if equal or greater than 0
840
+ metres tide height, otherwise "low".
841
+
842
+ This function supports all parameters that are supported
843
+ by `model_tides`.
844
+
845
+ Parameters
846
+ ----------
847
+ x, y : float or list of float
848
+ One or more x and y coordinates used to define
849
+ the location at which to model tide phases. By default
850
+ these coordinates should be lat/lon; use "crs" if they
851
+ are in a custom coordinate reference system.
852
+ time : DatetimeLike
853
+ Times at which to model tide phases (in UTC). Accepts
854
+ any format that can be converted by `pandas.to_datetime()`;
855
+ e.g. np.ndarray[datetime64], pd.DatetimeIndex, pd.Timestamp,
856
+ datetime.datetime and strings (e.g. "2020-01-01 23:00").
857
+ model : str or list of str, optional
858
+ The tide model (or models) to use to compute tide phases.
859
+ Defaults to "EOT20"; for a full list of available/supported
860
+ models, run `eo_tides.model.list_models`.
861
+ directory : str, optional
862
+ The directory containing tide model data files. If no path is
863
+ provided, this will default to the environment variable
864
+ `EO_TIDES_TIDE_MODELS` if set, or raise an error if not.
865
+ Tide modelling files should be stored in sub-folders for each
866
+ model that match the structure required by `pyTMD`
867
+ (<https://geoscienceaustralia.github.io/eo-tides/setup/>).
868
+ time_offset: str, optional
869
+ The time offset/delta used to generate a time series of
870
+ offset tide heights required for phase calculation. Defeaults
871
+ to "15 min"; can be any string passed to `pandas.Timedelta`.
872
+ return_tides: bool, optional
873
+ Whether to return intermediate modelled tide heights as a
874
+ "tide_height" column in the output dataframe. Defaults to False.
875
+ **model_tides_kwargs :
876
+ Optional parameters passed to the `eo_tides.model.model_tides`
877
+ function. Important parameters include `output_format` (e.g.
878
+ whether to return results in wide or long format), `crop`
879
+ (whether to crop tide model constituent files on-the-fly to
880
+ improve performance) etc.
881
+
882
+ Returns
883
+ -------
884
+ pandas.DataFrame
885
+ A dataframe containing modelled tide phases.
886
+
887
+ """
888
+
889
+ # Pop output format and mode for special handling
890
+ output_format = model_tides_kwargs.pop("output_format", "long")
891
+ mode = model_tides_kwargs.pop("mode", "one-to-many")
892
+
893
+ # Model tides
894
+ tide_df = model_tides(
895
+ x=x,
896
+ y=y,
897
+ time=time,
898
+ model=model,
899
+ directory=directory,
900
+ **model_tides_kwargs,
901
+ )
902
+
903
+ # Model tides for a time 15 minutes prior to each previously
904
+ # modelled satellite acquisition time. This allows us to compare
905
+ # tide heights to see if they are rising or falling.
906
+ pre_df = model_tides(
907
+ x=x,
908
+ y=y,
909
+ time=time - pd.Timedelta(time_offset),
910
+ model=model,
911
+ directory=directory,
912
+ **model_tides_kwargs,
913
+ )
914
+
915
+ # Compare tides computed for each timestep. If the previous tide
916
+ # was higher than the current tide, the tide is 'ebbing'. If the
917
+ # previous tide was lower, the tide is 'flowing'
918
+ ebb_flow = (tide_df.tide_height < pre_df.tide_height.values).replace({True: "ebb", False: "flow"})
919
+
920
+ # If tides are greater than 0, then "high", otherwise "low"
921
+ high_low = (tide_df.tide_height >= 0).replace({True: "high", False: "low"})
922
+
923
+ # Combine into one string and add to data
924
+ tide_df["tide_phase"] = high_low.astype(str) + "-" + ebb_flow.astype(str)
925
+
926
+ # Optionally convert to a wide format dataframe with a tide model in
927
+ # each dataframe column
928
+ if output_format == "wide":
929
+ # Pivot into wide format with each time model as a column
930
+ print("Converting to a wide format dataframe")
931
+ tide_df = tide_df.pivot(columns="tide_model")
932
+
933
+ # If in 'one-to-one' mode, reindex using our input time/x/y
934
+ # values to ensure the output is sorted the same as our inputs
935
+ if mode == "one-to-one":
936
+ output_indices = pd.MultiIndex.from_arrays([time, x, y], names=["time", "x", "y"])
937
+ tide_df = tide_df.reindex(output_indices)
938
+
939
+ # Optionally drop tides
940
+ if not return_tides:
941
+ return tide_df.drop("tide_height", axis=1)["tide_phase"]
942
+
943
+ # Optionally drop tide heights
944
+ if not return_tides:
945
+ return tide_df.drop("tide_height", axis=1)
946
+
947
+ return tide_df