oceanarray 0.1.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.
- oceanarray/__init__.py +20 -0
- oceanarray/_version.py +1 -0
- oceanarray/cli.py +1343 -0
- oceanarray/clock_offset.py +675 -0
- oceanarray/config/OS1_sensor_attrs.yaml +49 -0
- oceanarray/config/OS1_var_names.yaml +5 -0
- oceanarray/config/OS1_vocab_attrs.yaml +84 -0
- oceanarray/config/legacy/project_RAPID.yaml +69 -0
- oceanarray/config/legacy/rodb_keys.txt +206 -0
- oceanarray/config/legacy/rodb_keys.yaml +1012 -0
- oceanarray/config/logging.yaml +28 -0
- oceanarray/legacy/__init__.py +47 -0
- oceanarray/legacy/caldip.py +753 -0
- oceanarray/legacy/convertOS.py +527 -0
- oceanarray/legacy/find_deployment.py +489 -0
- oceanarray/legacy/mooring_rodb.py +429 -0
- oceanarray/legacy/process_rodb.py +369 -0
- oceanarray/legacy/rodb.py +330 -0
- oceanarray/logger.py +192 -0
- oceanarray/logsheet/__init__.py +60 -0
- oceanarray/logsheet/_columns.py +97 -0
- oceanarray/logsheet/_config.py +245 -0
- oceanarray/logsheet/_firmware.py +43 -0
- oceanarray/logsheet/_latex.py +128 -0
- oceanarray/logsheet/_render.py +126 -0
- oceanarray/logsheet/builders/__init__.py +13 -0
- oceanarray/logsheet/builders/caldip_download.py +195 -0
- oceanarray/logsheet/builders/caldip_setup.py +188 -0
- oceanarray/logsheet/builders/deployment_setup.py +218 -0
- oceanarray/logsheet/builders/mooring_download.py +243 -0
- oceanarray/logsheet/builders/recovery.py +157 -0
- oceanarray/logsheet/data/column_defs.yaml +686 -0
- oceanarray/logsheet/data/latex_templates/_base_instrument.tex.j2 +42 -0
- oceanarray/logsheet/data/latex_templates/caldip_download.tex.j2 +47 -0
- oceanarray/logsheet/data/latex_templates/caldip_setup.tex.j2 +70 -0
- oceanarray/logsheet/data/latex_templates/deployment_setup.tex.j2 +97 -0
- oceanarray/logsheet/data/latex_templates/mooring_download.tex.j2 +54 -0
- oceanarray/logsheet/data/latex_templates/recovery.tex.j2 +103 -0
- oceanarray/mooring_level.py +1368 -0
- oceanarray/oceanarray.mplstyle +18 -0
- oceanarray/parameters.py +270 -0
- oceanarray/plotter.py +942 -0
- oceanarray/plotters/__init__.py +97 -0
- oceanarray/plotters/_animation.py +261 -0
- oceanarray/plotters/_current.py +705 -0
- oceanarray/plotters/_diagnostic.py +667 -0
- oceanarray/plotters/_helpers.py +57 -0
- oceanarray/plotters/_primitives.py +101 -0
- oceanarray/plotters/_section.py +16 -0
- oceanarray/plotters/_timeseries.py +11 -0
- oceanarray/rapid_interp.py +739 -0
- oceanarray/read_rbr_hex.py +541 -0
- oceanarray/readers.py +303 -0
- oceanarray/report/__init__.py +5 -0
- oceanarray/report/_array.py +386 -0
- oceanarray/report/_grid.py +539 -0
- oceanarray/report/_html_helpers.py +1004 -0
- oceanarray/report/_instrument.py +853 -0
- oceanarray/report/_mooring.py +1385 -0
- oceanarray/report/_plots.py +4284 -0
- oceanarray/report/_stack.py +941 -0
- oceanarray/stage1.py +1959 -0
- oceanarray/stage2.py +1160 -0
- oceanarray/stage3.py +2494 -0
- oceanarray/time_gridding.py +958 -0
- oceanarray/tools.py +400 -0
- oceanarray/transports.py +1 -0
- oceanarray/utilities.py +538 -0
- oceanarray/validation.py +389 -0
- oceanarray/writers.py +99 -0
- oceanarray-0.1.0.dist-info/METADATA +503 -0
- oceanarray-0.1.0.dist-info/RECORD +76 -0
- oceanarray-0.1.0.dist-info/WHEEL +5 -0
- oceanarray-0.1.0.dist-info/entry_points.txt +2 -0
- oceanarray-0.1.0.dist-info/licenses/LICENSE +21 -0
- oceanarray-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
r"""Reader for RBR TR-1050 (and similar) binary-hex data files.
|
|
2
|
+
|
|
3
|
+
The ``.hex`` file format produced by Ruskin stores the header as human-readable
|
|
4
|
+
text followed by the instrument data as ASCII hex characters. The binary
|
|
5
|
+
payload uses BCD-encoded timestamps in 12-byte event records (``\\x00\\x00\\x00TIM``
|
|
6
|
+
or ``\\x00\\x00\\x00STP`` + 6 BCD bytes) and 3-byte raw ADC counts per channel per
|
|
7
|
+
sample.
|
|
8
|
+
|
|
9
|
+
Usage::
|
|
10
|
+
|
|
11
|
+
import xarray as xr
|
|
12
|
+
from oceanarray.read_rbr_hex import read_rbr_hex
|
|
13
|
+
|
|
14
|
+
ds = read_rbr_hex("13875_recovery.hex")
|
|
15
|
+
print(ds)
|
|
16
|
+
|
|
17
|
+
Calibration
|
|
18
|
+
-----------
|
|
19
|
+
Raw 24-bit counts are stored in ``channel_{n}`` (uint32). When the header
|
|
20
|
+
contains exactly four calibration coefficients, the Steinhart-Hart equation is
|
|
21
|
+
applied and the result is stored as a separate variable named after the channel
|
|
22
|
+
(e.g. ``temp``). See :func:`_apply_rbr_cal` for the equation. If the
|
|
23
|
+
coefficient count is not four, the variable falls back to raw / 16777215 and
|
|
24
|
+
a warning is encoded in the ``calibration_method`` attribute.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import datetime
|
|
30
|
+
import re
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Dict, List, Optional, Tuple
|
|
33
|
+
|
|
34
|
+
import numpy as np
|
|
35
|
+
import xarray as xr
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# BCD helpers
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _bcd(b: int) -> int:
|
|
44
|
+
"""Decode one byte of packed BCD: upper nibble * 10 + lower nibble."""
|
|
45
|
+
return (b >> 4) * 10 + (b & 0xF)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _bcd_datetime(b6: bytes) -> datetime.datetime:
|
|
49
|
+
"""Decode 6 BCD bytes → datetime (YY MM DD HH MM SS, year base 2000)."""
|
|
50
|
+
return datetime.datetime(
|
|
51
|
+
2000 + _bcd(b6[0]),
|
|
52
|
+
_bcd(b6[1]),
|
|
53
|
+
_bcd(b6[2]),
|
|
54
|
+
_bcd(b6[3]),
|
|
55
|
+
_bcd(b6[4]),
|
|
56
|
+
_bcd(b6[5]),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _bcd_timedelta(b3: bytes) -> int:
|
|
61
|
+
"""Decode 3 BCD bytes → sample period in seconds (HH MM SS)."""
|
|
62
|
+
return _bcd(b3[0]) * 3600 + _bcd(b3[1]) * 60 + _bcd(b3[2])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Text header parsing
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_text_header(lines: List[str]) -> Dict:
|
|
71
|
+
"""Extract metadata from the human-readable header section."""
|
|
72
|
+
meta: Dict = {
|
|
73
|
+
"model": None,
|
|
74
|
+
"firmware": None,
|
|
75
|
+
"serial": None,
|
|
76
|
+
"n_channels": 1,
|
|
77
|
+
"n_samples": 0,
|
|
78
|
+
"n_bytes_data": 0,
|
|
79
|
+
"channel_names": {}, # from Channel[N].name= lines (raw.txt format)
|
|
80
|
+
"column_names": [], # from whitespace-separated header line (hex format)
|
|
81
|
+
"channel_units": {},
|
|
82
|
+
"channel_calibration": {},
|
|
83
|
+
"host_time": None,
|
|
84
|
+
"logger_time": None,
|
|
85
|
+
"logging_start": None,
|
|
86
|
+
"logging_end": None,
|
|
87
|
+
"sample_period_str": None,
|
|
88
|
+
"timestamps": [],
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
cal_channel: Optional[int] = None
|
|
92
|
+
cal_coeffs: List[float] = []
|
|
93
|
+
cal_units: str = ""
|
|
94
|
+
|
|
95
|
+
for raw_line in lines:
|
|
96
|
+
is_indented = raw_line and raw_line[0] in (" ", "\t")
|
|
97
|
+
line = raw_line.strip()
|
|
98
|
+
|
|
99
|
+
m = re.match(r"RBR\s+([\w\-]+)\s+([\d.]+)\s+0*(\d+)", line)
|
|
100
|
+
if m:
|
|
101
|
+
meta["model"] = m.group(1)
|
|
102
|
+
meta["firmware"] = m.group(2)
|
|
103
|
+
meta["serial"] = m.group(3)
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
m = re.match(r"Host time\s+(\S+\s+\S+)", line)
|
|
107
|
+
if m:
|
|
108
|
+
meta["host_time"] = m.group(1)
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
m = re.match(r"Logger time\s+(\S+\s+\S+)", line)
|
|
112
|
+
if m:
|
|
113
|
+
meta["logger_time"] = m.group(1)
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
m = re.match(r"Logging start\s+(\S+\s+\S+)", line)
|
|
117
|
+
if m:
|
|
118
|
+
meta["logging_start"] = m.group(1)
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
m = re.match(r"Logging end\s+(\S+\s+\S+)", line)
|
|
122
|
+
if m:
|
|
123
|
+
meta["logging_end"] = m.group(1)
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
m = re.match(r"Sample period\s+(\S+)", line)
|
|
127
|
+
if m:
|
|
128
|
+
meta["sample_period_str"] = m.group(1)
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
m = re.match(
|
|
132
|
+
r"Number of channels\s*=\s*(\d+).*number of samples\s*=\s*(\d+)", line
|
|
133
|
+
)
|
|
134
|
+
if m:
|
|
135
|
+
meta["n_channels"] = int(m.group(1))
|
|
136
|
+
meta["n_samples"] = int(m.group(2))
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
m = re.match(r"Number of bytes of data\s+(\d+)", line)
|
|
140
|
+
if m:
|
|
141
|
+
meta["n_bytes_data"] = int(m.group(1))
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
# Column header line (hex format): indented, all tokens are purely alphabetic.
|
|
145
|
+
# Appears between "Number of bytes in header" and "Number of bytes of data".
|
|
146
|
+
# e.g. " Temp "
|
|
147
|
+
if (
|
|
148
|
+
not meta["column_names"]
|
|
149
|
+
and meta["n_bytes_data"] == 0
|
|
150
|
+
and raw_line.startswith(" ")
|
|
151
|
+
and line
|
|
152
|
+
and all(tok.isalpha() for tok in line.split())
|
|
153
|
+
):
|
|
154
|
+
meta["column_names"] = line.split()
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
# Channel names and units: e.g. "Channel[1].name=Temperature"
|
|
158
|
+
m = re.match(r"Channel\[(\d+)\]\.name=(.+)", line)
|
|
159
|
+
if m:
|
|
160
|
+
meta["channel_names"][int(m.group(1))] = m.group(2).strip()
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
m = re.match(r"Channel\[(\d+)\]\.units=(.+)", line)
|
|
164
|
+
if m:
|
|
165
|
+
meta["channel_units"][int(m.group(1))] = m.group(2).strip()
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
m = re.match(r"Channel\[(\d+)\]\.calibration=(.+)", line)
|
|
169
|
+
if m:
|
|
170
|
+
cal_channel = int(m.group(1))
|
|
171
|
+
cal_coeffs = [float(x) for x in m.group(2).split()]
|
|
172
|
+
meta["channel_calibration"][cal_channel] = cal_coeffs
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
# Multi-line calibration (indented continuation — hex file format)
|
|
176
|
+
if cal_channel is not None and is_indented and line:
|
|
177
|
+
float_parts = [float(p) for p in line.split() if _is_float(p)]
|
|
178
|
+
# Non-float token at the end is the units label (e.g. "Degrees_C")
|
|
179
|
+
non_float = [p for p in line.split() if not _is_float(p)]
|
|
180
|
+
if non_float:
|
|
181
|
+
cal_units = non_float[-1].replace("_", " ")
|
|
182
|
+
cal_coeffs.extend(float_parts)
|
|
183
|
+
meta["channel_calibration"][cal_channel] = list(cal_coeffs)
|
|
184
|
+
meta["channel_units"][cal_channel] = cal_units
|
|
185
|
+
continue
|
|
186
|
+
elif not is_indented:
|
|
187
|
+
cal_channel = None
|
|
188
|
+
cal_units = ""
|
|
189
|
+
|
|
190
|
+
# In-line calibration (hex file: "Calibration 1: value" + indented lines)
|
|
191
|
+
m = re.match(r"Calibration\s+(\d+):\s+([-\d.Ee+]+)", line)
|
|
192
|
+
if m:
|
|
193
|
+
cal_channel = int(m.group(1))
|
|
194
|
+
cal_coeffs = [float(m.group(2))]
|
|
195
|
+
meta["channel_calibration"][cal_channel] = list(cal_coeffs)
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
# Timestamps / reset stamps
|
|
199
|
+
m = re.match(
|
|
200
|
+
r"(?:Timestamp|Reset stamp)\s+(\d{4}/\d{2}/\d{2}\s+\d{2}:\d{2}:\d{2})"
|
|
201
|
+
r"\s+at sample\s+(\d+)\s+of type:\s+(.+)",
|
|
202
|
+
line,
|
|
203
|
+
)
|
|
204
|
+
if m:
|
|
205
|
+
meta["timestamps"].append(
|
|
206
|
+
{
|
|
207
|
+
"time": m.group(1),
|
|
208
|
+
"sample": int(m.group(2)),
|
|
209
|
+
"type": m.group(3).strip(),
|
|
210
|
+
}
|
|
211
|
+
)
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
return meta
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _is_float(s: str) -> bool:
|
|
218
|
+
"""Return True if *s* can be parsed as a float."""
|
|
219
|
+
try:
|
|
220
|
+
float(s)
|
|
221
|
+
except ValueError:
|
|
222
|
+
return False
|
|
223
|
+
else:
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _apply_rbr_cal(raw_counts: np.ndarray, coeffs: list[float]) -> np.ndarray:
|
|
228
|
+
"""Apply RBR Steinhart-Hart calibration to raw 24-bit ADC counts.
|
|
229
|
+
|
|
230
|
+
Parameters
|
|
231
|
+
----------
|
|
232
|
+
raw_counts : np.ndarray
|
|
233
|
+
Raw 24-bit unsigned integer counts from the instrument.
|
|
234
|
+
coeffs : list of float
|
|
235
|
+
Four calibration coefficients [c0, c1, c2, c3] from the HEX header.
|
|
236
|
+
|
|
237
|
+
Returns
|
|
238
|
+
-------
|
|
239
|
+
np.ndarray
|
|
240
|
+
Temperature in degrees Celsius (float64).
|
|
241
|
+
|
|
242
|
+
Notes
|
|
243
|
+
-----
|
|
244
|
+
The RBR TR-1050 (and similar thermistor-based loggers) use a
|
|
245
|
+
Steinhart-Hart form. With x = raw / (2^24 − 1) and R = (1−x)/x::
|
|
246
|
+
|
|
247
|
+
1/T_K = c0 + c1·ln(R) + c2·ln(R)² + c3·ln(R)³
|
|
248
|
+
T(°C) = 1/T_K − 273.15
|
|
249
|
+
|
|
250
|
+
Verified against Ruskin _eng.txt output; residual error < 3 µK (rounding
|
|
251
|
+
in Ruskin's intermediate _raw.txt representation).
|
|
252
|
+
|
|
253
|
+
"""
|
|
254
|
+
if len(coeffs) != 4:
|
|
255
|
+
raise ValueError(f"Expected 4 calibration coefficients, got {len(coeffs)}") # noqa: TRY003
|
|
256
|
+
c0, c1, c2, c3 = coeffs
|
|
257
|
+
x = raw_counts.astype(np.float64) / 16777215.0
|
|
258
|
+
ln_r = np.log((1.0 - x) / x)
|
|
259
|
+
inv_tk = c0 + c1 * ln_r + c2 * ln_r**2 + c3 * ln_r**3
|
|
260
|
+
return 1.0 / inv_tk - 273.15
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ---------------------------------------------------------------------------
|
|
264
|
+
# Binary data parsing
|
|
265
|
+
# ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
_EVENT_MARKER_LEN = 12 # \x00\x00\x00 + 3-char tag + 6 BCD bytes
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _parse_binary_header(
|
|
271
|
+
raw: bytes,
|
|
272
|
+
) -> Tuple[datetime.datetime, datetime.datetime, int]:
|
|
273
|
+
"""Parse the 48-byte binary header: returns (log_start, log_end, period_seconds)."""
|
|
274
|
+
log_start = _bcd_datetime(raw[0:6])
|
|
275
|
+
log_end = _bcd_datetime(raw[6:12])
|
|
276
|
+
period_s = _bcd_timedelta(raw[12:15])
|
|
277
|
+
return log_start, log_end, period_s
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _find_events(
|
|
281
|
+
data: bytes, rec_bytes: int
|
|
282
|
+
) -> List[Tuple[int, str, datetime.datetime]]:
|
|
283
|
+
"""Scan the data section and return [(sample_index, marker, timestamp), ...].
|
|
284
|
+
|
|
285
|
+
``sample_index`` is the 0-based index of the data record immediately
|
|
286
|
+
following each event (i.e. the record that event anchors).
|
|
287
|
+
"""
|
|
288
|
+
events: List[Tuple[int, str, datetime.datetime]] = []
|
|
289
|
+
pos = 0
|
|
290
|
+
sample_count = 0
|
|
291
|
+
|
|
292
|
+
while pos < len(data):
|
|
293
|
+
# 12-byte event record: \x00\x00\x00 + 3 alpha + 6 BCD bytes
|
|
294
|
+
if (
|
|
295
|
+
pos + _EVENT_MARKER_LEN <= len(data)
|
|
296
|
+
and data[pos : pos + 3] == b"\x00\x00\x00"
|
|
297
|
+
and data[pos + 3 : pos + 6].isalpha()
|
|
298
|
+
):
|
|
299
|
+
marker = data[pos + 3 : pos + 6].decode("ascii")
|
|
300
|
+
try:
|
|
301
|
+
ts = _bcd_datetime(data[pos + 6 : pos + 12])
|
|
302
|
+
events.append((sample_count, marker, ts))
|
|
303
|
+
except (ValueError, OverflowError):
|
|
304
|
+
pass # malformed timestamp; ignore
|
|
305
|
+
pos += _EVENT_MARKER_LEN
|
|
306
|
+
elif pos + rec_bytes <= len(data):
|
|
307
|
+
sample_count += 1
|
|
308
|
+
pos += rec_bytes
|
|
309
|
+
else:
|
|
310
|
+
break # trailing partial record
|
|
311
|
+
|
|
312
|
+
return events
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _build_time_array(
|
|
316
|
+
n_samples: int,
|
|
317
|
+
period_s: int,
|
|
318
|
+
events: List[Tuple[int, str, datetime.datetime]],
|
|
319
|
+
fallback_start: datetime.datetime,
|
|
320
|
+
) -> np.ndarray:
|
|
321
|
+
"""Return a (n_samples,) datetime64[ms] array.
|
|
322
|
+
|
|
323
|
+
Each TIM event anchors its associated sample exactly; samples between
|
|
324
|
+
consecutive anchors are spaced by ``period_s``. If no TIM events are
|
|
325
|
+
found, ``fallback_start`` (logging start from the binary header) is used
|
|
326
|
+
for sample 0.
|
|
327
|
+
"""
|
|
328
|
+
times = np.empty(n_samples, dtype="datetime64[ms]")
|
|
329
|
+
period_ms = int(period_s * 1000)
|
|
330
|
+
|
|
331
|
+
# Collect only TIM anchors (exclude STP and other markers)
|
|
332
|
+
anchors = [(idx, ts) for idx, marker, ts in events if marker == "TIM"]
|
|
333
|
+
|
|
334
|
+
if not anchors:
|
|
335
|
+
# No TIM events — use fallback
|
|
336
|
+
t0 = np.datetime64(fallback_start, "ms")
|
|
337
|
+
times[:] = t0 + np.arange(n_samples, dtype="int64") * period_ms
|
|
338
|
+
return times
|
|
339
|
+
|
|
340
|
+
# Fill blocks between consecutive TIM anchors
|
|
341
|
+
for i, (anchor_idx, anchor_ts) in enumerate(anchors):
|
|
342
|
+
t0 = np.datetime64(anchor_ts, "ms")
|
|
343
|
+
next_anchor_idx = anchors[i + 1][0] if i + 1 < len(anchors) else n_samples
|
|
344
|
+
block_len = next_anchor_idx - anchor_idx
|
|
345
|
+
if block_len > 0:
|
|
346
|
+
times[anchor_idx:next_anchor_idx] = (
|
|
347
|
+
t0 + np.arange(block_len, dtype="int64") * period_ms
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
# Fill any samples before the first TIM anchor (rare, but handle it)
|
|
351
|
+
first_anchor_idx, first_anchor_ts = anchors[0]
|
|
352
|
+
if first_anchor_idx > 0:
|
|
353
|
+
t0 = np.datetime64(first_anchor_ts, "ms") - first_anchor_idx * period_ms
|
|
354
|
+
times[:first_anchor_idx] = (
|
|
355
|
+
t0 + np.arange(first_anchor_idx, dtype="int64") * period_ms
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
return times
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# ---------------------------------------------------------------------------
|
|
362
|
+
# Public API
|
|
363
|
+
# ---------------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def read_rbr_hex(file_path: str | Path) -> xr.Dataset:
|
|
367
|
+
"""Read an RBR binary-hex data file and return an xarray Dataset.
|
|
368
|
+
|
|
369
|
+
Parameters
|
|
370
|
+
----------
|
|
371
|
+
file_path : str or Path
|
|
372
|
+
Path to the ``.hex`` file.
|
|
373
|
+
|
|
374
|
+
Returns
|
|
375
|
+
-------
|
|
376
|
+
xarray.Dataset
|
|
377
|
+
- ``time`` coordinate (datetime64[ms]).
|
|
378
|
+
- ``channel_{n}`` — raw 24-bit ADC counts (uint32) for each channel.
|
|
379
|
+
- One calibrated variable per channel (e.g. ``temp``) in engineering
|
|
380
|
+
units, derived via :func:`_apply_rbr_cal` when four coefficients are
|
|
381
|
+
present in the header. Global attributes record instrument model,
|
|
382
|
+
firmware, serial number, logging window, and sample period.
|
|
383
|
+
|
|
384
|
+
"""
|
|
385
|
+
file_path = Path(file_path)
|
|
386
|
+
|
|
387
|
+
# ------------------------------------------------------------------
|
|
388
|
+
# Read file and split into text header / hex data
|
|
389
|
+
# ------------------------------------------------------------------
|
|
390
|
+
raw_bytes = file_path.read_bytes()
|
|
391
|
+
text = raw_bytes.decode("latin-1").replace("\r\n", "\n").replace("\r", "\n")
|
|
392
|
+
lines = text.split("\n")
|
|
393
|
+
|
|
394
|
+
# Text header ends just before the '|...|' progress bar line
|
|
395
|
+
data_start_line = None
|
|
396
|
+
for i, line in enumerate(lines):
|
|
397
|
+
if line.startswith("|") and line.count("|") >= 2:
|
|
398
|
+
data_start_line = i + 1
|
|
399
|
+
break
|
|
400
|
+
|
|
401
|
+
if data_start_line is None:
|
|
402
|
+
raise ValueError(f"Could not find hex data section in {file_path.name}") # noqa: TRY003
|
|
403
|
+
|
|
404
|
+
meta = _parse_text_header(lines[:data_start_line])
|
|
405
|
+
|
|
406
|
+
# Decode hex characters to bytes (strip all non-hex chars)
|
|
407
|
+
hex_str = re.sub(r"[^0-9A-Fa-f]", "", "".join(lines[data_start_line:]))
|
|
408
|
+
raw = bytes.fromhex(hex_str)
|
|
409
|
+
|
|
410
|
+
# ------------------------------------------------------------------
|
|
411
|
+
# Binary header (first 48 bytes)
|
|
412
|
+
# ------------------------------------------------------------------
|
|
413
|
+
N_HDR = 48
|
|
414
|
+
log_start, log_end, period_s = _parse_binary_header(raw[:N_HDR])
|
|
415
|
+
|
|
416
|
+
# Prefer sample period from binary header; fall back to text header
|
|
417
|
+
if period_s == 0 and meta.get("sample_period_str"):
|
|
418
|
+
h, m, s = (int(x) for x in meta["sample_period_str"].split(":"))
|
|
419
|
+
period_s = h * 3600 + m * 60 + s
|
|
420
|
+
|
|
421
|
+
# ------------------------------------------------------------------
|
|
422
|
+
# Data section
|
|
423
|
+
# ------------------------------------------------------------------
|
|
424
|
+
n_channels = meta["n_channels"]
|
|
425
|
+
n_samples = meta["n_samples"]
|
|
426
|
+
rec_bytes = 3 * n_channels
|
|
427
|
+
|
|
428
|
+
data_section = raw[N_HDR:]
|
|
429
|
+
events = _find_events(data_section, rec_bytes)
|
|
430
|
+
|
|
431
|
+
# ------------------------------------------------------------------
|
|
432
|
+
# Extract raw channel data into separate arrays
|
|
433
|
+
# ------------------------------------------------------------------
|
|
434
|
+
channel_data: List[List[int]] = [[] for _ in range(n_channels)]
|
|
435
|
+
pos = 0
|
|
436
|
+
|
|
437
|
+
while pos < len(data_section):
|
|
438
|
+
if (
|
|
439
|
+
pos + _EVENT_MARKER_LEN <= len(data_section)
|
|
440
|
+
and data_section[pos : pos + 3] == b"\x00\x00\x00"
|
|
441
|
+
and data_section[pos + 3 : pos + 6].isalpha()
|
|
442
|
+
):
|
|
443
|
+
pos += _EVENT_MARKER_LEN
|
|
444
|
+
elif pos + rec_bytes <= len(data_section):
|
|
445
|
+
for ch in range(n_channels):
|
|
446
|
+
offset = pos + ch * 3
|
|
447
|
+
val = int.from_bytes(data_section[offset : offset + 3], "big")
|
|
448
|
+
channel_data[ch].append(val)
|
|
449
|
+
pos += rec_bytes
|
|
450
|
+
else:
|
|
451
|
+
break
|
|
452
|
+
|
|
453
|
+
n_actual = len(channel_data[0])
|
|
454
|
+
if n_actual != n_samples:
|
|
455
|
+
import warnings
|
|
456
|
+
|
|
457
|
+
warnings.warn(
|
|
458
|
+
f"{file_path.name}: expected {n_samples} samples, decoded {n_actual}. "
|
|
459
|
+
"Time array built from decoded count.",
|
|
460
|
+
stacklevel=2,
|
|
461
|
+
)
|
|
462
|
+
n_samples = n_actual
|
|
463
|
+
|
|
464
|
+
# ------------------------------------------------------------------
|
|
465
|
+
# Build time coordinate
|
|
466
|
+
# ------------------------------------------------------------------
|
|
467
|
+
times = _build_time_array(n_samples, period_s, events, log_start)
|
|
468
|
+
|
|
469
|
+
# ------------------------------------------------------------------
|
|
470
|
+
# Build xarray Dataset
|
|
471
|
+
# ------------------------------------------------------------------
|
|
472
|
+
coords = {"time": times}
|
|
473
|
+
data_vars: Dict[str, xr.Variable] = {}
|
|
474
|
+
|
|
475
|
+
# Column names: prefer Channel[N].name (raw.txt format); fall back to column
|
|
476
|
+
# header line (hex format); final fallback to "Channel N".
|
|
477
|
+
col_names = meta.get("column_names", [])
|
|
478
|
+
|
|
479
|
+
for ch_idx in range(n_channels):
|
|
480
|
+
ch_num = ch_idx + 1
|
|
481
|
+
ch_name = (
|
|
482
|
+
meta["channel_names"].get(ch_num)
|
|
483
|
+
or (col_names[ch_idx] if ch_idx < len(col_names) else None)
|
|
484
|
+
or f"Channel {ch_num}"
|
|
485
|
+
)
|
|
486
|
+
ch_units = meta["channel_units"].get(ch_num, "")
|
|
487
|
+
cal = meta["channel_calibration"].get(ch_num, [])
|
|
488
|
+
raw_arr = np.array(channel_data[ch_idx], dtype=np.uint32)
|
|
489
|
+
|
|
490
|
+
# Raw counts (uint32) — always stored as channel_N
|
|
491
|
+
data_vars[f"channel_{ch_num}"] = xr.Variable(
|
|
492
|
+
"time",
|
|
493
|
+
raw_arr,
|
|
494
|
+
attrs={
|
|
495
|
+
"long_name": f"{ch_name} raw ADC count",
|
|
496
|
+
"comment": "Raw 24-bit unsigned integer from instrument.",
|
|
497
|
+
"calibration_coefficients": " ".join(str(c) for c in cal),
|
|
498
|
+
"units_after_calibration": ch_units,
|
|
499
|
+
},
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
# Calibrated engineering-units variable.
|
|
503
|
+
phys_name = re.sub(r"\W+", "_", ch_name.lower()).strip("_")
|
|
504
|
+
if phys_name == f"channel_{ch_num}":
|
|
505
|
+
phys_name = f"channel_{ch_num}_phys" # avoid overwriting raw variable
|
|
506
|
+
if len(cal) == 4:
|
|
507
|
+
phys_data = _apply_rbr_cal(raw_arr, cal)
|
|
508
|
+
cal_method = (
|
|
509
|
+
"Steinhart-Hart: x=raw/16777215, R=(1-x)/x, "
|
|
510
|
+
"1/T_K=c0+c1*ln(R)+c2*ln(R)^2+c3*ln(R)^3, T=1/T_K-273.15"
|
|
511
|
+
)
|
|
512
|
+
cal_units = "degC"
|
|
513
|
+
else:
|
|
514
|
+
phys_data = raw_arr.astype(np.float64) / 16777215.0
|
|
515
|
+
cal_method = "raw / 16777215 (calibration not applied: unexpected number of coefficients)"
|
|
516
|
+
cal_units = ch_units
|
|
517
|
+
data_vars[phys_name] = xr.Variable(
|
|
518
|
+
"time",
|
|
519
|
+
phys_data,
|
|
520
|
+
attrs={
|
|
521
|
+
"long_name": ch_name,
|
|
522
|
+
"units": cal_units,
|
|
523
|
+
"calibration_method": cal_method,
|
|
524
|
+
"calibration_coefficients": " ".join(str(c) for c in cal),
|
|
525
|
+
},
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
global_attrs: Dict = {
|
|
529
|
+
"instrument_model": meta.get("model", "UNK"),
|
|
530
|
+
"instrument_firmware": meta.get("firmware", "UNK"),
|
|
531
|
+
"serial_number": meta.get("serial", "UNK"),
|
|
532
|
+
"logging_start": str(log_start),
|
|
533
|
+
"logging_end": str(log_end),
|
|
534
|
+
"sample_period_seconds": period_s,
|
|
535
|
+
"n_channels": n_channels,
|
|
536
|
+
"source_file": file_path.name,
|
|
537
|
+
"reader": "oceanarray.read_rbr_hex",
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
ds = xr.Dataset(data_vars, coords=coords, attrs=global_attrs)
|
|
541
|
+
return ds
|