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
odas_tpw/chi/l2_chi.py ADDED
@@ -0,0 +1,153 @@
1
+ """L2 chi: cleaned temperature and vibration for chi estimation.
2
+
3
+ Despikes fast temperature, HP-filters vibration for Goodman cleaning,
4
+ and computes temperature gradient (dT/dz) from the cleaned signals.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+
11
+ import numpy as np
12
+ from scipy.signal import butter, filtfilt
13
+
14
+ from odas_tpw.scor160.despike import despike
15
+ from odas_tpw.scor160.io import L1Data, L2Data
16
+
17
+
18
+ @dataclass
19
+ class L2ChiData:
20
+ """Level-2 cleaned data for chi estimation."""
21
+
22
+ time: np.ndarray # (N_TIME,)
23
+ pres: np.ndarray # (N_TIME,), dbar
24
+ temp: np.ndarray # (N_TIME,), slow-rate temperature [°C] (for viscosity)
25
+ temp_fast: np.ndarray # (N_TEMP, N_TIME), despiked temperature [°C]
26
+ gradt: np.ndarray # (N_TEMP, N_TIME), temperature gradient [°C/m]
27
+ vib: np.ndarray # (N_VIB, N_TIME), HP-filtered vibration
28
+ pspd_rel: np.ndarray # (N_TIME,), profiling speed [m/s]
29
+ section_number: np.ndarray # (N_TIME,), 0 = excluded
30
+ diff_gains: list[float]
31
+ fs_fast: float
32
+
33
+ @property
34
+ def n_temp(self) -> int:
35
+ """Number of fast thermistor channels."""
36
+ return self.temp_fast.shape[0] if self.temp_fast.ndim == 2 else 0
37
+
38
+ @property
39
+ def n_vib(self) -> int:
40
+ """Number of vibration/accelerometer channels."""
41
+ return int(self.vib.shape[0])
42
+
43
+ @property
44
+ def n_time(self) -> int:
45
+ """Number of fast-rate time samples."""
46
+ return int(self.time.shape[0])
47
+
48
+
49
+ @dataclass
50
+ class L2ChiParams:
51
+ """Processing parameters for L2 chi cleaning."""
52
+
53
+ HP_cut: float = 0.25 # HP cutoff for vibration [Hz]
54
+ despike_T: np.ndarray = field(
55
+ default_factory=lambda: np.array([10.0, 0.5, 0.04]),
56
+ ) # [threshold, smooth_freq, half_width_fraction]
57
+
58
+
59
+ def process_l2_chi(
60
+ l1: L1Data,
61
+ l2: L2Data,
62
+ params: L2ChiParams | None = None,
63
+ ) -> L2ChiData:
64
+ """Clean L1 temperature and vibration for chi estimation.
65
+
66
+ Steps:
67
+ 1. Despike fast temperature within sections.
68
+ 2. HP-filter vibration for Goodman coherent noise removal.
69
+ 3. Compute temperature gradient: dT/dz = fs * diff(T) / speed.
70
+
71
+ Parameters
72
+ ----------
73
+ l1 : L1Data
74
+ Level-1 data with raw temp_fast and vib.
75
+ l2 : L2Data
76
+ Level-2 data for section_number and pspd_rel.
77
+ params : L2ChiParams, optional
78
+ Cleaning parameters. Uses defaults if None.
79
+
80
+ Returns
81
+ -------
82
+ L2ChiData
83
+ """
84
+ if not l1.has_temp_fast:
85
+ raise ValueError("L1Data has no temp_fast — cannot compute chi")
86
+
87
+ if params is None:
88
+ params = L2ChiParams()
89
+
90
+ fs = l1.fs_fast
91
+ n_temp = l1.n_temp
92
+ diff_gains = l1.diff_gains if l1.diff_gains else [0.94] * n_temp
93
+
94
+ # 1. Despike temperature within sections
95
+ temp_fast = np.array(l1.temp_fast, dtype=np.float64)
96
+ t_thresh, t_smooth, t_hw = params.despike_T
97
+
98
+ if np.isfinite(t_thresh):
99
+ sections = np.unique(l2.section_number)
100
+ sections = sections[sections > 0]
101
+ for sec_id in sections:
102
+ mask = l2.section_number == sec_id
103
+ for ci in range(n_temp):
104
+ N_t = round(t_hw * fs) if t_hw < 1 else int(t_hw)
105
+ temp_fast[ci, mask], _, _, _ = despike(
106
+ temp_fast[ci, mask],
107
+ fs,
108
+ thresh=t_thresh,
109
+ smooth=t_smooth,
110
+ N=N_t,
111
+ )
112
+
113
+ # 2. HP-filter vibration for Goodman cleaning
114
+ vib = l1.vib.copy() if l1.vib.size > 0 else l1.vib
115
+ if vib.size > 0 and params.HP_cut > 0:
116
+ b, a = butter(1, params.HP_cut / (fs / 2), btype="high")
117
+ for vi in range(l1.n_vib):
118
+ vib[vi] = filtfilt(b, a, vib[vi])
119
+
120
+ # 3. Compute temperature gradient: dT/dz = fs * diff(T) / speed
121
+ spd_safe = np.maximum(np.abs(l2.pspd_rel), 0.01)
122
+ gradt = np.empty_like(temp_fast)
123
+ for ci in range(n_temp):
124
+ dTdt = np.diff(temp_fast[ci]) * fs
125
+ # A length-1 (or empty) temperature row has no finite-difference
126
+ # gradient; emit zeros so dTdt[-1] doesn't IndexError on the empty diff.
127
+ dTdt = (
128
+ np.append(dTdt, dTdt[-1])
129
+ if dTdt.size
130
+ else np.zeros(temp_fast[ci].shape[0])
131
+ )
132
+ gradt[ci] = dTdt / spd_safe
133
+
134
+ # Temperature for viscosity
135
+ if l1.temp.size == l1.n_time:
136
+ temp_slow = l1.temp
137
+ elif n_temp > 0:
138
+ temp_slow = temp_fast[0]
139
+ else:
140
+ temp_slow = np.full(l1.n_time, 10.0)
141
+
142
+ return L2ChiData(
143
+ time=l1.time.copy(),
144
+ pres=l1.pres.copy(),
145
+ temp=temp_slow,
146
+ temp_fast=temp_fast,
147
+ gradt=gradt,
148
+ vib=vib,
149
+ pspd_rel=l2.pspd_rel.copy(),
150
+ section_number=l2.section_number.copy(),
151
+ diff_gains=diff_gains,
152
+ fs_fast=fs,
153
+ )
odas_tpw/chi/l3_chi.py ADDED
@@ -0,0 +1,388 @@
1
+ """L3 chi: temperature gradient spectra for chi estimation.
2
+
3
+ Computes Welch-method spectra of temperature gradient, applies Goodman
4
+ coherent-noise removal, first-difference and bilinear corrections, and
5
+ converts to wavenumber units.
6
+
7
+ Reads cleaned temperature gradient and HP-filtered vibration from L2ChiData.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import warnings
13
+ from dataclasses import dataclass, field
14
+
15
+ import numpy as np
16
+
17
+ from odas_tpw.chi.chi import _bilinear_correction
18
+ from odas_tpw.chi.fp07 import (
19
+ default_tau_model,
20
+ fp07_tau_batch,
21
+ fp07_transfer_batch,
22
+ gradT_noise_batch,
23
+ )
24
+ from odas_tpw.chi.l2_chi import L2ChiData
25
+ from odas_tpw.scor160.goodman import clean_shear_spec_batch
26
+ from odas_tpw.scor160.io import L3Params
27
+ from odas_tpw.scor160.ocean import kappa_T as kappa_T_TSP
28
+ from odas_tpw.scor160.ocean import visc, visc35
29
+ from odas_tpw.scor160.spectral import csd_matrix_batch
30
+
31
+
32
+ @dataclass
33
+ class _SectionResult:
34
+ """Accumulator for per-window results within a section."""
35
+
36
+ times: list = field(default_factory=list)
37
+ pres: list = field(default_factory=list)
38
+ temp: list = field(default_factory=list)
39
+ speed: list = field(default_factory=list)
40
+ section: list = field(default_factory=list)
41
+ gradt_spec: list = field(default_factory=list)
42
+ noise_spec: list = field(default_factory=list)
43
+ kcyc: list = field(default_factory=list)
44
+ H2: list = field(default_factory=list)
45
+ tau0: list = field(default_factory=list)
46
+ nu: list = field(default_factory=list)
47
+ kappa_T: list = field(default_factory=list)
48
+
49
+
50
+ @dataclass
51
+ class L3ChiData:
52
+ """Level-3 temperature gradient spectra for chi estimation."""
53
+
54
+ time: np.ndarray # (N_SPECTRA,), center time per window
55
+ pres: np.ndarray # (N_SPECTRA,), mean pressure per window
56
+ temp: np.ndarray # (N_SPECTRA,), mean temperature per window
57
+ pspd_rel: np.ndarray # (N_SPECTRA,), mean speed per window
58
+ section_number: np.ndarray # (N_SPECTRA,)
59
+ nu: np.ndarray # (N_SPECTRA,), kinematic viscosity
60
+ kappa_T: np.ndarray # (N_SPECTRA,), molecular thermal diffusivity [m^2/s]
61
+
62
+ kcyc: np.ndarray # (N_WAVENUMBER, N_SPECTRA)
63
+ freq: np.ndarray # (N_FREQ,), frequency vector [Hz]
64
+ gradt_spec: np.ndarray # (N_GRADT, N_WAVENUMBER, N_SPECTRA)
65
+ noise_spec: np.ndarray # (N_GRADT, N_WAVENUMBER, N_SPECTRA)
66
+ H2: np.ndarray # (N_SPECTRA, N_FREQ), FP07 transfer function
67
+ tau0: np.ndarray # (N_SPECTRA,), FP07 time constant
68
+
69
+ diff_gains: list[float] = field(default_factory=list)
70
+ fp07_model: str = "single_pole"
71
+
72
+ @property
73
+ def n_spectra(self) -> int:
74
+ """Number of spectral windows."""
75
+ return int(self.time.shape[0])
76
+
77
+ @property
78
+ def n_wavenumber(self) -> int:
79
+ """Number of wavenumber bins."""
80
+ return int(self.kcyc.shape[0])
81
+
82
+ @property
83
+ def n_gradt(self) -> int:
84
+ """Number of temperature gradient channels."""
85
+ return int(self.gradt_spec.shape[0])
86
+
87
+
88
+ def _process_section_chi(
89
+ l2_chi: L2ChiData,
90
+ sec_id: int,
91
+ diss_length: int,
92
+ diss_step: int,
93
+ nfft: int,
94
+ fs: float,
95
+ n_temp: int,
96
+ n_freq: int,
97
+ do_goodman: bool,
98
+ fd_correction: np.ndarray,
99
+ bl_corrections: list[np.ndarray],
100
+ F_const: np.ndarray,
101
+ fp07_model: str,
102
+ salinity: float | np.ndarray | None,
103
+ diff_gains: list[float],
104
+ therm_cal: list[dict] | None,
105
+ acc: _SectionResult,
106
+ ) -> None:
107
+ """Process a single section, appending results to *acc*."""
108
+ mask = l2_chi.section_number == sec_id
109
+ idx = np.where(mask)[0]
110
+ if len(idx) < diss_length:
111
+ return
112
+
113
+ sec_start = idx[0]
114
+ sec_end = idx[-1] + 1
115
+ sec_len = sec_end - sec_start
116
+
117
+ n_windows = (sec_len - diss_length) // diss_step + 1
118
+ if n_windows < 1:
119
+ return
120
+
121
+ starts = sec_start + np.arange(n_windows) * diss_step
122
+ indices = starts[:, np.newaxis] + np.arange(diss_length)[np.newaxis, :]
123
+
124
+ gradt_windows = np.stack(
125
+ [l2_chi.gradt[ci][indices] for ci in range(n_temp)],
126
+ axis=-1,
127
+ )
128
+
129
+ # Goodman cleaning
130
+ n_vib = l2_chi.n_vib
131
+ if do_goodman:
132
+ vib_windows = np.stack(
133
+ [l2_chi.vib[vi][indices] for vi in range(n_vib)],
134
+ axis=-1,
135
+ )
136
+ clean_TT, _ = clean_shear_spec_batch(vib_windows, gradt_windows, nfft, fs)
137
+ clean_spectra = np.real(np.diagonal(clean_TT, axis1=2, axis2=3)).copy()
138
+ else:
139
+ TT, _, _, _ = csd_matrix_batch(
140
+ gradt_windows,
141
+ None,
142
+ nfft,
143
+ fs,
144
+ overlap=nfft // 2,
145
+ detrend="linear",
146
+ )
147
+ clean_spectra = np.real(np.diagonal(TT, axis1=2, axis2=3)).copy()
148
+
149
+ # Per-window means. nanmean (not mean) so a window with a few NaN samples
150
+ # (routine near the surface) averages its finite samples instead of
151
+ # collapsing the whole window: np.mean returns NaN on a single NaN, which
152
+ # for speed then floored the window to 0.01 m/s (#58) and for temperature
153
+ # NaN'd its viscosity. An ALL-NaN window then needs a deliberate fallback
154
+ # rather than numpy's NaN + "Mean of empty slice" warning (#60), so the
155
+ # nanmeans run under suppressed RuntimeWarnings.
156
+ with warnings.catch_warnings():
157
+ warnings.simplefilter("ignore", RuntimeWarning)
158
+ # nan_to_num before the floor: an all-NaN speed window -> 0.01 m/s floor
159
+ # instead of propagating NaN into fp07_tau and the wavenumber axis.
160
+ speed_means = np.maximum(
161
+ np.nan_to_num(
162
+ np.nanmean(np.abs(l2_chi.pspd_rel[indices]), axis=1), nan=0.01
163
+ ),
164
+ 0.01,
165
+ )
166
+ T_means = np.nanmean(l2_chi.temp[indices], axis=1)
167
+ P_means = np.mean(l2_chi.pres[indices], axis=1)
168
+ t_means = np.mean(l2_chi.time[indices], axis=1)
169
+ # All-NaN-temperature windows: substitute 10 degC for viscosity (mirrors the
170
+ # epsilon side, scor160/l4.py) so chi is computed rather than silently
171
+ # dropped to NaN; warn once if any window needed it (#57).
172
+ if not np.all(np.isfinite(T_means)):
173
+ warnings.warn(
174
+ "Non-finite window temperature(s); using 10 degC for viscosity "
175
+ "in those chi windows",
176
+ stacklevel=2,
177
+ )
178
+ T_means = np.where(np.isfinite(T_means), T_means, 10.0)
179
+
180
+ # Viscosity
181
+ if salinity is not None:
182
+ if np.ndim(salinity) > 0:
183
+ with warnings.catch_warnings():
184
+ warnings.simplefilter("ignore", RuntimeWarning)
185
+ sal_means = np.nanmean(np.asarray(salinity)[indices], axis=1)
186
+ # All-NaN-salinity windows: fall back to 35 PSU (the visc35 reference
187
+ # salinity) so viscosity -> chi is not silently NaN'd, mirroring the
188
+ # 10 degC temperature fallback above (#57, #60).
189
+ sal_means = np.where(np.isfinite(sal_means), sal_means, 35.0)
190
+ else:
191
+ sal_means = np.full(n_windows, float(salinity))
192
+ nu_all = np.asarray(
193
+ [visc(T_means[i], sal_means[i], P_means[i]) for i in range(n_windows)],
194
+ dtype=np.float64,
195
+ )
196
+ # Molecular thermal diffusivity per window from the same T/S/P. chi is
197
+ # linear in kappa_T (chi = 6*kappa_T*I) and epsilon_T ∝ kappa_T², so the
198
+ # T-dependence (~+8% warm vs the old fixed 1.4e-7) propagates directly.
199
+ kappa_T_all = np.asarray(
200
+ kappa_T_TSP(T_means, sal_means, P_means), dtype=np.float64
201
+ )
202
+ else:
203
+ nu_all = np.asarray(
204
+ [visc35(T_means[i]) for i in range(n_windows)],
205
+ dtype=np.float64,
206
+ )
207
+ # No salinity supplied: use the S=35, P=0 reference (matches visc35).
208
+ kappa_T_all = np.asarray(kappa_T_TSP(T_means), dtype=np.float64)
209
+
210
+ K_all = F_const[:, np.newaxis] / speed_means[np.newaxis, :]
211
+
212
+ # Apply corrections
213
+ for ci in range(n_temp):
214
+ clean_spectra[:, :, ci] *= (
215
+ speed_means[:, np.newaxis]
216
+ * fd_correction[np.newaxis, :]
217
+ * bl_corrections[ci][np.newaxis, :]
218
+ )
219
+
220
+ # FP07 transfer function
221
+ tau_model = default_tau_model(fp07_model)
222
+ tau0_all = fp07_tau_batch(speed_means, model=tau_model)
223
+ H2_all = fp07_transfer_batch(F_const, tau0_all, model=fp07_model)
224
+
225
+ # Noise spectra
226
+ noise_all = np.empty((n_temp, n_windows, n_freq))
227
+ for ci in range(n_temp):
228
+ cal_ci = therm_cal[ci] if therm_cal and ci < len(therm_cal) else {}
229
+ noise_kwargs = {
230
+ k: v
231
+ for k, v in cal_ci.items()
232
+ if k in ("e_b", "gain", "b", "beta_1", "beta_2", "T_0", "adc_fs", "adc_bits")
233
+ }
234
+ noise_all[ci] = gradT_noise_batch(
235
+ F_const,
236
+ T_means,
237
+ speed_means,
238
+ fs=fs,
239
+ diff_gain=diff_gains[ci],
240
+ **noise_kwargs,
241
+ )
242
+
243
+ # Append results
244
+ for w in range(n_windows):
245
+ acc.times.append(t_means[w])
246
+ acc.pres.append(P_means[w])
247
+ acc.temp.append(T_means[w])
248
+ acc.speed.append(speed_means[w])
249
+ acc.section.append(sec_id)
250
+ acc.nu.append(nu_all[w])
251
+ acc.kappa_T.append(kappa_T_all[w])
252
+ acc.kcyc.append(K_all[:, w])
253
+ acc.H2.append(H2_all[w])
254
+ acc.tau0.append(tau0_all[w])
255
+ acc.gradt_spec.append(clean_spectra[w].T)
256
+ acc.noise_spec.append(noise_all[:, w, :])
257
+
258
+
259
+ def process_l3_chi(
260
+ l2_chi: L2ChiData,
261
+ params: L3Params,
262
+ *,
263
+ fp07_model: str = "single_pole",
264
+ salinity: float | np.ndarray | None = None,
265
+ therm_cal: list[dict] | None = None,
266
+ ) -> L3ChiData:
267
+ """Compute L3 temperature gradient spectra from L2ChiData.
268
+
269
+ Parameters
270
+ ----------
271
+ l2_chi : L2ChiData
272
+ Level-2 cleaned chi data (despiked temperature gradient,
273
+ HP-filtered vibration, section info).
274
+ params : L3Params
275
+ Spectral parameters (fft_length, diss_length, overlap).
276
+ fp07_model : str
277
+ FP07 transfer function model ('single_pole' or 'double_pole').
278
+ salinity : float or array or None
279
+ Practical salinity for viscosity.
280
+ therm_cal : list of dict, optional
281
+ Per-thermistor calibration for noise model.
282
+
283
+ Returns
284
+ -------
285
+ L3ChiData
286
+ """
287
+ fs = params.fs_fast
288
+ nfft = params.fft_length
289
+ diss_length = params.diss_length
290
+ diss_overlap = params.overlap
291
+ # Clamp to >= 1: overlap == diss_length would make diss_step 0 and the
292
+ # n_windows division below raise ZeroDivisionError.
293
+ diss_step = max(diss_length - diss_overlap, 1)
294
+ n_freq = nfft // 2 + 1
295
+ n_temp = l2_chi.n_temp
296
+ diff_gains = l2_chi.diff_gains
297
+
298
+ # Frequency vector (constant)
299
+ F_const = np.arange(n_freq) * fs / nfft
300
+
301
+ # First-difference correction (constant across windows/probes)
302
+ fd_correction = np.ones(n_freq)
303
+ with np.errstate(divide="ignore", invalid="ignore"):
304
+ fd_correction[1:] = (np.pi * F_const[1:] / (fs * np.sin(np.pi * F_const[1:] / fs))) ** 2
305
+ fd_correction = np.where(np.isfinite(fd_correction), fd_correction, 1.0)
306
+
307
+ # Bilinear corrections per probe
308
+ bl_corrections = [_bilinear_correction(F_const, dg, fs) for dg in diff_gains]
309
+
310
+ do_goodman = params.goodman and l2_chi.n_vib > 0
311
+ sections = np.unique(l2_chi.section_number)
312
+ sections = sections[sections > 0]
313
+
314
+ acc = _SectionResult()
315
+
316
+ for sec_id in sections:
317
+ _process_section_chi(
318
+ l2_chi,
319
+ sec_id,
320
+ diss_length,
321
+ diss_step,
322
+ nfft,
323
+ fs,
324
+ n_temp,
325
+ n_freq,
326
+ do_goodman,
327
+ fd_correction,
328
+ bl_corrections,
329
+ F_const,
330
+ fp07_model,
331
+ salinity,
332
+ diff_gains,
333
+ therm_cal,
334
+ acc,
335
+ )
336
+
337
+ if not acc.times:
338
+ return L3ChiData(
339
+ time=np.array([]),
340
+ pres=np.array([]),
341
+ temp=np.array([]),
342
+ pspd_rel=np.array([]),
343
+ section_number=np.array([]),
344
+ nu=np.array([]),
345
+ kappa_T=np.array([]),
346
+ kcyc=np.zeros((n_freq, 0)),
347
+ freq=F_const,
348
+ gradt_spec=np.zeros((n_temp, n_freq, 0)),
349
+ noise_spec=np.zeros((n_temp, n_freq, 0)),
350
+ H2=np.zeros((0, n_freq)),
351
+ tau0=np.array([]),
352
+ diff_gains=diff_gains,
353
+ fp07_model=fp07_model,
354
+ )
355
+
356
+ # Assemble output arrays
357
+ time_out = np.array(acc.times)
358
+ pres_out = np.array(acc.pres)
359
+ temp_out = np.array(acc.temp)
360
+ speed_out = np.array(acc.speed)
361
+ section_out = np.array(acc.section)
362
+ nu_out = np.array(acc.nu)
363
+ kappa_T_out = np.array(acc.kappa_T)
364
+ tau0_out = np.array(acc.tau0)
365
+
366
+ kcyc_out = np.column_stack(acc.kcyc) # (n_freq, n_spec)
367
+ H2_out = np.stack(acc.H2, axis=0) # (n_spec, n_freq)
368
+
369
+ gradt_spec_out = np.stack(acc.gradt_spec, axis=-1) # (n_temp, n_freq, n_spec)
370
+ noise_spec_out = np.stack(acc.noise_spec, axis=-1) # (n_temp, n_freq, n_spec)
371
+
372
+ return L3ChiData(
373
+ time=time_out,
374
+ pres=pres_out,
375
+ temp=temp_out,
376
+ pspd_rel=speed_out,
377
+ section_number=section_out,
378
+ nu=nu_out,
379
+ kappa_T=kappa_T_out,
380
+ kcyc=kcyc_out,
381
+ freq=F_const,
382
+ gradt_spec=gradt_spec_out,
383
+ noise_spec=noise_spec_out,
384
+ H2=H2_out,
385
+ tau0=tau0_out,
386
+ diff_gains=diff_gains,
387
+ fp07_model=fp07_model,
388
+ )