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.
@@ -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
+
@@ -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
+