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,790 @@
1
+ # Jun-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """Post-acquisition patching of the embedded configuration string in RSI ``.p`` files.
3
+
4
+ It is common for an instrument's onboard configuration (calibration coefficients,
5
+ vehicle, cruise metadata) to be wrong or incomplete at acquisition time and only
6
+ corrected afterwards. This module rewrites selected fields of the INI-style
7
+ configuration string embedded in record 0 of a ``.p`` file, writing the result to
8
+ a NEW file. The original is never modified, and the full original configuration is
9
+ embedded (commented out) in the patched file for provenance and recovery.
10
+
11
+ Design (verified against RSI ``patch_setupstr.m`` and the ``PFile`` reader):
12
+
13
+ * The data records are copied byte-for-byte. Only record 0's header word 11
14
+ (``config_size``) is updated to the new configuration length. No reader (this
15
+ package's ``PFile``, RSI ``read_odas.m`` / ``odas_p2mat.m``) consumes the stale
16
+ ``config_size`` carried in the data-record headers, so they are left untouched.
17
+ * The header version is preserved (RSI's full rewrite downgrades v6.1 → v6.0).
18
+ * Acquisition-defining parameters (the ``[matrix]`` stanza and ``[root]``
19
+ ``rate``/``recsize``/``no-fast``/``no-slow``) are unreachable by design — they
20
+ cannot be addressed through the edit spec, so they can never be corrupted.
21
+
22
+ The edit spec is a small YAML file (see :func:`scaffold_yaml`). Every value must be
23
+ a quoted string so the text written to the file is exactly what the user typed —
24
+ this avoids YAML coercing ``0.1130`` → ``0.113`` or ``2.5e5`` → ``250000.0``.
25
+
26
+ The text editor mirrors :func:`odas_tpw.rsi.p_file.parse_config` tokenization
27
+ exactly (comment stripped at the first ``;``, section/assignment regexes on the
28
+ stripped line, keys compared case-insensitively, channel ``name`` values verbatim)
29
+ so that what the editor targets is exactly what ``PFile`` later reads.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import difflib
35
+ import os
36
+ import re
37
+ import struct
38
+ import sys
39
+ import warnings
40
+ from dataclasses import dataclass, field
41
+ from pathlib import Path
42
+ from typing import Any
43
+
44
+ from odas_tpw.rsi.p_file import (
45
+ _H,
46
+ HEADER_BYTES,
47
+ HEADER_WORDS,
48
+ _detect_endian,
49
+ parse_config,
50
+ )
51
+
52
+ try: # package version for the provenance banner
53
+ from importlib.metadata import version as _pkg_version
54
+
55
+ _VERSION = _pkg_version("microstructure-tpw")
56
+ except Exception: # pragma: no cover - fallback only when metadata is unavailable
57
+ _VERSION = "unknown"
58
+
59
+ # Matches RSI patch_setupstr.m so RSI's -revert can still recover the original.
60
+ CONFIG_MARKER = "; ### Original Configuration String Below ###"
61
+ TOOL_NAME = "rsi-tpw patch-config"
62
+
63
+ # Top-level keys allowed in the edit spec. Acquisition stanzas (root, matrix) are
64
+ # deliberately absent: they cannot be addressed and therefore cannot be corrupted.
65
+ ALLOWED_TOP_LEVEL = {"note", "author", "instrument_info", "cruise_info", "channels"}
66
+ EDITABLE_SECTIONS = {"instrument_info", "cruise_info"}
67
+ # 'name' identifies a channel and (in any stanza) creates an RSI section alias
68
+ # (setupstr.m). It must never be set through this tool.
69
+ RESERVED_KEY = "name"
70
+
71
+ # Same tokenization as parse_config (p_file.py).
72
+ _SECTION_RE = re.compile(r"^\[(.+)\]$")
73
+ _ASSIGN_RE = re.compile(r"^(.+?)\s*=\s*(.*)$")
74
+ # Splits a raw assignment line so the value can be replaced while leading
75
+ # whitespace, the '=' alignment, trailing whitespace and any inline comment
76
+ # are all preserved.
77
+ _LINE_RE = re.compile(
78
+ r"^(?P<lead>\s*)(?P<key>[^=\s][^=]*?)(?P<sep>\s*=\s*)(?P<val>.*?)(?P<trail>\s*)$"
79
+ )
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Edit spec
84
+ # ---------------------------------------------------------------------------
85
+
86
+
87
+ @dataclass
88
+ class EditSpec:
89
+ """A parsed, validated set of configuration edits."""
90
+
91
+ note: str
92
+ author: str
93
+ sections: dict[str, dict[str, str]] = field(default_factory=dict)
94
+ channels: dict[str, dict[str, str]] = field(default_factory=dict)
95
+
96
+ @property
97
+ def touches_channels(self) -> bool:
98
+ return bool(self.channels)
99
+
100
+
101
+ @dataclass
102
+ class Change:
103
+ """A single applied edit (for provenance comments and CLI reporting)."""
104
+
105
+ where: str # e.g. "instrument_info" or "channel sh1"
106
+ key: str # original-case key as written to the file
107
+ old: str | None # None when the key is being added
108
+ new: str
109
+
110
+
111
+ def _check_value(value: object, ctx: str) -> str:
112
+ """Validate one edit-spec value and return its verbatim text.
113
+
114
+ Values must be quoted strings in the YAML. A bare ``0.1130`` / ``2.5e5`` /
115
+ ``yes`` would be coerced by the YAML loader to a float/bool and lose its exact
116
+ text, so non-string scalars are rejected with guidance to quote them.
117
+ """
118
+ if isinstance(value, bool) or not isinstance(value, str):
119
+ raise ValueError(
120
+ f"{ctx}: value must be quoted text to preserve it exactly "
121
+ f'(e.g. {ctx.rsplit(".", 1)[-1]}: "0.1130"); got {type(value).__name__} {value!r}'
122
+ )
123
+ text = str(value)
124
+ if not text.isascii():
125
+ raise ValueError(f"{ctx}: value must be ASCII (the .p config is ASCII); got {text!r}")
126
+ if ";" in text:
127
+ raise ValueError(
128
+ f"{ctx}: value must not contain ';' (the config parser truncates at it); got {text!r}"
129
+ )
130
+ # A newline (e.g. from a YAML block scalar or '\n' escape) would break out
131
+ # of the key=value assignment and inject arbitrary stanzas — bypassing the
132
+ # guarantee that [matrix]/[root] acquisition parameters are unaddressable.
133
+ if any(ord(c) < 0x20 for c in text):
134
+ raise ValueError(
135
+ f"{ctx}: value must be a single line of printable text "
136
+ f"(no newlines or control characters); got {text!r}"
137
+ )
138
+ return text
139
+
140
+
141
+ def _check_keymap(raw: object, ctx: str) -> dict[str, str]:
142
+ """Validate one stanza/channel mapping from the spec and return key→text."""
143
+ if not isinstance(raw, dict):
144
+ raise ValueError(f"{ctx}: expected a mapping of key: value, got {type(raw).__name__}")
145
+ out: dict[str, str] = {}
146
+ for key, value in raw.items():
147
+ key = str(key)
148
+ if key.strip().lower() == RESERVED_KEY:
149
+ raise ValueError(
150
+ f"{ctx}.{key}: '{RESERVED_KEY}' identifies a channel/section and cannot be set"
151
+ )
152
+ # The key is written verbatim as ``{key} = {value}``. Validate it as
153
+ # strictly as the value: a newline/control char would split the
154
+ # assignment and a ';'/'['/']'/'=' would inject a stanza or comment,
155
+ # defeating the guarantee that acquisition params are unaddressable.
156
+ if not key.isascii() or any(ord(c) < 0x20 for c in key) or any(c in key for c in "=;[]"):
157
+ raise ValueError(
158
+ f"{ctx}: key {key!r} must be a single line of ASCII text with no "
159
+ f"'=', ';', '[' or ']' (the key is written verbatim as 'key = value')"
160
+ )
161
+ out[key] = _check_value(value, f"{ctx}.{key}")
162
+ return out
163
+
164
+
165
+ def load_edit_spec(path: str | Path) -> EditSpec:
166
+ """Load and validate a YAML edit spec.
167
+
168
+ Raises ``ValueError`` with an actionable message on any malformed input —
169
+ unknown top-level keys, a missing ``note``, an unquoted/non-ASCII value, or a
170
+ reserved ``name`` key.
171
+ """
172
+ from ruamel.yaml import YAML
173
+
174
+ path = Path(path)
175
+ yaml = YAML(typ="rt")
176
+ yaml.preserve_quotes = True
177
+ with open(path, encoding="utf-8") as fh:
178
+ data = yaml.load(fh)
179
+ if data is None:
180
+ raise ValueError(f"{path.name}: edit spec is empty")
181
+ if not isinstance(data, dict):
182
+ raise ValueError(f"{path.name}: edit spec must be a YAML mapping")
183
+
184
+ unknown = {str(k) for k in data} - ALLOWED_TOP_LEVEL
185
+ if unknown:
186
+ raise ValueError(
187
+ f"{path.name}: unknown top-level key(s) {sorted(unknown)}; "
188
+ f"valid keys: {sorted(ALLOWED_TOP_LEVEL)}"
189
+ )
190
+
191
+ note = data.get("note")
192
+ if not isinstance(note, str) or not note.strip():
193
+ raise ValueError(f"{path.name}: a non-empty 'note' describing the change is required")
194
+ if not note.isascii() or any(ord(c) < 0x20 for c in note.strip()):
195
+ raise ValueError(f"{path.name}: 'note' must be a single line of ASCII text")
196
+ note = note.strip()
197
+
198
+ author = data.get("author")
199
+ if author is not None and (
200
+ not isinstance(author, str) or not author.isascii() or any(ord(c) < 0x20 for c in author)
201
+ ):
202
+ raise ValueError(f"{path.name}: 'author' must be a single line of ASCII text")
203
+ author = str(author).strip() if author else (os.environ.get("USER") or "unknown")
204
+
205
+ sections: dict[str, dict[str, str]] = {}
206
+ for sec in EDITABLE_SECTIONS:
207
+ if sec in data:
208
+ sections[sec] = _check_keymap(data[sec], sec)
209
+
210
+ channels: dict[str, dict[str, str]] = {}
211
+ if "channels" in data:
212
+ chans = data["channels"]
213
+ if not isinstance(chans, dict):
214
+ raise ValueError(f"{path.name}: 'channels' must be a mapping of channel-name: {{...}}")
215
+ for name, kv in chans.items():
216
+ channels[str(name)] = _check_keymap(kv, f"channels.{name}")
217
+
218
+ if not sections and not channels:
219
+ raise ValueError(f"{path.name}: edit spec contains no edits")
220
+
221
+ return EditSpec(note=note, author=author, sections=sections, channels=channels)
222
+
223
+
224
+ # ---------------------------------------------------------------------------
225
+ # Config-text parsing and editing (pure string -> string)
226
+ # ---------------------------------------------------------------------------
227
+
228
+
229
+ def _code_part(line: str) -> str:
230
+ """Portion of a raw line before the first ';' (matches parse_config)."""
231
+ idx = line.find(";")
232
+ return line if idx < 0 else line[:idx]
233
+
234
+
235
+ def _split_eol(line: str) -> tuple[str, str]:
236
+ for term in ("\r\n", "\r", "\n"):
237
+ if line.endswith(term):
238
+ return line[: -len(term)], term
239
+ return line, ""
240
+
241
+
242
+ def _detect_eol(text: str) -> str:
243
+ if "\r\n" in text:
244
+ return "\r\n"
245
+ if "\r" in text:
246
+ return "\r"
247
+ return "\n"
248
+
249
+
250
+ def _parse_blocks(raw_lines: list[str]) -> list[dict[str, Any]]:
251
+ """Walk lines exactly as parse_config does, recording per-stanza key locations.
252
+
253
+ Each block is ``{"section", "name", "keys", "header_idx", "last_key_idx"}``
254
+ where ``keys`` maps a lower-cased key to ``(line_index, original_key, value)``.
255
+ Commented lines (including ones that mention ``[matrix]`` in prose) strip to
256
+ empty and are skipped, so they never create a phantom section or key.
257
+ """
258
+ blocks: list[dict[str, Any]] = []
259
+ cur: dict[str, Any] = {
260
+ "section": "root",
261
+ "name": None,
262
+ "keys": {},
263
+ "header_idx": -1,
264
+ "last_key_idx": -1,
265
+ }
266
+ blocks.append(cur)
267
+ for i, line in enumerate(raw_lines):
268
+ stripped = _code_part(line).strip()
269
+ if not stripped:
270
+ continue
271
+ ms = _SECTION_RE.match(stripped)
272
+ if ms:
273
+ cur = {
274
+ "section": ms.group(1).strip().lower(),
275
+ "name": None,
276
+ "keys": {},
277
+ "header_idx": i,
278
+ "last_key_idx": i,
279
+ }
280
+ blocks.append(cur)
281
+ continue
282
+ ma = _ASSIGN_RE.match(stripped)
283
+ if ma:
284
+ orig = ma.group(1).strip()
285
+ val = ma.group(2).strip()
286
+ kl = orig.lower()
287
+ cur["keys"][kl] = (i, orig, val)
288
+ cur["last_key_idx"] = i
289
+ if cur["section"] == "channel" and kl == RESERVED_KEY:
290
+ cur["name"] = val
291
+ return blocks
292
+
293
+
294
+ def _rewrite_value(raw_line: str, new_value: str) -> str:
295
+ """Replace the value in an assignment line, preserving everything else."""
296
+ body, eol = _split_eol(raw_line)
297
+ idx = body.find(";")
298
+ code, comment = (body, "") if idx < 0 else (body[:idx], body[idx:])
299
+ m = _LINE_RE.match(code)
300
+ if m is None: # pragma: no cover - only assignment lines are ever passed here
301
+ return raw_line
302
+ return f"{m['lead']}{m['key']}{m['sep']}{new_value}{m['trail']}{comment}{eol}"
303
+
304
+
305
+ def _comment_lines(text: str, eol: str) -> str:
306
+ """Prefix every line of *text* with '; ' (for the embedded original)."""
307
+ out = []
308
+ for line in text.splitlines(keepends=True):
309
+ body, term = _split_eol(line)
310
+ out.append(f"; {body}{term or eol}")
311
+ return "".join(out)
312
+
313
+
314
+ def _find_self_original_marker(config_str: str) -> int:
315
+ """Index of the CONFIG_MARKER that begins *this tool's* frozen original block.
316
+
317
+ Returns -1 when no qualifying marker exists. The marker must (a) start at
318
+ column 0 (be the start of the string or immediately follow an EOL) and (b)
319
+ have every subsequent non-blank line be a ``; ``-prefixed comment — exactly
320
+ what :func:`_comment_lines` writes. This avoids mis-detecting a marker that
321
+ merely appears inside an ORIGINAL config (e.g. an RSI-pre-patched file whose
322
+ uncommented stanzas live below the marker, or a descriptive comment that
323
+ happens to contain the marker text), which would otherwise freeze those
324
+ stanzas and make them silently un-editable.
325
+ """
326
+ search_from = 0
327
+ while True:
328
+ idx = config_str.find(CONFIG_MARKER, search_from)
329
+ if idx < 0:
330
+ return -1
331
+ at_line_start = idx == 0 or config_str[idx - 1] in "\r\n"
332
+ if at_line_start:
333
+ # Everything after the marker line must be a fully commented block.
334
+ post = config_str[idx + len(CONFIG_MARKER) :]
335
+ if all(
336
+ (not (b := _split_eol(ln)[0].strip())) or b.startswith(";")
337
+ for ln in post.splitlines(keepends=True)
338
+ ):
339
+ return idx
340
+ search_from = idx + len(CONFIG_MARKER)
341
+
342
+
343
+ def edit_config_text(
344
+ config_str: str,
345
+ spec: EditSpec,
346
+ *,
347
+ add_keys: bool = False,
348
+ when: str = "",
349
+ source_label: str = "",
350
+ ) -> tuple[str, list[Change]]:
351
+ """Apply *spec* to a configuration string.
352
+
353
+ Returns ``(new_config_text, changes)``. When ``changes`` is empty the input is
354
+ returned unchanged (the caller then skips writing the file). Raises
355
+ ``ValueError`` for unknown stanzas/keys (unless ``add_keys``), unknown or
356
+ ambiguous channel names, or a missing target section.
357
+ """
358
+ # Apply-boundary re-validation: a directly-constructed EditSpec (bypassing
359
+ # the validated load_edit_spec) is still key/value-checked here, so the
360
+ # stanza-injection guard holds on the programmatic API, not only the CLI.
361
+ for _sec, _kv in spec.sections.items():
362
+ _check_keymap(_kv, _sec)
363
+ for _name, _kv in spec.channels.items():
364
+ _check_keymap(_kv, f"channels.{_name}")
365
+
366
+ eol = _detect_eol(config_str)
367
+
368
+ # Separate any previously-embedded original block; we only edit above it and
369
+ # re-attach it verbatim (idempotent re-patch, mirroring patch_setupstr.m).
370
+ # Only a marker WE wrote (at column 0, followed by a fully commented block)
371
+ # is treated as frozen; a marker living inside an original config stays
372
+ # active so its stanzas remain editable.
373
+ marker_idx = _find_self_original_marker(config_str)
374
+ if marker_idx >= 0:
375
+ active = config_str[:marker_idx]
376
+ original_block: str | None = config_str[marker_idx:]
377
+ else:
378
+ active = config_str
379
+ original_block = None
380
+
381
+ raw_lines = active.splitlines(keepends=True)
382
+ blocks = _parse_blocks(raw_lines)
383
+
384
+ sect_block: dict[str, dict[str, Any]] = {}
385
+ sect_keys: dict[str, dict[str, Any]] = {}
386
+ chan_by_name: dict[str, list[dict[str, Any]]] = {}
387
+ for b in blocks:
388
+ if b["section"] == "channel":
389
+ if b["name"] is not None:
390
+ chan_by_name.setdefault(b["name"], []).append(b)
391
+ else:
392
+ sect_block[b["section"]] = b
393
+ sect_keys.setdefault(b["section"], {}).update(b["keys"])
394
+
395
+ changes: list[Change] = []
396
+ replace: dict[int, tuple[str, str]] = {}
397
+ inserts: dict[int, list[tuple[str, str]]] = {}
398
+
399
+ def provenance(ch: Change) -> str:
400
+ # Sanitize every interpolated field (when/author/note/old/new) the same
401
+ # way _build_banner does: a stray newline in a directly-constructed
402
+ # EditSpec or a non-trivial 'when' must not break out of the '; ' comment
403
+ # prefix and inject active config (defense-in-depth; mirrors the banner).
404
+ head = f"; [PATCH {_one_line(when)} {_one_line(spec.author)}]".rstrip()
405
+ note = _one_line(spec.note)
406
+ new = _one_line(ch.new)
407
+ # Include the stanza/channel context and quote both values so the
408
+ # original and new value are unambiguous (e.g. which channel's 'SN',
409
+ # and that an empty old value really was empty).
410
+ if ch.old is None:
411
+ return f'{head} {ch.where} {ch.key}: (added) = "{new}" ({note}){eol}'
412
+ return f'{head} {ch.where} {ch.key}: "{_one_line(ch.old)}" -> "{new}" ({note}){eol}'
413
+
414
+ def schedule(
415
+ keys: dict, block: dict, where: str, key: str, new_value: str, *, is_inst_sn: bool
416
+ ) -> None:
417
+ kl = key.lower()
418
+ if kl in keys:
419
+ idx, orig, cur = keys[kl]
420
+ if cur == new_value:
421
+ return # already at the target value — not a change
422
+ ch = Change(where, orig, cur, new_value)
423
+ changes.append(ch)
424
+ replace[idx] = (provenance(ch), _rewrite_value(raw_lines[idx], new_value))
425
+ if is_inst_sn:
426
+ warnings.warn(
427
+ f"{where}: changing the instrument serial number 'sn' "
428
+ f"({cur!r} -> {new_value!r}); this is rarely correct",
429
+ stacklevel=2,
430
+ )
431
+ else:
432
+ if not add_keys:
433
+ avail = sorted({orig for _i, orig, _v in keys.values()})
434
+ raise ValueError(
435
+ f"{where}: unknown key '{key}'; existing keys: {avail} "
436
+ f"(use --add-keys to add a new one)"
437
+ )
438
+ ch = Change(where, key, None, new_value)
439
+ changes.append(ch)
440
+ ins = block["last_key_idx"] + 1
441
+ inserts.setdefault(ins, []).append((provenance(ch), f"{key} = {new_value}{eol}"))
442
+
443
+ for section, kv in spec.sections.items():
444
+ if section not in sect_block:
445
+ raise ValueError(f"[{section}] section is not present in this file")
446
+ for key, val in kv.items():
447
+ schedule(
448
+ sect_keys[section],
449
+ sect_block[section],
450
+ section,
451
+ key,
452
+ val,
453
+ is_inst_sn=(section == "instrument_info" and key.lower() == "sn"),
454
+ )
455
+
456
+ for name, kv in spec.channels.items():
457
+ blist = chan_by_name.get(name)
458
+ if not blist:
459
+ raise ValueError(
460
+ f"channel '{name}' not found; available channels: {sorted(chan_by_name)}"
461
+ )
462
+ if len(blist) > 1:
463
+ raise ValueError(
464
+ f"channel name '{name}' appears {len(blist)} times; cannot target it unambiguously"
465
+ )
466
+ for key, val in kv.items():
467
+ schedule(blist[0]["keys"], blist[0], f"channel {name}", key, val, is_inst_sn=False)
468
+
469
+ if not changes:
470
+ return config_str, []
471
+
472
+ # Reassemble the active region with provenance comments interleaved.
473
+ out: list[str] = []
474
+ for i, line in enumerate(raw_lines):
475
+ for prov, newl in inserts.get(i, []):
476
+ out.append(prov)
477
+ out.append(newl)
478
+ if i in replace:
479
+ prov, newl = replace[i]
480
+ out.append(prov)
481
+ out.append(newl)
482
+ else:
483
+ out.append(line)
484
+ for prov, newl in inserts.get(len(raw_lines), []):
485
+ out.append(prov)
486
+ out.append(newl)
487
+ edited_active = "".join(out)
488
+
489
+ banner = _build_banner(when, spec.author, spec.note, source_label, eol)
490
+ new_config = banner + edited_active
491
+ if not new_config.endswith(("\n", "\r")):
492
+ new_config += eol
493
+ if original_block is not None:
494
+ new_config += original_block
495
+ else:
496
+ new_config += eol + CONFIG_MARKER + eol + _comment_lines(active, eol)
497
+ return new_config, changes
498
+
499
+
500
+ def _one_line(text: str) -> str:
501
+ """Collapse any control characters to spaces so an interpolated field can
502
+ never break out of its leading ``; `` comment prefix in the banner."""
503
+ return "".join(" " if ord(c) < 0x20 else c for c in text)
504
+
505
+
506
+ def _build_banner(when: str, author: str, note: str, source_label: str, eol: str) -> str:
507
+ # note/author are control-char-validated upstream; source_label is a
508
+ # machine-derived path; 'when' is caller-supplied (public API). Collapse all
509
+ # of them defensively so a stray newline cannot spill past the '; ' prefix
510
+ # and become active config.
511
+ when, author, note, source_label = (
512
+ _one_line(when),
513
+ _one_line(author),
514
+ _one_line(note),
515
+ _one_line(source_label),
516
+ )
517
+ bar = "; " + "=" * 70 + eol
518
+ lines = [
519
+ bar,
520
+ f"; PATCHED {when} by {author} -- {TOOL_NAME} {_VERSION}".rstrip() + eol,
521
+ ]
522
+ if source_label:
523
+ lines.append(f"; source: {source_label}{eol}")
524
+ lines.append(f"; note: {note}{eol}")
525
+ lines.append(bar)
526
+ return "".join(lines)
527
+
528
+
529
+ # ---------------------------------------------------------------------------
530
+ # Binary I/O
531
+ # ---------------------------------------------------------------------------
532
+
533
+
534
+ def read_config_text(path: str | Path) -> str:
535
+ """Read just the embedded configuration string (latin-1, lossless)."""
536
+ path = Path(path)
537
+ with open(path, "rb") as f:
538
+ head = f.read(HEADER_BYTES)
539
+ if len(head) < HEADER_BYTES:
540
+ raise ValueError(f"{path.name}: file too small for a header")
541
+ endian = _detect_endian(head, path)
542
+ words = struct.unpack(f"{endian}{HEADER_WORDS}H", head)
543
+ header_size = words[_H["header_size"]]
544
+ config_size = words[_H["config_size"]]
545
+ # Bounds-validate header_size: a corrupt source advertising header_size
546
+ # < HEADER_BYTES would otherwise read config from inside its own header,
547
+ # matching the guard extract_pfile_segment already enforces.
548
+ if header_size < HEADER_BYTES:
549
+ raise ValueError(f"{path.name}: invalid header_size={header_size}")
550
+ f.seek(0, 2)
551
+ file_size = f.tell()
552
+ if header_size + config_size > file_size:
553
+ raise ValueError(
554
+ f"{path.name}: header_size+config_size ({header_size + config_size}) exceeds "
555
+ f"file size ({file_size}); truncated or corrupt"
556
+ )
557
+ f.seek(header_size)
558
+ cfg = f.read(config_size)
559
+ if len(cfg) != config_size:
560
+ raise ValueError(f"{path.name}: config string truncated")
561
+ return cfg.decode("latin-1")
562
+
563
+
564
+ def write_patched_pfile(src: str | Path, dst: str | Path, new_config_text: str) -> Path:
565
+ """Write *dst* = *src* with its embedded config replaced by *new_config_text*.
566
+
567
+ Copies the original header (patching only ``config_size``) and every data
568
+ record byte-for-byte. Refuses legacy (header major version < 6) files and any
569
+ configuration that would overflow the uint16 ``config_size`` field.
570
+ """
571
+ src = Path(src)
572
+ dst = Path(dst)
573
+ new_bytes = new_config_text.encode("latin-1")
574
+ with open(src, "rb") as f:
575
+ head = f.read(HEADER_BYTES)
576
+ if len(head) < HEADER_BYTES:
577
+ raise ValueError(f"{src.name}: file too small for a header")
578
+ endian = _detect_endian(head, src)
579
+ words = struct.unpack(f"{endian}{HEADER_WORDS}H", head)
580
+ header_size = words[_H["header_size"]]
581
+ config_size = words[_H["config_size"]]
582
+ # Bounds-validate header_size before it is used to size header_region /
583
+ # pack config_size: a corrupt header_size < HEADER_BYTES would otherwise
584
+ # raise a cryptic struct.error (uncatchable by the CLI's ValueError/OSError
585
+ # handler) or silently write a truncated (<128-byte) header.
586
+ if header_size < HEADER_BYTES:
587
+ raise ValueError(f"{src.name}: invalid header_size={header_size}")
588
+ major = words[_H["header_version"]] >> 8
589
+ if major < 6:
590
+ raise ValueError(
591
+ f"{src.name}: header version {major}.x (< 6) is not supported by patch-config"
592
+ )
593
+ if len(new_bytes) > 0xFFFF:
594
+ raise ValueError(
595
+ f"{src.name}: patched config is {len(new_bytes)} bytes, exceeds the "
596
+ f"65535-byte limit of the config_size header field"
597
+ )
598
+ # Guard against a truncated/corrupt source whose header advertises a
599
+ # first record larger than the file: seeking past EOF would otherwise
600
+ # copy zero data records and silently produce a data-less output.
601
+ file_size = src.stat().st_size
602
+ first_record_size = header_size + config_size
603
+ if first_record_size >= file_size:
604
+ raise ValueError(
605
+ f"{src.name}: header_size+config_size ({first_record_size}) is not smaller "
606
+ f"than the file ({file_size} bytes); the source is truncated or has no data records"
607
+ )
608
+ f.seek(0)
609
+ header_region = bytearray(f.read(header_size))
610
+ struct.pack_into(f"{endian}H", header_region, _H["config_size"] * 2, len(new_bytes))
611
+
612
+ dst.parent.mkdir(parents=True, exist_ok=True)
613
+ # Preserve the no-overwrite intent up front (cheap, and avoids work on a
614
+ # collision); the final os.replace re-checks just before committing.
615
+ if dst.exists():
616
+ raise FileExistsError(f"{dst} already exists; remove it or choose another --out")
617
+ f.seek(first_record_size) # start of the first data record
618
+ # Atomic write: stream into a sibling temp file, fsync, then os.replace
619
+ # into place. A mid-copy failure (disk full, I/O error, interrupt) thus
620
+ # leaves only the temp file (which we unlink), never a truncated but
621
+ # valid-looking "patched" .p in the final destination. "xb" keeps the
622
+ # temp create exclusive so concurrent patches can't share it.
623
+ tmp = dst.with_name(f"{dst.name}.tmp.{os.getpid()}")
624
+ try:
625
+ with open(tmp, "xb") as out:
626
+ out.write(header_region)
627
+ out.write(new_bytes)
628
+ while True:
629
+ chunk = f.read(1024 * 1024)
630
+ if not chunk:
631
+ break
632
+ out.write(chunk)
633
+ out.flush()
634
+ os.fsync(out.fileno())
635
+ if dst.exists():
636
+ raise FileExistsError(f"{dst} already exists; remove it or choose another --out")
637
+ # os.link (not os.replace): atomically fails if dst already exists,
638
+ # preserving the no-clobber-under-concurrency guarantee the prior
639
+ # open(dst, "xb") had — os.replace would silently overwrite a file a
640
+ # racing process created between the check above and the commit.
641
+ os.link(tmp, dst)
642
+ tmp.unlink()
643
+ except BaseException:
644
+ tmp.unlink(missing_ok=True)
645
+ raise
646
+ return dst
647
+
648
+
649
+ # ---------------------------------------------------------------------------
650
+ # Template scaffolding
651
+ # ---------------------------------------------------------------------------
652
+
653
+
654
+ def scaffold_yaml(pfile_path: str | Path) -> str:
655
+ """Build a commented edit-spec template pre-filled from *pfile_path*."""
656
+ p = Path(pfile_path)
657
+ cfg = parse_config(read_config_text(p))
658
+ ii = cfg.get("instrument_info", {})
659
+ ci = cfg.get("cruise_info", {})
660
+ chans = cfg.get("channels", [])
661
+
662
+ lines = [
663
+ f"# rsi-tpw patch-config edit spec (generated from {p.name})",
664
+ "#",
665
+ "# HOW TO USE:",
666
+ '# 1. Change the values you want. QUOTE every value, e.g. sens: "0.0812"',
667
+ "# 2. Delete every line you are NOT changing (only real edits are applied).",
668
+ f"# 3. rsi-tpw patch-config {p.name} --edits THIS.yaml --out patched/",
669
+ "#",
670
+ "# Your original file is never modified. If nothing actually changes, no",
671
+ "# file is written.",
672
+ "",
673
+ 'note: "describe what you changed and why"',
674
+ '# author: "your name"',
675
+ "",
676
+ "instrument_info:",
677
+ f' vehicle: "{ii.get("vehicle", "")}" # the field that actually affects processing',
678
+ ]
679
+ if ii.get("model") is not None:
680
+ lines.append(f' # model: "{ii.get("model", "")}"')
681
+ lines.append("")
682
+
683
+ if ci:
684
+ lines.append("# cruise_info:")
685
+ for key, val in ci.items():
686
+ lines.append(f'# {key}: "{val}"')
687
+ lines.append("")
688
+
689
+ lines.append("# channels: # per-channel calibration (usually one instrument at a time)")
690
+ for ch in chans:
691
+ name = ch.get("name")
692
+ if not name:
693
+ continue
694
+ shown = [k for k in ("sens", "sn", "cal_date", "coef0", "coef1") if k in ch]
695
+ if not shown:
696
+ continue
697
+ lines.append(f"# {name}:")
698
+ for key in shown:
699
+ lines.append(f'# {key}: "{ch[key]}"')
700
+ lines.append("")
701
+ return "\n".join(lines) + "\n"
702
+
703
+
704
+ # ---------------------------------------------------------------------------
705
+ # Orchestration
706
+ # ---------------------------------------------------------------------------
707
+
708
+ # Editing any channel key is per-instrument calibration; applying it to many files
709
+ # at once is usually a mistake, so it requires an explicit opt-in.
710
+
711
+
712
+ def patch_files(
713
+ srcs: list[Path],
714
+ out_dir: str | Path,
715
+ spec: EditSpec,
716
+ *,
717
+ dry_run: bool = False,
718
+ add_keys: bool = False,
719
+ batch_cal: bool = False,
720
+ when: str = "",
721
+ ) -> list[tuple[Path, Path | None, list[Change]]]:
722
+ """Apply *spec* to each source file, writing patched copies into *out_dir*.
723
+
724
+ Returns ``(src, dst_or_None, changes)`` per file. ``dst`` is ``None`` when the
725
+ file was a no-op (no values changed) or for a dry run. Progress is printed.
726
+ """
727
+ out_dir = Path(out_dir)
728
+ if spec.touches_channels and len(srcs) > 1 and not batch_cal:
729
+ raise ValueError(
730
+ "refusing to apply per-channel calibration edits to multiple files "
731
+ "(calibrations are per-instrument); pass --batch-cal if this is intended"
732
+ )
733
+
734
+ # Outputs are keyed by basename (out_dir / src.name), so two inputs that
735
+ # share a basename (e.g. dirA/SAME.p and dirB/SAME.p) would collide: the
736
+ # first is written and the second aborts the whole batch with a misleading
737
+ # "already exists" error. Detect the real cause up front, before any output.
738
+ by_name: dict[str, list[Path]] = {}
739
+ for src in srcs:
740
+ by_name.setdefault(Path(src).name, []).append(Path(src))
741
+ collisions = {name: paths for name, paths in by_name.items() if len(paths) > 1}
742
+ if collisions:
743
+ detail = "; ".join(
744
+ f"{name}: {', '.join(str(p) for p in paths)}"
745
+ for name, paths in sorted(collisions.items())
746
+ )
747
+ raise ValueError(
748
+ f"multiple source files share a basename and would map to the same output "
749
+ f"in {out_dir}: {detail}; patch them separately into distinct --out directories"
750
+ )
751
+
752
+ results: list[tuple[Path, Path | None, list[Change]]] = []
753
+ for src in srcs:
754
+ src = Path(src)
755
+ dst = out_dir / src.name
756
+ if dst.resolve() == src.resolve():
757
+ raise ValueError(
758
+ f"--out would overwrite the original {src}; choose a different directory"
759
+ )
760
+ cfg = read_config_text(src)
761
+ new_text, changes = edit_config_text(
762
+ cfg, spec, add_keys=add_keys, when=when, source_label=str(src.resolve())
763
+ )
764
+ if not changes:
765
+ print(f"{src.name}: no changes; not written")
766
+ results.append((src, None, []))
767
+ continue
768
+ if dry_run:
769
+ print(f"--- {src.name} (dry run, {len(changes)} change(s)) ---")
770
+ diff = difflib.unified_diff(
771
+ cfg.splitlines(), new_text.splitlines(), src.name, dst.name, lineterm=""
772
+ )
773
+ for line in diff:
774
+ print(line)
775
+ results.append((src, None, changes))
776
+ continue
777
+ if dst.exists():
778
+ raise FileExistsError(f"{dst} already exists; remove it or choose another --out")
779
+ write_patched_pfile(src, dst, new_text)
780
+ print(f"{src.name}: {len(changes)} change(s) -> {dst}")
781
+ for ch in changes:
782
+ arrow = f'(added) = "{ch.new}"' if ch.old is None else f'"{ch.old}" -> "{ch.new}"'
783
+ print(f" {ch.where} {ch.key}: {arrow}")
784
+ results.append((src, dst, changes))
785
+ return results
786
+
787
+
788
+ def _print_error_and_exit(message: str) -> None: # pragma: no cover - CLI glue
789
+ print(f"Error: {message}", file=sys.stderr)
790
+ sys.exit(1)