bottomtime 0.3.0__tar.gz → 0.4.0__tar.gz

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 (29) hide show
  1. {bottomtime-0.3.0 → bottomtime-0.4.0}/.gitignore +0 -1
  2. {bottomtime-0.3.0 → bottomtime-0.4.0}/PKG-INFO +2 -1
  3. {bottomtime-0.3.0 → bottomtime-0.4.0}/pyproject.toml +7 -2
  4. {bottomtime-0.3.0 → bottomtime-0.4.0}/src/bottomtime/__init__.py +1 -1
  5. {bottomtime-0.3.0 → bottomtime-0.4.0}/src/bottomtime/api.py +1 -1
  6. {bottomtime-0.3.0 → bottomtime-0.4.0}/src/bottomtime/ingest/shearwater_db.py +14 -6
  7. bottomtime-0.4.0/tests/test_decoder_dependency.py +40 -0
  8. bottomtime-0.3.0/.github/workflows/test.yml +0 -20
  9. bottomtime-0.3.0/src/bottomtime/decode/pnf.py +0 -503
  10. bottomtime-0.3.0/src/bottomtime/verify/__init__.py +0 -1
  11. bottomtime-0.3.0/tests/test_pnf.py +0 -188
  12. {bottomtime-0.3.0 → bottomtime-0.4.0}/LICENSE +0 -0
  13. {bottomtime-0.3.0 → bottomtime-0.4.0}/README.md +0 -0
  14. {bottomtime-0.3.0 → bottomtime-0.4.0}/src/bottomtime/cli.py +0 -0
  15. {bottomtime-0.3.0/src/bottomtime/decode → bottomtime-0.4.0/src/bottomtime/ingest}/__init__.py +0 -0
  16. {bottomtime-0.3.0 → bottomtime-0.4.0}/src/bottomtime/ingest/archive.py +0 -0
  17. {bottomtime-0.3.0 → bottomtime-0.4.0}/src/bottomtime/ingest/garmin_fit.py +0 -0
  18. {bottomtime-0.3.0 → bottomtime-0.4.0}/src/bottomtime/ingest/shearwater_xml.py +0 -0
  19. {bottomtime-0.3.0/src/bottomtime/ingest → bottomtime-0.4.0/src/bottomtime/match}/__init__.py +0 -0
  20. {bottomtime-0.3.0 → bottomtime-0.4.0}/src/bottomtime/match/correlate.py +0 -0
  21. {bottomtime-0.3.0 → bottomtime-0.4.0}/src/bottomtime/match/matcher.py +0 -0
  22. {bottomtime-0.3.0 → bottomtime-0.4.0}/src/bottomtime/plot.py +0 -0
  23. {bottomtime-0.3.0/src/bottomtime/match → bottomtime-0.4.0/src/bottomtime/store}/__init__.py +0 -0
  24. {bottomtime-0.3.0 → bottomtime-0.4.0}/src/bottomtime/store/db.py +0 -0
  25. {bottomtime-0.3.0 → bottomtime-0.4.0}/src/bottomtime/store/schema.py +0 -0
  26. {bottomtime-0.3.0/src/bottomtime/store → bottomtime-0.4.0/src/bottomtime/verify}/__init__.py +0 -0
  27. {bottomtime-0.3.0 → bottomtime-0.4.0}/src/bottomtime/verify/crosscheck.py +0 -0
  28. {bottomtime-0.3.0 → bottomtime-0.4.0}/tests/test_cli.py +0 -0
  29. {bottomtime-0.3.0 → bottomtime-0.4.0}/tests/test_correlate.py +0 -0
@@ -4,7 +4,6 @@ __pycache__/
4
4
  dist/
5
5
  .pytest_cache/
6
6
  .mypy_cache/
7
- .claude/settings.local.json
8
7
 
9
8
  # Personal dive data lives outside version control
10
9
  data/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bottomtime
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Lossless dive-log store: ingest, decode, and reconcile dives from Garmin (FIT) and Shearwater (Petrel Native Format) dive computers
5
5
  Project-URL: Homepage, https://github.com/snehankekre/bottomtime
6
6
  Project-URL: Issues, https://github.com/snehankekre/bottomtime/issues
@@ -14,6 +14,7 @@ Classifier: Programming Language :: Python :: 3
14
14
  Classifier: Topic :: Scientific/Engineering
15
15
  Requires-Python: >=3.10
16
16
  Requires-Dist: garmin-fit-sdk>=21.0
17
+ Requires-Dist: pnf>=0.1.0
17
18
  Provides-Extra: dev
18
19
  Requires-Dist: pytest; extra == 'dev'
19
20
  Provides-Extra: plot
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "bottomtime"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Lossless dive-log store: ingest, decode, and reconcile dives from Garmin (FIT) and Shearwater (Petrel Native Format) dive computers"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -18,7 +18,7 @@ classifiers = [
18
18
  "Programming Language :: Python :: 3",
19
19
  "Topic :: Scientific/Engineering",
20
20
  ]
21
- dependencies = ["garmin-fit-sdk>=21.0"]
21
+ dependencies = ["garmin-fit-sdk>=21.0", "pnf>=0.1.0"]
22
22
 
23
23
  [project.optional-dependencies]
24
24
  plot = ["matplotlib>=3.5"]
@@ -33,3 +33,8 @@ Issues = "https://github.com/snehankekre/bottomtime/issues"
33
33
 
34
34
  [tool.hatch.build.targets.wheel]
35
35
  packages = ["src/bottomtime"]
36
+
37
+ [tool.hatch.build.targets.sdist]
38
+ # Positive allowlist: the sdist ships only the package, its tests, and the
39
+ # standard metadata files, so local tooling and CI config stay out of releases.
40
+ include = ["/src", "/tests", "/README.md", "/LICENSE"]
@@ -1,6 +1,6 @@
1
1
  """bottomtime: lossless dive-log store for multi-computer divers."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
4
4
 
5
5
  from .api import list_dives, load_dive
6
6
 
@@ -11,7 +11,7 @@ import json
11
11
  import sqlite3
12
12
  from pathlib import Path
13
13
 
14
- from .decode.pnf import COMPUTER_MODELS
14
+ from pnf import COMPUTER_MODELS
15
15
 
16
16
  _SAMPLE_TABLES = {"garmin": "garmin_samples", "shearwater": "shearwater_samples"}
17
17
 
@@ -1,10 +1,10 @@
1
1
  """Ingest a Shearwater Cloud database (dive_data.db / 'Export Database').
2
2
 
3
3
  Each dive's complete native log lives in log_data.data_bytes_1 as an sw-pnf
4
- blob (decoded by bottomtime.decode.pnf); dive_details supplies the metadata
5
- layer (gas plan JSON, EndGF99, site/notes when present). The blob is stored
6
- verbatim in source_dives.raw_blob in addition to the archived .db, so every
7
- Shearwater byte is preserved twice over.
4
+ blob (decoded by the pnf package); dive_details supplies the metadata layer
5
+ (gas plan JSON, EndGF99, site/notes when present). The blob is stored verbatim
6
+ in source_dives.raw_blob in addition to the archived .db, so every Shearwater
7
+ byte is preserved twice over.
8
8
  """
9
9
 
10
10
  from __future__ import annotations
@@ -15,10 +15,18 @@ import sqlite3
15
15
  from datetime import datetime, timezone
16
16
  from pathlib import Path
17
17
 
18
- from ..decode import pnf
18
+ import pnf
19
+
19
20
  from ..store.db import source_dive_exists
20
21
  from .archive import archive_file
21
22
 
23
+ # Decode-contract version stamped on each Shearwater source dive so that an
24
+ # archive-first re-decode can find dives produced by an older decode. Bump this
25
+ # whenever a pnf upgrade changes decoded output. History: 1 libdivecomputer
26
+ # field set, 2 empirical ceiling/gf99/battery/@+5, 3 pnf reconciliation
27
+ # (DCIEM branch, 16-bit battery, battery %, SAC, status bits).
28
+ DECODER_VERSION = 3
29
+
22
30
 
23
31
  def _details_row(con: sqlite3.Connection, dive_id: str) -> dict:
24
32
  row = con.execute(
@@ -123,7 +131,7 @@ def _ingest_log(
123
131
  artifact_id,
124
132
  source_key,
125
133
  digest,
126
- pnf.DECODER_VERSION,
134
+ DECODER_VERSION,
127
135
  start_local,
128
136
  dive.header.get("divetime_s"),
129
137
  dive.header.get("max_depth_m"),
@@ -0,0 +1,40 @@
1
+ """Contract test for the pnf decoder dependency.
2
+
3
+ The PNF decoder lives in the standalone `pnf` package (with its own full test
4
+ suite). bottomtime depends on it and relies on a specific surface: the
5
+ `decode` entry point, the `COMPUTER_MODELS` table, and a set of sample fields
6
+ the Shearwater ingest inserts into `shearwater_samples`. This test pins that
7
+ contract so a breaking pnf upgrade fails here rather than at ingest time.
8
+ """
9
+
10
+ import pnf
11
+
12
+
13
+ def test_decode_entry_points_exist():
14
+ assert callable(pnf.decode)
15
+ assert callable(pnf.decompress)
16
+ assert 11 in pnf.COMPUTER_MODELS # Perdix 2, surfaced by api.computer_info
17
+
18
+
19
+ def test_sample_has_fields_ingest_inserts():
20
+ # Every column bottomtime writes to shearwater_samples from a decoded
21
+ # sample (see ingest/shearwater_db.py).
22
+ fields = pnf.Sample.__dataclass_fields__
23
+ for name in (
24
+ "t_s", "depth_m", "temp_c", "stop_depth_m", "stop_or_ndl_min", "tts_min",
25
+ "in_deco", "avg_ppo2", "o2_pct", "he_pct", "setpoint", "cns_pct",
26
+ "gf99", "ceiling_m", "safe_ascent_depth_m", "battery_pct", "sac",
27
+ "sensor1_raw", "sensor2_raw", "sensor3_raw",
28
+ "sensor1_ppo2", "sensor2_ppo2", "sensor3_ppo2",
29
+ "battery_v", "status_flags", "solenoid_fired_count",
30
+ "tank0_psi", "tank1_psi", "gas_time_min", "extra",
31
+ ):
32
+ assert name in fields, f"pnf.Sample lost field {name!r}"
33
+
34
+
35
+ def test_dive_shape_ingest_reads():
36
+ for attr in ("header", "samples", "gases", "events", "unknown_records",
37
+ "sample_interval_ms"):
38
+ assert attr in pnf.Dive.__dataclass_fields__, f"pnf.Dive lost {attr!r}"
39
+ for attr in ("index", "o2_pct", "he_pct", "diluent", "enabled"):
40
+ assert attr in pnf.Gas.__dataclass_fields__, f"pnf.Gas lost {attr!r}"
@@ -1,20 +0,0 @@
1
- name: test
2
-
3
- on:
4
- push:
5
- branches: [main]
6
- pull_request:
7
-
8
- jobs:
9
- test:
10
- runs-on: ubuntu-latest
11
- strategy:
12
- matrix:
13
- python-version: ["3.10", "3.14"]
14
- steps:
15
- - uses: actions/checkout@v4
16
- - uses: actions/setup-python@v5
17
- with:
18
- python-version: ${{ matrix.python-version }}
19
- - run: pip install -e .[dev]
20
- - run: pytest -q
@@ -1,503 +0,0 @@
1
- """Pure-Python decoder for Shearwater's Petrel Native Format (PNF).
2
-
3
- Shearwater Cloud stores each dive as a `sw-pnf` blob: a 4-byte little-endian
4
- uncompressed-length prefix followed by a gzip stream. The decompressed payload
5
- is a sequence of 32-byte records; byte 0 of each record is its type:
6
-
7
- 0x01 dive sample 0x10-0x19 opening (config) records
8
- 0x02 freedive sample (4x8) 0x20-0x29 closing records
9
- 0x03 Avelo sample 0x30 info event
10
- 0xE1 extended dive sample 0xFF final record
11
-
12
- Byte offsets follow libdivecomputer's shearwater_predator_parser.c: sample
13
- fields sit at legacy-offset + 1 (the PNF type byte), opening/closing record
14
- fields at the absolute offsets used for their legacy 128-byte block.
15
-
16
- Record types this decoder does not understand are returned raw in
17
- `PnfDive.unknown_records`; nothing is dropped.
18
-
19
- DECODER_VERSION history:
20
- 1 initial port of the libdivecomputer field set
21
- 2 adds ceiling_m (byte 24), gf99 (byte 25), battery_v (byte 18 / 100) and
22
- @+5 TTS (bytes 26-27), identified empirically: byte 25 converges on
23
- dive_details.EndGF99 and decays during surface offgassing; byte 24 sits
24
- just below the 3 m-quantized stop depth and zeroes when deco clears;
25
- byte 18 equals the XML export's batteryVoltage x 100
26
- 3 reconciled against Shearwater Cloud's own parser (DiveLogParser.dll,
27
- class PetrelNativeLogV14Parser, read from the desktop app). Every sample
28
- byte 0-31 is now named. Changes vs v2:
29
- - bytes 24/25 are ceiling/gf99 only under gradient-factor deco models;
30
- under DCIEM (deco model 3) they are a safe-ascent depth instead
31
- (byte 24 integer metres, byte 25 hundredths), decoded to
32
- safe_ascent_depth_m with ceiling_m/gf99 left None.
33
- - battery_v is a 16-bit value: (byte 17 << 8 | byte 18) / 100, not byte 18
34
- alone (byte 17 is zero only because a ~1.5 V cell never needs it; a
35
- higher-voltage computer such as a Teric would overflow the old read).
36
- - byte 11 is battery_pct (BATTERY_PERCENT_REMAINING).
37
- - bytes 30-31 are sac (surface air consumption, u16 / 100; 0xFFFF = n/a).
38
- """
39
-
40
- from __future__ import annotations
41
-
42
- import gzip
43
- import struct
44
- from dataclasses import dataclass, field
45
-
46
- DECODER_VERSION = 3
47
-
48
- RECORD_SIZE = 32
49
-
50
- DIVE_SAMPLE = 0x01
51
- FREEDIVE_SAMPLE = 0x02
52
- AVELO_SAMPLE = 0x03
53
- OPENING_0, OPENING_9 = 0x10, 0x19
54
- CLOSING_0, CLOSING_9 = 0x20, 0x29
55
- INFO_EVENT = 0x30
56
- DIVE_SAMPLE_EXT = 0xE1
57
- FINAL = 0xFF
58
-
59
- # Sample byte 12 is a packed status/mode byte. Bit meanings per Shearwater's
60
- # PetrelNativeLogV14Parser: 0 gas-switch-needed, 1 external ppO2, 2 setpoint
61
- # type (high/low), 3 circuit-switch type, 4 circuit mode (set = OC on this
62
- # family), 5 CCR mode, 6-7 solenoid-fired count.
63
- FLAG_GASSWITCH = 0x01
64
- FLAG_PPO2_EXTERNAL = 0x02
65
- FLAG_SETPOINT_HIGH = 0x04
66
- FLAG_SC = 0x08
67
- FLAG_OC = 0x10
68
- FLAG_CCR_MODE = 0x20
69
- SOLENOID_COUNT_SHIFT = 6
70
-
71
- DIVE_MODES = {
72
- 0: "cc",
73
- 1: "oc_tec",
74
- 2: "gauge",
75
- 3: "ppo2",
76
- 4: "sc",
77
- 5: "cc2",
78
- 6: "oc_rec",
79
- 7: "freedive",
80
- 12: "avelo",
81
- }
82
-
83
- DECO_MODELS = {0: "gf", 1: "vpmb", 2: "vpmb_gfs", 3: "dciem"}
84
-
85
- # Final-record byte 13 model ids, names per libdivecomputer's descriptors.
86
- # The PNF layout is shared across the family; bottomtime's field mappings are
87
- # validated so far on Petrel 3 (10) and Perdix 2 (11) logs.
88
- COMPUTER_MODELS = {
89
- 2: "Predator",
90
- 3: "Petrel",
91
- 4: "Nerd",
92
- 5: "Perdix",
93
- 6: "Perdix AI",
94
- 7: "Nerd 2",
95
- 8: "Teric",
96
- 9: "Peregrine",
97
- 10: "Petrel 3",
98
- 11: "Perdix 2",
99
- 12: "Tern",
100
- }
101
-
102
- # As of v3 every sample byte 0-31 is named (reconciled against Shearwater's
103
- # own PetrelNativeLogV14Parser), so no bytes are stashed raw in extra_json.
104
- SAC_SENTINEL = 0xFFFF # bytes 30-31 when no air integration / no SAC
105
-
106
-
107
- def _u16be(data: bytes, off: int) -> int:
108
- return struct.unpack_from(">H", data, off)[0]
109
-
110
-
111
- def _u24be(data: bytes, off: int) -> int:
112
- return (data[off] << 16) | (data[off + 1] << 8) | data[off + 2]
113
-
114
-
115
- def _u32be(data: bytes, off: int) -> int:
116
- return struct.unpack_from(">I", data, off)[0]
117
-
118
-
119
- def _bcd2dec(value: int) -> int:
120
- return ((value >> 4) & 0x0F) * 10 + (value & 0x0F)
121
-
122
-
123
- def _bcd_bytes(data: bytes, off: int, n: int) -> int:
124
- result = 0
125
- for i in range(n):
126
- result = result * 100 + _bcd2dec(data[off + i])
127
- return result
128
-
129
-
130
- @dataclass
131
- class PnfSample:
132
- t_s: float
133
- depth_m: float | None = None
134
- temp_c: float | None = None
135
- stop_depth_m: float | None = None
136
- stop_or_ndl_min: float | None = None
137
- tts_min: float | None = None
138
- in_deco: int | None = None
139
- avg_ppo2: float | None = None
140
- o2_pct: float | None = None
141
- he_pct: float | None = None
142
- setpoint: float | None = None
143
- cns_pct: float | None = None
144
- gf99: float | None = None
145
- ceiling_m: float | None = None
146
- safe_ascent_depth_m: float | None = None # DCIEM only (bytes 24-25)
147
- battery_pct: int | None = None
148
- sac: float | None = None
149
- sensor1_raw: int | None = None
150
- sensor2_raw: int | None = None
151
- sensor3_raw: int | None = None
152
- sensor1_ppo2: float | None = None
153
- sensor2_ppo2: float | None = None
154
- sensor3_ppo2: float | None = None
155
- battery_v: float | None = None
156
- status_flags: int | None = None
157
- gas_switch_needed: bool | None = None
158
- ppo2_external: bool | None = None
159
- setpoint_high: bool | None = None
160
- circuit_switch: bool | None = None
161
- circuit_mode: int | None = None
162
- ccr_mode: int | None = None
163
- solenoid_fired_count: int | None = None
164
- tank0_psi: float | None = None
165
- tank1_psi: float | None = None
166
- gas_time_min: float | None = None
167
- extra: dict = field(default_factory=dict)
168
-
169
-
170
- @dataclass
171
- class PnfGas:
172
- index: int
173
- o2_pct: int
174
- he_pct: int
175
- diluent: bool
176
- enabled: bool
177
-
178
-
179
- @dataclass
180
- class PnfEvent:
181
- t_s: float
182
- event: int
183
- timestamp: int
184
- w1: int
185
- w2: int
186
- raw_hex: str
187
-
188
-
189
- @dataclass
190
- class PnfDive:
191
- header: dict
192
- samples: list[PnfSample]
193
- gases: list[PnfGas]
194
- events: list[PnfEvent]
195
- unknown_records: list[tuple[str, int, bytes]] # (type_key, seq, raw)
196
- sample_interval_ms: int
197
-
198
-
199
- def decompress(blob: bytes) -> bytes:
200
- """Strip the 4-byte LE length prefix and gunzip; verify the length."""
201
- expected = struct.unpack_from("<I", blob, 0)[0]
202
- data = gzip.decompress(blob[4:])
203
- if len(data) != expected:
204
- raise ValueError(f"PNF length mismatch: prefix {expected}, got {len(data)}")
205
- if len(data) % RECORD_SIZE:
206
- raise ValueError(f"PNF payload not a multiple of {RECORD_SIZE} bytes")
207
- return data
208
-
209
-
210
- def decode(blob: bytes) -> PnfDive:
211
- """Decode a raw sw-pnf blob (as stored in Shearwater Cloud's log_data)."""
212
- data = decompress(blob)
213
-
214
- opening: dict[int, int] = {}
215
- closing: dict[int, int] = {}
216
- final_off: int | None = None
217
- raw_records: list[tuple[int, int]] = [] # (type, offset), stream order
218
-
219
- for off in range(0, len(data), RECORD_SIZE):
220
- rec = data[off : off + RECORD_SIZE]
221
- if rec == b"\x00" * RECORD_SIZE:
222
- continue
223
- rtype = rec[0]
224
- raw_records.append((rtype, off))
225
- if OPENING_0 <= rtype <= OPENING_9:
226
- opening[rtype - OPENING_0] = off
227
- elif CLOSING_0 <= rtype <= CLOSING_9:
228
- closing[rtype - CLOSING_0] = off
229
- elif rtype == FINAL:
230
- final_off = off
231
-
232
- for i in range(5):
233
- if i not in opening or i not in closing:
234
- raise ValueError(f"missing opening/closing record {i}")
235
-
236
- header = _parse_header(data, opening, closing, final_off)
237
- interval_ms = header["sample_interval_ms"]
238
- calibrated = header["sensor_calibrated_mask"]
239
- calibration = header["sensor_calibration"]
240
- imperial = header["imperial"]
241
-
242
- gases = [
243
- PnfGas(
244
- index=i,
245
- o2_pct=header["gas_o2"][i],
246
- he_pct=header["gas_he"][i],
247
- diluent=i >= 5,
248
- enabled=bool(header["gas_enabled_mask"] & (1 << i)),
249
- )
250
- for i in range(10)
251
- ]
252
-
253
- samples: list[PnfSample] = []
254
- events: list[PnfEvent] = []
255
- unknown: list[tuple[str, int, bytes]] = []
256
- seq = 0
257
- t = 0.0
258
-
259
- is_dciem = header["deco_model"] == "dciem"
260
-
261
- for rtype, off in raw_records:
262
- rec = data[off : off + RECORD_SIZE]
263
- if rtype in (DIVE_SAMPLE, AVELO_SAMPLE):
264
- samples.append(
265
- _parse_sample(
266
- rec, t, imperial, calibrated, calibration, rtype, is_dciem
267
- )
268
- )
269
- t += interval_ms / 1000.0
270
- elif rtype == FREEDIVE_SAMPLE:
271
- for i in range(4):
272
- sub = rec[i * 8 : (i + 1) * 8]
273
- if sub == b"\x00" * 8:
274
- break
275
- pressure_mbar = _u16be(sub, 1)
276
- depth = (
277
- (pressure_mbar - header["atmospheric_mbar"])
278
- * 100.0
279
- / (header["water_density"] * 9.80665)
280
- )
281
- temp = struct.unpack_from(">h", sub, 3)[0] / 10.0
282
- samples.append(PnfSample(t_s=t, depth_m=round(depth, 2), temp_c=temp))
283
- t += interval_ms / 1000.0
284
- elif rtype == DIVE_SAMPLE_EXT:
285
- s = samples[-1] if samples else None
286
- for i, key in enumerate(("tank2_psi", "tank3_psi")):
287
- v = _u16be(rec, 1 + i * 2)
288
- if v < 0xFFF0 and (v & 0x0FFF) and s is not None:
289
- s.extra[key] = (v & 0x0FFF) * 2
290
- for i, key in enumerate(("tank_dil_psi", "tank_o2_psi")):
291
- v = _u16be(rec, 1 + 4 + i * 2)
292
- if v and s is not None:
293
- s.extra[key] = v * 2
294
- elif rtype == INFO_EVENT:
295
- events.append(
296
- PnfEvent(
297
- t_s=t,
298
- event=rec[1],
299
- timestamp=_u32be(rec, 4),
300
- w1=_u32be(rec, 8),
301
- w2=_u32be(rec, 12),
302
- raw_hex=rec.hex(),
303
- )
304
- )
305
- elif OPENING_0 <= rtype <= OPENING_9 or CLOSING_0 <= rtype <= CLOSING_9 or rtype == FINAL:
306
- pass # consumed by _parse_header; raw copies kept in header dict
307
- else:
308
- unknown.append((f"0x{rtype:02x}", seq, rec))
309
- seq += 1
310
-
311
- return PnfDive(
312
- header=header,
313
- samples=samples,
314
- gases=gases,
315
- events=events,
316
- unknown_records=unknown,
317
- sample_interval_ms=interval_ms,
318
- )
319
-
320
-
321
- def _parse_sample(
322
- rec: bytes,
323
- t: float,
324
- imperial: bool,
325
- calibrated: int,
326
- calibration: list[float],
327
- rtype: int,
328
- is_dciem: bool,
329
- ) -> PnfSample:
330
- ft = 0.3048
331
- depth = _u16be(rec, 1) / 10.0
332
- stop_depth = float(_u16be(rec, 3))
333
- if imperial:
334
- depth *= ft
335
- stop_depth *= ft
336
-
337
- temp = struct.unpack_from("b", rec, 14)[0]
338
- if temp < 0:
339
- temp = min(temp + 102, 0)
340
- if imperial:
341
- temp = (temp - 32.0) * 5.0 / 9.0
342
-
343
- status = rec[12] if rtype != AVELO_SAMPLE else 0
344
- ccr = (status & FLAG_OC) == 0 if rtype != AVELO_SAMPLE else False
345
- decoded_status = rtype != AVELO_SAMPLE
346
-
347
- sample = PnfSample(
348
- t_s=t,
349
- depth_m=round(depth, 2),
350
- temp_c=float(temp),
351
- stop_depth_m=stop_depth,
352
- tts_min=float(_u16be(rec, 5)),
353
- stop_or_ndl_min=float(rec[10]),
354
- in_deco=1 if _u16be(rec, 3) else 0,
355
- avg_ppo2=rec[7] / 100.0,
356
- o2_pct=float(rec[8]),
357
- he_pct=float(rec[9]),
358
- status_flags=status,
359
- sensor1_raw=rec[13],
360
- sensor2_raw=rec[15],
361
- sensor3_raw=rec[16],
362
- cns_pct=float(rec[23]),
363
- battery_pct=rec[11],
364
- battery_v=((rec[17] << 8) | rec[18]) / 100.0,
365
- )
366
-
367
- if decoded_status:
368
- sample.gas_switch_needed = bool(status & FLAG_GASSWITCH)
369
- sample.ppo2_external = bool(status & FLAG_PPO2_EXTERNAL)
370
- sample.setpoint_high = bool(status & FLAG_SETPOINT_HIGH)
371
- sample.circuit_switch = bool(status & FLAG_SC)
372
- sample.circuit_mode = (status >> 4) & 1
373
- sample.ccr_mode = (status >> 5) & 1
374
- sample.solenoid_fired_count = (status >> SOLENOID_COUNT_SHIFT) & 3
375
-
376
- # Bytes 24-25 are ceiling + GF99 under gradient-factor deco models. Under
377
- # DCIEM (deco model 3) the same two bytes are a safe-ascent depth: byte 24
378
- # whole metres, byte 25 hundredths, subtracted.
379
- if is_dciem:
380
- sample.safe_ascent_depth_m = rec[24] - rec[25] / 100.0
381
- else:
382
- if rec[24] != 0xFF:
383
- sample.ceiling_m = float(rec[24])
384
- if rec[25] != 0xFF: # 0xFF = not available (still on-gassing)
385
- sample.gf99 = float(rec[25])
386
-
387
- sac_raw = _u16be(rec, 30)
388
- if sac_raw != SAC_SENTINEL:
389
- sample.sac = sac_raw / 100.0
390
-
391
- if ccr:
392
- sample.setpoint = rec[19] / 100.0
393
- if not (status & FLAG_PPO2_EXTERNAL):
394
- if calibrated & 0x01:
395
- sample.sensor1_ppo2 = round(rec[13] * calibration[0], 3)
396
- if calibrated & 0x02:
397
- sample.sensor2_ppo2 = round(rec[15] * calibration[1], 3)
398
- if calibrated & 0x04:
399
- sample.sensor3_ppo2 = round(rec[16] * calibration[2], 3)
400
-
401
- for idx, key in ((20, "tank1_psi"), (28, "tank0_psi")):
402
- v = _u16be(rec, idx)
403
- if v < 0xFFF0:
404
- pressure = v & 0x0FFF
405
- battery = (v >> 12) & 0x0F
406
- if key == "tank1_psi":
407
- sample.tank1_psi = pressure * 2.0
408
- else:
409
- sample.tank0_psi = pressure * 2.0
410
- if battery:
411
- sample.extra[key + "_battery"] = battery
412
-
413
- if rec[22] < 0xF0:
414
- sample.gas_time_min = float(rec[22])
415
-
416
- # @+5: TTS if the diver stayed 5 more minutes (minutes, u16be)
417
- at_plus_five = _u16be(rec, 26)
418
- if at_plus_five != 0xFFFF:
419
- sample.extra["at_plus_five_min"] = at_plus_five
420
-
421
- return sample
422
-
423
-
424
- def _parse_header(
425
- data: bytes,
426
- opening: dict[int, int],
427
- closing: dict[int, int],
428
- final_off: int | None,
429
- ) -> dict:
430
- o = opening
431
- c = closing
432
-
433
- logversion = data[o[4] + 16]
434
- imperial = data[o[0] + 8] == 1
435
-
436
- gas_o2 = [data[o[0] + 20 + i] for i in range(10)]
437
- gas_he = [data[o[0] + 30 + i] for i in range(2)] + [
438
- data[o[1] + 1 + i] for i in range(8)
439
- ]
440
-
441
- calib_base = o[3] + 6
442
- calibrated_mask = data[calib_base]
443
- calibration = [
444
- _u16be(data, calib_base + 1 + i * 2) / 100000.0 for i in range(3)
445
- ]
446
-
447
- interval_ms = 10000
448
- if logversion >= 9 and 5 in o:
449
- interval_ms = _u16be(data, o[5] + 23) or 10000
450
-
451
- max_depth = _u16be(data, c[0] + 4) / 10.0
452
- if imperial:
453
- max_depth *= 0.3048
454
-
455
- header = {
456
- "logversion": logversion,
457
- "imperial": imperial,
458
- "divemode": DIVE_MODES.get(data[o[4] + 1], str(data[o[4] + 1])),
459
- "gf_low": data[o[0] + 4],
460
- "gf_high": data[o[0] + 5],
461
- "deco_model": DECO_MODELS.get(data[o[2] + 18], str(data[o[2] + 18])),
462
- "vpmb_conservatism": data[o[2] + 19],
463
- "atmospheric_mbar": _u16be(data, o[1] + 16),
464
- "water_density": _u16be(data, o[3] + 3),
465
- "sensor_calibrated_mask": calibrated_mask,
466
- "sensor_calibration": calibration,
467
- "gas_o2": gas_o2,
468
- "gas_he": gas_he,
469
- "gas_enabled_mask": _u16be(data, o[4] + 17),
470
- "ai_mode": data[o[4] + 28],
471
- "sample_interval_ms": interval_ms,
472
- "max_depth_m": round(max_depth, 2),
473
- "divetime_s": _u24be(data, c[0] + 6),
474
- }
475
-
476
- if final_off is not None:
477
- header["computer_serial"] = _u32be(data, final_off + 2)
478
- header["computer_firmware"] = _bcd2dec(data[final_off + 10])
479
- header["computer_model"] = data[final_off + 13]
480
-
481
- if logversion >= 17:
482
- for name, recs in (("gnss_start", o), ("gnss_end", c)):
483
- if 9 in recs:
484
- off = recs[9]
485
- fix = data[off + 16]
486
- if fix in (2, 3): # 2D / 3D fix
487
- lat = struct.unpack_from(">i", data, off + 21)[0] / 100000.0
488
- lon = struct.unpack_from(">i", data, off + 25)[0] / 100000.0
489
- header[name] = {"fix": fix, "lat": lat, "lon": lon}
490
-
491
- # Verbatim copies of every config record, keyed by record type.
492
- header["raw_opening"] = {
493
- f"0x{OPENING_0 + i:02x}": data[off : off + RECORD_SIZE].hex()
494
- for i, off in sorted(o.items())
495
- }
496
- header["raw_closing"] = {
497
- f"0x{CLOSING_0 + i:02x}": data[off : off + RECORD_SIZE].hex()
498
- for i, off in sorted(c.items())
499
- }
500
- if final_off is not None:
501
- header["raw_final"] = data[final_off : final_off + RECORD_SIZE].hex()
502
-
503
- return header
@@ -1,188 +0,0 @@
1
- """PNF decoder tests against a synthetic blob built record by record."""
2
-
3
- import gzip
4
- import struct
5
-
6
- import pytest
7
-
8
- from bottomtime.decode import pnf
9
-
10
-
11
- def _record(rtype: int, payload: dict[int, bytes] | None = None) -> bytearray:
12
- rec = bytearray(32)
13
- rec[0] = rtype
14
- for offset, data in (payload or {}).items():
15
- rec[offset : offset + len(data)] = data
16
- return rec
17
-
18
-
19
- def _sample(depth_dm: int, **kw) -> bytearray:
20
- rec = _record(pnf.DIVE_SAMPLE)
21
- struct.pack_into(">H", rec, 1, depth_dm)
22
- struct.pack_into(">H", rec, 3, kw.get("stop_m", 0))
23
- struct.pack_into(">H", rec, 5, kw.get("tts_min", 0))
24
- rec[7] = kw.get("ppo2_raw", 21)
25
- rec[8] = kw.get("o2", 21)
26
- rec[9] = kw.get("he", 0)
27
- rec[10] = kw.get("ndl_min", 99)
28
- rec[11] = kw.get("battery_pct", 100)
29
- rec[12] = kw.get("flags", pnf.FLAG_OC)
30
- rec[14] = kw.get("temp", 27) & 0xFF
31
- rec[17] = kw.get("battery_msb", 0)
32
- rec[18] = kw.get("battery_raw", 152)
33
- struct.pack_into(">H", rec, 20, 0xFFFF) # AI off
34
- rec[22] = 0xFF # gas time not paired
35
- rec[23] = kw.get("cns", 0)
36
- rec[24] = kw.get("ceiling", 0xFF)
37
- rec[25] = kw.get("gf99", 0xFF)
38
- struct.pack_into(">H", rec, 26, kw.get("at_plus_five", 0))
39
- struct.pack_into(">H", rec, 28, 0xFFFF) # AI off
40
- struct.pack_into(">H", rec, 30, kw.get("sac_raw", 0xFFFF)) # SAC n/a
41
- return rec
42
-
43
-
44
- def build_blob(samples=None, interval_ms=10000, extra_records=None, deco_model=0) -> bytes:
45
- records = []
46
-
47
- opening0 = _record(0x10)
48
- opening0[4], opening0[5] = 40, 80 # GF low/high
49
- opening0[8] = 0 # metric
50
- opening0[20] = 21 # gas 0 O2
51
- opening0[21] = 50 # gas 1 O2
52
- records.append(opening0)
53
-
54
- opening1 = _record(0x11)
55
- struct.pack_into(">H", opening1, 16, 1013) # atmospheric mbar
56
- records.append(opening1)
57
-
58
- opening2 = _record(0x12)
59
- opening2[18] = deco_model # 0=GF, 1=VPM-B, 2=VPM-B/GFS, 3=DCIEM
60
- records.append(opening2)
61
-
62
- opening3 = _record(0x13)
63
- struct.pack_into(">H", opening3, 3, 1020) # density
64
- records.append(opening3)
65
-
66
- opening4 = _record(0x14)
67
- opening4[1] = 1 # OC tec
68
- opening4[16] = 13 # log version
69
- struct.pack_into(">H", opening4, 17, 0b11) # gases 0,1 enabled
70
- records.append(opening4)
71
-
72
- opening5 = _record(0x15)
73
- struct.pack_into(">H", opening5, 23, interval_ms)
74
- records.append(opening5)
75
-
76
- records.extend(samples or [])
77
- records.extend(extra_records or [])
78
-
79
- for i in range(5):
80
- rec = _record(0x20 + i)
81
- if i == 0:
82
- struct.pack_into(">H", rec, 4, 528) # max depth dm
83
- rec[6:9] = (3374).to_bytes(3, "big")
84
- records.append(rec)
85
-
86
- final = _record(0xFF)
87
- struct.pack_into(">I", final, 2, 0xA823C228)
88
- final[13] = 11
89
- records.append(final)
90
-
91
- raw = b"".join(bytes(r) for r in records)
92
- return struct.pack("<I", len(raw)) + gzip.compress(raw)
93
-
94
-
95
- def test_header():
96
- dive = pnf.decode(build_blob(samples=[_sample(24)]))
97
- h = dive.header
98
- assert (h["gf_low"], h["gf_high"]) == (40, 80)
99
- assert h["deco_model"] == "gf"
100
- assert h["atmospheric_mbar"] == 1013
101
- assert h["water_density"] == 1020
102
- assert h["divemode"] == "oc_tec"
103
- assert h["max_depth_m"] == 52.8
104
- assert h["divetime_s"] == 3374
105
- assert h["computer_serial"] == 0xA823C228
106
- assert h["sample_interval_ms"] == 10000
107
- assert [g for g in dive.gases if g.enabled and g.o2_pct] == dive.gases[:2]
108
-
109
-
110
- def test_samples():
111
- dive = pnf.decode(
112
- build_blob(
113
- samples=[
114
- _sample(24, ndl_min=99),
115
- _sample(528, stop_m=9, tts_min=14, cns=4, ceiling=9, gf99=30, o2=21),
116
- ]
117
- )
118
- )
119
- assert len(dive.samples) == 2
120
- s0, s1 = dive.samples
121
- assert (s0.t_s, s1.t_s) == (0.0, 10.0)
122
- assert s0.depth_m == 2.4
123
- assert s0.gf99 is None and s0.ceiling_m is None # 0xFF sentinel
124
- assert s0.battery_v == 1.52
125
- assert s0.tank0_psi is None and s0.gas_time_min is None
126
- assert s1.depth_m == 52.8
127
- assert (s1.stop_depth_m, s1.tts_min, s1.in_deco) == (9.0, 14.0, 1)
128
- assert (s1.ceiling_m, s1.gf99, s1.cns_pct) == (9.0, 30.0, 4.0)
129
- assert s0.battery_pct == 100
130
- assert s0.sac is None # 0xFFFF sentinel
131
-
132
-
133
- def test_battery_voltage_is_16bit():
134
- # A higher-voltage computer (e.g. Teric rechargeable ~4.2 V) overflows a
135
- # single byte, so the decoder must read bytes 17-18 together.
136
- dive = pnf.decode(build_blob(samples=[_sample(24, battery_msb=1, battery_raw=164)]))
137
- assert dive.samples[0].battery_v == round((1 << 8 | 164) / 100.0, 2) # 4.20 V
138
-
139
-
140
- def test_sac_when_present():
141
- dive = pnf.decode(build_blob(samples=[_sample(24, sac_raw=1234)]))
142
- assert dive.samples[0].sac == 12.34
143
-
144
-
145
- def test_status_byte_bits_decoded():
146
- flags = 0x01 | 0x20 | (2 << 6) # gas-switch-needed + CCR mode + solenoid count 2
147
- dive = pnf.decode(build_blob(samples=[_sample(24, flags=flags)]))
148
- s = dive.samples[0]
149
- assert s.gas_switch_needed is True
150
- assert s.ccr_mode == 1
151
- assert s.solenoid_fired_count == 2
152
- assert s.circuit_mode == 0
153
- assert s.setpoint_high is False
154
- assert s.status_flags == flags
155
-
156
-
157
- def test_dciem_uses_safe_ascent_depth():
158
- # Under DCIEM (deco model 3), bytes 24-25 are a safe-ascent depth, not
159
- # ceiling/GF99: byte 24 whole metres minus byte 25 hundredths.
160
- blob = build_blob(samples=[_sample(300, ceiling=6, gf99=50)], deco_model=3)
161
- dive = pnf.decode(blob)
162
- assert dive.header["deco_model"] == "dciem"
163
- s = dive.samples[0]
164
- assert s.ceiling_m is None and s.gf99 is None
165
- assert s.safe_ascent_depth_m == 6 - 0.50 # 5.5 m
166
-
167
-
168
- def test_unknown_records_preserved():
169
- mystery = _record(0x51, {1: b"\xde\xad\xbe\xef"})
170
- dive = pnf.decode(build_blob(samples=[_sample(24)], extra_records=[mystery]))
171
- assert len(dive.unknown_records) == 1
172
- type_key, _seq, raw = dive.unknown_records[0]
173
- assert type_key == "0x51"
174
- assert raw == bytes(mystery)
175
-
176
-
177
- def test_length_prefix_validated():
178
- blob = build_blob(samples=[_sample(24)])
179
- corrupted = struct.pack("<I", 999999) + blob[4:]
180
- with pytest.raises(ValueError, match="length mismatch"):
181
- pnf.decode(corrupted)
182
-
183
-
184
- def test_imperial_units():
185
- samples = [_sample(100)] # 10.0 units
186
- blob = build_blob(samples=samples)
187
- dive = pnf.decode(blob)
188
- assert dive.samples[0].depth_m == 10.0 # metric build
File without changes
File without changes
File without changes