oceanarray 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.
- oceanarray/__init__.py +20 -0
- oceanarray/_version.py +1 -0
- oceanarray/cli.py +1343 -0
- oceanarray/clock_offset.py +675 -0
- oceanarray/config/OS1_sensor_attrs.yaml +49 -0
- oceanarray/config/OS1_var_names.yaml +5 -0
- oceanarray/config/OS1_vocab_attrs.yaml +84 -0
- oceanarray/config/legacy/project_RAPID.yaml +69 -0
- oceanarray/config/legacy/rodb_keys.txt +206 -0
- oceanarray/config/legacy/rodb_keys.yaml +1012 -0
- oceanarray/config/logging.yaml +28 -0
- oceanarray/legacy/__init__.py +47 -0
- oceanarray/legacy/caldip.py +753 -0
- oceanarray/legacy/convertOS.py +527 -0
- oceanarray/legacy/find_deployment.py +489 -0
- oceanarray/legacy/mooring_rodb.py +429 -0
- oceanarray/legacy/process_rodb.py +369 -0
- oceanarray/legacy/rodb.py +330 -0
- oceanarray/logger.py +192 -0
- oceanarray/logsheet/__init__.py +60 -0
- oceanarray/logsheet/_columns.py +97 -0
- oceanarray/logsheet/_config.py +245 -0
- oceanarray/logsheet/_firmware.py +43 -0
- oceanarray/logsheet/_latex.py +128 -0
- oceanarray/logsheet/_render.py +126 -0
- oceanarray/logsheet/builders/__init__.py +13 -0
- oceanarray/logsheet/builders/caldip_download.py +195 -0
- oceanarray/logsheet/builders/caldip_setup.py +188 -0
- oceanarray/logsheet/builders/deployment_setup.py +218 -0
- oceanarray/logsheet/builders/mooring_download.py +243 -0
- oceanarray/logsheet/builders/recovery.py +157 -0
- oceanarray/logsheet/data/column_defs.yaml +686 -0
- oceanarray/logsheet/data/latex_templates/_base_instrument.tex.j2 +42 -0
- oceanarray/logsheet/data/latex_templates/caldip_download.tex.j2 +47 -0
- oceanarray/logsheet/data/latex_templates/caldip_setup.tex.j2 +70 -0
- oceanarray/logsheet/data/latex_templates/deployment_setup.tex.j2 +97 -0
- oceanarray/logsheet/data/latex_templates/mooring_download.tex.j2 +54 -0
- oceanarray/logsheet/data/latex_templates/recovery.tex.j2 +103 -0
- oceanarray/mooring_level.py +1368 -0
- oceanarray/oceanarray.mplstyle +18 -0
- oceanarray/parameters.py +270 -0
- oceanarray/plotter.py +942 -0
- oceanarray/plotters/__init__.py +97 -0
- oceanarray/plotters/_animation.py +261 -0
- oceanarray/plotters/_current.py +705 -0
- oceanarray/plotters/_diagnostic.py +667 -0
- oceanarray/plotters/_helpers.py +57 -0
- oceanarray/plotters/_primitives.py +101 -0
- oceanarray/plotters/_section.py +16 -0
- oceanarray/plotters/_timeseries.py +11 -0
- oceanarray/rapid_interp.py +739 -0
- oceanarray/read_rbr_hex.py +541 -0
- oceanarray/readers.py +303 -0
- oceanarray/report/__init__.py +5 -0
- oceanarray/report/_array.py +386 -0
- oceanarray/report/_grid.py +539 -0
- oceanarray/report/_html_helpers.py +1004 -0
- oceanarray/report/_instrument.py +853 -0
- oceanarray/report/_mooring.py +1385 -0
- oceanarray/report/_plots.py +4284 -0
- oceanarray/report/_stack.py +941 -0
- oceanarray/stage1.py +1959 -0
- oceanarray/stage2.py +1160 -0
- oceanarray/stage3.py +2494 -0
- oceanarray/time_gridding.py +958 -0
- oceanarray/tools.py +400 -0
- oceanarray/transports.py +1 -0
- oceanarray/utilities.py +538 -0
- oceanarray/validation.py +389 -0
- oceanarray/writers.py +99 -0
- oceanarray-0.1.0.dist-info/METADATA +503 -0
- oceanarray-0.1.0.dist-info/RECORD +76 -0
- oceanarray-0.1.0.dist-info/WHEEL +5 -0
- oceanarray-0.1.0.dist-info/entry_points.txt +2 -0
- oceanarray-0.1.0.dist-info/licenses/LICENSE +21 -0
- oceanarray-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,958 @@
|
|
|
1
|
+
"""Step 1 processing for mooring data: Time gridding and optional filtering.
|
|
2
|
+
|
|
3
|
+
This module handles:
|
|
4
|
+
- Loading processed Stage 2 NetCDF files (_stage2.nc) from multiple instruments
|
|
5
|
+
- Optional time-domain filtering applied to individual instrument records
|
|
6
|
+
- Interpolating all instruments onto a common time grid
|
|
7
|
+
- Combining instruments into a single dataset with N_LEVELS dimension
|
|
8
|
+
- Encoding instrument metadata as coordinate arrays
|
|
9
|
+
- Writing time-gridded mooring datasets
|
|
10
|
+
|
|
11
|
+
This represents Step 1 in the mooring-level processing workflow:
|
|
12
|
+
- Step 1: Time gridding (this module)
|
|
13
|
+
- Step 2: Vertical gridding (future)
|
|
14
|
+
- Step 3: Multi-deployment stitching (future)
|
|
15
|
+
|
|
16
|
+
IMPORTANT: Filtering is applied to individual instrument records BEFORE interpolation
|
|
17
|
+
to preserve data integrity and avoid interpolation artifacts.
|
|
18
|
+
|
|
19
|
+
Version: 1.1
|
|
20
|
+
Last updated: 2025-09-07
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
import xarray as xr
|
|
28
|
+
import yaml
|
|
29
|
+
from seasenselib.writers import NetCdfWriter
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TimeGriddingProcessor:
|
|
33
|
+
"""Handles Step 1 processing: time gridding and optional filtering of mooring instruments."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, base_dir: str) -> None:
|
|
36
|
+
"""Initialize processor with base directory."""
|
|
37
|
+
self.base_dir = Path(base_dir)
|
|
38
|
+
self.log_file = None
|
|
39
|
+
|
|
40
|
+
def _setup_logging(self, mooring_name: str, output_path: Path) -> None:
|
|
41
|
+
"""Set up logging for the processing run using global config."""
|
|
42
|
+
from .logger import setup_stage_logging
|
|
43
|
+
|
|
44
|
+
self.log_file = setup_stage_logging(mooring_name, "time_gridding", output_path)
|
|
45
|
+
|
|
46
|
+
def _log_print(self, *args: Any, **kwargs: Any) -> None:
|
|
47
|
+
"""Print to both console and log file."""
|
|
48
|
+
print(*args, **kwargs)
|
|
49
|
+
if self.log_file:
|
|
50
|
+
with open(self.log_file, "a") as f:
|
|
51
|
+
print(*args, **kwargs, file=f)
|
|
52
|
+
|
|
53
|
+
def _load_mooring_config(self, config_path: Path) -> Dict[str, Any]:
|
|
54
|
+
"""Load mooring configuration from YAML file."""
|
|
55
|
+
with open(config_path, "r") as f:
|
|
56
|
+
return yaml.safe_load(f)
|
|
57
|
+
|
|
58
|
+
def _load_instrument_datasets(
|
|
59
|
+
self,
|
|
60
|
+
mooring_config: Dict[str, Any],
|
|
61
|
+
proc_dir: Path,
|
|
62
|
+
file_suffix: str = "_stage2",
|
|
63
|
+
) -> List[xr.Dataset]:
|
|
64
|
+
"""Load all instrument datasets for a mooring."""
|
|
65
|
+
datasets = []
|
|
66
|
+
mooring_name = mooring_config["name"]
|
|
67
|
+
expected_instruments = mooring_config.get("instruments", [])
|
|
68
|
+
found_instruments = []
|
|
69
|
+
missing_instruments = []
|
|
70
|
+
|
|
71
|
+
for instrument_config in expected_instruments:
|
|
72
|
+
instrument_type = instrument_config.get("instrument", "unknown")
|
|
73
|
+
serial = instrument_config.get("serial", 0)
|
|
74
|
+
|
|
75
|
+
# Construct file path
|
|
76
|
+
filename = f"{mooring_name}_{serial}{file_suffix}.nc"
|
|
77
|
+
filepath = proc_dir / instrument_type / filename
|
|
78
|
+
|
|
79
|
+
if not filepath.exists():
|
|
80
|
+
self._log_print(f"WARNING: File not found: {filepath}")
|
|
81
|
+
missing_instruments.append(f"{instrument_type}:{serial}")
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
self._log_print(
|
|
86
|
+
f"Loading {instrument_type} serial {serial}: {filename}"
|
|
87
|
+
)
|
|
88
|
+
ds = xr.open_dataset(filepath)
|
|
89
|
+
|
|
90
|
+
# Ensure required metadata is present
|
|
91
|
+
ds = self._ensure_instrument_metadata(ds, instrument_config)
|
|
92
|
+
|
|
93
|
+
# Clean unnecessary variables
|
|
94
|
+
ds = self._clean_dataset_variables(ds)
|
|
95
|
+
|
|
96
|
+
datasets.append(ds)
|
|
97
|
+
found_instruments.append(f"{instrument_type}:{serial}")
|
|
98
|
+
|
|
99
|
+
except Exception as e: # noqa: BLE001 — skip unreadable file; mooring continues
|
|
100
|
+
self._log_print(f"ERROR loading {filepath}: {e}")
|
|
101
|
+
missing_instruments.append(f"{instrument_type}:{serial}")
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
# Report on missing instruments
|
|
105
|
+
if missing_instruments:
|
|
106
|
+
self._log_print("")
|
|
107
|
+
self._log_print(
|
|
108
|
+
"WARNING: Missing instruments compared to YAML configuration:"
|
|
109
|
+
)
|
|
110
|
+
for missing in missing_instruments:
|
|
111
|
+
self._log_print(f" - {missing}")
|
|
112
|
+
self._log_print(
|
|
113
|
+
f" Expected {len(expected_instruments)}, found {len(found_instruments)}"
|
|
114
|
+
)
|
|
115
|
+
self._log_print("")
|
|
116
|
+
else:
|
|
117
|
+
self._log_print(
|
|
118
|
+
f"All {len(expected_instruments)} instruments from YAML found and loaded"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return datasets
|
|
122
|
+
|
|
123
|
+
def _ensure_instrument_metadata(
|
|
124
|
+
self, dataset: xr.Dataset, instrument_config: Dict[str, Any]
|
|
125
|
+
) -> xr.Dataset:
|
|
126
|
+
"""Ensure all required metadata is present in dataset."""
|
|
127
|
+
# Add missing metadata from config
|
|
128
|
+
if "InstrDepth" not in dataset.variables and "depth" in instrument_config:
|
|
129
|
+
dataset["InstrDepth"] = instrument_config["depth"]
|
|
130
|
+
|
|
131
|
+
if "instrument" not in dataset.variables and "instrument" in instrument_config:
|
|
132
|
+
dataset["instrument"] = instrument_config["instrument"]
|
|
133
|
+
|
|
134
|
+
if "serial_number" not in dataset.variables and "serial" in instrument_config:
|
|
135
|
+
dataset["serial_number"] = instrument_config["serial"]
|
|
136
|
+
|
|
137
|
+
return dataset
|
|
138
|
+
|
|
139
|
+
def _clean_dataset_variables(self, dataset: xr.Dataset) -> xr.Dataset:
|
|
140
|
+
"""Remove unnecessary variables from dataset."""
|
|
141
|
+
vars_to_remove = [
|
|
142
|
+
"timeS",
|
|
143
|
+
"density",
|
|
144
|
+
"potential_temperature",
|
|
145
|
+
"julian_days_offset",
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
for var in vars_to_remove:
|
|
149
|
+
if var in dataset.variables:
|
|
150
|
+
dataset = dataset.drop_vars(var, errors="ignore")
|
|
151
|
+
|
|
152
|
+
return dataset
|
|
153
|
+
|
|
154
|
+
def _apply_time_filtering_single(
|
|
155
|
+
self,
|
|
156
|
+
dataset: xr.Dataset,
|
|
157
|
+
filter_type: Optional[str] = None,
|
|
158
|
+
filter_params: Optional[Dict[str, Any]] = None,
|
|
159
|
+
) -> xr.Dataset:
|
|
160
|
+
"""Apply time-domain filtering to a single instrument dataset.
|
|
161
|
+
|
|
162
|
+
This method applies filtering to the original instrument time grid
|
|
163
|
+
BEFORE interpolation to preserve data integrity.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
dataset: Single instrument dataset on its native time grid
|
|
167
|
+
filter_type: Type of filtering ('lowpass', 'bandpass', 'detide', etc.)
|
|
168
|
+
filter_params: Filter parameters (cutoff frequencies, order, etc.)
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
Filtered dataset on the same time grid
|
|
172
|
+
|
|
173
|
+
"""
|
|
174
|
+
if filter_type is None:
|
|
175
|
+
# No filtering requested
|
|
176
|
+
return dataset
|
|
177
|
+
|
|
178
|
+
# Get instrument info for logging
|
|
179
|
+
instrument = (
|
|
180
|
+
dataset["instrument"].values if "instrument" in dataset else "unknown"
|
|
181
|
+
)
|
|
182
|
+
serial = (
|
|
183
|
+
dataset["serial_number"].values if "serial_number" in dataset else "unknown"
|
|
184
|
+
)
|
|
185
|
+
depth = dataset["InstrDepth"].values if "InstrDepth" in dataset else "unknown"
|
|
186
|
+
|
|
187
|
+
self._log_print(
|
|
188
|
+
f" Applying {filter_type} filtering to {instrument}:{serial} at {depth}m"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
if filter_type.lower() == "lowpass":
|
|
192
|
+
return self._apply_lowpass_filter(dataset, filter_params)
|
|
193
|
+
elif filter_type.lower() == "detide":
|
|
194
|
+
return self._apply_detiding_filter(dataset, filter_params)
|
|
195
|
+
elif filter_type.lower() == "bandpass":
|
|
196
|
+
return self._apply_bandpass_filter(dataset, filter_params)
|
|
197
|
+
else:
|
|
198
|
+
self._log_print(
|
|
199
|
+
f" WARNING: Unknown filter type '{filter_type}', skipping"
|
|
200
|
+
)
|
|
201
|
+
return dataset
|
|
202
|
+
|
|
203
|
+
def _apply_lowpass_filter(
|
|
204
|
+
self, dataset: xr.Dataset, filter_params: Optional[Dict[str, Any]] = None
|
|
205
|
+
) -> xr.Dataset:
|
|
206
|
+
"""Apply low-pass Butterworth filter (e.g., for de-tiding).
|
|
207
|
+
|
|
208
|
+
Default parameters match RAPID array processing:
|
|
209
|
+
- Cutoff: 2 days (removes tidal and inertial signals)
|
|
210
|
+
- Order: 6th order Butterworth
|
|
211
|
+
"""
|
|
212
|
+
# Default RAPID-style parameters
|
|
213
|
+
default_params = {"cutoff_days": 2.0, "order": 6, "method": "butterworth"}
|
|
214
|
+
|
|
215
|
+
if filter_params:
|
|
216
|
+
default_params.update(filter_params)
|
|
217
|
+
|
|
218
|
+
cutoff_days = default_params["cutoff_days"]
|
|
219
|
+
order = default_params["order"]
|
|
220
|
+
|
|
221
|
+
self._log_print(f" Low-pass filter: {cutoff_days} day cutoff, order {order}")
|
|
222
|
+
|
|
223
|
+
# Check if we have sufficient data length
|
|
224
|
+
if "time" not in dataset.sizes or dataset.sizes["time"] < 100:
|
|
225
|
+
self._log_print(
|
|
226
|
+
f" WARNING: Insufficient data for filtering (n={dataset.sizes.get('time', 0)})"
|
|
227
|
+
)
|
|
228
|
+
return dataset
|
|
229
|
+
|
|
230
|
+
# Calculate sampling rate
|
|
231
|
+
time_diffs = np.diff(dataset["time"].values) / np.timedelta64(1, "s")
|
|
232
|
+
dt_seconds = np.nanmedian(time_diffs)
|
|
233
|
+
|
|
234
|
+
if not np.isfinite(dt_seconds) or dt_seconds <= 0:
|
|
235
|
+
self._log_print(" WARNING: Invalid sampling rate, skipping filter")
|
|
236
|
+
return dataset
|
|
237
|
+
|
|
238
|
+
# Convert cutoff to frequency
|
|
239
|
+
cutoff_seconds = cutoff_days * 24 * 3600
|
|
240
|
+
nyquist = 1.0 / (2.0 * dt_seconds)
|
|
241
|
+
cutoff_freq = 1.0 / cutoff_seconds
|
|
242
|
+
|
|
243
|
+
if cutoff_freq >= nyquist:
|
|
244
|
+
self._log_print(
|
|
245
|
+
f" WARNING: Cutoff frequency ({cutoff_freq:.2e} Hz) >= Nyquist ({nyquist:.2e} Hz)"
|
|
246
|
+
)
|
|
247
|
+
self._log_print(" Skipping filter to avoid artifacts")
|
|
248
|
+
return dataset
|
|
249
|
+
|
|
250
|
+
# Apply filter to each data variable
|
|
251
|
+
try:
|
|
252
|
+
from scipy import signal
|
|
253
|
+
|
|
254
|
+
# Design Butterworth filter
|
|
255
|
+
sos = signal.butter(
|
|
256
|
+
order, cutoff_freq, btype="low", fs=1 / dt_seconds, output="sos"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# Create filtered dataset
|
|
260
|
+
ds_filtered = dataset.copy()
|
|
261
|
+
|
|
262
|
+
# Variables to filter (skip coordinates and metadata)
|
|
263
|
+
filter_vars = [
|
|
264
|
+
"temperature",
|
|
265
|
+
"salinity",
|
|
266
|
+
"conductivity",
|
|
267
|
+
"pressure",
|
|
268
|
+
"u_velocity",
|
|
269
|
+
"v_velocity",
|
|
270
|
+
"eastward_velocity",
|
|
271
|
+
"northward_velocity",
|
|
272
|
+
]
|
|
273
|
+
|
|
274
|
+
for var in filter_vars:
|
|
275
|
+
if var in dataset.data_vars:
|
|
276
|
+
data = dataset[var].values
|
|
277
|
+
|
|
278
|
+
# Check for sufficient valid data
|
|
279
|
+
valid_mask = np.isfinite(data)
|
|
280
|
+
if np.sum(valid_mask) < 0.1 * len(data):
|
|
281
|
+
self._log_print(
|
|
282
|
+
f" WARNING: Too few valid points in {var}, skipping"
|
|
283
|
+
)
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
# Apply filter only to valid data segments
|
|
287
|
+
if np.all(valid_mask):
|
|
288
|
+
# No gaps, apply filter directly
|
|
289
|
+
filtered_data = signal.sosfiltfilt(sos, data)
|
|
290
|
+
else:
|
|
291
|
+
# Handle gaps by filtering continuous segments
|
|
292
|
+
filtered_data = self._filter_with_gaps(data, sos, valid_mask)
|
|
293
|
+
|
|
294
|
+
ds_filtered[var] = (
|
|
295
|
+
dataset[var].dims,
|
|
296
|
+
filtered_data,
|
|
297
|
+
dataset[var].attrs,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# Update attributes to record filtering
|
|
301
|
+
ds_filtered.attrs.update(
|
|
302
|
+
{
|
|
303
|
+
"time_filtering_applied": filter_params or default_params,
|
|
304
|
+
"time_filtering_cutoff_days": cutoff_days,
|
|
305
|
+
"time_filtering_order": order,
|
|
306
|
+
"processing_step": "time_filtered",
|
|
307
|
+
}
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
self._log_print(" Successfully applied low-pass filter")
|
|
311
|
+
|
|
312
|
+
except ImportError:
|
|
313
|
+
self._log_print(" ERROR: scipy not available for filtering")
|
|
314
|
+
return dataset
|
|
315
|
+
except Exception as e: # noqa: BLE001 — filter failure must not abort processing
|
|
316
|
+
self._log_print(f" ERROR applying filter: {e}")
|
|
317
|
+
return dataset
|
|
318
|
+
return ds_filtered
|
|
319
|
+
|
|
320
|
+
def _filter_with_gaps(
|
|
321
|
+
self, data: np.ndarray, sos: np.ndarray, valid_mask: np.ndarray
|
|
322
|
+
) -> np.ndarray:
|
|
323
|
+
"""Apply filter to data with gaps by processing continuous segments."""
|
|
324
|
+
from scipy import signal
|
|
325
|
+
|
|
326
|
+
filtered_data = np.full_like(data, np.nan)
|
|
327
|
+
|
|
328
|
+
# Find continuous segments
|
|
329
|
+
diff_mask = np.diff(np.concatenate(([False], valid_mask, [False])).astype(int))
|
|
330
|
+
starts = np.where(diff_mask == 1)[0]
|
|
331
|
+
ends = np.where(diff_mask == -1)[0]
|
|
332
|
+
|
|
333
|
+
for start, end in zip(starts, ends):
|
|
334
|
+
segment_length = end - start
|
|
335
|
+
|
|
336
|
+
# Only filter segments with sufficient length
|
|
337
|
+
if segment_length > 50: # Minimum length for stable filtering
|
|
338
|
+
segment_data = data[start:end]
|
|
339
|
+
try:
|
|
340
|
+
filtered_segment = signal.sosfiltfilt(sos, segment_data)
|
|
341
|
+
filtered_data[start:end] = filtered_segment
|
|
342
|
+
except Exception: # noqa: BLE001 — segment filter failure; keep original
|
|
343
|
+
filtered_data[start:end] = segment_data
|
|
344
|
+
else:
|
|
345
|
+
# Keep short segments unfiltered
|
|
346
|
+
filtered_data[start:end] = data[start:end]
|
|
347
|
+
|
|
348
|
+
return filtered_data
|
|
349
|
+
|
|
350
|
+
def _apply_detiding_filter(
|
|
351
|
+
self, dataset: xr.Dataset, filter_params: Optional[Dict[str, Any]] = None
|
|
352
|
+
) -> xr.Dataset:
|
|
353
|
+
"""Apply harmonic analysis for tidal removal (future implementation)."""
|
|
354
|
+
self._log_print(" WARNING: Harmonic de-tiding not yet implemented")
|
|
355
|
+
self._log_print(" Using low-pass filter as substitute")
|
|
356
|
+
|
|
357
|
+
# Fall back to low-pass filtering for now
|
|
358
|
+
return self._apply_lowpass_filter(dataset, filter_params)
|
|
359
|
+
|
|
360
|
+
def _apply_bandpass_filter(
|
|
361
|
+
self,
|
|
362
|
+
dataset: xr.Dataset,
|
|
363
|
+
filter_params: Optional[Dict[str, Any]] = None, # noqa: ARG002
|
|
364
|
+
) -> xr.Dataset:
|
|
365
|
+
"""Apply band-pass filter (future implementation)."""
|
|
366
|
+
self._log_print(" WARNING: Band-pass filtering not yet implemented")
|
|
367
|
+
return dataset
|
|
368
|
+
|
|
369
|
+
def _analyze_timing_info(
|
|
370
|
+
self, datasets: List[xr.Dataset]
|
|
371
|
+
) -> Tuple[np.ndarray, np.datetime64, np.datetime64]:
|
|
372
|
+
"""Analyze timing information across all datasets."""
|
|
373
|
+
intervals_min = []
|
|
374
|
+
start_times = []
|
|
375
|
+
end_times = []
|
|
376
|
+
timing_details = []
|
|
377
|
+
|
|
378
|
+
for idx, ds in enumerate(datasets):
|
|
379
|
+
if "time" not in ds.sizes or ds.sizes["time"] <= 1:
|
|
380
|
+
self._log_print(f"WARNING: Dataset {idx} has insufficient time data")
|
|
381
|
+
continue
|
|
382
|
+
|
|
383
|
+
time = ds["time"]
|
|
384
|
+
start_time = time.values[0]
|
|
385
|
+
end_time = time.values[-1]
|
|
386
|
+
|
|
387
|
+
# Calculate time intervals (in minutes)
|
|
388
|
+
time_diffs = np.diff(time.values) / np.timedelta64(1, "m")
|
|
389
|
+
time_interval_median = np.nanmedian(time_diffs)
|
|
390
|
+
time_interval_min = np.nanmin(time_diffs)
|
|
391
|
+
time_interval_max = np.nanmax(time_diffs)
|
|
392
|
+
time_interval_std = np.nanstd(time_diffs)
|
|
393
|
+
|
|
394
|
+
# Format interval string
|
|
395
|
+
if time_interval_median > 1:
|
|
396
|
+
tstr = f"{time_interval_median:.2f} min"
|
|
397
|
+
tmin_str = f"{time_interval_min:.2f} min"
|
|
398
|
+
tmax_str = f"{time_interval_max:.2f} min"
|
|
399
|
+
tstd_str = f"{time_interval_std:.2f} min"
|
|
400
|
+
else:
|
|
401
|
+
tstr = f"{time_interval_median * 60:.2f} sec"
|
|
402
|
+
tmin_str = f"{time_interval_min * 60:.2f} sec"
|
|
403
|
+
tmax_str = f"{time_interval_max * 60:.2f} sec"
|
|
404
|
+
tstd_str = f"{time_interval_std * 60:.2f} sec"
|
|
405
|
+
|
|
406
|
+
variables = list(ds.data_vars)
|
|
407
|
+
depth = ds["InstrDepth"].values if "InstrDepth" in ds else "unknown"
|
|
408
|
+
instrument = ds["instrument"].values if "instrument" in ds else "unknown"
|
|
409
|
+
serial = ds["serial_number"].values if "serial_number" in ds else "unknown"
|
|
410
|
+
|
|
411
|
+
self._log_print(f"Dataset {idx} depth {depth} [{instrument}:{serial}]:")
|
|
412
|
+
self._log_print(
|
|
413
|
+
f" Start: {str(start_time)[:19]}, End: {str(end_time)[:19]}"
|
|
414
|
+
)
|
|
415
|
+
self._log_print(
|
|
416
|
+
f" Time interval - Median: {tstr}, Range: {tmin_str} to {tmax_str}, Std: {tstd_str}"
|
|
417
|
+
)
|
|
418
|
+
self._log_print(f" Variables: {variables}")
|
|
419
|
+
|
|
420
|
+
# Store timing details for later analysis
|
|
421
|
+
timing_details.append(
|
|
422
|
+
{
|
|
423
|
+
"idx": idx,
|
|
424
|
+
"instrument": f"{instrument}:{serial}",
|
|
425
|
+
"depth": depth,
|
|
426
|
+
"median_interval_min": time_interval_median,
|
|
427
|
+
"min_interval_min": time_interval_min,
|
|
428
|
+
"max_interval_min": time_interval_max,
|
|
429
|
+
"std_interval_min": time_interval_std,
|
|
430
|
+
"n_points": len(time),
|
|
431
|
+
}
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
intervals_min.append(time_interval_median)
|
|
435
|
+
start_times.append(start_time)
|
|
436
|
+
end_times.append(end_time)
|
|
437
|
+
|
|
438
|
+
if not start_times:
|
|
439
|
+
raise ValueError("No valid datasets with time information found") # noqa: TRY003
|
|
440
|
+
|
|
441
|
+
earliest_start = min(start_times)
|
|
442
|
+
latest_end = max(end_times)
|
|
443
|
+
median_interval = np.nanmedian(intervals_min)
|
|
444
|
+
|
|
445
|
+
# Analyze timing consistency and warn about issues
|
|
446
|
+
self._log_print("")
|
|
447
|
+
self._log_print("TIMING ANALYSIS:")
|
|
448
|
+
self._log_print(f" Overall median interval: {median_interval:.2f} min")
|
|
449
|
+
self._log_print(
|
|
450
|
+
f" Range of median intervals: {np.min(intervals_min):.2f} to {np.max(intervals_min):.2f} min"
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
# Check for large differences in sampling rates
|
|
454
|
+
interval_ratio = np.max(intervals_min) / np.min(intervals_min)
|
|
455
|
+
if interval_ratio > 2.0:
|
|
456
|
+
self._log_print(" WARNING: Large differences in sampling rates detected!")
|
|
457
|
+
self._log_print(
|
|
458
|
+
f" WARNING: Ratio of slowest to fastest: {interval_ratio:.1f}x"
|
|
459
|
+
)
|
|
460
|
+
self._log_print(
|
|
461
|
+
" WARNING: This may cause significant interpolation artifacts"
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
# Check for irregular sampling within instruments
|
|
465
|
+
for detail in timing_details:
|
|
466
|
+
if (
|
|
467
|
+
detail["std_interval_min"] > 0.1 * detail["median_interval_min"]
|
|
468
|
+
): # >10% variation
|
|
469
|
+
self._log_print(
|
|
470
|
+
f" WARNING: Irregular sampling in {detail['instrument']}"
|
|
471
|
+
)
|
|
472
|
+
self._log_print(
|
|
473
|
+
f" Std dev ({detail['std_interval_min']:.2f} min) > 10% of median ({detail['median_interval_min']:.2f} min)"
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
# Report on interpolation target
|
|
477
|
+
self._log_print(f" Common grid will use {median_interval:.2f} min intervals")
|
|
478
|
+
for detail in timing_details:
|
|
479
|
+
diff_pct = (
|
|
480
|
+
abs(detail["median_interval_min"] - median_interval)
|
|
481
|
+
/ median_interval
|
|
482
|
+
* 100
|
|
483
|
+
)
|
|
484
|
+
if diff_pct > 10:
|
|
485
|
+
status = "SIGNIFICANT CHANGE"
|
|
486
|
+
elif diff_pct > 1:
|
|
487
|
+
status = "MINOR CHANGE"
|
|
488
|
+
else:
|
|
489
|
+
status = "MINIMAL CHANGE"
|
|
490
|
+
self._log_print(
|
|
491
|
+
f" {detail['instrument']}: {detail['median_interval_min']:.2f} min -> {median_interval:.2f} min ({diff_pct:.1f}% change) {status}"
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
# Create common time grid
|
|
495
|
+
time_grid = np.arange(
|
|
496
|
+
earliest_start, latest_end, np.timedelta64(int(median_interval * 60), "s")
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
self._log_print(
|
|
500
|
+
f" Common time grid: {len(time_grid)} points from {time_grid[0]} to {time_grid[-1]}"
|
|
501
|
+
)
|
|
502
|
+
self._log_print("")
|
|
503
|
+
|
|
504
|
+
return time_grid, earliest_start, latest_end
|
|
505
|
+
|
|
506
|
+
def _interpolate_datasets(
|
|
507
|
+
self, datasets: List[xr.Dataset], time_grid: np.ndarray
|
|
508
|
+
) -> List[xr.Dataset]:
|
|
509
|
+
"""Interpolate all datasets onto common time grid."""
|
|
510
|
+
datasets_interp = []
|
|
511
|
+
|
|
512
|
+
with xr.set_options(keep_attrs=True):
|
|
513
|
+
for idx, ds in enumerate(datasets):
|
|
514
|
+
if "time" not in ds.sizes or ds.sizes["time"] <= 1:
|
|
515
|
+
self._log_print(f"Skipping dataset {idx}: insufficient time data")
|
|
516
|
+
continue
|
|
517
|
+
|
|
518
|
+
try:
|
|
519
|
+
# Interpolate the whole dataset at once
|
|
520
|
+
ds_i = ds.interp(time=time_grid)
|
|
521
|
+
|
|
522
|
+
# Preserve global attributes (Dataset.interp can drop them)
|
|
523
|
+
ds_i.attrs = dict(ds.attrs)
|
|
524
|
+
|
|
525
|
+
# Keep coordinate attributes
|
|
526
|
+
if "time" in ds.coords and ds.time.attrs:
|
|
527
|
+
ds_i.time.attrs = dict(ds.time.attrs)
|
|
528
|
+
|
|
529
|
+
# Add extra coordinates as needed
|
|
530
|
+
for coord_name in [
|
|
531
|
+
"InstrDepth",
|
|
532
|
+
"clock_offset",
|
|
533
|
+
"serial_number",
|
|
534
|
+
"instrument",
|
|
535
|
+
]:
|
|
536
|
+
if coord_name in ds:
|
|
537
|
+
ds_i = ds_i.assign_coords({coord_name: ds[coord_name]})
|
|
538
|
+
|
|
539
|
+
datasets_interp.append(ds_i)
|
|
540
|
+
self._log_print(f"Successfully interpolated dataset {idx}")
|
|
541
|
+
|
|
542
|
+
except Exception as e: # noqa: BLE001 — skip one bad instrument; interpolation continues
|
|
543
|
+
self._log_print(f"ERROR interpolating dataset {idx}: {e}")
|
|
544
|
+
continue
|
|
545
|
+
|
|
546
|
+
return datasets_interp
|
|
547
|
+
|
|
548
|
+
def _merge_global_attrs(
|
|
549
|
+
self, ds_list: List[xr.Dataset], order: str = "last"
|
|
550
|
+
) -> Dict[str, Any]:
|
|
551
|
+
"""Union of all global attrs across datasets."""
|
|
552
|
+
merged = {}
|
|
553
|
+
if order == "last":
|
|
554
|
+
it = ds_list
|
|
555
|
+
else: # 'first'
|
|
556
|
+
it = reversed(ds_list)
|
|
557
|
+
for ds in it:
|
|
558
|
+
if hasattr(ds, "attrs") and ds.attrs:
|
|
559
|
+
merged.update(dict(ds.attrs))
|
|
560
|
+
return merged
|
|
561
|
+
|
|
562
|
+
def _merge_var_attrs(
|
|
563
|
+
self, varname: str, ds_list: List[xr.Dataset], order: str = "last"
|
|
564
|
+
) -> Dict[str, Any]:
|
|
565
|
+
"""Union of attrs for a given variable across datasets."""
|
|
566
|
+
merged = {}
|
|
567
|
+
if order == "last":
|
|
568
|
+
it = ds_list
|
|
569
|
+
else:
|
|
570
|
+
it = reversed(ds_list)
|
|
571
|
+
for ds in it:
|
|
572
|
+
if varname in ds and getattr(ds[varname], "attrs", None):
|
|
573
|
+
merged.update(dict(ds[varname].attrs))
|
|
574
|
+
return merged
|
|
575
|
+
|
|
576
|
+
def _create_combined_dataset(
|
|
577
|
+
self,
|
|
578
|
+
datasets_interp: List[xr.Dataset],
|
|
579
|
+
time_grid: np.ndarray, # noqa: ARG002 — reserved for future use (currently inferred from datasets_interp)
|
|
580
|
+
vars_to_keep: List[str] = None,
|
|
581
|
+
) -> xr.Dataset:
|
|
582
|
+
"""Combine interpolated datasets into single dataset with N_LEVELS dimension."""
|
|
583
|
+
if vars_to_keep is None:
|
|
584
|
+
vars_to_keep = [
|
|
585
|
+
"temperature",
|
|
586
|
+
"salinity",
|
|
587
|
+
"conductivity",
|
|
588
|
+
"pressure",
|
|
589
|
+
"u_velocity",
|
|
590
|
+
"v_velocity",
|
|
591
|
+
]
|
|
592
|
+
|
|
593
|
+
if not datasets_interp:
|
|
594
|
+
raise ValueError("No interpolated datasets provided") # noqa: TRY003
|
|
595
|
+
|
|
596
|
+
time_coord = datasets_interp[0]["time"]
|
|
597
|
+
n_levels = len(datasets_interp)
|
|
598
|
+
|
|
599
|
+
# Helper functions
|
|
600
|
+
def stacked_or_nan(var: str) -> np.ndarray:
|
|
601
|
+
"""Stack variable across all datasets, filling with NaN if missing."""
|
|
602
|
+
arrs = []
|
|
603
|
+
for ds in datasets_interp:
|
|
604
|
+
if var in ds:
|
|
605
|
+
arrs.append(ds[var].values)
|
|
606
|
+
else:
|
|
607
|
+
arrs.append(np.full(time_coord.shape, np.nan, dtype=float))
|
|
608
|
+
return np.stack(arrs, axis=-1) # (time, N_LEVELS)
|
|
609
|
+
|
|
610
|
+
# Create combined data variables
|
|
611
|
+
combined_data = {}
|
|
612
|
+
for var in vars_to_keep:
|
|
613
|
+
# Check if any dataset has this variable
|
|
614
|
+
if not any(var in ds for ds in datasets_interp):
|
|
615
|
+
self._log_print(f"Variable '{var}' not found in any dataset, skipping")
|
|
616
|
+
continue
|
|
617
|
+
|
|
618
|
+
stacked = stacked_or_nan(var)
|
|
619
|
+
var_attrs = self._merge_var_attrs(var, datasets_interp, order="last")
|
|
620
|
+
combined_data[var] = xr.DataArray(
|
|
621
|
+
stacked,
|
|
622
|
+
dims=("time", "N_LEVELS"),
|
|
623
|
+
coords={"time": time_coord, "N_LEVELS": np.arange(n_levels)},
|
|
624
|
+
attrs=var_attrs,
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
# Extract per-level metadata
|
|
628
|
+
depths, clock_offsets, serial_nums, instrument_types = [], [], [], []
|
|
629
|
+
for ds in datasets_interp:
|
|
630
|
+
depths.append(
|
|
631
|
+
float(ds["InstrDepth"].item()) if "InstrDepth" in ds else np.nan
|
|
632
|
+
)
|
|
633
|
+
serial_nums.append(
|
|
634
|
+
ds["serial_number"].item() if "serial_number" in ds else np.nan
|
|
635
|
+
)
|
|
636
|
+
instrument_types.append(
|
|
637
|
+
ds["instrument"].item() if "instrument" in ds else "unknown"
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
# Handle different clock offset variable names
|
|
641
|
+
if "clock_offset" in ds:
|
|
642
|
+
co = ds["clock_offset"].item()
|
|
643
|
+
elif "seconds_offset" in ds:
|
|
644
|
+
co = ds["seconds_offset"].item()
|
|
645
|
+
else:
|
|
646
|
+
co = 0
|
|
647
|
+
clock_offsets.append(int(np.rint(co)) if np.isfinite(co) else 0)
|
|
648
|
+
|
|
649
|
+
# Create combined dataset
|
|
650
|
+
combined_ds = xr.Dataset(
|
|
651
|
+
data_vars=combined_data,
|
|
652
|
+
coords={
|
|
653
|
+
"time": time_coord,
|
|
654
|
+
"N_LEVELS": np.arange(n_levels),
|
|
655
|
+
"clock_offset": ("N_LEVELS", np.asarray(clock_offsets)),
|
|
656
|
+
"serial_number": ("N_LEVELS", np.asarray(serial_nums)),
|
|
657
|
+
"nominal_depth": ("N_LEVELS", np.asarray(depths)),
|
|
658
|
+
"instrument": ("N_LEVELS", np.asarray(instrument_types)),
|
|
659
|
+
},
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
# Apply merged global attributes
|
|
663
|
+
combined_ds.attrs = self._merge_global_attrs(datasets_interp, order="last")
|
|
664
|
+
|
|
665
|
+
# Copy coordinate attributes
|
|
666
|
+
if "time" in datasets_interp[0].coords and datasets_interp[0].time.attrs:
|
|
667
|
+
combined_ds["time"].attrs = dict(datasets_interp[0].time.attrs)
|
|
668
|
+
|
|
669
|
+
return combined_ds
|
|
670
|
+
|
|
671
|
+
def _encode_instrument_as_flags(
|
|
672
|
+
self,
|
|
673
|
+
ds: xr.Dataset,
|
|
674
|
+
var_name: str = "instrument",
|
|
675
|
+
out_name: str = "instrument_id",
|
|
676
|
+
) -> xr.Dataset:
|
|
677
|
+
"""Encode instrument names as integer flags for NetCDF compatibility."""
|
|
678
|
+
if var_name not in ds:
|
|
679
|
+
return ds
|
|
680
|
+
|
|
681
|
+
names = [str(v) for v in np.asarray(ds[var_name].values)]
|
|
682
|
+
uniq = []
|
|
683
|
+
for n in names:
|
|
684
|
+
if n not in uniq:
|
|
685
|
+
uniq.append(n)
|
|
686
|
+
|
|
687
|
+
codes = {name: i + 1 for i, name in enumerate(uniq)} # start at 1
|
|
688
|
+
id_arr = np.array([codes[n] for n in names], dtype=np.int16)
|
|
689
|
+
|
|
690
|
+
ds = ds.copy()
|
|
691
|
+
ds[out_name] = (("N_LEVELS",), id_arr)
|
|
692
|
+
|
|
693
|
+
# CF style metadata
|
|
694
|
+
ds[out_name].attrs.update(
|
|
695
|
+
{
|
|
696
|
+
"standard_name": "instrument_id",
|
|
697
|
+
"long_name": "Instrument identifier (encoded)",
|
|
698
|
+
"flag_values": np.array(list(range(1, len(uniq) + 1)), dtype=np.int16),
|
|
699
|
+
"flag_meanings": " ".join(s.replace(" ", "_") for s in uniq),
|
|
700
|
+
"comment": f"Mapping: {codes}",
|
|
701
|
+
}
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
# Keep readable list at global attrs
|
|
705
|
+
ds.attrs["instrument_names"] = ", ".join(uniq)
|
|
706
|
+
|
|
707
|
+
# Drop the string variable
|
|
708
|
+
ds = ds.drop_vars(var_name)
|
|
709
|
+
|
|
710
|
+
return ds
|
|
711
|
+
|
|
712
|
+
def _get_netcdf_writer_params(self) -> Dict[str, Any]:
|
|
713
|
+
"""Get standard parameters for NetCDF writer."""
|
|
714
|
+
return {
|
|
715
|
+
"optimize": True,
|
|
716
|
+
"drop_derived": False,
|
|
717
|
+
"uint8_vars": [
|
|
718
|
+
"correlation_magnitude",
|
|
719
|
+
"echo_intensity",
|
|
720
|
+
"status",
|
|
721
|
+
"percent_good",
|
|
722
|
+
"bt_correlation",
|
|
723
|
+
"bt_amplitude",
|
|
724
|
+
"bt_percent_good",
|
|
725
|
+
],
|
|
726
|
+
"float32_vars": [
|
|
727
|
+
"eastward_velocity",
|
|
728
|
+
"northward_velocity",
|
|
729
|
+
"upward_velocity",
|
|
730
|
+
"temperature",
|
|
731
|
+
"salinity",
|
|
732
|
+
"pressure",
|
|
733
|
+
"pressure_std",
|
|
734
|
+
"depth",
|
|
735
|
+
"bt_velocity",
|
|
736
|
+
],
|
|
737
|
+
"chunk_time": 3600,
|
|
738
|
+
"complevel": 5,
|
|
739
|
+
"quantize": 3,
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
def process_mooring(
|
|
743
|
+
self,
|
|
744
|
+
mooring_name: str,
|
|
745
|
+
output_path: Optional[str] = None,
|
|
746
|
+
file_suffix: str = "_stage2",
|
|
747
|
+
vars_to_keep: List[str] = None,
|
|
748
|
+
filter_type: Optional[str] = None,
|
|
749
|
+
filter_params: Optional[Dict[str, Any]] = None,
|
|
750
|
+
) -> bool:
|
|
751
|
+
"""Process Step 1 for a single mooring: time gridding and optional filtering.
|
|
752
|
+
|
|
753
|
+
Args:
|
|
754
|
+
mooring_name: Name of the mooring to process
|
|
755
|
+
output_path: Optional custom output path
|
|
756
|
+
file_suffix: Suffix for input files ('_stage2' or '_raw')
|
|
757
|
+
vars_to_keep: List of variables to include in combined dataset
|
|
758
|
+
filter_type: Type of time filtering to apply ('lowpass', 'detide', 'bandpass')
|
|
759
|
+
filter_params: Parameters for filtering
|
|
760
|
+
|
|
761
|
+
Returns:
|
|
762
|
+
bool: True if processing completed successfully
|
|
763
|
+
|
|
764
|
+
"""
|
|
765
|
+
# Set up paths
|
|
766
|
+
if output_path is None:
|
|
767
|
+
proc_dir = self.base_dir / "moor" / "proc" / mooring_name
|
|
768
|
+
else:
|
|
769
|
+
proc_dir = Path(output_path) / mooring_name
|
|
770
|
+
|
|
771
|
+
if not proc_dir.exists():
|
|
772
|
+
print(f"ERROR: Processing directory not found: {proc_dir}")
|
|
773
|
+
return False
|
|
774
|
+
|
|
775
|
+
# Set up logging
|
|
776
|
+
self._setup_logging(mooring_name, proc_dir)
|
|
777
|
+
self._log_print(
|
|
778
|
+
f"Starting Step 1 (time gridding) processing for mooring: {mooring_name}"
|
|
779
|
+
)
|
|
780
|
+
self._log_print(f"Using files with suffix: {file_suffix}")
|
|
781
|
+
|
|
782
|
+
if filter_type:
|
|
783
|
+
self._log_print(f"Filtering requested: {filter_type}")
|
|
784
|
+
if filter_params:
|
|
785
|
+
self._log_print(f"Filter parameters: {filter_params}")
|
|
786
|
+
|
|
787
|
+
# Load configuration
|
|
788
|
+
config_file = proc_dir / f"{mooring_name}.mooring.yaml"
|
|
789
|
+
if not config_file.exists():
|
|
790
|
+
self._log_print(f"ERROR: Configuration file not found: {config_file}")
|
|
791
|
+
return False
|
|
792
|
+
|
|
793
|
+
try:
|
|
794
|
+
mooring_config = self._load_mooring_config(config_file)
|
|
795
|
+
except Exception as e: # noqa: BLE001 — config parse failure must not crash mooring
|
|
796
|
+
self._log_print(f"ERROR: Failed to load configuration: {e}")
|
|
797
|
+
return False
|
|
798
|
+
|
|
799
|
+
# Load instrument datasets
|
|
800
|
+
datasets = self._load_instrument_datasets(mooring_config, proc_dir, file_suffix)
|
|
801
|
+
|
|
802
|
+
if not datasets:
|
|
803
|
+
self._log_print(f"ERROR: No valid datasets found for {mooring_name}")
|
|
804
|
+
return False
|
|
805
|
+
|
|
806
|
+
self._log_print(f"Loaded {len(datasets)} instrument datasets")
|
|
807
|
+
|
|
808
|
+
try:
|
|
809
|
+
# STEP 1: Apply filtering to individual instrument records (BEFORE interpolation)
|
|
810
|
+
if filter_type:
|
|
811
|
+
self._log_print("")
|
|
812
|
+
self._log_print("APPLYING TIME FILTERING TO INDIVIDUAL INSTRUMENTS:")
|
|
813
|
+
datasets_filtered = []
|
|
814
|
+
for ds in datasets:
|
|
815
|
+
ds_filtered = self._apply_time_filtering_single(
|
|
816
|
+
ds, filter_type, filter_params
|
|
817
|
+
)
|
|
818
|
+
datasets_filtered.append(ds_filtered)
|
|
819
|
+
self._log_print("Completed filtering for all instruments")
|
|
820
|
+
else:
|
|
821
|
+
datasets_filtered = datasets
|
|
822
|
+
|
|
823
|
+
# STEP 2: Analyze timing and create common grid
|
|
824
|
+
time_grid, start_time, end_time = self._analyze_timing_info(
|
|
825
|
+
datasets_filtered
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
# STEP 3: Interpolate filtered datasets onto common grid
|
|
829
|
+
self._log_print("INTERPOLATING FILTERED DATASETS ONTO COMMON GRID:")
|
|
830
|
+
datasets_interp = self._interpolate_datasets(datasets_filtered, time_grid)
|
|
831
|
+
|
|
832
|
+
if not datasets_interp:
|
|
833
|
+
self._log_print("ERROR: No datasets could be interpolated")
|
|
834
|
+
return False
|
|
835
|
+
|
|
836
|
+
# STEP 4: Combine into single dataset
|
|
837
|
+
combined_ds = self._create_combined_dataset(
|
|
838
|
+
datasets_interp, time_grid, vars_to_keep
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
# STEP 5: Encode instrument names as flags
|
|
842
|
+
ds_to_save = self._encode_instrument_as_flags(combined_ds)
|
|
843
|
+
|
|
844
|
+
# Write output file
|
|
845
|
+
filter_suffix = f"_{filter_type}" if filter_type else ""
|
|
846
|
+
output_filename = f"{mooring_name}_mooring{file_suffix}{filter_suffix}.nc"
|
|
847
|
+
output_filepath = proc_dir / output_filename
|
|
848
|
+
|
|
849
|
+
writer = NetCdfWriter(ds_to_save)
|
|
850
|
+
writer_params = self._get_netcdf_writer_params()
|
|
851
|
+
writer.write(str(output_filepath), **writer_params)
|
|
852
|
+
|
|
853
|
+
self._log_print(
|
|
854
|
+
f"Successfully wrote time-gridded dataset: {output_filepath}"
|
|
855
|
+
)
|
|
856
|
+
self._log_print(f"Combined dataset shape: {dict(ds_to_save.sizes)}")
|
|
857
|
+
self._log_print(f"Variables: {list(ds_to_save.data_vars)}")
|
|
858
|
+
|
|
859
|
+
except Exception as e: # noqa: BLE001 — log and report False; mooring run continues
|
|
860
|
+
self._log_print(f"ERROR during time gridding processing: {e}")
|
|
861
|
+
return False
|
|
862
|
+
return True
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def time_gridding_mooring(
|
|
866
|
+
mooring_name: str,
|
|
867
|
+
basedir: str,
|
|
868
|
+
output_path: Optional[str] = None,
|
|
869
|
+
file_suffix: str = "_stage2",
|
|
870
|
+
filter_type: Optional[str] = None,
|
|
871
|
+
filter_params: Optional[Dict[str, Any]] = None,
|
|
872
|
+
) -> bool:
|
|
873
|
+
"""Process Step 1 for a single mooring (convenience function).
|
|
874
|
+
|
|
875
|
+
Args:
|
|
876
|
+
mooring_name: Name of the mooring to process
|
|
877
|
+
basedir: Base directory containing the data
|
|
878
|
+
output_path: Optional output path override
|
|
879
|
+
file_suffix: Suffix for input files ('_stage2' or '_raw')
|
|
880
|
+
filter_type: Optional time filtering to apply ('lowpass', 'detide', 'bandpass')
|
|
881
|
+
filter_params: Optional parameters for filtering
|
|
882
|
+
|
|
883
|
+
Returns:
|
|
884
|
+
bool: True if processing completed successfully
|
|
885
|
+
|
|
886
|
+
"""
|
|
887
|
+
processor = TimeGriddingProcessor(basedir)
|
|
888
|
+
return processor.process_mooring(
|
|
889
|
+
mooring_name,
|
|
890
|
+
output_path,
|
|
891
|
+
file_suffix,
|
|
892
|
+
filter_type=filter_type,
|
|
893
|
+
filter_params=filter_params,
|
|
894
|
+
)
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def process_multiple_moorings_time_gridding(
|
|
898
|
+
mooring_list: List[str],
|
|
899
|
+
basedir: str,
|
|
900
|
+
file_suffix: str = "_stage2",
|
|
901
|
+
filter_type: Optional[str] = None,
|
|
902
|
+
filter_params: Optional[Dict[str, Any]] = None,
|
|
903
|
+
) -> Dict[str, bool]:
|
|
904
|
+
"""Process Step 1 for multiple moorings.
|
|
905
|
+
|
|
906
|
+
Args:
|
|
907
|
+
mooring_list: List of mooring names to process
|
|
908
|
+
basedir: Base directory containing the data
|
|
909
|
+
file_suffix: Suffix for input files ('_stage2' or '_raw')
|
|
910
|
+
filter_type: Optional time filtering to apply ('lowpass', 'detide', 'bandpass')
|
|
911
|
+
filter_params: Optional parameters for filtering
|
|
912
|
+
|
|
913
|
+
Returns:
|
|
914
|
+
Dict mapping mooring names to success status
|
|
915
|
+
|
|
916
|
+
"""
|
|
917
|
+
processor = TimeGriddingProcessor(basedir)
|
|
918
|
+
results = {}
|
|
919
|
+
|
|
920
|
+
for mooring_name in mooring_list:
|
|
921
|
+
print(f"\n{'=' * 50}")
|
|
922
|
+
print(f"Processing Step 1 (time gridding) for mooring {mooring_name}")
|
|
923
|
+
print(f"{'=' * 50}")
|
|
924
|
+
|
|
925
|
+
results[mooring_name] = processor.process_mooring(
|
|
926
|
+
mooring_name,
|
|
927
|
+
file_suffix=file_suffix,
|
|
928
|
+
filter_type=filter_type,
|
|
929
|
+
filter_params=filter_params,
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
return results
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
# Example usage
|
|
936
|
+
if __name__ == "__main__":
|
|
937
|
+
# Your mooring list
|
|
938
|
+
moorlist = ["dsE_1_2018"]
|
|
939
|
+
|
|
940
|
+
basedir = "/Users/eddifying/Dropbox/data/ifmro_mixsed/ds_data_eleanor/"
|
|
941
|
+
|
|
942
|
+
# Process all moorings without filtering
|
|
943
|
+
results = process_multiple_moorings_time_gridding(moorlist, basedir)
|
|
944
|
+
|
|
945
|
+
# Example: Process with low-pass filtering (RAPID-style de-tiding)
|
|
946
|
+
# results = process_multiple_moorings_time_gridding(
|
|
947
|
+
# moorlist, basedir,
|
|
948
|
+
# filter_type='lowpass',
|
|
949
|
+
# filter_params={'cutoff_days': 2.0, 'order': 6}
|
|
950
|
+
# )
|
|
951
|
+
|
|
952
|
+
# Print summary
|
|
953
|
+
print(f"\n{'=' * 50}")
|
|
954
|
+
print("STEP 1 (TIME GRIDDING) PROCESSING SUMMARY")
|
|
955
|
+
print(f"{'=' * 50}")
|
|
956
|
+
for mooring, success in results.items():
|
|
957
|
+
status = "SUCCESS" if success else "FAILED"
|
|
958
|
+
print(f"{mooring}: {status}")
|