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,310 @@
1
+ # Jul-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """Auto-generate a plotting ``sections.yaml`` from a perturb run's profiles.
3
+
4
+ A *section* (see :mod:`odas_tpw.perturb.plot.sections`) groups a contiguous
5
+ batch of casts and assigns an x-axis for depth-vs-x plots. This builds one
6
+ automatically by splitting the run's detected profiles wherever the time gap
7
+ between consecutive casts exceeds a threshold — the common "casts arrive in
8
+ station batches separated by transits" pattern.
9
+
10
+ The split criterion lives in :func:`split_indices`; a future heading-change
11
+ criterion (a new transect leg in lat/lon space) plugs in there without touching
12
+ the reader or the writer.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import math
19
+ import sys
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import TYPE_CHECKING
23
+
24
+ if TYPE_CHECKING:
25
+ import numpy as np
26
+
27
+ # numpy is imported inside the functions that use it (not at module top) so that
28
+ # merely building the CLI parser — cli.build_parser() imports this module to
29
+ # register the `sections` subcommand's args on every `perturb` invocation — does
30
+ # not pull numpy for subcommands that never touch it. Matches the lazy-import
31
+ # discipline in cli.py and the deferred xarray/ruamel imports below.
32
+
33
+ _DUR_UNITS = {"s": 1.0, "m": 60.0, "h": 3600.0, "d": 86400.0}
34
+
35
+
36
+ def parse_duration(text: str) -> float:
37
+ """Parse ``'90m'`` / ``'1.5h'`` / ``'3600s'`` / ``'2d'`` / bare seconds.
38
+
39
+ Returns seconds as a float. A bare number is seconds.
40
+ """
41
+ s = str(text).strip().lower()
42
+ if not s:
43
+ raise ValueError("empty duration")
44
+ unit = _DUR_UNITS.get(s[-1])
45
+ if unit is not None:
46
+ s = s[:-1]
47
+ else:
48
+ unit = 1.0
49
+ try:
50
+ value = float(s)
51
+ except ValueError:
52
+ raise ValueError(
53
+ f"could not parse duration {text!r} (use e.g. 90m, 1.5h, 3600s, 2d)"
54
+ ) from None
55
+ if not (value > 0 and math.isfinite(value)):
56
+ raise ValueError(f"duration must be a positive finite number, got {text!r}")
57
+ return value * unit
58
+
59
+
60
+ @dataclass
61
+ class ProfileTimes:
62
+ """Per-profile start times (epoch seconds) and positions, sorted by time."""
63
+
64
+ stime: np.ndarray
65
+ lat: np.ndarray
66
+ lon: np.ndarray
67
+
68
+
69
+ def read_profile_times(config: dict, product: str = "combo") -> ProfileTimes:
70
+ """Read per-profile ``stime`` / ``lat`` / ``lon`` from a run's combo product.
71
+
72
+ Resolves the ``{product}_NN`` directory for *config* (by signature, via
73
+ :mod:`odas_tpw.perturb.resolve`) and reads ``combo.nc``. Profiles with a
74
+ non-finite start time are dropped; the result is sorted by time.
75
+ """
76
+ import numpy as np
77
+ import xarray as xr
78
+
79
+ from odas_tpw.perturb import resolve
80
+
81
+ try:
82
+ stage = resolve.stage_dir(config, product)
83
+ except FileNotFoundError as exc:
84
+ raise SystemExit(
85
+ f"no {product}_NN output found — run the pipeline first, or pass "
86
+ f"--product (e.g. diss_combo). ({exc})"
87
+ ) from exc
88
+ except resolve.StageConflict as exc:
89
+ raise SystemExit(str(exc)) from exc
90
+ except ValueError as exc: # unknown stage/product name (not in resolve.STAGES)
91
+ raise SystemExit(
92
+ f"{exc} (--product must name a combo product, e.g. combo, diss_combo, "
93
+ "chi_combo)"
94
+ ) from exc
95
+ path = Path(stage) / "combo.nc"
96
+ if not path.exists():
97
+ raise SystemExit(
98
+ f"{path} not found — the {product} stage directory exists but has no "
99
+ "combo.nc (an interrupted run?). Re-run the pipeline's combo stage."
100
+ )
101
+ with xr.open_dataset(path, decode_times=False) as ds:
102
+ if "stime" not in ds.variables:
103
+ raise SystemExit(
104
+ f"{path}: no per-profile 'stime' variable — {product} is not a "
105
+ "(bin, profile) product; try --product combo or diss_combo."
106
+ )
107
+ stime = np.asarray(ds["stime"].values, dtype=float)
108
+ n = stime.size
109
+ lat = (
110
+ np.asarray(ds["lat"].values, dtype=float)
111
+ if "lat" in ds.variables else np.full(n, np.nan)
112
+ )
113
+ lon = (
114
+ np.asarray(ds["lon"].values, dtype=float)
115
+ if "lon" in ds.variables else np.full(n, np.nan)
116
+ )
117
+ finite = np.isfinite(stime)
118
+ stime, lat, lon = stime[finite], lat[finite], lon[finite]
119
+ order = np.argsort(stime, kind="stable")
120
+ return ProfileTimes(stime[order], lat[order], lon[order])
121
+
122
+
123
+ def split_indices(stime: np.ndarray, gap_seconds: float) -> list[np.ndarray]:
124
+ """Split time-sorted profiles into groups at gaps exceeding *gap_seconds*.
125
+
126
+ Returns a list of index arrays (into *stime*), one per section, in time
127
+ order. This is the extension seam: an additional criterion (e.g. a change
128
+ of heading in lat/lon space) would contribute more break points here.
129
+ """
130
+ import numpy as np
131
+
132
+ stime = np.asarray(stime, dtype=float)
133
+ if stime.size == 0:
134
+ return []
135
+ breaks = np.flatnonzero(np.diff(stime) > gap_seconds) + 1
136
+ return np.split(np.arange(stime.size), breaks)
137
+
138
+
139
+ def _iso(epoch: float) -> str:
140
+ """UTC ``YYYY-MM-DDTHH:MM:SSZ`` for an integer-second instant.
141
+
142
+ *epoch* is expected already floored/ceiled to whole seconds by the caller;
143
+ a fractional value is truncated toward zero.
144
+ """
145
+ import numpy as np
146
+
147
+ return np.datetime_as_string(np.datetime64(int(epoch), "s"), unit="s") + "Z"
148
+
149
+
150
+ def build_sections(
151
+ pt: ProfileTimes,
152
+ groups: list[np.ndarray],
153
+ method: str,
154
+ units: str,
155
+ pad: float,
156
+ ) -> list[dict]:
157
+ """One section dict per group: name, padded UTC window, cast count, xaxis."""
158
+ xaxis: dict = {"method": method}
159
+ if method in ("distance_from_point", "along_line", "signed_distance"):
160
+ xaxis["units"] = units
161
+ sections = []
162
+ for i, idx in enumerate(groups):
163
+ # Floor the start and ceil the stop so the integer-second window always
164
+ # brackets the batch's first/last cast, even at --pad 0 (the plot side
165
+ # filters inclusively: stime >= start & stime <= stop).
166
+ sections.append({
167
+ "name": f"section_{i:02d}",
168
+ "n_casts": int(idx.size),
169
+ "start": _iso(math.floor(float(pt.stime[idx[0]]) - pad)),
170
+ "stop": _iso(math.ceil(float(pt.stime[idx[-1]]) + pad)),
171
+ "xaxis": dict(xaxis),
172
+ })
173
+ return sections
174
+
175
+
176
+ def render_yaml(sections: list[dict], header: str) -> str:
177
+ """Render section dicts to a sections.yaml matching the hand-written style."""
178
+ lines = [header, "", "sections:"]
179
+ for s in sections:
180
+ lines.append(f" # {s['n_casts']} cast(s)")
181
+ lines.append(f" - name: {s['name']}")
182
+ lines.append(f' start: "{s["start"]}"')
183
+ lines.append(f' stop: "{s["stop"]}"')
184
+ xa = s["xaxis"]
185
+ if set(xa) == {"method"}:
186
+ lines.append(f" xaxis: {{method: {xa['method']}}}")
187
+ else:
188
+ lines.append(" xaxis:")
189
+ lines.append(f" method: {xa['method']}")
190
+ for key, val in xa.items():
191
+ if key != "method":
192
+ lines.append(f" {key}: {val}")
193
+ return "\n".join(lines) + "\n"
194
+
195
+
196
+ def _validate(text: str) -> None:
197
+ """Confirm the emitted YAML round-trips through the plot-side loader."""
198
+ from ruamel.yaml import YAML
199
+
200
+ from odas_tpw.perturb.plot import sections as plot_sections
201
+
202
+ raw = YAML().load(text) or {}
203
+ for i, sec in enumerate(raw.get("sections", [])):
204
+ plot_sections._section_from_dict(dict(sec), i) # raises on any invalid field
205
+
206
+
207
+ def _report(pt: ProfileTimes, gap: float, sections: list[dict]) -> None:
208
+ """Print a human summary + the largest gaps (so --gap is easy to tune)."""
209
+ import numpy as np
210
+
211
+ span_h = (float(pt.stime[-1]) - float(pt.stime[0])) / 3600.0 if pt.stime.size else 0.0
212
+ print(
213
+ f"{pt.stime.size} profiles over {span_h:.1f} h -> {len(sections)} sections "
214
+ f"(gap > {gap / 3600:.2f} h)",
215
+ file=sys.stderr,
216
+ )
217
+ if pt.stime.size > 1:
218
+ top = np.sort(np.diff(pt.stime))[::-1][:8]
219
+ pretty = ", ".join(f"{g / 3600:.2f}h" for g in top)
220
+ print(f" largest inter-cast gaps: {pretty}", file=sys.stderr)
221
+ print(" (re-run with --gap to change the batching)", file=sys.stderr)
222
+
223
+
224
+ def add_arguments(parser: argparse.ArgumentParser) -> None:
225
+ """Register the ``perturb sections`` flags on *parser*."""
226
+ parser.add_argument(
227
+ "-c", "--config", metavar="YAML", required=True,
228
+ help="perturb config (locates the run's combo output)",
229
+ )
230
+ parser.add_argument(
231
+ "-o", "--output", metavar="FILE",
232
+ help="write the sections YAML here (default: stdout)",
233
+ )
234
+ parser.add_argument(
235
+ "--gap", default="1h", metavar="DUR",
236
+ help="start a new section when the gap between consecutive casts exceeds "
237
+ "this (e.g. 90m, 1.5h, 3600s, 2d; default 1h)",
238
+ )
239
+ parser.add_argument(
240
+ "--xaxis", default="time",
241
+ choices=["time", "latitude", "longitude", "signed_distance"],
242
+ help="x-axis method for every section (default time). distance_from_point "
243
+ "and along_line are omitted: they need a point/waypoints this tool "
244
+ "cannot infer — add them by hand afterward.",
245
+ )
246
+ parser.add_argument(
247
+ "--units", default="km", choices=["m", "km", "nm"],
248
+ help="distance units for a spatial --xaxis (default km)",
249
+ )
250
+ parser.add_argument(
251
+ "--pad", type=float, default=30.0, metavar="SEC",
252
+ help="pad each section's time window by this many seconds (default 30)",
253
+ )
254
+ parser.add_argument(
255
+ "--product", default="combo", metavar="P",
256
+ help="combo product to read per-profile times from (default combo)",
257
+ )
258
+ parser.add_argument(
259
+ "-f", "--force", action="store_true",
260
+ help="overwrite --output if it already exists",
261
+ )
262
+
263
+
264
+ def run(args: argparse.Namespace) -> None:
265
+ """Build a sections.yaml from a run's profiles and write it (or print it)."""
266
+ from odas_tpw.perturb.config import load_config
267
+
268
+ gap = parse_duration(args.gap)
269
+ if not (math.isfinite(args.pad) and args.pad >= 0):
270
+ raise SystemExit(
271
+ f"--pad must be a non-negative finite number of seconds, got {args.pad}"
272
+ )
273
+
274
+ # Validate the output destination before the (expensive) combo read, so a
275
+ # bad -o path fails fast instead of after loading and processing the run.
276
+ out: Path | None = None
277
+ if args.output:
278
+ out = Path(args.output)
279
+ if out.exists() and not args.force:
280
+ raise SystemExit(f"{out} exists; pass --force to overwrite")
281
+ if not out.parent.exists():
282
+ raise SystemExit(
283
+ f"{out.parent} does not exist — create it or choose another "
284
+ "--output path"
285
+ )
286
+
287
+ config = load_config(args.config)
288
+ pt = read_profile_times(config, args.product)
289
+ if pt.stime.size == 0:
290
+ raise SystemExit("no profiles with a finite start time were found")
291
+
292
+ groups = split_indices(pt.stime, gap)
293
+ sections = build_sections(pt, groups, args.xaxis, args.units, args.pad)
294
+ span_h = (float(pt.stime[-1]) - float(pt.stime[0])) / 3600.0
295
+ header = (
296
+ f"# Auto-generated by `perturb sections` from {args.config}\n"
297
+ f"# {pt.stime.size} profiles over {span_h:.1f} h, split on gaps > "
298
+ f"{args.gap} -> {len(sections)} sections.\n"
299
+ f"# Edit freely: adjust windows, rename, or change each section's xaxis.\n"
300
+ f"# Re-run with --gap (e.g. --gap 2h) to change the batch granularity."
301
+ )
302
+ text = render_yaml(sections, header)
303
+ _validate(text) # never emit something the plot loader would reject
304
+ _report(pt, gap, sections)
305
+
306
+ if out is not None:
307
+ out.write_text(text, encoding="utf-8")
308
+ print(f"wrote {len(sections)} sections -> {out}", file=sys.stderr)
309
+ else:
310
+ sys.stdout.write(text)