bottomtime 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bottomtime/__init__.py +7 -0
- bottomtime/api.py +88 -0
- bottomtime/cli.py +107 -0
- bottomtime/decode/__init__.py +1 -0
- bottomtime/decode/pnf.py +435 -0
- bottomtime/ingest/__init__.py +1 -0
- bottomtime/ingest/archive.py +48 -0
- bottomtime/ingest/garmin_fit.py +303 -0
- bottomtime/ingest/shearwater_db.py +231 -0
- bottomtime/ingest/shearwater_xml.py +92 -0
- bottomtime/match/__init__.py +1 -0
- bottomtime/match/correlate.py +95 -0
- bottomtime/match/matcher.py +334 -0
- bottomtime/store/__init__.py +1 -0
- bottomtime/store/db.py +37 -0
- bottomtime/store/schema.py +209 -0
- bottomtime/verify/__init__.py +1 -0
- bottomtime/verify/crosscheck.py +229 -0
- bottomtime-0.1.0.dist-info/METADATA +108 -0
- bottomtime-0.1.0.dist-info/RECORD +23 -0
- bottomtime-0.1.0.dist-info/WHEEL +4 -0
- bottomtime-0.1.0.dist-info/entry_points.txt +2 -0
- bottomtime-0.1.0.dist-info/licenses/LICENSE +21 -0
bottomtime/__init__.py
ADDED
bottomtime/api.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Small read API for analysis code.
|
|
2
|
+
|
|
3
|
+
import bottomtime
|
|
4
|
+
dive = bottomtime.load_dive("data/dives.db", 291)
|
|
5
|
+
dive["sources"]["garmin"]["samples"]["depth_m"]
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sqlite3
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
_SAMPLE_TABLES = {"garmin": "garmin_samples", "shearwater": "shearwater_samples"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _columns_of(con: sqlite3.Connection, table: str) -> list[str]:
|
|
18
|
+
return [r[1] for r in con.execute(f"PRAGMA table_info({table})")]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_dive(db_path: str | Path, dive_number: int) -> dict:
|
|
22
|
+
"""Load one canonical dive with every member source log's samples as
|
|
23
|
+
column-oriented lists (ready for plotting / DataFrame construction)."""
|
|
24
|
+
con = sqlite3.connect(db_path)
|
|
25
|
+
con.row_factory = sqlite3.Row
|
|
26
|
+
try:
|
|
27
|
+
dive = con.execute(
|
|
28
|
+
"SELECT * FROM dives WHERE dive_number=?", (dive_number,)
|
|
29
|
+
).fetchone()
|
|
30
|
+
if dive is None:
|
|
31
|
+
raise KeyError(f"no dive with number {dive_number}")
|
|
32
|
+
out = {k: dive[k] for k in dive.keys()}
|
|
33
|
+
out["sources"] = {}
|
|
34
|
+
members = con.execute(
|
|
35
|
+
"SELECT sd.* FROM source_dives sd JOIN dive_members dm"
|
|
36
|
+
" ON dm.source_dive_id = sd.id WHERE dm.dive_id=?",
|
|
37
|
+
(dive["id"],),
|
|
38
|
+
).fetchall()
|
|
39
|
+
for sd in members:
|
|
40
|
+
table = _SAMPLE_TABLES[sd["source"]]
|
|
41
|
+
cols = [c for c in _columns_of(con, table) if c != "source_dive_id"]
|
|
42
|
+
rows = con.execute(
|
|
43
|
+
f"SELECT {', '.join(cols)} FROM {table}"
|
|
44
|
+
" WHERE source_dive_id=? ORDER BY t_s",
|
|
45
|
+
(sd["id"],),
|
|
46
|
+
).fetchall()
|
|
47
|
+
samples = {c: [r[i] for r in rows] for i, c in enumerate(cols)}
|
|
48
|
+
entry = {
|
|
49
|
+
"source_dive_id": sd["id"],
|
|
50
|
+
"source_key": sd["source_key"],
|
|
51
|
+
"start_time_utc": sd["start_time_utc"],
|
|
52
|
+
"start_time_local": sd["start_time_local"],
|
|
53
|
+
"duration_s": sd["duration_s"],
|
|
54
|
+
"max_depth_m": sd["max_depth_m"],
|
|
55
|
+
"mode": sd["mode"],
|
|
56
|
+
"header": json.loads(sd["header_json"]),
|
|
57
|
+
"samples": samples,
|
|
58
|
+
}
|
|
59
|
+
key = sd["source"]
|
|
60
|
+
if key in out["sources"]: # several logs from one source (splits)
|
|
61
|
+
existing = out["sources"][key]
|
|
62
|
+
if isinstance(existing, list):
|
|
63
|
+
existing.append(entry)
|
|
64
|
+
else:
|
|
65
|
+
out["sources"][key] = [existing, entry]
|
|
66
|
+
else:
|
|
67
|
+
out["sources"][key] = entry
|
|
68
|
+
return out
|
|
69
|
+
finally:
|
|
70
|
+
con.close()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def list_dives(db_path: str | Path, include_tests: bool = False) -> list[dict]:
|
|
74
|
+
con = sqlite3.connect(db_path)
|
|
75
|
+
con.row_factory = sqlite3.Row
|
|
76
|
+
try:
|
|
77
|
+
where = "" if include_tests else "WHERE d.is_test = 0"
|
|
78
|
+
return [
|
|
79
|
+
{k: r[k] for k in r.keys()}
|
|
80
|
+
for r in con.execute(
|
|
81
|
+
"SELECT d.*, group_concat(sd.source) AS sources FROM dives d"
|
|
82
|
+
" JOIN dive_members dm ON dm.dive_id = d.id"
|
|
83
|
+
" JOIN source_dives sd ON sd.id = dm.source_dive_id"
|
|
84
|
+
f" {where} GROUP BY d.id ORDER BY d.start_time_utc"
|
|
85
|
+
)
|
|
86
|
+
]
|
|
87
|
+
finally:
|
|
88
|
+
con.close()
|
bottomtime/cli.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""bottomtime command line interface.
|
|
2
|
+
|
|
3
|
+
bottomtime init
|
|
4
|
+
bottomtime ingest garmin <fit-dir>
|
|
5
|
+
bottomtime ingest shearwater <dive_data.db>
|
|
6
|
+
bottomtime match
|
|
7
|
+
bottomtime verify [--xml-dir DIR]
|
|
8
|
+
bottomtime status
|
|
9
|
+
|
|
10
|
+
All commands are idempotent: re-running an ingest skips anything already
|
|
11
|
+
stored (content-hash keyed), match recomputes only 'auto' links.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .store import db as store_db
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main(argv: list[str] | None = None) -> int:
|
|
24
|
+
parser = argparse.ArgumentParser(prog="bottomtime")
|
|
25
|
+
parser.add_argument("--db", default="data/dives.db", help="path to the dive store")
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--archive", default="archive", help="directory for verbatim raw source copies"
|
|
28
|
+
)
|
|
29
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
30
|
+
|
|
31
|
+
sub.add_parser("init", help="create an empty dive store")
|
|
32
|
+
|
|
33
|
+
p_ingest = sub.add_parser("ingest", help="ingest a data source")
|
|
34
|
+
ingest_sub = p_ingest.add_subparsers(dest="source", required=True)
|
|
35
|
+
p_garmin = ingest_sub.add_parser("garmin", help="a directory of FIT files")
|
|
36
|
+
p_garmin.add_argument("fit_dir", type=Path)
|
|
37
|
+
p_garmin.add_argument(
|
|
38
|
+
"--index",
|
|
39
|
+
default="dives_index.json",
|
|
40
|
+
help="Garmin Connect index JSON filename inside fit-dir",
|
|
41
|
+
)
|
|
42
|
+
p_sw = ingest_sub.add_parser("shearwater", help="a Shearwater Cloud dive_data.db")
|
|
43
|
+
p_sw.add_argument("db_path", type=Path)
|
|
44
|
+
|
|
45
|
+
sub.add_parser("match", help="match dual-computer dives, build canonical dives")
|
|
46
|
+
|
|
47
|
+
p_verify = sub.add_parser("verify", help="run the verification suite")
|
|
48
|
+
p_verify.add_argument("--xml-dir", type=Path, default=None,
|
|
49
|
+
help="directory of Shearwater XML exports to cross-check")
|
|
50
|
+
|
|
51
|
+
sub.add_parser("status", help="summarize the store")
|
|
52
|
+
|
|
53
|
+
args = parser.parse_args(argv)
|
|
54
|
+
con = store_db.connect(args.db)
|
|
55
|
+
archive_dir = Path(args.archive)
|
|
56
|
+
|
|
57
|
+
if args.command == "init":
|
|
58
|
+
print(f"initialized {args.db}")
|
|
59
|
+
return 0
|
|
60
|
+
|
|
61
|
+
if args.command == "ingest" and args.source == "garmin":
|
|
62
|
+
from .ingest.garmin_fit import ingest_garmin_dir
|
|
63
|
+
|
|
64
|
+
stats = ingest_garmin_dir(con, archive_dir, args.fit_dir, args.index)
|
|
65
|
+
print(f"garmin: {stats}")
|
|
66
|
+
return 1 if stats["failed"] else 0
|
|
67
|
+
|
|
68
|
+
if args.command == "ingest" and args.source == "shearwater":
|
|
69
|
+
from .ingest.shearwater_db import ingest_shearwater_db
|
|
70
|
+
|
|
71
|
+
stats = ingest_shearwater_db(con, archive_dir, args.db_path)
|
|
72
|
+
print(f"shearwater: {stats}")
|
|
73
|
+
return 1 if stats["failed"] else 0
|
|
74
|
+
|
|
75
|
+
if args.command == "match":
|
|
76
|
+
from .match.matcher import run_match
|
|
77
|
+
|
|
78
|
+
stats = run_match(con)
|
|
79
|
+
print(f"match: {stats}")
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
if args.command == "verify":
|
|
83
|
+
from .verify.crosscheck import run_verify
|
|
84
|
+
|
|
85
|
+
return run_verify(con, args.xml_dir)
|
|
86
|
+
|
|
87
|
+
if args.command == "status":
|
|
88
|
+
for line in con.execute(
|
|
89
|
+
"SELECT source || ' source dives: ' || count(*) FROM source_dives GROUP BY source"
|
|
90
|
+
):
|
|
91
|
+
print(line[0])
|
|
92
|
+
for line in con.execute(
|
|
93
|
+
"SELECT 'canonical dives: ' || count(*) || ' (' ||"
|
|
94
|
+
" sum(CASE WHEN is_test THEN 1 ELSE 0 END) || ' test)' FROM dives"
|
|
95
|
+
):
|
|
96
|
+
print(line[0])
|
|
97
|
+
for line in con.execute(
|
|
98
|
+
"SELECT 'matched pairs: ' || count(*) FROM matches WHERE status IN ('auto','confirmed')"
|
|
99
|
+
):
|
|
100
|
+
print(line[0])
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
return 2
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
sys.exit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
bottomtime/decode/pnf.py
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
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
|
+
# Sample bytes not (yet) assigned a meaning; kept per sample as b<offset>.
|
|
69
|
+
# 18 (battery), 24 (ceiling), 25 (gf99), 26-27 (@+5) were identified
|
|
70
|
+
# empirically and promoted in v2. 30-31 read 0xFFFF (sentinel) so far.
|
|
71
|
+
UNMAPPED_SAMPLE_BYTES = (11, 17, 30, 31)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _u16be(data: bytes, off: int) -> int:
|
|
75
|
+
return struct.unpack_from(">H", data, off)[0]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _u24be(data: bytes, off: int) -> int:
|
|
79
|
+
return (data[off] << 16) | (data[off + 1] << 8) | data[off + 2]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _u32be(data: bytes, off: int) -> int:
|
|
83
|
+
return struct.unpack_from(">I", data, off)[0]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _bcd2dec(value: int) -> int:
|
|
87
|
+
return ((value >> 4) & 0x0F) * 10 + (value & 0x0F)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _bcd_bytes(data: bytes, off: int, n: int) -> int:
|
|
91
|
+
result = 0
|
|
92
|
+
for i in range(n):
|
|
93
|
+
result = result * 100 + _bcd2dec(data[off + i])
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class PnfSample:
|
|
99
|
+
t_s: float
|
|
100
|
+
depth_m: float | None = None
|
|
101
|
+
temp_c: float | None = None
|
|
102
|
+
stop_depth_m: float | None = None
|
|
103
|
+
stop_or_ndl_min: float | None = None
|
|
104
|
+
tts_min: float | None = None
|
|
105
|
+
in_deco: int | None = None
|
|
106
|
+
avg_ppo2: float | None = None
|
|
107
|
+
o2_pct: float | None = None
|
|
108
|
+
he_pct: float | None = None
|
|
109
|
+
setpoint: float | None = None
|
|
110
|
+
cns_pct: float | None = None
|
|
111
|
+
gf99: float | None = None
|
|
112
|
+
ceiling_m: float | None = None
|
|
113
|
+
sensor1_raw: int | None = None
|
|
114
|
+
sensor2_raw: int | None = None
|
|
115
|
+
sensor3_raw: int | None = None
|
|
116
|
+
sensor1_ppo2: float | None = None
|
|
117
|
+
sensor2_ppo2: float | None = None
|
|
118
|
+
sensor3_ppo2: float | None = None
|
|
119
|
+
battery_v: float | None = None
|
|
120
|
+
status_flags: int | None = None
|
|
121
|
+
tank0_psi: float | None = None
|
|
122
|
+
tank1_psi: float | None = None
|
|
123
|
+
gas_time_min: float | None = None
|
|
124
|
+
extra: dict = field(default_factory=dict)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class PnfGas:
|
|
129
|
+
index: int
|
|
130
|
+
o2_pct: int
|
|
131
|
+
he_pct: int
|
|
132
|
+
diluent: bool
|
|
133
|
+
enabled: bool
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class PnfEvent:
|
|
138
|
+
t_s: float
|
|
139
|
+
event: int
|
|
140
|
+
timestamp: int
|
|
141
|
+
w1: int
|
|
142
|
+
w2: int
|
|
143
|
+
raw_hex: str
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass
|
|
147
|
+
class PnfDive:
|
|
148
|
+
header: dict
|
|
149
|
+
samples: list[PnfSample]
|
|
150
|
+
gases: list[PnfGas]
|
|
151
|
+
events: list[PnfEvent]
|
|
152
|
+
unknown_records: list[tuple[str, int, bytes]] # (type_key, seq, raw)
|
|
153
|
+
sample_interval_ms: int
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def decompress(blob: bytes) -> bytes:
|
|
157
|
+
"""Strip the 4-byte LE length prefix and gunzip; verify the length."""
|
|
158
|
+
expected = struct.unpack_from("<I", blob, 0)[0]
|
|
159
|
+
data = gzip.decompress(blob[4:])
|
|
160
|
+
if len(data) != expected:
|
|
161
|
+
raise ValueError(f"PNF length mismatch: prefix {expected}, got {len(data)}")
|
|
162
|
+
if len(data) % RECORD_SIZE:
|
|
163
|
+
raise ValueError(f"PNF payload not a multiple of {RECORD_SIZE} bytes")
|
|
164
|
+
return data
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def decode(blob: bytes) -> PnfDive:
|
|
168
|
+
"""Decode a raw sw-pnf blob (as stored in Shearwater Cloud's log_data)."""
|
|
169
|
+
data = decompress(blob)
|
|
170
|
+
|
|
171
|
+
opening: dict[int, int] = {}
|
|
172
|
+
closing: dict[int, int] = {}
|
|
173
|
+
final_off: int | None = None
|
|
174
|
+
raw_records: list[tuple[int, int]] = [] # (type, offset), stream order
|
|
175
|
+
|
|
176
|
+
for off in range(0, len(data), RECORD_SIZE):
|
|
177
|
+
rec = data[off : off + RECORD_SIZE]
|
|
178
|
+
if rec == b"\x00" * RECORD_SIZE:
|
|
179
|
+
continue
|
|
180
|
+
rtype = rec[0]
|
|
181
|
+
raw_records.append((rtype, off))
|
|
182
|
+
if OPENING_0 <= rtype <= OPENING_9:
|
|
183
|
+
opening[rtype - OPENING_0] = off
|
|
184
|
+
elif CLOSING_0 <= rtype <= CLOSING_9:
|
|
185
|
+
closing[rtype - CLOSING_0] = off
|
|
186
|
+
elif rtype == FINAL:
|
|
187
|
+
final_off = off
|
|
188
|
+
|
|
189
|
+
for i in range(5):
|
|
190
|
+
if i not in opening or i not in closing:
|
|
191
|
+
raise ValueError(f"missing opening/closing record {i}")
|
|
192
|
+
|
|
193
|
+
header = _parse_header(data, opening, closing, final_off)
|
|
194
|
+
interval_ms = header["sample_interval_ms"]
|
|
195
|
+
calibrated = header["sensor_calibrated_mask"]
|
|
196
|
+
calibration = header["sensor_calibration"]
|
|
197
|
+
imperial = header["imperial"]
|
|
198
|
+
|
|
199
|
+
gases = [
|
|
200
|
+
PnfGas(
|
|
201
|
+
index=i,
|
|
202
|
+
o2_pct=header["gas_o2"][i],
|
|
203
|
+
he_pct=header["gas_he"][i],
|
|
204
|
+
diluent=i >= 5,
|
|
205
|
+
enabled=bool(header["gas_enabled_mask"] & (1 << i)),
|
|
206
|
+
)
|
|
207
|
+
for i in range(10)
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
samples: list[PnfSample] = []
|
|
211
|
+
events: list[PnfEvent] = []
|
|
212
|
+
unknown: list[tuple[str, int, bytes]] = []
|
|
213
|
+
seq = 0
|
|
214
|
+
t = 0.0
|
|
215
|
+
|
|
216
|
+
for rtype, off in raw_records:
|
|
217
|
+
rec = data[off : off + RECORD_SIZE]
|
|
218
|
+
if rtype in (DIVE_SAMPLE, AVELO_SAMPLE):
|
|
219
|
+
samples.append(
|
|
220
|
+
_parse_sample(rec, t, imperial, calibrated, calibration, rtype)
|
|
221
|
+
)
|
|
222
|
+
t += interval_ms / 1000.0
|
|
223
|
+
elif rtype == FREEDIVE_SAMPLE:
|
|
224
|
+
for i in range(4):
|
|
225
|
+
sub = rec[i * 8 : (i + 1) * 8]
|
|
226
|
+
if sub == b"\x00" * 8:
|
|
227
|
+
break
|
|
228
|
+
pressure_mbar = _u16be(sub, 1)
|
|
229
|
+
depth = (
|
|
230
|
+
(pressure_mbar - header["atmospheric_mbar"])
|
|
231
|
+
* 100.0
|
|
232
|
+
/ (header["water_density"] * 9.80665)
|
|
233
|
+
)
|
|
234
|
+
temp = struct.unpack_from(">h", sub, 3)[0] / 10.0
|
|
235
|
+
samples.append(PnfSample(t_s=t, depth_m=round(depth, 2), temp_c=temp))
|
|
236
|
+
t += interval_ms / 1000.0
|
|
237
|
+
elif rtype == DIVE_SAMPLE_EXT:
|
|
238
|
+
s = samples[-1] if samples else None
|
|
239
|
+
for i, key in enumerate(("tank2_psi", "tank3_psi")):
|
|
240
|
+
v = _u16be(rec, 1 + i * 2)
|
|
241
|
+
if v < 0xFFF0 and (v & 0x0FFF) and s is not None:
|
|
242
|
+
s.extra[key] = (v & 0x0FFF) * 2
|
|
243
|
+
for i, key in enumerate(("tank_dil_psi", "tank_o2_psi")):
|
|
244
|
+
v = _u16be(rec, 1 + 4 + i * 2)
|
|
245
|
+
if v and s is not None:
|
|
246
|
+
s.extra[key] = v * 2
|
|
247
|
+
elif rtype == INFO_EVENT:
|
|
248
|
+
events.append(
|
|
249
|
+
PnfEvent(
|
|
250
|
+
t_s=t,
|
|
251
|
+
event=rec[1],
|
|
252
|
+
timestamp=_u32be(rec, 4),
|
|
253
|
+
w1=_u32be(rec, 8),
|
|
254
|
+
w2=_u32be(rec, 12),
|
|
255
|
+
raw_hex=rec.hex(),
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
elif OPENING_0 <= rtype <= OPENING_9 or CLOSING_0 <= rtype <= CLOSING_9 or rtype == FINAL:
|
|
259
|
+
pass # consumed by _parse_header; raw copies kept in header dict
|
|
260
|
+
else:
|
|
261
|
+
unknown.append((f"0x{rtype:02x}", seq, rec))
|
|
262
|
+
seq += 1
|
|
263
|
+
|
|
264
|
+
return PnfDive(
|
|
265
|
+
header=header,
|
|
266
|
+
samples=samples,
|
|
267
|
+
gases=gases,
|
|
268
|
+
events=events,
|
|
269
|
+
unknown_records=unknown,
|
|
270
|
+
sample_interval_ms=interval_ms,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _parse_sample(
|
|
275
|
+
rec: bytes,
|
|
276
|
+
t: float,
|
|
277
|
+
imperial: bool,
|
|
278
|
+
calibrated: int,
|
|
279
|
+
calibration: list[float],
|
|
280
|
+
rtype: int,
|
|
281
|
+
) -> PnfSample:
|
|
282
|
+
ft = 0.3048
|
|
283
|
+
depth = _u16be(rec, 1) / 10.0
|
|
284
|
+
stop_depth = float(_u16be(rec, 3))
|
|
285
|
+
if imperial:
|
|
286
|
+
depth *= ft
|
|
287
|
+
stop_depth *= ft
|
|
288
|
+
|
|
289
|
+
temp = struct.unpack_from("b", rec, 14)[0]
|
|
290
|
+
if temp < 0:
|
|
291
|
+
temp = min(temp + 102, 0)
|
|
292
|
+
if imperial:
|
|
293
|
+
temp = (temp - 32.0) * 5.0 / 9.0
|
|
294
|
+
|
|
295
|
+
status = rec[12] if rtype != AVELO_SAMPLE else 0
|
|
296
|
+
ccr = (status & FLAG_OC) == 0 if rtype != AVELO_SAMPLE else False
|
|
297
|
+
|
|
298
|
+
sample = PnfSample(
|
|
299
|
+
t_s=t,
|
|
300
|
+
depth_m=round(depth, 2),
|
|
301
|
+
temp_c=float(temp),
|
|
302
|
+
stop_depth_m=stop_depth,
|
|
303
|
+
tts_min=float(_u16be(rec, 5)),
|
|
304
|
+
stop_or_ndl_min=float(rec[10]),
|
|
305
|
+
in_deco=1 if _u16be(rec, 3) else 0,
|
|
306
|
+
avg_ppo2=rec[7] / 100.0,
|
|
307
|
+
o2_pct=float(rec[8]),
|
|
308
|
+
he_pct=float(rec[9]),
|
|
309
|
+
status_flags=status,
|
|
310
|
+
sensor1_raw=rec[13],
|
|
311
|
+
sensor2_raw=rec[15],
|
|
312
|
+
sensor3_raw=rec[16],
|
|
313
|
+
cns_pct=float(rec[23]),
|
|
314
|
+
ceiling_m=float(rec[24]) if rec[24] != 0xFF else None,
|
|
315
|
+
gf99=float(rec[25]) if rec[25] != 0xFF else None, # 0xFF = not available
|
|
316
|
+
battery_v=rec[18] / 100.0,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
if ccr:
|
|
320
|
+
sample.setpoint = rec[19] / 100.0
|
|
321
|
+
if not (status & FLAG_PPO2_EXTERNAL):
|
|
322
|
+
if calibrated & 0x01:
|
|
323
|
+
sample.sensor1_ppo2 = round(rec[13] * calibration[0], 3)
|
|
324
|
+
if calibrated & 0x02:
|
|
325
|
+
sample.sensor2_ppo2 = round(rec[15] * calibration[1], 3)
|
|
326
|
+
if calibrated & 0x04:
|
|
327
|
+
sample.sensor3_ppo2 = round(rec[16] * calibration[2], 3)
|
|
328
|
+
|
|
329
|
+
for idx, key in ((20, "tank1_psi"), (28, "tank0_psi")):
|
|
330
|
+
v = _u16be(rec, idx)
|
|
331
|
+
if v < 0xFFF0:
|
|
332
|
+
pressure = v & 0x0FFF
|
|
333
|
+
battery = (v >> 12) & 0x0F
|
|
334
|
+
if key == "tank1_psi":
|
|
335
|
+
sample.tank1_psi = pressure * 2.0
|
|
336
|
+
else:
|
|
337
|
+
sample.tank0_psi = pressure * 2.0
|
|
338
|
+
if battery:
|
|
339
|
+
sample.extra[key + "_battery"] = battery
|
|
340
|
+
|
|
341
|
+
if rec[22] < 0xF0:
|
|
342
|
+
sample.gas_time_min = float(rec[22])
|
|
343
|
+
|
|
344
|
+
# @+5: TTS if the diver stayed 5 more minutes (minutes, u16be)
|
|
345
|
+
at_plus_five = _u16be(rec, 26)
|
|
346
|
+
if at_plus_five != 0xFFFF:
|
|
347
|
+
sample.extra["at_plus_five_min"] = at_plus_five
|
|
348
|
+
|
|
349
|
+
for b in UNMAPPED_SAMPLE_BYTES:
|
|
350
|
+
if rec[b]:
|
|
351
|
+
sample.extra[f"b{b}"] = rec[b]
|
|
352
|
+
|
|
353
|
+
return sample
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _parse_header(
|
|
357
|
+
data: bytes,
|
|
358
|
+
opening: dict[int, int],
|
|
359
|
+
closing: dict[int, int],
|
|
360
|
+
final_off: int | None,
|
|
361
|
+
) -> dict:
|
|
362
|
+
o = opening
|
|
363
|
+
c = closing
|
|
364
|
+
|
|
365
|
+
logversion = data[o[4] + 16]
|
|
366
|
+
imperial = data[o[0] + 8] == 1
|
|
367
|
+
|
|
368
|
+
gas_o2 = [data[o[0] + 20 + i] for i in range(10)]
|
|
369
|
+
gas_he = [data[o[0] + 30 + i] for i in range(2)] + [
|
|
370
|
+
data[o[1] + 1 + i] for i in range(8)
|
|
371
|
+
]
|
|
372
|
+
|
|
373
|
+
calib_base = o[3] + 6
|
|
374
|
+
calibrated_mask = data[calib_base]
|
|
375
|
+
calibration = [
|
|
376
|
+
_u16be(data, calib_base + 1 + i * 2) / 100000.0 for i in range(3)
|
|
377
|
+
]
|
|
378
|
+
|
|
379
|
+
interval_ms = 10000
|
|
380
|
+
if logversion >= 9 and 5 in o:
|
|
381
|
+
interval_ms = _u16be(data, o[5] + 23) or 10000
|
|
382
|
+
|
|
383
|
+
max_depth = _u16be(data, c[0] + 4) / 10.0
|
|
384
|
+
if imperial:
|
|
385
|
+
max_depth *= 0.3048
|
|
386
|
+
|
|
387
|
+
header = {
|
|
388
|
+
"logversion": logversion,
|
|
389
|
+
"imperial": imperial,
|
|
390
|
+
"divemode": DIVE_MODES.get(data[o[4] + 1], str(data[o[4] + 1])),
|
|
391
|
+
"gf_low": data[o[0] + 4],
|
|
392
|
+
"gf_high": data[o[0] + 5],
|
|
393
|
+
"deco_model": DECO_MODELS.get(data[o[2] + 18], str(data[o[2] + 18])),
|
|
394
|
+
"vpmb_conservatism": data[o[2] + 19],
|
|
395
|
+
"atmospheric_mbar": _u16be(data, o[1] + 16),
|
|
396
|
+
"water_density": _u16be(data, o[3] + 3),
|
|
397
|
+
"sensor_calibrated_mask": calibrated_mask,
|
|
398
|
+
"sensor_calibration": calibration,
|
|
399
|
+
"gas_o2": gas_o2,
|
|
400
|
+
"gas_he": gas_he,
|
|
401
|
+
"gas_enabled_mask": _u16be(data, o[4] + 17),
|
|
402
|
+
"ai_mode": data[o[4] + 28],
|
|
403
|
+
"sample_interval_ms": interval_ms,
|
|
404
|
+
"max_depth_m": round(max_depth, 2),
|
|
405
|
+
"divetime_s": _u24be(data, c[0] + 6),
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if final_off is not None:
|
|
409
|
+
header["computer_serial"] = _u32be(data, final_off + 2)
|
|
410
|
+
header["computer_firmware"] = _bcd2dec(data[final_off + 10])
|
|
411
|
+
header["computer_model"] = data[final_off + 13]
|
|
412
|
+
|
|
413
|
+
if logversion >= 17:
|
|
414
|
+
for name, recs in (("gnss_start", o), ("gnss_end", c)):
|
|
415
|
+
if 9 in recs:
|
|
416
|
+
off = recs[9]
|
|
417
|
+
fix = data[off + 16]
|
|
418
|
+
if fix in (2, 3): # 2D / 3D fix
|
|
419
|
+
lat = struct.unpack_from(">i", data, off + 21)[0] / 100000.0
|
|
420
|
+
lon = struct.unpack_from(">i", data, off + 25)[0] / 100000.0
|
|
421
|
+
header[name] = {"fix": fix, "lat": lat, "lon": lon}
|
|
422
|
+
|
|
423
|
+
# Verbatim copies of every config record, keyed by record type.
|
|
424
|
+
header["raw_opening"] = {
|
|
425
|
+
f"0x{OPENING_0 + i:02x}": data[off : off + RECORD_SIZE].hex()
|
|
426
|
+
for i, off in sorted(o.items())
|
|
427
|
+
}
|
|
428
|
+
header["raw_closing"] = {
|
|
429
|
+
f"0x{CLOSING_0 + i:02x}": data[off : off + RECORD_SIZE].hex()
|
|
430
|
+
for i, off in sorted(c.items())
|
|
431
|
+
}
|
|
432
|
+
if final_off is not None:
|
|
433
|
+
header["raw_final"] = data[final_off : final_off + RECORD_SIZE].hex()
|
|
434
|
+
|
|
435
|
+
return header
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Content-addressed archive of verbatim raw source files.
|
|
2
|
+
|
|
3
|
+
Every ingested file is copied (if absent) to <archive_dir>/<kind>/<sha256><ext>
|
|
4
|
+
and registered in raw_artifacts. The archive, not the database, is the
|
|
5
|
+
byte-level source of truth; re-running is a no-op.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import shutil
|
|
12
|
+
import sqlite3
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from ..store.db import utcnow
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def sha256_file(path: Path) -> str:
|
|
19
|
+
h = hashlib.sha256()
|
|
20
|
+
with open(path, "rb") as f:
|
|
21
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
22
|
+
h.update(chunk)
|
|
23
|
+
return h.hexdigest()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def archive_file(
|
|
27
|
+
con: sqlite3.Connection, archive_dir: Path, kind: str, path: Path
|
|
28
|
+
) -> tuple[int, str]:
|
|
29
|
+
"""Archive path under kind; return (artifact_id, sha256)."""
|
|
30
|
+
digest = sha256_file(path)
|
|
31
|
+
row = con.execute(
|
|
32
|
+
"SELECT id FROM raw_artifacts WHERE sha256=?", (digest,)
|
|
33
|
+
).fetchone()
|
|
34
|
+
if row:
|
|
35
|
+
return row[0], digest
|
|
36
|
+
|
|
37
|
+
dest_dir = archive_dir / kind
|
|
38
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
dest = dest_dir / (digest + path.suffix.lower())
|
|
40
|
+
if not dest.exists():
|
|
41
|
+
shutil.copy2(path, dest)
|
|
42
|
+
|
|
43
|
+
cur = con.execute(
|
|
44
|
+
"INSERT INTO raw_artifacts (kind, sha256, size_bytes, original_path, archive_path, imported_at)"
|
|
45
|
+
" VALUES (?,?,?,?,?,?)",
|
|
46
|
+
(kind, digest, path.stat().st_size, str(path), str(dest), utcnow()),
|
|
47
|
+
)
|
|
48
|
+
return cur.lastrowid, digest
|