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,112 @@
|
|
|
1
|
+
# Jun-2026, Claude and Pat Welch, pat@mousebrains.com
|
|
2
|
+
"""Scatter-to-grid binning and signed-safe color limits for section plots.
|
|
3
|
+
|
|
4
|
+
A distinct coordinate model from :mod:`layout` (which positions already-binned
|
|
5
|
+
cast columns): here we bin irregular ``(x, depth, value)`` samples from a CTD
|
|
6
|
+
trajectory onto a regular mesh, averaging the samples in each cell and leaving
|
|
7
|
+
empty cells NaN. We never interpolate across unsampled gaps, so the figure
|
|
8
|
+
cannot paint values into water the vehicle did not sample.
|
|
9
|
+
|
|
10
|
+
Pure numpy — no matplotlib, no NetCDF, no colormaps.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def auto_step(lo: float, hi: float, target: int = 300) -> float:
|
|
19
|
+
"""Bin width giving ~*target* bins across [lo, hi]; 1.0 as a safe fallback."""
|
|
20
|
+
span = hi - lo
|
|
21
|
+
if not np.isfinite(span) or span <= 0:
|
|
22
|
+
return 1.0
|
|
23
|
+
return span / float(target)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def make_edges(lo: float, hi: float, step: float) -> np.ndarray:
|
|
27
|
+
"""Regular bin edges spanning [lo, hi] with width ~*step* (>= 1 bin)."""
|
|
28
|
+
if not (np.isfinite(lo) and np.isfinite(hi)) or hi <= lo:
|
|
29
|
+
width = step if (step and np.isfinite(step) and step > 0) else 1.0
|
|
30
|
+
return np.array([lo, lo + width], dtype=float)
|
|
31
|
+
if not (step and np.isfinite(step) and step > 0):
|
|
32
|
+
step = auto_step(lo, hi)
|
|
33
|
+
n = max(1, int(np.ceil((hi - lo) / step)))
|
|
34
|
+
edges = lo + step * np.arange(n + 1, dtype=float)
|
|
35
|
+
# np.digitize is half-open [edge_i, edge_{i+1}); a sample exactly on the
|
|
36
|
+
# last edge would be dropped. When the span divides evenly the last edge
|
|
37
|
+
# lands on *hi* (e.g. max depth at the bottom turn, the most interesting
|
|
38
|
+
# row) -- nudge it just past hi so that sample falls inside the last bin.
|
|
39
|
+
if edges[-1] <= hi:
|
|
40
|
+
edges[-1] = np.nextafter(float(hi), np.inf)
|
|
41
|
+
return edges
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def grid_mean(
|
|
45
|
+
x: np.ndarray,
|
|
46
|
+
z: np.ndarray,
|
|
47
|
+
v: np.ndarray,
|
|
48
|
+
x_edges: np.ndarray,
|
|
49
|
+
z_edges: np.ndarray,
|
|
50
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
51
|
+
"""Mean of *v* per ``(z_bin, x_bin)`` cell.
|
|
52
|
+
|
|
53
|
+
Returns ``(grid, count)`` each of shape ``(len(z_edges)-1, len(x_edges)-1)``.
|
|
54
|
+
Samples with non-finite x, z, or v are dropped before binning (so this is
|
|
55
|
+
an effective per-cell nanmean for the supplied variable). Empty cells are
|
|
56
|
+
NaN in *grid* and 0 in *count*. Down- and up-cast samples that share a
|
|
57
|
+
cell are averaged together — intended, since re-occupation of a cell only
|
|
58
|
+
happens at the bottom turn of a cast.
|
|
59
|
+
"""
|
|
60
|
+
x = np.asarray(x, dtype=float)
|
|
61
|
+
z = np.asarray(z, dtype=float)
|
|
62
|
+
v = np.asarray(v, dtype=float)
|
|
63
|
+
|
|
64
|
+
nx = len(x_edges) - 1
|
|
65
|
+
nz = len(z_edges) - 1
|
|
66
|
+
grid = np.full((nz, nx), np.nan)
|
|
67
|
+
count = np.zeros((nz, nx), dtype=float)
|
|
68
|
+
if nx < 1 or nz < 1:
|
|
69
|
+
return grid, count
|
|
70
|
+
|
|
71
|
+
good = np.isfinite(x) & np.isfinite(z) & np.isfinite(v)
|
|
72
|
+
x, z, v = x[good], z[good], v[good]
|
|
73
|
+
if x.size == 0:
|
|
74
|
+
return grid, count
|
|
75
|
+
|
|
76
|
+
ix = np.digitize(x, x_edges) - 1
|
|
77
|
+
iz = np.digitize(z, z_edges) - 1
|
|
78
|
+
inb = (ix >= 0) & (ix < nx) & (iz >= 0) & (iz < nz)
|
|
79
|
+
ix, iz, v = ix[inb], iz[inb], v[inb]
|
|
80
|
+
if ix.size == 0:
|
|
81
|
+
return grid, count
|
|
82
|
+
|
|
83
|
+
flat = iz * nx + ix
|
|
84
|
+
sums = np.bincount(flat, weights=v, minlength=nz * nx)
|
|
85
|
+
cnts = np.bincount(flat, minlength=nz * nx)
|
|
86
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
87
|
+
mean = np.where(cnts > 0, sums / np.maximum(cnts, 1), np.nan)
|
|
88
|
+
return mean.reshape(nz, nx), cnts.reshape(nz, nx).astype(float)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def linear_limits(
|
|
92
|
+
z: np.ndarray,
|
|
93
|
+
lo: float | None = None,
|
|
94
|
+
hi: float | None = None,
|
|
95
|
+
q_lo: float = 0.01,
|
|
96
|
+
q_hi: float = 0.99,
|
|
97
|
+
) -> tuple[float | None, float | None]:
|
|
98
|
+
"""Inner 1/99 quantile of finite values, sign-agnostic; overrides win.
|
|
99
|
+
|
|
100
|
+
Unlike :func:`layout.quantile_limits` (which filters ``> 0`` for log-scaled
|
|
101
|
+
epsilon/chi), this keeps negative values — essential for signed scalars
|
|
102
|
+
such as ``sigma0`` (a density *anomaly*) or ``dTdz``. Returns the passed
|
|
103
|
+
overrides unchanged when the data is all-NaN.
|
|
104
|
+
"""
|
|
105
|
+
finite = z[np.isfinite(z)]
|
|
106
|
+
if finite.size == 0:
|
|
107
|
+
return lo, hi
|
|
108
|
+
q = np.quantile(finite, [q_lo, q_hi])
|
|
109
|
+
return (
|
|
110
|
+
lo if lo is not None else float(q[0]),
|
|
111
|
+
hi if hi is not None else float(q[1]),
|
|
112
|
+
)
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# May-2026, Claude and Pat Welch, pat@mousebrains.com
|
|
2
|
+
"""Shared depth-by-cast pcolor helpers used by perturb-plot subcommands.
|
|
3
|
+
|
|
4
|
+
Pure-numpy / pure-matplotlib utilities — no perturb config, no NetCDF I/O.
|
|
5
|
+
Suitable for any 2-D depth-by-cast field a subcommand wants to render.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import glob
|
|
11
|
+
import os
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from matplotlib.axes import Axes
|
|
18
|
+
from matplotlib.figure import Figure
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def panel_grid(
|
|
22
|
+
n: int,
|
|
23
|
+
ncols: int = 1,
|
|
24
|
+
*,
|
|
25
|
+
figsize: tuple[float, float] | list[float] | None = None,
|
|
26
|
+
) -> tuple[Figure, list[Axes], list[Axes], list[Axes]]:
|
|
27
|
+
"""Create a figure of *n* panels sharing one x and one y axis.
|
|
28
|
+
|
|
29
|
+
Panels are arranged in ``ceil(n/ncols)`` rows by ``ncols`` columns, filled
|
|
30
|
+
row-major (left-to-right, top-to-bottom). ``ncols`` is clamped to
|
|
31
|
+
``[1, n]``; the default (1) is the classic single vertical stack. Unused
|
|
32
|
+
cells of a ragged last row are blanked. The default figure size scales with
|
|
33
|
+
the grid: ``max(11, 5.5*ncols)`` wide by ``3*nrows + 1`` tall (so ``ncols=1``
|
|
34
|
+
reproduces the historical ``(11, 3n+1)``).
|
|
35
|
+
|
|
36
|
+
Returns ``(fig, panels, left, col_bottom)``:
|
|
37
|
+
|
|
38
|
+
* ``panels``: the *n* panel Axes, in variable order.
|
|
39
|
+
* ``left``: the left-column Axes. They carry the shared depth label; the
|
|
40
|
+
other columns' y tick labels are hidden by ``sharey``, so a y label there
|
|
41
|
+
would read as unlabeled numbers.
|
|
42
|
+
* ``col_bottom``: the bottom-most panel of each column. They carry the x
|
|
43
|
+
label; their x tick labels are re-enabled here so a ragged short column
|
|
44
|
+
still shows its axis (``sharex`` otherwise hides all but the last row).
|
|
45
|
+
|
|
46
|
+
The caller inverts the shared depth axis on ``panels[0]``, fills each panel,
|
|
47
|
+
sets the y label on ``left`` and the x label/formatter on ``col_bottom``.
|
|
48
|
+
"""
|
|
49
|
+
import matplotlib.pyplot as plt
|
|
50
|
+
|
|
51
|
+
ncols = max(1, min(int(ncols or 1), n))
|
|
52
|
+
nrows = (n + ncols - 1) // ncols
|
|
53
|
+
fig, axes2d = plt.subplots(
|
|
54
|
+
nrows, ncols,
|
|
55
|
+
figsize=tuple(figsize) if figsize else (max(11.0, 5.5 * ncols), 3.0 * nrows + 1.0),
|
|
56
|
+
sharex=True, sharey=True, constrained_layout=True, squeeze=False,
|
|
57
|
+
)
|
|
58
|
+
axes = list(axes2d.ravel()) # row-major
|
|
59
|
+
for ax in axes[n:]:
|
|
60
|
+
ax.set_visible(False) # blank the unused cells of a ragged last row
|
|
61
|
+
panels = axes[:n]
|
|
62
|
+
left = [ax for i, ax in enumerate(panels) if i % ncols == 0]
|
|
63
|
+
bottom_by_col: dict[int, Axes] = {}
|
|
64
|
+
for i, ax in enumerate(panels):
|
|
65
|
+
bottom_by_col[i % ncols] = ax # later rows overwrite -> lowest row wins
|
|
66
|
+
col_bottom = list(bottom_by_col.values())
|
|
67
|
+
for ax in col_bottom:
|
|
68
|
+
ax.xaxis.set_tick_params(labelbottom=True) # re-show ticks hidden by sharex
|
|
69
|
+
return fig, panels, left, col_bottom
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def latest_stage_dir(root: str, prefix: str) -> str | None:
|
|
73
|
+
"""Path to the highest-numbered ``<prefix>_NN/`` dir under *root*.
|
|
74
|
+
|
|
75
|
+
Falls back to the un-versioned ``<prefix>/`` dir if no versioned
|
|
76
|
+
siblings exist. Returns ``None`` if neither is present.
|
|
77
|
+
"""
|
|
78
|
+
versioned = sorted(glob.glob(os.path.join(root, f"{prefix}_[0-9][0-9]")))
|
|
79
|
+
if versioned:
|
|
80
|
+
return versioned[-1]
|
|
81
|
+
legacy = os.path.join(root, prefix)
|
|
82
|
+
return legacy if os.path.isdir(legacy) else None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def depth_edges(d: np.ndarray) -> np.ndarray:
|
|
86
|
+
"""Center-spaced edges around 1-D depth bin centers.
|
|
87
|
+
|
|
88
|
+
Half-bin spacing is the median of ``diff(d)``; for a singleton bin we
|
|
89
|
+
fall back to 0.5 m so the colorbar still renders something visible.
|
|
90
|
+
"""
|
|
91
|
+
half = 0.5 if len(d) < 2 else float(np.median(np.diff(d))) / 2
|
|
92
|
+
return np.concatenate((d - half, [d[-1] + half]))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def quantile_limits(
|
|
96
|
+
z: np.ndarray,
|
|
97
|
+
lo: float | None = None,
|
|
98
|
+
hi: float | None = None,
|
|
99
|
+
q_lo: float = 0.01,
|
|
100
|
+
q_hi: float = 0.99,
|
|
101
|
+
) -> tuple[float | None, float | None]:
|
|
102
|
+
"""Inner 1/99 quantile of finite, positive values; CLI overrides win."""
|
|
103
|
+
finite = z[np.isfinite(z) & (z > 0)]
|
|
104
|
+
if finite.size == 0:
|
|
105
|
+
return lo, hi
|
|
106
|
+
q = np.quantile(finite, [q_lo, q_hi])
|
|
107
|
+
return (
|
|
108
|
+
lo if lo is not None else float(q[0]),
|
|
109
|
+
hi if hi is not None else float(q[1]),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def ffill_down(z: np.ndarray, max_gap: int = 1) -> np.ndarray:
|
|
114
|
+
"""Forward-fill internal NaNs along axis 0 (depth) within each column.
|
|
115
|
+
|
|
116
|
+
Leading NaNs (above the shallowest valid sample) and trailing NaNs
|
|
117
|
+
(below the deepest valid sample) are left as NaN — colors don't extend
|
|
118
|
+
past either end of a cast. Used so the pcolor reads as a continuous
|
|
119
|
+
profile when there are scattered missing bins inside the cast envelope.
|
|
120
|
+
|
|
121
|
+
The fill is bounded by the length of the whole internal NaN run (default
|
|
122
|
+
``max_gap=1``): a run is bridged only if its entire length is ``<= max_gap``,
|
|
123
|
+
otherwise it is left as a visible gap. So an isolated missing bin is
|
|
124
|
+
cosmetically bridged, but a longer run — e.g. a QC-rejected / pipeline-NaN'd
|
|
125
|
+
region (``drop_action='nan'``) — is left untouched rather than having its
|
|
126
|
+
first bins repainted with the shallower bin's value. Gating on the run
|
|
127
|
+
length (not the distance below the last valid sample) is what keeps the
|
|
128
|
+
*first* bin of a multi-bin dropped block from being silently repainted as if
|
|
129
|
+
it had passed.
|
|
130
|
+
"""
|
|
131
|
+
z = z.copy()
|
|
132
|
+
n = z.shape[0]
|
|
133
|
+
rows = np.arange(n)[:, None]
|
|
134
|
+
valid = np.isfinite(z)
|
|
135
|
+
# Nearest valid bin above (>=0) and below (index, else n) each cell.
|
|
136
|
+
last_above = np.maximum.accumulate(np.where(valid, rows, -1), axis=0)
|
|
137
|
+
idx_below = np.where(valid, rows, n)
|
|
138
|
+
first_below = np.minimum.accumulate(idx_below[::-1], axis=0)[::-1]
|
|
139
|
+
has_below = first_below < n
|
|
140
|
+
# Length of the internal NaN run a cell belongs to (all NaNs in one run
|
|
141
|
+
# share last_above/first_below, so they get one run_len and fill together).
|
|
142
|
+
run_len = first_below - last_above - 1
|
|
143
|
+
fill = ~valid & (last_above >= 0) & has_below & (run_len <= max_gap)
|
|
144
|
+
cols = np.broadcast_to(np.arange(z.shape[1]), z.shape)
|
|
145
|
+
z[fill] = z[last_above[fill], cols[fill]]
|
|
146
|
+
return z
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def split_segments(t: np.ndarray, gap_seconds: float = 600) -> list[tuple[int, int]]:
|
|
150
|
+
"""Indices ``[start, end)`` for runs of profiles separated by < gap_seconds."""
|
|
151
|
+
t_sec = t.astype("datetime64[s]").astype(np.int64)
|
|
152
|
+
breaks = np.where(np.diff(t_sec) > gap_seconds)[0]
|
|
153
|
+
starts = np.concatenate(([0], breaks + 1))
|
|
154
|
+
ends = np.concatenate((breaks + 1, [len(t)]))
|
|
155
|
+
return list(zip(starts.tolist(), ends.tolist()))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def compute_layout(
|
|
159
|
+
t: np.ndarray,
|
|
160
|
+
gap_seconds: float = 600,
|
|
161
|
+
cluster_gap: float = 0.5,
|
|
162
|
+
) -> tuple[np.ndarray, list[tuple[int, int]], np.ndarray, list, list]:
|
|
163
|
+
"""Cast-index x positions with a small visual gap between time clusters.
|
|
164
|
+
|
|
165
|
+
Returns ``(cast_x, segments, centers, t_starts, t_ends)`` where
|
|
166
|
+
``cast_x`` is the float x-position of every profile, ``segments`` are
|
|
167
|
+
the half-open index ranges of each cluster, and the remaining tuples
|
|
168
|
+
annotate cluster centers / start / end timestamps for axis labels.
|
|
169
|
+
"""
|
|
170
|
+
segments = split_segments(t, gap_seconds)
|
|
171
|
+
cast_x = np.zeros(len(t))
|
|
172
|
+
pos = 0.0
|
|
173
|
+
centers: list[float] = []
|
|
174
|
+
t_starts: list = []
|
|
175
|
+
t_ends: list = []
|
|
176
|
+
for k, (s, e) in enumerate(segments):
|
|
177
|
+
if k > 0:
|
|
178
|
+
pos += cluster_gap
|
|
179
|
+
n = e - s
|
|
180
|
+
cast_x[s:e] = pos + np.arange(n)
|
|
181
|
+
centers.append(pos + (n - 1) / 2)
|
|
182
|
+
t_starts.append(t[s])
|
|
183
|
+
t_ends.append(t[e - 1])
|
|
184
|
+
pos += n
|
|
185
|
+
return cast_x, segments, np.asarray(centers), t_starts, t_ends
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def plot_panel(
|
|
189
|
+
ax,
|
|
190
|
+
fig,
|
|
191
|
+
cast_x: np.ndarray,
|
|
192
|
+
segments: list[tuple[int, int]],
|
|
193
|
+
depth: np.ndarray,
|
|
194
|
+
z: np.ndarray,
|
|
195
|
+
cmap,
|
|
196
|
+
norm,
|
|
197
|
+
cbar_label: str,
|
|
198
|
+
):
|
|
199
|
+
"""Pcolor a depth-by-cast field on *ax*, one mesh per cluster.
|
|
200
|
+
|
|
201
|
+
Forward-fills internal-NaN holes. Returns the last
|
|
202
|
+
``QuadMesh`` (or ``None`` when every cluster was empty).
|
|
203
|
+
"""
|
|
204
|
+
z = ffill_down(z)
|
|
205
|
+
d_edges = depth_edges(depth)
|
|
206
|
+
pcm = None
|
|
207
|
+
for s, e in segments:
|
|
208
|
+
x_seg = cast_x[s:e]
|
|
209
|
+
if len(x_seg) == 1:
|
|
210
|
+
edges = np.array([x_seg[0] - 0.5, x_seg[0] + 0.5])
|
|
211
|
+
else:
|
|
212
|
+
mids = 0.5 * (x_seg[:-1] + x_seg[1:])
|
|
213
|
+
edges = np.concatenate(([x_seg[0] - 0.5], mids, [x_seg[-1] + 0.5]))
|
|
214
|
+
pcm = ax.pcolormesh(
|
|
215
|
+
edges, d_edges, z[:, s:e],
|
|
216
|
+
cmap=cmap, norm=norm, shading="flat",
|
|
217
|
+
)
|
|
218
|
+
if pcm is not None:
|
|
219
|
+
fig.colorbar(pcm, ax=ax, label=cbar_label)
|
|
220
|
+
return pcm
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def column_clusters(x: np.ndarray, gap_factor: float = 4.0) -> list[tuple[int, int]]:
|
|
224
|
+
"""Group sorted-ascending column positions *x* into clusters by x-gaps.
|
|
225
|
+
|
|
226
|
+
A break is inserted wherever the gap between adjacent columns exceeds
|
|
227
|
+
``gap_factor`` times the median column spacing. Returns half-open
|
|
228
|
+
``[start, end)`` index ranges. Keeps sparse/irregular casts honest: each
|
|
229
|
+
cluster is drawn as one mesh and the gaps between clusters stay blank
|
|
230
|
+
rather than being stretched across unsampled water/time.
|
|
231
|
+
"""
|
|
232
|
+
n = len(x)
|
|
233
|
+
if n <= 1:
|
|
234
|
+
return [(0, n)]
|
|
235
|
+
d = np.diff(x)
|
|
236
|
+
pos = d[d > 0]
|
|
237
|
+
med = float(np.median(pos)) if pos.size else 0.0
|
|
238
|
+
if med <= 0:
|
|
239
|
+
return [(0, n)]
|
|
240
|
+
breaks = np.where(d > gap_factor * med)[0]
|
|
241
|
+
starts = np.concatenate(([0], breaks + 1))
|
|
242
|
+
ends = np.concatenate((breaks + 1, [n]))
|
|
243
|
+
return list(zip(starts.tolist(), ends.tolist()))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def strictly_increasing(x: np.ndarray) -> np.ndarray:
|
|
247
|
+
"""Nudge tied/non-increasing sorted *x* up by a sub-spacing epsilon.
|
|
248
|
+
|
|
249
|
+
pcolormesh needs strictly monotonic edges; two casts at the same x (a
|
|
250
|
+
re-occupied station on a lat/longitude/distance axis, or two casts equally
|
|
251
|
+
far from a reference point) would otherwise collapse to zero-width cells.
|
|
252
|
+
The nudge (1e-3 of the median spacing) renders them as adjacent thin
|
|
253
|
+
columns instead of vanishing.
|
|
254
|
+
"""
|
|
255
|
+
x = np.array(x, dtype=float)
|
|
256
|
+
if x.size < 2:
|
|
257
|
+
return x
|
|
258
|
+
d = np.diff(x)
|
|
259
|
+
pos = d[d > 0]
|
|
260
|
+
eps = (float(np.median(pos)) if pos.size else 1.0) * 1.0e-3
|
|
261
|
+
for i in range(1, x.size):
|
|
262
|
+
if x[i] <= x[i - 1]:
|
|
263
|
+
x[i] = x[i - 1] + eps
|
|
264
|
+
return x
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def column_edges(x: np.ndarray) -> np.ndarray:
|
|
268
|
+
"""Pcolor x-edges for columns centered at sorted-ascending positions *x*.
|
|
269
|
+
|
|
270
|
+
Interior edges are midpoints; the outer edges extend by half the adjacent
|
|
271
|
+
spacing so the first/last column matches its neighbor's width. A single
|
|
272
|
+
column gets a half-unit width on each side.
|
|
273
|
+
"""
|
|
274
|
+
x = np.asarray(x, dtype=float)
|
|
275
|
+
if len(x) == 1:
|
|
276
|
+
return np.array([x[0] - 0.5, x[0] + 0.5])
|
|
277
|
+
mids = 0.5 * (x[:-1] + x[1:])
|
|
278
|
+
first = x[0] - (mids[0] - x[0])
|
|
279
|
+
last = x[-1] + (x[-1] - mids[-1])
|
|
280
|
+
return np.concatenate(([first], mids, [last]))
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def fit_colorbar_labels(
|
|
284
|
+
fig, *, min_fontsize: float = 6.0, fit_frac: float = 0.95
|
|
285
|
+
) -> None:
|
|
286
|
+
"""Shrink over-long colorbar labels so each fits within its own bar.
|
|
287
|
+
|
|
288
|
+
A verbose ``long_name [units]`` label, rotated vertically onto a short
|
|
289
|
+
per-panel colorbar, can be *taller* than the colorbar bar itself.
|
|
290
|
+
matplotlib then centers it on the bar and it overflows above/below into the
|
|
291
|
+
neighboring panel's colorbar label. ``constrained_layout`` reserves
|
|
292
|
+
horizontal width for the rotated label but does not police this vertical
|
|
293
|
+
collision, so stacked panels (``profiles``/``scalar``/``eps_chi``) show
|
|
294
|
+
colliding colorbar labels.
|
|
295
|
+
|
|
296
|
+
We settle the layout (``draw_without_rendering``), measure each colorbar
|
|
297
|
+
label against its bar along the bar's long axis, and scale the font *down*
|
|
298
|
+
to fit within ``fit_frac`` of the bar — never up — with a readability floor.
|
|
299
|
+
The sub-1 ``fit_frac`` leaves headroom for the savefig re-draw: text height
|
|
300
|
+
is not perfectly linear in font size and shrinking fonts frees layout space,
|
|
301
|
+
so the final bar geometry shifts slightly; the margin keeps the fit stable
|
|
302
|
+
rather than letting a 1-px residual creep back over the edge. A no-op for
|
|
303
|
+
labels that already fit.
|
|
304
|
+
"""
|
|
305
|
+
fig.draw_without_rendering() # settle constrained_layout; cache a renderer
|
|
306
|
+
for ax in fig.axes:
|
|
307
|
+
cbar = getattr(ax, "_colorbar", None)
|
|
308
|
+
if cbar is None:
|
|
309
|
+
continue
|
|
310
|
+
vertical = getattr(cbar, "orientation", "vertical") == "vertical"
|
|
311
|
+
label = ax.yaxis.label if vertical else ax.xaxis.label
|
|
312
|
+
if not label.get_text():
|
|
313
|
+
continue
|
|
314
|
+
bar = ax.get_window_extent() # no renderer arg: uses fig cached renderer
|
|
315
|
+
avail = (bar.height if vertical else bar.width) * fit_frac
|
|
316
|
+
need_box = label.get_window_extent()
|
|
317
|
+
need = need_box.height if vertical else need_box.width
|
|
318
|
+
if need > avail > 0:
|
|
319
|
+
scaled = label.get_fontsize() * avail / need
|
|
320
|
+
label.set_fontsize(max(scaled, min_fontsize))
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def plot_columns(
|
|
324
|
+
ax,
|
|
325
|
+
fig,
|
|
326
|
+
x: np.ndarray,
|
|
327
|
+
depth: np.ndarray,
|
|
328
|
+
z: np.ndarray,
|
|
329
|
+
cmap,
|
|
330
|
+
norm,
|
|
331
|
+
cbar_label: str,
|
|
332
|
+
gap_factor: float = 4.0,
|
|
333
|
+
reverse_cbar: bool = False,
|
|
334
|
+
):
|
|
335
|
+
"""Pcolor a depth-by-column field at arbitrary x; one mesh per x-cluster.
|
|
336
|
+
|
|
337
|
+
*x* must be finite and ascending; *z* is ``(n_depth, n_col)`` with columns
|
|
338
|
+
in x-order. Each cluster (see :func:`column_clusters`) is drawn with
|
|
339
|
+
midpoint edges; the gaps between clusters are left blank, never stretched.
|
|
340
|
+
Internal-NaN holes are forward-filled within each column and NaN cells are
|
|
341
|
+
masked (so the cmap's ``set_bad`` color shows for unsampled depths).
|
|
342
|
+
Returns the last ``QuadMesh`` (or ``None`` if every cluster was empty).
|
|
343
|
+
"""
|
|
344
|
+
x = np.asarray(x, dtype=float)
|
|
345
|
+
z = ffill_down(z)
|
|
346
|
+
d_edges = depth_edges(depth)
|
|
347
|
+
pcm = None
|
|
348
|
+
# Cluster on the ORIGINAL x (tie nudging would deflate the median spacing
|
|
349
|
+
# and spuriously split evenly-spaced casts); break ties only for the edges
|
|
350
|
+
# of each cluster.
|
|
351
|
+
for s, e in column_clusters(x, gap_factor):
|
|
352
|
+
edges = column_edges(strictly_increasing(x[s:e]))
|
|
353
|
+
pcm = ax.pcolormesh(
|
|
354
|
+
edges, d_edges, np.ma.masked_invalid(z[:, s:e]),
|
|
355
|
+
cmap=cmap, norm=norm, shading="flat",
|
|
356
|
+
)
|
|
357
|
+
if pcm is not None:
|
|
358
|
+
cbar = fig.colorbar(pcm, ax=ax, label=cbar_label)
|
|
359
|
+
if reverse_cbar:
|
|
360
|
+
cbar.ax.invert_yaxis() # smallest value at the top
|
|
361
|
+
return pcm
|