roms-tools 3.1.2__py3-none-any.whl → 3.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.
Files changed (41) hide show
  1. roms_tools/__init__.py +3 -0
  2. roms_tools/analysis/cdr_analysis.py +203 -0
  3. roms_tools/analysis/cdr_ensemble.py +198 -0
  4. roms_tools/analysis/roms_output.py +80 -46
  5. roms_tools/data/grids/GLORYS_global_grid.nc +0 -0
  6. roms_tools/download.py +4 -0
  7. roms_tools/plot.py +75 -21
  8. roms_tools/setup/boundary_forcing.py +44 -19
  9. roms_tools/setup/cdr_forcing.py +122 -8
  10. roms_tools/setup/cdr_release.py +161 -8
  11. roms_tools/setup/datasets.py +626 -340
  12. roms_tools/setup/grid.py +138 -137
  13. roms_tools/setup/initial_conditions.py +113 -48
  14. roms_tools/setup/mask.py +63 -7
  15. roms_tools/setup/nesting.py +67 -42
  16. roms_tools/setup/river_forcing.py +45 -19
  17. roms_tools/setup/surface_forcing.py +4 -6
  18. roms_tools/setup/tides.py +1 -2
  19. roms_tools/setup/topography.py +4 -4
  20. roms_tools/setup/utils.py +134 -22
  21. roms_tools/tests/test_analysis/test_cdr_analysis.py +144 -0
  22. roms_tools/tests/test_analysis/test_cdr_ensemble.py +202 -0
  23. roms_tools/tests/test_analysis/test_roms_output.py +61 -3
  24. roms_tools/tests/test_setup/test_boundary_forcing.py +54 -52
  25. roms_tools/tests/test_setup/test_cdr_forcing.py +54 -0
  26. roms_tools/tests/test_setup/test_cdr_release.py +118 -1
  27. roms_tools/tests/test_setup/test_datasets.py +392 -44
  28. roms_tools/tests/test_setup/test_grid.py +222 -115
  29. roms_tools/tests/test_setup/test_initial_conditions.py +94 -41
  30. roms_tools/tests/test_setup/test_surface_forcing.py +2 -1
  31. roms_tools/tests/test_setup/test_utils.py +91 -1
  32. roms_tools/tests/test_setup/utils.py +71 -0
  33. roms_tools/tests/test_tiling/test_join.py +241 -0
  34. roms_tools/tests/test_utils.py +139 -17
  35. roms_tools/tiling/join.py +189 -0
  36. roms_tools/utils.py +131 -99
  37. {roms_tools-3.1.2.dist-info → roms_tools-3.2.0.dist-info}/METADATA +12 -2
  38. {roms_tools-3.1.2.dist-info → roms_tools-3.2.0.dist-info}/RECORD +41 -33
  39. {roms_tools-3.1.2.dist-info → roms_tools-3.2.0.dist-info}/WHEEL +0 -0
  40. {roms_tools-3.1.2.dist-info → roms_tools-3.2.0.dist-info}/licenses/LICENSE +0 -0
  41. {roms_tools-3.1.2.dist-info → roms_tools-3.2.0.dist-info}/top_level.txt +0 -0
roms_tools/setup/grid.py CHANGED
@@ -1,9 +1,9 @@
1
1
  import importlib.metadata
2
2
  import logging
3
3
  import re
4
- import time
5
4
  from dataclasses import asdict, dataclass, field
6
5
  from pathlib import Path
6
+ from typing import Any
7
7
 
8
8
  import numpy as np
9
9
  import xarray as xr
@@ -12,9 +12,10 @@ from matplotlib.axes import Axes
12
12
 
13
13
  from roms_tools.constants import MAXIMUM_GRID_SIZE, R_EARTH
14
14
  from roms_tools.plot import plot
15
- from roms_tools.setup.mask import _add_mask, _add_velocity_masks
16
- from roms_tools.setup.topography import _add_topography
15
+ from roms_tools.setup.mask import add_mask, add_velocity_masks
16
+ from roms_tools.setup.topography import add_topography
17
17
  from roms_tools.setup.utils import (
18
+ Timed,
18
19
  extract_single_value,
19
20
  gc_dist,
20
21
  get_target_coords,
@@ -64,6 +65,8 @@ class Grid:
64
65
  - "path" (Union[str, Path, List[Union[str, Path]]]): The path to the raw data file. Can be a string or a Path object.
65
66
 
66
67
  The default is "ETOPO5", which does not require a path.
68
+ mask_shapefile: str | Path | None, optional
69
+ Path to a custom shapefile to use to determine the land mask; if None, use NaturalEarth 10m.
67
70
  hmin : float, optional
68
71
  The minimum ocean depth (in meters). The default is 5.0.
69
72
  N : int, optional
@@ -106,8 +109,10 @@ class Grid:
106
109
  """The bottom control parameter."""
107
110
  hc: float = 300.0
108
111
  """The critical depth (in meters)."""
109
- topography_source: dict[str, str | Path | list[str | Path]] = None
112
+ topography_source: dict[str, str | Path | list[str | Path]] | None = None
110
113
  """Dictionary specifying the source of the topography data."""
114
+ mask_shapefile: str | Path | None = None
115
+ """Path to a custom shapefile to use to determine the landmask; if None, use NaturalEarth 10m."""
111
116
  hmin: float = 5.0
112
117
  """The minimum ocean depth (in meters)."""
113
118
  verbose: bool = False
@@ -129,7 +134,7 @@ class Grid:
129
134
  self._straddle()
130
135
 
131
136
  # Mask
132
- self._create_mask(verbose=self.verbose)
137
+ self.update_mask(mask_shapefile=self.mask_shapefile, verbose=self.verbose)
133
138
 
134
139
  # Coarsen the dataset if needed
135
140
  self._coarsen()
@@ -165,19 +170,35 @@ class Grid:
165
170
  "`topography_source` must include a 'path' key when the 'name' is not 'ETOPO5'."
166
171
  )
167
172
 
168
- def _create_mask(self, verbose=False) -> None:
169
- if verbose:
170
- start_time = time.time()
171
- logging.info("=== Creating the mask ===")
172
- ds = _add_mask(self.ds)
173
+ def update_mask(
174
+ self, mask_shapefile: str | Path | None = None, verbose: bool = False
175
+ ) -> None:
176
+ """
177
+ Update the land mask of the current grid dataset.
173
178
 
174
- if verbose:
175
- logging.info(f"Total time: {time.time() - start_time:.3f} seconds")
176
- logging.info(
177
- "========================================================================================================"
178
- )
179
+ This method generates a land mask based on the provided coastline
180
+ shapefile, fills enclosed basins with lands and updates the dataset
181
+ stored in `self.ds`. If no shapefile is provided, a default dataset (Natural
182
+ Earth 10m) is used. The operation is optionally timed and logged.
179
183
 
180
- self.ds = ds
184
+ Parameters
185
+ ----------
186
+ mask_shapefile : str or Path, optional
187
+ Path to a coastal shapefile to derive the land mask. If `None`,
188
+ the default Natural Earth 10m coastline dataset is used.
189
+ verbose : bool, default False
190
+ If True, prints timing and progress information.
191
+
192
+ Returns
193
+ -------
194
+ None
195
+ Updates the `self.ds` attribute in place with the new mask.
196
+
197
+ """
198
+ with Timed("=== Deriving the mask from coastlines ===", verbose=verbose):
199
+ ds = add_mask(self.ds, shapefile=mask_shapefile)
200
+ self.ds = ds
201
+ self.mask_shapefile = mask_shapefile
181
202
 
182
203
  def update_topography(
183
204
  self, topography_source=None, hmin=None, verbose=False
@@ -218,33 +239,22 @@ class Grid:
218
239
  # Extract target coordinates for processing
219
240
  target_coords = get_target_coords(self)
220
241
 
221
- # If verbose is enabled, start the timer and print the start message
222
- if verbose:
223
- start_time = time.time()
224
- logging.info(
225
- f"=== Generating the topography using {topography_source['name']} data and hmin = {hmin} meters ==="
226
- )
227
-
228
- # Add topography to the dataset
229
- ds = _add_topography(
230
- ds=self.ds,
231
- target_coords=target_coords,
232
- topography_source=topography_source,
233
- hmin=hmin,
242
+ with Timed(
243
+ f"=== Generating the topography using {topography_source['name']} data and hmin = {hmin} meters ===",
234
244
  verbose=verbose,
235
- )
236
-
237
- # If verbose is enabled, print elapsed time and a separator
238
- if verbose:
239
- logging.info(f"Total time: {time.time() - start_time:.3f} seconds")
240
- logging.info(
241
- "========================================================================================================"
245
+ ):
246
+ ds = add_topography(
247
+ ds=self.ds,
248
+ target_coords=target_coords,
249
+ topography_source=topography_source,
250
+ hmin=hmin,
251
+ verbose=verbose,
242
252
  )
243
253
 
244
- # Update the grid's dataset and related attributes
245
- self.ds = ds
246
- self.topography_source = topography_source
247
- self.hmin = hmin
254
+ # Update the grid's dataset and related attributes
255
+ self.ds = ds
256
+ self.topography_source = topography_source
257
+ self.hmin = hmin
248
258
 
249
259
  def update_vertical_coordinate(
250
260
  self, N=None, theta_s=None, theta_b=None, hc=None, verbose=False
@@ -281,69 +291,61 @@ class Grid:
281
291
  theta_b = theta_b or self.theta_b
282
292
  hc = hc or self.hc
283
293
 
284
- if verbose:
285
- start_time = time.time()
286
- logging.info(
287
- f"=== Preparing the vertical coordinate system using N = {N}, theta_s = {theta_s}, theta_b = {theta_b}, hc = {hc} ==="
288
- )
289
-
290
- ds = self.ds
291
- # need to drop vertical coordinates because they could cause conflict if N changed
292
- vars_to_drop = [
293
- "layer_depth_rho",
294
- "layer_depth_u",
295
- "layer_depth_v",
296
- "interface_depth_rho",
297
- "interface_depth_u",
298
- "interface_depth_v",
299
- "sigma_r",
300
- "sigma_w",
301
- "Cs_w",
302
- "Cs_r",
303
- ]
304
-
305
- for var in vars_to_drop:
306
- if var in ds.variables:
307
- ds = ds.drop_vars(var)
308
-
309
- cs_r, sigma_r = sigma_stretch(theta_s, theta_b, N, "r")
310
- cs_w, sigma_w = sigma_stretch(theta_s, theta_b, N, "w")
311
-
312
- ds["sigma_r"] = sigma_r.astype(np.float32)
313
- ds["sigma_r"].attrs["long_name"] = (
314
- "Fractional vertical stretching coordinate at rho-points"
315
- )
316
- ds["sigma_r"].attrs["units"] = "nondimensional"
294
+ with Timed(
295
+ f"=== Preparing the vertical coordinate system using N = {N}, theta_s = {theta_s}, theta_b = {theta_b}, hc = {hc} ===",
296
+ verbose=verbose,
297
+ ):
298
+ ds = self.ds
299
+ # need to drop vertical coordinates because they could cause conflict if N changed
300
+ vars_to_drop = [
301
+ "layer_depth_rho",
302
+ "layer_depth_u",
303
+ "layer_depth_v",
304
+ "interface_depth_rho",
305
+ "interface_depth_u",
306
+ "interface_depth_v",
307
+ "sigma_r",
308
+ "sigma_w",
309
+ "Cs_w",
310
+ "Cs_r",
311
+ ]
317
312
 
318
- ds["Cs_r"] = cs_r.astype(np.float32)
319
- ds["Cs_r"].attrs["long_name"] = "Vertical stretching function at rho-points"
320
- ds["Cs_r"].attrs["units"] = "nondimensional"
313
+ for var in vars_to_drop:
314
+ if var in ds.variables:
315
+ ds = ds.drop_vars(var)
321
316
 
322
- ds["sigma_w"] = sigma_w.astype(np.float32)
323
- ds["sigma_w"].attrs["long_name"] = (
324
- "Fractional vertical stretching coordinate at w-points"
325
- )
326
- ds["sigma_w"].attrs["units"] = "nondimensional"
317
+ cs_r, sigma_r = sigma_stretch(theta_s, theta_b, N, "r")
318
+ cs_w, sigma_w = sigma_stretch(theta_s, theta_b, N, "w")
327
319
 
328
- ds["Cs_w"] = cs_w.astype(np.float32)
329
- ds["Cs_w"].attrs["long_name"] = "Vertical stretching function at w-points"
330
- ds["Cs_w"].attrs["units"] = "nondimensional"
320
+ ds["sigma_r"] = sigma_r.astype(np.float32)
321
+ ds["sigma_r"].attrs["long_name"] = (
322
+ "Fractional vertical stretching coordinate at rho-points"
323
+ )
324
+ ds["sigma_r"].attrs["units"] = "nondimensional"
331
325
 
332
- ds.attrs["theta_s"] = np.float32(theta_s)
333
- ds.attrs["theta_b"] = np.float32(theta_b)
334
- ds.attrs["hc"] = np.float32(hc)
326
+ ds["Cs_r"] = cs_r.astype(np.float32)
327
+ ds["Cs_r"].attrs["long_name"] = "Vertical stretching function at rho-points"
328
+ ds["Cs_r"].attrs["units"] = "nondimensional"
335
329
 
336
- if verbose:
337
- logging.info(f"Total time: {time.time() - start_time:.3f} seconds")
338
- logging.info(
339
- "========================================================================================================"
330
+ ds["sigma_w"] = sigma_w.astype(np.float32)
331
+ ds["sigma_w"].attrs["long_name"] = (
332
+ "Fractional vertical stretching coordinate at w-points"
340
333
  )
334
+ ds["sigma_w"].attrs["units"] = "nondimensional"
341
335
 
342
- self.ds = ds
343
- self.theta_s = theta_s
344
- self.theta_b = theta_b
345
- self.hc = hc
346
- self.N = N
336
+ ds["Cs_w"] = cs_w.astype(np.float32)
337
+ ds["Cs_w"].attrs["long_name"] = "Vertical stretching function at w-points"
338
+ ds["Cs_w"].attrs["units"] = "nondimensional"
339
+
340
+ ds.attrs["theta_s"] = np.float32(theta_s)
341
+ ds.attrs["theta_b"] = np.float32(theta_b)
342
+ ds.attrs["hc"] = np.float32(hc)
343
+
344
+ self.ds = ds
345
+ self.theta_s = theta_s
346
+ self.theta_b = theta_b
347
+ self.hc = hc
348
+ self.N = N
347
349
 
348
350
  def _straddle(self) -> None:
349
351
  """Check if the Greenwich meridian goes through the domain.
@@ -603,7 +605,7 @@ class Grid:
603
605
  ds = xr.open_dataset(filepath)
604
606
 
605
607
  if not all(mask in ds for mask in ["mask_u", "mask_v"]):
606
- ds = _add_velocity_masks(ds)
608
+ ds = add_velocity_masks(ds)
607
609
 
608
610
  # Create a new Grid instance without calling __init__ and __post_init__
609
611
  grid = cls.__new__(cls)
@@ -758,24 +760,30 @@ class Grid:
758
760
  "hmin",
759
761
  ]:
760
762
  if attr in ds.attrs:
761
- a = float(ds.attrs[attr])
763
+ value = float(ds.attrs[attr])
762
764
  else:
763
- a = None
765
+ value = None
764
766
 
765
- object.__setattr__(grid, attr, a)
767
+ object.__setattr__(grid, attr, value)
766
768
 
767
769
  if "topography_source_name" in ds.attrs:
768
770
  if "topography_source_path" in ds.attrs:
769
- a = {
771
+ topo_source = {
770
772
  "name": ds.attrs["topography_source_name"],
771
773
  "path": ds.attrs["topography_source_path"],
772
774
  }
773
775
  else:
774
- a = {"name": ds.attrs["topography_source_name"]}
776
+ topo_source = {"name": ds.attrs["topography_source_name"]}
777
+ else:
778
+ topo_source = None
779
+ grid.topography_source = topo_source
780
+
781
+ if "mask_shapefile" in ds.attrs:
782
+ mask_shapefile = ds.attrs["mask_shapefile"]
775
783
  else:
776
- a = None
784
+ mask_shapefile = None
777
785
 
778
- object.__setattr__(grid, "topography_source", a)
786
+ grid.mask_shapefile = mask_shapefile
779
787
 
780
788
  return grid
781
789
 
@@ -796,10 +804,7 @@ class Grid:
796
804
 
797
805
  @classmethod
798
806
  def from_yaml(
799
- cls,
800
- filepath: str | Path,
801
- section_name: str = "Grid",
802
- verbose: bool = False,
807
+ cls, filepath: str | Path, verbose: bool = False, **kwargs: Any
803
808
  ) -> "Grid":
804
809
  """Create an instance of the class from a YAML file.
805
810
 
@@ -807,10 +812,13 @@ class Grid:
807
812
  ----------
808
813
  filepath : Union[str, Path]
809
814
  The path to the YAML file from which the parameters will be read.
810
- section_name : str, optional
811
- The name of the YAML section containing the grid configuration. Defaults to "Grid".
812
815
  verbose : bool, optional
813
816
  Indicates whether to print grid generation steps with timing. Defaults to False.
817
+ **kwargs : Any
818
+ Additional keyword arguments:
819
+
820
+ - section_name : str, optional (default: "Grid")
821
+ The name of the YAML section containing the grid configuration.
814
822
 
815
823
  Returns
816
824
  -------
@@ -828,6 +836,8 @@ class Grid:
828
836
  Issues a warning if the ROMS-Tools version in the YAML header does not match the
829
837
  currently installed version.
830
838
  """
839
+ section_name: str = kwargs.pop("section_name", None) or "Grid"
840
+
831
841
  filepath = Path(filepath)
832
842
  # Read the entire file content
833
843
  with filepath.open("r") as file:
@@ -881,7 +891,7 @@ class Grid:
881
891
  attr_str = ", ".join(f"{k}={v!r}" for k, v in attr_dict.items())
882
892
  return f"{cls_name}({attr_str})"
883
893
 
884
- def _create_horizontal_grid(self) -> xr.Dataset():
894
+ def _create_horizontal_grid(self) -> xr.Dataset:
885
895
  """Create the horizontal grid based on a Mercator projection and store it in the
886
896
  'ds' attribute.
887
897
 
@@ -899,41 +909,32 @@ class Grid:
899
909
  - Longitude values are adjusted to fall within the range [0, 360].
900
910
  - Grid rotation and translation are applied based on the specified parameters.
901
911
  """
902
- if self.verbose:
903
- start_time = time.time()
904
- logging.info("=== Creating the horizontal grid ===")
905
-
906
- self._raise_if_domain_size_too_large()
912
+ with Timed("=== Creating the horizontal grid ===", verbose=self.verbose):
913
+ self._raise_if_domain_size_too_large()
907
914
 
908
- coords = self._make_initial_lon_lat_ds()
915
+ coords = self._make_initial_lon_lat_ds()
909
916
 
910
- # rotate coordinate system
911
- coords = _rotate(coords, self.rot)
917
+ # rotate coordinate system
918
+ coords = _rotate(coords, self.rot)
912
919
 
913
- # translate coordinate system
914
- coords = _translate(coords, self.center_lat, self.center_lon)
920
+ # translate coordinate system
921
+ coords = _translate(coords, self.center_lat, self.center_lon)
915
922
 
916
- # compute 1/dx and 1/dy
917
- coords["pm"], coords["pn"] = _compute_coordinate_metrics(coords)
923
+ # compute 1/dx and 1/dy
924
+ coords["pm"], coords["pn"] = _compute_coordinate_metrics(coords)
918
925
 
919
- # compute angle of local grid positive x-axis relative to east
920
- coords["angle"] = _compute_angle(coords)
926
+ # compute angle of local grid positive x-axis relative to east
927
+ coords["angle"] = _compute_angle(coords)
921
928
 
922
- # make sure lons are in [0, 360] range
923
- for lon in ["lon", "lonu", "lonv", "lonq"]:
924
- coords[lon][coords[lon] < 0] = coords[lon][coords[lon] < 0] + 2 * np.pi
929
+ # make sure lons are in [0, 360] range
930
+ for lon in ["lon", "lonu", "lonv", "lonq"]:
931
+ coords[lon][coords[lon] < 0] = coords[lon][coords[lon] < 0] + 2 * np.pi
925
932
 
926
- ds = self._create_grid_ds(coords)
933
+ ds = self._create_grid_ds(coords)
927
934
 
928
- ds = self._add_global_metadata(ds)
935
+ ds = self._add_global_metadata(ds)
929
936
 
930
- if self.verbose:
931
- logging.info(f"Total time: {time.time() - start_time:.3f} seconds")
932
- logging.info(
933
- "========================================================================================================"
934
- )
935
-
936
- self.ds = ds
937
+ self.ds = ds
937
938
 
938
939
  def _add_global_metadata(self, ds):
939
940
  """Add global metadata and attributes to the dataset.