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/fp07.py ADDED
@@ -0,0 +1,649 @@
1
+ # Mar-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """FP07 thermistor transfer function and electronics noise model.
3
+
4
+ References
5
+ ----------
6
+ Lueck, R.G., O. Hertzman, and T.R. Osborn, 1977: The spectral response of
7
+ thermistors. Deep-Sea Res., 24, 951-970.
8
+ Gregg, M.C. and T.B. Meagher, 1980: The dynamic response of glass rod
9
+ thermistors. J. Geophys. Res., 85, 2779-2786.
10
+ Peterson, A.K. and I. Fer, 2014: Dissipation measurements using temperature
11
+ microstructure from an underwater glider. Methods in Oceanography, 10, 44-69.
12
+ RSI Technical Note 040: Noise in Temperature Gradient Measurements.
13
+ """
14
+
15
+ import warnings
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+ import numpy as np
20
+ import numpy.typing as npt
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # FP07 transfer functions
24
+ # ---------------------------------------------------------------------------
25
+
26
+
27
+ def fp07_transfer(f: npt.ArrayLike, tau0: float) -> np.ndarray:
28
+ """Single-pole FP07 transfer function |H(f)|^2.
29
+
30
+ H^2 = 1 / (1 + (2*pi*f*tau0)^2)
31
+
32
+ Lueck et al. 1977.
33
+
34
+ Parameters
35
+ ----------
36
+ f : array_like
37
+ Frequency [Hz].
38
+ tau0 : float
39
+ Time constant [s].
40
+
41
+ Returns
42
+ -------
43
+ H2 : ndarray
44
+ Squared magnitude of transfer function.
45
+ """
46
+ f = np.asarray(f, dtype=np.float64)
47
+ return 1.0 / (1.0 + (2 * np.pi * f * tau0) ** 2)
48
+
49
+
50
+ def fp07_double_pole(f: npt.ArrayLike, tau0: float) -> np.ndarray:
51
+ """Double-pole FP07 transfer function |H(f)|^2.
52
+
53
+ H^2 = 1 / (1 + (2*pi*f*tau0)^2)^2
54
+
55
+ Gregg & Meagher 1980. Accounts for glass bead thermal mass
56
+ plus thermal boundary layer.
57
+
58
+ Parameters
59
+ ----------
60
+ f : array_like
61
+ Frequency [Hz].
62
+ tau0 : float
63
+ Time constant [s].
64
+
65
+ Returns
66
+ -------
67
+ H2 : ndarray
68
+ """
69
+ f = np.asarray(f, dtype=np.float64)
70
+ return 1.0 / (1.0 + (2 * np.pi * f * tau0) ** 2) ** 2
71
+
72
+
73
+ def default_tau_model(fp07_model: str) -> str:
74
+ """Auto-select FP07 tau model based on transfer function model.
75
+
76
+ ``double_pole`` pairs with 'goto' (fixed tau = 3 ms, Goto et al.
77
+ 2016); ``single_pole`` pairs with 'lueck' (speed-dependent). Note
78
+ that Peterson & Fer (2014) used a SINGLE-pole response,
79
+ H^2(f) = (1 + (f/fc)^2)^-1, with a speed-dependent cut-off, so
80
+ reproducing them means the ``single_pole`` transfer with the
81
+ 'peterson' tau (not the default 'lueck') — see the :func:`fp07_tau`
82
+ notes for how to do that explicitly.
83
+ """
84
+ # Defect: an unrecognized fp07_model previously fell through to 'lueck'
85
+ # silently, pairing a single-pole tau with a double-pole transfer on a typo.
86
+ if fp07_model == "double_pole":
87
+ return "goto"
88
+ elif fp07_model == "single_pole":
89
+ return "lueck"
90
+ raise ValueError(f"Unknown FP07 model: {fp07_model!r}")
91
+
92
+
93
+ def fp07_tau(speed: npt.ArrayLike, model: str = "lueck") -> np.ndarray | float:
94
+ """Speed-dependent FP07 time constant [s].
95
+
96
+ Handles both scalar and array inputs via broadcasting.
97
+
98
+ Parameters
99
+ ----------
100
+ speed : float or array_like
101
+ Profiling speed [m/s].
102
+ model : str
103
+ 'lueck' : tau = 0.01 * (1.0/speed)^0.5 (Lueck et al. 1977)
104
+ 'peterson' : tau = 0.012 * speed^(-0.32) — the speed-dependent
105
+ response adopted by Peterson & Fer (2014,
106
+ doi:10.1016/j.mio.2014.05.002), in the tradition of
107
+ Vachon & Lueck (1984) and Gregg & Meagher (1980)
108
+ 'goto' : tau = 0.003, speed-independent (Goto et al. 2016,
109
+ doi:10.1175/JTECH-D-15-0220.1, for their double-pole model)
110
+
111
+ Notes
112
+ -----
113
+ The pipeline pairs tau models with transfer-function models via
114
+ :func:`default_tau_model`: ``fp07_model='single_pole'`` uses 'lueck'
115
+ and ``'double_pole'`` uses 'goto'. Be aware that Peterson & Fer
116
+ (2014) used a SINGLE-pole response, H^2(f) = (1 + (f/fc)^2)^-1, with
117
+ a speed-dependent cut-off fc = (2*pi*tau*U^-0.32)^-1, tau = 12 ms
118
+ (their double-pole tau = 8 ms is only a Fig. 15 sensitivity test).
119
+ The default 'lueck' tau therefore does NOT reproduce them. To
120
+ reproduce Peterson & Fer exactly, call the single-pole transfer with
121
+ ``fp07_transfer`` and ``fp07_tau(speed, model='peterson')``.
122
+
123
+ Returns
124
+ -------
125
+ tau0 : float or ndarray
126
+ Time constant [s].
127
+ """
128
+ is_scalar = np.ndim(speed) == 0
129
+ speed = np.asarray(speed, dtype=np.float64)
130
+ # Defect: MATLAB gradT_noise_odas.m validates speed > 0; without it a
131
+ # non-positive speed yields NaN (lueck/peterson) or inf downstream.
132
+ if np.any(speed <= 0):
133
+ raise ValueError(f"FP07 speed must be > 0, got min {np.min(speed)!r}")
134
+ if model == "lueck":
135
+ result = 0.01 * (1.0 / speed) ** 0.5
136
+ elif model == "peterson":
137
+ result = 0.012 * speed ** (-0.32)
138
+ elif model == "goto":
139
+ result = np.full_like(speed, 0.003) if speed.ndim > 0 else 0.003
140
+ else:
141
+ raise ValueError(f"Unknown FP07 tau model: {model!r}")
142
+ return float(result) if is_scalar else result
143
+
144
+
145
+ def fp07_tau_batch(speeds: npt.ArrayLike, model: str = "lueck") -> np.ndarray:
146
+ """Vectorized FP07 time constants for multiple speeds.
147
+
148
+ Thin wrapper around :func:`fp07_tau` ensuring array output.
149
+
150
+ Parameters
151
+ ----------
152
+ speeds : array_like
153
+ Profiling speeds [m/s], shape ``(n,)``.
154
+ model : str
155
+ Same as :func:`fp07_tau`.
156
+
157
+ Returns
158
+ -------
159
+ tau0 : ndarray, shape ``(n,)``
160
+ """
161
+ return np.atleast_1d(fp07_tau(speeds, model))
162
+
163
+
164
+ def fp07_transfer_batch(
165
+ F: npt.ArrayLike,
166
+ tau0: npt.ArrayLike,
167
+ model: str = "single_pole",
168
+ ) -> np.ndarray:
169
+ """Vectorized FP07 |H(f)|^2 for multiple time constants.
170
+
171
+ Parameters
172
+ ----------
173
+ F : array_like, shape ``(n_freq,)``
174
+ Frequency vector [Hz].
175
+ tau0 : array_like, shape ``(n_est,)``
176
+ Per-window time constants [s].
177
+ model : str
178
+ 'single_pole' or 'double_pole'.
179
+
180
+ Returns
181
+ -------
182
+ H2 : ndarray, shape ``(n_est, n_freq)``
183
+ """
184
+ F = np.asarray(F, dtype=np.float64)
185
+ tau0 = np.asarray(tau0, dtype=np.float64)
186
+ omega_tau_sq = (2 * np.pi * F[:, np.newaxis] * tau0[np.newaxis, :]) ** 2
187
+ # Defect: an unrecognized model string silently fell through to
188
+ # double_pole, mixing physics on a config typo. Validate like fp07_tau.
189
+ if model == "single_pole":
190
+ return (1.0 / (1.0 + omega_tau_sq)).T
191
+ elif model == "double_pole":
192
+ return (1.0 / (1.0 + omega_tau_sq) ** 2).T
193
+ else:
194
+ raise ValueError(f"Unknown FP07 model: {model!r}")
195
+
196
+
197
+ # ---------------------------------------------------------------------------
198
+ # Electronics noise model — ports noise_thermchannel.m + gradT_noise_odas.m
199
+ # ---------------------------------------------------------------------------
200
+
201
+
202
+ @dataclass
203
+ class FP07NoiseConfig:
204
+ """Hardware configuration for FP07 electronics noise model.
205
+
206
+ Consolidates the 17 hardware parameters used by ``noise_thermchannel``
207
+ with RSI defaults. Pass an instance as the ``config`` argument to
208
+ override any subset of defaults.
209
+ """
210
+
211
+ R_0: float = 3000 # nominal thermistor resistance [Ohm]
212
+ gain: float = 6 # first-stage amplifier gain
213
+ f_AA: float = 110 # anti-aliasing filter cutoff [Hz]
214
+ adc_fs: float = 4.096 # ADC full-scale voltage [V]
215
+ adc_bits: int = 16 # ADC resolution [bits]
216
+ E_n: float = 4e-9 # first-stage voltage noise [V/sqrt(Hz)]
217
+ fc: float = 18.7 # first-stage flicker noise knee [Hz]
218
+ E_n2: float = 8e-9 # second-stage voltage noise [V/sqrt(Hz)]
219
+ fc_2: float = 42 # second-stage flicker noise knee [Hz]
220
+ gamma_RSI: float = 3 # ADC noise factor
221
+ T_K: float = 295 # operating temperature [K]
222
+ K_B: float = 1.382e-23 # Boltzmann constant [J/K]
223
+ e_b: float = 0.68 # bridge excitation voltage [V]
224
+ b: float = 1.0 # Steinhart-Hart 'b' coefficient
225
+ beta_1: float = 3000.0 # Steinhart-Hart beta_1 [K]
226
+ T_0: float = 289.3 # Steinhart-Hart reference temperature [K]
227
+ beta_2: float | None = None # Steinhart-Hart beta_2 [K]
228
+
229
+
230
+ def _unpack_noise_config(
231
+ config: FP07NoiseConfig | None,
232
+ *,
233
+ R_0: float = 3000,
234
+ gain: float = 6,
235
+ f_AA: float = 110,
236
+ adc_fs: float = 4.096,
237
+ adc_bits: int = 16,
238
+ E_n: float = 4e-9,
239
+ fc: float = 18.7,
240
+ E_n2: float = 8e-9,
241
+ fc_2: float = 42,
242
+ gamma_RSI: float = 3,
243
+ T_K: float = 295,
244
+ K_B: float = 1.382e-23,
245
+ e_b: float = 0.68,
246
+ b: float = 1.0,
247
+ beta_1: float = 3000.0,
248
+ T_0: float = 289.3,
249
+ beta_2: float | None = None,
250
+ ) -> dict:
251
+ """Resolve hardware parameters from config dataclass or kwargs."""
252
+ if config is not None:
253
+ return {
254
+ "R_0": config.R_0,
255
+ "gain": config.gain,
256
+ "f_AA": config.f_AA,
257
+ "adc_fs": config.adc_fs,
258
+ "adc_bits": config.adc_bits,
259
+ "E_n": config.E_n,
260
+ "fc": config.fc,
261
+ "E_n2": config.E_n2,
262
+ "fc_2": config.fc_2,
263
+ "gamma_RSI": config.gamma_RSI,
264
+ "T_K": config.T_K,
265
+ "K_B": config.K_B,
266
+ "e_b": config.e_b,
267
+ "b": config.b,
268
+ "beta_1": config.beta_1,
269
+ "T_0": config.T_0,
270
+ "beta_2": config.beta_2,
271
+ }
272
+ return {
273
+ "R_0": R_0,
274
+ "gain": gain,
275
+ "f_AA": f_AA,
276
+ "adc_fs": adc_fs,
277
+ "adc_bits": adc_bits,
278
+ "E_n": E_n,
279
+ "fc": fc,
280
+ "E_n2": E_n2,
281
+ "fc_2": fc_2,
282
+ "gamma_RSI": gamma_RSI,
283
+ "T_K": T_K,
284
+ "K_B": K_B,
285
+ "e_b": e_b,
286
+ "b": b,
287
+ "beta_1": beta_1,
288
+ "T_0": T_0,
289
+ "beta_2": beta_2,
290
+ }
291
+
292
+
293
+ def _noise_f_intermediates(
294
+ F: np.ndarray,
295
+ fs: float,
296
+ diff_gain: float,
297
+ p: dict,
298
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, float]:
299
+ """Compute F-dependent noise intermediates shared between scalar and batch.
300
+
301
+ Returns (base_f, johnson_gain_f, counts_factor, delta_s).
302
+ """
303
+ delta_s = p["adc_fs"] / 2 ** p["adc_bits"]
304
+ fN = fs / 2
305
+
306
+ with np.errstate(divide="ignore", invalid="ignore"):
307
+ V1 = 2 * p["E_n"] ** 2 * np.sqrt(1 + (F / p["fc"]) ** 2) / (F / p["fc"])
308
+ V1 = np.where(np.isfinite(V1), V1, V1[np.isfinite(V1)].max() if np.any(np.isfinite(V1)) else 0)
309
+
310
+ G_2 = 1 + (2 * np.pi * diff_gain * F) ** 2
311
+
312
+ with np.errstate(divide="ignore", invalid="ignore"):
313
+ V2 = 2 * p["E_n2"] ** 2 * np.sqrt(1 + (F / p["fc_2"]) ** 2) / (F / p["fc_2"])
314
+ V2 = np.where(np.isfinite(V2), V2, V2[np.isfinite(V2)].max() if np.any(np.isfinite(V2)) else 0)
315
+
316
+ G_AA = 1.0 / (1 + (F / p["f_AA"]) ** 8) ** 2
317
+ adc_floor = p["gamma_RSI"] * delta_s**2 / (12 * fN)
318
+
319
+ w = 2 * np.pi * diff_gain * F
320
+ G_HP = (1 / diff_gain) ** 2 * w**2 / (1 + w**2)
321
+
322
+ base_f = G_AA * G_2 * (p["gain"] ** 2 * V1 + V2) + adc_floor
323
+ johnson_gain_f = G_AA * G_2 * p["gain"] ** 2
324
+ counts_factor = G_HP / delta_s**2
325
+
326
+ return base_f, johnson_gain_f, counts_factor, delta_s
327
+
328
+
329
+ def noise_thermchannel(
330
+ F: npt.ArrayLike,
331
+ T_mean: float,
332
+ fs: float = 512,
333
+ diff_gain: float = 0.94,
334
+ R_0: float = 3000,
335
+ gain: float = 6,
336
+ f_AA: float = 110,
337
+ adc_fs: float = 4.096,
338
+ adc_bits: int = 16,
339
+ E_n: float = 4e-9,
340
+ fc: float = 18.7,
341
+ E_n2: float = 8e-9,
342
+ fc_2: float = 42,
343
+ gamma_RSI: float = 3,
344
+ T_K: float = 295,
345
+ K_B: float = 1.382e-23,
346
+ e_b: float = 0.68,
347
+ b: float = 1.0,
348
+ beta_1: float = 3000.0,
349
+ T_0: float = 289.3,
350
+ beta_2: float | None = None,
351
+ config: FP07NoiseConfig | None = None,
352
+ ) -> np.ndarray:
353
+ """Electronics noise spectrum for FP07 thermistor [(K/s)^2 / Hz].
354
+
355
+ Returns the noise of the temperature *time derivative* dT/dt. Use
356
+ :func:`gradT_noise` to convert to a spatial-gradient spectrum
357
+ [(K/m)^2 / cpm] using the profiling speed.
358
+
359
+ Computes the noise spectrum including:
360
+ - Johnson noise from thermistor resistance
361
+ - Amplifier voltage noise (two stages with 1/f knee)
362
+ - Pre-emphasis gain
363
+ - Anti-aliasing filter (two-stage 4th-order Butterworth)
364
+ - ADC quantization noise
365
+ - Conversion to physical gradient units via scale factor
366
+
367
+ Ports noise_thermchannel.m and gradT_noise_odas.m from ODAS.
368
+
369
+ Parameters
370
+ ----------
371
+ F : array_like
372
+ Frequency vector [Hz].
373
+ T_mean : float
374
+ Mean temperature [deg C] for computing scale factor.
375
+ fs : float
376
+ Sampling rate [Hz].
377
+ diff_gain : float
378
+ Pre-emphasis differentiator gain [s].
379
+ R_0 : float
380
+ Nominal thermistor resistance [Ohm] at operating temperature.
381
+ gain : float
382
+ First-stage amplifier gain.
383
+ f_AA : float
384
+ Anti-aliasing filter cutoff [Hz] (each stage).
385
+ adc_fs : float
386
+ ADC full-scale voltage [V].
387
+ adc_bits : int
388
+ ADC resolution [bits].
389
+ E_n : float
390
+ First-stage amplifier voltage noise [V/sqrt(Hz)].
391
+ fc : float
392
+ First-stage flicker noise knee [Hz].
393
+ E_n2 : float
394
+ Second-stage amplifier voltage noise [V/sqrt(Hz)].
395
+ fc_2 : float
396
+ Second-stage flicker noise knee [Hz].
397
+ gamma_RSI : float
398
+ ADC noise factor (RSI sampler excess).
399
+ T_K : float
400
+ Operating temperature [K].
401
+ K_B : float
402
+ Boltzmann constant [J/K].
403
+ e_b : float
404
+ Bridge excitation voltage [V].
405
+ b : float
406
+ Steinhart-Hart 'b' coefficient.
407
+ beta_1 : float
408
+ Steinhart-Hart beta_1 coefficient [K].
409
+ T_0 : float
410
+ Steinhart-Hart reference temperature [K]. Default 289.3 (~16 deg C).
411
+ beta_2 : float or None
412
+ Steinhart-Hart beta_2 coefficient [K]. When finite, applies
413
+ scale factor correction (MATLAB gradT_noise_odas.m l.335-338).
414
+
415
+ config : FP07NoiseConfig or None
416
+ Hardware configuration dataclass. When provided, its fields
417
+ override the corresponding keyword arguments.
418
+
419
+ Returns
420
+ -------
421
+ noise_f : ndarray
422
+ Temperature time-derivative noise spectrum [(K/s)^2 / Hz].
423
+ """
424
+ p = _unpack_noise_config(
425
+ config,
426
+ R_0=R_0,
427
+ gain=gain,
428
+ f_AA=f_AA,
429
+ adc_fs=adc_fs,
430
+ adc_bits=adc_bits,
431
+ E_n=E_n,
432
+ fc=fc,
433
+ E_n2=E_n2,
434
+ fc_2=fc_2,
435
+ gamma_RSI=gamma_RSI,
436
+ T_K=T_K,
437
+ K_B=K_B,
438
+ e_b=e_b,
439
+ b=b,
440
+ beta_1=beta_1,
441
+ T_0=T_0,
442
+ beta_2=beta_2,
443
+ )
444
+
445
+ F = np.asarray(F, dtype=np.float64)
446
+ base_f, johnson_gain_f, counts_factor, _delta_s = _noise_f_intermediates(F, fs, diff_gain, p)
447
+
448
+ # T-dependent terms
449
+ T_kelvin = T_mean + 273.15
450
+ R_ratio = np.exp(p["beta_1"] * (1.0 / T_kelvin - 1.0 / p["T_0"]))
451
+ if R_ratio < 0.1:
452
+ warnings.warn(
453
+ f"R_ratio={R_ratio:.4g} < 0.1 (possible broken thermistor at T={T_mean:.1f}°C); "
454
+ "clamped to 1.0",
455
+ stacklevel=2,
456
+ )
457
+ R_ratio = 1.0
458
+ R_actual = R_ratio * p["R_0"]
459
+ phi_R = 4 * p["K_B"] * R_actual * p["T_K"]
460
+
461
+ # Scale factor
462
+ eta = (p["b"] / 2) * 2 ** p["adc_bits"] * p["gain"] * p["e_b"] / p["adc_fs"]
463
+ scale_factor = T_kelvin**2 * (1 + R_ratio) ** 2 / (2 * eta * p["beta_1"] * R_ratio)
464
+ if p["beta_2"] is not None and np.isfinite(p["beta_2"]):
465
+ scale_factor *= 1 + 2 * (p["beta_1"] / p["beta_2"]) * np.log(R_ratio)
466
+
467
+ Noise_counts = (base_f + johnson_gain_f * phi_R) * counts_factor
468
+ return Noise_counts * scale_factor**2
469
+
470
+
471
+ def gradT_noise(
472
+ F: npt.ArrayLike,
473
+ T_mean: float,
474
+ speed: float,
475
+ fs: float = 512,
476
+ diff_gain: float = 0.94,
477
+ **kwargs: Any,
478
+ ) -> tuple[np.ndarray, np.ndarray]:
479
+ """Temperature gradient noise spectrum [(K/m)^2 / cpm].
480
+
481
+ Convenience wrapper that converts noise_thermchannel output to
482
+ wavenumber units using profiling speed.
483
+
484
+ Parameters
485
+ ----------
486
+ F : array_like
487
+ Frequency vector [Hz].
488
+ T_mean : float
489
+ Mean temperature [deg C].
490
+ speed : float
491
+ Profiling speed [m/s].
492
+ fs : float
493
+ Sampling rate [Hz].
494
+ diff_gain : float
495
+ Pre-emphasis differentiator gain [s].
496
+ **kwargs
497
+ Additional parameters passed to noise_thermchannel.
498
+
499
+ Returns
500
+ -------
501
+ noise_K : ndarray
502
+ Noise spectrum [(K/m)^2 / cpm].
503
+ K : ndarray
504
+ Wavenumber vector [cpm].
505
+ """
506
+ # Defect: a non-positive speed gives a negative noise PSD (impossible) or
507
+ # inf; MATLAB gradT_noise_odas.m validates speed > 0 (val_speed).
508
+ if not (np.isfinite(speed) and speed > 0):
509
+ raise ValueError(f"FP07 speed must be > 0, got {speed!r}")
510
+ noise_f = noise_thermchannel(F, T_mean, fs=fs, diff_gain=diff_gain, **kwargs)
511
+ F_arr = np.asarray(F, dtype=np.float64)
512
+ K = F_arr / speed
513
+ # noise_f is the time-derivative noise [(K/s)^2/Hz]. Converting to a
514
+ # gradient spectrum divides by speed^2 (dT/dz = (dT/dt)/W), and the
515
+ # per-Hz -> per-cpm change of variable multiplies by speed: net /speed.
516
+ # Matches ODAS gradT_noise_odas.m ("scale_factor ./ speed", squared).
517
+ noise_K = noise_f / speed
518
+ return noise_K, K
519
+
520
+
521
+ def noise_thermchannel_batch(
522
+ F: npt.ArrayLike,
523
+ T_means: npt.ArrayLike,
524
+ fs: float = 512,
525
+ diff_gain: float = 0.94,
526
+ R_0: float = 3000,
527
+ gain: float = 6,
528
+ f_AA: float = 110,
529
+ adc_fs: float = 4.096,
530
+ adc_bits: int = 16,
531
+ E_n: float = 4e-9,
532
+ fc: float = 18.7,
533
+ E_n2: float = 8e-9,
534
+ fc_2: float = 42,
535
+ gamma_RSI: float = 3,
536
+ T_K: float = 295,
537
+ K_B: float = 1.382e-23,
538
+ e_b: float = 0.68,
539
+ b: float = 1.0,
540
+ beta_1: float = 3000.0,
541
+ T_0: float = 289.3,
542
+ beta_2: float | None = None,
543
+ config: FP07NoiseConfig | None = None,
544
+ ) -> np.ndarray:
545
+ """Vectorized electronics noise for multiple T_mean values.
546
+
547
+ Same physics as :func:`noise_thermchannel` but computes F-dependent
548
+ intermediates once and broadcasts T_means through T-dependent terms
549
+ (Johnson noise and scale factor).
550
+
551
+ Parameters
552
+ ----------
553
+ F : array_like
554
+ Frequency vector [Hz], shape ``(n_freq,)``.
555
+ T_means : array_like
556
+ Mean temperatures [deg C], shape ``(n_est,)``.
557
+ Other parameters same as :func:`noise_thermchannel`.
558
+
559
+ Returns
560
+ -------
561
+ noise_f : ndarray
562
+ Temperature time-derivative noise [(K/s)^2 / Hz],
563
+ shape ``(n_est, n_freq)``.
564
+ """
565
+ p = _unpack_noise_config(
566
+ config,
567
+ R_0=R_0,
568
+ gain=gain,
569
+ f_AA=f_AA,
570
+ adc_fs=adc_fs,
571
+ adc_bits=adc_bits,
572
+ E_n=E_n,
573
+ fc=fc,
574
+ E_n2=E_n2,
575
+ fc_2=fc_2,
576
+ gamma_RSI=gamma_RSI,
577
+ T_K=T_K,
578
+ K_B=K_B,
579
+ e_b=e_b,
580
+ b=b,
581
+ beta_1=beta_1,
582
+ T_0=T_0,
583
+ beta_2=beta_2,
584
+ )
585
+
586
+ F = np.asarray(F, dtype=np.float64)
587
+ T_means = np.asarray(T_means, dtype=np.float64)
588
+
589
+ base_f, johnson_gain_f, counts_factor, _delta_s = _noise_f_intermediates(F, fs, diff_gain, p)
590
+
591
+ # --- T-dependent terms ---
592
+ T_kelvin = T_means + 273.15 # (n_est,)
593
+ R_ratio = np.exp(p["beta_1"] * (1.0 / T_kelvin - 1.0 / p["T_0"])) # (n_est,)
594
+ # Defect: the batch path clamped R_ratio < 0.1 silently while the scalar
595
+ # noise_thermchannel warns; surface the broken-thermistor condition here too.
596
+ n_clamped = int(np.count_nonzero(R_ratio < 0.1))
597
+ if n_clamped:
598
+ warnings.warn(
599
+ f"R_ratio < 0.1 for {n_clamped} of {R_ratio.size} T_means "
600
+ "(possible broken thermistor); clamped to 1.0",
601
+ stacklevel=2,
602
+ )
603
+ R_ratio = np.where(R_ratio < 0.1, 1.0, R_ratio)
604
+ R_actual = R_ratio * p["R_0"]
605
+ phi_R = 4 * p["K_B"] * R_actual * p["T_K"] # (n_est,)
606
+
607
+ # Scale factor
608
+ eta = (p["b"] / 2) * 2 ** p["adc_bits"] * p["gain"] * p["e_b"] / p["adc_fs"]
609
+ scale_factor = T_kelvin**2 * (1 + R_ratio) ** 2 / (2 * eta * p["beta_1"] * R_ratio)
610
+ if p["beta_2"] is not None and np.isfinite(p["beta_2"]):
611
+ scale_factor = scale_factor * (1 + 2 * (p["beta_1"] / p["beta_2"]) * np.log(R_ratio))
612
+
613
+ Noise_counts = (
614
+ base_f[np.newaxis, :] + johnson_gain_f[np.newaxis, :] * phi_R[:, np.newaxis]
615
+ ) * counts_factor[np.newaxis, :]
616
+
617
+ return Noise_counts * (scale_factor**2)[:, np.newaxis]
618
+
619
+
620
+ def gradT_noise_batch(
621
+ F: npt.ArrayLike,
622
+ T_means: npt.ArrayLike,
623
+ speeds: npt.ArrayLike,
624
+ fs: float = 512,
625
+ diff_gain: float = 0.94,
626
+ **kwargs: Any,
627
+ ) -> np.ndarray:
628
+ """Batch temperature gradient noise in wavenumber units.
629
+
630
+ Parameters
631
+ ----------
632
+ F : shape ``(n_freq,)``
633
+ T_means : shape ``(n_est,)``
634
+ speeds : shape ``(n_est,)``
635
+
636
+ Returns
637
+ -------
638
+ noise_K : ndarray, shape ``(n_est, n_freq)``
639
+ Noise spectrum [(K/m)^2 / cpm].
640
+ """
641
+ speeds = np.asarray(speeds, dtype=np.float64)
642
+ # Defect: non-positive speeds give a negative/inf noise PSD; MATLAB
643
+ # gradT_noise_odas.m validates speed > 0 (val_speed).
644
+ if not np.all(np.isfinite(speeds) & (speeds > 0)):
645
+ raise ValueError(f"FP07 speeds must all be > 0, got min {np.min(speeds)!r}")
646
+ noise_f = noise_thermchannel_batch(F, T_means, fs=fs, diff_gain=diff_gain, **kwargs)
647
+ # Time-derivative noise -> gradient noise per cpm: /speed^2 * speed
648
+ # (see gradT_noise).
649
+ return noise_f / speeds[:, np.newaxis]