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