microstructure-tpw 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- microstructure_tpw-0.3.0.dist-info/METADATA +234 -0
- microstructure_tpw-0.3.0.dist-info/RECORD +119 -0
- microstructure_tpw-0.3.0.dist-info/WHEEL +5 -0
- microstructure_tpw-0.3.0.dist-info/entry_points.txt +7 -0
- microstructure_tpw-0.3.0.dist-info/licenses/LICENSE +674 -0
- microstructure_tpw-0.3.0.dist-info/top_level.txt +1 -0
- odas_tpw/__init__.py +18 -0
- odas_tpw/chi/__init__.py +48 -0
- odas_tpw/chi/batchelor.py +189 -0
- odas_tpw/chi/chi.py +857 -0
- odas_tpw/chi/fp07.py +649 -0
- odas_tpw/chi/l2_chi.py +153 -0
- odas_tpw/chi/l3_chi.py +388 -0
- odas_tpw/chi/l4_chi.py +391 -0
- odas_tpw/config_base.py +423 -0
- odas_tpw/perturb/__init__.py +18 -0
- odas_tpw/perturb/adcp.py +274 -0
- odas_tpw/perturb/atomic_io.py +47 -0
- odas_tpw/perturb/binning.py +761 -0
- odas_tpw/perturb/cli.py +591 -0
- odas_tpw/perturb/combo.py +346 -0
- odas_tpw/perturb/config.py +1046 -0
- odas_tpw/perturb/ctd.py +424 -0
- odas_tpw/perturb/diag/__init__.py +12 -0
- odas_tpw/perturb/diag/_common.py +140 -0
- odas_tpw/perturb/diag/chi.py +42 -0
- odas_tpw/perturb/diag/cli.py +67 -0
- odas_tpw/perturb/diag/data.py +662 -0
- odas_tpw/perturb/diag/epsilon.py +45 -0
- odas_tpw/perturb/diag/inspector.py +342 -0
- odas_tpw/perturb/diag/mixing.py +94 -0
- odas_tpw/perturb/diag/render.py +543 -0
- odas_tpw/perturb/discover.py +40 -0
- odas_tpw/perturb/fp07_cal.py +354 -0
- odas_tpw/perturb/gps.py +458 -0
- odas_tpw/perturb/hotel.py +572 -0
- odas_tpw/perturb/logging_setup.py +181 -0
- odas_tpw/perturb/merge.py +320 -0
- odas_tpw/perturb/mk_sections.py +310 -0
- odas_tpw/perturb/netcdf_schema.py +520 -0
- odas_tpw/perturb/pipeline.py +2654 -0
- odas_tpw/perturb/plot/__init__.py +15 -0
- odas_tpw/perturb/plot/cli.py +107 -0
- odas_tpw/perturb/plot/diagnostics.py +195 -0
- odas_tpw/perturb/plot/eps_chi.py +524 -0
- odas_tpw/perturb/plot/figure.py +679 -0
- odas_tpw/perturb/plot/gamma_scaling.py +1097 -0
- odas_tpw/perturb/plot/grid.py +112 -0
- odas_tpw/perturb/plot/layout.py +361 -0
- odas_tpw/perturb/plot/overview.py +547 -0
- odas_tpw/perturb/plot/presets/eps-chi.yaml +16 -0
- odas_tpw/perturb/plot/presets/profiles.yaml +32 -0
- odas_tpw/perturb/plot/presets/scalar.yaml +21 -0
- odas_tpw/perturb/plot/profiles.py +652 -0
- odas_tpw/perturb/plot/scalar.py +391 -0
- odas_tpw/perturb/plot/sections.py +506 -0
- odas_tpw/perturb/plot/xaxis.py +396 -0
- odas_tpw/perturb/qc_gate.py +177 -0
- odas_tpw/perturb/qc_rules.py +373 -0
- odas_tpw/perturb/resolve.py +306 -0
- odas_tpw/perturb/seawater.py +76 -0
- odas_tpw/perturb/speed.py +14 -0
- odas_tpw/perturb/trim.py +313 -0
- odas_tpw/processing/__init__.py +62 -0
- odas_tpw/processing/bottom.py +215 -0
- odas_tpw/processing/chi_combine.py +300 -0
- odas_tpw/processing/ct_align.py +172 -0
- odas_tpw/processing/epsilon_combine.py +210 -0
- odas_tpw/processing/mixing.py +630 -0
- odas_tpw/processing/thorpe.py +458 -0
- odas_tpw/processing/top_trim.py +274 -0
- odas_tpw/pyturb/__init__.py +3 -0
- odas_tpw/pyturb/__main__.py +5 -0
- odas_tpw/pyturb/_compat.py +204 -0
- odas_tpw/pyturb/_profind.py +124 -0
- odas_tpw/pyturb/bin.py +113 -0
- odas_tpw/pyturb/cli.py +175 -0
- odas_tpw/pyturb/eps.py +353 -0
- odas_tpw/pyturb/merge.py +101 -0
- odas_tpw/pyturb/p2nc.py +76 -0
- odas_tpw/rsi/__init__.py +31 -0
- odas_tpw/rsi/adapter.py +314 -0
- odas_tpw/rsi/bench.py +904 -0
- odas_tpw/rsi/binning.py +193 -0
- odas_tpw/rsi/channels.py +376 -0
- odas_tpw/rsi/chi_io.py +980 -0
- odas_tpw/rsi/cli.py +1411 -0
- odas_tpw/rsi/combine.py +120 -0
- odas_tpw/rsi/config.py +126 -0
- odas_tpw/rsi/config_patch.py +790 -0
- odas_tpw/rsi/convert.py +651 -0
- odas_tpw/rsi/deconvolve.py +119 -0
- odas_tpw/rsi/diss_look.py +343 -0
- odas_tpw/rsi/dissipation.py +669 -0
- odas_tpw/rsi/helpers.py +527 -0
- odas_tpw/rsi/mixing_look.py +509 -0
- odas_tpw/rsi/p_file.py +771 -0
- odas_tpw/rsi/pipeline.py +924 -0
- odas_tpw/rsi/profile.py +624 -0
- odas_tpw/rsi/quick_look.py +758 -0
- odas_tpw/rsi/sensor_inventory.py +684 -0
- odas_tpw/rsi/shear_noise.py +128 -0
- odas_tpw/rsi/speed.py +221 -0
- odas_tpw/rsi/vehicle.py +74 -0
- odas_tpw/rsi/viewer_base.py +1071 -0
- odas_tpw/rsi/window.py +459 -0
- odas_tpw/scor160/__init__.py +36 -0
- odas_tpw/scor160/cli.py +242 -0
- odas_tpw/scor160/compare.py +484 -0
- odas_tpw/scor160/despike.py +227 -0
- odas_tpw/scor160/goodman.py +288 -0
- odas_tpw/scor160/io.py +690 -0
- odas_tpw/scor160/l2.py +311 -0
- odas_tpw/scor160/l3.py +279 -0
- odas_tpw/scor160/l4.py +850 -0
- odas_tpw/scor160/nasmyth.py +208 -0
- odas_tpw/scor160/ocean.py +287 -0
- odas_tpw/scor160/profile.py +231 -0
- odas_tpw/scor160/spectral.py +482 -0
|
@@ -0,0 +1,1097 @@
|
|
|
1
|
+
# Jul-2026, Claude and Pat Welch, pat@mousebrains.com
|
|
2
|
+
"""Mixing-efficiency scaling figures (Lewin et al. 2025, Fig. 5).
|
|
3
|
+
|
|
4
|
+
``perturb-plot gamma-scaling`` sweeps a run's **per-window** products
|
|
5
|
+
(``chi_NN`` + ``profiles_NN``, plus ``diss_NN`` for QC flags / legacy
|
|
6
|
+
epsilon re-pairing when the chi product lacks ``epsilon_paired``) and
|
|
7
|
+
renders the flux
|
|
8
|
+
coefficient Gamma against the three dimensionless mixing parameters of
|
|
9
|
+
Lewin et al. (2025, https://doi.org/10.1175/JPO-D-25-0012.1):
|
|
10
|
+
|
|
11
|
+
- ``R_OT = L_O / L_T`` — Ozmidov over Thorpe scale (turbulence age proxy),
|
|
12
|
+
- ``Re_b = epsilon / (nu * N2_patch)`` — buoyancy Reynolds number,
|
|
13
|
+
- ``Ri_g = N2 / S2`` — gradient Richardson number (needs ``--adcp``).
|
|
14
|
+
|
|
15
|
+
Each panel: scatter of per-window values (colored by ``--sections``
|
|
16
|
+
windows when given — the analog of the paper's W1/W2), medians in
|
|
17
|
+
equispaced log-x bins with 5/95% whiskers, marginal pdfs, and the
|
|
18
|
+
literature slope guides (-1 and -4/3 on R_OT; -1/2 on Re_b; +1 on Ri_g).
|
|
19
|
+
A companion figure compares the two Thorpe-sort routes (JAC sigma0 vs
|
|
20
|
+
FP07 temperature) so the density route's ``L_T`` floor can be judged
|
|
21
|
+
against data rather than assumed.
|
|
22
|
+
|
|
23
|
+
Method notes (deliberate deviations from the paper are called out):
|
|
24
|
+
|
|
25
|
+
- **Gamma is recomputed from unmasked components** (``chiMean``, paired
|
|
26
|
+
``epsilonMean``, ``N2``, ``dTdz``) rather than read from the stored
|
|
27
|
+
``Gamma``: the pipeline's mixing product masks ``Gamma > 5`` and
|
|
28
|
+
``|dTdz| < 1e-4`` (sane for maps, but exactly the tails this figure
|
|
29
|
+
exists to show — the paper's axis runs to 10 and its weak-gradient
|
|
30
|
+
windows are *kept*, filtered instead by the Cox number). QC here is
|
|
31
|
+
the paper's: ``Re_b > 20``, ``C_x > 50``, upper ``--min-depth`` cut,
|
|
32
|
+
plus the pipeline's ``qc_drop_*`` hotel flags.
|
|
33
|
+
- **Thorpe scales** come from the slow (64 Hz) grid — both routes:
|
|
34
|
+
``sigma0`` (JAC CT; trusted salinity, so an upgrade on the paper's
|
|
35
|
+
temperature-only sort) and the FP07 ``T1`` (the paper's proxy) — over
|
|
36
|
+
a ``--sort-window`` (default 4 s, the paper's segment span) centered
|
|
37
|
+
on each chi window. ``N2_patch`` is the Smyth/Kaminski
|
|
38
|
+
overturn-weighted form (see :mod:`odas_tpw.processing.thorpe`); when
|
|
39
|
+
``L_T`` is below the route's floor the window falls back to the
|
|
40
|
+
chi-window background ``N2`` (the paper does the same below 0.05 m).
|
|
41
|
+
- **Our N2 is full TEOS-10** (temperature + JAC salinity); the paper's
|
|
42
|
+
is temperature-only (their Eq. 4). Better physics, but medians can
|
|
43
|
+
differ systematically where salinity stratifies or compensates.
|
|
44
|
+
- **Ri_g is a coarse-grained analog, not a clone**: shipboard-ADCP
|
|
45
|
+
S2 over >= 2-m bins and ~120-s ensembles vs their 3-m pole ADCP at
|
|
46
|
+
20-s cadence. The numerator N2 is recomputed over ``--ri-n2-span``
|
|
47
|
+
(default 4 m ~ 2 ADCP bins) so both Ri_g operands live at a matched
|
|
48
|
+
vertical scale. Multiple ``--adcp`` sonars are **never blended** —
|
|
49
|
+
each gets its own panel (16-m os75 shear is not the same quantity as
|
|
50
|
+
2-m wh300 shear).
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
import argparse
|
|
56
|
+
import glob
|
|
57
|
+
import os
|
|
58
|
+
import sys
|
|
59
|
+
from dataclasses import dataclass, field
|
|
60
|
+
from typing import TYPE_CHECKING, Any
|
|
61
|
+
|
|
62
|
+
import numpy as np
|
|
63
|
+
import xarray as xr
|
|
64
|
+
|
|
65
|
+
from odas_tpw.perturb import resolve
|
|
66
|
+
from odas_tpw.perturb.hotel import _dt64_to_epoch_s
|
|
67
|
+
from odas_tpw.perturb.plot import xaxis
|
|
68
|
+
from odas_tpw.perturb.plot.sections import (
|
|
69
|
+
add_output_arguments,
|
|
70
|
+
can_display,
|
|
71
|
+
close_new_figs_on_error,
|
|
72
|
+
closing_figs,
|
|
73
|
+
fig_dpi,
|
|
74
|
+
grouped,
|
|
75
|
+
load_sections,
|
|
76
|
+
save_or_show,
|
|
77
|
+
select_sections,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if TYPE_CHECKING:
|
|
81
|
+
from collections.abc import Iterator
|
|
82
|
+
|
|
83
|
+
from matplotlib.axes import Axes
|
|
84
|
+
from matplotlib.figure import Figure
|
|
85
|
+
|
|
86
|
+
# Paper QC defaults (Lewin et al. 2025 section 2): discard Re_b <= 20 and
|
|
87
|
+
# Cox number <= 50; drop the ship-wake-contaminated upper water column.
|
|
88
|
+
DEFAULT_MIN_REB = 20.0
|
|
89
|
+
DEFAULT_MIN_COX = 50.0
|
|
90
|
+
DEFAULT_MIN_DEPTH = 10.0 # [dbar]
|
|
91
|
+
|
|
92
|
+
# diss->chi window pairing tolerance [s]. The pipeline writes both products
|
|
93
|
+
# on the same window grid (sub-ms offsets); 0.5 s tolerates a half-step
|
|
94
|
+
# offset without ever pairing across neighboring 1-s windows.
|
|
95
|
+
DEFAULT_MAX_DT = 0.5
|
|
96
|
+
|
|
97
|
+
# Vertical span [m] for the Ri_g numerator's N2 (~2 wh300 bins), so N2 and
|
|
98
|
+
# S2 live at a matched scale; 0 falls back to the chi-window N2.
|
|
99
|
+
DEFAULT_RI_N2_SPAN = 4.0
|
|
100
|
+
|
|
101
|
+
_GUIDES: dict[str, tuple[tuple[float, str], ...]] = {
|
|
102
|
+
"R_OT": ((-1.0, "-1"), (-4.0 / 3.0, "-4/3")),
|
|
103
|
+
"Re_b": ((-0.5, "-1/2"),),
|
|
104
|
+
"Ri_g": ((1.0, "1"),),
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class _Table:
|
|
110
|
+
"""Window-level columns concatenated over every profile."""
|
|
111
|
+
|
|
112
|
+
cols: dict[str, np.ndarray] = field(default_factory=dict)
|
|
113
|
+
s2: dict[str, np.ndarray] = field(default_factory=dict) # per sonar
|
|
114
|
+
rig: dict[str, np.ndarray] = field(default_factory=dict) # per sonar
|
|
115
|
+
n_profiles: int = 0
|
|
116
|
+
|
|
117
|
+
def __getitem__(self, key: str) -> np.ndarray:
|
|
118
|
+
return self.cols[key]
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def n(self) -> int:
|
|
122
|
+
return self.cols["epoch"].size if self.cols else 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def add_arguments(p: argparse.ArgumentParser) -> None:
|
|
126
|
+
"""Register the ``gamma-scaling`` flags on *p*."""
|
|
127
|
+
p.add_argument(
|
|
128
|
+
"--root",
|
|
129
|
+
default=None,
|
|
130
|
+
help="perturb output root (contains chi_NN/diss_NN/"
|
|
131
|
+
"profiles_NN). Required unless --config is given.",
|
|
132
|
+
)
|
|
133
|
+
resolve.add_resolve_args(p)
|
|
134
|
+
p.add_argument(
|
|
135
|
+
"--sections",
|
|
136
|
+
default=None,
|
|
137
|
+
help="sections YAML; windows are colored by which section's "
|
|
138
|
+
"start/stop window they fall in (only name/start/stop "
|
|
139
|
+
"are used — the x-axis entries are ignored here)",
|
|
140
|
+
)
|
|
141
|
+
p.add_argument(
|
|
142
|
+
"--select",
|
|
143
|
+
action="append",
|
|
144
|
+
default=None,
|
|
145
|
+
metavar="NAME",
|
|
146
|
+
help="use only the named section(s) for coloring (repeatable or comma-separated)",
|
|
147
|
+
)
|
|
148
|
+
p.add_argument(
|
|
149
|
+
"--out-dir",
|
|
150
|
+
default=None,
|
|
151
|
+
help="write PNGs here instead of showing on screen "
|
|
152
|
+
"(default: display; headless falls back to --root)",
|
|
153
|
+
)
|
|
154
|
+
p.add_argument(
|
|
155
|
+
"--adcp",
|
|
156
|
+
action="append",
|
|
157
|
+
default=None,
|
|
158
|
+
metavar="FILE",
|
|
159
|
+
help="CODAS gridded ADCP NetCDF (e.g. .../wh300/contour/"
|
|
160
|
+
"wh300.nc) for the Ri_g panel; repeatable — each "
|
|
161
|
+
"sonar gets its OWN panel (never blended)",
|
|
162
|
+
)
|
|
163
|
+
p.add_argument(
|
|
164
|
+
"--min-pg",
|
|
165
|
+
type=float,
|
|
166
|
+
default=None,
|
|
167
|
+
help="optional ADCP percent-good floor (default: none — "
|
|
168
|
+
"the CODAS contour product is already edited)",
|
|
169
|
+
)
|
|
170
|
+
p.add_argument(
|
|
171
|
+
"--time-tolerance",
|
|
172
|
+
type=float,
|
|
173
|
+
default=300.0,
|
|
174
|
+
metavar="SEC",
|
|
175
|
+
help="ADCP ensembles within this many seconds of a window "
|
|
176
|
+
"are averaged for S2 (default 300)",
|
|
177
|
+
)
|
|
178
|
+
p.add_argument(
|
|
179
|
+
"--sort-window",
|
|
180
|
+
type=float,
|
|
181
|
+
default=4.0,
|
|
182
|
+
metavar="SEC",
|
|
183
|
+
help="Thorpe sort-window span centered on each chi window "
|
|
184
|
+
"(default 4 s — the paper's segment span; wider "
|
|
185
|
+
"resolves larger overturns)",
|
|
186
|
+
)
|
|
187
|
+
p.add_argument(
|
|
188
|
+
"--route",
|
|
189
|
+
choices=("density", "temperature"),
|
|
190
|
+
default="density",
|
|
191
|
+
help="which Thorpe route feeds R_OT/Re_b: density "
|
|
192
|
+
"(JAC sigma0; default) or temperature (FP07 T1, "
|
|
193
|
+
"paper-style)",
|
|
194
|
+
)
|
|
195
|
+
p.add_argument(
|
|
196
|
+
"--lt-floor-density",
|
|
197
|
+
type=float,
|
|
198
|
+
default=None,
|
|
199
|
+
metavar="M",
|
|
200
|
+
help="L_T floor for the sigma0 route (default 0.10 m — "
|
|
201
|
+
"provisional; judge it from the route-comparison "
|
|
202
|
+
"figure)",
|
|
203
|
+
)
|
|
204
|
+
p.add_argument(
|
|
205
|
+
"--lt-floor-temperature",
|
|
206
|
+
type=float,
|
|
207
|
+
default=None,
|
|
208
|
+
metavar="M",
|
|
209
|
+
help="L_T floor for the T1 route (default 0.05 m, per the paper)",
|
|
210
|
+
)
|
|
211
|
+
p.add_argument(
|
|
212
|
+
"--min-reb",
|
|
213
|
+
type=float,
|
|
214
|
+
default=DEFAULT_MIN_REB,
|
|
215
|
+
help="discard windows with Re_b below this (default 20, per the paper; 0 disables)",
|
|
216
|
+
)
|
|
217
|
+
p.add_argument(
|
|
218
|
+
"--min-cox",
|
|
219
|
+
type=float,
|
|
220
|
+
default=DEFAULT_MIN_COX,
|
|
221
|
+
help="discard windows with Cox number below this (default 50, per the paper; 0 disables)",
|
|
222
|
+
)
|
|
223
|
+
p.add_argument(
|
|
224
|
+
"--min-depth",
|
|
225
|
+
type=float,
|
|
226
|
+
default=DEFAULT_MIN_DEPTH,
|
|
227
|
+
metavar="DBAR",
|
|
228
|
+
help="discard windows shallower than this (default 10 — ship-wake contamination)",
|
|
229
|
+
)
|
|
230
|
+
p.add_argument(
|
|
231
|
+
"--max-dt",
|
|
232
|
+
type=float,
|
|
233
|
+
default=DEFAULT_MAX_DT,
|
|
234
|
+
metavar="SEC",
|
|
235
|
+
help="diss->chi window pairing tolerance (default 0.5); applies to "
|
|
236
|
+
"the legacy re-pairing when the chi product has no epsilon_paired "
|
|
237
|
+
"(stored pairings are consumed as-is)",
|
|
238
|
+
)
|
|
239
|
+
p.add_argument(
|
|
240
|
+
"--ri-n2-span",
|
|
241
|
+
type=float,
|
|
242
|
+
default=DEFAULT_RI_N2_SPAN,
|
|
243
|
+
metavar="M",
|
|
244
|
+
help="vertical span for the Ri_g numerator N2 (default "
|
|
245
|
+
"4 m ~ 2 ADCP bins; 0 uses the chi-window N2)",
|
|
246
|
+
)
|
|
247
|
+
p.add_argument(
|
|
248
|
+
"--min-run",
|
|
249
|
+
type=float,
|
|
250
|
+
default=10,
|
|
251
|
+
metavar="N",
|
|
252
|
+
help="Galbraith-Kelley coherence: an overturn counts as "
|
|
253
|
+
"resolved only when >= N consecutive same-sign "
|
|
254
|
+
"displacements occur in the window (default 10 slow "
|
|
255
|
+
"samples ~ 10 cm at typical fall speed; noise flips "
|
|
256
|
+
"sign every 1-2)",
|
|
257
|
+
)
|
|
258
|
+
p.add_argument(
|
|
259
|
+
"--keep-truncated",
|
|
260
|
+
action="store_true",
|
|
261
|
+
help="keep window-edge-truncated overturns in R_OT "
|
|
262
|
+
"(default: excluded — a clipped L_T is only a lower "
|
|
263
|
+
"bound, biasing R_OT high)",
|
|
264
|
+
)
|
|
265
|
+
p.add_argument(
|
|
266
|
+
"--max-profiles",
|
|
267
|
+
type=int,
|
|
268
|
+
default=0,
|
|
269
|
+
metavar="N",
|
|
270
|
+
help="sweep only the first N profiles (0 = all; for quick looks)",
|
|
271
|
+
)
|
|
272
|
+
p.add_argument(
|
|
273
|
+
"--no-qc-flags",
|
|
274
|
+
dest="qc_flags",
|
|
275
|
+
action="store_false",
|
|
276
|
+
default=True,
|
|
277
|
+
help="ignore the pipeline's qc_drop_* hotel flags",
|
|
278
|
+
)
|
|
279
|
+
add_output_arguments(p)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# --------------------------------------------------------------------------- #
|
|
283
|
+
# sweep
|
|
284
|
+
# --------------------------------------------------------------------------- #
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _window_means(
|
|
288
|
+
win_times: np.ndarray,
|
|
289
|
+
half_width: float,
|
|
290
|
+
t: np.ndarray,
|
|
291
|
+
arrays: dict[str, np.ndarray],
|
|
292
|
+
) -> dict[str, np.ndarray]:
|
|
293
|
+
"""NaN-aware means of slow-grid *arrays* over each window (same time base)."""
|
|
294
|
+
out = {k: np.full(win_times.size, np.nan) for k in arrays}
|
|
295
|
+
for j, tau in enumerate(win_times):
|
|
296
|
+
sel = np.abs(t - tau) <= half_width
|
|
297
|
+
if not np.any(sel):
|
|
298
|
+
continue
|
|
299
|
+
for k, arr in arrays.items():
|
|
300
|
+
w = arr[sel]
|
|
301
|
+
w = w[np.isfinite(w)]
|
|
302
|
+
if w.size:
|
|
303
|
+
out[k][j] = float(np.mean(w))
|
|
304
|
+
return out
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _pair_indices(
|
|
308
|
+
src_t: np.ndarray,
|
|
309
|
+
dst_t: np.ndarray,
|
|
310
|
+
max_dt: float | None,
|
|
311
|
+
valid: np.ndarray | None = None,
|
|
312
|
+
) -> np.ndarray:
|
|
313
|
+
"""Index of the nearest src window per dst window (-1 = unpaired).
|
|
314
|
+
|
|
315
|
+
A single index pairing (rather than per-variable ``pair_nearest`` calls)
|
|
316
|
+
guarantees every diss column — epsilon, its QC flag — comes from the SAME
|
|
317
|
+
source window. *valid* restricts the candidate source windows (e.g. to
|
|
318
|
+
finite-epsilon ones), matching ``pair_nearest``'s own NaN-source rule: a
|
|
319
|
+
QC'd-out window must not shadow a valid neighbor within ``max_dt``.
|
|
320
|
+
"""
|
|
321
|
+
from odas_tpw.processing.mixing import pair_nearest
|
|
322
|
+
|
|
323
|
+
keep = (
|
|
324
|
+
np.flatnonzero(np.asarray(valid, dtype=bool))
|
|
325
|
+
if valid is not None
|
|
326
|
+
else np.arange(src_t.size)
|
|
327
|
+
)
|
|
328
|
+
idx = np.full(dst_t.size, -1, dtype=np.intp)
|
|
329
|
+
if keep.size == 0:
|
|
330
|
+
return idx
|
|
331
|
+
idxf = pair_nearest(src_t[keep], keep.astype(float), dst_t, max_dt)
|
|
332
|
+
ok = np.isfinite(idxf)
|
|
333
|
+
idx[ok] = idxf[ok].astype(np.intp)
|
|
334
|
+
return idx
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _gather(values: np.ndarray, idx: np.ndarray) -> np.ndarray:
|
|
338
|
+
"""values[idx] with -1 mapped to NaN."""
|
|
339
|
+
out = np.full(idx.size, np.nan)
|
|
340
|
+
ok = idx >= 0
|
|
341
|
+
out[ok] = np.asarray(values, dtype=float)[idx[ok]]
|
|
342
|
+
return out
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _epoch(var: xr.DataArray) -> np.ndarray:
|
|
346
|
+
"""Decoded-time variable -> float epoch seconds (NaT -> NaN)."""
|
|
347
|
+
return _dt64_to_epoch_s(np.atleast_1d(var.values))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _sweep_profile(
|
|
351
|
+
chi_path: str,
|
|
352
|
+
diss_path: str | None,
|
|
353
|
+
prof_path: str | None,
|
|
354
|
+
args: argparse.Namespace,
|
|
355
|
+
cols: dict[str, list],
|
|
356
|
+
counters: dict[str, int],
|
|
357
|
+
) -> None:
|
|
358
|
+
"""Append one profile's window-level columns onto *cols*."""
|
|
359
|
+
import gsw
|
|
360
|
+
|
|
361
|
+
from odas_tpw.processing import mixing, thorpe
|
|
362
|
+
|
|
363
|
+
with xr.open_dataset(chi_path) as c:
|
|
364
|
+
ct = _epoch(c["t"])
|
|
365
|
+
n = ct.size
|
|
366
|
+
lat = float(np.atleast_1d(c["lat"].values)[0])
|
|
367
|
+
lon = float(np.atleast_1d(c["lon"].values)[0])
|
|
368
|
+
chi = np.asarray(c["chiMean"].values, dtype=float)
|
|
369
|
+
n2_win = np.asarray(c["N2"].values, dtype=float)
|
|
370
|
+
dTdz = np.asarray(c["dTdz"].values, dtype=float)
|
|
371
|
+
nu = np.asarray(c["nu"].values, dtype=float)
|
|
372
|
+
P = np.asarray(c["P_mean"].values, dtype=float)
|
|
373
|
+
T_mean = np.asarray(c["T_mean"].values, dtype=float)
|
|
374
|
+
speed = np.asarray(c["speed"].values, dtype=float)
|
|
375
|
+
qc_chi = (
|
|
376
|
+
np.asarray(c["qc_drop_chi"].values, dtype=float)
|
|
377
|
+
if "qc_drop_chi" in c.variables
|
|
378
|
+
else np.zeros(n)
|
|
379
|
+
)
|
|
380
|
+
# Prefer the pipeline's stored pairing (epsilon_paired): the figure is
|
|
381
|
+
# then built from the SAME epsilon that entered the product's Gamma,
|
|
382
|
+
# and the diss product is not needed at all.
|
|
383
|
+
eps_stored = "epsilon_paired" in c.variables
|
|
384
|
+
eps = np.asarray(c["epsilon_paired"].values, dtype=float) if eps_stored else None
|
|
385
|
+
qc_eps = np.zeros(n)
|
|
386
|
+
if diss_path is not None:
|
|
387
|
+
with xr.open_dataset(diss_path) as d:
|
|
388
|
+
eps_src = np.asarray(d["epsilonMean"].values, dtype=float)
|
|
389
|
+
# With a stored pairing, QC flags are paired at the PIPELINE's
|
|
390
|
+
# tolerance (pair_nearest's median-spacing default, max_dt=None)
|
|
391
|
+
# so a window whose stored eps paired looser than --max-dt still
|
|
392
|
+
# gets its qc_drop_epsilon; --max-dt governs only the legacy
|
|
393
|
+
# re-pair path.
|
|
394
|
+
idx = _pair_indices(
|
|
395
|
+
_epoch(d["t"]),
|
|
396
|
+
ct,
|
|
397
|
+
args.max_dt if eps is None else None,
|
|
398
|
+
valid=np.isfinite(eps_src),
|
|
399
|
+
)
|
|
400
|
+
if eps is None: # older chi product without epsilon_paired
|
|
401
|
+
counters["re_pair_fallback"] += 1
|
|
402
|
+
idx_eps = _pair_indices(_epoch(d["t"]), ct, args.max_dt, valid=np.isfinite(eps_src))
|
|
403
|
+
eps = _gather(eps_src, idx_eps)
|
|
404
|
+
if "qc_drop_epsilon" in d.variables:
|
|
405
|
+
qc_eps = _gather(d["qc_drop_epsilon"].values, idx)
|
|
406
|
+
if eps is None:
|
|
407
|
+
# No stored pairing and no diss file: epsilon-dependent quantities
|
|
408
|
+
# (Gamma, Re_b, R_OT) are unavailable for this profile.
|
|
409
|
+
counters["no_epsilon"] += 1
|
|
410
|
+
eps = np.full(n, np.nan)
|
|
411
|
+
elif eps_stored and not np.isfinite(eps).any():
|
|
412
|
+
# A stored-but-empty pairing (e.g. every diss window QC'd out) is
|
|
413
|
+
# just as epsilon-less; count it so the all-profiles guard can fire.
|
|
414
|
+
# (A legacy re-pair that found nothing is NOT counted: there the
|
|
415
|
+
# user's --max-dt did the excluding, and an all-NaN column is the
|
|
416
|
+
# honest result of that experiment.)
|
|
417
|
+
counters["no_epsilon"] += 1
|
|
418
|
+
|
|
419
|
+
half = args.sort_window / 2.0
|
|
420
|
+
lt_s = np.full(n, np.nan)
|
|
421
|
+
lt_t = np.full(n, np.nan)
|
|
422
|
+
n2p_s = np.full(n, np.nan)
|
|
423
|
+
n2p_t = np.full(n, np.nan)
|
|
424
|
+
edge_s = np.zeros(n)
|
|
425
|
+
edge_t = np.zeros(n)
|
|
426
|
+
run_s = np.zeros(n)
|
|
427
|
+
run_t = np.zeros(n)
|
|
428
|
+
n2_ri = np.array(n2_win, dtype=float, copy=True)
|
|
429
|
+
sp_mean = np.full(n, np.nan)
|
|
430
|
+
if prof_path is not None:
|
|
431
|
+
with xr.open_dataset(prof_path) as p:
|
|
432
|
+
ts = _epoch(p["t_slow"])
|
|
433
|
+
Ps = np.asarray(p["P"].values, dtype=float)
|
|
434
|
+
have = {v for v in ("sigma0", "T1", "JAC_T", "SA", "CT", "SP") if v in p.variables}
|
|
435
|
+
slow = {v: np.asarray(p[v].values, dtype=float) for v in have}
|
|
436
|
+
|
|
437
|
+
means = _window_means(
|
|
438
|
+
ct,
|
|
439
|
+
half,
|
|
440
|
+
ts,
|
|
441
|
+
{k: slow[k] for k in ("SA", "CT", "SP") if k in slow},
|
|
442
|
+
)
|
|
443
|
+
sp_mean = means.get("SP", sp_mean)
|
|
444
|
+
g = gsw.grav(lat, P)
|
|
445
|
+
if "sigma0" in slow:
|
|
446
|
+
res_s = thorpe.window_thorpe(
|
|
447
|
+
ct, half, ts, Ps, slow["sigma0"], increasing_down=True, lat=lat
|
|
448
|
+
)
|
|
449
|
+
lt_s = res_s.L_T
|
|
450
|
+
edge_s = res_s.edge_truncated.astype(float)
|
|
451
|
+
run_s = res_s.max_run.astype(float)
|
|
452
|
+
sig_means = _window_means(ct, half, ts, {"s0": slow["sigma0"]})
|
|
453
|
+
rho0 = 1000.0 + sig_means["s0"]
|
|
454
|
+
n2p_s = thorpe.patch_n2(res_s.rms_fluct, lt_s, g / rho0)
|
|
455
|
+
if "T1" in slow:
|
|
456
|
+
res_t = thorpe.window_thorpe(
|
|
457
|
+
ct, half, ts, Ps, slow["T1"], increasing_down=False, lat=lat
|
|
458
|
+
)
|
|
459
|
+
lt_t = res_t.L_T
|
|
460
|
+
edge_t = res_t.edge_truncated.astype(float)
|
|
461
|
+
run_t = res_t.max_run.astype(float)
|
|
462
|
+
if "SA" in means and "CT" in means:
|
|
463
|
+
alpha = gsw.alpha(means["SA"], means["CT"], P)
|
|
464
|
+
n2p_t = thorpe.patch_n2(res_t.rms_fluct, lt_t, alpha * g)
|
|
465
|
+
|
|
466
|
+
# Matched-scale N2 for the Ri_g numerator: Thorpe-sorted background
|
|
467
|
+
# over ~the ADCP differencing span, converted to a time half-width
|
|
468
|
+
# via the cast's median fall speed.
|
|
469
|
+
if args.adcp and args.ri_n2_span > 0 and "JAC_T" in slow:
|
|
470
|
+
w_med = float(np.nanmedian(speed))
|
|
471
|
+
if np.isfinite(w_med) and w_med > 0:
|
|
472
|
+
strat = mixing.sorted_stratification(
|
|
473
|
+
ct,
|
|
474
|
+
(args.ri_n2_span / 2.0) / w_med,
|
|
475
|
+
ts,
|
|
476
|
+
Ps,
|
|
477
|
+
slow["JAC_T"],
|
|
478
|
+
S=slow.get("SP"),
|
|
479
|
+
lat=lat,
|
|
480
|
+
lon=lon,
|
|
481
|
+
)
|
|
482
|
+
n2_ri = strat.N2
|
|
483
|
+
else:
|
|
484
|
+
counters["no_profiles_file"] += 1
|
|
485
|
+
|
|
486
|
+
# Gamma / Cox from UNMASKED components (see module docstring).
|
|
487
|
+
from odas_tpw.scor160.ocean import kappa_T
|
|
488
|
+
|
|
489
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
490
|
+
ok_g = (
|
|
491
|
+
np.isfinite(chi)
|
|
492
|
+
& (chi > 0)
|
|
493
|
+
& np.isfinite(eps)
|
|
494
|
+
& (eps > 0)
|
|
495
|
+
& np.isfinite(n2_win)
|
|
496
|
+
& (n2_win > 0)
|
|
497
|
+
& np.isfinite(dTdz)
|
|
498
|
+
& (dTdz != 0)
|
|
499
|
+
)
|
|
500
|
+
gamma = np.where(
|
|
501
|
+
ok_g,
|
|
502
|
+
n2_win * chi / (2.0 * eps * np.where(ok_g, dTdz, 1.0) ** 2),
|
|
503
|
+
np.nan,
|
|
504
|
+
)
|
|
505
|
+
sal = np.where(np.isfinite(sp_mean), sp_mean, 35.0)
|
|
506
|
+
cox = thorpe.cox_number(chi, kappa_T(T_mean, sal, P), dTdz)
|
|
507
|
+
|
|
508
|
+
cols["epoch"].append(ct)
|
|
509
|
+
cols["P"].append(P)
|
|
510
|
+
cols["lat"].append(np.full(n, lat))
|
|
511
|
+
cols["lon"].append(np.full(n, lon))
|
|
512
|
+
cols["eps"].append(eps)
|
|
513
|
+
cols["nu"].append(nu)
|
|
514
|
+
cols["chi"].append(chi)
|
|
515
|
+
cols["N2_win"].append(n2_win)
|
|
516
|
+
cols["N2_ri"].append(np.asarray(n2_ri, dtype=float))
|
|
517
|
+
cols["dTdz"].append(dTdz)
|
|
518
|
+
cols["Gamma"].append(gamma)
|
|
519
|
+
cols["Cox"].append(cox)
|
|
520
|
+
cols["LT_sigma"].append(np.asarray(lt_s, dtype=float))
|
|
521
|
+
cols["LT_temp"].append(np.asarray(lt_t, dtype=float))
|
|
522
|
+
cols["N2p_sigma"].append(np.asarray(n2p_s, dtype=float))
|
|
523
|
+
cols["N2p_temp"].append(np.asarray(n2p_t, dtype=float))
|
|
524
|
+
cols["edge_sigma"].append(edge_s)
|
|
525
|
+
cols["edge_temp"].append(edge_t)
|
|
526
|
+
cols["run_sigma"].append(run_s)
|
|
527
|
+
cols["run_temp"].append(run_t)
|
|
528
|
+
# Union of the two products' hotel-QC bitfields. Missing/unpaired flags
|
|
529
|
+
# count as clean: an unpaired diss window already has eps=NaN, so its
|
|
530
|
+
# Gamma never survives to the figure anyway.
|
|
531
|
+
cols["qc"].append(
|
|
532
|
+
np.where(np.isfinite(qc_chi), qc_chi, 0.0) + np.where(np.isfinite(qc_eps), qc_eps, 0.0)
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def sweep(args: argparse.Namespace) -> _Table:
|
|
537
|
+
"""Assemble the window-level table for the whole run (+ ADCP S2/Ri_g)."""
|
|
538
|
+
import gsw
|
|
539
|
+
|
|
540
|
+
from odas_tpw.perturb import adcp as adcp_mod
|
|
541
|
+
from odas_tpw.processing import thorpe
|
|
542
|
+
|
|
543
|
+
chi_dir = resolve.resolve_for_args(args, "chi")
|
|
544
|
+
if chi_dir is None:
|
|
545
|
+
raise SystemExit(
|
|
546
|
+
"gamma-scaling needs the per-profile chi_NN product; run the "
|
|
547
|
+
"pipeline with chi enabled first"
|
|
548
|
+
)
|
|
549
|
+
# diss is optional: chi products written by pipelines that store
|
|
550
|
+
# epsilon_paired carry Gamma's epsilon themselves; diss is then only
|
|
551
|
+
# consulted for its qc_drop_epsilon flags (and as the epsilon fallback
|
|
552
|
+
# for older chi products).
|
|
553
|
+
diss_dir = resolve.resolve_for_args(args, "diss", optional=True)
|
|
554
|
+
prof_dir = resolve.resolve_for_args(args, "profiles", optional=True)
|
|
555
|
+
if prof_dir is None:
|
|
556
|
+
print(
|
|
557
|
+
"no profiles_NN dir: Thorpe scales (R_OT) unavailable; Re_b uses the chi-window N2",
|
|
558
|
+
file=sys.stderr,
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
files = sorted(os.path.basename(f) for f in glob.glob(os.path.join(chi_dir, "*.nc")))
|
|
562
|
+
if args.max_profiles > 0:
|
|
563
|
+
files = files[: args.max_profiles]
|
|
564
|
+
if not files:
|
|
565
|
+
raise SystemExit(f"no per-profile chi files under {chi_dir}")
|
|
566
|
+
|
|
567
|
+
cols: dict[str, list] = {
|
|
568
|
+
k: []
|
|
569
|
+
for k in (
|
|
570
|
+
"epoch",
|
|
571
|
+
"P",
|
|
572
|
+
"lat",
|
|
573
|
+
"lon",
|
|
574
|
+
"eps",
|
|
575
|
+
"nu",
|
|
576
|
+
"chi",
|
|
577
|
+
"N2_win",
|
|
578
|
+
"N2_ri",
|
|
579
|
+
"dTdz",
|
|
580
|
+
"Gamma",
|
|
581
|
+
"Cox",
|
|
582
|
+
"LT_sigma",
|
|
583
|
+
"LT_temp",
|
|
584
|
+
"N2p_sigma",
|
|
585
|
+
"N2p_temp",
|
|
586
|
+
"edge_sigma",
|
|
587
|
+
"edge_temp",
|
|
588
|
+
"run_sigma",
|
|
589
|
+
"run_temp",
|
|
590
|
+
"qc",
|
|
591
|
+
)
|
|
592
|
+
}
|
|
593
|
+
counters = {"no_profiles_file": 0, "re_pair_fallback": 0, "no_epsilon": 0}
|
|
594
|
+
n_done = 0
|
|
595
|
+
for f in files:
|
|
596
|
+
diss_path = (
|
|
597
|
+
os.path.join(diss_dir, f)
|
|
598
|
+
if diss_dir is not None and os.path.exists(os.path.join(diss_dir, f))
|
|
599
|
+
else None
|
|
600
|
+
)
|
|
601
|
+
prof_path = (
|
|
602
|
+
os.path.join(prof_dir, f)
|
|
603
|
+
if prof_dir is not None and os.path.exists(os.path.join(prof_dir, f))
|
|
604
|
+
else None
|
|
605
|
+
)
|
|
606
|
+
_sweep_profile(os.path.join(chi_dir, f), diss_path, prof_path, args, cols, counters)
|
|
607
|
+
n_done += 1
|
|
608
|
+
|
|
609
|
+
if n_done == 0:
|
|
610
|
+
raise SystemExit("no per-profile chi files could be swept")
|
|
611
|
+
if counters["no_epsilon"] == n_done:
|
|
612
|
+
raise SystemExit(
|
|
613
|
+
"no epsilon available for any profile (no usable epsilon_paired "
|
|
614
|
+
"in the chi product and no diss_NN product to fall back on) — "
|
|
615
|
+
"run the pipeline's diss stage, or a pipeline that stores "
|
|
616
|
+
"epsilon_paired"
|
|
617
|
+
)
|
|
618
|
+
table = _Table(
|
|
619
|
+
cols={k: np.concatenate(v) if v else np.empty(0) for k, v in cols.items()},
|
|
620
|
+
n_profiles=n_done,
|
|
621
|
+
)
|
|
622
|
+
if table.n == 0:
|
|
623
|
+
raise SystemExit("the chi product contains no dissipation windows")
|
|
624
|
+
for name, count in counters.items():
|
|
625
|
+
if count:
|
|
626
|
+
print(f"note: {count} profile(s) skipped/degraded ({name})", file=sys.stderr)
|
|
627
|
+
|
|
628
|
+
# Route selection + patch-N2 fallback (below the floor: background N2).
|
|
629
|
+
floor_s = (
|
|
630
|
+
args.lt_floor_density
|
|
631
|
+
if args.lt_floor_density is not None
|
|
632
|
+
else thorpe.DEFAULT_LT_FLOOR_DENSITY
|
|
633
|
+
)
|
|
634
|
+
floor_t = (
|
|
635
|
+
args.lt_floor_temperature
|
|
636
|
+
if args.lt_floor_temperature is not None
|
|
637
|
+
else thorpe.DEFAULT_LT_FLOOR_TEMPERATURE
|
|
638
|
+
)
|
|
639
|
+
if args.route == "density":
|
|
640
|
+
lt, n2p, floor = table["LT_sigma"], table["N2p_sigma"], floor_s
|
|
641
|
+
edge, run = table["edge_sigma"], table["run_sigma"]
|
|
642
|
+
else:
|
|
643
|
+
lt, n2p, floor = table["LT_temp"], table["N2p_temp"], floor_t
|
|
644
|
+
edge, run = table["edge_temp"], table["run_temp"]
|
|
645
|
+
# A window's overturn is "resolved" (usable for L_T / N2_patch) when the
|
|
646
|
+
# scale clears the route's noise floor, the displacement block is coherent
|
|
647
|
+
# (Galbraith-Kelley run length — uncorrelated noise flips sign every 1-2
|
|
648
|
+
# samples), and the overturn is NOT clipped by the sort window (a
|
|
649
|
+
# truncated L_T is only a lower bound, which biases R_OT high; in weakly
|
|
650
|
+
# stratified water saturation at ~the window span is the signature of
|
|
651
|
+
# noise, not of a measured overturn).
|
|
652
|
+
resolved = np.isfinite(lt) & (lt >= floor) & np.isfinite(n2p) & (n2p > 0)
|
|
653
|
+
resolved &= run >= args.min_run
|
|
654
|
+
if not args.keep_truncated:
|
|
655
|
+
resolved &= edge == 0
|
|
656
|
+
n2_bg = table["N2_win"]
|
|
657
|
+
n2_scale = np.where(resolved, n2p, np.where(np.isfinite(n2_bg) & (n2_bg > 0), n2_bg, np.nan))
|
|
658
|
+
table.cols["LT"] = np.where(resolved, lt, np.nan)
|
|
659
|
+
table.cols["LT_floor_sigma"] = np.full(table.n, floor_s)
|
|
660
|
+
table.cols["LT_floor_temp"] = np.full(table.n, floor_t)
|
|
661
|
+
table.cols["resolved"] = resolved.astype(float)
|
|
662
|
+
table.cols["N2_scale"] = n2_scale
|
|
663
|
+
table.cols["Re_b"] = thorpe.reynolds_buoyancy(table["eps"], table["nu"], n2_scale)
|
|
664
|
+
lo = thorpe.ozmidov(table["eps"], n2_scale)
|
|
665
|
+
table.cols["R_OT"] = thorpe.r_ot(lo, table.cols["LT"])
|
|
666
|
+
|
|
667
|
+
# Per-sonar S2 -> Ri_g (matched-scale numerator; sonars never blended).
|
|
668
|
+
depth_m = np.asarray(-gsw.z_from_p(table["P"], table["lat"]), dtype=float)
|
|
669
|
+
for k, path in enumerate(args.adcp or []):
|
|
670
|
+
try:
|
|
671
|
+
src = adcp_mod.read_codas(path, min_pg=args.min_pg)
|
|
672
|
+
except ValueError as exc: # corrupt file / min_pg without pg / ...
|
|
673
|
+
raise SystemExit(str(exc)) from exc
|
|
674
|
+
name = src.name
|
|
675
|
+
if name in table.rig:
|
|
676
|
+
# Two --adcp files with the same stem (e.g. two legs' wh300.nc)
|
|
677
|
+
# must not silently overwrite each other's panel.
|
|
678
|
+
name = f"{name}#{k + 1}"
|
|
679
|
+
print(f"note: duplicate sonar name; labeling {path} as {name!r}", file=sys.stderr)
|
|
680
|
+
try:
|
|
681
|
+
ws = adcp_mod.window_shear(
|
|
682
|
+
src, table["epoch"], depth_m, time_tolerance=args.time_tolerance
|
|
683
|
+
)
|
|
684
|
+
except ValueError as exc: # bad tolerance / unusable geometry
|
|
685
|
+
raise SystemExit(str(exc)) from exc
|
|
686
|
+
n2r = table["N2_ri"]
|
|
687
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
688
|
+
ok = np.isfinite(n2r) & (n2r > 0) & np.isfinite(ws.S2) & (ws.S2 > 0)
|
|
689
|
+
table.rig[name] = np.where(ok, n2r / np.where(ok, ws.S2, 1.0), np.nan)
|
|
690
|
+
table.s2[name] = ws.S2
|
|
691
|
+
return table
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
# --------------------------------------------------------------------------- #
|
|
695
|
+
# QC + section coloring
|
|
696
|
+
# --------------------------------------------------------------------------- #
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def qc_mask(table: _Table, args: argparse.Namespace) -> np.ndarray:
|
|
700
|
+
"""Paper-default QC: Re_b, Cox, depth, qc_drop flags, positive Gamma."""
|
|
701
|
+
ok = np.isfinite(table["Gamma"]) & (table["Gamma"] > 0)
|
|
702
|
+
ok &= table["P"] >= args.min_depth
|
|
703
|
+
if args.min_reb > 0:
|
|
704
|
+
ok &= np.isfinite(table["Re_b"]) & (table["Re_b"] > args.min_reb)
|
|
705
|
+
if args.min_cox > 0:
|
|
706
|
+
ok &= np.isfinite(table["Cox"]) & (table["Cox"] > args.min_cox)
|
|
707
|
+
if args.qc_flags:
|
|
708
|
+
ok &= table["qc"] == 0
|
|
709
|
+
return np.asarray(ok)
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _section_colors(table: _Table, args: argparse.Namespace) -> tuple[np.ndarray, list[str]]:
|
|
713
|
+
"""(per-window section index, section names); index -1 = unsectioned.
|
|
714
|
+
|
|
715
|
+
Where sections overlap, the **narrowest** time window wins. Files often
|
|
716
|
+
lead with an umbrella section (e.g. the ARCTERX example's unbounded
|
|
717
|
+
``full_timeseries``); with first-wins ordering it would swallow every
|
|
718
|
+
window and render the figure monochrome, so specific sections take
|
|
719
|
+
precedence and the umbrella only catches windows nothing narrower claims.
|
|
720
|
+
"""
|
|
721
|
+
idx = np.full(table.n, -1, dtype=int)
|
|
722
|
+
if not args.sections:
|
|
723
|
+
return idx, []
|
|
724
|
+
sections = load_sections(args.sections)
|
|
725
|
+
if args.select:
|
|
726
|
+
sections = select_sections(sections, args.select)
|
|
727
|
+
epoch = table["epoch"]
|
|
728
|
+
|
|
729
|
+
def _width(i: int) -> float:
|
|
730
|
+
sec = sections[i]
|
|
731
|
+
if sec.start is None or sec.stop is None:
|
|
732
|
+
return np.inf
|
|
733
|
+
return float((sec.stop - sec.start) / np.timedelta64(1, "s"))
|
|
734
|
+
|
|
735
|
+
for i in sorted(range(len(sections)), key=_width):
|
|
736
|
+
sec = sections[i]
|
|
737
|
+
m = np.ones(table.n, dtype=bool)
|
|
738
|
+
if sec.start is not None:
|
|
739
|
+
m &= epoch >= float(xaxis.to_epoch_seconds(np.array([sec.start]))[0])
|
|
740
|
+
if sec.stop is not None:
|
|
741
|
+
m &= epoch <= float(xaxis.to_epoch_seconds(np.array([sec.stop]))[0])
|
|
742
|
+
idx[m & (idx < 0)] = i
|
|
743
|
+
return idx, [s.name for s in sections]
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
# --------------------------------------------------------------------------- #
|
|
747
|
+
# figures
|
|
748
|
+
# --------------------------------------------------------------------------- #
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _log_bins(x: np.ndarray, n_bins: int | None = None) -> np.ndarray:
|
|
752
|
+
"""Equispaced log bins across the robust (1-99%) range of *x*.
|
|
753
|
+
|
|
754
|
+
The bin count adapts to the sample size (sqrt rule, clipped to 6..14) so
|
|
755
|
+
sparse panels still accumulate the >= 5 points per bin the median/whisker
|
|
756
|
+
overlay needs.
|
|
757
|
+
"""
|
|
758
|
+
fin = x[np.isfinite(x) & (x > 0)]
|
|
759
|
+
if n_bins is None:
|
|
760
|
+
n_bins = int(np.clip(np.sqrt(fin.size), 6, 14)) if fin.size else 6
|
|
761
|
+
lo, hi = np.percentile(fin, [1.0, 99.0]) if fin.size else (np.nan, np.nan)
|
|
762
|
+
if not (np.isfinite(lo) and np.isfinite(hi)) or lo <= 0 or hi <= lo:
|
|
763
|
+
lo, hi = 1e-1, 1e1
|
|
764
|
+
return np.logspace(np.log10(lo), np.log10(hi), n_bins + 1)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _binned_median(ax: Axes, x: np.ndarray, y: np.ndarray, bins: np.ndarray) -> None:
|
|
768
|
+
"""Gray squares: median y per log-x bin, 5/95% whiskers (paper style)."""
|
|
769
|
+
for k in range(bins.size - 1):
|
|
770
|
+
m = (x >= bins[k]) & (x < bins[k + 1]) & np.isfinite(y)
|
|
771
|
+
if np.count_nonzero(m) < 5:
|
|
772
|
+
continue
|
|
773
|
+
xc = np.sqrt(bins[k] * bins[k + 1])
|
|
774
|
+
med = float(np.median(y[m]))
|
|
775
|
+
p5, p95 = np.percentile(y[m], [5.0, 95.0])
|
|
776
|
+
ax.errorbar(
|
|
777
|
+
xc,
|
|
778
|
+
med,
|
|
779
|
+
yerr=[[med - p5], [p95 - med]],
|
|
780
|
+
fmt="s",
|
|
781
|
+
color="0.25",
|
|
782
|
+
mfc="0.85",
|
|
783
|
+
mec="0.25",
|
|
784
|
+
ms=5,
|
|
785
|
+
elinewidth=0.9,
|
|
786
|
+
capsize=2,
|
|
787
|
+
zorder=5,
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _marginal_pdf(
|
|
792
|
+
ax: Axes, data: np.ndarray, bins: np.ndarray, color: Any, *, vertical: bool = False
|
|
793
|
+
) -> None:
|
|
794
|
+
"""Filled log-bin density on a marginal axis (paper-style pdfs)."""
|
|
795
|
+
d = data[np.isfinite(data) & (data > 0)]
|
|
796
|
+
if d.size < 5:
|
|
797
|
+
return
|
|
798
|
+
counts, edges = np.histogram(d, bins=bins)
|
|
799
|
+
dens = counts / max(counts.sum(), 1) / np.diff(np.log10(edges))
|
|
800
|
+
if vertical:
|
|
801
|
+
ax.stairs(dens, edges, orientation="horizontal", fill=True, color=color, alpha=0.35)
|
|
802
|
+
else:
|
|
803
|
+
ax.stairs(dens, edges, fill=True, color=color, alpha=0.35)
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _slope_guides(
|
|
807
|
+
ax: Axes, x: np.ndarray, y: np.ndarray, guides: tuple[tuple[float, str], ...]
|
|
808
|
+
) -> None:
|
|
809
|
+
"""Dashed slope-labeled reference lines anchored at the data median."""
|
|
810
|
+
fin = np.isfinite(x) & np.isfinite(y) & (x > 0) & (y > 0)
|
|
811
|
+
if np.count_nonzero(fin) < 5:
|
|
812
|
+
return
|
|
813
|
+
x0 = float(np.exp(np.median(np.log(x[fin]))))
|
|
814
|
+
y0 = float(np.exp(np.median(np.log(y[fin]))))
|
|
815
|
+
lo, hi = np.nanpercentile(x[fin], [1.0, 99.0])
|
|
816
|
+
xs = np.logspace(np.log10(lo), np.log10(hi), 32)
|
|
817
|
+
for slope, label in guides:
|
|
818
|
+
ax.plot(xs, y0 * (xs / x0) ** slope, ls="--", lw=0.9, color="0.3", zorder=4)
|
|
819
|
+
ax.annotate(
|
|
820
|
+
label,
|
|
821
|
+
(xs[-1], y0 * (xs[-1] / x0) ** slope),
|
|
822
|
+
fontsize=8,
|
|
823
|
+
color="0.3",
|
|
824
|
+
textcoords="offset points",
|
|
825
|
+
xytext=(3, 0),
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
_PANEL_XLABEL = {
|
|
830
|
+
"R_OT": r"$R_{OT} = L_O / L_T$",
|
|
831
|
+
"Re_b": r"$\mathrm{Re}_b = \epsilon\,/\,(\nu N^2_{patch})$",
|
|
832
|
+
"Ri_g": r"estimated local $\mathrm{Ri}_g = N^2 / S^2$",
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _fig_gamma(table: _Table, args: argparse.Namespace) -> Figure | None:
|
|
837
|
+
"""The Fig.-5 analog: Gamma vs R_OT / Re_b / Ri_g (one panel per sonar)."""
|
|
838
|
+
import matplotlib.pyplot as plt
|
|
839
|
+
from matplotlib.lines import Line2D
|
|
840
|
+
from matplotlib.ticker import NullFormatter
|
|
841
|
+
|
|
842
|
+
ok = qc_mask(table, args)
|
|
843
|
+
gamma = np.where(ok, table["Gamma"], np.nan)
|
|
844
|
+
panels: list[tuple[str, str, np.ndarray]] = [
|
|
845
|
+
("R_OT", _PANEL_XLABEL["R_OT"], np.where(ok, table["R_OT"], np.nan)),
|
|
846
|
+
("Re_b", _PANEL_XLABEL["Re_b"], np.where(ok, table["Re_b"], np.nan)),
|
|
847
|
+
]
|
|
848
|
+
for name, rig in table.rig.items():
|
|
849
|
+
panels.append(("Ri_g", f"{_PANEL_XLABEL['Ri_g']} [{name}]", np.where(ok, rig, np.nan)))
|
|
850
|
+
panels = [p for p in panels if np.count_nonzero(np.isfinite(p[2]) & np.isfinite(gamma)) >= 5]
|
|
851
|
+
if not panels:
|
|
852
|
+
print("gamma-scaling: no QC-passing windows to plot", file=sys.stderr)
|
|
853
|
+
return None
|
|
854
|
+
|
|
855
|
+
sec_idx, sec_names = _section_colors(table, args)
|
|
856
|
+
cmap = plt.get_cmap("tab10")
|
|
857
|
+
if len(sec_names) > 10:
|
|
858
|
+
print(
|
|
859
|
+
f"gamma-scaling: {len(sec_names)} sections but only 10 distinct "
|
|
860
|
+
"colors (tab10 wraps); consider --select",
|
|
861
|
+
file=sys.stderr,
|
|
862
|
+
)
|
|
863
|
+
sec_plotted = np.zeros(len(sec_names), dtype=bool) # for the legend
|
|
864
|
+
|
|
865
|
+
n_p = len(panels)
|
|
866
|
+
figsize = args.figsize or (4.6 * n_p, 5.0)
|
|
867
|
+
fig = plt.figure(figsize=figsize)
|
|
868
|
+
outer = fig.add_gridspec(1, n_p, wspace=0.32, left=0.07, right=0.97, top=0.86, bottom=0.13)
|
|
869
|
+
ylims = _gamma_limits(gamma)
|
|
870
|
+
ybins = np.logspace(np.log10(ylims[0]), np.log10(ylims[1]), 25)
|
|
871
|
+
|
|
872
|
+
for i, (kind, xlabel, x) in enumerate(panels):
|
|
873
|
+
inner = outer[i].subgridspec(
|
|
874
|
+
2, 2, width_ratios=(4, 1), height_ratios=(1, 4), hspace=0.06, wspace=0.06
|
|
875
|
+
)
|
|
876
|
+
ax = fig.add_subplot(inner[1, 0])
|
|
877
|
+
ax_top = fig.add_subplot(inner[0, 0], sharex=ax)
|
|
878
|
+
ax_right = fig.add_subplot(inner[1, 1], sharey=ax)
|
|
879
|
+
fin = np.isfinite(x) & np.isfinite(gamma)
|
|
880
|
+
if sec_names:
|
|
881
|
+
un = sec_idx[fin & (sec_idx < 0)]
|
|
882
|
+
if un.size:
|
|
883
|
+
m = fin & (sec_idx < 0)
|
|
884
|
+
ax.scatter(x[m], gamma[m], s=5, c="0.75", alpha=0.4, lw=0, rasterized=True)
|
|
885
|
+
for si in range(len(sec_names)):
|
|
886
|
+
m = fin & (sec_idx == si)
|
|
887
|
+
if not m.any():
|
|
888
|
+
continue
|
|
889
|
+
sec_plotted[si] = True
|
|
890
|
+
color = cmap(si % 10)
|
|
891
|
+
ax.scatter(x[m], gamma[m], s=5, color=color, alpha=0.45, lw=0, rasterized=True)
|
|
892
|
+
_marginal_pdf(ax_top, x[m], _log_bins(x[fin]), color)
|
|
893
|
+
_marginal_pdf(ax_right, gamma[m], ybins, color, vertical=True)
|
|
894
|
+
else:
|
|
895
|
+
ax.scatter(x[fin], gamma[fin], s=5, color="tab:blue", alpha=0.35, lw=0, rasterized=True)
|
|
896
|
+
_marginal_pdf(ax_top, x[fin], _log_bins(x[fin]), "0.5")
|
|
897
|
+
_marginal_pdf(ax_right, gamma[fin], ybins, "0.5", vertical=True)
|
|
898
|
+
bins = _log_bins(x[fin])
|
|
899
|
+
_binned_median(ax, x, gamma, bins)
|
|
900
|
+
_slope_guides(ax, x, gamma, _GUIDES[kind])
|
|
901
|
+
ax.set_xscale("log")
|
|
902
|
+
ax.set_yscale("log")
|
|
903
|
+
ax.set_ylim(*ylims)
|
|
904
|
+
# Expand x to whole decades: a sub-decade log axis draws labeled
|
|
905
|
+
# minor ticks that pile into an unreadable jumble.
|
|
906
|
+
xf = x[fin & (x > 0)]
|
|
907
|
+
if xf.size:
|
|
908
|
+
ax.set_xlim(10 ** np.floor(np.log10(xf.min())), 10 ** np.ceil(np.log10(xf.max())))
|
|
909
|
+
# Whole-decade limits alone don't stop the label pileup: matplotlib
|
|
910
|
+
# still labels minor ticks when the span is <= 1 decade.
|
|
911
|
+
ax.xaxis.set_minor_formatter(NullFormatter())
|
|
912
|
+
ax.set_xlabel(xlabel)
|
|
913
|
+
if kind == "R_OT":
|
|
914
|
+
# R_OT exists only for resolved (floor/coherence/truncation-
|
|
915
|
+
# gated) overturns — say how selective that gate was, so this
|
|
916
|
+
# panel is not read as the full window population.
|
|
917
|
+
n_rot = int(np.count_nonzero(fin))
|
|
918
|
+
n_ok_all = int(np.count_nonzero(ok))
|
|
919
|
+
frac = n_rot / n_ok_all if n_ok_all else 0.0
|
|
920
|
+
ax.text(
|
|
921
|
+
0.03,
|
|
922
|
+
0.03,
|
|
923
|
+
f"{grouped(n_rot)} resolved overturns ({frac:.0%} of QC-pass)",
|
|
924
|
+
transform=ax.transAxes,
|
|
925
|
+
fontsize=7,
|
|
926
|
+
color="0.35",
|
|
927
|
+
)
|
|
928
|
+
if i == 0:
|
|
929
|
+
ax.set_ylabel(r"$\Gamma = N^2\chi\,/\,(2\epsilon\,(dT/dz)^2)$")
|
|
930
|
+
ax.grid(True, which="major", color="0.85", lw=0.5)
|
|
931
|
+
ax.tick_params(labelsize=9)
|
|
932
|
+
# sharex/sharey already propagate the log scales onto the marginals;
|
|
933
|
+
# their own value axes are hidden entirely.
|
|
934
|
+
ax_top.axis("off")
|
|
935
|
+
ax_right.axis("off")
|
|
936
|
+
|
|
937
|
+
if sec_names and sec_plotted.any():
|
|
938
|
+
# Only sections that actually placed points — an empty legend entry
|
|
939
|
+
# (e.g. an umbrella section fully shadowed by narrower ones) would
|
|
940
|
+
# mislabel colors that never appear.
|
|
941
|
+
handles = [
|
|
942
|
+
Line2D([], [], marker="o", ls="", color=cmap(i % 10), label=name)
|
|
943
|
+
for i, name in enumerate(sec_names)
|
|
944
|
+
if sec_plotted[i]
|
|
945
|
+
]
|
|
946
|
+
fig.legend(handles=handles, loc="upper right", fontsize=8, ncols=2, frameon=False)
|
|
947
|
+
n_ok = int(np.count_nonzero(ok))
|
|
948
|
+
title_id = os.path.basename(os.path.normpath(args.root))
|
|
949
|
+
fig.suptitle(
|
|
950
|
+
getattr(args, "title", None)
|
|
951
|
+
or (
|
|
952
|
+
f"{title_id} — mixing-efficiency scaling — {table.n_profiles} casts, "
|
|
953
|
+
f"{grouped(n_ok)} QC-pass windows (of {grouped(table.n)}) — "
|
|
954
|
+
f"Thorpe route: {args.route}"
|
|
955
|
+
),
|
|
956
|
+
fontsize=11,
|
|
957
|
+
)
|
|
958
|
+
return fig
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def _gamma_limits(gamma: np.ndarray) -> tuple[float, float]:
|
|
962
|
+
"""Paper axis (1e-3..1e1) stretched to cover the data quantiles."""
|
|
963
|
+
fin = gamma[np.isfinite(gamma) & (gamma > 0)]
|
|
964
|
+
lo, hi = 1e-3, 1e1
|
|
965
|
+
if fin.size:
|
|
966
|
+
lo = min(lo, 10 ** np.floor(np.log10(np.percentile(fin, 0.5))))
|
|
967
|
+
hi = max(hi, 10 ** np.ceil(np.log10(np.percentile(fin, 99.5))))
|
|
968
|
+
return lo, hi
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def _fig_thorpe_compare(table: _Table, args: argparse.Namespace) -> Figure | None:
|
|
972
|
+
"""sigma0-route vs T1-route L_T: the floor-setting comparison figure."""
|
|
973
|
+
import matplotlib.pyplot as plt
|
|
974
|
+
|
|
975
|
+
lt_s = table["LT_sigma"]
|
|
976
|
+
lt_t = table["LT_temp"]
|
|
977
|
+
both = np.isfinite(lt_s) & (lt_s > 0) & np.isfinite(lt_t) & (lt_t > 0)
|
|
978
|
+
if not both.any():
|
|
979
|
+
print(
|
|
980
|
+
"gamma-scaling: no windows with both Thorpe routes; route-comparison figure skipped",
|
|
981
|
+
file=sys.stderr,
|
|
982
|
+
)
|
|
983
|
+
return None
|
|
984
|
+
floor_s = float(table["LT_floor_sigma"][0])
|
|
985
|
+
floor_t = float(table["LT_floor_temp"][0])
|
|
986
|
+
|
|
987
|
+
fig, (ax, axh) = plt.subplots(1, 2, figsize=args.figsize or (10.0, 4.6))
|
|
988
|
+
ax.scatter(lt_t[both], lt_s[both], s=4, alpha=0.3, lw=0, color="tab:blue", rasterized=True)
|
|
989
|
+
lims = (1e-3, max(np.nanmax(lt_s[both]), np.nanmax(lt_t[both])) * 1.5)
|
|
990
|
+
ax.plot(lims, lims, color="0.3", lw=0.8, ls="-", label="1:1")
|
|
991
|
+
ax.axvline(floor_t, color="tab:red", ls="--", lw=0.9, label=f"T$_1$ floor {floor_t:g} m")
|
|
992
|
+
ax.axhline(
|
|
993
|
+
floor_s, color="tab:purple", ls="--", lw=0.9, label=rf"$\sigma_0$ floor {floor_s:g} m"
|
|
994
|
+
)
|
|
995
|
+
ax.set_xscale("log")
|
|
996
|
+
ax.set_yscale("log")
|
|
997
|
+
ax.set_xlim(*lims)
|
|
998
|
+
ax.set_ylim(*lims)
|
|
999
|
+
ax.set_xlabel(r"$L_T$ from FP07 $T_1$ (m)")
|
|
1000
|
+
ax.set_ylabel(r"$L_T$ from JAC $\sigma_0$ (m)")
|
|
1001
|
+
ax.grid(True, color="0.9", lw=0.5)
|
|
1002
|
+
ax.legend(fontsize=8, loc="upper left")
|
|
1003
|
+
|
|
1004
|
+
bins = np.logspace(-3, np.log10(lims[1]), 40)
|
|
1005
|
+
for arr, floor, color, label in (
|
|
1006
|
+
(lt_s, floor_s, "tab:purple", r"$\sigma_0$ route"),
|
|
1007
|
+
(lt_t, floor_t, "tab:red", r"$T_1$ route"),
|
|
1008
|
+
):
|
|
1009
|
+
fin = arr[np.isfinite(arr) & (arr > 0)]
|
|
1010
|
+
resolved = float(np.mean(fin >= floor)) if fin.size else 0.0
|
|
1011
|
+
axh.hist(
|
|
1012
|
+
fin,
|
|
1013
|
+
bins=bins,
|
|
1014
|
+
histtype="step",
|
|
1015
|
+
color=color,
|
|
1016
|
+
label=f"{label}: {resolved:.0%} of overturned windows $\\geq$ floor",
|
|
1017
|
+
)
|
|
1018
|
+
axh.axvline(floor, color=color, ls="--", lw=0.9)
|
|
1019
|
+
axh.set_xscale("log")
|
|
1020
|
+
axh.set_xlabel(r"$L_T$ (m)")
|
|
1021
|
+
axh.set_ylabel("windows")
|
|
1022
|
+
axh.legend(fontsize=8)
|
|
1023
|
+
axh.grid(True, color="0.9", lw=0.5)
|
|
1024
|
+
|
|
1025
|
+
fallback = 1.0 - float(np.mean(table["resolved"]))
|
|
1026
|
+
title_id = os.path.basename(os.path.normpath(args.root))
|
|
1027
|
+
fig.suptitle(
|
|
1028
|
+
f"{title_id} — Thorpe-scale route comparison — route={args.route}: "
|
|
1029
|
+
f"{fallback:.0%} of windows fall back to background N$^2$ "
|
|
1030
|
+
f"(paper: 18%)",
|
|
1031
|
+
fontsize=11,
|
|
1032
|
+
)
|
|
1033
|
+
fig.tight_layout(rect=(0, 0, 1, 0.93))
|
|
1034
|
+
return fig
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
# --------------------------------------------------------------------------- #
|
|
1038
|
+
# driver
|
|
1039
|
+
# --------------------------------------------------------------------------- #
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def build_figures(args: argparse.Namespace) -> Iterator[tuple[str, Any]]:
|
|
1043
|
+
"""Yield the (stem, Figure) pairs: the scaling figure + route comparison."""
|
|
1044
|
+
args.root = resolve.require_root(args)
|
|
1045
|
+
if args.sort_window <= 0:
|
|
1046
|
+
raise SystemExit("--sort-window must be > 0")
|
|
1047
|
+
if args.select and not args.sections:
|
|
1048
|
+
raise SystemExit("--select requires --sections")
|
|
1049
|
+
table = sweep(args)
|
|
1050
|
+
_report(table, args)
|
|
1051
|
+
for stem, builder in (
|
|
1052
|
+
("gamma_scaling", _fig_gamma),
|
|
1053
|
+
("gamma_thorpe_compare", _fig_thorpe_compare),
|
|
1054
|
+
):
|
|
1055
|
+
with close_new_figs_on_error():
|
|
1056
|
+
fig = builder(table, args)
|
|
1057
|
+
if fig is not None:
|
|
1058
|
+
yield stem, fig
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
def _report(table: _Table, args: argparse.Namespace) -> None:
|
|
1062
|
+
"""One-look sweep summary on stderr (coverage, QC attrition, fallback)."""
|
|
1063
|
+
ok = qc_mask(table, args)
|
|
1064
|
+
parts = [
|
|
1065
|
+
f"{table.n_profiles} casts, {grouped(table.n)} windows",
|
|
1066
|
+
f"QC pass {grouped(int(ok.sum()))}",
|
|
1067
|
+
]
|
|
1068
|
+
for route, key in (("sigma0", "LT_sigma"), ("T1", "LT_temp")):
|
|
1069
|
+
lt = table[key]
|
|
1070
|
+
fin = np.isfinite(lt)
|
|
1071
|
+
if fin.any():
|
|
1072
|
+
parts.append(f"L_T[{route}] finite {float(fin.mean()):.0%}")
|
|
1073
|
+
edge_key = "edge_sigma" if args.route == "density" else "edge_temp"
|
|
1074
|
+
parts.append(
|
|
1075
|
+
f"patch-N2 resolved {float(np.mean(table['resolved'])):.0%} "
|
|
1076
|
+
f"(route {args.route}; {float(np.mean(table[edge_key] > 0)):.0%} "
|
|
1077
|
+
f"edge-truncated)"
|
|
1078
|
+
)
|
|
1079
|
+
for name, s2 in table.s2.items():
|
|
1080
|
+
cov = float(np.mean(np.isfinite(s2[ok]))) if ok.any() else 0.0
|
|
1081
|
+
parts.append(f"S2[{name}] covers {cov:.0%} of QC-pass windows")
|
|
1082
|
+
print("gamma-scaling: " + "; ".join(parts), file=sys.stderr)
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
def run(args: argparse.Namespace) -> str:
|
|
1086
|
+
"""Render the scaling figures; show or write PNGs."""
|
|
1087
|
+
args.root = resolve.require_root(args)
|
|
1088
|
+
display = args.out_dir is None and can_display()
|
|
1089
|
+
if display:
|
|
1090
|
+
with closing_figs(build_figures(args)) as figs:
|
|
1091
|
+
shown = save_or_show(figs, None, fig_dpi(args))
|
|
1092
|
+
return f"displayed {shown} figure(s)"
|
|
1093
|
+
out_dir = args.out_dir or args.root
|
|
1094
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
1095
|
+
with closing_figs(build_figures(args)) as figs:
|
|
1096
|
+
save_or_show(figs, out_dir, fig_dpi(args))
|
|
1097
|
+
return str(out_dir)
|