bottomtime 0.2.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 (30) hide show
  1. {bottomtime-0.2.0 → bottomtime-0.4.0}/.gitignore +1 -0
  2. {bottomtime-0.2.0 → bottomtime-0.4.0}/PKG-INFO +2 -1
  3. {bottomtime-0.2.0 → bottomtime-0.4.0}/pyproject.toml +7 -2
  4. {bottomtime-0.2.0 → bottomtime-0.4.0}/src/bottomtime/__init__.py +1 -1
  5. {bottomtime-0.2.0 → bottomtime-0.4.0}/src/bottomtime/api.py +1 -1
  6. {bottomtime-0.2.0 → bottomtime-0.4.0}/src/bottomtime/ingest/shearwater_db.py +20 -9
  7. {bottomtime-0.2.0 → bottomtime-0.4.0}/src/bottomtime/store/schema.py +8 -4
  8. {bottomtime-0.2.0 → bottomtime-0.4.0}/src/bottomtime/verify/crosscheck.py +5 -0
  9. bottomtime-0.4.0/tests/test_decoder_dependency.py +40 -0
  10. bottomtime-0.2.0/.claude/settings.local.json +0 -30
  11. bottomtime-0.2.0/.github/workflows/test.yml +0 -20
  12. bottomtime-0.2.0/src/bottomtime/decode/pnf.py +0 -452
  13. bottomtime-0.2.0/src/bottomtime/verify/__init__.py +0 -1
  14. bottomtime-0.2.0/tests/test_pnf.py +0 -148
  15. {bottomtime-0.2.0 → bottomtime-0.4.0}/LICENSE +0 -0
  16. {bottomtime-0.2.0 → bottomtime-0.4.0}/README.md +0 -0
  17. {bottomtime-0.2.0 → bottomtime-0.4.0}/src/bottomtime/cli.py +0 -0
  18. {bottomtime-0.2.0/src/bottomtime/decode → bottomtime-0.4.0/src/bottomtime/ingest}/__init__.py +0 -0
  19. {bottomtime-0.2.0 → bottomtime-0.4.0}/src/bottomtime/ingest/archive.py +0 -0
  20. {bottomtime-0.2.0 → bottomtime-0.4.0}/src/bottomtime/ingest/garmin_fit.py +0 -0
  21. {bottomtime-0.2.0 → bottomtime-0.4.0}/src/bottomtime/ingest/shearwater_xml.py +0 -0
  22. {bottomtime-0.2.0/src/bottomtime/ingest → bottomtime-0.4.0/src/bottomtime/match}/__init__.py +0 -0
  23. {bottomtime-0.2.0 → bottomtime-0.4.0}/src/bottomtime/match/correlate.py +0 -0
  24. {bottomtime-0.2.0 → bottomtime-0.4.0}/src/bottomtime/match/matcher.py +0 -0
  25. {bottomtime-0.2.0 → bottomtime-0.4.0}/src/bottomtime/plot.py +0 -0
  26. {bottomtime-0.2.0/src/bottomtime/match → bottomtime-0.4.0/src/bottomtime/store}/__init__.py +0 -0
  27. {bottomtime-0.2.0 → bottomtime-0.4.0}/src/bottomtime/store/db.py +0 -0
  28. {bottomtime-0.2.0/src/bottomtime/store → bottomtime-0.4.0/src/bottomtime/verify}/__init__.py +0 -0
  29. {bottomtime-0.2.0 → bottomtime-0.4.0}/tests/test_cli.py +0 -0
  30. {bottomtime-0.2.0 → bottomtime-0.4.0}/tests/test_correlate.py +0 -0
@@ -3,6 +3,7 @@ __pycache__/
3
3
  *.egg-info/
4
4
  dist/
5
5
  .pytest_cache/
6
+ .mypy_cache/
6
7
 
7
8
  # Personal dive data lives outside version control
8
9
  data/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bottomtime
3
- Version: 0.2.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.2.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.2.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"),
@@ -139,19 +147,22 @@ def _ingest_log(
139
147
  con.executemany(
140
148
  "INSERT INTO shearwater_samples (source_dive_id, t_s, depth_m, temp_c,"
141
149
  " stop_depth_m, stop_or_ndl_min, tts_min, in_deco, avg_ppo2, o2_pct, he_pct,"
142
- " setpoint, cns_pct, gf99, ceiling_m, sensor1_raw, sensor2_raw, sensor3_raw,"
150
+ " setpoint, cns_pct, gf99, ceiling_m, safe_ascent_depth_m, battery_pct, sac,"
151
+ " sensor1_raw, sensor2_raw, sensor3_raw,"
143
152
  " sensor1_ppo2, sensor2_ppo2, sensor3_ppo2, battery_v, status_flags,"
144
- " tank0_psi, tank1_psi, gas_time_min, extra_json)"
145
- " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
153
+ " solenoid_fired_count, tank0_psi, tank1_psi, gas_time_min, extra_json)"
154
+ " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
146
155
  [
147
156
  (
148
157
  dive_id, s.t_s, s.depth_m, s.temp_c,
149
158
  s.stop_depth_m, s.stop_or_ndl_min, s.tts_min, s.in_deco,
150
159
  s.avg_ppo2, s.o2_pct, s.he_pct,
151
160
  s.setpoint, s.cns_pct, s.gf99, s.ceiling_m,
161
+ s.safe_ascent_depth_m, s.battery_pct, s.sac,
152
162
  s.sensor1_raw, s.sensor2_raw, s.sensor3_raw,
153
163
  s.sensor1_ppo2, s.sensor2_ppo2, s.sensor3_ppo2,
154
164
  s.battery_v, s.status_flags,
165
+ s.solenoid_fired_count,
155
166
  s.tank0_psi, s.tank1_psi, s.gas_time_min,
156
167
  json.dumps(s.extra, separators=(",", ":")) if s.extra else None,
157
168
  )
@@ -11,7 +11,7 @@ Design principles:
11
11
  dives is the canonical entity built from matches + singletons.
12
12
  """
13
13
 
14
- SCHEMA_VERSION = 1
14
+ SCHEMA_VERSION = 2
15
15
 
16
16
  DDL = """
17
17
  CREATE TABLE IF NOT EXISTS meta (
@@ -89,8 +89,11 @@ CREATE TABLE IF NOT EXISTS shearwater_samples (
89
89
  he_pct REAL,
90
90
  setpoint REAL,
91
91
  cns_pct REAL,
92
- gf99 REAL, -- populated at decoder_version >= 2
93
- ceiling_m REAL, -- populated at decoder_version >= 2
92
+ gf99 REAL, -- populated at decoder_version >= 2; NULL under DCIEM
93
+ ceiling_m REAL, -- populated at decoder_version >= 2; NULL under DCIEM
94
+ safe_ascent_depth_m REAL, -- DCIEM only (bytes 24-25); NULL otherwise, v3
95
+ battery_pct INTEGER, -- battery percent remaining (byte 11), v3
96
+ sac REAL, -- surface air consumption (bytes 30-31), v3
94
97
  sensor1_raw INTEGER,
95
98
  sensor2_raw INTEGER,
96
99
  sensor3_raw INTEGER,
@@ -98,7 +101,8 @@ CREATE TABLE IF NOT EXISTS shearwater_samples (
98
101
  sensor2_ppo2 REAL,
99
102
  sensor3_ppo2 REAL,
100
103
  battery_v REAL,
101
- status_flags INTEGER,
104
+ status_flags INTEGER, -- raw sample byte 12 (packed mode/status bits)
105
+ solenoid_fired_count INTEGER, -- CCR O2 solenoid firings, byte 12 bits 6-7, v3
102
106
  tank0_psi REAL,
103
107
  tank1_psi REAL,
104
108
  gas_time_min REAL,
@@ -30,6 +30,7 @@ TOL = {
30
30
  "temp_c": 0.51,
31
31
  "battery_v": 0.011,
32
32
  "ndl_min": 0.001,
33
+ "sac": 0.011,
33
34
  }
34
35
 
35
36
 
@@ -71,6 +72,10 @@ def check_xml(con: sqlite3.Connection, xml_dir: Path, failures: list) -> None:
71
72
  ("averagePPO2", "avg_ppo2", 1.0),
72
73
  ("waterTemp", "temp_c", 1.0),
73
74
  ("batteryVoltage", "battery_v", 1.0),
75
+ # SAC (bytes 30-31) only present with wireless air integration. The
76
+ # XML is produced by the same parser, so it should match exactly;
77
+ # inert on dives without AI (both sides None).
78
+ ("sac", "sac", 1.0),
74
79
  ]
75
80
  for i, (db_row, x) in enumerate(zip(samples, xs)):
76
81
  for xml_field, col, scale in pairs:
@@ -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,30 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(sqlite3 \"file:data/dives.db?mode=ro\" \"SELECT source, count(*) FROM source_dives GROUP BY source;\" \"SELECT json_extract(header_json,'$.pnf.computer_model'), json_extract(header_json,'$.pnf.computer_firmware'), json_extract(header_json,'$.dive_details.Product'), count(*) FROM source_dives WHERE source='shearwater' GROUP BY 1,2,3;\")",
5
- "Bash(sqlite3 \"file:data/dives.db?mode=ro\" \"SELECT json_extract(header_json,'$.file_id_mesgs[0].product_name'), json_extract(header_json,'$.file_id_mesgs[0].manufacturer'), json_extract(header_json,'$.file_id_mesgs[0].product'), count(*) FROM source_dives WHERE source='garmin' GROUP BY 1,2,3 LIMIT 10;\")",
6
- "Bash(sqlite3 \"file:$(pwd)/data/dives.db?mode=ro&immutable=1\" \"SELECT source, count(*) FROM source_dives GROUP BY source;\")",
7
- "Bash(DB=\"file:$(pwd)/data/dives.db?mode=ro&immutable=1\")",
8
- "Bash(sqlite3 \"$DB\" \"SELECT json_extract(header_json,''$.pnf.computer_model''), json_extract(header_json,''$.pnf.computer_firmware''), json_extract(header_json,''$.dive_details.Product''), count(*) FROM source_dives WHERE source=''shearwater'' GROUP BY 1,2,3;\")",
9
- "Bash(sqlite3 \"$DB\" \"SELECT json_extract(header_json,''$.file_id_mesgs[0].product_name''), json_extract(header_json,''$.device_info_mesgs[0].product_name''), json_extract(header_json,''$.device_info_mesgs[0].software_version''), count(*) FROM source_dives WHERE source=''garmin'' GROUP BY 1,2,3 LIMIT 10;\")",
10
- "Bash(DB=\"file:$(pwd)/data/dives.db?mode=ro&immutable=1\")",
11
- "Bash(sqlite3 \"$DB\" \"SELECT json_extract(header_json,''$.file_id_mesgs[0]'') FROM source_dives WHERE source=''garmin'' LIMIT 1;\")",
12
- "Bash(sqlite3 \"$DB\" \"SELECT json_extract(header_json,''$.device_info_mesgs[0]'') FROM source_dives WHERE source=''garmin'' LIMIT 1;\")",
13
- "Bash(sqlite3 \"$DB\" \"SELECT DISTINCT json_extract(header_json,''$.file_id_mesgs[0].garmin_product'') FROM source_dives WHERE source=''garmin'';\")",
14
- "Bash(TMP=/var/folders/xx/8wmmss0x7lb3v3mrhc1rzzc00000gn/T/tmp.yC0jxrHEY0)",
15
- "Bash(python -c \"import matplotlib; print(matplotlib.__version__)\")",
16
- "Bash(python -m bottomtime.cli --db \"$TMP/dives.db\" plot 318 -o \"$TMP/d318.png\")",
17
- "Bash(python -c \"\nfrom bottomtime.api import load_dive\nfrom bottomtime.plot import aligned_series\nd = load_dive(''$TMP/dives.db'', 318)\nfor s in aligned_series(d):\n print(s[''source''], s[''label''], ''x0=%.0f'' % s[''x''][0], ''xN=%.0f'' % s[''x''][-1])\n\")",
18
- "Bash(python -m pytest tests/ -q 2 >& 1)",
19
- "Bash(python -c \"import sys; print(sys.prefix != sys.base_prefix, sys.prefix)\")",
20
- "Bash(rg -n \"—|–\" --no-heading /Users/snehan/Downloads/bottomtime/README.md /Users/snehan/Downloads/bottomtime/src /Users/snehan/Downloads/bottomtime/tests /Users/snehan/Downloads/bottomtime/pyproject.toml)",
21
- "Bash(rg -ni \"leverage|seamless|robust|delve|deep dive|journey|landscape|ecosystem|empower|unlock|supercharge|elevate|streamline|game-chang|cutting-edge|state-of-the-art|best-in-class|thrilled|excited|passionate|humbled|honored|compelling|actionable|utilize|in order to|testament|underscores|boasts|not just|isn''t about|not about\" --no-heading /Users/snehan/Downloads/bottomtime/README.md /Users/snehan/Downloads/bottomtime/src /Users/snehan/Downloads/bottomtime/tests)",
22
- "Bash(ls -R /Users/snehan/Downloads/snehankekre.github.io/.claude 2 > /dev/null)",
23
- "Bash(ls /Users/snehan/.claude/projects/-Users-snehan-Downloads-snehankekre-github-io/memory/ 2>/dev/null)",
24
- "Bash(rg -n \"—|–\" /Users/snehan/Downloads/bottomtime/README.md /Users/snehan/Downloads/bottomtime/src /Users/snehan/Downloads/bottomtime/tests /Users/snehan/Downloads/bottomtime/pyproject.toml)",
25
- "Bash(echo \"clean: $?\")",
26
- "Bash(python -m pytest tests/ -q)"
27
- ],
28
- "deny": []
29
- }
30
- }
@@ -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,452 +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
- """
27
-
28
- from __future__ import annotations
29
-
30
- import gzip
31
- import struct
32
- from dataclasses import dataclass, field
33
-
34
- DECODER_VERSION = 2
35
-
36
- RECORD_SIZE = 32
37
-
38
- DIVE_SAMPLE = 0x01
39
- FREEDIVE_SAMPLE = 0x02
40
- AVELO_SAMPLE = 0x03
41
- OPENING_0, OPENING_9 = 0x10, 0x19
42
- CLOSING_0, CLOSING_9 = 0x20, 0x29
43
- INFO_EVENT = 0x30
44
- DIVE_SAMPLE_EXT = 0xE1
45
- FINAL = 0xFF
46
-
47
- # status flag bits (sample byte 12)
48
- FLAG_GASSWITCH = 0x01
49
- FLAG_PPO2_EXTERNAL = 0x02
50
- FLAG_SETPOINT_HIGH = 0x04
51
- FLAG_SC = 0x08
52
- FLAG_OC = 0x10
53
-
54
- DIVE_MODES = {
55
- 0: "cc",
56
- 1: "oc_tec",
57
- 2: "gauge",
58
- 3: "ppo2",
59
- 4: "sc",
60
- 5: "cc2",
61
- 6: "oc_rec",
62
- 7: "freedive",
63
- 12: "avelo",
64
- }
65
-
66
- DECO_MODELS = {0: "gf", 1: "vpmb", 2: "vpmb_gfs", 3: "dciem"}
67
-
68
- # Final-record byte 13 model ids, names per libdivecomputer's descriptors.
69
- # The PNF layout is shared across the family; bottomtime's field mappings are
70
- # validated so far on Petrel 3 (10) and Perdix 2 (11) logs.
71
- COMPUTER_MODELS = {
72
- 2: "Predator",
73
- 3: "Petrel",
74
- 4: "Nerd",
75
- 5: "Perdix",
76
- 6: "Perdix AI",
77
- 7: "Nerd 2",
78
- 8: "Teric",
79
- 9: "Peregrine",
80
- 10: "Petrel 3",
81
- 11: "Perdix 2",
82
- 12: "Tern",
83
- }
84
-
85
- # Sample bytes not (yet) assigned a meaning; kept per sample as b<offset>.
86
- # 18 (battery), 24 (ceiling), 25 (gf99), 26-27 (@+5) were identified
87
- # empirically and promoted in v2. 30-31 read 0xFFFF (sentinel) so far.
88
- UNMAPPED_SAMPLE_BYTES = (11, 17, 30, 31)
89
-
90
-
91
- def _u16be(data: bytes, off: int) -> int:
92
- return struct.unpack_from(">H", data, off)[0]
93
-
94
-
95
- def _u24be(data: bytes, off: int) -> int:
96
- return (data[off] << 16) | (data[off + 1] << 8) | data[off + 2]
97
-
98
-
99
- def _u32be(data: bytes, off: int) -> int:
100
- return struct.unpack_from(">I", data, off)[0]
101
-
102
-
103
- def _bcd2dec(value: int) -> int:
104
- return ((value >> 4) & 0x0F) * 10 + (value & 0x0F)
105
-
106
-
107
- def _bcd_bytes(data: bytes, off: int, n: int) -> int:
108
- result = 0
109
- for i in range(n):
110
- result = result * 100 + _bcd2dec(data[off + i])
111
- return result
112
-
113
-
114
- @dataclass
115
- class PnfSample:
116
- t_s: float
117
- depth_m: float | None = None
118
- temp_c: float | None = None
119
- stop_depth_m: float | None = None
120
- stop_or_ndl_min: float | None = None
121
- tts_min: float | None = None
122
- in_deco: int | None = None
123
- avg_ppo2: float | None = None
124
- o2_pct: float | None = None
125
- he_pct: float | None = None
126
- setpoint: float | None = None
127
- cns_pct: float | None = None
128
- gf99: float | None = None
129
- ceiling_m: float | None = None
130
- sensor1_raw: int | None = None
131
- sensor2_raw: int | None = None
132
- sensor3_raw: int | None = None
133
- sensor1_ppo2: float | None = None
134
- sensor2_ppo2: float | None = None
135
- sensor3_ppo2: float | None = None
136
- battery_v: float | None = None
137
- status_flags: int | None = None
138
- tank0_psi: float | None = None
139
- tank1_psi: float | None = None
140
- gas_time_min: float | None = None
141
- extra: dict = field(default_factory=dict)
142
-
143
-
144
- @dataclass
145
- class PnfGas:
146
- index: int
147
- o2_pct: int
148
- he_pct: int
149
- diluent: bool
150
- enabled: bool
151
-
152
-
153
- @dataclass
154
- class PnfEvent:
155
- t_s: float
156
- event: int
157
- timestamp: int
158
- w1: int
159
- w2: int
160
- raw_hex: str
161
-
162
-
163
- @dataclass
164
- class PnfDive:
165
- header: dict
166
- samples: list[PnfSample]
167
- gases: list[PnfGas]
168
- events: list[PnfEvent]
169
- unknown_records: list[tuple[str, int, bytes]] # (type_key, seq, raw)
170
- sample_interval_ms: int
171
-
172
-
173
- def decompress(blob: bytes) -> bytes:
174
- """Strip the 4-byte LE length prefix and gunzip; verify the length."""
175
- expected = struct.unpack_from("<I", blob, 0)[0]
176
- data = gzip.decompress(blob[4:])
177
- if len(data) != expected:
178
- raise ValueError(f"PNF length mismatch: prefix {expected}, got {len(data)}")
179
- if len(data) % RECORD_SIZE:
180
- raise ValueError(f"PNF payload not a multiple of {RECORD_SIZE} bytes")
181
- return data
182
-
183
-
184
- def decode(blob: bytes) -> PnfDive:
185
- """Decode a raw sw-pnf blob (as stored in Shearwater Cloud's log_data)."""
186
- data = decompress(blob)
187
-
188
- opening: dict[int, int] = {}
189
- closing: dict[int, int] = {}
190
- final_off: int | None = None
191
- raw_records: list[tuple[int, int]] = [] # (type, offset), stream order
192
-
193
- for off in range(0, len(data), RECORD_SIZE):
194
- rec = data[off : off + RECORD_SIZE]
195
- if rec == b"\x00" * RECORD_SIZE:
196
- continue
197
- rtype = rec[0]
198
- raw_records.append((rtype, off))
199
- if OPENING_0 <= rtype <= OPENING_9:
200
- opening[rtype - OPENING_0] = off
201
- elif CLOSING_0 <= rtype <= CLOSING_9:
202
- closing[rtype - CLOSING_0] = off
203
- elif rtype == FINAL:
204
- final_off = off
205
-
206
- for i in range(5):
207
- if i not in opening or i not in closing:
208
- raise ValueError(f"missing opening/closing record {i}")
209
-
210
- header = _parse_header(data, opening, closing, final_off)
211
- interval_ms = header["sample_interval_ms"]
212
- calibrated = header["sensor_calibrated_mask"]
213
- calibration = header["sensor_calibration"]
214
- imperial = header["imperial"]
215
-
216
- gases = [
217
- PnfGas(
218
- index=i,
219
- o2_pct=header["gas_o2"][i],
220
- he_pct=header["gas_he"][i],
221
- diluent=i >= 5,
222
- enabled=bool(header["gas_enabled_mask"] & (1 << i)),
223
- )
224
- for i in range(10)
225
- ]
226
-
227
- samples: list[PnfSample] = []
228
- events: list[PnfEvent] = []
229
- unknown: list[tuple[str, int, bytes]] = []
230
- seq = 0
231
- t = 0.0
232
-
233
- for rtype, off in raw_records:
234
- rec = data[off : off + RECORD_SIZE]
235
- if rtype in (DIVE_SAMPLE, AVELO_SAMPLE):
236
- samples.append(
237
- _parse_sample(rec, t, imperial, calibrated, calibration, rtype)
238
- )
239
- t += interval_ms / 1000.0
240
- elif rtype == FREEDIVE_SAMPLE:
241
- for i in range(4):
242
- sub = rec[i * 8 : (i + 1) * 8]
243
- if sub == b"\x00" * 8:
244
- break
245
- pressure_mbar = _u16be(sub, 1)
246
- depth = (
247
- (pressure_mbar - header["atmospheric_mbar"])
248
- * 100.0
249
- / (header["water_density"] * 9.80665)
250
- )
251
- temp = struct.unpack_from(">h", sub, 3)[0] / 10.0
252
- samples.append(PnfSample(t_s=t, depth_m=round(depth, 2), temp_c=temp))
253
- t += interval_ms / 1000.0
254
- elif rtype == DIVE_SAMPLE_EXT:
255
- s = samples[-1] if samples else None
256
- for i, key in enumerate(("tank2_psi", "tank3_psi")):
257
- v = _u16be(rec, 1 + i * 2)
258
- if v < 0xFFF0 and (v & 0x0FFF) and s is not None:
259
- s.extra[key] = (v & 0x0FFF) * 2
260
- for i, key in enumerate(("tank_dil_psi", "tank_o2_psi")):
261
- v = _u16be(rec, 1 + 4 + i * 2)
262
- if v and s is not None:
263
- s.extra[key] = v * 2
264
- elif rtype == INFO_EVENT:
265
- events.append(
266
- PnfEvent(
267
- t_s=t,
268
- event=rec[1],
269
- timestamp=_u32be(rec, 4),
270
- w1=_u32be(rec, 8),
271
- w2=_u32be(rec, 12),
272
- raw_hex=rec.hex(),
273
- )
274
- )
275
- elif OPENING_0 <= rtype <= OPENING_9 or CLOSING_0 <= rtype <= CLOSING_9 or rtype == FINAL:
276
- pass # consumed by _parse_header; raw copies kept in header dict
277
- else:
278
- unknown.append((f"0x{rtype:02x}", seq, rec))
279
- seq += 1
280
-
281
- return PnfDive(
282
- header=header,
283
- samples=samples,
284
- gases=gases,
285
- events=events,
286
- unknown_records=unknown,
287
- sample_interval_ms=interval_ms,
288
- )
289
-
290
-
291
- def _parse_sample(
292
- rec: bytes,
293
- t: float,
294
- imperial: bool,
295
- calibrated: int,
296
- calibration: list[float],
297
- rtype: int,
298
- ) -> PnfSample:
299
- ft = 0.3048
300
- depth = _u16be(rec, 1) / 10.0
301
- stop_depth = float(_u16be(rec, 3))
302
- if imperial:
303
- depth *= ft
304
- stop_depth *= ft
305
-
306
- temp = struct.unpack_from("b", rec, 14)[0]
307
- if temp < 0:
308
- temp = min(temp + 102, 0)
309
- if imperial:
310
- temp = (temp - 32.0) * 5.0 / 9.0
311
-
312
- status = rec[12] if rtype != AVELO_SAMPLE else 0
313
- ccr = (status & FLAG_OC) == 0 if rtype != AVELO_SAMPLE else False
314
-
315
- sample = PnfSample(
316
- t_s=t,
317
- depth_m=round(depth, 2),
318
- temp_c=float(temp),
319
- stop_depth_m=stop_depth,
320
- tts_min=float(_u16be(rec, 5)),
321
- stop_or_ndl_min=float(rec[10]),
322
- in_deco=1 if _u16be(rec, 3) else 0,
323
- avg_ppo2=rec[7] / 100.0,
324
- o2_pct=float(rec[8]),
325
- he_pct=float(rec[9]),
326
- status_flags=status,
327
- sensor1_raw=rec[13],
328
- sensor2_raw=rec[15],
329
- sensor3_raw=rec[16],
330
- cns_pct=float(rec[23]),
331
- ceiling_m=float(rec[24]) if rec[24] != 0xFF else None,
332
- gf99=float(rec[25]) if rec[25] != 0xFF else None, # 0xFF = not available
333
- battery_v=rec[18] / 100.0,
334
- )
335
-
336
- if ccr:
337
- sample.setpoint = rec[19] / 100.0
338
- if not (status & FLAG_PPO2_EXTERNAL):
339
- if calibrated & 0x01:
340
- sample.sensor1_ppo2 = round(rec[13] * calibration[0], 3)
341
- if calibrated & 0x02:
342
- sample.sensor2_ppo2 = round(rec[15] * calibration[1], 3)
343
- if calibrated & 0x04:
344
- sample.sensor3_ppo2 = round(rec[16] * calibration[2], 3)
345
-
346
- for idx, key in ((20, "tank1_psi"), (28, "tank0_psi")):
347
- v = _u16be(rec, idx)
348
- if v < 0xFFF0:
349
- pressure = v & 0x0FFF
350
- battery = (v >> 12) & 0x0F
351
- if key == "tank1_psi":
352
- sample.tank1_psi = pressure * 2.0
353
- else:
354
- sample.tank0_psi = pressure * 2.0
355
- if battery:
356
- sample.extra[key + "_battery"] = battery
357
-
358
- if rec[22] < 0xF0:
359
- sample.gas_time_min = float(rec[22])
360
-
361
- # @+5: TTS if the diver stayed 5 more minutes (minutes, u16be)
362
- at_plus_five = _u16be(rec, 26)
363
- if at_plus_five != 0xFFFF:
364
- sample.extra["at_plus_five_min"] = at_plus_five
365
-
366
- for b in UNMAPPED_SAMPLE_BYTES:
367
- if rec[b]:
368
- sample.extra[f"b{b}"] = rec[b]
369
-
370
- return sample
371
-
372
-
373
- def _parse_header(
374
- data: bytes,
375
- opening: dict[int, int],
376
- closing: dict[int, int],
377
- final_off: int | None,
378
- ) -> dict:
379
- o = opening
380
- c = closing
381
-
382
- logversion = data[o[4] + 16]
383
- imperial = data[o[0] + 8] == 1
384
-
385
- gas_o2 = [data[o[0] + 20 + i] for i in range(10)]
386
- gas_he = [data[o[0] + 30 + i] for i in range(2)] + [
387
- data[o[1] + 1 + i] for i in range(8)
388
- ]
389
-
390
- calib_base = o[3] + 6
391
- calibrated_mask = data[calib_base]
392
- calibration = [
393
- _u16be(data, calib_base + 1 + i * 2) / 100000.0 for i in range(3)
394
- ]
395
-
396
- interval_ms = 10000
397
- if logversion >= 9 and 5 in o:
398
- interval_ms = _u16be(data, o[5] + 23) or 10000
399
-
400
- max_depth = _u16be(data, c[0] + 4) / 10.0
401
- if imperial:
402
- max_depth *= 0.3048
403
-
404
- header = {
405
- "logversion": logversion,
406
- "imperial": imperial,
407
- "divemode": DIVE_MODES.get(data[o[4] + 1], str(data[o[4] + 1])),
408
- "gf_low": data[o[0] + 4],
409
- "gf_high": data[o[0] + 5],
410
- "deco_model": DECO_MODELS.get(data[o[2] + 18], str(data[o[2] + 18])),
411
- "vpmb_conservatism": data[o[2] + 19],
412
- "atmospheric_mbar": _u16be(data, o[1] + 16),
413
- "water_density": _u16be(data, o[3] + 3),
414
- "sensor_calibrated_mask": calibrated_mask,
415
- "sensor_calibration": calibration,
416
- "gas_o2": gas_o2,
417
- "gas_he": gas_he,
418
- "gas_enabled_mask": _u16be(data, o[4] + 17),
419
- "ai_mode": data[o[4] + 28],
420
- "sample_interval_ms": interval_ms,
421
- "max_depth_m": round(max_depth, 2),
422
- "divetime_s": _u24be(data, c[0] + 6),
423
- }
424
-
425
- if final_off is not None:
426
- header["computer_serial"] = _u32be(data, final_off + 2)
427
- header["computer_firmware"] = _bcd2dec(data[final_off + 10])
428
- header["computer_model"] = data[final_off + 13]
429
-
430
- if logversion >= 17:
431
- for name, recs in (("gnss_start", o), ("gnss_end", c)):
432
- if 9 in recs:
433
- off = recs[9]
434
- fix = data[off + 16]
435
- if fix in (2, 3): # 2D / 3D fix
436
- lat = struct.unpack_from(">i", data, off + 21)[0] / 100000.0
437
- lon = struct.unpack_from(">i", data, off + 25)[0] / 100000.0
438
- header[name] = {"fix": fix, "lat": lat, "lon": lon}
439
-
440
- # Verbatim copies of every config record, keyed by record type.
441
- header["raw_opening"] = {
442
- f"0x{OPENING_0 + i:02x}": data[off : off + RECORD_SIZE].hex()
443
- for i, off in sorted(o.items())
444
- }
445
- header["raw_closing"] = {
446
- f"0x{CLOSING_0 + i:02x}": data[off : off + RECORD_SIZE].hex()
447
- for i, off in sorted(c.items())
448
- }
449
- if final_off is not None:
450
- header["raw_final"] = data[final_off : final_off + RECORD_SIZE].hex()
451
-
452
- return header
@@ -1,148 +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[12] = kw.get("flags", pnf.FLAG_OC)
29
- rec[14] = kw.get("temp", 27) & 0xFF
30
- rec[18] = kw.get("battery_raw", 152)
31
- struct.pack_into(">H", rec, 20, 0xFFFF) # AI off
32
- rec[22] = 0xFF # gas time not paired
33
- rec[23] = kw.get("cns", 0)
34
- rec[24] = kw.get("ceiling", 0xFF)
35
- rec[25] = kw.get("gf99", 0xFF)
36
- struct.pack_into(">H", rec, 26, kw.get("at_plus_five", 0))
37
- struct.pack_into(">H", rec, 28, 0xFFFF) # AI off
38
- return rec
39
-
40
-
41
- def build_blob(samples=None, interval_ms=10000, extra_records=None) -> bytes:
42
- records = []
43
-
44
- opening0 = _record(0x10)
45
- opening0[4], opening0[5] = 40, 80 # GF low/high
46
- opening0[8] = 0 # metric
47
- opening0[20] = 21 # gas 0 O2
48
- opening0[21] = 50 # gas 1 O2
49
- records.append(opening0)
50
-
51
- opening1 = _record(0x11)
52
- struct.pack_into(">H", opening1, 16, 1013) # atmospheric mbar
53
- records.append(opening1)
54
-
55
- opening2 = _record(0x12)
56
- opening2[18] = 0 # GF deco model
57
- records.append(opening2)
58
-
59
- opening3 = _record(0x13)
60
- struct.pack_into(">H", opening3, 3, 1020) # density
61
- records.append(opening3)
62
-
63
- opening4 = _record(0x14)
64
- opening4[1] = 1 # OC tec
65
- opening4[16] = 13 # log version
66
- struct.pack_into(">H", opening4, 17, 0b11) # gases 0,1 enabled
67
- records.append(opening4)
68
-
69
- opening5 = _record(0x15)
70
- struct.pack_into(">H", opening5, 23, interval_ms)
71
- records.append(opening5)
72
-
73
- records.extend(samples or [])
74
- records.extend(extra_records or [])
75
-
76
- for i in range(5):
77
- rec = _record(0x20 + i)
78
- if i == 0:
79
- struct.pack_into(">H", rec, 4, 528) # max depth dm
80
- rec[6:9] = (3374).to_bytes(3, "big")
81
- records.append(rec)
82
-
83
- final = _record(0xFF)
84
- struct.pack_into(">I", final, 2, 0xA823C228)
85
- final[13] = 11
86
- records.append(final)
87
-
88
- raw = b"".join(bytes(r) for r in records)
89
- return struct.pack("<I", len(raw)) + gzip.compress(raw)
90
-
91
-
92
- def test_header():
93
- dive = pnf.decode(build_blob(samples=[_sample(24)]))
94
- h = dive.header
95
- assert (h["gf_low"], h["gf_high"]) == (40, 80)
96
- assert h["deco_model"] == "gf"
97
- assert h["atmospheric_mbar"] == 1013
98
- assert h["water_density"] == 1020
99
- assert h["divemode"] == "oc_tec"
100
- assert h["max_depth_m"] == 52.8
101
- assert h["divetime_s"] == 3374
102
- assert h["computer_serial"] == 0xA823C228
103
- assert h["sample_interval_ms"] == 10000
104
- assert [g for g in dive.gases if g.enabled and g.o2_pct] == dive.gases[:2]
105
-
106
-
107
- def test_samples():
108
- dive = pnf.decode(
109
- build_blob(
110
- samples=[
111
- _sample(24, ndl_min=99),
112
- _sample(528, stop_m=9, tts_min=14, cns=4, ceiling=9, gf99=30, o2=21),
113
- ]
114
- )
115
- )
116
- assert len(dive.samples) == 2
117
- s0, s1 = dive.samples
118
- assert (s0.t_s, s1.t_s) == (0.0, 10.0)
119
- assert s0.depth_m == 2.4
120
- assert s0.gf99 is None and s0.ceiling_m is None # 0xFF sentinel
121
- assert s0.battery_v == 1.52
122
- assert s0.tank0_psi is None and s0.gas_time_min is None
123
- assert s1.depth_m == 52.8
124
- assert (s1.stop_depth_m, s1.tts_min, s1.in_deco) == (9.0, 14.0, 1)
125
- assert (s1.ceiling_m, s1.gf99, s1.cns_pct) == (9.0, 30.0, 4.0)
126
-
127
-
128
- def test_unknown_records_preserved():
129
- mystery = _record(0x51, {1: b"\xde\xad\xbe\xef"})
130
- dive = pnf.decode(build_blob(samples=[_sample(24)], extra_records=[mystery]))
131
- assert len(dive.unknown_records) == 1
132
- type_key, _seq, raw = dive.unknown_records[0]
133
- assert type_key == "0x51"
134
- assert raw == bytes(mystery)
135
-
136
-
137
- def test_length_prefix_validated():
138
- blob = build_blob(samples=[_sample(24)])
139
- corrupted = struct.pack("<I", 999999) + blob[4:]
140
- with pytest.raises(ValueError, match="length mismatch"):
141
- pnf.decode(corrupted)
142
-
143
-
144
- def test_imperial_units():
145
- samples = [_sample(100)] # 10.0 units
146
- blob = build_blob(samples=samples)
147
- dive = pnf.decode(blob)
148
- assert dive.samples[0].depth_m == 10.0 # metric build
File without changes
File without changes
File without changes