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,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
+ ]