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.
- {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/.github/workflows/publish-to-pypi.yml +1 -2
- {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/.github/workflows/run-tests.yml +4 -1
- {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/PKG-INFO +2 -2
- {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack/Collocation/collocate.py +18 -2
- ocstrack-0.1.2.post0/ocstrack/Model/model.py +455 -0
- {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack/_version.py +3 -3
- {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack.egg-info/PKG-INFO +2 -2
- {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack.egg-info/requires.txt +1 -1
- {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/pyproject.toml +2 -3
- ocstrack-0.1.1.post1.dev0/ocstrack/Model/model.py +0 -238
- {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/.gitignore +0 -0
- {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/.pylintrc +0 -0
- {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/LICENSE.txt +0 -0
- {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/README.md +0 -0
- {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/examples/Plot_Collocated.ipynb +0 -0
- {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack/Collocation/__init__.py +0 -0
- {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack/Collocation/output.py +0 -0
- {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack/Collocation/spatial.py +0 -0
- {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack/Collocation/temporal.py +0 -0
- {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack/Model/__init__.py +0 -0
- {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack/Satellite/__init__.py +0 -0
- {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack/Satellite/get_sat.py +0 -0
- {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack/Satellite/satellite.py +0 -0
- {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack/Satellite/urls.py +0 -0
- {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack/__init__.py +0 -0
- {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack/utils.py +0 -0
- {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack.egg-info/SOURCES.txt +0 -0
- {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack.egg-info/dependency_links.txt +0 -0
- {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/ocstrack.egg-info/top_level.txt +0 -0
- {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/requirements.txt +0 -0
- {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/setup.cfg +0 -0
- {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/setup.py +0 -0
- {ocstrack-0.1.1.post1.dev0 → ocstrack-0.1.2.post0}/tests/test_get_sat.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ocstrack
|
|
3
|
-
Version: 0.1.
|
|
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,
|
|
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.
|
|
32
|
-
__version_tuple__ = version_tuple = (0, 1,
|
|
31
|
+
__version__ = version = '0.1.2.post0'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 1, 2, 'post0')
|
|
33
33
|
|
|
34
|
-
__commit_id__ = commit_id = '
|
|
34
|
+
__commit_id__ = commit_id = 'gb2ff6e94b'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ocstrack
|
|
3
|
-
Version: 0.1.
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|