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
oceanarray/cli.py ADDED
@@ -0,0 +1,1343 @@
1
+ """Command-line interface for oceanarray processing."""
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+ from .utilities import _status
7
+
8
+
9
+ def _resolve_basedir(basedir: str) -> str:
10
+ """Strip trailing 'moor' component if user passed data/moor/ instead of data/."""
11
+ p = Path(basedir)
12
+ if p.name == "moor":
13
+ return str(p.parent)
14
+ return str(p)
15
+
16
+
17
+ def _get_proc_root(basedir: str) -> Path:
18
+ """Return basedir/proc, falling back to basedir/moor/proc for legacy layouts."""
19
+ base = Path(basedir)
20
+ proc = base / "proc"
21
+ if proc.is_dir():
22
+ return proc
23
+ legacy = base / "moor" / "proc"
24
+ return legacy if legacy.is_dir() else proc
25
+
26
+
27
+ def _parse_dirs(
28
+ args: "argparse.Namespace",
29
+ ) -> "tuple[Path | None, Path, bool]":
30
+ """Resolve raw and proc directories from CLI args.
31
+
32
+ Returns
33
+ -------
34
+ raw_dir : Path or None
35
+ Cruise-level raw directory (``--raw-dir``). None in legacy mode.
36
+ proc_root : Path
37
+ Cruise-level processed output directory.
38
+ legacy : bool
39
+ True when ``--basedir`` was used (deprecated path).
40
+
41
+ """
42
+ import warnings
43
+
44
+ basedir = getattr(args, "basedir", None)
45
+ raw_dir = getattr(args, "raw_dir", None)
46
+ proc_dir = getattr(args, "proc_dir", None)
47
+
48
+ if raw_dir or proc_dir:
49
+ if not proc_dir:
50
+ raise SystemExit("ERROR: --proc-dir is required when --raw-dir is given.") # noqa: TRY003
51
+ return Path(raw_dir) if raw_dir else None, Path(proc_dir), False
52
+
53
+ if not basedir:
54
+ raise SystemExit( # noqa: TRY003
55
+ "ERROR: supply either --raw-dir + --proc-dir (recommended) "
56
+ "or --basedir (deprecated)."
57
+ )
58
+ basedir = _resolve_basedir(basedir)
59
+ warnings.warn(
60
+ "--basedir is deprecated; switch to --raw-dir and --proc-dir. "
61
+ "See MIGRATION-BASEDIR.md at the repository root.",
62
+ DeprecationWarning,
63
+ stacklevel=3,
64
+ )
65
+ return None, _get_proc_root(basedir), True
66
+
67
+
68
+ def _print_report(basedir: "str | None", mooring: str, legacy: bool = True) -> None:
69
+ """Print a per-instrument summary of stage1/stage2/stage3 NetCDF files for a mooring.
70
+
71
+ For each instrument, reports: serial number, record count (raw from stage1 vs
72
+ processed from stage2/stage3), time span, nominal depth, sampling interval, and
73
+ which geophysical variables are present (temperature, salinity, velocity, etc.).
74
+ """
75
+ import datetime
76
+ import numpy as np
77
+ import xarray as xr
78
+
79
+ if legacy and basedir is not None:
80
+ proc_dir = _get_proc_root(basedir) / mooring
81
+ else:
82
+ proc_dir = Path(basedir) if basedir else Path(".")
83
+
84
+ # Collect all processed files; prefer _stage3 > _use as the "best" file per serial
85
+ use_files = sorted(proc_dir.rglob("*_stage2.nc"))
86
+ stage3_files = {
87
+ f.name.replace("_stage3.nc", ""): f for f in proc_dir.rglob("*_stage3.nc")
88
+ }
89
+
90
+ if not use_files and not stage3_files:
91
+ print(" No processed files found.")
92
+ return
93
+
94
+ by_instrument: dict = {}
95
+ for nc in use_files:
96
+ instrument = nc.parent.name
97
+ by_instrument.setdefault(instrument, []).append(nc)
98
+
99
+ for instrument, files in sorted(by_instrument.items()):
100
+ print(f"\n {instrument}")
101
+ for nc in sorted(files):
102
+ # Use _stage3.nc for reporting if it exists
103
+ stem = nc.name.replace("_stage2.nc", "")
104
+ best_nc = stage3_files.get(stem, nc)
105
+ stage_label = "stage3" if best_nc != nc else "use"
106
+ try:
107
+ ds = xr.open_dataset(best_nc, decode_timedelta=False)
108
+ n_rec = len(ds.time)
109
+ t0 = str(ds.time.values[0])[:16].replace("T", " ")
110
+ t1 = str(ds.time.values[-1])[:16].replace("T", " ")
111
+ serial = ds["serial_number"].item() if "serial_number" in ds else "?"
112
+ depth = (
113
+ f"{ds['InstrDepth'].item():.0f} m" if "InstrDepth" in ds else "?"
114
+ )
115
+ vars_present = [
116
+ v
117
+ for v in (
118
+ "temperature",
119
+ "conductivity",
120
+ "pressure",
121
+ "east_velocity",
122
+ "north_velocity",
123
+ "up_velocity",
124
+ "velocity_beam1",
125
+ )
126
+ if v in ds.data_vars
127
+ ]
128
+ if len(ds.time) > 1:
129
+ dt_s = np.median(
130
+ np.diff(ds.time.values).astype("timedelta64[s]").astype(float)
131
+ )
132
+ interval = f"{dt_s:.0f} s"
133
+ else:
134
+ interval = "? s"
135
+ ds.close()
136
+
137
+ raw_nc = nc.with_name(nc.name.replace("_stage2.nc", "_stage1.nc"))
138
+ try:
139
+ if raw_nc.exists():
140
+ ds_raw = xr.open_dataset(raw_nc, decode_timedelta=False)
141
+ n_raw = len(ds_raw.time)
142
+ ds_raw.close()
143
+ counts = f"{n_raw:>7} raw → {n_rec:>7} {stage_label}"
144
+ else:
145
+ counts = f"{'?':>7} raw → {n_rec:>7} {stage_label}"
146
+ except Exception: # noqa: BLE001 — intentional broad catch at I/O boundary
147
+ counts = f"{'HDF ERR':>7} raw → {n_rec:>7} {stage_label}"
148
+
149
+ mtime = datetime.datetime.fromtimestamp(
150
+ best_nc.stat().st_mtime
151
+ ).strftime("%Y-%m-%d %H:%M")
152
+ print(
153
+ f" s/n {serial:<8} {counts} {t0} → {t1} depth: {depth} dt: {interval} processed: {mtime} vars: {', '.join(vars_present)}"
154
+ )
155
+ except Exception as e: # noqa: BLE001 — display helper; must not crash status output
156
+ print(f" {nc.name}: ERROR — {e}")
157
+
158
+
159
+ def cmd_process(args: argparse.Namespace) -> int:
160
+ """Convert raw instrument files to processed NetCDF for one mooring.
161
+
162
+ By default runs stage 1 (raw → CF-NetCDF) and stage 2 (trim to deployment
163
+ window + clock-drift correction). Pass ``--stage 1 2 3`` to also run
164
+ stage 3 (QC flags, pressure interpolation, beam → ENU velocity rotation).
165
+
166
+ Optional side effects:
167
+ - ``--report``: after processing, print a per-instrument record summary to
168
+ the console (record counts, time spans, variables present).
169
+ - ``--plot``: save a PNG overview plot for each instrument alongside the
170
+ stage2 NetCDF file in the proc directory.
171
+ """
172
+ from .stage1 import MooringProcessor
173
+ from .stage2 import Stage2Processor
174
+ from .stage3 import Stage3Processor
175
+
176
+ raw_dir, proc_root, legacy = _parse_dirs(args)
177
+ basedir = _resolve_basedir(args.basedir) if legacy else None
178
+
179
+ # If --stage was not explicitly set and --report is the only action, skip processing
180
+ stages = args.stage if (args.stage is not None or not args.report) else []
181
+ if stages is None:
182
+ stages = [1, 2]
183
+
184
+ overall_success = True
185
+
186
+ serials = args.serial or None
187
+
188
+ if 1 in stages:
189
+ _status("section", f"Stage 1: {args.mooring}")
190
+ if legacy:
191
+ proc = MooringProcessor(basedir)
192
+ else:
193
+ proc = MooringProcessor(raw_dir=str(raw_dir), proc_dir=str(proc_root))
194
+ ok = proc.process_mooring(args.mooring, serials=serials, force=args.force)
195
+ if not ok:
196
+ print("Stage 1 failed.")
197
+ overall_success = False
198
+
199
+ if 2 in stages:
200
+ _status("section", f"Stage 2: {args.mooring}")
201
+ if legacy:
202
+ s2 = Stage2Processor(basedir)
203
+ else:
204
+ s2 = Stage2Processor(proc_dir=str(proc_root))
205
+ ok = s2.process_mooring(args.mooring, serials=serials, force=args.force)
206
+ if not ok:
207
+ print("Stage 2 failed.")
208
+ overall_success = False
209
+
210
+ if 3 in stages:
211
+ dry = getattr(args, "dry_run", False)
212
+ print(
213
+ f"\n=== Stage 3: {args.mooring} (pressure interpolation)"
214
+ + (" — DRY RUN" if dry else "")
215
+ + " ==="
216
+ )
217
+ if legacy:
218
+ s3 = Stage3Processor(basedir)
219
+ else:
220
+ s3 = Stage3Processor(proc_dir=str(proc_root))
221
+ ok = s3.process_mooring(
222
+ args.mooring, serials=serials, force=args.force, dry_run=dry
223
+ )
224
+ if not ok:
225
+ print("Stage 3 failed.")
226
+ overall_success = False
227
+
228
+ if args.report:
229
+ _status("section", f"Record Summary: {args.mooring}")
230
+ _print_report(
231
+ basedir or str(proc_root / args.mooring), args.mooring, legacy=legacy
232
+ )
233
+
234
+ if args.plot:
235
+ import matplotlib
236
+
237
+ matplotlib.use("Agg")
238
+ import xarray as xr
239
+ from .plotters import plot_microcat_raw
240
+
241
+ from .plotters import plot_aquadopp_raw
242
+
243
+ _status("section", f"Plotting: {args.mooring}")
244
+ proc_dir = (
245
+ _get_proc_root(basedir) / args.mooring
246
+ if legacy
247
+ else proc_root / args.mooring
248
+ )
249
+
250
+ for nc in sorted((proc_dir / "microcat").glob("*_stage2.nc")):
251
+ ds = xr.open_dataset(nc, decode_timedelta=False)
252
+ plot_microcat_raw(ds, save_path=nc.with_suffix(".png"))
253
+ print(f"Saved: {nc.with_suffix('.png')}")
254
+
255
+ for nc in sorted((proc_dir / "aquadopp").glob("*_stage2.nc")):
256
+ ds = xr.open_dataset(nc, decode_timedelta=False)
257
+ plot_aquadopp_raw(ds, save_path=nc.with_suffix(".png"))
258
+ print(f"Saved: {nc.with_suffix('.png')}")
259
+
260
+ return 0 if overall_success else 1
261
+
262
+
263
+ def cmd_plot(args: argparse.Namespace) -> int:
264
+ """Generate a multi-instrument mooring overview plot.
265
+
266
+ Reads the best available processed NetCDF (stage3 preferred, falling back to
267
+ stage2) for all instruments and produces a scatter/line plot with a user-chosen
268
+ y-axis variable (default: pressure) and colour variable (default: temperature).
269
+
270
+ When ``--output`` is given the plot is saved to that filename in the proc
271
+ directory (or ``--output-dir`` if specified). Without ``--output``, the plot
272
+ is displayed interactively.
273
+ """
274
+ from pathlib import Path
275
+ from .plotters import plot_mooring_timeseries
276
+ from . import parameters as P
277
+
278
+ _, proc_root, _ = _parse_dirs(args)
279
+
280
+ if args.colormap:
281
+ P.DEFAULT_COLORMAP = args.colormap
282
+ if args.downsample:
283
+ P.DOWNSAMPLE_SECONDS = args.downsample
284
+
285
+ save_path = None
286
+ if args.output:
287
+ out_dir = Path(args.output_dir) if args.output_dir else proc_root / args.mooring
288
+ save_path = out_dir / args.output
289
+
290
+ try:
291
+ plot_mooring_timeseries(
292
+ proc_dir=proc_root,
293
+ mooring=args.mooring,
294
+ var_y=args.var_y,
295
+ var_color=args.var_color,
296
+ markersize=args.markersize,
297
+ save_path=save_path,
298
+ show=args.show or save_path is None,
299
+ )
300
+ except (FileNotFoundError, ValueError) as e:
301
+ print(f"ERROR: {e}")
302
+ return 1
303
+
304
+ if save_path:
305
+ print(f"Saved: {save_path}")
306
+ return 0
307
+
308
+
309
+ def cmd_report(args: argparse.Namespace) -> int:
310
+ """Generate HTML quality-control reports for a mooring or an array.
311
+
312
+ Without ``--array``: generates the mooring summary page (and optionally
313
+ ``--instruments``, ``--stack``, ``--grid`` pages) for a single mooring.
314
+
315
+ With ``--array``: the positional argument is treated as a path to an
316
+ ``*.array.yaml`` file and an array-level HTML index is generated linking
317
+ all mooring reports. Other report flags are ignored in array mode.
318
+ """
319
+ from pathlib import Path
320
+
321
+ _, proc_root, _ = _parse_dirs(args)
322
+
323
+ if getattr(args, "array", False):
324
+ from .report._array import generate_array_report
325
+
326
+ # Resolve the YAML path: try as-given first, then relative to proc_dir.
327
+ _yaml_path = Path(args.mooring)
328
+ if not _yaml_path.exists() and not _yaml_path.is_absolute():
329
+ _yaml_path = proc_root / args.mooring
330
+ _status("section", f"Array report: {_yaml_path.name}")
331
+ result = generate_array_report(
332
+ array_yaml_path=_yaml_path,
333
+ proc_dir=proc_root,
334
+ force=args.force,
335
+ )
336
+ return 0 if result else 1
337
+
338
+ from .report import MooringReport
339
+
340
+ raw_dir, proc_root, legacy = _parse_dirs(args)
341
+ basedir = _resolve_basedir(args.basedir) if legacy else None
342
+ _status("section", f"Report: {args.mooring}")
343
+ serials = getattr(args, "serial", None)
344
+ if legacy:
345
+ reporter = MooringReport(basedir)
346
+ else:
347
+ reporter = MooringReport(
348
+ proc_dir=str(proc_root),
349
+ raw_dir=str(raw_dir) if raw_dir else None,
350
+ )
351
+ all_reports = getattr(args, "all_reports", False)
352
+ result = reporter.generate(
353
+ args.mooring,
354
+ force=args.force,
355
+ outdir=getattr(args, "outdir", None),
356
+ serials=serials,
357
+ instruments=all_reports or args.instruments or bool(serials),
358
+ grid=all_reports or args.grid,
359
+ stack=all_reports or args.stack,
360
+ )
361
+ return 0 if result else 1
362
+
363
+
364
+ def cmd_stack(args: argparse.Namespace) -> int:
365
+ """Combine all instruments from a mooring onto a common time axis.
366
+
367
+ Reads ``{mooring}_{serial}_stage3.nc`` (falling back to ``_stage2.nc``) for
368
+ every instrument listed in the mooring YAML and resamples each time series to
369
+ a uniform sampling interval (default 60 s). Writes a single multi-instrument
370
+ file ``{mooring}_stack.nc`` in the proc directory.
371
+
372
+ Prerequisite: stage 1 and 2 (and ideally stage 3) must have completed for all
373
+ instruments before running this command.
374
+ """
375
+ from .mooring_level import MooringStacker
376
+
377
+ _, proc_root, legacy = _parse_dirs(args)
378
+ basedir = _resolve_basedir(args.basedir) if legacy else None
379
+ _status("section", f"Stack: {args.mooring}")
380
+ if legacy:
381
+ stacker = MooringStacker(basedir)
382
+ else:
383
+ stacker = MooringStacker(proc_dir=str(proc_root))
384
+ ok = stacker.stack(
385
+ args.mooring,
386
+ dt_seconds=args.dt,
387
+ force=args.force,
388
+ )
389
+ return 0 if ok else 1
390
+
391
+
392
+ def cmd_grid(args: argparse.Namespace) -> int:
393
+ """Vertically interpolate a mooring stack onto a uniform pressure grid.
394
+
395
+ Reads ``{mooring}_stack.nc`` (produced by ``oceanarray stack``) and
396
+ interpolates scalar variables onto a regular pressure axis between
397
+ ``--pmin`` and ``--pmax`` dbar at ``--dp`` dbar spacing. Writes
398
+ ``{mooring}_grid.nc`` in the proc directory.
399
+
400
+ Prerequisite: ``oceanarray stack`` must have completed successfully.
401
+ """
402
+ from .mooring_level import MooringGridder
403
+
404
+ _, proc_root, legacy = _parse_dirs(args)
405
+ basedir = _resolve_basedir(args.basedir) if legacy else None
406
+ _status("section", f"Grid: {args.mooring}")
407
+ gridder = (
408
+ MooringGridder(basedir) if legacy else MooringGridder(proc_dir=str(proc_root))
409
+ )
410
+ ok = gridder.grid(
411
+ args.mooring,
412
+ p_start=args.pmin,
413
+ p_end=args.pmax,
414
+ dp=args.dp,
415
+ force=args.force,
416
+ )
417
+ return 0 if ok else 1
418
+
419
+
420
+ def cmd_run(args: argparse.Namespace) -> int:
421
+ """Run the complete processing pipeline for one mooring.
422
+
423
+ Executes in order: stage 1 (raw → CF-NetCDF), stage 2 (trim + clock
424
+ correction), stage 3 (QC + velocity rotation), stack (common time axis),
425
+ grid (pressure interpolation), and all reports (summary, per-instrument,
426
+ stack, grid).
427
+
428
+ All steps run regardless of earlier failures. If any step fails the command
429
+ returns exit code 1 at the end, but subsequent steps are not skipped. Check
430
+ the log files in ``processing_logs/`` to diagnose partial failures.
431
+ """
432
+ import argparse as _ap
433
+
434
+ overall_ok = True
435
+ _dirs = dict(
436
+ basedir=getattr(args, "basedir", None),
437
+ raw_dir=getattr(args, "raw_dir", None),
438
+ proc_dir=getattr(args, "proc_dir", None),
439
+ )
440
+
441
+ process_args = _ap.Namespace(
442
+ mooring=args.mooring,
443
+ stage=[1, 2, 3],
444
+ force=args.force,
445
+ serial=args.serial,
446
+ report=False,
447
+ plot=False,
448
+ dry_run=False,
449
+ **_dirs,
450
+ )
451
+ if cmd_process(process_args) != 0:
452
+ overall_ok = False
453
+
454
+ stack_args = _ap.Namespace(
455
+ mooring=args.mooring,
456
+ dt=args.dt,
457
+ force=args.force,
458
+ **_dirs,
459
+ )
460
+ if cmd_stack(stack_args) != 0:
461
+ overall_ok = False
462
+
463
+ grid_args = _ap.Namespace(
464
+ mooring=args.mooring,
465
+ pmin=args.pmin,
466
+ pmax=args.pmax,
467
+ dp=args.dp,
468
+ force=args.force,
469
+ **_dirs,
470
+ )
471
+ if cmd_grid(grid_args) != 0:
472
+ overall_ok = False
473
+
474
+ report_args = _ap.Namespace(
475
+ mooring=args.mooring,
476
+ force=args.force,
477
+ outdir=None,
478
+ serial=args.serial or None,
479
+ instruments=True,
480
+ grid=True,
481
+ stack=True,
482
+ **_dirs,
483
+ )
484
+ if cmd_report(report_args) != 0:
485
+ overall_ok = False
486
+
487
+ return 0 if overall_ok else 1
488
+
489
+
490
+ def cmd_validate(args: argparse.Namespace) -> int:
491
+ """Validate one or more mooring YAML files."""
492
+ from .validation import print_validation_report
493
+
494
+ all_ok = True
495
+ for yaml_path in args.yaml:
496
+ ok = print_validation_report(yaml_path)
497
+ if not ok:
498
+ all_ok = False
499
+ print()
500
+
501
+ return 0 if all_ok else 1
502
+
503
+
504
+ def cmd_animate(args: argparse.Namespace) -> int:
505
+ """Write animated hodograph GIF(s) for Aquadopp current-meter instruments.
506
+
507
+ A hodograph plots the tip of the horizontal velocity vector (east vs north
508
+ component) over time, showing how current speed and direction evolve. The
509
+ animation highlights rotary motion such as tidal ellipses or eddy passages.
510
+
511
+ Requires ``east_velocity`` and ``north_velocity`` in the NetCDF file, which
512
+ means stage 3 (BEAM → ENU coordinate rotation) must have completed for the
513
+ target instrument. A low-pass filter can be applied via ``--lp-days`` to
514
+ separate sub-inertial (eddy/mean) flow from tidal and near-inertial signals.
515
+
516
+ Requires the Pillow package (``pip install Pillow``); will fail at import time
517
+ if Pillow is not installed.
518
+ """
519
+ import matplotlib
520
+
521
+ matplotlib.use("Agg")
522
+ import xarray as xr
523
+
524
+ from .plotters import animate_hodograph
525
+
526
+ _, proc_root, legacy = _parse_dirs(args)
527
+ basedir = _resolve_basedir(args.basedir) if legacy else None
528
+ proc_dir = (
529
+ _get_proc_root(basedir) / args.mooring if legacy else proc_root / args.mooring
530
+ )
531
+ serials = args.serial or []
532
+
533
+ # Build {stem: best_nc} — stage3 preferred over stage2
534
+ stage3_map = {
535
+ f.name.replace("_stage3.nc", ""): f
536
+ for f in sorted(proc_dir.rglob("*_stage3.nc"))
537
+ }
538
+
539
+ if serials:
540
+ candidates: list[tuple[str, Path]] = []
541
+ for sn in serials:
542
+ matches = [stem for stem in stage3_map if str(sn) in stem]
543
+ if matches:
544
+ candidates.extend((m, stage3_map[m]) for m in matches)
545
+ else:
546
+ stage2 = sorted(proc_dir.rglob(f"*{sn}*_stage2.nc"))
547
+ if stage2:
548
+ stem = stage2[0].name.replace("_stage2.nc", "")
549
+ candidates.append((stem, stage2[0]))
550
+ else:
551
+ print(
552
+ f" WARNING: no stage2/3 NC found for serial {sn} under {proc_dir}"
553
+ )
554
+ else:
555
+ # All stage3 files; fill in with stage2 where stage3 is absent
556
+ seen: set[str] = set(stage3_map)
557
+ candidates = list(stage3_map.items())
558
+ for nc in sorted(proc_dir.rglob("*_stage2.nc")):
559
+ stem = nc.name.replace("_stage2.nc", "")
560
+ if stem not in seen:
561
+ seen.add(stem)
562
+ candidates.append((stem, nc))
563
+
564
+ if not candidates:
565
+ print(f" No processed files found under {proc_dir}")
566
+ return 1
567
+
568
+ n_ok = 0
569
+ for stem, nc in candidates:
570
+ if args.output and len(candidates) == 1:
571
+ out = Path(args.output)
572
+ else:
573
+ out = nc.parent / f"{stem}_hodograph.gif"
574
+
575
+ _status("section", f"Animate: {nc.name}")
576
+ try:
577
+ import numpy as np
578
+ import pandas as pd
579
+
580
+ ds = xr.open_dataset(nc, decode_timedelta=False)
581
+ t = ds["time"].values
582
+ t0_str = pd.Timestamp(t[0]).strftime("%Y-%m-%d %H:%M")
583
+ t1_str = pd.Timestamp(t[-1]).strftime("%Y-%m-%d %H:%M")
584
+ n_days = float((t[-1] - t[0]) / np.timedelta64(1, "D"))
585
+ if len(t) > 1:
586
+ dt_s = float(np.median(np.diff(t) / np.timedelta64(1, "s")))
587
+ step_n = max(1, int(round(args.frame_hours * 3600.0 / dt_s)))
588
+ n_exp = int(np.ceil(len(t) / step_n))
589
+ else:
590
+ dt_s = float("nan")
591
+ n_exp = 1
592
+ print(
593
+ f" Data: {t0_str} → {t1_str} ({n_days:.1f} days, "
594
+ f"{len(t)} records, dt={dt_s:.0f} s)"
595
+ )
596
+ print(f" Frames: ~{n_exp} ({args.frame_hours:.0f}-h steps)")
597
+
598
+ result = animate_hodograph(
599
+ ds,
600
+ out,
601
+ u_var=args.u_var,
602
+ v_var=args.v_var,
603
+ lp_days=args.lp_days,
604
+ smooth_hours=args.smooth_hours,
605
+ frame_hours=args.frame_hours,
606
+ fps=args.fps,
607
+ dpi=args.dpi,
608
+ )
609
+ ds.close()
610
+ except Exception as exc: # noqa: BLE001 — I/O boundary; report and continue
611
+ print(f" ERROR: {exc}")
612
+ continue
613
+
614
+ if result is None:
615
+ print(
616
+ f" Skipped: {nc.name} — no {args.u_var}/{args.v_var} variables "
617
+ "or pillow writer unavailable"
618
+ )
619
+ else:
620
+ print(f" Saved: {result}")
621
+ n_ok += 1
622
+
623
+ return 0 if n_ok > 0 else 1
624
+
625
+
626
+ def cmd_list(args: argparse.Namespace) -> int:
627
+ """Print allowed instrument types and file_type values for mooring YAML files."""
628
+ from . import parameters as P
629
+
630
+ topic = getattr(args, "topic", None)
631
+
632
+ instr_col = max(len(k) for k in P.INSTRUMENT_FILE_TYPES) + 2
633
+ file_col = max(len(", ".join(v)) for v in P.INSTRUMENT_FILE_TYPES.values()) + 2
634
+
635
+ if topic in (None, "instruments", "file-types"):
636
+ print()
637
+ print(" Mooring YAML — allowed values for instrument: and file_type:")
638
+ print()
639
+ print(f" {'instrument':<{instr_col}} {'file_type (seasenselib reader)'}")
640
+ print(f" {'-' * instr_col} {'-' * file_col}")
641
+ for name in sorted(P.INSTRUMENT_FILE_TYPES):
642
+ readers = ", ".join(P.INSTRUMENT_FILE_TYPES[name])
643
+ print(f" {name:<{instr_col}} {readers}")
644
+
645
+ if P.EXTRA_FILE_TYPES:
646
+ print()
647
+ print(" Additional file_type values (specialist / deprecated):")
648
+ print(f" {'-' * instr_col} {'-' * file_col}")
649
+ for ft, note in sorted(P.EXTRA_FILE_TYPES.items()):
650
+ print(f" {ft:<{instr_col}} {note}")
651
+
652
+ print()
653
+ print(" Mooring YAML fields for each instrument entry:")
654
+ print(" instrument: name from the table above; stored as instrument_type")
655
+ print(" in the NetCDF output. Drives Aquadopp-specific plots,")
656
+ print(" declination correction, and tilt QC.")
657
+ print(" file_type: selects the seasenselib reader for stage 1.")
658
+ print(" header: path to the Nortek .hdr file (aquadopp only); required")
659
+ print(" for beam→XYZ coordinate transformation.")
660
+ print()
661
+
662
+ return 0
663
+
664
+
665
+ def cmd_logsheet(args: "argparse.Namespace") -> int:
666
+ """Generate fieldwork logsheet PDFs for a mooring or cal-dip cast.
667
+
668
+ Reads ``cruise_config.yaml`` and ``instruments.yaml`` from ``--config-dir``,
669
+ or supply paths directly via ``--logsheet-config`` and ``--inventory``.
670
+ Mooring YAMLs are located via ``--proc-dir`` or ``mooring_yaml_map`` in
671
+ ``cruise_config.yaml``.
672
+ """
673
+ import sys
674
+ from pathlib import Path
675
+ from .logsheet import (
676
+ build_caldip_setup,
677
+ build_caldip_download,
678
+ build_recovery,
679
+ build_mooring_download,
680
+ build_deployment_setup,
681
+ )
682
+ from .logsheet._config import resolve_config, load_yaml
683
+
684
+ config_dir = Path(args.logsheet_config_dir) if args.logsheet_config_dir else None
685
+ inventory_path = Path(args.logsheet_inventory) if args.logsheet_inventory else None
686
+ logsheet_config_path = Path(args.logsheet_config) if args.logsheet_config else None
687
+ output_dir = Path(args.logsheet_output_dir)
688
+ # proc_dir is optional for logsheets — caldip sheets don't need it
689
+ _pd = getattr(args, "proc_dir", None)
690
+ proc_root = Path(_pd) if _pd else None
691
+
692
+ cfg = resolve_config(
693
+ config_dir=config_dir,
694
+ inventory_path=inventory_path,
695
+ logsheet_config_path=logsheet_config_path,
696
+ output_dir=output_dir,
697
+ proc_dir=proc_root,
698
+ )
699
+ fmt = args.logsheet_format
700
+
701
+ if args.logsheet_all:
702
+ if not cfg.cruise_config_path.exists():
703
+ print(
704
+ "Error: --all requires cruise_config.yaml; "
705
+ "provide --logsheet-config or --config-dir",
706
+ file=sys.stderr,
707
+ )
708
+ return 1
709
+ cruise_cfg = load_yaml(cfg.cruise_config_path)
710
+ for cast_id in cruise_cfg.get("casts", {}):
711
+ _status("section", f"caldip-setup {cast_id}")
712
+ build_caldip_setup(cast_id, cfg, fmt=fmt)
713
+ _status("section", f"caldip-download {cast_id}")
714
+ build_caldip_download(cast_id, cfg, fmt=fmt)
715
+ for mooring in cruise_cfg.get("moorings_to_recover", []):
716
+ _status("section", f"mooring-download {mooring}")
717
+ build_mooring_download(mooring, cfg, fmt=fmt)
718
+ _status("section", f"mooring-recovery {mooring}")
719
+ build_recovery(mooring, cfg, fmt=fmt)
720
+ for mooring in cruise_cfg.get("moorings_to_deploy", []):
721
+ _status("section", f"mooring-setup {mooring}")
722
+ build_deployment_setup(mooring, cfg, fmt=fmt)
723
+ return 0
724
+
725
+ ltypes: list[str] = args.logsheet_type or []
726
+ if not ltypes:
727
+ print("Error: --type is required (or use --all)", file=sys.stderr)
728
+ return 1
729
+
730
+ # Resolve caldip YAML once for all caldip sheet types in this call.
731
+ caldip_yaml = None
732
+ cast_val = args.logsheet_cast or ""
733
+ if any(t in ("caldip-setup", "caldip-download") for t in ltypes):
734
+ if not cast_val:
735
+ print("Error: --cast is required for caldip sheet types", file=sys.stderr)
736
+ return 1
737
+ cast_path = Path(cast_val)
738
+ if cast_path.suffix.lower() == ".yaml":
739
+ if not cast_path.exists():
740
+ print(f"Error: caldip YAML not found: {cast_val}", file=sys.stderr)
741
+ return 1
742
+ caldip_yaml = load_yaml(cast_path)
743
+
744
+ for ltype in ltypes:
745
+ if ltype in ("caldip-setup", "caldip-download"):
746
+ if ltype == "caldip-setup":
747
+ build_caldip_setup(cast_val, cfg, fmt=fmt, caldip_yaml=caldip_yaml)
748
+ else:
749
+ build_caldip_download(cast_val, cfg, fmt=fmt, caldip_yaml=caldip_yaml)
750
+
751
+ elif ltype in ("mooring-download", "mooring-recovery", "mooring-setup"):
752
+ if not args.logsheet_mooring:
753
+ print(
754
+ f"Error: --mooring is required for --type {ltype}",
755
+ file=sys.stderr,
756
+ )
757
+ return 1
758
+ if ltype == "mooring-download":
759
+ build_mooring_download(args.logsheet_mooring, cfg, fmt=fmt)
760
+ elif ltype == "mooring-recovery":
761
+ build_recovery(args.logsheet_mooring, cfg, fmt=fmt)
762
+ else:
763
+ build_deployment_setup(args.logsheet_mooring, cfg, fmt=fmt)
764
+
765
+ return 0
766
+
767
+
768
+ def _add_dir_args(p: "argparse.ArgumentParser", raw_needed: bool = True) -> None:
769
+ """Add directory arguments to a subparser.
770
+
771
+ Adds ``--raw-dir`` / ``--proc-dir`` (new layout) and the deprecated
772
+ ``--basedir`` flag. When *raw_needed* is False (e.g. for ``stack``,
773
+ ``grid``, ``report``, ``animate`` which operate on already-processed NetCDF
774
+ files) ``--raw-dir`` is silently omitted and the argument defaults to None.
775
+ Stage 1 processing requires raw instrument files and therefore sets
776
+ *raw_needed=True*.
777
+
778
+ Parameters
779
+ ----------
780
+ p : argparse.ArgumentParser
781
+ The subparser to add arguments to.
782
+ raw_needed : bool
783
+ Whether to add ``--raw-dir``. Pass False for commands that do not
784
+ read raw instrument data files (stack, grid, report, animate).
785
+
786
+ """
787
+ grp = p.add_mutually_exclusive_group()
788
+ grp.add_argument(
789
+ "--basedir",
790
+ default=None,
791
+ metavar="DIR",
792
+ help="[DEPRECATED] Root data directory (contains proc/). "
793
+ "Use --raw-dir + --proc-dir instead.",
794
+ )
795
+ if raw_needed:
796
+ p.add_argument(
797
+ "--raw-dir",
798
+ default=None,
799
+ dest="raw_dir",
800
+ metavar="DIR",
801
+ help="Cruise-level raw data directory (pipeline appends /{mooring}/). "
802
+ "Required for stage 1.",
803
+ )
804
+ else:
805
+ p.set_defaults(raw_dir=None)
806
+ p.add_argument(
807
+ "--proc-dir",
808
+ default=None,
809
+ dest="proc_dir",
810
+ metavar="DIR",
811
+ help="Cruise-level processed output directory (pipeline appends /{mooring}/).",
812
+ )
813
+
814
+
815
+ def build_parser() -> argparse.ArgumentParser:
816
+ """Build and return the top-level argument parser for the oceanarray CLI."""
817
+ epilog = (
818
+ "Typical per-mooring workflow (new canonical layout):\n"
819
+ " oceanarray process MOORING --raw-dir /data/raw --proc-dir /data/proc --stage 1 2 3\n"
820
+ " oceanarray stack MOORING --proc-dir /data/proc\n"
821
+ " oceanarray grid MOORING --proc-dir /data/proc --dp 20\n"
822
+ " oceanarray report MOORING --raw-dir /data/raw --proc-dir /data/proc --instruments --stack --grid\n"
823
+ "\n"
824
+ "Or run all steps in one command:\n"
825
+ " oceanarray run MOORING --raw-dir /data/raw --proc-dir /data/proc --dp 20 --force\n"
826
+ "\n"
827
+ "Legacy (deprecated):\n"
828
+ " oceanarray run MOORING --basedir /data --dp 20 --force\n"
829
+ " See MIGRATION-BASEDIR.md for migration instructions.\n"
830
+ )
831
+ parser = argparse.ArgumentParser(
832
+ prog="oceanarray",
833
+ description="Oceanographic mooring data processing.",
834
+ epilog=epilog,
835
+ formatter_class=argparse.RawDescriptionHelpFormatter,
836
+ )
837
+ sub = parser.add_subparsers(dest="command", required=True)
838
+
839
+ p_process = sub.add_parser(
840
+ "process",
841
+ help="Run per-instrument processing stages 1-3 (raw→NetCDF, trim+clock, QC+ENU).",
842
+ description=(
843
+ "Run one or more per-instrument processing stages for a mooring.\n"
844
+ " Stage 1: read raw files → CF-NetCDF (_stage1.nc)\n"
845
+ " Stage 2: trim to deployment window + apply clock-drift correction (_stage2.nc)\n"
846
+ " Stage 3: QC flags, BEAM→ENU rotation, magnetic declination, derived vars (_stage3.nc)\n"
847
+ "Default when --stage is omitted: stages 1 and 2 only."
848
+ ),
849
+ formatter_class=argparse.RawDescriptionHelpFormatter,
850
+ )
851
+ p_process.add_argument("mooring", help="Mooring name, e.g. dsG3_1_2026")
852
+ _add_dir_args(p_process, raw_needed=True)
853
+ p_process.add_argument(
854
+ "--stage",
855
+ type=int,
856
+ nargs="+",
857
+ choices=[1, 2, 3],
858
+ default=None,
859
+ metavar="{1,2,3}",
860
+ help="Stage(s) to run (default: 1 2). "
861
+ "Stage 3 applies QC, coordinate rotation, and derived variables.",
862
+ )
863
+ p_process.add_argument(
864
+ "--plot",
865
+ action="store_true",
866
+ help="Generate plots of processed microcat data after stage 2",
867
+ )
868
+ p_process.add_argument(
869
+ "--report",
870
+ action="store_true",
871
+ help="Print a summary of processed records per instrument and serial number",
872
+ )
873
+ p_process.add_argument(
874
+ "--serial",
875
+ nargs="+",
876
+ metavar="SERIAL",
877
+ default=[],
878
+ help="Process only instrument(s) with these serial number(s), e.g. --serial 14321 400118",
879
+ )
880
+ p_process.add_argument(
881
+ "--force",
882
+ action="store_true",
883
+ help="Overwrite existing output files (both stages skip existing files by default)",
884
+ )
885
+ p_process.add_argument(
886
+ "-n",
887
+ "--dry-run",
888
+ action="store_true",
889
+ dest="dry_run",
890
+ help="Do not actually modify any files; show what would be done (stage 3 only)",
891
+ )
892
+ p_process.set_defaults(func=cmd_process)
893
+
894
+ p_report = sub.add_parser(
895
+ "report",
896
+ help="Generate a mooring recovery HTML report.",
897
+ )
898
+ p_report.add_argument("mooring", help="Mooring name, e.g. dsG3_1_2026")
899
+ _add_dir_args(p_report, raw_needed=True)
900
+ p_report.add_argument(
901
+ "-o",
902
+ "--output-dir",
903
+ default=None,
904
+ dest="outdir",
905
+ metavar="DIR",
906
+ help="Directory for the HTML report (default: proc/{mooring}/ inside basedir)",
907
+ )
908
+ p_report.add_argument(
909
+ "--force", action="store_true", help="Overwrite existing report"
910
+ )
911
+ p_report.add_argument(
912
+ "--instruments",
913
+ "-i",
914
+ action="store_true",
915
+ default=False,
916
+ help="Also generate per-instrument HTML pages (slow; skipped by default)",
917
+ )
918
+ p_report.add_argument(
919
+ "--grid",
920
+ "-g",
921
+ action="store_true",
922
+ default=False,
923
+ help="Also generate the gridded-data report page (requires _grid.nc)",
924
+ )
925
+ p_report.add_argument(
926
+ "--stack",
927
+ "-s",
928
+ action="store_true",
929
+ default=False,
930
+ help="Also generate the stacked-data report page with pressure and T time series (requires _stack.nc)",
931
+ )
932
+ p_report.add_argument(
933
+ "--serial",
934
+ nargs="+",
935
+ metavar="SN",
936
+ default=None,
937
+ help="Generate per-instrument page(s) for these serial number(s) only "
938
+ "(implies --instruments for the listed serials)",
939
+ )
940
+ p_report.add_argument(
941
+ "--all",
942
+ "-A",
943
+ action="store_true",
944
+ default=False,
945
+ dest="all_reports",
946
+ help="Generate all report pages: equivalent to --stack --grid --instruments",
947
+ )
948
+ p_report.add_argument(
949
+ "--array",
950
+ action="store_true",
951
+ default=False,
952
+ help="Treat the positional argument as a *.array.yaml path and generate an "
953
+ "array-level HTML index linking all mooring reports.",
954
+ )
955
+ p_report.set_defaults(func=cmd_report)
956
+
957
+ p_stack = sub.add_parser(
958
+ "stack",
959
+ help="Mooring-level step 1: interpolate all instruments onto a common time axis → _stack.nc",
960
+ description=(
961
+ "Reads all _stage3.nc (or _stage2.nc) files for a mooring and interpolates\n"
962
+ "every instrument onto a uniform time grid, producing {mooring}_stack.nc.\n"
963
+ "Run after 'oceanarray process --stage 1 2 3'."
964
+ ),
965
+ formatter_class=argparse.RawDescriptionHelpFormatter,
966
+ )
967
+ p_stack.add_argument("mooring", help="Mooring name, e.g. dsG3_1_2026")
968
+ _add_dir_args(p_stack, raw_needed=False)
969
+ p_stack.add_argument(
970
+ "--dt",
971
+ type=int,
972
+ default=60,
973
+ metavar="SECONDS",
974
+ help="Common time-grid interval in seconds (default: 60)",
975
+ )
976
+ p_stack.add_argument(
977
+ "--force", action="store_true", help="Overwrite existing output file"
978
+ )
979
+ p_stack.set_defaults(func=cmd_stack)
980
+
981
+ p_grid = sub.add_parser(
982
+ "grid",
983
+ help="Mooring-level step 2: vertically interpolate stack onto a pressure grid → _grid.nc",
984
+ description=(
985
+ "Reads {mooring}_stack.nc and interpolates temperature, salinity, and other\n"
986
+ "scalar fields onto a uniform pressure grid, producing {mooring}_grid.nc.\n"
987
+ "Run after 'oceanarray stack'.\n\n"
988
+ "Example:\n"
989
+ " oceanarray grid MOORING --proc-dir /data/proc --pmin 100 --pmax 2000 --dp 20"
990
+ ),
991
+ formatter_class=argparse.RawDescriptionHelpFormatter,
992
+ )
993
+ p_grid.add_argument("mooring", help="Mooring name, e.g. dsG3_1_2026")
994
+ _add_dir_args(p_grid, raw_needed=False)
995
+ p_grid.add_argument(
996
+ "--pmin",
997
+ type=float,
998
+ default=200.0,
999
+ metavar="DBAR",
1000
+ help="Shallowest pressure level (default: 200)",
1001
+ )
1002
+ p_grid.add_argument(
1003
+ "--pmax",
1004
+ type=float,
1005
+ default=1000.0,
1006
+ metavar="DBAR",
1007
+ help="Deepest pressure level (default: 1000)",
1008
+ )
1009
+ p_grid.add_argument(
1010
+ "--dp",
1011
+ type=float,
1012
+ default=20.0,
1013
+ metavar="DBAR",
1014
+ help="Pressure grid spacing in dbar (default: 20)",
1015
+ )
1016
+ p_grid.add_argument(
1017
+ "--force", action="store_true", help="Overwrite existing output file"
1018
+ )
1019
+ p_grid.set_defaults(func=cmd_grid)
1020
+
1021
+ p_run = sub.add_parser(
1022
+ "run",
1023
+ help="Full pipeline: stages 1-3, stack, grid, and all reports in one command.",
1024
+ )
1025
+ p_run.add_argument("mooring", help="Mooring name, e.g. dsG3_1_2026")
1026
+ _add_dir_args(p_run, raw_needed=True)
1027
+ p_run.add_argument(
1028
+ "--force", action="store_true", help="Overwrite existing files at every stage"
1029
+ )
1030
+ p_run.add_argument(
1031
+ "--serial",
1032
+ nargs="+",
1033
+ metavar="SN",
1034
+ default=[],
1035
+ help="Restrict to these serial number(s) for processing and per-instrument reports",
1036
+ )
1037
+ p_run.add_argument(
1038
+ "--dt",
1039
+ type=int,
1040
+ default=60,
1041
+ metavar="SECONDS",
1042
+ help="Stack time-grid interval in seconds (default: 60)",
1043
+ )
1044
+ p_run.add_argument(
1045
+ "--dp",
1046
+ type=float,
1047
+ default=20.0,
1048
+ metavar="DBAR",
1049
+ help="Pressure grid spacing in dbar (default: 20)",
1050
+ )
1051
+ p_run.add_argument(
1052
+ "--pmin",
1053
+ type=float,
1054
+ default=200.0,
1055
+ metavar="DBAR",
1056
+ help="Shallowest pressure level for grid (default: 200)",
1057
+ )
1058
+ p_run.add_argument(
1059
+ "--pmax",
1060
+ type=float,
1061
+ default=1000.0,
1062
+ metavar="DBAR",
1063
+ help="Deepest pressure level for grid (default: 1000)",
1064
+ )
1065
+ p_run.set_defaults(func=cmd_run)
1066
+
1067
+ p_validate = sub.add_parser(
1068
+ "validate",
1069
+ help="Validate mooring YAML configuration file(s).",
1070
+ description=(
1071
+ "Parse and validate one or more .mooring.yaml files.\n"
1072
+ "Checks instrument types, serial numbers, file_type values, HAB fields,\n"
1073
+ "deployment dates, and inline instrument conventions.\n"
1074
+ "Exits 0 if all files are valid, 1 if any file has errors or warnings."
1075
+ ),
1076
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1077
+ )
1078
+ p_validate.add_argument("yaml", nargs="+", help="Path(s) to .mooring.yaml file(s)")
1079
+ p_validate.set_defaults(func=cmd_validate)
1080
+
1081
+ p_plot = sub.add_parser("plot", help="Plot multi-instrument mooring overview.")
1082
+ p_plot.add_argument("mooring", help="Mooring name, e.g. dsG3_1_2026")
1083
+ _add_dir_args(p_plot, raw_needed=False)
1084
+ p_plot.add_argument(
1085
+ "--var_y",
1086
+ default="temperature",
1087
+ help="Variable on y-axis (default: temperature)",
1088
+ )
1089
+ p_plot.add_argument(
1090
+ "--var_color",
1091
+ default=None,
1092
+ help="Variable for scatter colour; omit for line plot",
1093
+ )
1094
+ p_plot.add_argument(
1095
+ "--colormap",
1096
+ default=None,
1097
+ help=f"Matplotlib colormap (default: {__import__('oceanarray.parameters', fromlist=['DEFAULT_COLORMAP']).DEFAULT_COLORMAP})",
1098
+ )
1099
+ p_plot.add_argument(
1100
+ "--markersize",
1101
+ type=float,
1102
+ default=None,
1103
+ metavar="PTS2",
1104
+ help="Scatter marker size in points² (default 4). Only used in scatter mode.",
1105
+ )
1106
+ p_plot.add_argument(
1107
+ "--downsample",
1108
+ type=int,
1109
+ default=None,
1110
+ metavar="SECONDS",
1111
+ help="Resample interval in seconds (default: 120)",
1112
+ )
1113
+ p_plot.add_argument(
1114
+ "--output",
1115
+ default=None,
1116
+ metavar="FILENAME",
1117
+ help="Base filename for saved figure (e.g. overview.png); "
1118
+ "combined with --output-dir if given",
1119
+ )
1120
+ p_plot.add_argument(
1121
+ "-o",
1122
+ "--output-dir",
1123
+ default=None,
1124
+ metavar="DIR",
1125
+ help="Directory for saved figure (default: mooring proc dir)",
1126
+ )
1127
+ p_plot.add_argument(
1128
+ "--show",
1129
+ action="store_true",
1130
+ help="Display the figure interactively (works alongside --output)",
1131
+ )
1132
+ p_plot.set_defaults(func=cmd_plot)
1133
+
1134
+ p_animate = sub.add_parser(
1135
+ "animate",
1136
+ help="Write an animated hodograph GIF for Aquadopp instrument(s).",
1137
+ )
1138
+ p_animate.add_argument("mooring", help="Mooring name, e.g. dsG2_1_2026")
1139
+ _add_dir_args(p_animate, raw_needed=False)
1140
+ p_animate.add_argument(
1141
+ "--serial",
1142
+ nargs="+",
1143
+ metavar="SN",
1144
+ default=[],
1145
+ help="Serial number(s) to animate (default: all instruments with velocity data)",
1146
+ )
1147
+ p_animate.add_argument(
1148
+ "-o",
1149
+ "--output",
1150
+ default=None,
1151
+ metavar="FILE",
1152
+ help="Output GIF path (only used when a single --serial is given; "
1153
+ "default: {stem}_hodograph.gif next to the NC file)",
1154
+ )
1155
+ p_animate.add_argument(
1156
+ "--u-var",
1157
+ default="east_velocity",
1158
+ dest="u_var",
1159
+ metavar="VAR",
1160
+ help="Eastward velocity variable name (default: east_velocity)",
1161
+ )
1162
+ p_animate.add_argument(
1163
+ "--v-var",
1164
+ default="north_velocity",
1165
+ dest="v_var",
1166
+ metavar="VAR",
1167
+ help="Northward velocity variable name (default: north_velocity)",
1168
+ )
1169
+ p_animate.add_argument(
1170
+ "--lp-days",
1171
+ type=float,
1172
+ default=4.0,
1173
+ dest="lp_days",
1174
+ metavar="DAYS",
1175
+ help="Low-pass window for eddy-component removal in days (default: 4.0)",
1176
+ )
1177
+ p_animate.add_argument(
1178
+ "--smooth-hours",
1179
+ type=float,
1180
+ default=3.0,
1181
+ dest="smooth_hours",
1182
+ metavar="HOURS",
1183
+ help="Tukey smoothing window in hours applied to both panels (default: 3.0)",
1184
+ )
1185
+ p_animate.add_argument(
1186
+ "--frame-hours",
1187
+ type=float,
1188
+ default=6.0,
1189
+ dest="frame_hours",
1190
+ metavar="HOURS",
1191
+ help="Time step between frames in hours (default: 6.0 → one frame per "
1192
+ "quarter-day of deployment)",
1193
+ )
1194
+ p_animate.add_argument(
1195
+ "--fps",
1196
+ type=int,
1197
+ default=20,
1198
+ metavar="FPS",
1199
+ help="Frames per second in the output GIF (default: 20)",
1200
+ )
1201
+ p_animate.add_argument(
1202
+ "--dpi",
1203
+ type=int,
1204
+ default=100,
1205
+ metavar="DPI",
1206
+ help="Resolution of each frame in dots per inch (default: 100)",
1207
+ )
1208
+ p_animate.set_defaults(func=cmd_animate)
1209
+
1210
+ p_list = sub.add_parser(
1211
+ "list",
1212
+ help="List allowed instrument types and file_type values for mooring YAML files.",
1213
+ )
1214
+ p_list.add_argument(
1215
+ "topic",
1216
+ nargs="?",
1217
+ choices=["instruments", "file-types"],
1218
+ default=None,
1219
+ help="Filter output: 'instruments' or 'file-types' (default: show both).",
1220
+ )
1221
+ p_list.set_defaults(func=cmd_list)
1222
+
1223
+ p_logsheet = sub.add_parser(
1224
+ "logsheet",
1225
+ help="Generate fieldwork logsheet PDFs (recovery, download, deployment, cal-dip).",
1226
+ description=(
1227
+ "Generate PDF or LaTeX logsheets for mooring fieldwork operations. "
1228
+ "Supply --config-dir (pointing to a directory with cruise_config.yaml "
1229
+ "and instruments.yaml), or pass --logsheet-config and --inventory directly. "
1230
+ "LaTeX templates and column definitions are bundled with the package."
1231
+ ),
1232
+ )
1233
+ p_logsheet.add_argument(
1234
+ "--type",
1235
+ dest="logsheet_type",
1236
+ metavar="TYPE",
1237
+ nargs="+",
1238
+ choices=[
1239
+ "caldip-setup",
1240
+ "caldip-download",
1241
+ "mooring-download",
1242
+ "mooring-recovery",
1243
+ "mooring-setup",
1244
+ ],
1245
+ help=(
1246
+ "Sheet type(s): caldip-setup, caldip-download, mooring-download, "
1247
+ "mooring-recovery, mooring-setup. Pass multiple values to generate "
1248
+ "several sheets in one call, e.g. --type caldip-setup caldip-download."
1249
+ ),
1250
+ )
1251
+ p_logsheet.add_argument(
1252
+ "--mooring",
1253
+ dest="logsheet_mooring",
1254
+ metavar="MOORING",
1255
+ help="Mooring name, e.g. dsG3_1_2026 (required for mooring sheet types).",
1256
+ )
1257
+ p_logsheet.add_argument(
1258
+ "--cast",
1259
+ dest="logsheet_cast",
1260
+ metavar="CAST",
1261
+ help="Cast ID, e.g. B1 (required for caldip sheet types).",
1262
+ )
1263
+ p_logsheet.add_argument(
1264
+ "--config-dir",
1265
+ dest="logsheet_config_dir",
1266
+ metavar="DIR",
1267
+ help=(
1268
+ "Directory containing cruise_config.yaml and instruments.yaml. "
1269
+ "Shorthand for --logsheet-config DIR/cruise_config.yaml "
1270
+ "--inventory DIR/instruments.yaml."
1271
+ ),
1272
+ )
1273
+ p_logsheet.add_argument(
1274
+ "--inventory",
1275
+ dest="logsheet_inventory",
1276
+ metavar="PATH",
1277
+ help="Path to instruments.yaml (or instrument_inventory.csv).",
1278
+ )
1279
+ p_logsheet.add_argument(
1280
+ "--logsheet-config",
1281
+ dest="logsheet_config",
1282
+ metavar="PATH",
1283
+ help=(
1284
+ "Path to cruise_config.yaml. Optional; paths/filenames left blank "
1285
+ "on the sheet if absent."
1286
+ ),
1287
+ )
1288
+ p_logsheet.add_argument(
1289
+ "--output-dir",
1290
+ dest="logsheet_output_dir",
1291
+ metavar="DIR",
1292
+ default="logsheets",
1293
+ help="Directory where PDFs are written (default: ./logsheets/).",
1294
+ )
1295
+ p_logsheet.add_argument(
1296
+ "--format",
1297
+ dest="logsheet_format",
1298
+ choices=["pdf", "tex"],
1299
+ default="pdf",
1300
+ help="Output format: pdf (compile via pdflatex) or tex (LaTeX source only).",
1301
+ )
1302
+ p_logsheet.add_argument(
1303
+ "--all",
1304
+ action="store_true",
1305
+ dest="logsheet_all",
1306
+ help="Generate all sheet types for every cast and mooring in cruise_config.yaml.",
1307
+ )
1308
+ _add_dir_args(p_logsheet, raw_needed=False)
1309
+ p_logsheet.set_defaults(func=cmd_logsheet)
1310
+
1311
+ return parser
1312
+
1313
+
1314
+ def main() -> None:
1315
+ """Entry point for the ``oceanarray`` command-line tool."""
1316
+ parser = build_parser()
1317
+ args, _unknown = parser.parse_known_args()
1318
+ if _unknown:
1319
+ _raw_dir_unknowns = []
1320
+ _other_unknowns = []
1321
+ _skip_next = False
1322
+ for _i, _tok in enumerate(_unknown):
1323
+ if _skip_next:
1324
+ _skip_next = False
1325
+ continue
1326
+ if _tok == "--raw-dir":
1327
+ _raw_dir_unknowns.append(_tok)
1328
+ if _i + 1 < len(_unknown) and not _unknown[_i + 1].startswith("--"):
1329
+ _raw_dir_unknowns.append(_unknown[_i + 1])
1330
+ _skip_next = True
1331
+ elif _tok.startswith("--raw-dir="):
1332
+ _raw_dir_unknowns.append(_tok)
1333
+ else:
1334
+ _other_unknowns.append(_tok)
1335
+ if _raw_dir_unknowns:
1336
+ print(
1337
+ f"oceanarray: WARNING: --raw-dir is not used by this subcommand "
1338
+ f"and will be ignored ({' '.join(_raw_dir_unknowns)})",
1339
+ file=sys.stderr,
1340
+ )
1341
+ if _other_unknowns:
1342
+ parser.error(f"unrecognized arguments: {' '.join(_other_unknowns)}")
1343
+ sys.exit(args.func(args))