microstructure-tpw 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- microstructure_tpw-0.3.0.dist-info/METADATA +234 -0
- microstructure_tpw-0.3.0.dist-info/RECORD +119 -0
- microstructure_tpw-0.3.0.dist-info/WHEEL +5 -0
- microstructure_tpw-0.3.0.dist-info/entry_points.txt +7 -0
- microstructure_tpw-0.3.0.dist-info/licenses/LICENSE +674 -0
- microstructure_tpw-0.3.0.dist-info/top_level.txt +1 -0
- odas_tpw/__init__.py +18 -0
- odas_tpw/chi/__init__.py +48 -0
- odas_tpw/chi/batchelor.py +189 -0
- odas_tpw/chi/chi.py +857 -0
- odas_tpw/chi/fp07.py +649 -0
- odas_tpw/chi/l2_chi.py +153 -0
- odas_tpw/chi/l3_chi.py +388 -0
- odas_tpw/chi/l4_chi.py +391 -0
- odas_tpw/config_base.py +423 -0
- odas_tpw/perturb/__init__.py +18 -0
- odas_tpw/perturb/adcp.py +274 -0
- odas_tpw/perturb/atomic_io.py +47 -0
- odas_tpw/perturb/binning.py +761 -0
- odas_tpw/perturb/cli.py +591 -0
- odas_tpw/perturb/combo.py +346 -0
- odas_tpw/perturb/config.py +1046 -0
- odas_tpw/perturb/ctd.py +424 -0
- odas_tpw/perturb/diag/__init__.py +12 -0
- odas_tpw/perturb/diag/_common.py +140 -0
- odas_tpw/perturb/diag/chi.py +42 -0
- odas_tpw/perturb/diag/cli.py +67 -0
- odas_tpw/perturb/diag/data.py +662 -0
- odas_tpw/perturb/diag/epsilon.py +45 -0
- odas_tpw/perturb/diag/inspector.py +342 -0
- odas_tpw/perturb/diag/mixing.py +94 -0
- odas_tpw/perturb/diag/render.py +543 -0
- odas_tpw/perturb/discover.py +40 -0
- odas_tpw/perturb/fp07_cal.py +354 -0
- odas_tpw/perturb/gps.py +458 -0
- odas_tpw/perturb/hotel.py +572 -0
- odas_tpw/perturb/logging_setup.py +181 -0
- odas_tpw/perturb/merge.py +320 -0
- odas_tpw/perturb/mk_sections.py +310 -0
- odas_tpw/perturb/netcdf_schema.py +520 -0
- odas_tpw/perturb/pipeline.py +2654 -0
- odas_tpw/perturb/plot/__init__.py +15 -0
- odas_tpw/perturb/plot/cli.py +107 -0
- odas_tpw/perturb/plot/diagnostics.py +195 -0
- odas_tpw/perturb/plot/eps_chi.py +524 -0
- odas_tpw/perturb/plot/figure.py +679 -0
- odas_tpw/perturb/plot/gamma_scaling.py +1097 -0
- odas_tpw/perturb/plot/grid.py +112 -0
- odas_tpw/perturb/plot/layout.py +361 -0
- odas_tpw/perturb/plot/overview.py +547 -0
- odas_tpw/perturb/plot/presets/eps-chi.yaml +16 -0
- odas_tpw/perturb/plot/presets/profiles.yaml +32 -0
- odas_tpw/perturb/plot/presets/scalar.yaml +21 -0
- odas_tpw/perturb/plot/profiles.py +652 -0
- odas_tpw/perturb/plot/scalar.py +391 -0
- odas_tpw/perturb/plot/sections.py +506 -0
- odas_tpw/perturb/plot/xaxis.py +396 -0
- odas_tpw/perturb/qc_gate.py +177 -0
- odas_tpw/perturb/qc_rules.py +373 -0
- odas_tpw/perturb/resolve.py +306 -0
- odas_tpw/perturb/seawater.py +76 -0
- odas_tpw/perturb/speed.py +14 -0
- odas_tpw/perturb/trim.py +313 -0
- odas_tpw/processing/__init__.py +62 -0
- odas_tpw/processing/bottom.py +215 -0
- odas_tpw/processing/chi_combine.py +300 -0
- odas_tpw/processing/ct_align.py +172 -0
- odas_tpw/processing/epsilon_combine.py +210 -0
- odas_tpw/processing/mixing.py +630 -0
- odas_tpw/processing/thorpe.py +458 -0
- odas_tpw/processing/top_trim.py +274 -0
- odas_tpw/pyturb/__init__.py +3 -0
- odas_tpw/pyturb/__main__.py +5 -0
- odas_tpw/pyturb/_compat.py +204 -0
- odas_tpw/pyturb/_profind.py +124 -0
- odas_tpw/pyturb/bin.py +113 -0
- odas_tpw/pyturb/cli.py +175 -0
- odas_tpw/pyturb/eps.py +353 -0
- odas_tpw/pyturb/merge.py +101 -0
- odas_tpw/pyturb/p2nc.py +76 -0
- odas_tpw/rsi/__init__.py +31 -0
- odas_tpw/rsi/adapter.py +314 -0
- odas_tpw/rsi/bench.py +904 -0
- odas_tpw/rsi/binning.py +193 -0
- odas_tpw/rsi/channels.py +376 -0
- odas_tpw/rsi/chi_io.py +980 -0
- odas_tpw/rsi/cli.py +1411 -0
- odas_tpw/rsi/combine.py +120 -0
- odas_tpw/rsi/config.py +126 -0
- odas_tpw/rsi/config_patch.py +790 -0
- odas_tpw/rsi/convert.py +651 -0
- odas_tpw/rsi/deconvolve.py +119 -0
- odas_tpw/rsi/diss_look.py +343 -0
- odas_tpw/rsi/dissipation.py +669 -0
- odas_tpw/rsi/helpers.py +527 -0
- odas_tpw/rsi/mixing_look.py +509 -0
- odas_tpw/rsi/p_file.py +771 -0
- odas_tpw/rsi/pipeline.py +924 -0
- odas_tpw/rsi/profile.py +624 -0
- odas_tpw/rsi/quick_look.py +758 -0
- odas_tpw/rsi/sensor_inventory.py +684 -0
- odas_tpw/rsi/shear_noise.py +128 -0
- odas_tpw/rsi/speed.py +221 -0
- odas_tpw/rsi/vehicle.py +74 -0
- odas_tpw/rsi/viewer_base.py +1071 -0
- odas_tpw/rsi/window.py +459 -0
- odas_tpw/scor160/__init__.py +36 -0
- odas_tpw/scor160/cli.py +242 -0
- odas_tpw/scor160/compare.py +484 -0
- odas_tpw/scor160/despike.py +227 -0
- odas_tpw/scor160/goodman.py +288 -0
- odas_tpw/scor160/io.py +690 -0
- odas_tpw/scor160/l2.py +311 -0
- odas_tpw/scor160/l3.py +279 -0
- odas_tpw/scor160/l4.py +850 -0
- odas_tpw/scor160/nasmyth.py +208 -0
- odas_tpw/scor160/ocean.py +287 -0
- odas_tpw/scor160/profile.py +231 -0
- odas_tpw/scor160/spectral.py +482 -0
odas_tpw/rsi/convert.py
ADDED
|
@@ -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}")
|