microstructure-tpw 0.3.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 (119) hide show
  1. microstructure_tpw-0.3.0.dist-info/METADATA +234 -0
  2. microstructure_tpw-0.3.0.dist-info/RECORD +119 -0
  3. microstructure_tpw-0.3.0.dist-info/WHEEL +5 -0
  4. microstructure_tpw-0.3.0.dist-info/entry_points.txt +7 -0
  5. microstructure_tpw-0.3.0.dist-info/licenses/LICENSE +674 -0
  6. microstructure_tpw-0.3.0.dist-info/top_level.txt +1 -0
  7. odas_tpw/__init__.py +18 -0
  8. odas_tpw/chi/__init__.py +48 -0
  9. odas_tpw/chi/batchelor.py +189 -0
  10. odas_tpw/chi/chi.py +857 -0
  11. odas_tpw/chi/fp07.py +649 -0
  12. odas_tpw/chi/l2_chi.py +153 -0
  13. odas_tpw/chi/l3_chi.py +388 -0
  14. odas_tpw/chi/l4_chi.py +391 -0
  15. odas_tpw/config_base.py +423 -0
  16. odas_tpw/perturb/__init__.py +18 -0
  17. odas_tpw/perturb/adcp.py +274 -0
  18. odas_tpw/perturb/atomic_io.py +47 -0
  19. odas_tpw/perturb/binning.py +761 -0
  20. odas_tpw/perturb/cli.py +591 -0
  21. odas_tpw/perturb/combo.py +346 -0
  22. odas_tpw/perturb/config.py +1046 -0
  23. odas_tpw/perturb/ctd.py +424 -0
  24. odas_tpw/perturb/diag/__init__.py +12 -0
  25. odas_tpw/perturb/diag/_common.py +140 -0
  26. odas_tpw/perturb/diag/chi.py +42 -0
  27. odas_tpw/perturb/diag/cli.py +67 -0
  28. odas_tpw/perturb/diag/data.py +662 -0
  29. odas_tpw/perturb/diag/epsilon.py +45 -0
  30. odas_tpw/perturb/diag/inspector.py +342 -0
  31. odas_tpw/perturb/diag/mixing.py +94 -0
  32. odas_tpw/perturb/diag/render.py +543 -0
  33. odas_tpw/perturb/discover.py +40 -0
  34. odas_tpw/perturb/fp07_cal.py +354 -0
  35. odas_tpw/perturb/gps.py +458 -0
  36. odas_tpw/perturb/hotel.py +572 -0
  37. odas_tpw/perturb/logging_setup.py +181 -0
  38. odas_tpw/perturb/merge.py +320 -0
  39. odas_tpw/perturb/mk_sections.py +310 -0
  40. odas_tpw/perturb/netcdf_schema.py +520 -0
  41. odas_tpw/perturb/pipeline.py +2654 -0
  42. odas_tpw/perturb/plot/__init__.py +15 -0
  43. odas_tpw/perturb/plot/cli.py +107 -0
  44. odas_tpw/perturb/plot/diagnostics.py +195 -0
  45. odas_tpw/perturb/plot/eps_chi.py +524 -0
  46. odas_tpw/perturb/plot/figure.py +679 -0
  47. odas_tpw/perturb/plot/gamma_scaling.py +1097 -0
  48. odas_tpw/perturb/plot/grid.py +112 -0
  49. odas_tpw/perturb/plot/layout.py +361 -0
  50. odas_tpw/perturb/plot/overview.py +547 -0
  51. odas_tpw/perturb/plot/presets/eps-chi.yaml +16 -0
  52. odas_tpw/perturb/plot/presets/profiles.yaml +32 -0
  53. odas_tpw/perturb/plot/presets/scalar.yaml +21 -0
  54. odas_tpw/perturb/plot/profiles.py +652 -0
  55. odas_tpw/perturb/plot/scalar.py +391 -0
  56. odas_tpw/perturb/plot/sections.py +506 -0
  57. odas_tpw/perturb/plot/xaxis.py +396 -0
  58. odas_tpw/perturb/qc_gate.py +177 -0
  59. odas_tpw/perturb/qc_rules.py +373 -0
  60. odas_tpw/perturb/resolve.py +306 -0
  61. odas_tpw/perturb/seawater.py +76 -0
  62. odas_tpw/perturb/speed.py +14 -0
  63. odas_tpw/perturb/trim.py +313 -0
  64. odas_tpw/processing/__init__.py +62 -0
  65. odas_tpw/processing/bottom.py +215 -0
  66. odas_tpw/processing/chi_combine.py +300 -0
  67. odas_tpw/processing/ct_align.py +172 -0
  68. odas_tpw/processing/epsilon_combine.py +210 -0
  69. odas_tpw/processing/mixing.py +630 -0
  70. odas_tpw/processing/thorpe.py +458 -0
  71. odas_tpw/processing/top_trim.py +274 -0
  72. odas_tpw/pyturb/__init__.py +3 -0
  73. odas_tpw/pyturb/__main__.py +5 -0
  74. odas_tpw/pyturb/_compat.py +204 -0
  75. odas_tpw/pyturb/_profind.py +124 -0
  76. odas_tpw/pyturb/bin.py +113 -0
  77. odas_tpw/pyturb/cli.py +175 -0
  78. odas_tpw/pyturb/eps.py +353 -0
  79. odas_tpw/pyturb/merge.py +101 -0
  80. odas_tpw/pyturb/p2nc.py +76 -0
  81. odas_tpw/rsi/__init__.py +31 -0
  82. odas_tpw/rsi/adapter.py +314 -0
  83. odas_tpw/rsi/bench.py +904 -0
  84. odas_tpw/rsi/binning.py +193 -0
  85. odas_tpw/rsi/channels.py +376 -0
  86. odas_tpw/rsi/chi_io.py +980 -0
  87. odas_tpw/rsi/cli.py +1411 -0
  88. odas_tpw/rsi/combine.py +120 -0
  89. odas_tpw/rsi/config.py +126 -0
  90. odas_tpw/rsi/config_patch.py +790 -0
  91. odas_tpw/rsi/convert.py +651 -0
  92. odas_tpw/rsi/deconvolve.py +119 -0
  93. odas_tpw/rsi/diss_look.py +343 -0
  94. odas_tpw/rsi/dissipation.py +669 -0
  95. odas_tpw/rsi/helpers.py +527 -0
  96. odas_tpw/rsi/mixing_look.py +509 -0
  97. odas_tpw/rsi/p_file.py +771 -0
  98. odas_tpw/rsi/pipeline.py +924 -0
  99. odas_tpw/rsi/profile.py +624 -0
  100. odas_tpw/rsi/quick_look.py +758 -0
  101. odas_tpw/rsi/sensor_inventory.py +684 -0
  102. odas_tpw/rsi/shear_noise.py +128 -0
  103. odas_tpw/rsi/speed.py +221 -0
  104. odas_tpw/rsi/vehicle.py +74 -0
  105. odas_tpw/rsi/viewer_base.py +1071 -0
  106. odas_tpw/rsi/window.py +459 -0
  107. odas_tpw/scor160/__init__.py +36 -0
  108. odas_tpw/scor160/cli.py +242 -0
  109. odas_tpw/scor160/compare.py +484 -0
  110. odas_tpw/scor160/despike.py +227 -0
  111. odas_tpw/scor160/goodman.py +288 -0
  112. odas_tpw/scor160/io.py +690 -0
  113. odas_tpw/scor160/l2.py +311 -0
  114. odas_tpw/scor160/l3.py +279 -0
  115. odas_tpw/scor160/l4.py +850 -0
  116. odas_tpw/scor160/nasmyth.py +208 -0
  117. odas_tpw/scor160/ocean.py +287 -0
  118. odas_tpw/scor160/profile.py +231 -0
  119. odas_tpw/scor160/spectral.py +482 -0
odas_tpw/rsi/cli.py ADDED
@@ -0,0 +1,1411 @@
1
+ # Mar-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """
3
+ CLI for rsi-tpw.
4
+
5
+ Main command:
6
+ rsi-tpw <subcommand> [options]
7
+
8
+ Subcommands:
9
+ info — Print summary of .p file(s)
10
+ cutp — Copy a short record range from a .p file for debugging
11
+ nc — Convert .p files to NetCDF
12
+ patch-config — Edit config fields in .p file(s), writing new files
13
+ patch-template — Scaffold a patch-config edit spec from a .p file
14
+ prof — Extract profiles from .p or full-record .nc files
15
+ eps — Compute epsilon (TKE dissipation) from any pipeline stage
16
+ chi — Compute chi (thermal variance dissipation) from any pipeline stage
17
+ pipeline — Run full processing pipeline (.p → profiles → epsilon → chi)
18
+ sensors — Inventory shear/FP07 sensors across a .p file tree
19
+ ql — Interactive quick-look viewer
20
+ dl — Interactive dissipation quality viewer
21
+ ml — Interactive mixing viewer
22
+ bench — Bench-test diagnostic (quick_bench + auto checklist)
23
+ init — Generate a template configuration file
24
+ """
25
+
26
+ import argparse
27
+ import os
28
+ import sys
29
+ from concurrent.futures import ProcessPoolExecutor, as_completed
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+
34
+ def _resolve_p_files(patterns: list[str]) -> list[Path]:
35
+ """Expand glob patterns and return list of .p file Paths."""
36
+ import glob as globmod
37
+
38
+ files = []
39
+ for pattern in patterns:
40
+ expanded = sorted(globmod.glob(pattern))
41
+ if not expanded:
42
+ print(f"Warning: no files match '{pattern}'", file=sys.stderr)
43
+ for f in expanded:
44
+ p = Path(f)
45
+ if p.is_file() and p.suffix.lower() == ".p":
46
+ files.append(p)
47
+ if not files:
48
+ print("Error: no .p files found", file=sys.stderr)
49
+ sys.exit(1)
50
+ return files
51
+
52
+
53
+ def _resolve_files(patterns: list[str], extensions: set[str] | None = None) -> list[Path]:
54
+ """Expand glob patterns and return list of matching file Paths.
55
+
56
+ Parameters
57
+ ----------
58
+ patterns : list of str
59
+ File paths or glob patterns.
60
+ extensions : set of str or None
61
+ Allowed file extensions (e.g. {'.p', '.nc'}). If None, accept all.
62
+ """
63
+ import glob as globmod
64
+
65
+ files = []
66
+ for pattern in patterns:
67
+ expanded = sorted(globmod.glob(pattern))
68
+ if not expanded:
69
+ print(f"Warning: no files match '{pattern}'", file=sys.stderr)
70
+ for f in expanded:
71
+ p = Path(f)
72
+ if p.is_file() and (extensions is None or p.suffix.lower() in extensions):
73
+ files.append(p)
74
+ if not files:
75
+ ext_str = ", ".join(extensions) if extensions else "any"
76
+ print(f"Error: no matching files found ({ext_str})", file=sys.stderr)
77
+ sys.exit(1)
78
+ return files
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Config integration helpers
83
+ # ---------------------------------------------------------------------------
84
+
85
+
86
+ def _load_file_config(args: argparse.Namespace) -> dict[str, Any]:
87
+ """Load config from the -c/--config flag, or return empty dict."""
88
+ if getattr(args, "config", None) is not None:
89
+ from odas_tpw.rsi.config import load_config
90
+
91
+ return load_config(args.config)
92
+ return {}
93
+
94
+
95
+ def _extract_cli_overrides(args: argparse.Namespace, section: str) -> dict[str, Any]:
96
+ """Extract CLI-provided overrides for a config section.
97
+
98
+ Only includes values that were explicitly specified on the command line
99
+ (i.e., not None). Handles arg-name -> config-key mapping and the
100
+ --no-goodman inversion.
101
+ """
102
+ if section == "profiles":
103
+ mapping = {
104
+ "P_min": "P_min",
105
+ "W_min": "W_min",
106
+ "direction": "direction",
107
+ "vehicle": "vehicle",
108
+ "min_duration": "min_duration",
109
+ }
110
+ elif section == "epsilon":
111
+ mapping = {
112
+ "fft_length": "fft_length",
113
+ "diss_length": "diss_length",
114
+ "overlap": "overlap",
115
+ "speed": "speed",
116
+ "direction": "direction",
117
+ "vehicle": "vehicle",
118
+ "f_AA": "f_AA",
119
+ "salinity": "salinity",
120
+ }
121
+ elif section == "chi":
122
+ mapping = {
123
+ "fft_length": "fft_length",
124
+ "diss_length": "diss_length",
125
+ "overlap": "overlap",
126
+ "speed": "speed",
127
+ "direction": "direction",
128
+ "vehicle": "vehicle",
129
+ "fp07_model": "fp07_model",
130
+ "f_AA": "f_AA",
131
+ "fit_method": "fit_method",
132
+ "spectrum_model": "spectrum_model",
133
+ "salinity": "salinity",
134
+ }
135
+ elif section == "epsilon_pipeline":
136
+ mapping = {
137
+ "eps_fft_length": "fft_length",
138
+ "direction": "direction",
139
+ "vehicle": "vehicle",
140
+ "f_AA": "f_AA",
141
+ "speed": "speed",
142
+ "salinity": "salinity",
143
+ }
144
+ elif section == "chi_pipeline":
145
+ mapping = {
146
+ "chi_fft_length": "fft_length",
147
+ "direction": "direction",
148
+ "vehicle": "vehicle",
149
+ "f_AA": "f_AA",
150
+ "fp07_model": "fp07_model",
151
+ "spectrum_model": "spectrum_model",
152
+ "speed": "speed",
153
+ "salinity": "salinity",
154
+ }
155
+ else:
156
+ return {}
157
+
158
+ overrides = {}
159
+ for attr, key in mapping.items():
160
+ val = getattr(args, attr, None)
161
+ if val is not None:
162
+ overrides[key] = val
163
+
164
+ # Handle --no-goodman (store_const: None=not specified, True=specified)
165
+ if section in ("epsilon", "epsilon_pipeline", "chi", "chi_pipeline"):
166
+ no_goodman = getattr(args, "no_goodman", None)
167
+ if no_goodman is True:
168
+ overrides["goodman"] = False
169
+
170
+ return overrides
171
+
172
+
173
+ def _merge_for_section(args: argparse.Namespace, section: str) -> dict[str, Any]:
174
+ """Load config file + CLI overrides and merge for the given section.
175
+
176
+ Returns the merged kwargs dict (None values stripped).
177
+ """
178
+ from odas_tpw.rsi.config import merge_config
179
+
180
+ file_config = _load_file_config(args)
181
+ # For pipeline pseudo-sections, map to the real config section name
182
+ real_section = section.replace("_pipeline", "")
183
+ file_values = file_config.get(real_section, {})
184
+ cli_overrides = _extract_cli_overrides(args, section)
185
+ return merge_config(real_section, file_values, cli_overrides)
186
+
187
+
188
+ def _setup_output_dir(
189
+ args: argparse.Namespace,
190
+ prefix: str,
191
+ section: str,
192
+ params: dict[str, Any],
193
+ upstream: list[tuple[str, dict[str, Any]]] | None = None,
194
+ ) -> Path:
195
+ """Resolve the sequential output directory, write signature file and config.yaml.
196
+
197
+ Parameters
198
+ ----------
199
+ upstream : list of (section, params) tuples, optional
200
+ Upstream sections to include in the hash for cumulative tracking.
201
+ """
202
+ from odas_tpw.rsi.config import resolve_output_dir, write_resolved_config
203
+
204
+ real_section = section.replace("_pipeline", "")
205
+ output_dir = resolve_output_dir(args.output, prefix, real_section, params, upstream=upstream)
206
+ write_resolved_config(output_dir, real_section, params, upstream=upstream)
207
+ return output_dir
208
+
209
+
210
+ # ---------------------------------------------------------------------------
211
+ # Subcommand implementations
212
+ # ---------------------------------------------------------------------------
213
+
214
+
215
+ def _cmd_nc(args: argparse.Namespace) -> None:
216
+ """Convert Rockland .p files to NetCDF4."""
217
+ from odas_tpw.rsi.convert import convert_all, p_to_L1
218
+
219
+ p_files = _resolve_p_files(args.files)
220
+
221
+ if len(p_files) == 1 and args.output and not Path(args.output).is_dir():
222
+ _, nc_path = p_to_L1(p_files[0], args.output)
223
+ print(f"Wrote {nc_path} ({nc_path.stat().st_size / 1e6:.1f} MB)")
224
+ else:
225
+ output_dir = Path(args.output) if args.output else None
226
+ convert_all(p_files, output_dir, jobs=args.jobs)
227
+
228
+
229
+ def _cmd_info(args: argparse.Namespace) -> None:
230
+ """Print summary information about .p file(s)."""
231
+ from odas_tpw.rsi.p_file import PFile
232
+
233
+ p_files = _resolve_p_files(args.files)
234
+ for i, pf_path in enumerate(p_files):
235
+ if i > 0:
236
+ print("\n" + "=" * 60 + "\n")
237
+ pf = PFile(pf_path)
238
+ pf.summary()
239
+
240
+
241
+ def _cmd_cutp(args: argparse.Namespace) -> None:
242
+ """Copy a contiguous data-record range from a .p file."""
243
+ from odas_tpw.rsi.p_file import extract_pfile_segment
244
+
245
+ try:
246
+ out = extract_pfile_segment(
247
+ args.file,
248
+ args.output,
249
+ start_record=args.start,
250
+ n_records=args.n_records,
251
+ overwrite=args.overwrite,
252
+ )
253
+ except FileExistsError:
254
+ print(f"Error: {args.output} exists; pass --force to replace it", file=sys.stderr)
255
+ sys.exit(1)
256
+ except (FileNotFoundError, OSError, ValueError) as e:
257
+ print(f"Error: {e}", file=sys.stderr)
258
+ sys.exit(1)
259
+
260
+ print(f"Wrote {args.n_records} record(s) to {out}")
261
+
262
+
263
+ def _cmd_init(args: argparse.Namespace) -> None:
264
+ """Generate a template configuration file."""
265
+ from odas_tpw.rsi.config import generate_template
266
+
267
+ path = Path(args.path)
268
+ if path.exists() and not args.force:
269
+ print(f"Error: {path} already exists (use --force to overwrite)", file=sys.stderr)
270
+ sys.exit(1)
271
+ generate_template(path)
272
+ print(f"Wrote template config to {path}")
273
+
274
+
275
+ def _cmd_patch_config(args: argparse.Namespace) -> None:
276
+ """Patch config fields in .p file(s), writing new files."""
277
+ from datetime import datetime
278
+
279
+ from ruamel.yaml import YAMLError
280
+
281
+ from odas_tpw.rsi.config_patch import load_edit_spec, patch_files
282
+
283
+ files = _resolve_p_files(args.files)
284
+ when = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
285
+ try:
286
+ spec = load_edit_spec(args.edits)
287
+ patch_files(
288
+ files,
289
+ args.out,
290
+ spec,
291
+ dry_run=args.dry_run,
292
+ add_keys=args.add_keys,
293
+ batch_cal=args.batch_cal,
294
+ when=when,
295
+ )
296
+ # YAMLError: load_edit_spec parses with ruamel, whose ParserError/ScannerError
297
+ # subclass YAMLError (not ValueError/OSError) and otherwise escape as a traceback.
298
+ except (FileNotFoundError, FileExistsError, ValueError, OSError, YAMLError) as e:
299
+ print(f"Error: {e}", file=sys.stderr)
300
+ sys.exit(1)
301
+
302
+
303
+ def _cmd_patch_template(args: argparse.Namespace) -> None:
304
+ """Scaffold a patch-config edit-spec template from a .p file."""
305
+ from odas_tpw.rsi.config_patch import scaffold_yaml
306
+
307
+ src = Path(args.file)
308
+ if not src.is_file():
309
+ print(f"Error: {src} not found", file=sys.stderr)
310
+ sys.exit(1)
311
+ try:
312
+ text = scaffold_yaml(src)
313
+ except (OSError, ValueError) as e:
314
+ print(f"Error: {e}", file=sys.stderr)
315
+ sys.exit(1)
316
+ if args.output:
317
+ out = Path(args.output)
318
+ if out.exists() and not args.force:
319
+ print(f"Error: {out} already exists (use --force to overwrite)", file=sys.stderr)
320
+ sys.exit(1)
321
+ out.write_text(text)
322
+ print(f"Wrote template to {out}")
323
+ else:
324
+ print(text, end="")
325
+
326
+
327
+ def _cmd_prof(args: argparse.Namespace) -> None:
328
+ """Extract profiles from .p or full-record .nc files."""
329
+ from odas_tpw.rsi.profile import extract_profiles
330
+
331
+ files = _resolve_files(args.files, {".p", ".nc"})
332
+
333
+ merged = _merge_for_section(args, "profiles")
334
+ output_dir = _setup_output_dir(args, "prof", "profiles", merged)
335
+ print(f"Output directory: {output_dir}")
336
+
337
+ for f in files:
338
+ print(f"{f.name}:")
339
+ extract_profiles(f, output_dir, **merged)
340
+
341
+
342
+ def _cmd_eps(args: argparse.Namespace) -> None:
343
+ """Compute epsilon (TKE dissipation rate) from any pipeline stage."""
344
+ from odas_tpw.rsi.dissipation import _compute_diss_one, compute_diss_file
345
+
346
+ files = _resolve_files(args.files, {".p", ".nc"})
347
+
348
+ merged = _merge_for_section(args, "epsilon")
349
+ output_dir = _setup_output_dir(args, "eps", "epsilon", merged)
350
+ print(f"Output directory: {output_dir}")
351
+
352
+ jobs = args.jobs
353
+ if jobs == 0:
354
+ jobs = os.cpu_count() or 1
355
+
356
+ if jobs == 1:
357
+ for f in files:
358
+ print(f"{f.name}:")
359
+ try:
360
+ compute_diss_file(f, output_dir, **merged)
361
+ except (OSError, ValueError, RuntimeError) as e:
362
+ print(f" ERROR: {e}")
363
+ else:
364
+ work = []
365
+ for f in files:
366
+ work.append((f, output_dir, merged))
367
+ print(f"Processing {len(work)} files with {jobs} workers")
368
+ with ProcessPoolExecutor(max_workers=jobs) as pool:
369
+ futures = {pool.submit(_compute_diss_one, w): w for w in work}
370
+ for future in as_completed(futures):
371
+ src, _, _ = futures[future]
372
+ try:
373
+ name, n_profiles = future.result()
374
+ print(f" {Path(name).name}: {n_profiles} profile(s)")
375
+ except (OSError, ValueError, RuntimeError) as e:
376
+ print(f" {src.name}: ERROR: {e}")
377
+
378
+
379
+ def _cmd_chi(args: argparse.Namespace) -> None:
380
+ """Compute chi (thermal variance dissipation rate) from any pipeline stage."""
381
+ from odas_tpw.rsi.chi_io import _compute_chi_one, compute_chi_file
382
+
383
+ files = _resolve_files(args.files, {".p", ".nc"})
384
+
385
+ merged = _merge_for_section(args, "chi")
386
+ output_dir = _setup_output_dir(args, "chi", "chi", merged)
387
+ print(f"Output directory: {output_dir}")
388
+
389
+ # Load epsilon datasets if Method 1
390
+ epsilon_dir = getattr(args, "epsilon_dir", None)
391
+
392
+ jobs = args.jobs
393
+ if jobs == 0:
394
+ jobs = os.cpu_count() or 1
395
+
396
+ if jobs == 1:
397
+ for f in files:
398
+ print(f"{f.name}:")
399
+
400
+ kw = dict(merged)
401
+ eps_ds = None
402
+ if epsilon_dir is not None:
403
+ from odas_tpw.rsi.chi_io import load_epsilon_dataset
404
+
405
+ eps_ds = load_epsilon_dataset(f, epsilon_dir)
406
+ if eps_ds is not None:
407
+ kw["epsilon_ds"] = eps_ds
408
+ else:
409
+ print(
410
+ f" Warning: no epsilon files for {f.stem} under "
411
+ f"{epsilon_dir} (searched eps_* subdirectories too); "
412
+ "using Method 2"
413
+ )
414
+
415
+ try:
416
+ compute_chi_file(f, output_dir, **kw)
417
+ except (OSError, ValueError, RuntimeError) as e:
418
+ print(f" ERROR: {e}")
419
+ finally:
420
+ if eps_ds is not None:
421
+ eps_ds.close()
422
+ else:
423
+ work = []
424
+ for f in files:
425
+ work.append((f, output_dir, merged, epsilon_dir))
426
+ print(f"Processing {len(work)} files with {jobs} workers")
427
+ with ProcessPoolExecutor(max_workers=jobs) as pool:
428
+ futures = {pool.submit(_compute_chi_one, w): w for w in work}
429
+ for future in as_completed(futures):
430
+ src = futures[future][0]
431
+ try:
432
+ name, n_profiles = future.result()
433
+ print(f" {Path(name).name}: {n_profiles} profile(s)")
434
+ except (OSError, ValueError, RuntimeError) as e:
435
+ print(f" {Path(src).name}: ERROR: {e}")
436
+
437
+
438
+ def _cmd_pipeline(args: argparse.Namespace) -> None:
439
+ """Run full processing pipeline: .p -> L2 -> L3 -> L4 -> chi -> binning."""
440
+ from odas_tpw.rsi.pipeline import run_pipeline
441
+
442
+ p_files = _resolve_p_files(args.files)
443
+ output_dir = Path(args.output)
444
+
445
+ # Gather parameters from CLI/config
446
+ eps_merged = _merge_for_section(args, "epsilon_pipeline")
447
+ chi_merged = _merge_for_section(args, "chi_pipeline")
448
+
449
+ kwargs = {
450
+ "direction": eps_merged.get("direction", "auto"),
451
+ "vehicle": eps_merged.get("vehicle"),
452
+ "speed": eps_merged.get("speed"),
453
+ "fft_length": eps_merged.get("fft_length", 1024),
454
+ "f_AA": eps_merged.get("f_AA", 98.0),
455
+ "salinity": eps_merged.get("salinity"),
456
+ "goodman": eps_merged.get("goodman", True),
457
+ "chi_fft_length": chi_merged.get("fft_length", 1024),
458
+ "fp07_model": chi_merged.get("fp07_model", "single_pole"),
459
+ "spectrum_model": chi_merged.get("spectrum_model", "kraichnan"),
460
+ }
461
+ # Remove None values
462
+ kwargs = {k: v for k, v in kwargs.items() if v is not None}
463
+
464
+ # Speed model (pipeline-level; default pressure / |dP/dt|).
465
+ if getattr(args, "speed_method", None):
466
+ kwargs["speed_method"] = args.speed_method
467
+ if getattr(args, "aoa", None) is not None:
468
+ kwargs["aoa_deg"] = args.aoa
469
+
470
+ run_pipeline(p_files, output_dir, **kwargs)
471
+
472
+
473
+ def _cmd_ql(args: argparse.Namespace) -> None:
474
+ """Interactive quick-look viewer."""
475
+ from odas_tpw.rsi.quick_look import quick_look
476
+
477
+ spec_P_range = None
478
+ if args.spec_P_range is not None:
479
+ spec_P_range = tuple(args.spec_P_range)
480
+
481
+ # Merge config file + CLI overrides for epsilon section
482
+ merged = _merge_for_section(args, "epsilon")
483
+ fft_length = args.fft_length or merged.get("fft_length", 1024)
484
+ diss_length = args.diss_length or merged.get("diss_length")
485
+ f_AA = args.f_AA or merged.get("f_AA", 98.0)
486
+ goodman = merged.get("goodman", True) if not args.no_goodman else False
487
+
488
+ W_min = getattr(args, "W_min", None) or 0.3
489
+
490
+ p_files = _resolve_p_files(args.files)
491
+ for pf_path in p_files:
492
+ quick_look(
493
+ pf_path,
494
+ fft_length=fft_length,
495
+ diss_length=diss_length,
496
+ f_AA=f_AA,
497
+ goodman=goodman,
498
+ direction=args.direction or "auto",
499
+ vehicle=getattr(args, "vehicle", None),
500
+ W_min=W_min,
501
+ spec_P_range=spec_P_range,
502
+ chi_method=args.chi_method,
503
+ spectrum_model=args.spectrum_model,
504
+ )
505
+
506
+
507
+ def _cmd_dl(args: argparse.Namespace) -> None:
508
+ """Interactive dissipation quality viewer."""
509
+ from odas_tpw.rsi.diss_look import diss_look
510
+
511
+ spec_P_range = None
512
+ if args.spec_P_range is not None:
513
+ spec_P_range = tuple(args.spec_P_range)
514
+
515
+ # Merge config file + CLI overrides for epsilon section
516
+ merged = _merge_for_section(args, "epsilon")
517
+ fft_length = args.fft_length or merged.get("fft_length", 1024)
518
+ diss_length = args.diss_length or merged.get("diss_length")
519
+ f_AA = args.f_AA or merged.get("f_AA", 98.0)
520
+ goodman = merged.get("goodman", True) if not args.no_goodman else False
521
+
522
+ W_min = getattr(args, "W_min", None) or 0.3
523
+
524
+ p_files = _resolve_p_files(args.files)
525
+ for pf_path in p_files:
526
+ diss_look(
527
+ pf_path,
528
+ fft_length=fft_length,
529
+ diss_length=diss_length,
530
+ f_AA=f_AA,
531
+ goodman=goodman,
532
+ direction=args.direction or "auto",
533
+ vehicle=getattr(args, "vehicle", None),
534
+ W_min=W_min,
535
+ spec_P_range=spec_P_range,
536
+ )
537
+
538
+
539
+ def _cmd_bench(args: argparse.Namespace) -> None:
540
+ """Bench-test diagnostic: raw-count figures + auto-evaluated checklist."""
541
+ # Force a non-interactive backend unless the user wants windows, so batch /
542
+ # headless runs need no display. Must precede the bench import, which loads
543
+ # pyplot at module level.
544
+ if not args.show:
545
+ import matplotlib
546
+
547
+ matplotlib.use("Agg")
548
+ from odas_tpw.rsi.bench import run_bench
549
+
550
+ p_files = _resolve_p_files(args.files)
551
+
552
+ # Save by default (to ./bench/) unless the user only wants an interactive
553
+ # look (--show with no -o), in which case display without writing files.
554
+ out_dir = args.output
555
+ if out_dir is None and not args.show:
556
+ out_dir = "bench"
557
+
558
+ for i, pf_path in enumerate(p_files):
559
+ if i > 0:
560
+ print("\n" + "=" * 60 + "\n")
561
+ try:
562
+ run_bench(
563
+ pf_path,
564
+ out_dir=out_dir,
565
+ show=args.show,
566
+ sn=args.sn,
567
+ fft_sec=args.fft_sec,
568
+ dpi=args.dpi,
569
+ fmt=args.format,
570
+ )
571
+ except (OSError, ValueError) as e:
572
+ print(f" ERROR: {e}", file=sys.stderr)
573
+
574
+
575
+ def _cmd_sensors(args: argparse.Namespace) -> None:
576
+ """Inventory microstructure sensors across a tree of .p files."""
577
+ from odas_tpw.rsi.sensor_inventory import resolve_kinds, run
578
+
579
+ kinds = resolve_kinds(args.shear, args.fp07, args.want_all)
580
+ code = run(
581
+ [Path(p) for p in args.paths],
582
+ kinds,
583
+ csv_out=Path(args.csv) if args.csv else None,
584
+ verbose=args.verbose,
585
+ compact=args.compact,
586
+ )
587
+ if code != 0:
588
+ sys.exit(code)
589
+
590
+
591
+ def _cmd_ml(args: argparse.Namespace) -> None:
592
+ """Interactive mixing viewer."""
593
+ from odas_tpw.rsi.mixing_look import mixing_look
594
+
595
+ spec_P_range = None
596
+ if args.spec_P_range is not None:
597
+ spec_P_range = tuple(args.spec_P_range)
598
+
599
+ # Merge config file + CLI overrides for epsilon section
600
+ merged = _merge_for_section(args, "epsilon")
601
+ fft_length = args.fft_length or merged.get("fft_length", 1024)
602
+ diss_length = args.diss_length or merged.get("diss_length")
603
+ f_AA = args.f_AA or merged.get("f_AA", 98.0)
604
+ goodman = merged.get("goodman", True) if not args.no_goodman else False
605
+
606
+ W_min = getattr(args, "W_min", None) or 0.3
607
+
608
+ p_files = _resolve_p_files(args.files)
609
+ for pf_path in p_files:
610
+ mixing_look(
611
+ pf_path,
612
+ fft_length=fft_length,
613
+ diss_length=diss_length,
614
+ f_AA=f_AA,
615
+ goodman=goodman,
616
+ direction=args.direction or "auto",
617
+ vehicle=getattr(args, "vehicle", None),
618
+ W_min=W_min,
619
+ spec_P_range=spec_P_range,
620
+ salinity=args.salinity,
621
+ )
622
+
623
+
624
+ # ---------------------------------------------------------------------------
625
+ # Subcommand parsers
626
+ # ---------------------------------------------------------------------------
627
+
628
+
629
+ def _add_nc_parser(subparsers: argparse._SubParsersAction) -> None:
630
+ p = subparsers.add_parser(
631
+ "nc",
632
+ help="Convert .p files to NetCDF",
633
+ description="Convert Rockland Scientific .p data files to NetCDF4.",
634
+ )
635
+ p.add_argument("files", nargs="+", metavar="FILE", help=".p file(s) or glob pattern(s)")
636
+ p.add_argument(
637
+ "-o",
638
+ "--output",
639
+ metavar="PATH",
640
+ default=None,
641
+ help="Output file (single input) or directory (multiple inputs)",
642
+ )
643
+ p.add_argument(
644
+ "-j",
645
+ "--jobs",
646
+ type=int,
647
+ default=1,
648
+ metavar="N",
649
+ help="Parallel workers (0 = all cores, default: 1)",
650
+ )
651
+ p.set_defaults(func=_cmd_nc)
652
+
653
+
654
+ def _add_info_parser(subparsers: argparse._SubParsersAction) -> None:
655
+ p = subparsers.add_parser(
656
+ "info",
657
+ help="Print .p file summary",
658
+ description="Print summary of Rockland Scientific .p data files.",
659
+ )
660
+ p.add_argument("files", nargs="+", metavar="FILE", help=".p file(s) or glob pattern(s)")
661
+ p.set_defaults(func=_cmd_info)
662
+
663
+
664
+ def _add_cutp_parser(subparsers: argparse._SubParsersAction) -> None:
665
+ p = subparsers.add_parser(
666
+ "cutp",
667
+ help="Copy .p records for debugging",
668
+ description=(
669
+ "Create a valid .p file containing a contiguous range of complete "
670
+ "data records. This is a byte-level debugging utility, not a "
671
+ "pressure- or profile-aware scientific extraction. Absolute time "
672
+ "is correct only when --start is 0 because the header is copied "
673
+ "unchanged."
674
+ ),
675
+ )
676
+ p.add_argument("file", metavar="FILE", help="Input .p file")
677
+ p.add_argument(
678
+ "-o",
679
+ "--output",
680
+ metavar="FILE",
681
+ required=True,
682
+ help="Output .p file",
683
+ )
684
+ p.add_argument(
685
+ "-s",
686
+ "--start",
687
+ type=int,
688
+ default=0,
689
+ metavar="N",
690
+ help="First data record to copy, 0-based after the config record (default: 0)",
691
+ )
692
+ p.add_argument(
693
+ "-n",
694
+ "--n-records",
695
+ type=int,
696
+ default=60,
697
+ metavar="N",
698
+ help="Number of complete data records to copy (default: 60)",
699
+ )
700
+ p.add_argument(
701
+ "-f",
702
+ "--force",
703
+ "--overwrite",
704
+ dest="overwrite",
705
+ action="store_true",
706
+ help="Overwrite output file if it exists",
707
+ )
708
+ p.set_defaults(func=_cmd_cutp)
709
+
710
+
711
+ def _add_init_parser(subparsers: argparse._SubParsersAction) -> None:
712
+ p = subparsers.add_parser(
713
+ "init",
714
+ help="Generate a template configuration file",
715
+ description="Write a fully-commented template config.yaml with all default values.",
716
+ )
717
+ p.add_argument(
718
+ "path",
719
+ nargs="?",
720
+ default="config.yaml",
721
+ help="Output path (default: config.yaml)",
722
+ )
723
+ p.add_argument(
724
+ "--force",
725
+ action="store_true",
726
+ help="Overwrite existing file",
727
+ )
728
+ p.set_defaults(func=_cmd_init)
729
+
730
+
731
+ def _add_prof_parser(subparsers: argparse._SubParsersAction) -> None:
732
+ p = subparsers.add_parser(
733
+ "prof",
734
+ help="Extract profiles",
735
+ description="Extract profiles from .p or full-record .nc files.",
736
+ )
737
+ p.add_argument("files", nargs="+", metavar="FILE", help=".p or .nc file(s) or glob pattern(s)")
738
+ p.add_argument(
739
+ "-o",
740
+ "--output",
741
+ metavar="DIR",
742
+ required=True,
743
+ help="Output directory for per-profile .nc files",
744
+ )
745
+ p.add_argument(
746
+ "-j",
747
+ "--jobs",
748
+ type=int,
749
+ default=1,
750
+ metavar="N",
751
+ help="Parallel workers (0 = all cores, default: 1)",
752
+ )
753
+ p.add_argument(
754
+ "--P-min", type=float, default=None, help="Minimum pressure [dbar] (default: 0.5)"
755
+ )
756
+ p.add_argument(
757
+ "--W-min", type=float, default=None, help="Minimum fall rate [dbar/s] (default: 0.3)"
758
+ )
759
+ p.add_argument(
760
+ "--direction",
761
+ default=None,
762
+ choices=["auto", "up", "down", "glide", "horizontal"],
763
+ help="Profile direction (default: auto, from vehicle)",
764
+ )
765
+ p.add_argument(
766
+ "--vehicle",
767
+ default=None,
768
+ help="Vehicle type override (e.g. slocum_glider, vmp)",
769
+ )
770
+ p.add_argument(
771
+ "--min-duration", type=float, default=None, help="Minimum profile duration [s] (default: 7)"
772
+ )
773
+ p.set_defaults(func=_cmd_prof)
774
+
775
+
776
+ def _add_eps_parser(subparsers: argparse._SubParsersAction) -> None:
777
+ p = subparsers.add_parser(
778
+ "eps",
779
+ help="Compute epsilon (TKE dissipation)",
780
+ description="Compute TKE dissipation rate (epsilon) from VMP data.",
781
+ )
782
+ p.add_argument(
783
+ "files", nargs="+", metavar="FILE", help=".p, full-record .nc, or per-profile .nc file(s)"
784
+ )
785
+ p.add_argument(
786
+ "-o",
787
+ "--output",
788
+ metavar="DIR",
789
+ required=True,
790
+ help="Output directory for epsilon .nc files",
791
+ )
792
+ p.add_argument(
793
+ "-j",
794
+ "--jobs",
795
+ type=int,
796
+ default=1,
797
+ metavar="N",
798
+ help="Parallel workers (0 = all cores, default: 1)",
799
+ )
800
+ p.add_argument(
801
+ "--fft-length", type=int, default=None, help="FFT segment length [samples] (default: 1024)"
802
+ )
803
+ p.add_argument(
804
+ "--diss-length",
805
+ type=int,
806
+ default=None,
807
+ help="Dissipation window [samples] (default: 4*fft-length)",
808
+ )
809
+ p.add_argument(
810
+ "--overlap",
811
+ type=int,
812
+ default=None,
813
+ help="Window overlap [samples] (default: diss-length//2)",
814
+ )
815
+ p.add_argument(
816
+ "--speed",
817
+ type=float,
818
+ default=None,
819
+ help="Fixed profiling speed [m/s] (default: from dP/dt)",
820
+ )
821
+ p.add_argument(
822
+ "--direction",
823
+ default=None,
824
+ choices=["auto", "up", "down", "glide", "horizontal"],
825
+ help="Profile direction (default: auto, from vehicle)",
826
+ )
827
+ p.add_argument(
828
+ "--vehicle",
829
+ default=None,
830
+ help="Vehicle type override (e.g. slocum_glider, vmp)",
831
+ )
832
+ p.add_argument(
833
+ "--no-goodman",
834
+ action="store_const",
835
+ const=True,
836
+ default=None,
837
+ help="Disable Goodman coherent noise removal",
838
+ )
839
+ p.add_argument(
840
+ "--f-AA", type=float, default=None, help="Anti-aliasing filter cutoff [Hz] (default: 98)"
841
+ )
842
+ p.add_argument(
843
+ "--salinity",
844
+ type=float,
845
+ default=None,
846
+ help="Salinity [PSU] for viscosity (default: 35, fixed S)",
847
+ )
848
+ p.set_defaults(func=_cmd_eps)
849
+
850
+
851
+ def _add_chi_parser(subparsers: argparse._SubParsersAction) -> None:
852
+ p = subparsers.add_parser(
853
+ "chi",
854
+ help="Compute chi (thermal dissipation)",
855
+ description="Compute thermal variance dissipation rate (chi) from VMP data.",
856
+ )
857
+ p.add_argument(
858
+ "files", nargs="+", metavar="FILE", help=".p, full-record .nc, or per-profile .nc file(s)"
859
+ )
860
+ p.add_argument(
861
+ "-o",
862
+ "--output",
863
+ metavar="DIR",
864
+ required=True,
865
+ help="Output directory for chi .nc files",
866
+ )
867
+ p.add_argument(
868
+ "-j",
869
+ "--jobs",
870
+ type=int,
871
+ default=1,
872
+ metavar="N",
873
+ help="Parallel workers (0 = all cores, default: 1)",
874
+ )
875
+ p.add_argument(
876
+ "--fft-length", type=int, default=None, help="FFT segment length [samples] (default: 1024)"
877
+ )
878
+ p.add_argument(
879
+ "--diss-length",
880
+ type=int,
881
+ default=None,
882
+ help="Dissipation window [samples] (default: 4*fft-length)",
883
+ )
884
+ p.add_argument(
885
+ "--overlap",
886
+ type=int,
887
+ default=None,
888
+ help="Window overlap [samples] (default: diss-length//2)",
889
+ )
890
+ p.add_argument(
891
+ "--speed",
892
+ type=float,
893
+ default=None,
894
+ help="Fixed profiling speed [m/s] (default: from dP/dt)",
895
+ )
896
+ p.add_argument(
897
+ "--direction",
898
+ default=None,
899
+ choices=["auto", "up", "down", "glide", "horizontal"],
900
+ help="Profile direction (default: auto, from vehicle)",
901
+ )
902
+ p.add_argument(
903
+ "--vehicle",
904
+ default=None,
905
+ help="Vehicle type override (e.g. slocum_glider, vmp)",
906
+ )
907
+ p.add_argument(
908
+ "--no-goodman",
909
+ action="store_const",
910
+ const=True,
911
+ default=None,
912
+ help="Disable Goodman coherent noise removal",
913
+ )
914
+ p.add_argument(
915
+ "--fp07-model",
916
+ default=None,
917
+ choices=["single_pole", "double_pole"],
918
+ help="FP07 transfer function model (default: single_pole)",
919
+ )
920
+ p.add_argument(
921
+ "--epsilon-dir",
922
+ metavar="DIR",
923
+ default=None,
924
+ help="Directory with epsilon .nc files from 'rsi-tpw eps' (Method 1). "
925
+ "If omitted, uses Method 2 (spectral fitting).",
926
+ )
927
+ p.add_argument(
928
+ "--fit-method",
929
+ default=None,
930
+ choices=["mle", "iterative"],
931
+ help="Method 2 fitting: mle or iterative (default: iterative)",
932
+ )
933
+ p.add_argument(
934
+ "--spectrum-model",
935
+ default=None,
936
+ choices=["batchelor", "kraichnan"],
937
+ help="Theoretical spectrum model (default: kraichnan)",
938
+ )
939
+ p.add_argument(
940
+ "--f-AA", type=float, default=None, help="Anti-aliasing filter cutoff [Hz] (default: 98)"
941
+ )
942
+ p.add_argument(
943
+ "--salinity",
944
+ type=float,
945
+ default=None,
946
+ help="Salinity [PSU] for viscosity (default: 35, fixed S)",
947
+ )
948
+ p.set_defaults(func=_cmd_chi)
949
+
950
+
951
+ def _add_pipeline_parser(subparsers: argparse._SubParsersAction) -> None:
952
+ p = subparsers.add_parser(
953
+ "pipeline",
954
+ help="Run full pipeline (.p -> epsilon -> chi)",
955
+ description="Run the full processing pipeline from raw .p files through "
956
+ "epsilon (TKE dissipation) and chi (thermal dissipation). "
957
+ "Profiles are detected automatically. Chi is computed using "
958
+ "Method 1 (from shear-probe epsilon).",
959
+ )
960
+ p.add_argument("files", nargs="+", metavar="FILE", help=".p file(s) or glob pattern(s)")
961
+ p.add_argument(
962
+ "-o",
963
+ "--output",
964
+ metavar="DIR",
965
+ required=True,
966
+ help="Base output directory",
967
+ )
968
+ p.add_argument(
969
+ "--direction",
970
+ default=None,
971
+ choices=["auto", "up", "down", "glide", "horizontal"],
972
+ help="Profile direction (default: auto, from vehicle)",
973
+ )
974
+ p.add_argument(
975
+ "--vehicle",
976
+ default=None,
977
+ help="Vehicle type override (e.g. slocum_glider, vmp)",
978
+ )
979
+ p.add_argument(
980
+ "--speed",
981
+ type=float,
982
+ default=None,
983
+ help="Fixed profiling speed [m/s] (default: from dP/dt)",
984
+ )
985
+ p.add_argument(
986
+ "--speed-method",
987
+ choices=["pressure", "em", "flight"],
988
+ default=None,
989
+ help=(
990
+ "Through-water speed model (default: pressure = |dP/dt|). "
991
+ "'em' uses the U_EM flowmeter channel; 'flight' uses the inviscid "
992
+ "glider flight model |W|/sin(|pitch|-aoa) from the inclinometers."
993
+ ),
994
+ )
995
+ p.add_argument(
996
+ "--aoa",
997
+ type=float,
998
+ default=None,
999
+ help="Angle of attack [deg] for --speed-method flight (default: 3.0)",
1000
+ )
1001
+ p.add_argument(
1002
+ "--eps-fft-length",
1003
+ type=int,
1004
+ default=None,
1005
+ help="FFT length for epsilon [samples] (default: 1024)",
1006
+ )
1007
+ p.add_argument(
1008
+ "--chi-fft-length",
1009
+ type=int,
1010
+ default=None,
1011
+ help="FFT length for chi [samples] (default: 1024)",
1012
+ )
1013
+ p.add_argument(
1014
+ "--no-goodman",
1015
+ action="store_const",
1016
+ const=True,
1017
+ default=None,
1018
+ help="Disable Goodman coherent noise removal for epsilon and chi",
1019
+ )
1020
+ p.add_argument(
1021
+ "--fp07-model",
1022
+ default=None,
1023
+ choices=["single_pole", "double_pole"],
1024
+ help="FP07 transfer function model (default: single_pole)",
1025
+ )
1026
+ p.add_argument(
1027
+ "--spectrum-model",
1028
+ default=None,
1029
+ choices=["batchelor", "kraichnan"],
1030
+ help="Theoretical spectrum model for chi (default: kraichnan)",
1031
+ )
1032
+ p.add_argument(
1033
+ "--f-AA", type=float, default=None, help="Anti-aliasing filter cutoff [Hz] (default: 98)"
1034
+ )
1035
+ p.add_argument(
1036
+ "--salinity",
1037
+ type=float,
1038
+ default=None,
1039
+ help="Salinity [PSU] for viscosity (default: 35, fixed S)",
1040
+ )
1041
+ p.set_defaults(func=_cmd_pipeline)
1042
+
1043
+
1044
+ def _add_ql_parser(subparsers: argparse._SubParsersAction) -> None:
1045
+ p = subparsers.add_parser(
1046
+ "ql",
1047
+ help="Interactive quick-look viewer",
1048
+ description="Open an interactive multi-panel viewer with profile navigation.",
1049
+ )
1050
+ p.add_argument("files", nargs="+", metavar="FILE", help=".p file(s) or glob pattern(s)")
1051
+ p.add_argument(
1052
+ "--fft-length", type=int, default=None, help="FFT segment length [samples] (default: 1024)"
1053
+ )
1054
+ p.add_argument(
1055
+ "--diss-length",
1056
+ type=int,
1057
+ default=None,
1058
+ help="Dissipation window [samples] (default: 4*fft-length)",
1059
+ )
1060
+ p.add_argument(
1061
+ "--f-AA", type=float, default=None, help="Anti-aliasing filter cutoff [Hz] (default: 98)"
1062
+ )
1063
+ p.add_argument(
1064
+ "--no-goodman",
1065
+ action="store_true",
1066
+ default=False,
1067
+ help="Disable Goodman coherent noise removal",
1068
+ )
1069
+ p.add_argument(
1070
+ "--direction",
1071
+ default=None,
1072
+ choices=["auto", "up", "down", "glide", "horizontal"],
1073
+ help="Profile direction (default: auto, from vehicle)",
1074
+ )
1075
+ p.add_argument(
1076
+ "--vehicle",
1077
+ default=None,
1078
+ help="Vehicle type override (e.g. slocum_glider, vmp)",
1079
+ )
1080
+ p.add_argument(
1081
+ "--W-min", type=float, default=None, help="Minimum fall rate [dbar/s] (default: 0.3)"
1082
+ )
1083
+ p.add_argument(
1084
+ "--spec-P-range",
1085
+ type=float,
1086
+ nargs=2,
1087
+ metavar=("P_MIN", "P_MAX"),
1088
+ default=None,
1089
+ help="Pressure range [dbar] for spectral calculations (default: full profile)",
1090
+ )
1091
+ p.add_argument(
1092
+ "--chi-method",
1093
+ type=int,
1094
+ default=1,
1095
+ choices=[1, 2],
1096
+ help="Chi method for profile estimates: 1 = from epsilon, "
1097
+ "2 = iterative spectral fit (default: 1)",
1098
+ )
1099
+ p.add_argument(
1100
+ "--spectrum-model",
1101
+ default="kraichnan",
1102
+ choices=["batchelor", "kraichnan"],
1103
+ help="Theoretical spectrum model (default: kraichnan)",
1104
+ )
1105
+ p.set_defaults(func=_cmd_ql)
1106
+
1107
+
1108
+ def _add_dl_parser(subparsers: argparse._SubParsersAction) -> None:
1109
+ p = subparsers.add_parser(
1110
+ "dl",
1111
+ help="Interactive dissipation quality viewer",
1112
+ description="Open an interactive viewer comparing epsilon, chi (Batchelor vs "
1113
+ "Kraichnan), and Lueck (2022) figure of merit (FM) with profile navigation.",
1114
+ )
1115
+ p.add_argument("files", nargs="+", metavar="FILE", help=".p file(s) or glob pattern(s)")
1116
+ p.add_argument(
1117
+ "--fft-length", type=int, default=None, help="FFT segment length [samples] (default: 1024)"
1118
+ )
1119
+ p.add_argument(
1120
+ "--diss-length",
1121
+ type=int,
1122
+ default=None,
1123
+ help="Dissipation window [samples] (default: 4*fft-length)",
1124
+ )
1125
+ p.add_argument(
1126
+ "--f-AA", type=float, default=None, help="Anti-aliasing filter cutoff [Hz] (default: 98)"
1127
+ )
1128
+ p.add_argument(
1129
+ "--no-goodman",
1130
+ action="store_true",
1131
+ default=False,
1132
+ help="Disable Goodman coherent noise removal",
1133
+ )
1134
+ p.add_argument(
1135
+ "--direction",
1136
+ default=None,
1137
+ choices=["auto", "up", "down", "glide", "horizontal"],
1138
+ help="Profile direction (default: auto, from vehicle)",
1139
+ )
1140
+ p.add_argument(
1141
+ "--vehicle",
1142
+ default=None,
1143
+ help="Vehicle type override (e.g. slocum_glider, vmp)",
1144
+ )
1145
+ p.add_argument(
1146
+ "--W-min", type=float, default=None, help="Minimum fall rate [dbar/s] (default: 0.3)"
1147
+ )
1148
+ p.add_argument(
1149
+ "--spec-P-range",
1150
+ type=float,
1151
+ nargs=2,
1152
+ metavar=("P_MIN", "P_MAX"),
1153
+ default=None,
1154
+ help="Pressure range [dbar] for spectral calculations (default: full profile)",
1155
+ )
1156
+ p.set_defaults(func=_cmd_dl)
1157
+
1158
+
1159
+ def _add_sensors_parser(subparsers: argparse._SubParsersAction) -> None:
1160
+ p = subparsers.add_parser(
1161
+ "sensors",
1162
+ help="Inventory shear/FP07 sensors across a .p file tree",
1163
+ description=(
1164
+ "Walk a directory tree of Rockland .p files and summarize, per sensor "
1165
+ "serial number, the date range of use, the number of files, the "
1166
+ "platform(s) it was mounted on, and whether its calibration parameters "
1167
+ "changed. Reads only each file's header + config block (not the data), "
1168
+ "so it is fast over large trees. Select sensor kinds with --shear / "
1169
+ "--fp07 / --all (default: all)."
1170
+ ),
1171
+ )
1172
+ p.add_argument(
1173
+ "paths",
1174
+ nargs="+",
1175
+ metavar="PATH",
1176
+ help="Directories (scanned recursively), .p files, or glob patterns",
1177
+ )
1178
+ p.add_argument("--shear", action="store_true", help="Inventory shear probes")
1179
+ p.add_argument("--fp07", action="store_true", help="Inventory FP07 thermistors")
1180
+ p.add_argument(
1181
+ "--all",
1182
+ dest="want_all",
1183
+ action="store_true",
1184
+ help="Inventory every sensor kind (shear + fp07; the default if none is given)",
1185
+ )
1186
+ p.add_argument(
1187
+ "--csv",
1188
+ metavar="PATH",
1189
+ default=None,
1190
+ help="Write a per-(file,channel) CSV table (overwritten if it exists)",
1191
+ )
1192
+ p.add_argument(
1193
+ "-v",
1194
+ "--verbose",
1195
+ action="store_true",
1196
+ help="List the individual files behind each changed parameter value",
1197
+ )
1198
+ p.add_argument(
1199
+ "--compact",
1200
+ action="store_true",
1201
+ help="One line per probe: SN, file count, calibration, and date range",
1202
+ )
1203
+ p.set_defaults(func=_cmd_sensors)
1204
+
1205
+
1206
+ def _add_ml_parser(subparsers: argparse._SubParsersAction) -> None:
1207
+ p = subparsers.add_parser(
1208
+ "ml",
1209
+ help="Interactive mixing viewer",
1210
+ description="Open an interactive viewer of the background stratification "
1211
+ "(N², dT/dz) and derived diapycnal-mixing quantities (K_T, Γ, K_rho) with "
1212
+ "profile navigation.",
1213
+ )
1214
+ p.add_argument("files", nargs="+", metavar="FILE", help=".p file(s) or glob pattern(s)")
1215
+ p.add_argument(
1216
+ "--fft-length", type=int, default=None, help="FFT segment length [samples] (default: 1024)"
1217
+ )
1218
+ p.add_argument(
1219
+ "--diss-length",
1220
+ type=int,
1221
+ default=None,
1222
+ help="Dissipation window [samples] (default: 4*fft-length)",
1223
+ )
1224
+ p.add_argument(
1225
+ "--f-AA", type=float, default=None, help="Anti-aliasing filter cutoff [Hz] (default: 98)"
1226
+ )
1227
+ p.add_argument(
1228
+ "--no-goodman",
1229
+ action="store_true",
1230
+ default=False,
1231
+ help="Disable Goodman coherent noise removal",
1232
+ )
1233
+ p.add_argument(
1234
+ "--direction",
1235
+ default=None,
1236
+ choices=["auto", "up", "down", "glide", "horizontal"],
1237
+ help="Profile direction (default: auto, from vehicle)",
1238
+ )
1239
+ p.add_argument(
1240
+ "--vehicle",
1241
+ default=None,
1242
+ help="Vehicle type override (e.g. slocum_glider, vmp)",
1243
+ )
1244
+ p.add_argument(
1245
+ "--W-min", type=float, default=None, help="Minimum fall rate [dbar/s] (default: 0.3)"
1246
+ )
1247
+ p.add_argument(
1248
+ "--spec-P-range",
1249
+ type=float,
1250
+ nargs=2,
1251
+ metavar=("P_MIN", "P_MAX"),
1252
+ default=None,
1253
+ help="Pressure range [dbar] to highlight on the profiles (default: none)",
1254
+ )
1255
+ p.add_argument(
1256
+ "--salinity",
1257
+ type=float,
1258
+ default=None,
1259
+ help="Fixed practical salinity [PSU] for stratification "
1260
+ "(default: measured from JAC C/T, else 35)",
1261
+ )
1262
+ p.set_defaults(func=_cmd_ml)
1263
+
1264
+
1265
+ def _add_bench_parser(subparsers: argparse._SubParsersAction) -> None:
1266
+ p = subparsers.add_parser(
1267
+ "bench",
1268
+ help="Bench-test diagnostic (quick_bench + checklist)",
1269
+ description=(
1270
+ "Evaluate a bench-test recording (dummy probes, instrument at rest on "
1271
+ "foam). Produces the raw-count time-series and counts^2/Hz spectra "
1272
+ "figures of ODAS quick_bench.m, plus an automatic PASS/FAIL evaluation "
1273
+ "of the Rockland Bench Test Review Checklist (subjective items are "
1274
+ "flagged REVIEW). Figures and the checklist text are written to the "
1275
+ "output directory (default: ./bench/); --show also opens them "
1276
+ "interactively. Compare the spectra against the instrument's RSI "
1277
+ "calibration report."
1278
+ ),
1279
+ )
1280
+ p.add_argument("files", nargs="+", metavar="FILE", help=".p file(s) or glob pattern(s)")
1281
+ p.add_argument(
1282
+ "-o",
1283
+ "--output",
1284
+ metavar="DIR",
1285
+ default=None,
1286
+ help="Output directory for figures + checklist (default: ./bench/, unless "
1287
+ "--show is given without -o, which only displays)",
1288
+ )
1289
+ p.add_argument(
1290
+ "--show", action="store_true", help="Open the figures in interactive windows"
1291
+ )
1292
+ p.add_argument(
1293
+ "--sn",
1294
+ default=None,
1295
+ metavar="SN",
1296
+ help="Serial number for figure titles/filenames (default: from config)",
1297
+ )
1298
+ p.add_argument(
1299
+ "--fft-sec",
1300
+ type=float,
1301
+ default=2.0,
1302
+ help="FFT segment length for spectra [seconds] (default: 2.0)",
1303
+ )
1304
+ p.add_argument(
1305
+ "--dpi", type=int, default=150, help="Figure resolution when saving (default: 150)"
1306
+ )
1307
+ p.add_argument(
1308
+ "--format",
1309
+ choices=["png", "pdf", "both", "pdf-bundle"],
1310
+ default="png",
1311
+ help="Saved figure format: one file per figure (png/pdf/both), or "
1312
+ "'pdf-bundle' for a single multi-page PDF (default: png)",
1313
+ )
1314
+ p.set_defaults(func=_cmd_bench)
1315
+
1316
+
1317
+ def _add_patchconfig_parser(subparsers: argparse._SubParsersAction) -> None:
1318
+ p = subparsers.add_parser(
1319
+ "patch-config",
1320
+ help="Patch config fields in .p file(s) (writes new files)",
1321
+ description=(
1322
+ "Edit selected configuration fields (instrument/cruise info, per-channel "
1323
+ "calibration) in Rockland .p files, driven by a YAML edit spec. Originals "
1324
+ "are never modified; patched copies are written to --out, with the change "
1325
+ "annotated and the full original configuration embedded for recovery. If a "
1326
+ "file's targeted values already match, that file is left unwritten. "
1327
+ "Scaffold the YAML with 'rsi-tpw patch-template'."
1328
+ ),
1329
+ )
1330
+ p.add_argument("files", nargs="+", metavar="FILE", help=".p file(s) or glob pattern(s)")
1331
+ p.add_argument(
1332
+ "--edits", required=True, metavar="YAML", help="YAML edit spec (see 'patch-template')"
1333
+ )
1334
+ p.add_argument(
1335
+ "-o", "--out", required=True, metavar="DIR", help="Output directory for patched .p files"
1336
+ )
1337
+ p.add_argument(
1338
+ "--dry-run", action="store_true", help="Show the configuration diff; write nothing"
1339
+ )
1340
+ p.add_argument(
1341
+ "--add-keys", action="store_true", help="Allow adding keys that do not already exist"
1342
+ )
1343
+ p.add_argument(
1344
+ "--batch-cal",
1345
+ action="store_true",
1346
+ help="Allow per-channel calibration edits across multiple files",
1347
+ )
1348
+ p.set_defaults(func=_cmd_patch_config)
1349
+
1350
+
1351
+ def _add_patchtemplate_parser(subparsers: argparse._SubParsersAction) -> None:
1352
+ p = subparsers.add_parser(
1353
+ "patch-template",
1354
+ help="Scaffold a patch-config edit spec from a .p file",
1355
+ description=(
1356
+ "Write a commented YAML edit-spec template pre-filled with the file's "
1357
+ "current editable values and channel names. Edit it, then apply it with "
1358
+ "'rsi-tpw patch-config'."
1359
+ ),
1360
+ )
1361
+ p.add_argument("file", metavar="FILE", help="Input .p file")
1362
+ p.add_argument("-o", "--output", metavar="YAML", help="Output path (default: print to stdout)")
1363
+ p.add_argument("--force", action="store_true", help="Overwrite existing output file")
1364
+ p.set_defaults(func=_cmd_patch_template)
1365
+
1366
+
1367
+ # ---------------------------------------------------------------------------
1368
+ # Main entry point
1369
+ # ---------------------------------------------------------------------------
1370
+
1371
+
1372
+ def main() -> None:
1373
+ """Main CLI entry point for rsi-tpw."""
1374
+ import logging
1375
+
1376
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
1377
+
1378
+ from odas_tpw.rsi import __version__
1379
+
1380
+ parser = argparse.ArgumentParser(
1381
+ prog="rsi-tpw",
1382
+ description="microstructure-tpw: Read Rockland Scientific .P files and convert to NetCDF.",
1383
+ )
1384
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
1385
+ parser.add_argument(
1386
+ "-c",
1387
+ "--config",
1388
+ metavar="YAML",
1389
+ default=None,
1390
+ help="Configuration file (YAML). Generate a template with 'rsi-tpw init'.",
1391
+ )
1392
+ subparsers = parser.add_subparsers(dest="command", required=True)
1393
+
1394
+ _add_info_parser(subparsers)
1395
+ _add_cutp_parser(subparsers)
1396
+ _add_nc_parser(subparsers)
1397
+ _add_init_parser(subparsers)
1398
+ _add_patchconfig_parser(subparsers)
1399
+ _add_patchtemplate_parser(subparsers)
1400
+ _add_prof_parser(subparsers)
1401
+ _add_eps_parser(subparsers)
1402
+ _add_chi_parser(subparsers)
1403
+ _add_pipeline_parser(subparsers)
1404
+ _add_ql_parser(subparsers)
1405
+ _add_dl_parser(subparsers)
1406
+ _add_ml_parser(subparsers)
1407
+ _add_bench_parser(subparsers)
1408
+ _add_sensors_parser(subparsers)
1409
+
1410
+ args = parser.parse_args()
1411
+ args.func(args)