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
@@ -0,0 +1,1046 @@
1
+ # Mar-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """YAML configuration file support for perturb.
3
+
4
+ Provides loading, validation, three-way merge (defaults <- config <- CLI),
5
+ deterministic hashing, and sequential output directory management.
6
+
7
+ All config management logic lives in :mod:`odas_tpw.config_base.ConfigManager`;
8
+ this module defines perturb-specific DEFAULTS (17 sections), instantiates the
9
+ manager, and re-exports the methods as module-level functions.
10
+ """
11
+
12
+ import functools
13
+ import hashlib
14
+ import importlib.metadata
15
+ import os
16
+ import warnings
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from odas_tpw.config_base import ConfigManager
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Canonical defaults — one dict per processing section
24
+ # ---------------------------------------------------------------------------
25
+
26
+ DEFAULTS: dict[str, dict] = {
27
+ "files": {
28
+ "p_file_root": "VMP/",
29
+ "p_file_pattern": "**/*.p",
30
+ "output_root": "results/",
31
+ "trim": True,
32
+ "force_trim": False,
33
+ "force": False,
34
+ "merge": False,
35
+ # Absolute directory of the loaded YAML, stamped by load_config so path
36
+ # values written with the <CONFIG_DIR> token can be resolved relative to
37
+ # the config file. None when the config was not loaded from a file.
38
+ # Hash-excluded (see _HASH_EXCLUDE_KEYS): it is mount-specific, so the
39
+ # portable <CONFIG_DIR> token — not this absolute path — is what feeds
40
+ # the stage-dir signatures.
41
+ "config_dir": None,
42
+ },
43
+ "gps": {
44
+ "source": "nan",
45
+ "lat": None,
46
+ "lon": None,
47
+ "file": None,
48
+ "time_col": None, # null = source default ("t" CSV, "time" NetCDF)
49
+ "lat_col": "lat",
50
+ "lon_col": "lon",
51
+ "max_time_diff": 60,
52
+ },
53
+ "hotel": {
54
+ "enable": False,
55
+ "file": None,
56
+ "time_column": "time",
57
+ "time_format": "auto",
58
+ "channels": {},
59
+ "fast_channels": ["speed", "P"],
60
+ "interpolation": "pchip",
61
+ },
62
+ "profiles": {
63
+ "P_min": 0.5,
64
+ "W_min": 0.3,
65
+ "direction": "down",
66
+ "min_duration": 7.0,
67
+ "diagnostics": False,
68
+ },
69
+ "fp07": {
70
+ "calibrate": True,
71
+ "order": 2,
72
+ "max_lag_seconds": 10,
73
+ "reference": "JAC_T",
74
+ "must_be_negative": True,
75
+ },
76
+ "ct": {
77
+ "align": True,
78
+ "T_name": "JAC_T",
79
+ "C_name": "JAC_C",
80
+ },
81
+ "bottom": {
82
+ "enable": False,
83
+ "depth_window": 4.0,
84
+ "depth_minimum": 10.0,
85
+ "speed_factor": 0.3,
86
+ "median_factor": 1.0,
87
+ "vibration_frequency": 16,
88
+ "vibration_factor": 4.0,
89
+ },
90
+ "top_trim": {
91
+ "enable": False,
92
+ "dz": 0.5,
93
+ "min_depth": 1.0,
94
+ "max_depth": 50.0,
95
+ "quantile": 0.6,
96
+ "noise_factor": 2.0,
97
+ },
98
+ "epsilon": {
99
+ # Durations (seconds) are the primary interface — instruments sample
100
+ # at different rates (standard VMP-250: 512 Hz; coastal/high-energy
101
+ # units: 1-2 kHz), while the physical constraints on the windows are
102
+ # durations x speed (Lueck et al. 2024, doi:10.3389/fmars.2024.1334327).
103
+ # Sample-count keys (fft_length/diss_length/overlap), when set, are an
104
+ # exact-control override and win over the duration keys.
105
+ "fft_sec": 1.0,
106
+ "diss_sec": None, # None = 4 x fft
107
+ "overlap_sec": None, # None = half the dissipation window
108
+ "fft_length": None,
109
+ "diss_length": None,
110
+ "overlap": None,
111
+ "goodman": True,
112
+ "f_AA": 98.0,
113
+ "f_limit": None,
114
+ "fit_order": 3,
115
+ "despike_thresh": 8,
116
+ "despike_smooth": 0.5,
117
+ "salinity": None,
118
+ "epsilon_minimum": 1e-13,
119
+ "T_source": None,
120
+ "T1_norm": 1.0,
121
+ "T2_norm": 1.0,
122
+ "fom_max": None, # null = no FOM cut. e.g. 2.0 NaNs each
123
+ # per-probe (e_N, epsilon[probe,:]) cell
124
+ # whose figure-of-merit fom[probe,seg]
125
+ # >= fom_max BEFORE mk_epsilon_mean, so
126
+ # bad probes drop out of the geomean
127
+ # individually.
128
+ "diagnostics": False,
129
+ },
130
+ "chi": {
131
+ "enable": False,
132
+ # Same duration-first interface as [epsilon] above.
133
+ "fft_sec": 1.0,
134
+ "diss_sec": None,
135
+ "overlap_sec": None,
136
+ "fft_length": None,
137
+ "diss_length": None,
138
+ "overlap": None,
139
+ "fp07_model": "single_pole",
140
+ "goodman": True,
141
+ "f_AA": 98.0,
142
+ "use_epsilon": True, # Method 1 (chi from shear epsilon).
143
+ # Set false for Method 2 spectral fit -- a
144
+ # MR on a vibrating glider has unreliable
145
+ # epsilon and should not seed chi from it.
146
+ "fit_method": "iterative", # Only used when use_epsilon=False
147
+ "spectrum_model": "kraichnan",
148
+ "salinity": None,
149
+ "mixing": True, # Derived mixing quantities (N2, dTdz,
150
+ # K_T, Gamma, K_rho) on the chi grid,
151
+ # with salinity from the profile's own
152
+ # C/T/P (TEOS-10).
153
+ "chi_minimum": 1.0e-13,
154
+ "spectral_qc": True, # Soft fom + K_max_ratio QC on chiMean
155
+ # (hence K_T/Gamma), matching the rsi
156
+ # chi_final. Never drops a window (falls
157
+ # back to all probes when none pass). Set
158
+ # false to publish unfiltered chiMean.
159
+ "fom_max": None, # null = no FOM cut. An OPTIONAL, more
160
+ # aggressive HARD per-probe pre-cut (can NaN a
161
+ # whole window); independent of spectral_qc.
162
+ # Same mechanism as epsilon.fom_max.
163
+ "diagnostics": False,
164
+ },
165
+ "ctd": {
166
+ "enable": True,
167
+ "bin_width": 0.5,
168
+ "T_name": "JAC_T",
169
+ "C_name": "JAC_C",
170
+ "variables": None,
171
+ "method": "mean",
172
+ "diagnostics": False,
173
+ },
174
+ "speed": {
175
+ "method": "pressure", # pressure | em | flight | constant
176
+ "value": None, # m/s, only for method="constant"
177
+ "aoa_deg": 3.0, # angle of attack, only for method="flight"
178
+ "min_pitch_deg": 5.0, # flight: skip |pitch+aoa| below this (deg)
179
+ "speed_cutout": 0.05, # m/s floor applied to fast-rate speed
180
+ "tau": None, # smoothing time constant; null = vehicle default
181
+ "amplitude_quantile": [1.0, 99.0], # for flight pitch-axis auto-pick
182
+ },
183
+ # Per-segment QC gate. Each "_drop_from" entry names a hotel-injected
184
+ # channel (uint8 bitfield or boolean) sampled by time over the segment's
185
+ # window; if any sample is nonzero, the segment is flagged. The
186
+ # qc_drop_epsilon / qc_drop_chi variables on the diss / chi NetCDFs are
187
+ # always written (CF flag attrs preserved from the source channel); when
188
+ # drop_action is "nan" the corresponding epsilon / chi values are also
189
+ # NaN'd so default plots and combos exclude them.
190
+ "qc": {
191
+ "enable": True,
192
+ "drop_action": "nan", # nan | flag_only
193
+ "epsilon_drop_from": [], # e.g. ["q_drop_epsilon"]
194
+ "chi_drop_from": [], # e.g. ["q_drop_chi"]
195
+ # Internal range-check rules. Each entry produces a synthetic
196
+ # uint8 channel and can be referenced by *_drop_from.
197
+ # See odas_tpw.perturb.qc_rules for the per-entry schema.
198
+ "rules": {},
199
+ },
200
+ "binning": {
201
+ "method": "depth",
202
+ "width": 1.0,
203
+ "aggregation": "mean",
204
+ "diss_width": None,
205
+ "diss_aggregation": None,
206
+ "chi_width": None,
207
+ "chi_aggregation": None,
208
+ "diagnostics": False,
209
+ },
210
+ "netcdf": {
211
+ "title": None,
212
+ "summary": None,
213
+ "institution": None,
214
+ "creator_name": None,
215
+ "creator_email": None,
216
+ "creator_url": None,
217
+ "creator_type": None,
218
+ "creator_institution": None,
219
+ "contributor_name": None,
220
+ "contributor_role": None,
221
+ "publisher_name": None,
222
+ "publisher_email": None,
223
+ "publisher_url": None,
224
+ "publisher_type": None,
225
+ "publisher_institution": None,
226
+ "project": None,
227
+ "program": None,
228
+ "id": None,
229
+ "naming_authority": None,
230
+ "source": None,
231
+ "platform": None,
232
+ "platform_vocabulary": None,
233
+ "instrument": None,
234
+ "instrument_vocabulary": None,
235
+ "processing_level": None,
236
+ "license": None,
237
+ "references": None,
238
+ "comment": None,
239
+ "acknowledgement": None,
240
+ "date_created": None,
241
+ "date_modified": None,
242
+ "date_issued": None,
243
+ "geospatial_lat_min": None,
244
+ "geospatial_lat_max": None,
245
+ "geospatial_lon_min": None,
246
+ "geospatial_lon_max": None,
247
+ "geospatial_vertical_min": None,
248
+ "geospatial_vertical_max": None,
249
+ "geospatial_vertical_positive": "down",
250
+ "time_coverage_start": None,
251
+ "time_coverage_end": None,
252
+ "time_coverage_duration": None,
253
+ "time_coverage_resolution": None,
254
+ "keywords": None,
255
+ "keywords_vocabulary": None,
256
+ "standard_name_vocabulary": "CF Standard Name Table v89",
257
+ "Conventions": "CF-1.13, ACDD-1.3",
258
+ "history": None,
259
+ },
260
+ # Background stratification (N2, dT/dz) written to the diss and profile
261
+ # products, independent of epsilon/chi. Computed with the Thorpe-sorted
262
+ # (adiabatically leveled) method. diss uses its dissipation window; the
263
+ # profile product uses the configurable ``window`` below. NOT written to the
264
+ # CTD product (profile-only; the CTD spans the whole up/down trajectory).
265
+ "stratification": {
266
+ "enable": True,
267
+ "window": 2.0, # background vertical window [dbar] for the
268
+ # profile product
269
+ },
270
+ "parallel": {
271
+ "jobs": 1,
272
+ },
273
+ # Per-instrument overrides keyed by serial-number identifier (matched
274
+ # against the parent directory of each .p file). Allowed inner keys:
275
+ # exclude_shear_probes : list[str] (e.g. ["sh1"] or ["sh2"])
276
+ "instruments": {},
277
+ }
278
+
279
+ # Keys excluded from hashing — runtime toggles that must NOT create new output
280
+ # dirs: ``diagnostics`` (extra plots), and the cache-bypass flags ``force`` /
281
+ # ``force_trim`` (they re-run work into the SAME dirs, so they cannot change the
282
+ # dir identity or --force would defeat itself by recomputing into fresh dirs).
283
+ _HASH_EXCLUDE_KEYS = frozenset({"diagnostics", "force", "force_trim", "config_dir"})
284
+
285
+ # Sections whose inner keys are user-defined (e.g. instrument serials).
286
+ # ConfigManager skips strict unknown-key validation for these; per-section
287
+ # inner-schema validation happens in a wrapper below.
288
+ _DYNAMIC_KEY_SECTIONS = frozenset({"instruments"})
289
+
290
+ _INSTRUMENT_VALID_KEYS = frozenset({"exclude_shear_probes"})
291
+
292
+ # Numeric dependencies whose version can change ε/χ/N² outputs even with no
293
+ # change to our own source — folded into the engine fingerprint so a dep
294
+ # upgrade also invalidates cached results.
295
+ _ENGINE_DEPS = ("numpy", "scipy", "gsw", "netCDF4", "xarray", "pandas")
296
+ # Subpackages that cannot affect processing numerics; excluded from the source
297
+ # hash so editing plots/standalone tools doesn't invalidate cached science.
298
+ _FINGERPRINT_EXCLUDE = ("perturb/plot/", "pyturb/")
299
+ _ENGINE_OVERRIDE_ENV = "ODAS_TPW_ENGINE_FINGERPRINT"
300
+
301
+
302
+ @functools.lru_cache(maxsize=1)
303
+ def engine_fingerprint() -> str:
304
+ """Hash of the processing *code + key numeric deps*.
305
+
306
+ Folded into every stage signature so a change that could alter outputs
307
+ yields new ``{stage}_NN`` dirs (recompute) while an unchanged engine reuses
308
+ them — making "outputs already exist in this dir" a *safe* cache signal.
309
+ Covers our own ``.py`` source (excluding plot/pyturb) plus the installed
310
+ versions of the distribution and key numeric deps.
311
+
312
+ Override with ``$ODAS_TPW_ENGINE_FINGERPRINT`` to pin the value — to force
313
+ cache reuse across a code change you KNOW can't affect numerics, or for
314
+ deterministic tests.
315
+ """
316
+ override = os.environ.get(_ENGINE_OVERRIDE_ENV)
317
+ if override:
318
+ return override
319
+
320
+ h = hashlib.sha256()
321
+ for dist in ("microstructure-tpw", *_ENGINE_DEPS):
322
+ try:
323
+ version = importlib.metadata.version(dist)
324
+ except importlib.metadata.PackageNotFoundError:
325
+ version = "?"
326
+ h.update(f"{dist}={version}\n".encode())
327
+
328
+ pkg_root = Path(__file__).resolve().parent.parent # .../odas_tpw
329
+ n_py = 0
330
+ for py in sorted(pkg_root.rglob("*.py")):
331
+ rel = py.relative_to(pkg_root).as_posix()
332
+ if any(rel.startswith(excl) for excl in _FINGERPRINT_EXCLUDE):
333
+ continue
334
+ try:
335
+ data = py.read_bytes()
336
+ except OSError:
337
+ continue
338
+ h.update(rel.encode())
339
+ h.update(b"\0")
340
+ h.update(hashlib.sha256(data).digest())
341
+ n_py += 1
342
+ if n_py == 0:
343
+ warnings.warn(
344
+ "engine_fingerprint: no .py source found under "
345
+ f"{pkg_root} (compiled/zip install?); the processing cache is keyed "
346
+ "on dependency versions only and will NOT detect source edits — set "
347
+ f"${_ENGINE_OVERRIDE_ENV} to manage cache invalidation manually.",
348
+ stacklevel=2,
349
+ )
350
+ return h.hexdigest()
351
+
352
+
353
+ _WINDOW_KEY_PAIRS = (
354
+ ("fft_length", "fft_sec"),
355
+ ("diss_length", "diss_sec"),
356
+ ("overlap", "overlap_sec"),
357
+ )
358
+
359
+ _WINDOW_SECTIONS = ("epsilon", "chi")
360
+
361
+
362
+ class _PerturbConfigManager(ConfigManager):
363
+ """ConfigManager with perturb's window-duration key semantics.
364
+
365
+ In [epsilon]/[chi] a duration key (fft_sec/diss_sec/overlap_sec) is
366
+ dropped from every section VIEW — the canonical form that is hashed
367
+ into stage-directory signatures, and the resolved config.yaml written
368
+ into each stage dir — when it is inert: null, or overridden by a
369
+ non-null sample-count twin. A legacy config that pins fft_length
370
+ therefore keeps a byte-identical canonical form (and signature) across
371
+ this feature; only a GOVERNING duration key changes hashes.
372
+ """
373
+
374
+ def _section_view_postprocess(self, section: str, mapping: dict) -> dict:
375
+ if section in _WINDOW_SECTIONS:
376
+ for samples_key, sec_key in _WINDOW_KEY_PAIRS:
377
+ if sec_key in mapping and (
378
+ mapping[sec_key] is None or mapping.get(samples_key) is not None
379
+ ):
380
+ del mapping[sec_key]
381
+ return mapping
382
+
383
+ def validate_config(self, config: dict) -> None:
384
+ """Structural validation plus fs-independent window-duration checks.
385
+
386
+ Durations are validated at LOAD time so a sign typo (fft_sec: -1)
387
+ or an inverted window pair fails before any stage directory is
388
+ created, rather than aborting mid-run inside per-file processing.
389
+ """
390
+ super().validate_config(config)
391
+ for section in _WINDOW_SECTIONS:
392
+ params = config.get(section) or {}
393
+ for _, sec_key in _WINDOW_KEY_PAIRS:
394
+ val = params.get(sec_key)
395
+ if val is None:
396
+ continue
397
+ if not isinstance(val, (int, float)) or isinstance(val, bool):
398
+ raise ValueError(
399
+ f"{section}.{sec_key}: expected a number of seconds, got {val!r}"
400
+ )
401
+ if not (val > 0 and val == val and val != float("inf")):
402
+ raise ValueError(
403
+ f"{section}.{sec_key}: must be a positive finite "
404
+ f"number of seconds, got {val!r}"
405
+ )
406
+ fft_s, diss_s = params.get("fft_sec"), params.get("diss_sec")
407
+ if (
408
+ isinstance(fft_s, (int, float))
409
+ and isinstance(diss_s, (int, float))
410
+ and diss_s < fft_s
411
+ ):
412
+ raise ValueError(
413
+ f"{section}: diss_sec ({diss_s}) is shorter than fft_sec ({fft_s})"
414
+ )
415
+
416
+
417
+ _mgr = _PerturbConfigManager(
418
+ DEFAULTS,
419
+ hash_exclude_keys=_HASH_EXCLUDE_KEYS,
420
+ dynamic_key_sections=_DYNAMIC_KEY_SECTIONS,
421
+ engine_fingerprint=engine_fingerprint,
422
+ )
423
+
424
+
425
+ def _validate_instruments(instruments: dict) -> None:
426
+ """Validate the inner structure of the dynamic ``instruments`` section."""
427
+ for sn, settings in instruments.items():
428
+ if not isinstance(settings, dict):
429
+ raise ValueError(f"instruments.{sn}: must be a mapping, got {type(settings).__name__}")
430
+ unknown = set(settings) - _INSTRUMENT_VALID_KEYS
431
+ if unknown:
432
+ raise ValueError(
433
+ f"Unknown key(s) in instruments.{sn}: {sorted(unknown)}. "
434
+ f"Valid keys: {sorted(_INSTRUMENT_VALID_KEYS)}"
435
+ )
436
+ excludes = settings.get("exclude_shear_probes", [])
437
+ if not isinstance(excludes, list) or not all(isinstance(p, str) for p in excludes):
438
+ raise ValueError(
439
+ f"instruments.{sn}.exclude_shear_probes: must be a list of strings, "
440
+ f"got {excludes!r}"
441
+ )
442
+
443
+
444
+ CONFIG_DIR_TOKEN = "<CONFIG_DIR>"
445
+
446
+
447
+ def expand_config_dir(value: Any, config_dir: str | None) -> Any:
448
+ """Resolve a leading ``<CONFIG_DIR>`` token against *config_dir*.
449
+
450
+ A path written as ``<CONFIG_DIR>/sub`` in a YAML config is made absolute
451
+ relative to the config file's own directory, so a config plus its data tree
452
+ can be moved (or mounted at a different point) without editing paths. Values
453
+ without the token — and non-strings — pass through unchanged. Raises
454
+ ``ValueError`` if the token is used but no config directory is known (e.g. a
455
+ config assembled in memory rather than loaded from a file).
456
+ """
457
+ # Accept str or os.PathLike (a Path holding the token must not slip through
458
+ # the isinstance check and reach the filesystem verbatim). None/numbers and
459
+ # any non-token value pass through unchanged, preserving the original type.
460
+ if isinstance(value, str):
461
+ text = value
462
+ elif isinstance(value, os.PathLike):
463
+ text = os.fspath(value)
464
+ else:
465
+ return value
466
+ if not text.startswith(CONFIG_DIR_TOKEN):
467
+ return value
468
+ if not config_dir:
469
+ raise ValueError(
470
+ f"{CONFIG_DIR_TOKEN} in path {text!r} requires a config loaded from "
471
+ f"a file (no config directory is known)"
472
+ )
473
+ # Split the remainder on either separator and re-join with the OS separator,
474
+ # so a config's forward-slash paths yield a clean native path on Windows too
475
+ # (a bare os.path.join would leave the internal "/" -> a mixed "C:\x/y").
476
+ parts = [seg for seg in text[len(CONFIG_DIR_TOKEN) :].replace("\\", "/").split("/") if seg]
477
+ return os.path.join(config_dir, *parts) if parts else config_dir
478
+
479
+
480
+ def config_dir_of(config: dict) -> str | None:
481
+ """The absolute directory of the loaded config, or None (see load_config)."""
482
+ return (config.get("files") or {}).get("config_dir")
483
+
484
+
485
+ def _load_config_with_instruments_check(path: str | Path) -> dict[str, dict]:
486
+ """Load and validate a perturb config, including the instruments section."""
487
+ config = _mgr.load_config(path)
488
+ _validate_instruments(config.get("instruments", {}))
489
+ # Stamp the config's own directory so <CONFIG_DIR> path tokens resolve
490
+ # relative to it. Excluded from every stage hash (_HASH_EXCLUDE_KEYS) so the
491
+ # same config on a different mount still matches its existing output dirs.
492
+ # An empty config file loads to {} (shared contract, and it has no paths to
493
+ # resolve), so it is left untouched.
494
+ if config:
495
+ config.setdefault("files", {})["config_dir"] = str(Path(path).resolve().parent)
496
+ return config
497
+
498
+
499
+ def _validate_config_with_instruments_check(config: dict[str, dict]) -> None:
500
+ """Validate a perturb config including the instruments section."""
501
+ _mgr.validate_config(config)
502
+ _validate_instruments(config.get("instruments", {}))
503
+
504
+
505
+ # Re-export manager methods as module-level functions
506
+ load_config = _load_config_with_instruments_check
507
+ validate_config = _validate_config_with_instruments_check
508
+
509
+
510
+ def merge_config(
511
+ section: str,
512
+ file_values: dict | None = None,
513
+ cli_overrides: dict | None = None,
514
+ ) -> dict:
515
+ """merge defaults <- file <- CLI, then drop inert window-duration keys.
516
+
517
+ In the [epsilon]/[chi] sections an explicit sample-count key
518
+ (fft_length/diss_length/overlap) wins over its duration twin
519
+ (fft_sec/diss_sec/overlap_sec); the duration key is then inert and is
520
+ removed so it cannot perturb the stage-directory signature — a legacy
521
+ config that pins fft_length keeps a bit-identical signature.
522
+ """
523
+ merged = _mgr.merge_config(section, file_values, cli_overrides)
524
+ if section in ("epsilon", "chi"):
525
+ for samples_key, sec_key in _WINDOW_KEY_PAIRS:
526
+ if samples_key in merged and sec_key in merged:
527
+ del merged[sec_key]
528
+ return merged
529
+
530
+
531
+ def resolve_window_config(cfg: dict, fs: float, *, section: str = "epsilon") -> dict:
532
+ """Resolve fft/diss/overlap windows to integer sample counts at rate *fs*.
533
+
534
+ *cfg* is a merge_config-merged [epsilon] or [chi] section. Duration keys
535
+ (seconds) are converted via *fs* and rounded to the nearest even sample
536
+ count; explicit sample keys pass through unchanged (they won at merge
537
+ time). Defaults: fft = 1 s, dissipation window = 4 x fft, overlap =
538
+ downstream default (half the window). Returns a copy with concrete
539
+ integer ``fft_length``/``diss_length`` (and ``overlap`` when
540
+ determined) and the ``*_sec`` keys removed, suitable for splatting into
541
+ the compute functions.
542
+ """
543
+ if not (fs and fs > 0):
544
+ raise ValueError(f"{section}: invalid sampling rate {fs!r}")
545
+
546
+ import math
547
+
548
+ def seconds(key: str) -> float | None:
549
+ val = cfg.get(key)
550
+ if val is None:
551
+ return None
552
+ try:
553
+ sec = float(val)
554
+ except (TypeError, ValueError):
555
+ raise ValueError(
556
+ f"{section}.{key}: expected a number of seconds, got {val!r}"
557
+ ) from None
558
+ if not (math.isfinite(sec) and sec > 0):
559
+ raise ValueError(
560
+ f"{section}.{key}: must be a positive finite number of seconds, got {val!r}"
561
+ )
562
+ return sec
563
+
564
+ def even(x: float) -> int:
565
+ return max(2, 2 * round(x / 2.0))
566
+
567
+ out = {k: v for k, v in cfg.items() if k not in ("fft_sec", "diss_sec", "overlap_sec")}
568
+ fft = cfg.get("fft_length")
569
+ if fft is None:
570
+ fft = even(fs * (seconds("fft_sec") or 1.0))
571
+ fft = int(fft)
572
+ diss = cfg.get("diss_length")
573
+ if diss is None:
574
+ diss_sec = seconds("diss_sec")
575
+ diss = even(fs * diss_sec) if diss_sec is not None else 4 * fft
576
+ diss = int(diss)
577
+ if diss < fft:
578
+ raise ValueError(
579
+ f"{section}: dissipation window ({diss} samples) is shorter than "
580
+ f"the FFT segment ({fft} samples)"
581
+ )
582
+ overlap = cfg.get("overlap")
583
+ if overlap is None:
584
+ overlap_sec = seconds("overlap_sec")
585
+ if overlap_sec is not None:
586
+ overlap = even(fs * overlap_sec)
587
+ if overlap is not None:
588
+ overlap = int(overlap)
589
+ if overlap >= diss:
590
+ raise ValueError(
591
+ f"{section}: overlap ({overlap} samples) must be smaller than "
592
+ f"the dissipation window ({diss} samples)"
593
+ )
594
+ out["overlap"] = overlap
595
+ out["fft_length"] = fft
596
+ out["diss_length"] = diss
597
+ return out
598
+
599
+
600
+ canonicalize = _mgr.canonicalize
601
+ compute_hash = _mgr.compute_hash
602
+ resolve_output_dir = _mgr.resolve_output_dir
603
+ write_signature = _mgr.write_signature
604
+ write_resolved_config = _mgr.write_resolved_config
605
+
606
+
607
+ # ---------------------------------------------------------------------------
608
+ # Per-stage signature inputs (relocated here from pipeline.py so the read-only
609
+ # plot-time resolver in resolve.py can reproduce a stage's signature without
610
+ # importing the heavy pipeline module). The pipeline imports these back.
611
+ # ---------------------------------------------------------------------------
612
+
613
+ # The section whose params are passed *positionally* to resolve_output_dir for
614
+ # each stage. NOT always the stage name (e.g. `diss` hashes under `epsilon`,
615
+ # every combo under `binning`/`ctd`) — mirrors the resolve_output_dir() calls
616
+ # in pipeline.py. The discriminating detail lives in the upstream chain.
617
+ _STAGE_SECTION: dict[str, str] = {
618
+ "profiles": "profiles",
619
+ "diss": "epsilon",
620
+ "chi": "chi",
621
+ "ctd": "ctd",
622
+ "profiles_binned": "binning",
623
+ "diss_binned": "binning",
624
+ "chi_binned": "binning",
625
+ "combo": "binning",
626
+ "diss_combo": "binning",
627
+ "chi_combo": "binning",
628
+ "ctd_combo": "ctd",
629
+ }
630
+
631
+ #: Stages a caller (e.g. the plot resolver) may resolve to a ``{stage}_NN`` dir.
632
+ STAGES: frozenset[str] = frozenset(_STAGE_SECTION)
633
+
634
+
635
+ def canonical_instruments_for_hash(instruments: dict | None) -> dict[str, Any]:
636
+ """Normalize set-like instrument settings before hashing."""
637
+ normalized: dict[str, Any] = {}
638
+ # Sort by str(key): instrument serials mix int (unquoted `465:`) and str keys.
639
+ for key, settings in sorted((instruments or {}).items(), key=lambda kv: str(kv[0])):
640
+ if not isinstance(settings, dict):
641
+ normalized[str(key)] = settings
642
+ continue
643
+ item = dict(settings)
644
+ excludes = item.get("exclude_shear_probes")
645
+ if isinstance(excludes, list):
646
+ item["exclude_shear_probes"] = sorted(str(probe) for probe in excludes)
647
+ normalized[str(key)] = item
648
+ return normalized
649
+
650
+
651
+ def upstream_for(stage: str, config: dict) -> list[tuple[str, dict]]:
652
+ """Return the full upstream parameter chain for *stage*.
653
+
654
+ Each downstream stage's ``.params_sha256_*`` signature is the hash of the
655
+ stage's own params plus every ancestor's params, so two runs that differ
656
+ only in a deep upstream knob still resolve to different output directories.
657
+ """
658
+ files_p = merge_config("files", config.get("files"))
659
+ gps_p = merge_config("gps", config.get("gps"))
660
+ hotel_p = merge_config("hotel", config.get("hotel"))
661
+ speed_p = merge_config("speed", config.get("speed"))
662
+ qc_p = merge_config("qc", config.get("qc"))
663
+ fp07_p = merge_config("fp07", config.get("fp07"))
664
+ ct_p = merge_config("ct", config.get("ct"))
665
+ bottom_p = merge_config("bottom", config.get("bottom"))
666
+ top_trim_p = merge_config("top_trim", config.get("top_trim"))
667
+ profiles_p = merge_config("profiles", config.get("profiles"))
668
+ eps_p = merge_config("epsilon", config.get("epsilon"))
669
+ chi_p = merge_config("chi", config.get("chi"))
670
+ ctd_p = merge_config("ctd", config.get("ctd"))
671
+ netcdf_p = merge_config("netcdf", config.get("netcdf"))
672
+ strat_p = merge_config("stratification", config.get("stratification"))
673
+ instruments_p = canonical_instruments_for_hash(config.get("instruments"))
674
+
675
+ profile_upstream = [
676
+ ("files", files_p),
677
+ ("gps", gps_p),
678
+ ("hotel", hotel_p),
679
+ ("speed", speed_p),
680
+ ("qc", qc_p),
681
+ ("fp07", fp07_p),
682
+ ("ct", ct_p),
683
+ ("bottom", bottom_p),
684
+ ("top_trim", top_trim_p),
685
+ # N2/dT/dz are injected onto the profile/diss products and gated by
686
+ # stratification.{enable,window}, so they must re-version those outputs.
687
+ ("stratification", strat_p),
688
+ ]
689
+ profile_chain = [*profile_upstream, ("profiles", profiles_p)]
690
+ diss_chain = [*profile_chain, ("instruments", instruments_p)]
691
+ chi_chain = [*diss_chain, ("epsilon", eps_p)]
692
+ # CTD salinity/density come from CT-aligned conductivity (depends on ct.* and
693
+ # the detected profiles) — so ct and profiles must be hashed. The CTD product
694
+ # does NOT carry the background N2/dT/dz (those are profile-only), so
695
+ # stratification.* deliberately does not version it.
696
+ ctd_chain = [
697
+ ("files", files_p),
698
+ ("gps", gps_p),
699
+ ("hotel", hotel_p),
700
+ ("speed", speed_p),
701
+ ("qc", qc_p),
702
+ ("ct", ct_p),
703
+ ("profiles", profiles_p),
704
+ ]
705
+
706
+ chains: dict[str, list[tuple[str, dict]]] = {
707
+ "profiles": profile_upstream,
708
+ "diss": diss_chain,
709
+ "chi": chi_chain,
710
+ "ctd": ctd_chain,
711
+ "profiles_binned": profile_chain,
712
+ "diss_binned": [*diss_chain, ("epsilon", eps_p)],
713
+ "chi_binned": [*chi_chain, ("chi", chi_p)],
714
+ "combo": [*profile_chain, ("netcdf", netcdf_p)],
715
+ "diss_combo": [*diss_chain, ("epsilon", eps_p), ("netcdf", netcdf_p)],
716
+ "chi_combo": [*chi_chain, ("chi", chi_p), ("netcdf", netcdf_p)],
717
+ "ctd_combo": [*ctd_chain, ("ctd", ctd_p), ("netcdf", netcdf_p)],
718
+ }
719
+ return chains[stage]
720
+
721
+
722
+ def stage_signature(stage: str, config: dict) -> tuple[str, dict, list[tuple[str, dict]]]:
723
+ """The exact ``(section, params, upstream)`` triple the pipeline passes to
724
+ ``resolve_output_dir`` for *stage* — the single source of truth shared by
725
+ the pipeline and the plot resolver."""
726
+ section = _STAGE_SECTION[stage]
727
+ params = merge_config(section, config.get(section))
728
+ return section, params, upstream_for(stage, config)
729
+
730
+
731
+ # Also keep _VALID_SECTIONS for any direct references
732
+ _VALID_SECTIONS = _mgr.valid_sections
733
+
734
+
735
+ # ---------------------------------------------------------------------------
736
+ # Template generation
737
+ # ---------------------------------------------------------------------------
738
+
739
+ _TEMPLATE = """\
740
+ # perturb configuration
741
+ # Values shown are the defaults. Uncomment and edit to customize.
742
+ # CLI flags override values in this file.
743
+ #
744
+ # Paths (p_file_root, output_root, gps.file, hotel.file) may begin with the
745
+ # token <CONFIG_DIR>, which expands to this file's own directory — so a config
746
+ # plus its data tree can be moved or mounted elsewhere without editing paths
747
+ # (e.g. p_file_root: <CONFIG_DIR>/VMP). Relative paths without the token stay
748
+ # relative to the current working directory, as before. The token — not the
749
+ # resolved absolute path — is what feeds the cache signatures, so a remount
750
+ # reuses existing output directories.
751
+
752
+ files:
753
+ p_file_root: "VMP/"
754
+ p_file_pattern: "**/*.p"
755
+ output_root: "results/"
756
+ trim: true
757
+ force_trim: false # re-trim even when output is already up to date
758
+ merge: false # size-limit rollovers only; fuses independent casts otherwise (#97)
759
+
760
+ gps:
761
+ source: "nan" # nan | fixed | csv | netcdf
762
+ lat: null
763
+ lon: null
764
+ file: null
765
+ time_col: null # null = source default ("t" for csv, "time" for netcdf)
766
+ lat_col: "lat"
767
+ lon_col: "lon"
768
+ max_time_diff: 60 # warn when extrapolating > this [s] outside GPS coverage
769
+
770
+ hotel:
771
+ enable: false
772
+ file: null # path to hotel file (CSV, NetCDF, or .mat)
773
+ time_column: "time" # time column/variable name
774
+ time_format: "auto" # auto | seconds | epoch | iso
775
+ channels: {} # source-keyed selection. {} = take every source
776
+ # variable. Each value can be:
777
+ # ~ (null) / {} : include with defaults
778
+ # "new_name" : rename only
779
+ # {name, interp, : per-variable options
780
+ # scale, offset, # (any subset). replace:true
781
+ # units, fast, # lets a hotel channel
782
+ # replace} # overwrite a native one
783
+ # # (else refused; pair with
784
+ # # fast:false for slow P).
785
+ fast_channels: # default fast set when 'fast' is unset per-var
786
+ - speed
787
+ - P
788
+ interpolation: "pchip" # default kind: pchip | linear | nearest |
789
+ # previous | next | zero | slinear | quadratic
790
+ # | cubic. Per-variable interp wins.
791
+
792
+ profiles:
793
+ P_min: 0.5 # minimum pressure [dbar]
794
+ W_min: 0.3 # minimum fall rate [dbar/s]
795
+ direction: "down" # profile direction: up or down
796
+ min_duration: 7.0 # minimum profile duration [s]
797
+ diagnostics: false # RESERVED / not yet implemented (would write T1_raw,
798
+ # C_raw, W, cal attrs). Currently a no-op for profiles.
799
+
800
+ fp07:
801
+ calibrate: true
802
+ order: 2 # Steinhart-Hart polynomial order
803
+ max_lag_seconds: 10 # max cross-correlation lag [s]
804
+ reference: "JAC_T" # reference temperature channel
805
+ must_be_negative: true # expect negative lag (falling VMP)
806
+
807
+ ct:
808
+ align: true
809
+ T_name: "JAC_T" # temperature channel for alignment
810
+ C_name: "JAC_C" # conductivity channel for alignment
811
+
812
+ bottom:
813
+ enable: false
814
+ depth_window: 4.0 # depth window for crash detection [m]
815
+ depth_minimum: 10.0 # minimum depth to search [m]
816
+ speed_factor: 0.3 # UNUSED (reserved; tuning has no effect)
817
+ median_factor: 1.0 # UNUSED (reserved; tuning has no effect)
818
+ vibration_frequency: 16 # UNUSED (reserved; tuning has no effect)
819
+ vibration_factor: 4.0 # vibration std dev acceptance factor
820
+
821
+ top_trim:
822
+ enable: false
823
+ dz: 0.5 # depth bin size [m]
824
+ min_depth: 1.0 # minimum search depth [m]
825
+ max_depth: 50.0 # maximum search depth [m]
826
+ quantile: 0.6 # quantile of per-bin std taken as settled background
827
+ noise_factor: 2.0 # std > noise_factor*background == still in prop wash
828
+
829
+ epsilon:
830
+ # Windows are specified as DURATIONS (seconds) and converted per instrument
831
+ # via its sampling rate (standard VMP-250: 512 Hz; coastal units: 1-2 kHz),
832
+ # so one config serves a mixed fleet. Choosing fft_sec is a sandwich
833
+ # (Lueck et al. 2024, Front. Mar. Sci. 11:1334327,
834
+ # doi:10.3389/fmars.2024.1334327):
835
+ # - MINIMUM: the spectrum must resolve its peak, so the lowest resolved
836
+ # wavenumber k_l = 1/(fft_sec*W) must be <= ~0.5 cpm for low epsilon
837
+ # (<~1e-9 W/kg), ~1 cpm for moderate (<~1e-7), ~2 cpm for high.
838
+ # - MAXIMUM: the FFT span fft_sec*W must not exceed the profiler's body
839
+ # length (larger eddies advect the whole body; their shear is
840
+ # attenuated, not measured).
841
+ # For a ~1-m VMP-250 at ~1 m/s the bounds meet at fft_sec = 1.0, and
842
+ # epsilon <~1e-9 cannot resolve the peak on that platform. Slower/longer
843
+ # platforms (MicroRider on a glider) shift both bounds; see
844
+ # docs/perturb/dissipation_length.md.
845
+ fft_sec: 1.0 # FFT segment duration [s]
846
+ diss_sec: null # dissipation window [s] (null = 4 * fft_sec);
847
+ # longer windows lower the statistical uncertainty
848
+ # (sigma_lnE, Lueck 2022 doi:10.1175/JTECH-D-21-0051.1)
849
+ # at the cost of vertical resolution and patch mixing
850
+ overlap_sec: null # window overlap [s] (null = half the window)
851
+ fft_length: null # EXPERT override [samples]; wins over fft_sec
852
+ diss_length: null # EXPERT override [samples]; wins over diss_sec
853
+ overlap: null # EXPERT override [samples]; wins over overlap_sec
854
+ goodman: true # Goodman coherent noise removal
855
+ f_AA: 98.0 # anti-aliasing filter cutoff [Hz]
856
+ f_limit: null # upper frequency limit [Hz] (null = f_AA)
857
+ fit_order: 3 # polynomial fit order for Nasmyth integration
858
+ despike_thresh: 8 # despike threshold (rectified-HP / LP-envelope ratio, not MAD)
859
+ despike_smooth: 0.5 # despike envelope low-pass cutoff [Hz]
860
+ salinity: null # salinity [PSU] (null = 35, fixed S)
861
+ epsilon_minimum: 1.0e-13 # floor for small epsilon values
862
+ T_source: null # temperature source for viscosity (null = blend T1/T2)
863
+ T1_norm: 1.0 # T1 blending weight
864
+ T2_norm: 1.0 # T2 blending weight
865
+ fom_max: null # null = NO spectral-fit QC on epsilon. IMPORTANT: unlike
866
+ # the rsi run_pipeline path (which applies the full ATOMIX
867
+ # flag set -- FM>1.15, var_resolved<0.5, despike limits --
868
+ # via _compute_flags), the perturb pipeline masks per-probe
869
+ # epsilon ONLY via this cut. With null, epsilonMean / binned
870
+ # / combo include estimates ATOMIX QC would reject. Set e.g.
871
+ # 2.0 to NaN each per-probe cell (e_N, epsilon[probe,:])
872
+ # whose variance-ratio FOM >= fom_max BEFORE mk_epsilon_mean,
873
+ # so bad probes drop out of the geomean individually. (Note
874
+ # this thresholds the variance-ratio `fom`, not the Lueck
875
+ # `FM` the file also carries.)
876
+ diagnostics: false # RESERVED / not yet implemented (would write
877
+ # spec_shear_raw, spike_count, probe_mask). No-op for epsilon.
878
+
879
+ chi:
880
+ enable: false # chi is optional, separate stage after diss
881
+ fft_sec: 1.0 # FFT segment duration [s]; same sandwich
882
+ # constraints as epsilon.fft_sec above
883
+ diss_sec: null # dissipation window [s] (null = 4 * fft_sec)
884
+ overlap_sec: null # window overlap [s] (null = half the window)
885
+ fft_length: null # EXPERT override [samples]; wins over fft_sec
886
+ diss_length: null # EXPERT override [samples]; wins over diss_sec
887
+ overlap: null # EXPERT override [samples]; wins over overlap_sec
888
+ fp07_model: "single_pole" # FP07 transfer function model
889
+ goodman: true # Goodman coherent noise removal
890
+ f_AA: 98.0 # anti-aliasing filter cutoff [Hz]
891
+ use_epsilon: true # true = Method 1: chi from shear-probe epsilon.
892
+ # false = Method 2: spectral fit (uses fit_method).
893
+ # Set false for instruments where shear epsilon is
894
+ # unreliable (e.g. MR on a glider with thruster /
895
+ # surface vibration contaminating shear probes).
896
+ fit_method: "iterative" # Method 2 fitting: iterative or mle (ignored if
897
+ # use_epsilon=true)
898
+ spectrum_model: "kraichnan" # theoretical spectrum: batchelor or kraichnan
899
+ salinity: null # viscosity salinity [PSU]: null = fixed 35; a
900
+ # number = that fixed S; "measured" = per-profile
901
+ # from JAC_C/JAC_T/P (TEOS-10)
902
+ mixing: true # derived mixing quantities (N2, dTdz, K_T, Gamma,
903
+ # K_rho) on the chi grid, with salinity from the
904
+ # profile's own C/T/P (TEOS-10)
905
+ chi_minimum: 1.0e-13 # floor for mk_chi_mean (values <= go to NaN)
906
+ fom_max: null # null = no FOM cut. Same per-probe mechanism as
907
+ # epsilon.fom_max but on chi NCs (NaN's chi[probe,
908
+ # seg] / chi_N where fom[probe, seg] >= fom_max).
909
+ diagnostics: false # RESERVED / not yet implemented (would write
910
+ # spec_noise, fp07_transfer, gradT_raw). No-op for chi.
911
+
912
+ ctd:
913
+ enable: true
914
+ bin_width: 0.5 # time bin width [s]
915
+ T_name: "JAC_T" # temperature channel
916
+ C_name: "JAC_C" # conductivity channel
917
+ variables: null # channels to bin (null = auto-detect)
918
+ method: "mean" # aggregation method
919
+ diagnostics: false # write n_samples, *_std per bin
920
+
921
+ speed:
922
+ # Through-water speed source. Computed AFTER hotel merge so the
923
+ # method has access to both .p-file and hotel channels.
924
+ method: "pressure" # pressure | em | flight | constant
925
+ # pressure : ODAS smoothed |dP/dt|. Correct for VMP. (default)
926
+ # em : use the U_EM channel from the .p file (MicroRider EM
927
+ # flowmeter). Errors out if U_EM is missing.
928
+ # flight : |W| / (sin(|pitch|-aoa)*cos|roll|), pitch axis auto-
929
+ # picked from Incl_X/Incl_Y by amplitude.
930
+ # constant : use the scalar in `value`.
931
+ value: null # m/s, only when method="constant"
932
+ aoa_deg: 3.0 # angle of attack [deg], for method="flight"
933
+ min_pitch_deg: 5.0 # flight: drop samples with |pitch|-aoa < this
934
+ speed_cutout: 0.05 # m/s floor applied to fast-rate speed
935
+ tau: null # smoothing tau [s]; null = vehicle default
936
+ # (vmp/xmp 1.5, slocum_glider 3.0, ...)
937
+ amplitude_quantile: [1.0, 99.0] # flight: percentile spread used to
938
+ # auto-pick the pitch axis from
939
+ # Incl_X/Incl_Y. 1..99 strips outliers
940
+ # (surface tumbles, sensor saturation
941
+ # spikes) that can dominate min/max.
942
+
943
+ qc:
944
+ enable: true
945
+ drop_action: "nan" # nan | flag_only
946
+ # 'nan' NaNs e_*/epsilonMean (and chi_*/chiMean)
947
+ # for flagged segments; 'flag_only' leaves the
948
+ # values untouched. The qc_drop_* bitfield is
949
+ # always written so plots can audit / mask.
950
+ epsilon_drop_from: [] # hotel channel names OR'd over each diss
951
+ # segment's time window. Each named channel
952
+ # should be a uint8 CF bitfield (or 0/1).
953
+ # e.g. ["q_drop_epsilon"]
954
+ chi_drop_from: [] # same, for chi segments
955
+ rules: {} # internal range-check rules; each entry synthesizes a
956
+ # uint8 channel referenceable by *_drop_from. See
957
+ # odas_tpw.perturb.qc_rules for the per-entry schema.
958
+
959
+ binning:
960
+ method: "depth" # depth | time
961
+ width: 1.0 # bin width [m or s]
962
+ aggregation: "mean" # mean | median
963
+ diss_width: null # override bin width for diss
964
+ diss_aggregation: null # override aggregation for diss
965
+ chi_width: null # override bin width for chi
966
+ chi_aggregation: null # override aggregation for chi
967
+ diagnostics: false # write n_samples, *_std per bin
968
+
969
+ netcdf:
970
+ title: null
971
+ summary: null
972
+ institution: null
973
+ creator_name: null
974
+ creator_email: null
975
+ creator_url: null
976
+ creator_type: null
977
+ creator_institution: null
978
+ contributor_name: null
979
+ contributor_role: null
980
+ publisher_name: null
981
+ publisher_email: null
982
+ publisher_url: null
983
+ publisher_type: null
984
+ publisher_institution: null
985
+ project: null
986
+ program: null
987
+ id: null
988
+ naming_authority: null
989
+ source: null
990
+ platform: null
991
+ platform_vocabulary: null
992
+ instrument: null
993
+ instrument_vocabulary: null
994
+ processing_level: null
995
+ license: null
996
+ references: null
997
+ comment: null
998
+ acknowledgement: null
999
+ date_created: null # auto-filled if null
1000
+ date_modified: null # auto-filled if null
1001
+ date_issued: null
1002
+ geospatial_lat_min: null # auto-filled from data if null
1003
+ geospatial_lat_max: null
1004
+ geospatial_lon_min: null
1005
+ geospatial_lon_max: null
1006
+ geospatial_vertical_min: null
1007
+ geospatial_vertical_max: null
1008
+ geospatial_vertical_positive: "down"
1009
+ time_coverage_start: null # auto-filled from data if null
1010
+ time_coverage_end: null
1011
+ time_coverage_duration: null
1012
+ time_coverage_resolution: null
1013
+ keywords: null
1014
+ keywords_vocabulary: null
1015
+ standard_name_vocabulary: "CF Standard Name Table v89"
1016
+ Conventions: "CF-1.13, ACDD-1.3"
1017
+ history: null # auto-filled with processing log
1018
+
1019
+ stratification:
1020
+ enable: true # write N2 and dT/dz (Thorpe-sorted) to the diss and
1021
+ # profile products, independent of eps/chi. Not on the
1022
+ # CTD product (profile-only; CTD spans up+down).
1023
+ window: 2.0 # background vertical window [dbar] for the profile
1024
+ # product (diss uses its dissipation window)
1025
+
1026
+ parallel:
1027
+ jobs: 1
1028
+
1029
+ # Per-instrument overrides. The key matches the parent directory of each .p
1030
+ # file (e.g. ARCTERX/VMP/SN465 -> SN465). Use this to suppress shear probes
1031
+ # whose amplifier or sensor is known to be bad — the named probe is NaN'd
1032
+ # out before mk_epsilon_mean, so it is excluded from the multi-probe
1033
+ # epsilonMean and from chi Method 1 (which uses epsilonMean).
1034
+ instruments: {}
1035
+ # Example:
1036
+ # instruments:
1037
+ # SN465:
1038
+ # exclude_shear_probes: ["sh2"]
1039
+ """
1040
+
1041
+
1042
+ def generate_template(path: str | Path) -> Path:
1043
+ """Write a fully-commented template configuration file."""
1044
+ path = Path(path)
1045
+ path.write_text(_TEMPLATE, encoding="utf-8")
1046
+ return path