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.
- {bottomtime-0.3.0 → bottomtime-0.4.0}/.gitignore +0 -1
- {bottomtime-0.3.0 → bottomtime-0.4.0}/PKG-INFO +2 -1
- {bottomtime-0.3.0 → bottomtime-0.4.0}/pyproject.toml +7 -2
- {bottomtime-0.3.0 → bottomtime-0.4.0}/src/bottomtime/__init__.py +1 -1
- {bottomtime-0.3.0 → bottomtime-0.4.0}/src/bottomtime/api.py +1 -1
- {bottomtime-0.3.0 → bottomtime-0.4.0}/src/bottomtime/ingest/shearwater_db.py +14 -6
- bottomtime-0.4.0/tests/test_decoder_dependency.py +40 -0
- bottomtime-0.3.0/.github/workflows/test.yml +0 -20
- bottomtime-0.3.0/src/bottomtime/decode/pnf.py +0 -503
- bottomtime-0.3.0/src/bottomtime/verify/__init__.py +0 -1
- bottomtime-0.3.0/tests/test_pnf.py +0 -188
- {bottomtime-0.3.0 → bottomtime-0.4.0}/LICENSE +0 -0
- {bottomtime-0.3.0 → bottomtime-0.4.0}/README.md +0 -0
- {bottomtime-0.3.0 → bottomtime-0.4.0}/src/bottomtime/cli.py +0 -0
- {bottomtime-0.3.0/src/bottomtime/decode → bottomtime-0.4.0/src/bottomtime/ingest}/__init__.py +0 -0
- {bottomtime-0.3.0 → bottomtime-0.4.0}/src/bottomtime/ingest/archive.py +0 -0
- {bottomtime-0.3.0 → bottomtime-0.4.0}/src/bottomtime/ingest/garmin_fit.py +0 -0
- {bottomtime-0.3.0 → bottomtime-0.4.0}/src/bottomtime/ingest/shearwater_xml.py +0 -0
- {bottomtime-0.3.0/src/bottomtime/ingest → bottomtime-0.4.0/src/bottomtime/match}/__init__.py +0 -0
- {bottomtime-0.3.0 → bottomtime-0.4.0}/src/bottomtime/match/correlate.py +0 -0
- {bottomtime-0.3.0 → bottomtime-0.4.0}/src/bottomtime/match/matcher.py +0 -0
- {bottomtime-0.3.0 → bottomtime-0.4.0}/src/bottomtime/plot.py +0 -0
- {bottomtime-0.3.0/src/bottomtime/match → bottomtime-0.4.0/src/bottomtime/store}/__init__.py +0 -0
- {bottomtime-0.3.0 → bottomtime-0.4.0}/src/bottomtime/store/db.py +0 -0
- {bottomtime-0.3.0 → bottomtime-0.4.0}/src/bottomtime/store/schema.py +0 -0
- {bottomtime-0.3.0/src/bottomtime/store → bottomtime-0.4.0/src/bottomtime/verify}/__init__.py +0 -0
- {bottomtime-0.3.0 → bottomtime-0.4.0}/src/bottomtime/verify/crosscheck.py +0 -0
- {bottomtime-0.3.0 → bottomtime-0.4.0}/tests/test_cli.py +0 -0
- {bottomtime-0.3.0 → bottomtime-0.4.0}/tests/test_correlate.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bottomtime
|
|
3
|
-
Version: 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.
|
|
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,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
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 +0,0 @@
|
|
|
1
|
-
|
|
@@ -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
|
{bottomtime-0.3.0/src/bottomtime/decode → bottomtime-0.4.0/src/bottomtime/ingest}/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{bottomtime-0.3.0/src/bottomtime/ingest → bottomtime-0.4.0/src/bottomtime/match}/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{bottomtime-0.3.0/src/bottomtime/store → bottomtime-0.4.0/src/bottomtime/verify}/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|