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.
Files changed (76) hide show
  1. oceanarray/__init__.py +20 -0
  2. oceanarray/_version.py +1 -0
  3. oceanarray/cli.py +1343 -0
  4. oceanarray/clock_offset.py +675 -0
  5. oceanarray/config/OS1_sensor_attrs.yaml +49 -0
  6. oceanarray/config/OS1_var_names.yaml +5 -0
  7. oceanarray/config/OS1_vocab_attrs.yaml +84 -0
  8. oceanarray/config/legacy/project_RAPID.yaml +69 -0
  9. oceanarray/config/legacy/rodb_keys.txt +206 -0
  10. oceanarray/config/legacy/rodb_keys.yaml +1012 -0
  11. oceanarray/config/logging.yaml +28 -0
  12. oceanarray/legacy/__init__.py +47 -0
  13. oceanarray/legacy/caldip.py +753 -0
  14. oceanarray/legacy/convertOS.py +527 -0
  15. oceanarray/legacy/find_deployment.py +489 -0
  16. oceanarray/legacy/mooring_rodb.py +429 -0
  17. oceanarray/legacy/process_rodb.py +369 -0
  18. oceanarray/legacy/rodb.py +330 -0
  19. oceanarray/logger.py +192 -0
  20. oceanarray/logsheet/__init__.py +60 -0
  21. oceanarray/logsheet/_columns.py +97 -0
  22. oceanarray/logsheet/_config.py +245 -0
  23. oceanarray/logsheet/_firmware.py +43 -0
  24. oceanarray/logsheet/_latex.py +128 -0
  25. oceanarray/logsheet/_render.py +126 -0
  26. oceanarray/logsheet/builders/__init__.py +13 -0
  27. oceanarray/logsheet/builders/caldip_download.py +195 -0
  28. oceanarray/logsheet/builders/caldip_setup.py +188 -0
  29. oceanarray/logsheet/builders/deployment_setup.py +218 -0
  30. oceanarray/logsheet/builders/mooring_download.py +243 -0
  31. oceanarray/logsheet/builders/recovery.py +157 -0
  32. oceanarray/logsheet/data/column_defs.yaml +686 -0
  33. oceanarray/logsheet/data/latex_templates/_base_instrument.tex.j2 +42 -0
  34. oceanarray/logsheet/data/latex_templates/caldip_download.tex.j2 +47 -0
  35. oceanarray/logsheet/data/latex_templates/caldip_setup.tex.j2 +70 -0
  36. oceanarray/logsheet/data/latex_templates/deployment_setup.tex.j2 +97 -0
  37. oceanarray/logsheet/data/latex_templates/mooring_download.tex.j2 +54 -0
  38. oceanarray/logsheet/data/latex_templates/recovery.tex.j2 +103 -0
  39. oceanarray/mooring_level.py +1368 -0
  40. oceanarray/oceanarray.mplstyle +18 -0
  41. oceanarray/parameters.py +270 -0
  42. oceanarray/plotter.py +942 -0
  43. oceanarray/plotters/__init__.py +97 -0
  44. oceanarray/plotters/_animation.py +261 -0
  45. oceanarray/plotters/_current.py +705 -0
  46. oceanarray/plotters/_diagnostic.py +667 -0
  47. oceanarray/plotters/_helpers.py +57 -0
  48. oceanarray/plotters/_primitives.py +101 -0
  49. oceanarray/plotters/_section.py +16 -0
  50. oceanarray/plotters/_timeseries.py +11 -0
  51. oceanarray/rapid_interp.py +739 -0
  52. oceanarray/read_rbr_hex.py +541 -0
  53. oceanarray/readers.py +303 -0
  54. oceanarray/report/__init__.py +5 -0
  55. oceanarray/report/_array.py +386 -0
  56. oceanarray/report/_grid.py +539 -0
  57. oceanarray/report/_html_helpers.py +1004 -0
  58. oceanarray/report/_instrument.py +853 -0
  59. oceanarray/report/_mooring.py +1385 -0
  60. oceanarray/report/_plots.py +4284 -0
  61. oceanarray/report/_stack.py +941 -0
  62. oceanarray/stage1.py +1959 -0
  63. oceanarray/stage2.py +1160 -0
  64. oceanarray/stage3.py +2494 -0
  65. oceanarray/time_gridding.py +958 -0
  66. oceanarray/tools.py +400 -0
  67. oceanarray/transports.py +1 -0
  68. oceanarray/utilities.py +538 -0
  69. oceanarray/validation.py +389 -0
  70. oceanarray/writers.py +99 -0
  71. oceanarray-0.1.0.dist-info/METADATA +503 -0
  72. oceanarray-0.1.0.dist-info/RECORD +76 -0
  73. oceanarray-0.1.0.dist-info/WHEEL +5 -0
  74. oceanarray-0.1.0.dist-info/entry_points.txt +2 -0
  75. oceanarray-0.1.0.dist-info/licenses/LICENSE +21 -0
  76. 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