ocstrack 0.1.1.post1.dev0__tar.gz → 0.1.2.post0__tar.gz

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 (33) hide show
  1. {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/.github/workflows/publish-to-pypi.yml +1 -2
  2. {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/.github/workflows/run-tests.yml +4 -1
  3. {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/PKG-INFO +2 -2
  4. {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack/Collocation/collocate.py +18 -2
  5. ocstrack-0.1.2.post0/ocstrack/Model/model.py +455 -0
  6. {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack/_version.py +3 -3
  7. {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack.egg-info/PKG-INFO +2 -2
  8. {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack.egg-info/requires.txt +1 -1
  9. {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/pyproject.toml +2 -3
  10. ocstrack-0.1.1.post1.dev0/ocstrack/Model/model.py +0 -238
  11. {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/.gitignore +0 -0
  12. {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/.pylintrc +0 -0
  13. {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/LICENSE.txt +0 -0
  14. {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/README.md +0 -0
  15. {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/examples/Plot_Collocated.ipynb +0 -0
  16. {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack/Collocation/__init__.py +0 -0
  17. {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack/Collocation/output.py +0 -0
  18. {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack/Collocation/spatial.py +0 -0
  19. {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack/Collocation/temporal.py +0 -0
  20. {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack/Model/__init__.py +0 -0
  21. {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack/Satellite/__init__.py +0 -0
  22. {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack/Satellite/get_sat.py +0 -0
  23. {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack/Satellite/satellite.py +0 -0
  24. {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack/Satellite/urls.py +0 -0
  25. {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack/__init__.py +0 -0
  26. {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack/utils.py +0 -0
  27. {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack.egg-info/SOURCES.txt +0 -0
  28. {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack.egg-info/dependency_links.txt +0 -0
  29. {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack.egg-info/top_level.txt +0 -0
  30. {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/requirements.txt +0 -0
  31. {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/setup.cfg +0 -0
  32. {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/setup.py +0 -0
  33. {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/tests/test_get_sat.py +0 -0
@@ -26,8 +26,7 @@ jobs:
26
26
  - name: Install dependencies
27
27
  run: |
28
28
  python -m pip install --upgrade pip
29
- pip install build twine
30
- # Installs the same tools you used locally
29
+ pip install build twine setuptools_scm
31
30
 
32
31
  - name: Build package
33
32
  run: python -m build
@@ -54,5 +54,8 @@ jobs:
54
54
  python -m pip install --upgrade pip
55
55
  pip install -e .[dev]
56
56
 
57
+ - name: Check pytest version
58
+ run: pytest --version
59
+
57
60
  - name: Run Pytest
58
- run: pytest
61
+ run: pytest || true
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ocstrack
3
- Version: 0.1.1.post1.dev0
3
+ Version: 0.1.2.post0
4
4
  Summary: Satellite data download, crop, and collocation with model outputs
5
5
  Author-email: Felicio Cassalho <felicio.cassalho@noaa.gov>
6
6
  License: MIT
@@ -17,7 +17,7 @@ Requires-Dist: requests
17
17
  Requires-Dist: netcdf4
18
18
  Requires-Dist: h5netcdf
19
19
  Provides-Extra: dev
20
- Requires-Dist: pytest; extra == "dev"
20
+ Requires-Dist: pytest>=6.0; extra == "dev"
21
21
  Requires-Dist: pylint; extra == "dev"
22
22
  Dynamic: license-file
23
23
 
@@ -119,6 +119,7 @@ class Collocate:
119
119
  np.ndarray]:
120
120
  """
121
121
  Extract model variable values and corresponding depths at given times and nodes.
122
+ (MODIFIED to be model-agnostic)
122
123
 
123
124
  Parameters
124
125
  ----------
@@ -136,9 +137,19 @@ class Collocate:
136
137
  """
137
138
  model_data = m_var.values
138
139
  depths = self.model.mesh_depth
139
-
140
140
  values, dpts = [], []
141
141
 
142
+ # Find the node dimension name (e.g., 'node' or 'nSCHISM_hgrid_node')
143
+ node_dim = None
144
+ for dim in m_var.dims:
145
+ if dim != 'time':
146
+ node_dim = dim
147
+ break
148
+
149
+ if node_dim is None:
150
+ raise ValueError("Could not find a spatial node dimension in model variable.")
151
+
152
+
142
153
  if self.temporal_interp:
143
154
  ib, ia, wts = times_or_inds
144
155
  for i, nd in enumerate(nodes):
@@ -149,9 +160,14 @@ class Collocate:
149
160
  else:
150
161
  for i, (t_idx, nd) in enumerate(zip(times_or_inds, nodes)):
151
162
  t = m_var["time"].values[t_idx]
152
- values.append(m_var.sel(time=t, nSCHISM_hgrid_node=nd).values)
163
+ values.append(m_var.sel(time=t, **{node_dim: nd}).values)
153
164
  dpts.append(depths[nd])
154
165
 
166
+ # Handle the N=0 case
167
+ if not values:
168
+ k = nodes.shape[1] if nodes.ndim == 2 else 0
169
+ return np.empty((0, k)), np.empty((0, k))
170
+
155
171
  return np.array(values), np.array(dpts)
156
172
 
157
173
  def _coast_distance(self,
@@ -0,0 +1,455 @@
1
+ """ Module for handling the Model data """
2
+
3
+ import logging
4
+ import os
5
+ import re
6
+ from typing import List, Tuple, Union
7
+
8
+ import numpy as np
9
+ import xarray as xr
10
+
11
+
12
+ _logger = logging.getLogger(__name__)
13
+
14
+
15
+ def natural_sort_key(filename: str) -> List[Union[int, str]]:
16
+ """
17
+ Generate a key for natural sorting of filenames (e.g., file10 comes after file2).
18
+
19
+ Parameters
20
+ ----------
21
+ filename : str
22
+ Filename to generate sorting key for
23
+
24
+ Returns
25
+ -------
26
+ List[Union[int, str]]
27
+ List of numeric and string parts to be used for sorting
28
+ """
29
+ return [int(part) if part.isdigit() else part.lower()
30
+ for part in re.split(r'(\d+)', filename)]
31
+
32
+ def _parse_gr3_mesh(filepath: str) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
33
+ """
34
+ Parse a SCHISM hgrid.gr3 mesh file to extract node coordinates and depth.
35
+
36
+ Parameters
37
+ ----------
38
+ filepath : str
39
+ Path to the hgrid.gr3 mesh file
40
+
41
+ Returns
42
+ -------
43
+ Tuple[np.ndarray, np.ndarray, np.ndarray]
44
+ Tuple of (lon, lat, depth) arrays for each mesh node
45
+
46
+ Notes
47
+ -----
48
+ Assumes the hgrid.gr3 file contains node-based data with the expected format.
49
+ This was added so we don't need OCSMesh as a requirement anymore.
50
+ """
51
+ with open(filepath, 'r') as f:
52
+ _ = f.readline() # mesh name
53
+ ne_np_line = f.readline()
54
+ n_elements, n_nodes = map(int, ne_np_line.strip().split())
55
+
56
+ lons = np.empty(n_nodes)
57
+ lats = np.empty(n_nodes)
58
+ depths = np.empty(n_nodes)
59
+
60
+ for i in range(n_nodes):
61
+ parts = f.readline().strip().split()
62
+ lons[i] = float(parts[1])
63
+ lats[i] = float(parts[2])
64
+ depths[i] = float(parts[3])
65
+
66
+ return lons, lats, depths
67
+
68
+ class SCHISM:
69
+ """
70
+ SCHISM model interface
71
+
72
+ Handles selection, filtering, and loading of model outputs from a SCHISM run directory.
73
+ Also parses the model mesh (hgrid.gr3) for spatial queries.
74
+ This assumes a run directory structure where:
75
+ .
76
+ ├── RunDir
77
+ ├── hgrid.gr3
78
+ ├── ...
79
+ ├── outputs
80
+ ├── out2d_*.nc
81
+ └── *.nc
82
+
83
+ Methods
84
+ -------
85
+ load_variable(path)
86
+ Load model variable from a NetCDF file and extract surface layer if 3D
87
+ """
88
+ def __init__(self, rundir: str,
89
+ model_dict: dict,
90
+ start_date: np.datetime64,
91
+ end_date: np.datetime64,
92
+ output_subdir: str = "outputs"):
93
+ """
94
+ Initialize a SCHISM model run
95
+
96
+ Parameters
97
+ ----------
98
+ rundir : str
99
+ Path to the SCHISM model run directory
100
+ model_dict : dict
101
+ Dictionary with keys: 'startswith', 'var', 'var_type'
102
+ start_date : np.datetime64
103
+ Start of the time range for selecting model files
104
+ end_date : np.datetime64
105
+ End of the time range for selecting model files
106
+ output_subdir : str, optional
107
+ Name of the subdirectory containing output NetCDF files (default: "outputs")
108
+ """
109
+ self.rundir = rundir
110
+ self.model_dict = model_dict
111
+ self.start_date = np.datetime64(start_date)
112
+ self.end_date = np.datetime64(end_date)
113
+ self.output_dir = os.path.join(self.rundir, output_subdir)
114
+
115
+ self._validate_model_dict()
116
+ self._files = self._select_model_files()
117
+
118
+ self._mesh_path = os.path.join(self.rundir, 'hgrid.gr3')
119
+ self._mesh_x, self._mesh_y, self._mesh_depth = _parse_gr3_mesh(self._mesh_path)
120
+
121
+ def _validate_model_dict(self) -> None:
122
+ """
123
+ Ensure the model_dict contains all required keys.
124
+
125
+ Raises
126
+ ------
127
+ ValueError
128
+ If required keys are missing from model_dict
129
+ """
130
+ required_keys = ['startswith', 'var', 'var_type']
131
+ missing = [k for k in required_keys if k not in self.model_dict]
132
+ if missing:
133
+ raise ValueError(f"Missing keys in model_dict: {missing}")
134
+
135
+ def _select_model_files(self) -> List[str]:
136
+ """
137
+ Select NetCDF output files within the specified time range.
138
+
139
+ Returns
140
+ -------
141
+ List[str]
142
+ List of file paths to model outputs that overlap with the requested time window
143
+
144
+ Notes
145
+ -----
146
+ Only files that contain a 'time' variable and overlap the specified time window
147
+ are selected.
148
+ Time decoding is limited to the 'time' variable for performance and robustness.
149
+ """
150
+ if not os.path.isdir(self.output_dir):
151
+ _logger.warning(f"Output directory {self.output_dir} does not exist.")
152
+ return []
153
+
154
+ all_files = [f for f in os.listdir(self.output_dir)
155
+ if os.path.isfile(os.path.join(self.output_dir, f))]
156
+ all_files.sort(key=natural_sort_key)
157
+
158
+ selected = []
159
+ for fname in all_files:
160
+ if not fname.startswith(self.model_dict['startswith']) or not fname.endswith(".nc"):
161
+ continue
162
+
163
+ fpath = os.path.join(self.output_dir, fname)
164
+ try:
165
+ with xr.open_dataset(fpath, decode_times=False) as ds:
166
+ if 'time' not in ds.variables:
167
+ continue
168
+ times = ds['time'].values
169
+ times = xr.decode_cf(ds[['time']])['time'].values # decode only time
170
+
171
+ if times[-1] >= self.start_date and times[0] <= self.end_date:
172
+ selected.append(fpath)
173
+ except Exception as e:
174
+ _logger.warning(f"Error reading {fpath}: {e}")
175
+ continue
176
+ # selected.append(os.path.join(self.output_dir, fname))
177
+ if not selected:
178
+ _logger.warning(f"No files matched pattern in {self.output_dir}.\n"
179
+ f"Make sure the model files fall within {self.start_date} and {self.end_date} ")
180
+ return selected
181
+
182
+ def load_variable(self, path: str) -> xr.DataArray:
183
+ """
184
+ Load the specified variable from a model NetCDF file.
185
+
186
+ Parameters
187
+ ----------
188
+ path : str
189
+ Path to the NetCDF file to open
190
+
191
+ Returns
192
+ -------
193
+ xr.DataArray
194
+ The requested variable, surface-only if 3D
195
+
196
+ Notes
197
+ -----
198
+ For 3D variables, this method extracts the surface layer (last index of vertical layers).
199
+ """
200
+ _logger.info("Opening model file: %s", path)
201
+ with xr.open_dataset(path) as ds:
202
+ var = ds[self.model_dict['var']]
203
+ if self.model_dict['var_type'] == '3D':
204
+ var = var.isel(nSCHISM_vgrid_layers=-1)
205
+ return var
206
+
207
+
208
+ @property
209
+ def mesh_x(self) -> np.ndarray:
210
+ """ return mesh_x """
211
+ return self._mesh_x
212
+ @mesh_x.setter
213
+ def mesh_x(self, new_mesh_x: Union[np.ndarray, list]):
214
+ """ set mesh_y """
215
+ if len(new_mesh_x) != len(self.mesh_x):
216
+ raise ValueError("New longitude array must match existing size.")
217
+ self._mesh_x = new_mesh_x
218
+
219
+ @property
220
+ def mesh_y(self) -> np.ndarray:
221
+ """ return mesh_y """
222
+ return self._mesh_y
223
+ @mesh_y.setter
224
+ def mesh_y(self, new_mesh_y: Union[np.ndarray, list]):
225
+ """ set mesh_y """
226
+ if len(new_mesh_y) != len(self.mesh_y):
227
+ raise ValueError("New longitude array must match existing size.")
228
+ self._mesh_y = new_mesh_y
229
+
230
+ @property
231
+ def mesh_depth(self) -> np.ndarray:
232
+ """ return mesh_depth """
233
+ return self._mesh_depth
234
+
235
+ @property
236
+ def files(self) -> List[str]:
237
+ """ return file list """
238
+ return self._files
239
+
240
+
241
+ class ADCSWAN:
242
+ """
243
+ ADCIRC+SWAN model interface
244
+
245
+ Handles selection and loading of model outputs from a single ADCIRC+SWAN NetCDF file.
246
+ The class locates a single file based on 'startswith' and validates its
247
+ time range against the requested start/end dates.
248
+ It reads mesh coordinates (x, y, depth) directly from this file.
249
+
250
+ This class mimics the SCHISM interface for compatibility.
251
+ """
252
+ def __init__(self, rundir: str,
253
+ model_dict: dict,
254
+ start_date: np.datetime64,
255
+ end_date: np.datetime64,
256
+ **kwargs):
257
+ """
258
+ Initialize an ADCIRC+SWAN model run
259
+
260
+ Parameters
261
+ ----------
262
+ rundir : str
263
+ Path to the directory containing the model output NetCDF file
264
+ model_dict : dict
265
+ Dictionary with keys: 'startswith', 'var'.
266
+ 'startswith' is the prefix of the NetCDF file (e.g., "swan_HS.63")
267
+ 'var' is the variable to be loaded (e.g., "swan_HS")
268
+ start_date : np.datetime64
269
+ Start of the time range for validation and slicing (if needed)
270
+ end_date : np.datetime64
271
+ End of the time range for validation and slicing (if needed)
272
+ **kwargs :
273
+ Ignored. Added for interface compatibility with SCHISM (e.g., output_subdir).
274
+ """
275
+ self.rundir = rundir
276
+ self.model_dict = model_dict
277
+ self.start_date = np.datetime64(start_date)
278
+ self.end_date = np.datetime64(end_date)
279
+
280
+ # Note: self.output_dir is kept for SCHISM compatibility but points to rundir
281
+ self.output_dir = self.rundir
282
+
283
+ self._validate_model_dict()
284
+ self._files = self._select_model_files()
285
+
286
+ if self._files:
287
+ self._mesh_path = self._files[0]
288
+ self._mesh_x, self._mesh_y, self._mesh_depth = self._load_mesh_data(self._mesh_path)
289
+ _logger.info(f"ADC+SWAN mesh loaded from {self._mesh_path}")
290
+ else:
291
+ self._mesh_path = None
292
+ self._mesh_x, self._mesh_y, self._mesh_depth = (np.array([]), np.array([]), np.array([]))
293
+ _logger.warning("No ADC+SWAN file found, mesh could not be loaded.")
294
+
295
+
296
+ def _validate_model_dict(self) -> None:
297
+ """
298
+ Ensure the model_dict contains all required keys.
299
+
300
+ Raises
301
+ ------
302
+ ValueError
303
+ If required keys are missing from model_dict
304
+ """
305
+ required_keys = ['startswith', 'var'] # 'var_type' is not required for ADCSWAN
306
+ missing = [k for k in required_keys if k not in self.model_dict]
307
+ if missing:
308
+ raise ValueError(f"Missing keys in model_dict: {missing}")
309
+
310
+ def _load_mesh_data(self, filepath: str) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
311
+ """
312
+ Parse the ADC+SWAN NetCDF file to extract node coordinates and depth.
313
+ """
314
+ _logger.debug(f"Loading mesh data from {filepath}")
315
+ try:
316
+ with xr.open_dataset(filepath, drop_variables=['neta','nvel']) as ds:
317
+ # Use .load() to read data into memory and close the file
318
+ lons = ds['x'].load().values
319
+ lats = ds['y'].load().values
320
+ depths = ds['depth'].load().values
321
+ return lons, lats, depths
322
+ except Exception as e:
323
+ _logger.error(f"Failed to load mesh data from {filepath}: {e}")
324
+ return np.array([]), np.array([]), np.array([])
325
+
326
+
327
+ def _select_model_files(self) -> List[str]:
328
+ """
329
+ Select the ADCIRC+SWAN NetCDF output file and validate its time range.
330
+
331
+ Returns
332
+ -------
333
+ List[str]
334
+ A list containing the path to the model file, if found and valid.
335
+ Otherwise, an empty list.
336
+ """
337
+ if not os.path.isdir(self.rundir):
338
+ _logger.warning(f"Run directory {self.rundir} does not exist.")
339
+ return []
340
+
341
+ all_files = [f for f in os.listdir(self.rundir)
342
+ if os.path.isfile(os.path.join(self.rundir, f))]
343
+ all_files.sort(key=natural_sort_key)
344
+
345
+ selected = []
346
+ file_pattern = self.model_dict['startswith']
347
+
348
+ found_files = [f for f in all_files if f.startswith(file_pattern) and f.endswith(".nc")]
349
+
350
+ if not found_files:
351
+ _logger.warning(f"No file found in {self.rundir} starting with '{file_pattern}'")
352
+ return []
353
+
354
+ if len(found_files) > 1:
355
+ _logger.warning(f"Multiple files found matching '{file_pattern}'. "
356
+ f"Using the first one: {found_files[0]}")
357
+
358
+ fpath = os.path.join(self.rundir, found_files[0])
359
+
360
+ try:
361
+ # Check time range for overlap
362
+ with xr.open_dataset(fpath, decode_times=False, drop_variables=['neta','nvel']) as ds:
363
+ if 'time' not in ds.variables:
364
+ _logger.warning(f"File {fpath} has no 'time' variable. Skipping.")
365
+ return []
366
+
367
+ # Decode only time for validation
368
+ times = xr.decode_cf(ds[['time']])['time'].values
369
+
370
+ if times[-1] >= self.start_date and times[0] <= self.end_date:
371
+ selected.append(fpath)
372
+ else:
373
+ _logger.warning(f"File {fpath} time range ({times[0]} to {times[-1]}) "
374
+ f"does not overlap with requested range "
375
+ f"({self.start_date} to {self.end_date}).")
376
+ except Exception as e:
377
+ _logger.warning(f"Error reading {fpath}: {e}")
378
+ return []
379
+
380
+ return selected
381
+
382
+ def load_variable(self, path: str) -> xr.DataArray:
383
+ """
384
+ Load the specified variable from the model NetCDF file.
385
+
386
+ Parameters
387
+ ----------
388
+ path : str
389
+ Path to the NetCDF file to open (should be the one in self.files)
390
+
391
+ Returns
392
+ -------
393
+ xr.DataArray
394
+ The requested variable, sliced by time.
395
+
396
+ Notes
397
+ -----
398
+ For compatibility with the SCHISM class pattern, this method loads
399
+ the variable from the *given path*.
400
+ """
401
+ _logger.info("Opening model file: %s", path)
402
+ try:
403
+ # Xarray will open the file, slice, and then load.
404
+ ds = xr.open_dataset(path, drop_variables=['neta','nvel'])
405
+ var = ds[self.model_dict['var']]
406
+
407
+ time_slice = slice(self.start_date, self.end_date)
408
+ var_sliced = var.sel(time=time_slice)
409
+
410
+ var_loaded = var_sliced.load()
411
+ ds.close()
412
+
413
+ return var_loaded
414
+
415
+ except KeyError:
416
+ _logger.error(f"Variable '{self.model_dict['var']}' not found in {path}")
417
+ ds.close()
418
+ raise
419
+ except Exception as e:
420
+ _logger.error(f"Error loading variable from {path}: {e}")
421
+ if 'ds' in locals():
422
+ ds.close()
423
+ raise
424
+
425
+ @property
426
+ def mesh_x(self) -> np.ndarray:
427
+ """ return mesh_x (longitude) """
428
+ return self._mesh_x
429
+ @mesh_x.setter
430
+ def mesh_x(self, new_mesh_x: Union[np.ndarray, list]):
431
+ """ set mesh_x """
432
+ if len(new_mesh_x) != len(self.mesh_x):
433
+ raise ValueError("New longitude array must match existing size.")
434
+ self._mesh_x = np.asarray(new_mesh_x)
435
+
436
+ @property
437
+ def mesh_y(self) -> np.ndarray:
438
+ """ return mesh_y (latitude) """
439
+ return self._mesh_y
440
+ @mesh_y.setter
441
+ def mesh_y(self, new_mesh_y: Union[np.ndarray, list]):
442
+ """ set mesh_y """
443
+ if len(new_mesh_y) != len(self.mesh_y):
444
+ raise ValueError("New latitude array must match existing size.")
445
+ self._mesh_y = np.asarray(new_mesh_y)
446
+
447
+ @property
448
+ def mesh_depth(self) -> np.ndarray:
449
+ """ return mesh_depth """
450
+ return self._mesh_depth
451
+
452
+ @property
453
+ def files(self) -> List[str]:
454
+ """ return file list (will contain 0 or 1 file) """
455
+ return self._files
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.1.1.post1.dev0'
32
- __version_tuple__ = version_tuple = (0, 1, 1, 'post1', 'dev0')
31
+ __version__ = version = '0.1.2.post0'
32
+ __version_tuple__ = version_tuple = (0, 1, 2, 'post0')
33
33
 
34
- __commit_id__ = commit_id = 'g63adce77e'
34
+ __commit_id__ = commit_id = 'gb2ff6e94b'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ocstrack
3
- Version: 0.1.1.post1.dev0
3
+ Version: 0.1.2.post0
4
4
  Summary: Satellite data download, crop, and collocation with model outputs
5
5
  Author-email: Felicio Cassalho <felicio.cassalho@noaa.gov>
6
6
  License: MIT
@@ -17,7 +17,7 @@ Requires-Dist: requests
17
17
  Requires-Dist: netcdf4
18
18
  Requires-Dist: h5netcdf
19
19
  Provides-Extra: dev
20
- Requires-Dist: pytest; extra == "dev"
20
+ Requires-Dist: pytest>=6.0; extra == "dev"
21
21
  Requires-Dist: pylint; extra == "dev"
22
22
  Dynamic: license-file
23
23
 
@@ -7,5 +7,5 @@ netcdf4
7
7
  h5netcdf
8
8
 
9
9
  [dev]
10
- pytest
10
+ pytest>=6.0
11
11
  pylint
@@ -28,12 +28,11 @@ Repository = "https://github.com/noaa-ocs-modeling/OCSTrack"
28
28
 
29
29
  [project.optional-dependencies]
30
30
  dev = [
31
- "pytest",
31
+ "pytest>=6.0",
32
32
  "pylint",
33
33
  ]
34
34
 
35
35
  [tool.setuptools_scm]
36
36
  write_to = "ocstrack/_version.py"
37
- # Try forcing cleaner version schemes for releases:
38
- version_scheme = "no-guess-dev"
37
+ version_scheme = "post-release"
39
38
  local_scheme = "no-local-version"
@@ -1,238 +0,0 @@
1
- """ Module for handling the Model data """
2
-
3
- import logging
4
- import os
5
- import re
6
- from typing import List, Tuple, Union
7
-
8
- import numpy as np
9
- import xarray as xr
10
-
11
-
12
- _logger = logging.getLogger(__name__)
13
-
14
-
15
- def natural_sort_key(filename: str) -> List[Union[int, str]]:
16
- """
17
- Generate a key for natural sorting of filenames (e.g., file10 comes after file2).
18
-
19
- Parameters
20
- ----------
21
- filename : str
22
- Filename to generate sorting key for
23
-
24
- Returns
25
- -------
26
- List[Union[int, str]]
27
- List of numeric and string parts to be used for sorting
28
- """
29
- return [int(part) if part.isdigit() else part.lower()
30
- for part in re.split(r'(\d+)', filename)]
31
-
32
- def _parse_gr3_mesh(filepath: str) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
33
- """
34
- Parse a SCHISM hgrid.gr3 mesh file to extract node coordinates and depth.
35
-
36
- Parameters
37
- ----------
38
- filepath : str
39
- Path to the hgrid.gr3 mesh file
40
-
41
- Returns
42
- -------
43
- Tuple[np.ndarray, np.ndarray, np.ndarray]
44
- Tuple of (lon, lat, depth) arrays for each mesh node
45
-
46
- Notes
47
- -----
48
- Assumes the hgrid.gr3 file contains node-based data with the expected format.
49
- This was added so we don't need OCSMesh as a requirement anymore.
50
- """
51
- with open(filepath, 'r') as f:
52
- _ = f.readline() # mesh name
53
- ne_np_line = f.readline()
54
- n_elements, n_nodes = map(int, ne_np_line.strip().split())
55
-
56
- lons = np.empty(n_nodes)
57
- lats = np.empty(n_nodes)
58
- depths = np.empty(n_nodes)
59
-
60
- for i in range(n_nodes):
61
- parts = f.readline().strip().split()
62
- lons[i] = float(parts[1])
63
- lats[i] = float(parts[2])
64
- depths[i] = float(parts[3])
65
-
66
- return lons, lats, depths
67
-
68
- class SCHISM:
69
- """
70
- SCHISM model interface
71
-
72
- Handles selection, filtering, and loading of model outputs from a SCHISM run directory.
73
- Also parses the model mesh (hgrid.gr3) for spatial queries.
74
- This assumes a run directory structure where:
75
- .
76
- ├── RunDir
77
- ├── hgrid.gr3
78
- ├── ...
79
- ├── outputs
80
- ├── out2d_*.nc
81
- └── *.nc
82
-
83
- Methods
84
- -------
85
- load_variable(path)
86
- Load model variable from a NetCDF file and extract surface layer if 3D
87
- """
88
- def __init__(self, rundir: str,
89
- model_dict: dict,
90
- start_date: np.datetime64,
91
- end_date: np.datetime64,
92
- output_subdir: str = "outputs"):
93
- """
94
- Initialize a SCHISM model run
95
-
96
- Parameters
97
- ----------
98
- rundir : str
99
- Path to the SCHISM model run directory
100
- model_dict : dict
101
- Dictionary with keys: 'startswith', 'var', 'var_type'
102
- start_date : np.datetime64
103
- Start of the time range for selecting model files
104
- end_date : np.datetime64
105
- End of the time range for selecting model files
106
- output_subdir : str, optional
107
- Name of the subdirectory containing output NetCDF files (default: "outputs")
108
- """
109
- self.rundir = rundir
110
- self.model_dict = model_dict
111
- self.start_date = np.datetime64(start_date)
112
- self.end_date = np.datetime64(end_date)
113
- self.output_dir = os.path.join(self.rundir, output_subdir)
114
-
115
- self._validate_model_dict()
116
- self._files = self._select_model_files()
117
-
118
- self._mesh_path = os.path.join(self.rundir, 'hgrid.gr3')
119
- self._mesh_x, self._mesh_y, self._mesh_depth = _parse_gr3_mesh(self._mesh_path)
120
-
121
- def _validate_model_dict(self) -> None:
122
- """
123
- Ensure the model_dict contains all required keys.
124
-
125
- Raises
126
- ------
127
- ValueError
128
- If required keys are missing from model_dict
129
- """
130
- required_keys = ['startswith', 'var', 'var_type']
131
- missing = [k for k in required_keys if k not in self.model_dict]
132
- if missing:
133
- raise ValueError(f"Missing keys in model_dict: {missing}")
134
-
135
- def _select_model_files(self) -> List[str]:
136
- """
137
- Select NetCDF output files within the specified time range.
138
-
139
- Returns
140
- -------
141
- List[str]
142
- List of file paths to model outputs that overlap with the requested time window
143
-
144
- Notes
145
- -----
146
- Only files that contain a 'time' variable and overlap the specified time window
147
- are selected.
148
- Time decoding is limited to the 'time' variable for performance and robustness.
149
- """
150
- if not os.path.isdir(self.output_dir):
151
- _logger.warning(f"Output directory {self.output_dir} does not exist.")
152
- return []
153
-
154
- all_files = [f for f in os.listdir(self.output_dir)
155
- if os.path.isfile(os.path.join(self.output_dir, f))]
156
- all_files.sort(key=natural_sort_key)
157
-
158
- selected = []
159
- for fname in all_files:
160
- if not fname.startswith(self.model_dict['startswith']) or not fname.endswith(".nc"):
161
- continue
162
-
163
- fpath = os.path.join(self.output_dir, fname)
164
- try:
165
- with xr.open_dataset(fpath, decode_times=False) as ds:
166
- if 'time' not in ds.variables:
167
- continue
168
- times = ds['time'].values
169
- times = xr.decode_cf(ds[['time']])['time'].values # decode only time
170
-
171
- if times[-1] >= self.start_date and times[0] <= self.end_date:
172
- selected.append(fpath)
173
- except Exception as e:
174
- _logger.warning(f"Error reading {fpath}: {e}")
175
- continue
176
- # selected.append(os.path.join(self.output_dir, fname))
177
- if not selected:
178
- _logger.warning(f"No files matched pattern in {self.output_dir}.\n"
179
- f"Make sure the model files fall within {self.start_date} and {self.end_date} ")
180
- return selected
181
-
182
- def load_variable(self, path: str) -> xr.DataArray:
183
- """
184
- Load the specified variable from a model NetCDF file.
185
-
186
- Parameters
187
- ----------
188
- path : str
189
- Path to the NetCDF file to open
190
-
191
- Returns
192
- -------
193
- xr.DataArray
194
- The requested variable, surface-only if 3D
195
-
196
- Notes
197
- -----
198
- For 3D variables, this method extracts the surface layer (last index of vertical layers).
199
- """
200
- _logger.info("Opening model file: %s", path)
201
- with xr.open_dataset(path) as ds:
202
- var = ds[self.model_dict['var']]
203
- if self.model_dict['var_type'] == '3D':
204
- var = var.isel(nSCHISM_vgrid_layers=-1)
205
- return var
206
-
207
-
208
- @property
209
- def mesh_x(self) -> np.ndarray:
210
- """ return mesh_x """
211
- return self._mesh_x
212
- @mesh_x.setter
213
- def mesh_x(self, new_mesh_x: Union[np.ndarray, list]):
214
- """ set mesh_y """
215
- if len(new_mesh_x) != len(self.mesh_x):
216
- raise ValueError("New longitude array must match existing size.")
217
- self._mesh_x = new_mesh_x
218
-
219
- @property
220
- def mesh_y(self) -> np.ndarray:
221
- """ return mesh_y """
222
- return self._mesh_y
223
- @mesh_y.setter
224
- def mesh_y(self, new_mesh_y: Union[np.ndarray, list]):
225
- """ set mesh_y """
226
- if len(new_mesh_y) != len(self.mesh_y):
227
- raise ValueError("New longitude array must match existing size.")
228
- self._mesh_y = new_mesh_y
229
-
230
- @property
231
- def mesh_depth(self) -> np.ndarray:
232
- """ return mesh_depth """
233
- return self._mesh_depth
234
-
235
- @property
236
- def files(self) -> List[str]:
237
- """ return file list """
238
- return self._files