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
odas_tpw/config_base.py
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
# Mar-2026, Claude and Pat Welch, pat@mousebrains.com
|
|
2
|
+
"""Shared configuration management logic.
|
|
3
|
+
|
|
4
|
+
Provides load/validate/merge/hash/resolve functions parameterized by
|
|
5
|
+
a DEFAULTS dict. Used by both rsi/config.py and perturb/config.py to
|
|
6
|
+
eliminate duplication of 9 identical config management functions.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import glob as globmod
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
import numbers
|
|
16
|
+
from collections.abc import Callable, Mapping
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from ruamel.yaml import YAML
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _normalize_value(v):
|
|
23
|
+
"""Normalize a single value for deterministic JSON encoding."""
|
|
24
|
+
if v is None:
|
|
25
|
+
return None
|
|
26
|
+
# bool check must come before int (bool is a subclass of int)
|
|
27
|
+
if isinstance(v, bool):
|
|
28
|
+
return v
|
|
29
|
+
# numbers.Integral/Real (not int/float) so a numpy scalar from a config
|
|
30
|
+
# value is coerced to a JSON-serializable Python number instead of crashing
|
|
31
|
+
# json.dumps later in canonicalize().
|
|
32
|
+
if isinstance(v, numbers.Integral):
|
|
33
|
+
return int(v)
|
|
34
|
+
if isinstance(v, numbers.Real):
|
|
35
|
+
v = float(v)
|
|
36
|
+
# int(nan) raises ValueError and int(inf) raises OverflowError, which
|
|
37
|
+
# would crash canonicalize -> compute_hash -> resolve_output_dir with an
|
|
38
|
+
# opaque message. Hash non-finite values by their repr instead.
|
|
39
|
+
if not math.isfinite(v):
|
|
40
|
+
return repr(v)
|
|
41
|
+
if v == int(v):
|
|
42
|
+
return int(v)
|
|
43
|
+
# Normalize by significant figures, not decimal places: round(v, 10)
|
|
44
|
+
# zeroes any |v| < ~5e-11, collapsing distinct small values (e.g. the
|
|
45
|
+
# 1e-13 epsilon_minimum/chi_minimum defaults) to 0.0 and corrupting the
|
|
46
|
+
# config hash / signature.
|
|
47
|
+
return float(f"{v:.10e}")
|
|
48
|
+
if isinstance(v, str):
|
|
49
|
+
return v
|
|
50
|
+
return v
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _overlay(defaults: dict, params: dict) -> dict:
|
|
54
|
+
"""Defaults overlaid with non-None params (the standard section view)."""
|
|
55
|
+
out = dict(defaults)
|
|
56
|
+
for k, v in (params or {}).items():
|
|
57
|
+
if k in out and v is not None:
|
|
58
|
+
out[k] = v
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _normalize_nested(v):
|
|
63
|
+
"""Normalize nested JSON-like values for deterministic hashing."""
|
|
64
|
+
if isinstance(v, list):
|
|
65
|
+
return [_normalize_nested(item) for item in v]
|
|
66
|
+
if isinstance(v, tuple):
|
|
67
|
+
return [_normalize_nested(item) for item in v]
|
|
68
|
+
if isinstance(v, dict):
|
|
69
|
+
# Sort by the STRING form of the key: a user-keyed dict (e.g. instrument
|
|
70
|
+
# serials) mixing int (unquoted `465:`) and str (`SN479:`) keys would
|
|
71
|
+
# otherwise raise TypeError comparing str < int.
|
|
72
|
+
return {
|
|
73
|
+
str(k): _normalize_nested(val)
|
|
74
|
+
for k, val in sorted(v.items(), key=lambda kv: str(kv[0]))
|
|
75
|
+
}
|
|
76
|
+
return _normalize_value(v)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def iter_stage_dirs(base: str | Path, prefix: str) -> list[tuple[int, Path]]:
|
|
80
|
+
"""Existing ``{prefix}_NN`` *directories* under *base*, sorted by sequence.
|
|
81
|
+
|
|
82
|
+
Read-only helper shared by the pipeline's resolver and the plot-time
|
|
83
|
+
config resolver. Uses the same width-adaptive glob as
|
|
84
|
+
:meth:`ConfigManager.resolve_output_dir` so ``{prefix}_100`` is not missed.
|
|
85
|
+
Returns ``(seq, dir)`` pairs; non-numeric suffixes and non-directories are
|
|
86
|
+
skipped. (Unlike ``resolve_output_dir``'s internal scan this drops non-dir
|
|
87
|
+
matches, because only directories can carry a signature.)
|
|
88
|
+
"""
|
|
89
|
+
base = Path(base)
|
|
90
|
+
out: list[tuple[int, Path]] = []
|
|
91
|
+
# Escape the base so a glob-active char in output_root (e.g. a "[" in a path)
|
|
92
|
+
# is matched literally; only the {prefix}_NN suffix stays glob-active.
|
|
93
|
+
pattern = str(Path(globmod.escape(str(base))) / f"{prefix}_[0-9][0-9]*")
|
|
94
|
+
for d in globmod.glob(pattern):
|
|
95
|
+
dp = Path(d)
|
|
96
|
+
if not dp.is_dir():
|
|
97
|
+
continue
|
|
98
|
+
try:
|
|
99
|
+
seq = int(dp.name.split("_")[-1])
|
|
100
|
+
except ValueError:
|
|
101
|
+
continue
|
|
102
|
+
out.append((seq, dp))
|
|
103
|
+
out.sort(key=lambda t: t[0])
|
|
104
|
+
return out
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ConfigManager:
|
|
108
|
+
"""Config management parameterized by a DEFAULTS dict.
|
|
109
|
+
|
|
110
|
+
Parameters
|
|
111
|
+
----------
|
|
112
|
+
defaults : dict[str, dict]
|
|
113
|
+
Canonical defaults — one dict per processing section.
|
|
114
|
+
hash_exclude_keys : frozenset[str]
|
|
115
|
+
Keys omitted from canonicalization/hashing (e.g. ``{"diagnostics"}``).
|
|
116
|
+
dynamic_key_sections : frozenset[str]
|
|
117
|
+
Sections whose keys are user-defined at runtime (e.g. instrument
|
|
118
|
+
serial numbers). Strict unknown-key validation is skipped for these
|
|
119
|
+
sections; the caller is responsible for validating the inner
|
|
120
|
+
structure where it is consumed.
|
|
121
|
+
engine_fingerprint : Callable[[], str] | None
|
|
122
|
+
Optional callable returning a hash of the *processing code* (and key
|
|
123
|
+
numeric deps). When set, its value is folded into every signature under
|
|
124
|
+
a synthetic ``_engine`` key, so a code change yields new ``{stage}_NN``
|
|
125
|
+
output dirs (forcing recompute) while unchanged code reuses them.
|
|
126
|
+
Plot-time consumers that match on *config only* strip ``_engine`` first
|
|
127
|
+
(see ``perturb.resolve``). Only the perturb pipeline sets this.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self,
|
|
132
|
+
defaults: dict[str, dict],
|
|
133
|
+
*,
|
|
134
|
+
hash_exclude_keys: frozenset[str] = frozenset(),
|
|
135
|
+
dynamic_key_sections: frozenset[str] = frozenset(),
|
|
136
|
+
engine_fingerprint: Callable[[], str] | None = None,
|
|
137
|
+
) -> None:
|
|
138
|
+
self.defaults = defaults
|
|
139
|
+
self.valid_sections = frozenset(defaults)
|
|
140
|
+
self.hash_exclude_keys = hash_exclude_keys
|
|
141
|
+
self.dynamic_key_sections = dynamic_key_sections
|
|
142
|
+
self.engine_fingerprint = engine_fingerprint
|
|
143
|
+
|
|
144
|
+
# -- Load / validate ---------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def load_config(self, path: str | Path) -> dict[str, dict]:
|
|
147
|
+
"""Load a YAML config file and return as plain dict of dicts.
|
|
148
|
+
|
|
149
|
+
Raises FileNotFoundError if *path* does not exist, ValueError if
|
|
150
|
+
the file contains unknown sections or keys.
|
|
151
|
+
"""
|
|
152
|
+
yaml = YAML()
|
|
153
|
+
with open(path, encoding="utf-8") as fh:
|
|
154
|
+
raw = yaml.load(fh)
|
|
155
|
+
if raw is None:
|
|
156
|
+
return {}
|
|
157
|
+
if not isinstance(raw, Mapping):
|
|
158
|
+
raise ValueError(f"Config file must be a mapping of sections, got {type(raw).__name__}")
|
|
159
|
+
# Each section must itself be a mapping (key: value). A list/scalar
|
|
160
|
+
# section otherwise hits dict(v) with an opaque TypeError (#29).
|
|
161
|
+
config: dict[str, dict] = {}
|
|
162
|
+
for k, v in raw.items():
|
|
163
|
+
if v is None:
|
|
164
|
+
config[str(k)] = {}
|
|
165
|
+
elif isinstance(v, Mapping):
|
|
166
|
+
config[str(k)] = dict(v)
|
|
167
|
+
else:
|
|
168
|
+
raise ValueError(
|
|
169
|
+
f"Config section {str(k)!r} must be a mapping of "
|
|
170
|
+
f"key: value pairs, got {type(v).__name__}"
|
|
171
|
+
)
|
|
172
|
+
self.validate_config(config)
|
|
173
|
+
return config
|
|
174
|
+
|
|
175
|
+
def validate_config(self, config: dict[str, dict]) -> None:
|
|
176
|
+
"""Raise ValueError if *config* has unknown sections or keys."""
|
|
177
|
+
for section, params in config.items():
|
|
178
|
+
if section not in self.valid_sections:
|
|
179
|
+
raise ValueError(
|
|
180
|
+
f"Unknown config section: {section!r}. "
|
|
181
|
+
f"Valid sections: {sorted(self.valid_sections)}"
|
|
182
|
+
)
|
|
183
|
+
if section in self.dynamic_key_sections:
|
|
184
|
+
continue
|
|
185
|
+
valid_keys = set(self.defaults[section])
|
|
186
|
+
unknown = set(params) - valid_keys
|
|
187
|
+
if unknown:
|
|
188
|
+
raise ValueError(
|
|
189
|
+
f"Unknown key(s) in [{section}]: {sorted(unknown)}. "
|
|
190
|
+
f"Valid keys: {sorted(valid_keys)}"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# -- Three-way merge ---------------------------------------------------
|
|
194
|
+
|
|
195
|
+
def merge_config(
|
|
196
|
+
self,
|
|
197
|
+
section: str,
|
|
198
|
+
file_values: dict | None = None,
|
|
199
|
+
cli_overrides: dict | None = None,
|
|
200
|
+
) -> dict:
|
|
201
|
+
"""Merge defaults <- config-file values <- CLI overrides.
|
|
202
|
+
|
|
203
|
+
None values in either layer are treated as "not specified" and do
|
|
204
|
+
not mask earlier layers. Returns a clean kwargs dict with all
|
|
205
|
+
None values removed.
|
|
206
|
+
"""
|
|
207
|
+
if section not in self.defaults:
|
|
208
|
+
raise ValueError(f"Unknown section: {section!r}")
|
|
209
|
+
|
|
210
|
+
# Dynamic-key sections (e.g. 'instruments') have empty {} defaults, so
|
|
211
|
+
# the `k in merged` gate below would discard every user-supplied key,
|
|
212
|
+
# silently losing all overrides. There are no fixed parameter keys to
|
|
213
|
+
# constrain against here: overlay file_values then cli_overrides, with
|
|
214
|
+
# None-filtering, keeping every user-defined key.
|
|
215
|
+
if section in self.dynamic_key_sections:
|
|
216
|
+
merged = dict(self.defaults[section])
|
|
217
|
+
for layer in (file_values, cli_overrides):
|
|
218
|
+
if layer:
|
|
219
|
+
for k, v in layer.items():
|
|
220
|
+
if v is not None:
|
|
221
|
+
merged[k] = v
|
|
222
|
+
return {k: v for k, v in merged.items() if v is not None}
|
|
223
|
+
|
|
224
|
+
merged = dict(self.defaults[section])
|
|
225
|
+
|
|
226
|
+
if file_values:
|
|
227
|
+
for k, v in file_values.items():
|
|
228
|
+
if k in merged and v is not None:
|
|
229
|
+
merged[k] = v
|
|
230
|
+
|
|
231
|
+
if cli_overrides:
|
|
232
|
+
for k, v in cli_overrides.items():
|
|
233
|
+
if k in merged and v is not None:
|
|
234
|
+
merged[k] = v
|
|
235
|
+
|
|
236
|
+
return {k: v for k, v in merged.items() if v is not None}
|
|
237
|
+
|
|
238
|
+
# -- Canonicalization and hashing --------------------------------------
|
|
239
|
+
|
|
240
|
+
def _section_view_postprocess(self, section: str, mapping: dict) -> dict:
|
|
241
|
+
"""Hook: adjust a defaults-overlaid section view before hashing/writing.
|
|
242
|
+
|
|
243
|
+
Called on the overlay produced by :meth:`_canonicalize_section` and
|
|
244
|
+
:meth:`write_resolved_config`. The base implementation is the
|
|
245
|
+
identity; subclasses may drop keys that are inert for a given
|
|
246
|
+
combination of settings (e.g. perturb's window-duration keys when
|
|
247
|
+
their sample-count twins are pinned) so that signatures and the
|
|
248
|
+
human-readable resolved config reflect only what governs the output.
|
|
249
|
+
"""
|
|
250
|
+
return mapping
|
|
251
|
+
|
|
252
|
+
def _canonicalize_section(self, section: str, params: dict) -> dict:
|
|
253
|
+
"""Canonicalize a single section's parameters into a normalized dict."""
|
|
254
|
+
if section in self.dynamic_key_sections:
|
|
255
|
+
# Keys here are user-defined names (e.g. instrument serials), NOT
|
|
256
|
+
# parameter keys, so hash_exclude_keys (meant to drop per-section
|
|
257
|
+
# toggles like 'diagnostics' in static sections) must NOT be applied:
|
|
258
|
+
# an instrument literally named 'diagnostics' was silently dropped
|
|
259
|
+
# from the hash/canonical config.
|
|
260
|
+
return {
|
|
261
|
+
str(k): _normalize_nested(v)
|
|
262
|
+
for k, v in sorted((params or {}).items(), key=lambda kv: str(kv[0]))
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
base = dict(self.defaults[section])
|
|
266
|
+
for k, v in params.items():
|
|
267
|
+
if k in base and v is not None:
|
|
268
|
+
base[k] = v
|
|
269
|
+
# _normalize_nested (not _normalize_value) so list/dict-valued params
|
|
270
|
+
# (e.g. speed.amplitude_quantile = [1.0, 99.0]) have their nested
|
|
271
|
+
# scalars type-normalized too. _normalize_value passes containers
|
|
272
|
+
# through untouched, so [1.0, 99.0] and [1, 99] would hash differently
|
|
273
|
+
# -> different output dirs / spurious recompute. Matches the
|
|
274
|
+
# dynamic-key branch above. For scalars the two are identical.
|
|
275
|
+
return self._section_view_postprocess(
|
|
276
|
+
section,
|
|
277
|
+
{
|
|
278
|
+
k: _normalize_nested(v)
|
|
279
|
+
for k, v in sorted(base.items())
|
|
280
|
+
if k not in self.hash_exclude_keys
|
|
281
|
+
},
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def canonicalize(
|
|
285
|
+
self,
|
|
286
|
+
section: str,
|
|
287
|
+
params: dict,
|
|
288
|
+
upstream: list[tuple[str, dict]] | None = None,
|
|
289
|
+
) -> str:
|
|
290
|
+
"""Produce a deterministic JSON string for hashing.
|
|
291
|
+
|
|
292
|
+
Overlays *params* on the section defaults, normalizes types, and
|
|
293
|
+
returns compact sorted JSON.
|
|
294
|
+
"""
|
|
295
|
+
sections = {}
|
|
296
|
+
if upstream:
|
|
297
|
+
for up_section, up_params in upstream:
|
|
298
|
+
sections[up_section] = self._canonicalize_section(up_section, up_params)
|
|
299
|
+
sections[section] = self._canonicalize_section(section, params)
|
|
300
|
+
if self.engine_fingerprint is not None:
|
|
301
|
+
# Synthetic, non-section key (added directly, NOT via
|
|
302
|
+
# _canonicalize_section, which validates real sections): folds the
|
|
303
|
+
# processing-code identity into every hash so a code change forces
|
|
304
|
+
# new output dirs. Config-only consumers strip it (perturb.resolve).
|
|
305
|
+
sections["_engine"] = {"fingerprint": self.engine_fingerprint()}
|
|
306
|
+
return json.dumps(sections, sort_keys=True, separators=(",", ":"))
|
|
307
|
+
|
|
308
|
+
def compute_hash(
|
|
309
|
+
self,
|
|
310
|
+
section: str,
|
|
311
|
+
params: dict,
|
|
312
|
+
upstream: list[tuple[str, dict]] | None = None,
|
|
313
|
+
) -> str:
|
|
314
|
+
"""SHA-256 hex digest of the canonical representation."""
|
|
315
|
+
canonical = self.canonicalize(section, params, upstream=upstream)
|
|
316
|
+
return hashlib.sha256(canonical.encode()).hexdigest()
|
|
317
|
+
|
|
318
|
+
# -- Output directory management ---------------------------------------
|
|
319
|
+
|
|
320
|
+
def resolve_output_dir(
|
|
321
|
+
self,
|
|
322
|
+
base: str | Path,
|
|
323
|
+
prefix: str,
|
|
324
|
+
section: str,
|
|
325
|
+
params: dict,
|
|
326
|
+
upstream: list[tuple[str, dict]] | None = None,
|
|
327
|
+
) -> Path:
|
|
328
|
+
"""Find or create a sequential output directory matching *params*.
|
|
329
|
+
|
|
330
|
+
Scans ``base/{prefix}_NN/`` directories for a hash-matching
|
|
331
|
+
signature file. If found, returns that directory. Otherwise
|
|
332
|
+
creates the next sequential directory and writes the signature.
|
|
333
|
+
"""
|
|
334
|
+
base = Path(base)
|
|
335
|
+
target_hash = self.compute_hash(section, params, upstream=upstream)
|
|
336
|
+
|
|
337
|
+
max_seq = -1
|
|
338
|
+
# Width-adaptive: the dir format is :02d but rolls to 3+ digits past 99
|
|
339
|
+
# (eps_100). A fixed [0-9][0-9] glob would miss eps_100 and recompute
|
|
340
|
+
# max_seq as 99, colliding the 101st+ distinct config back onto eps_100.
|
|
341
|
+
# Escape *base* so a glob-active char in the path (e.g. "[") matches
|
|
342
|
+
# literally; only the {prefix}_NN suffix is a glob.
|
|
343
|
+
pattern = str(Path(globmod.escape(str(base))) / f"{prefix}_[0-9][0-9]*")
|
|
344
|
+
for d in sorted(globmod.glob(pattern)):
|
|
345
|
+
dp = Path(d)
|
|
346
|
+
try:
|
|
347
|
+
seq = int(dp.name.split("_")[-1])
|
|
348
|
+
except ValueError:
|
|
349
|
+
continue
|
|
350
|
+
max_seq = max(max_seq, seq)
|
|
351
|
+
if not dp.is_dir():
|
|
352
|
+
continue
|
|
353
|
+
sig_file = dp / f".params_sha256_{target_hash}"
|
|
354
|
+
if sig_file.exists():
|
|
355
|
+
return dp
|
|
356
|
+
|
|
357
|
+
# Claim the next sequence ATOMICALLY (exist_ok=False): two concurrent
|
|
358
|
+
# runs of DIFFERENT configs would otherwise both pick the same
|
|
359
|
+
# {prefix}_NN and mkdir(exist_ok=True) it, fusing two configs into one
|
|
360
|
+
# output dir. On a lost race, reuse the dir if its signature matches
|
|
361
|
+
# ours, else advance to the next sequence.
|
|
362
|
+
next_seq = max_seq + 1
|
|
363
|
+
while True:
|
|
364
|
+
new_dir = base / f"{prefix}_{next_seq:02d}"
|
|
365
|
+
try:
|
|
366
|
+
new_dir.mkdir(parents=True, exist_ok=False)
|
|
367
|
+
break
|
|
368
|
+
except FileExistsError:
|
|
369
|
+
if (new_dir / f".params_sha256_{target_hash}").exists():
|
|
370
|
+
return new_dir
|
|
371
|
+
next_seq += 1
|
|
372
|
+
self.write_signature(new_dir, section, params, upstream=upstream)
|
|
373
|
+
return new_dir
|
|
374
|
+
|
|
375
|
+
def write_signature(
|
|
376
|
+
self,
|
|
377
|
+
directory: Path,
|
|
378
|
+
section: str,
|
|
379
|
+
params: dict,
|
|
380
|
+
upstream: list[tuple[str, dict]] | None = None,
|
|
381
|
+
) -> Path:
|
|
382
|
+
"""Write a ``.params_sha256_<hash>`` signature file."""
|
|
383
|
+
h = self.compute_hash(section, params, upstream=upstream)
|
|
384
|
+
canonical = self.canonicalize(section, params, upstream=upstream)
|
|
385
|
+
sig_file = directory / f".params_sha256_{h}"
|
|
386
|
+
sig_file.write_text(canonical)
|
|
387
|
+
return sig_file
|
|
388
|
+
|
|
389
|
+
def write_resolved_config(
|
|
390
|
+
self,
|
|
391
|
+
directory: Path,
|
|
392
|
+
section: str,
|
|
393
|
+
params: dict,
|
|
394
|
+
upstream: list[tuple[str, dict]] | None = None,
|
|
395
|
+
) -> Path:
|
|
396
|
+
"""Write a human-readable ``config.yaml`` with the resolved parameters."""
|
|
397
|
+
yaml = YAML()
|
|
398
|
+
yaml.default_flow_style = False
|
|
399
|
+
|
|
400
|
+
data = {}
|
|
401
|
+
if upstream:
|
|
402
|
+
for up_section, up_params in upstream:
|
|
403
|
+
if up_section in self.dynamic_key_sections:
|
|
404
|
+
resolved = dict(up_params or {})
|
|
405
|
+
else:
|
|
406
|
+
resolved = self._section_view_postprocess(
|
|
407
|
+
up_section,
|
|
408
|
+
_overlay(self.defaults[up_section], up_params),
|
|
409
|
+
)
|
|
410
|
+
data[up_section] = resolved
|
|
411
|
+
|
|
412
|
+
if section in self.dynamic_key_sections:
|
|
413
|
+
resolved = dict(params or {})
|
|
414
|
+
else:
|
|
415
|
+
resolved = self._section_view_postprocess(
|
|
416
|
+
section, _overlay(self.defaults[section], params)
|
|
417
|
+
)
|
|
418
|
+
data[section] = resolved
|
|
419
|
+
|
|
420
|
+
out = directory / "config.yaml"
|
|
421
|
+
with open(out, "w", encoding="utf-8") as fh:
|
|
422
|
+
yaml.dump(data, fh)
|
|
423
|
+
return out
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Mar-2026, Claude and Pat Welch, pat@mousebrains.com
|
|
2
|
+
"""perturb — Batch processing pipeline for Rockland VMP/MicroRider data."""
|
|
3
|
+
|
|
4
|
+
from importlib.metadata import version
|
|
5
|
+
|
|
6
|
+
from odas_tpw.perturb.config import DEFAULTS, generate_template, load_config, merge_config
|
|
7
|
+
from odas_tpw.perturb.pipeline import process_file, run_pipeline
|
|
8
|
+
|
|
9
|
+
__version__ = version("microstructure-tpw")
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"DEFAULTS",
|
|
13
|
+
"generate_template",
|
|
14
|
+
"load_config",
|
|
15
|
+
"merge_config",
|
|
16
|
+
"process_file",
|
|
17
|
+
"run_pipeline",
|
|
18
|
+
]
|