ocstrack 0.1.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.
- ocstrack/Collocation/__init__.py +1 -0
- ocstrack/Collocation/collocate.py +429 -0
- ocstrack/Collocation/output.py +63 -0
- ocstrack/Collocation/spatial.py +175 -0
- ocstrack/Collocation/temporal.py +112 -0
- ocstrack/Model/__init__.py +1 -0
- ocstrack/Model/model.py +236 -0
- ocstrack/Satellite/__init__.py +1 -0
- ocstrack/Satellite/get_sat.py +369 -0
- ocstrack/Satellite/satellite.py +103 -0
- ocstrack/Satellite/urls.py +11 -0
- ocstrack/__init__.py +1 -0
- ocstrack/utils.py +47 -0
- ocstrack-0.1.0.dist-info/METADATA +134 -0
- ocstrack-0.1.0.dist-info/RECORD +18 -0
- ocstrack-0.1.0.dist-info/WHEEL +5 -0
- ocstrack-0.1.0.dist-info/licenses/LICENSE.txt +121 -0
- ocstrack-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import xarray as xr
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def temporal_nearest(ds_sat: xr.Dataset,
|
|
6
|
+
model_times: np.ndarray,
|
|
7
|
+
buffer: np.timedelta64
|
|
8
|
+
) -> tuple[xr.Dataset, np.ndarray, np.ndarray]:
|
|
9
|
+
"""
|
|
10
|
+
Match satellite observations to the nearest model timestamps.
|
|
11
|
+
|
|
12
|
+
Parameters
|
|
13
|
+
----------
|
|
14
|
+
ds_sat : xr.Dataset
|
|
15
|
+
Satellite dataset with a 'time' dimension
|
|
16
|
+
model_times : np.ndarray
|
|
17
|
+
Array of model time values (np.datetime64)
|
|
18
|
+
buffer : np.timedelta64
|
|
19
|
+
Time buffer to include satellite points near the model time window
|
|
20
|
+
|
|
21
|
+
Returns
|
|
22
|
+
-------
|
|
23
|
+
tuple
|
|
24
|
+
sat_sub : xr.Dataset
|
|
25
|
+
Subset of satellite data within the buffered time range
|
|
26
|
+
nearest_inds : np.ndarray
|
|
27
|
+
Index of nearest model time for each satellite time
|
|
28
|
+
time_deltas : np.ndarray
|
|
29
|
+
Difference (in seconds) between satellite and matched model time
|
|
30
|
+
"""
|
|
31
|
+
start = model_times.min() - buffer
|
|
32
|
+
end = model_times.max() + buffer
|
|
33
|
+
sat_sub = ds_sat.sortby("time").sel(time=slice(start, end))
|
|
34
|
+
sat_times = sat_sub["time"].values
|
|
35
|
+
|
|
36
|
+
nearest_inds = np.abs(sat_times[:, None] - model_times[None, :]).argmin(axis=1)
|
|
37
|
+
nearest_model_times = model_times[nearest_inds]
|
|
38
|
+
time_deltas = (sat_times - nearest_model_times).astype("timedelta64[s]").astype(int)
|
|
39
|
+
|
|
40
|
+
return sat_sub, nearest_inds, time_deltas
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def temporal_interpolated(ds_sat: xr.Dataset,
|
|
44
|
+
model_times: np.ndarray,
|
|
45
|
+
buffer: np.timedelta64
|
|
46
|
+
) -> tuple[xr.Dataset, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|
47
|
+
"""
|
|
48
|
+
Perform linear time interpolation using surrounding model timestamps.
|
|
49
|
+
|
|
50
|
+
Parameters
|
|
51
|
+
----------
|
|
52
|
+
ds_sat : xr.Dataset
|
|
53
|
+
Satellite dataset with a 'time' dimension
|
|
54
|
+
model_times : np.ndarray
|
|
55
|
+
Array of model time values (np.datetime64)
|
|
56
|
+
buffer : np.timedelta64
|
|
57
|
+
Time buffer to include satellite points near the model time window
|
|
58
|
+
|
|
59
|
+
Returns
|
|
60
|
+
-------
|
|
61
|
+
tuple
|
|
62
|
+
sat_sub : xr.Dataset
|
|
63
|
+
Subset of satellite data used in interpolation
|
|
64
|
+
ib : np.ndarray
|
|
65
|
+
Index of earlier model time
|
|
66
|
+
ia : np.ndarray
|
|
67
|
+
Index of later model time
|
|
68
|
+
weights : np.ndarray
|
|
69
|
+
Linear interpolation weights for each satellite point
|
|
70
|
+
time_deltas : np.ndarray
|
|
71
|
+
Difference (in seconds) between satellite and closest model time
|
|
72
|
+
"""
|
|
73
|
+
start = model_times.min() - buffer
|
|
74
|
+
end = model_times.max() + buffer
|
|
75
|
+
sat_sorted = ds_sat.sortby("time").sel(time=slice(start, end))
|
|
76
|
+
sat_times = sat_sorted["time"].values
|
|
77
|
+
model_times_s = model_times.astype("datetime64[s]")
|
|
78
|
+
|
|
79
|
+
ib, ia, weights, valid_idx = [], [], [], []
|
|
80
|
+
|
|
81
|
+
for i, t in enumerate(sat_times):
|
|
82
|
+
idx = np.searchsorted(model_times_s, t)
|
|
83
|
+
i0 = max(0, idx - 1)
|
|
84
|
+
i1 = min(len(model_times_s) - 1, idx)
|
|
85
|
+
|
|
86
|
+
if i0 == i1:
|
|
87
|
+
continue # No valid interval for interpolation
|
|
88
|
+
|
|
89
|
+
dt = model_times_s[i1] - model_times_s[i0]
|
|
90
|
+
if dt == np.timedelta64(0, "s"):
|
|
91
|
+
continue # Avoid divide-by-zero or duplicate timestamps
|
|
92
|
+
|
|
93
|
+
w = (t - model_times_s[i0]) / dt
|
|
94
|
+
ib.append(i0)
|
|
95
|
+
ia.append(i1)
|
|
96
|
+
weights.append(w)
|
|
97
|
+
valid_idx.append(i)
|
|
98
|
+
|
|
99
|
+
sat_sub = sat_sorted.isel(time=valid_idx)
|
|
100
|
+
ib = np.array(ib, dtype=int)
|
|
101
|
+
ia = np.array(ia, dtype=int)
|
|
102
|
+
weights = np.array(weights)
|
|
103
|
+
|
|
104
|
+
# For metadata: calculate time delta to the nearest of the two model timestamps
|
|
105
|
+
t0 = model_times_s[ib]
|
|
106
|
+
t1 = model_times_s[ia]
|
|
107
|
+
dt0 = np.abs(sat_sub["time"].values - t0)
|
|
108
|
+
dt1 = np.abs(sat_sub["time"].values - t1)
|
|
109
|
+
nearest_model_times = np.where(dt0 <= dt1, t0, t1)
|
|
110
|
+
time_deltas = (sat_sub["time"].values - nearest_model_times).astype("timedelta64[s]").astype(int)
|
|
111
|
+
|
|
112
|
+
return sat_sub, ib, ia, weights, time_deltas
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
ocstrack/Model/model.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
from typing import List, Tuple, Union
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import xarray as xr
|
|
8
|
+
|
|
9
|
+
# Set up logging
|
|
10
|
+
logging.basicConfig(
|
|
11
|
+
level=logging.WARNING,
|
|
12
|
+
format='%(asctime)s - %(levelname)s - %(message)s',
|
|
13
|
+
handlers=[
|
|
14
|
+
logging.StreamHandler(),
|
|
15
|
+
]
|
|
16
|
+
)
|
|
17
|
+
_logger = logging.getLogger()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def natural_sort_key(filename: str) -> List[Union[int, str]]:
|
|
21
|
+
"""
|
|
22
|
+
Generate a key for natural sorting of filenames (e.g., file10 comes after file2).
|
|
23
|
+
|
|
24
|
+
Parameters
|
|
25
|
+
----------
|
|
26
|
+
filename : str
|
|
27
|
+
Filename to generate sorting key for
|
|
28
|
+
|
|
29
|
+
Returns
|
|
30
|
+
-------
|
|
31
|
+
List[Union[int, str]]
|
|
32
|
+
List of numeric and string parts to be used for sorting
|
|
33
|
+
"""
|
|
34
|
+
return [int(part) if part.isdigit() else part.lower()
|
|
35
|
+
for part in re.split(r'(\d+)', filename)]
|
|
36
|
+
|
|
37
|
+
def _parse_gr3_mesh(filepath: str) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
38
|
+
"""
|
|
39
|
+
Parse a SCHISM hgrid.gr3 mesh file to extract node coordinates and depth.
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
filepath : str
|
|
44
|
+
Path to the hgrid.gr3 mesh file
|
|
45
|
+
|
|
46
|
+
Returns
|
|
47
|
+
-------
|
|
48
|
+
Tuple[np.ndarray, np.ndarray, np.ndarray]
|
|
49
|
+
Tuple of (lon, lat, depth) arrays for each mesh node
|
|
50
|
+
|
|
51
|
+
Notes
|
|
52
|
+
-----
|
|
53
|
+
Assumes the hgrid.gr3 file contains node-based data with the expected format.
|
|
54
|
+
This was added so we don't need OCSMesh as a requirement anymore.
|
|
55
|
+
"""
|
|
56
|
+
with open(filepath, 'r') as f:
|
|
57
|
+
_ = f.readline() # mesh name
|
|
58
|
+
ne_np_line = f.readline()
|
|
59
|
+
n_elements, n_nodes = map(int, ne_np_line.strip().split())
|
|
60
|
+
|
|
61
|
+
lons = np.empty(n_nodes)
|
|
62
|
+
lats = np.empty(n_nodes)
|
|
63
|
+
depths = np.empty(n_nodes)
|
|
64
|
+
|
|
65
|
+
for i in range(n_nodes):
|
|
66
|
+
parts = f.readline().strip().split()
|
|
67
|
+
lons[i] = float(parts[1])
|
|
68
|
+
lats[i] = float(parts[2])
|
|
69
|
+
depths[i] = float(parts[3])
|
|
70
|
+
|
|
71
|
+
return lons, lats, depths
|
|
72
|
+
|
|
73
|
+
class SCHISM:
|
|
74
|
+
"""
|
|
75
|
+
SCHISM model interface
|
|
76
|
+
|
|
77
|
+
Handles selection, filtering, and loading of model outputs from a SCHISM run directory.
|
|
78
|
+
Also parses the model mesh (hgrid.gr3) for spatial queries.
|
|
79
|
+
This assumes a run directory structure where:
|
|
80
|
+
.
|
|
81
|
+
├── RunDir
|
|
82
|
+
├── hgrid.gr3
|
|
83
|
+
├── ...
|
|
84
|
+
├── outputs
|
|
85
|
+
├── out2d_*.nc
|
|
86
|
+
└── *.nc
|
|
87
|
+
|
|
88
|
+
Methods
|
|
89
|
+
-------
|
|
90
|
+
load_variable(path)
|
|
91
|
+
Load model variable from a NetCDF file and extract surface layer if 3D
|
|
92
|
+
"""
|
|
93
|
+
def __init__(self, rundir: str,
|
|
94
|
+
model_dict: dict,
|
|
95
|
+
start_date: np.datetime64,
|
|
96
|
+
end_date: np.datetime64,
|
|
97
|
+
output_subdir: str = "outputs"):
|
|
98
|
+
"""
|
|
99
|
+
Initialize a SCHISM model run
|
|
100
|
+
|
|
101
|
+
Parameters
|
|
102
|
+
----------
|
|
103
|
+
rundir : str
|
|
104
|
+
Path to the SCHISM model run directory
|
|
105
|
+
model_dict : dict
|
|
106
|
+
Dictionary with keys: 'startswith', 'var', 'var_type'
|
|
107
|
+
start_date : np.datetime64
|
|
108
|
+
Start of the time range for selecting model files
|
|
109
|
+
end_date : np.datetime64
|
|
110
|
+
End of the time range for selecting model files
|
|
111
|
+
output_subdir : str, optional
|
|
112
|
+
Name of the subdirectory containing output NetCDF files (default: "outputs")
|
|
113
|
+
"""
|
|
114
|
+
self.rundir = rundir
|
|
115
|
+
self.model_dict = model_dict
|
|
116
|
+
self.start_date = np.datetime64(start_date)
|
|
117
|
+
self.end_date = np.datetime64(end_date)
|
|
118
|
+
self.output_dir = os.path.join(self.rundir, output_subdir)
|
|
119
|
+
|
|
120
|
+
self._validate_model_dict()
|
|
121
|
+
self._files = self._select_model_files()
|
|
122
|
+
|
|
123
|
+
self._mesh_path = os.path.join(self.rundir, 'hgrid.gr3')
|
|
124
|
+
self._mesh_x, self._mesh_y, self._mesh_depth = _parse_gr3_mesh(self._mesh_path)
|
|
125
|
+
|
|
126
|
+
def _validate_model_dict(self) -> None:
|
|
127
|
+
"""
|
|
128
|
+
Ensure the model_dict contains all required keys.
|
|
129
|
+
|
|
130
|
+
Raises
|
|
131
|
+
------
|
|
132
|
+
ValueError
|
|
133
|
+
If required keys are missing from model_dict
|
|
134
|
+
"""
|
|
135
|
+
required_keys = ['startswith', 'var', 'var_type']
|
|
136
|
+
missing = [k for k in required_keys if k not in self.model_dict]
|
|
137
|
+
if missing:
|
|
138
|
+
raise ValueError(f"Missing keys in model_dict: {missing}")
|
|
139
|
+
|
|
140
|
+
def _select_model_files(self) -> List[str]:
|
|
141
|
+
"""
|
|
142
|
+
Select NetCDF output files within the specified time range.
|
|
143
|
+
|
|
144
|
+
Returns
|
|
145
|
+
-------
|
|
146
|
+
List[str]
|
|
147
|
+
List of file paths to model outputs that overlap with the requested time window
|
|
148
|
+
|
|
149
|
+
Notes
|
|
150
|
+
-----
|
|
151
|
+
Only files that contain a 'time' variable and overlap the specified time window are selected.
|
|
152
|
+
Time decoding is limited to the 'time' variable for performance and robustness.
|
|
153
|
+
"""
|
|
154
|
+
if not os.path.isdir(self.output_dir):
|
|
155
|
+
_logger.warning(f"Output directory {self.output_dir} does not exist.")
|
|
156
|
+
return []
|
|
157
|
+
|
|
158
|
+
all_files = [f for f in os.listdir(self.output_dir)
|
|
159
|
+
if os.path.isfile(os.path.join(self.output_dir, f))]
|
|
160
|
+
all_files.sort(key=natural_sort_key)
|
|
161
|
+
|
|
162
|
+
selected = []
|
|
163
|
+
for fname in all_files:
|
|
164
|
+
if not fname.startswith(self.model_dict['startswith']) or not fname.endswith(".nc"):
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
fpath = os.path.join(self.output_dir, fname)
|
|
168
|
+
try:
|
|
169
|
+
with xr.open_dataset(fpath, decode_times=False) as ds:
|
|
170
|
+
if 'time' not in ds.variables:
|
|
171
|
+
continue
|
|
172
|
+
times = ds['time'].values
|
|
173
|
+
times = xr.decode_cf(ds[['time']])['time'].values # decode only time
|
|
174
|
+
|
|
175
|
+
if times[-1] >= self.start_date and times[0] <= self.end_date:
|
|
176
|
+
selected.append(fpath)
|
|
177
|
+
except Exception as e:
|
|
178
|
+
_logger.warning(f"Error reading {fpath}: {e}")
|
|
179
|
+
continue
|
|
180
|
+
# selected.append(os.path.join(self.output_dir, fname))
|
|
181
|
+
if not selected:
|
|
182
|
+
_logger.warning(f"No files matched pattern in {self.output_dir}.\n"
|
|
183
|
+
f"Make sure the model files fall within {self.start_date} and {self.end_date} ")
|
|
184
|
+
return selected
|
|
185
|
+
|
|
186
|
+
def load_variable(self, path: str) -> xr.DataArray:
|
|
187
|
+
"""
|
|
188
|
+
Load the specified variable from a model NetCDF file.
|
|
189
|
+
|
|
190
|
+
Parameters
|
|
191
|
+
----------
|
|
192
|
+
path : str
|
|
193
|
+
Path to the NetCDF file to open
|
|
194
|
+
|
|
195
|
+
Returns
|
|
196
|
+
-------
|
|
197
|
+
xr.DataArray
|
|
198
|
+
The requested variable, surface-only if 3D
|
|
199
|
+
|
|
200
|
+
Notes
|
|
201
|
+
-----
|
|
202
|
+
For 3D variables, this method extracts the surface layer (last index of vertical layers).
|
|
203
|
+
"""
|
|
204
|
+
_logger.info("Opening model file: %s", path)
|
|
205
|
+
with xr.open_dataset(path) as ds:
|
|
206
|
+
var = ds[self.model_dict['var']]
|
|
207
|
+
if self.model_dict['var_type'] == '3D':
|
|
208
|
+
var = var.isel(nSCHISM_vgrid_layers=-1)
|
|
209
|
+
return var
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def mesh_x(self) -> np.ndarray:
|
|
214
|
+
return self._mesh_x
|
|
215
|
+
@mesh_x.setter
|
|
216
|
+
def mesh_x(self, new_mesh_x: Union[np.ndarray, list]):
|
|
217
|
+
if len(new_mesh_x) != len(self.mesh_x):
|
|
218
|
+
raise ValueError("New longitude array must match existing size.")
|
|
219
|
+
self._mesh_x = new_mesh_x
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def mesh_y(self) -> np.ndarray:
|
|
223
|
+
return self._mesh_y
|
|
224
|
+
@mesh_y.setter
|
|
225
|
+
def mesh_y(self, new_mesh_y: Union[np.ndarray, list]):
|
|
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 self._mesh_depth
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def files(self) -> List[str]:
|
|
236
|
+
return self._files
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|