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,651 @@
1
+ # Mar-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """
3
+ NetCDF conversion for Rockland .p files -- ATOMIX L1_converted format.
4
+
5
+ Follows the ATOMIX shear-probe benchmark structure (Lueck et al., 2024,
6
+ doi:10.3389/fmars.2024.1334327, Figure 4). The L1_converted group contains
7
+ time series converted to physical units, including speed-normalized shear
8
+ and temperature gradient.
9
+ """
10
+
11
+ import logging
12
+ import os
13
+ import struct
14
+ from concurrent.futures import ProcessPoolExecutor, as_completed
15
+ from datetime import UTC, datetime
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING
18
+
19
+ if TYPE_CHECKING:
20
+ from odas_tpw.rsi.p_file import PFile
21
+
22
+ import numpy as np
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # Minimum profiling speed to avoid singularity [m/s]
27
+ _SPEED_MIN = 0.05
28
+
29
+ # Butterworth smoothing time constant for speed computation [s]
30
+ _SPEED_TAU = 1.5
31
+
32
+
33
+ def _compute_speed(pf: "PFile") -> tuple[np.ndarray, np.ndarray]:
34
+ """Compute profiling speed from pressure.
35
+
36
+ Returns (speed_fast, speed_slow) in m/s.
37
+ """
38
+ from odas_tpw.scor160.profile import compute_speed_fast
39
+
40
+ P_slow = pf.channels.get("P_dP", pf.channels.get("P"))
41
+ if P_slow is None:
42
+ raise ValueError("No pressure channel (P or P_dP) found")
43
+
44
+ speed_fast, _W_slow = compute_speed_fast(
45
+ P_slow,
46
+ pf.t_fast,
47
+ pf.t_slow,
48
+ pf.fs_fast,
49
+ pf.fs_slow,
50
+ tau=_SPEED_TAU,
51
+ speed_min=_SPEED_MIN,
52
+ )
53
+ return speed_fast, np.maximum(np.abs(_W_slow), _SPEED_MIN)
54
+
55
+
56
+ def _classify_channels(pf: "PFile") -> dict:
57
+ """Classify PFile channels into ATOMIX roles.
58
+
59
+ Returns a dict with keys: shear, vib, acc, mag, gradt, cond_ctd,
60
+ temp_ctd, pitch, roll, chla, turb, doxy, doxy_temp, supplementary.
61
+ """
62
+ shear_names = sorted(n for n in pf.channels if pf.channel_info[n]["type"] == "shear")
63
+ vib_names = sorted(n for n in pf.channels if pf.channel_info[n]["type"] == "piezo")
64
+ acc_names = sorted(n for n in pf.channels if pf.channel_info[n]["type"] == "accel")
65
+ mag_names = sorted(n for n in pf.channels if pf.channel_info[n]["type"] == "magn")
66
+ gradt_names = sorted(
67
+ n for n in pf.channels if pf.channel_info[n]["type"] == "therm" and pf.is_fast(n)
68
+ )
69
+ cond_ctd_names = sorted(n for n in pf.channels if pf.channel_info[n]["type"] == "jac_c")
70
+
71
+ temp_ctd_name = next((n for n in pf.channels if pf.channel_info[n]["type"] == "jac_t"), None)
72
+ pitch_name = roll_name = None
73
+ for n in pf.channels:
74
+ if pf.channel_info[n]["type"] == "inclxy":
75
+ if "X" in n:
76
+ pitch_name = n
77
+ elif "Y" in n:
78
+ roll_name = n
79
+
80
+ chla_name = next((n for n in pf.channels if n.lower().startswith("chloro")), None)
81
+ turb_name = next((n for n in pf.channels if n.lower().startswith("turb")), None)
82
+ doxy_name = next((n for n in pf.channels if pf.channel_info[n]["type"] == "aroft_o2"), None)
83
+ doxy_temp_name = next((n for n in pf.channels if pf.channel_info[n]["type"] == "aroft_t"), None)
84
+
85
+ # Identify supplementary channels (not mapped to named variables)
86
+ mapped = set(shear_names + vib_names + gradt_names + acc_names + mag_names + cond_ctd_names)
87
+ for n in (
88
+ temp_ctd_name,
89
+ pitch_name,
90
+ roll_name,
91
+ "P",
92
+ "P_dP",
93
+ chla_name,
94
+ turb_name,
95
+ doxy_name,
96
+ doxy_temp_name,
97
+ ):
98
+ if n is not None:
99
+ mapped.add(n)
100
+ for n in list(pf.channels):
101
+ if pf.channel_info[n]["type"] == "therm" and not pf.is_fast(n):
102
+ mapped.add(n)
103
+ supplementary = sorted(n for n in pf.channels if n not in mapped)
104
+
105
+ return {
106
+ "shear": shear_names,
107
+ "vib": vib_names,
108
+ "acc": acc_names,
109
+ "mag": mag_names,
110
+ "gradt": gradt_names,
111
+ "cond_ctd": cond_ctd_names,
112
+ "temp_ctd": temp_ctd_name,
113
+ "pitch": pitch_name,
114
+ "roll": roll_name,
115
+ "chla": chla_name,
116
+ "turb": turb_name,
117
+ "doxy": doxy_name,
118
+ "doxy_temp": doxy_temp_name,
119
+ "supplementary": supplementary,
120
+ }
121
+
122
+
123
+ def _create_l1_variables(group, specs, complevel=4):
124
+ """Write a list of variable specs to a NetCDF group.
125
+
126
+ Each spec is (var_name, dtype, dims, data, attrs). Specs with
127
+ data=None are skipped (conditional variables).
128
+
129
+ Parameters
130
+ ----------
131
+ complevel : int
132
+ zlib compression level (0 = no compression, 1-9 = increasing).
133
+ """
134
+ use_zlib = complevel > 0
135
+ for var_name, dtype, dims, data, attrs in specs:
136
+ if data is None:
137
+ continue
138
+ v = group.createVariable(var_name, dtype, dims, zlib=use_zlib, complevel=complevel) # type: ignore[call-overload]
139
+ v[:] = data
140
+ for key, val in attrs.items():
141
+ setattr(v, key, val)
142
+
143
+
144
+ def _l1_variable_specs(
145
+ pf, ch, time_fast, time_slow, time_units, P_fast, P_slow, speed_fast, gradt_data
146
+ ):
147
+ """Build the list of (name, dtype, dims, data, attrs) for L1 variables.
148
+
149
+ Parameters
150
+ ----------
151
+ pf : PFile
152
+ Parsed instrument file.
153
+ ch : dict
154
+ Channel classification from _classify_channels().
155
+ time_fast, time_slow : ndarray
156
+ Time vectors as decimal days.
157
+ time_units : str
158
+ CF time units string.
159
+ P_fast, P_slow : ndarray
160
+ Pressure arrays (fast interpolated, slow original).
161
+ speed_fast : ndarray
162
+ Profiling speed at fast rate.
163
+ gradt_data : ndarray or None
164
+ Pre-computed (N_GRADT_SENSORS, TIME) gradient array, or None.
165
+ """
166
+ shear_names = ch["shear"]
167
+ vib_names = ch["vib"]
168
+ acc_names = ch["acc"]
169
+ mag_names = ch["mag"]
170
+ gradt_names = ch["gradt"]
171
+ cond_ctd_names = ch["cond_ctd"]
172
+ temp_ctd_name = ch["temp_ctd"]
173
+ pitch_name = ch["pitch"]
174
+ roll_name = ch["roll"]
175
+ chla_name = ch["chla"]
176
+ turb_name = ch["turb"]
177
+ doxy_name = ch["doxy"]
178
+ doxy_temp_name = ch["doxy_temp"]
179
+
180
+ # Multi-sensor stacking helper
181
+ def _stack(names):
182
+ return np.stack([pf.channels[n] for n in names], axis=0)
183
+
184
+ # SHEAR — normalized by speed^2
185
+ shear_data = None
186
+ if shear_names:
187
+ shear_data = np.stack(
188
+ [pf.channels[n] / speed_fast**2 for n in shear_names],
189
+ axis=0,
190
+ )
191
+
192
+ # TEMP (fast FP07)
193
+ temp_data = _stack(gradt_names) if gradt_names else None
194
+
195
+ specs = [
196
+ # TIME vectors
197
+ (
198
+ "TIME",
199
+ "f8",
200
+ ("TIME",),
201
+ time_fast,
202
+ {"standard_name": "time", "long_name": "Decimal day", "units": time_units, "axis": "T"},
203
+ ),
204
+ (
205
+ "TIME_SLOW",
206
+ "f8",
207
+ ("TIME_SLOW",),
208
+ time_slow,
209
+ {"standard_name": "time", "long_name": "Decimal day", "units": time_units, "axis": "T"},
210
+ ),
211
+ # Pressure
212
+ (
213
+ "PRES",
214
+ "f8",
215
+ ("TIME",),
216
+ P_fast,
217
+ {
218
+ "standard_name": "sea_water_pressure",
219
+ "units": "decibar",
220
+ "long_name": "Sea water pressure, equals 0 at sea-level",
221
+ },
222
+ ),
223
+ (
224
+ "PRES_SLOW",
225
+ "f8",
226
+ ("TIME_SLOW",),
227
+ P_slow,
228
+ {
229
+ "standard_name": "sea_water_pressure",
230
+ "units": "decibar",
231
+ "long_name": "Sea water pressure, equals 0 at sea-level",
232
+ },
233
+ ),
234
+ # SHEAR
235
+ (
236
+ "SHEAR",
237
+ "f8",
238
+ ("N_SHEAR_SENSORS", "TIME"),
239
+ shear_data,
240
+ {
241
+ "standard_name": "sea_water_velocity_shear",
242
+ "units": "s-1",
243
+ "long_name": "rate of change of cross axis sea water velocity "
244
+ "along transect measured by shear probes",
245
+ "sensor_names": ", ".join(shear_names),
246
+ },
247
+ ),
248
+ # VIB
249
+ (
250
+ "VIB",
251
+ "f8",
252
+ ("N_VIB_SENSORS", "TIME"),
253
+ _stack(vib_names) if vib_names else None,
254
+ {
255
+ "standard_name": "platform_vibration",
256
+ "units": "1",
257
+ "long_name": "platform vibration detected by piezo-accelerometers",
258
+ "sensor_names": ", ".join(vib_names),
259
+ },
260
+ ),
261
+ # ACC
262
+ (
263
+ "ACC",
264
+ "f8",
265
+ ("N_ACC_SENSORS", "TIME"),
266
+ _stack(acc_names) if acc_names else None,
267
+ {
268
+ "standard_name": "platform_acceleration",
269
+ "units": "m s-2",
270
+ "long_name": "platform acceleration detected by accelerometers",
271
+ "sensor_names": ", ".join(acc_names),
272
+ },
273
+ ),
274
+ # GRADT
275
+ (
276
+ "GRADT",
277
+ "f8",
278
+ ("N_GRADT_SENSORS", "TIME"),
279
+ gradt_data,
280
+ {
281
+ "standard_name": "along_profile_gradient_of_seawater_temperature",
282
+ "units": "degrees_Celsius m-1",
283
+ "long_name": "the gradient of seawater temperature along the path "
284
+ "of the profiler measured by an FP07 thermistor",
285
+ "sensor_names": ", ".join(gradt_names),
286
+ },
287
+ ),
288
+ # TEMP (fast FP07)
289
+ (
290
+ "TEMP",
291
+ "f8",
292
+ ("N_TEMP_SENSORS", "TIME"),
293
+ temp_data,
294
+ {
295
+ "standard_name": "sea_water_temperature",
296
+ "units": "degree_Celsius",
297
+ "long_name": "sea water temperature in-situ ITS-90 scale",
298
+ "sensor_names": ", ".join(gradt_names),
299
+ },
300
+ ),
301
+ # TEMP_CTD (slow)
302
+ (
303
+ "TEMP_CTD",
304
+ "f8",
305
+ ("TIME_SLOW",),
306
+ pf.channels[temp_ctd_name] if temp_ctd_name else None,
307
+ {
308
+ "standard_name": "sea_water_temperature",
309
+ "units": "degree_Celsius",
310
+ "long_name": "sea water temperature in-situ ITS-90 scale",
311
+ },
312
+ ),
313
+ # COND_CTD (slow)
314
+ (
315
+ "COND_CTD",
316
+ "f8",
317
+ ("TIME_SLOW",),
318
+ pf.channels[cond_ctd_names[0]] if cond_ctd_names else None,
319
+ {
320
+ "standard_name": "sea_water_electrical_conductivity",
321
+ "units": "mS cm-1",
322
+ "long_name": "sea water electrical conductivity",
323
+ },
324
+ ),
325
+ # PITCH
326
+ (
327
+ "PITCH",
328
+ "f8",
329
+ ("TIME_SLOW",),
330
+ pf.channels[pitch_name] if pitch_name else None,
331
+ {
332
+ "standard_name": "platform_pitch_angle_fore_down",
333
+ "units": "degrees",
334
+ "long_name": "Positive pitch represents the front of the platform "
335
+ "lowering as viewed by an observer on top of the "
336
+ "platform facing forward",
337
+ },
338
+ ),
339
+ # ROLL
340
+ (
341
+ "ROLL",
342
+ "f8",
343
+ ("TIME_SLOW",),
344
+ pf.channels[roll_name] if roll_name else None,
345
+ {
346
+ "standard_name": "platform_roll_angle_starboard_down",
347
+ "units": "degrees",
348
+ "long_name": "Positive roll represents the right side of the "
349
+ "platform falling as viewed by an observer on top of "
350
+ "the platform facing forward",
351
+ },
352
+ ),
353
+ # MAG
354
+ (
355
+ "MAG",
356
+ "f8",
357
+ ("N_MAG_SENSORS", "TIME_SLOW"),
358
+ _stack(mag_names) if mag_names else None,
359
+ {
360
+ # 'micro_Tesla' is not UDUNITS-parseable; 'uT' is the canonical form
361
+ # ('magnetic_field' is likewise not a CF standard name, but left as-is).
362
+ "standard_name": "magnetic_field",
363
+ "units": "uT",
364
+ "long_name": "magnetic field from magnetometer",
365
+ "sensor_names": ", ".join(mag_names),
366
+ },
367
+ ),
368
+ ]
369
+
370
+ # Biogeochemical sensors — dimension depends on sample rate
371
+ for var_name, ch_name, attrs in [
372
+ (
373
+ "CHLA",
374
+ chla_name,
375
+ {
376
+ "standard_name": "mass_concentration_of_chlorophyll_a_in_sea_water",
377
+ "units": "ug L-1",
378
+ "long_name": "chlorophyll-a fluorescence",
379
+ },
380
+ ),
381
+ (
382
+ "TURB",
383
+ turb_name,
384
+ {
385
+ "standard_name": "sea_water_turbidity",
386
+ "units": "FTU",
387
+ "long_name": "sea water turbidity",
388
+ },
389
+ ),
390
+ (
391
+ "DOXY",
392
+ doxy_name,
393
+ {
394
+ "standard_name": "mole_concentration_of_dissolved_molecular_oxygen_in_sea_water",
395
+ "units": "umol L-1",
396
+ "long_name": "dissolved oxygen concentration",
397
+ },
398
+ ),
399
+ (
400
+ "DOXY_TEMP",
401
+ doxy_temp_name,
402
+ {"units": "degree_Celsius", "long_name": "oxygen optode sensor temperature"},
403
+ ),
404
+ ]:
405
+ if ch_name is not None:
406
+ dim = ("TIME",) if pf.is_fast(ch_name) else ("TIME_SLOW",)
407
+ specs.append((var_name, "f8", dim, pf.channels[ch_name], attrs))
408
+
409
+ return specs
410
+
411
+
412
+ def p_to_L1(
413
+ p_filepath: str | Path,
414
+ nc_filepath: str | Path | None = None,
415
+ complevel: int = 4,
416
+ ) -> "tuple[PFile, Path]":
417
+ """Convert a .p file to ATOMIX L1_converted NetCDF format.
418
+
419
+ Produces a NetCDF4 file with root-level dimensions and an L1_converted
420
+ group containing time series in physical units. ATOMIX core variables
421
+ (SHEAR, VIB, ACC, GRADT, TEMP, TEMP_CTD, COND_CTD, PRES, PITCH, ROLL,
422
+ MAG) are mapped from PFile channels. Biogeochemical sensors (CHLA,
423
+ TURB, DOXY, DOXY_TEMP) get CF-compliant names. Remaining channels
424
+ are included as supplementary variables.
425
+
426
+ Parameters
427
+ ----------
428
+ p_filepath : str or Path
429
+ Path to the .p file.
430
+ nc_filepath : str or Path, optional
431
+ Output path. Defaults to same name with .nc extension.
432
+ complevel : int
433
+ zlib compression level (0 = no compression, 1-9 = increasing).
434
+ Default: 4.
435
+
436
+ Returns
437
+ -------
438
+ tuple of (PFile, Path)
439
+ The parsed data object and the output path.
440
+ """
441
+ import netCDF4 as nc
442
+
443
+ from odas_tpw.rsi.p_file import PFile
444
+
445
+ pf = PFile(p_filepath)
446
+ if nc_filepath is None:
447
+ nc_filepath = pf.filepath.with_suffix(".nc")
448
+ nc_filepath = Path(nc_filepath)
449
+
450
+ # ---- Profiling speed from pressure ----
451
+ speed_fast, _speed_slow = _compute_speed(pf)
452
+
453
+ # ---- Time vectors as decimal days since Jan 1 of start year ----
454
+ ref_year = pf.start_time.year
455
+ epoch = datetime(ref_year, 1, 1, tzinfo=UTC)
456
+ st = pf.start_time
457
+ if st.tzinfo is None:
458
+ st = st.replace(tzinfo=UTC)
459
+ t0_days = (st - epoch).total_seconds() / 86400.0
460
+ time_fast = t0_days + pf.t_fast / 86400.0
461
+ time_slow = t0_days + pf.t_slow / 86400.0
462
+ time_units = f"Days since {ref_year}-01-01T00:00:00Z"
463
+
464
+ # ---- Classify channels by ATOMIX role ----
465
+ ch = _classify_channels(pf)
466
+ shear_names = ch["shear"]
467
+ vib_names = ch["vib"]
468
+ acc_names = ch["acc"]
469
+ mag_names = ch["mag"]
470
+ gradt_names = ch["gradt"]
471
+ supplementary = ch["supplementary"]
472
+
473
+ # Pressure (prefer deconvolved P_dP, else P)
474
+ P_slow = pf.channels.get("P_dP", pf.channels.get("P"))
475
+ P_fast = np.interp(pf.t_fast, pf.t_slow, P_slow)
476
+
477
+ # ---- Create NetCDF file ----
478
+ ds = nc.Dataset(str(nc_filepath), "w", format="NETCDF4")
479
+
480
+ # Global attributes
481
+ ds.Conventions = "CF-1.13, ACDD-1.3"
482
+ ds.title = f"VMP data from {pf.filepath.name}"
483
+ ds.source = pf.config["instrument_info"].get("model", "")
484
+ ds.platform_type = pf.config["instrument_info"].get("vehicle", "")
485
+ ds.instrument_sn = pf.config["instrument_info"].get("sn", "")
486
+ ds.operator = pf.config["cruise_info"].get("operator", "")
487
+ ds.project = pf.config["cruise_info"].get("project", "")
488
+ ds.start_time = pf.start_time.isoformat()
489
+ ds.source_file = pf.filepath.name
490
+ ds.history = f"L1 converted from {pf.filepath.name} on {datetime.now(UTC).isoformat()}"
491
+ ds.configuration_string = pf.config_str
492
+
493
+ # Root dimensions (shared across groups, following ATOMIX convention)
494
+ ds.createDimension("TIME", len(pf.t_fast))
495
+ ds.createDimension("TIME_SLOW", len(pf.t_slow))
496
+ if shear_names:
497
+ ds.createDimension("N_SHEAR_SENSORS", len(shear_names))
498
+ if vib_names:
499
+ ds.createDimension("N_VIB_SENSORS", len(vib_names))
500
+ if acc_names:
501
+ ds.createDimension("N_ACC_SENSORS", len(acc_names))
502
+ if gradt_names:
503
+ ds.createDimension("N_GRADT_SENSORS", len(gradt_names))
504
+ ds.createDimension("N_TEMP_SENSORS", len(gradt_names))
505
+ if mag_names:
506
+ ds.createDimension("N_MAG_SENSORS", len(mag_names))
507
+
508
+ # ---- L1_converted group ----
509
+ L1 = ds.createGroup("L1_converted")
510
+
511
+ # Pre-compute GRADT arrays (need speed_fast before building specs)
512
+ gradt_data = None
513
+ if gradt_names:
514
+ gradt_arrays = []
515
+ for name in gradt_names:
516
+ T_fast_ch = pf.channels[name]
517
+ # GRADT uses the legacy ODAS 'first_difference' method
518
+ # (fs*diff(T)/speed), NOT the ODAS default 'high_pass' on the
519
+ # pre-emphasized signal, so this L1 GRADT differs sample-by-sample
520
+ # from the ODAS reference product. (epsilon/chi use their own
521
+ # spectral pre-emphasis path and do not consume this variable.)
522
+ # np.diff on a single sample is empty; dTdt[-1] would IndexError.
523
+ if T_fast_ch.size < 2:
524
+ dTdt = np.zeros_like(T_fast_ch)
525
+ else:
526
+ dTdt = np.diff(T_fast_ch) * pf.fs_fast
527
+ dTdt = np.append(dTdt, dTdt[-1])
528
+ gradt_arrays.append(dTdt / speed_fast)
529
+ gradt_data = np.stack(gradt_arrays, axis=0)
530
+
531
+ # Build variable specs and write them
532
+ specs = _l1_variable_specs(
533
+ pf,
534
+ ch,
535
+ time_fast,
536
+ time_slow,
537
+ time_units,
538
+ P_fast,
539
+ P_slow,
540
+ speed_fast,
541
+ gradt_data,
542
+ )
543
+ _create_l1_variables(L1, specs, complevel=complevel)
544
+
545
+ # Supplementary channels (V_Bat, Gnd, etc.) — dynamic names/types
546
+ from odas_tpw.perturb.netcdf_schema import canonicalize_units
547
+
548
+ use_zlib = complevel > 0
549
+ for name in supplementary:
550
+ info = pf.channel_info[name]
551
+ dim = ("TIME",) if pf.is_fast(name) else ("TIME_SLOW",)
552
+ var_name = name.replace(" ", "_")
553
+ v = L1.createVariable(var_name, "f4", dim, zlib=use_zlib, complevel=complevel) # type: ignore[call-overload]
554
+ v[:] = pf.channels[name].astype(np.float32)
555
+ # RSI emits non-UDUNITS unit strings (e.g. 'deg', 'mS_cm-1', 'deg_C');
556
+ # canonicalize to match the per-profile writer (profile.py).
557
+ v.units = canonicalize_units(info["units"])
558
+ v.sensor_type = info["type"]
559
+ v.long_name = name
560
+
561
+ # Group attributes
562
+ L1.time_reference_year = float(ref_year)
563
+ L1.fs_fast = pf.fs_fast
564
+ L1.fs_slow = pf.fs_slow
565
+ # 98.0 Hz is the RSI/ODAS default anti-alias cutoff, hardcoded because the
566
+ # AA cutoff is not parsed from the instrument config. Every writer and every
567
+ # read-with-recovery (adapter.py, io.py) defaults to the same 98.0, so it is
568
+ # self-consistent and does not alter published epsilon. Parsing the actual
569
+ # AA cutoff from the config is a separate follow-up. (#104 U1-4.)
570
+ L1.f_AA = 98.0
571
+ L1.vehicle = pf.config["instrument_info"].get("vehicle", "").lower()
572
+
573
+ ds.close()
574
+ return pf, nc_filepath
575
+
576
+
577
+ # Backward-compatible alias
578
+ p_to_netcdf = p_to_L1
579
+
580
+
581
+ def _convert_one(args):
582
+ """Worker function for parallel conversion. Must be top-level for pickling."""
583
+ if len(args) == 3:
584
+ p_path, nc_path, complevel = args
585
+ else:
586
+ p_path, nc_path = args
587
+ complevel = 4
588
+ p_to_L1(p_path, nc_path, complevel=complevel)
589
+ size_mb = nc_path.stat().st_size / 1e6
590
+ return p_path.name, nc_path.name, size_mb
591
+
592
+
593
+ def convert_all(p_files: list[Path], output_dir: Path | None = None, jobs: int = 1) -> None:
594
+ """Convert multiple .p files to ATOMIX L1_converted NetCDF.
595
+
596
+ Parameters
597
+ ----------
598
+ p_files : list of Path
599
+ .p files to convert.
600
+ output_dir : Path, optional
601
+ Output directory. If None, each .nc goes next to its .p file.
602
+ jobs : int
603
+ Number of parallel workers. 0 means use all CPU cores.
604
+ """
605
+ if output_dir is not None:
606
+ output_dir = Path(output_dir)
607
+ output_dir.mkdir(parents=True, exist_ok=True)
608
+
609
+ work = []
610
+ # Detect output-name collisions: when output_dir is set we flatten to the
611
+ # basename, so same-named .p files in different directories would map to one
612
+ # .nc — silently overwriting (serial) or racing/corrupting (parallel).
613
+ seen: dict[Path, Path] = {}
614
+ for pf_path in p_files:
615
+ pf_path = Path(pf_path)
616
+ if output_dir is not None:
617
+ nc_path = output_dir / pf_path.with_suffix(".nc").name
618
+ else:
619
+ nc_path = pf_path.with_suffix(".nc")
620
+ prior = seen.get(nc_path)
621
+ if prior is not None and prior != pf_path:
622
+ raise ValueError(
623
+ f"Output name collision: {prior} and {pf_path} both map to "
624
+ f"{nc_path}. Use distinct filenames or per-source output dirs."
625
+ )
626
+ seen[nc_path] = pf_path
627
+ work.append((pf_path, nc_path))
628
+
629
+ if jobs == 0:
630
+ jobs = os.cpu_count() or 1
631
+
632
+ if jobs == 1:
633
+ for p_path, nc_path in work:
634
+ try:
635
+ _name, _, size_mb = _convert_one((p_path, nc_path))
636
+ logger.info(f"{p_path.name} -> {nc_path.name} {size_mb:.1f} MB")
637
+ # struct.error: truncated/short-header .p file must not abort the batch
638
+ except (OSError, ValueError, RuntimeError, struct.error) as e:
639
+ logger.error(f"{p_path.name}: {e}")
640
+ else:
641
+ logger.info(f"Converting {len(work)} files with {jobs} workers")
642
+ with ProcessPoolExecutor(max_workers=jobs) as pool:
643
+ futures = {pool.submit(_convert_one, w): w for w in work}
644
+ for future in as_completed(futures):
645
+ p_path, nc_path = futures[future]
646
+ try:
647
+ _, _, size_mb = future.result()
648
+ logger.info(f"{p_path.name} -> {nc_path.name} {size_mb:.1f} MB")
649
+ # struct.error: truncated/short-header .p file must not abort the batch
650
+ except (OSError, ValueError, RuntimeError, struct.error) as e:
651
+ logger.error(f"{p_path.name}: {e}")