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.
- microstructure_tpw-0.3.0.dist-info/METADATA +234 -0
- microstructure_tpw-0.3.0.dist-info/RECORD +119 -0
- microstructure_tpw-0.3.0.dist-info/WHEEL +5 -0
- microstructure_tpw-0.3.0.dist-info/entry_points.txt +7 -0
- microstructure_tpw-0.3.0.dist-info/licenses/LICENSE +674 -0
- microstructure_tpw-0.3.0.dist-info/top_level.txt +1 -0
- odas_tpw/__init__.py +18 -0
- odas_tpw/chi/__init__.py +48 -0
- odas_tpw/chi/batchelor.py +189 -0
- odas_tpw/chi/chi.py +857 -0
- odas_tpw/chi/fp07.py +649 -0
- odas_tpw/chi/l2_chi.py +153 -0
- odas_tpw/chi/l3_chi.py +388 -0
- odas_tpw/chi/l4_chi.py +391 -0
- odas_tpw/config_base.py +423 -0
- odas_tpw/perturb/__init__.py +18 -0
- odas_tpw/perturb/adcp.py +274 -0
- odas_tpw/perturb/atomic_io.py +47 -0
- odas_tpw/perturb/binning.py +761 -0
- odas_tpw/perturb/cli.py +591 -0
- odas_tpw/perturb/combo.py +346 -0
- odas_tpw/perturb/config.py +1046 -0
- odas_tpw/perturb/ctd.py +424 -0
- odas_tpw/perturb/diag/__init__.py +12 -0
- odas_tpw/perturb/diag/_common.py +140 -0
- odas_tpw/perturb/diag/chi.py +42 -0
- odas_tpw/perturb/diag/cli.py +67 -0
- odas_tpw/perturb/diag/data.py +662 -0
- odas_tpw/perturb/diag/epsilon.py +45 -0
- odas_tpw/perturb/diag/inspector.py +342 -0
- odas_tpw/perturb/diag/mixing.py +94 -0
- odas_tpw/perturb/diag/render.py +543 -0
- odas_tpw/perturb/discover.py +40 -0
- odas_tpw/perturb/fp07_cal.py +354 -0
- odas_tpw/perturb/gps.py +458 -0
- odas_tpw/perturb/hotel.py +572 -0
- odas_tpw/perturb/logging_setup.py +181 -0
- odas_tpw/perturb/merge.py +320 -0
- odas_tpw/perturb/mk_sections.py +310 -0
- odas_tpw/perturb/netcdf_schema.py +520 -0
- odas_tpw/perturb/pipeline.py +2654 -0
- odas_tpw/perturb/plot/__init__.py +15 -0
- odas_tpw/perturb/plot/cli.py +107 -0
- odas_tpw/perturb/plot/diagnostics.py +195 -0
- odas_tpw/perturb/plot/eps_chi.py +524 -0
- odas_tpw/perturb/plot/figure.py +679 -0
- odas_tpw/perturb/plot/gamma_scaling.py +1097 -0
- odas_tpw/perturb/plot/grid.py +112 -0
- odas_tpw/perturb/plot/layout.py +361 -0
- odas_tpw/perturb/plot/overview.py +547 -0
- odas_tpw/perturb/plot/presets/eps-chi.yaml +16 -0
- odas_tpw/perturb/plot/presets/profiles.yaml +32 -0
- odas_tpw/perturb/plot/presets/scalar.yaml +21 -0
- odas_tpw/perturb/plot/profiles.py +652 -0
- odas_tpw/perturb/plot/scalar.py +391 -0
- odas_tpw/perturb/plot/sections.py +506 -0
- odas_tpw/perturb/plot/xaxis.py +396 -0
- odas_tpw/perturb/qc_gate.py +177 -0
- odas_tpw/perturb/qc_rules.py +373 -0
- odas_tpw/perturb/resolve.py +306 -0
- odas_tpw/perturb/seawater.py +76 -0
- odas_tpw/perturb/speed.py +14 -0
- odas_tpw/perturb/trim.py +313 -0
- odas_tpw/processing/__init__.py +62 -0
- odas_tpw/processing/bottom.py +215 -0
- odas_tpw/processing/chi_combine.py +300 -0
- odas_tpw/processing/ct_align.py +172 -0
- odas_tpw/processing/epsilon_combine.py +210 -0
- odas_tpw/processing/mixing.py +630 -0
- odas_tpw/processing/thorpe.py +458 -0
- odas_tpw/processing/top_trim.py +274 -0
- odas_tpw/pyturb/__init__.py +3 -0
- odas_tpw/pyturb/__main__.py +5 -0
- odas_tpw/pyturb/_compat.py +204 -0
- odas_tpw/pyturb/_profind.py +124 -0
- odas_tpw/pyturb/bin.py +113 -0
- odas_tpw/pyturb/cli.py +175 -0
- odas_tpw/pyturb/eps.py +353 -0
- odas_tpw/pyturb/merge.py +101 -0
- odas_tpw/pyturb/p2nc.py +76 -0
- odas_tpw/rsi/__init__.py +31 -0
- odas_tpw/rsi/adapter.py +314 -0
- odas_tpw/rsi/bench.py +904 -0
- odas_tpw/rsi/binning.py +193 -0
- odas_tpw/rsi/channels.py +376 -0
- odas_tpw/rsi/chi_io.py +980 -0
- odas_tpw/rsi/cli.py +1411 -0
- odas_tpw/rsi/combine.py +120 -0
- odas_tpw/rsi/config.py +126 -0
- odas_tpw/rsi/config_patch.py +790 -0
- odas_tpw/rsi/convert.py +651 -0
- odas_tpw/rsi/deconvolve.py +119 -0
- odas_tpw/rsi/diss_look.py +343 -0
- odas_tpw/rsi/dissipation.py +669 -0
- odas_tpw/rsi/helpers.py +527 -0
- odas_tpw/rsi/mixing_look.py +509 -0
- odas_tpw/rsi/p_file.py +771 -0
- odas_tpw/rsi/pipeline.py +924 -0
- odas_tpw/rsi/profile.py +624 -0
- odas_tpw/rsi/quick_look.py +758 -0
- odas_tpw/rsi/sensor_inventory.py +684 -0
- odas_tpw/rsi/shear_noise.py +128 -0
- odas_tpw/rsi/speed.py +221 -0
- odas_tpw/rsi/vehicle.py +74 -0
- odas_tpw/rsi/viewer_base.py +1071 -0
- odas_tpw/rsi/window.py +459 -0
- odas_tpw/scor160/__init__.py +36 -0
- odas_tpw/scor160/cli.py +242 -0
- odas_tpw/scor160/compare.py +484 -0
- odas_tpw/scor160/despike.py +227 -0
- odas_tpw/scor160/goodman.py +288 -0
- odas_tpw/scor160/io.py +690 -0
- odas_tpw/scor160/l2.py +311 -0
- odas_tpw/scor160/l3.py +279 -0
- odas_tpw/scor160/l4.py +850 -0
- odas_tpw/scor160/nasmyth.py +208 -0
- odas_tpw/scor160/ocean.py +287 -0
- odas_tpw/scor160/profile.py +231 -0
- 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
|