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.
Files changed (76) hide show
  1. oceanarray/__init__.py +20 -0
  2. oceanarray/_version.py +1 -0
  3. oceanarray/cli.py +1343 -0
  4. oceanarray/clock_offset.py +675 -0
  5. oceanarray/config/OS1_sensor_attrs.yaml +49 -0
  6. oceanarray/config/OS1_var_names.yaml +5 -0
  7. oceanarray/config/OS1_vocab_attrs.yaml +84 -0
  8. oceanarray/config/legacy/project_RAPID.yaml +69 -0
  9. oceanarray/config/legacy/rodb_keys.txt +206 -0
  10. oceanarray/config/legacy/rodb_keys.yaml +1012 -0
  11. oceanarray/config/logging.yaml +28 -0
  12. oceanarray/legacy/__init__.py +47 -0
  13. oceanarray/legacy/caldip.py +753 -0
  14. oceanarray/legacy/convertOS.py +527 -0
  15. oceanarray/legacy/find_deployment.py +489 -0
  16. oceanarray/legacy/mooring_rodb.py +429 -0
  17. oceanarray/legacy/process_rodb.py +369 -0
  18. oceanarray/legacy/rodb.py +330 -0
  19. oceanarray/logger.py +192 -0
  20. oceanarray/logsheet/__init__.py +60 -0
  21. oceanarray/logsheet/_columns.py +97 -0
  22. oceanarray/logsheet/_config.py +245 -0
  23. oceanarray/logsheet/_firmware.py +43 -0
  24. oceanarray/logsheet/_latex.py +128 -0
  25. oceanarray/logsheet/_render.py +126 -0
  26. oceanarray/logsheet/builders/__init__.py +13 -0
  27. oceanarray/logsheet/builders/caldip_download.py +195 -0
  28. oceanarray/logsheet/builders/caldip_setup.py +188 -0
  29. oceanarray/logsheet/builders/deployment_setup.py +218 -0
  30. oceanarray/logsheet/builders/mooring_download.py +243 -0
  31. oceanarray/logsheet/builders/recovery.py +157 -0
  32. oceanarray/logsheet/data/column_defs.yaml +686 -0
  33. oceanarray/logsheet/data/latex_templates/_base_instrument.tex.j2 +42 -0
  34. oceanarray/logsheet/data/latex_templates/caldip_download.tex.j2 +47 -0
  35. oceanarray/logsheet/data/latex_templates/caldip_setup.tex.j2 +70 -0
  36. oceanarray/logsheet/data/latex_templates/deployment_setup.tex.j2 +97 -0
  37. oceanarray/logsheet/data/latex_templates/mooring_download.tex.j2 +54 -0
  38. oceanarray/logsheet/data/latex_templates/recovery.tex.j2 +103 -0
  39. oceanarray/mooring_level.py +1368 -0
  40. oceanarray/oceanarray.mplstyle +18 -0
  41. oceanarray/parameters.py +270 -0
  42. oceanarray/plotter.py +942 -0
  43. oceanarray/plotters/__init__.py +97 -0
  44. oceanarray/plotters/_animation.py +261 -0
  45. oceanarray/plotters/_current.py +705 -0
  46. oceanarray/plotters/_diagnostic.py +667 -0
  47. oceanarray/plotters/_helpers.py +57 -0
  48. oceanarray/plotters/_primitives.py +101 -0
  49. oceanarray/plotters/_section.py +16 -0
  50. oceanarray/plotters/_timeseries.py +11 -0
  51. oceanarray/rapid_interp.py +739 -0
  52. oceanarray/read_rbr_hex.py +541 -0
  53. oceanarray/readers.py +303 -0
  54. oceanarray/report/__init__.py +5 -0
  55. oceanarray/report/_array.py +386 -0
  56. oceanarray/report/_grid.py +539 -0
  57. oceanarray/report/_html_helpers.py +1004 -0
  58. oceanarray/report/_instrument.py +853 -0
  59. oceanarray/report/_mooring.py +1385 -0
  60. oceanarray/report/_plots.py +4284 -0
  61. oceanarray/report/_stack.py +941 -0
  62. oceanarray/stage1.py +1959 -0
  63. oceanarray/stage2.py +1160 -0
  64. oceanarray/stage3.py +2494 -0
  65. oceanarray/time_gridding.py +958 -0
  66. oceanarray/tools.py +400 -0
  67. oceanarray/transports.py +1 -0
  68. oceanarray/utilities.py +538 -0
  69. oceanarray/validation.py +389 -0
  70. oceanarray/writers.py +99 -0
  71. oceanarray-0.1.0.dist-info/METADATA +503 -0
  72. oceanarray-0.1.0.dist-info/RECORD +76 -0
  73. oceanarray-0.1.0.dist-info/WHEEL +5 -0
  74. oceanarray-0.1.0.dist-info/entry_points.txt +2 -0
  75. oceanarray-0.1.0.dist-info/licenses/LICENSE +21 -0
  76. 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}")