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
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Ingest Garmin Descent FIT activity files.
|
|
2
|
+
|
|
3
|
+
Decodes with the official garmin-fit-sdk. Known dive channels land in typed
|
|
4
|
+
columns of garmin_samples; every other field (named or unknown-numeric) is
|
|
5
|
+
preserved per sample in extra_json. Whole message types the FIT profile does
|
|
6
|
+
not know (233, 325, ...) are preserved per occurrence in undecoded_payloads.
|
|
7
|
+
|
|
8
|
+
An optional Garmin Connect index JSON (a list of activity dicts with
|
|
9
|
+
activityId, startTimeGMT, startTimeLocal, ...) supplies true UTC offsets and
|
|
10
|
+
Connect metadata; without it, times fall back to FIT timestamps.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import sqlite3
|
|
17
|
+
from datetime import datetime, timedelta, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from garmin_fit_sdk import Decoder, Stream
|
|
21
|
+
|
|
22
|
+
from ..store.db import source_dive_exists, utcnow
|
|
23
|
+
from .archive import archive_file
|
|
24
|
+
|
|
25
|
+
DECODER_VERSION = 1
|
|
26
|
+
|
|
27
|
+
FIT_EPOCH_OFFSET = 631065600 # FIT timestamps count from 1989-12-31 00:00 UTC
|
|
28
|
+
SEMICIRCLE = 180.0 / 2**31
|
|
29
|
+
|
|
30
|
+
# record message field -> garmin_samples column
|
|
31
|
+
RECORD_COLUMNS = {
|
|
32
|
+
"depth": "depth_m",
|
|
33
|
+
"temperature": "temp_c",
|
|
34
|
+
"heart_rate": "heart_rate",
|
|
35
|
+
"absolute_pressure": "absolute_pressure",
|
|
36
|
+
"next_stop_depth": "next_stop_depth_m",
|
|
37
|
+
"next_stop_time": "next_stop_time_s",
|
|
38
|
+
"time_to_surface": "tts_s",
|
|
39
|
+
"ndl_time": "ndl_s",
|
|
40
|
+
"cns_load": "cns_pct",
|
|
41
|
+
"n2_load": "n2_load",
|
|
42
|
+
"po2": "po2",
|
|
43
|
+
"ascent_rate": "ascent_rate",
|
|
44
|
+
"air_time_remaining": "air_time_remaining_s",
|
|
45
|
+
"pressure_sac": "pressure_sac",
|
|
46
|
+
"volume_sac": "volume_sac",
|
|
47
|
+
"rmv": "rmv",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# message types routed to dedicated tables rather than header_json
|
|
51
|
+
NON_HEADER_KEYS = {"record_mesgs", "event_mesgs", "dive_alarm_mesgs", "dive_gas_mesgs"}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _jsonable(value):
|
|
55
|
+
if isinstance(value, (bytes, bytearray)):
|
|
56
|
+
return {"__hex__": bytes(value).hex()}
|
|
57
|
+
if isinstance(value, (list, tuple)):
|
|
58
|
+
return [_jsonable(v) for v in value]
|
|
59
|
+
if isinstance(value, dict):
|
|
60
|
+
return {str(k): _jsonable(v) for k, v in value.items()}
|
|
61
|
+
if isinstance(value, float) and value != value: # NaN
|
|
62
|
+
return None
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_index(index_path: Path | None) -> dict[str, dict]:
|
|
67
|
+
if not index_path or not index_path.exists():
|
|
68
|
+
return {}
|
|
69
|
+
entries = json.loads(index_path.read_text())
|
|
70
|
+
return {str(e["activityId"]): e for e in entries}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _parse_connect_time(value: str) -> datetime:
|
|
74
|
+
return datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def ingest_fit_file(
|
|
78
|
+
con: sqlite3.Connection,
|
|
79
|
+
archive_dir: Path,
|
|
80
|
+
path: Path,
|
|
81
|
+
index: dict[str, dict],
|
|
82
|
+
) -> str:
|
|
83
|
+
"""Ingest one FIT file. Returns 'ingested' | 'skipped'."""
|
|
84
|
+
artifact_id, digest = archive_file(con, archive_dir, "fit", path)
|
|
85
|
+
if source_dive_exists(con, "garmin", digest):
|
|
86
|
+
return "skipped"
|
|
87
|
+
|
|
88
|
+
stream = Stream.from_file(str(path))
|
|
89
|
+
decoder = Decoder(stream)
|
|
90
|
+
messages, errors = decoder.read(convert_datetimes_to_dates=False)
|
|
91
|
+
if errors:
|
|
92
|
+
raise ValueError(f"FIT decode errors in {path.name}: {errors}")
|
|
93
|
+
|
|
94
|
+
records = messages.get("record_mesgs", [])
|
|
95
|
+
# A file can carry summaries but no sample stream (rare device hiccup);
|
|
96
|
+
# keep it as a header-only source dive rather than losing the dive.
|
|
97
|
+
if records:
|
|
98
|
+
t0 = records[0]["timestamp"]
|
|
99
|
+
else:
|
|
100
|
+
session = (messages.get("session_mesgs") or [{}])[0]
|
|
101
|
+
file_id_msg = (messages.get("file_id_mesgs") or [{}])[0]
|
|
102
|
+
t0 = session.get("start_time") or file_id_msg.get("time_created")
|
|
103
|
+
if t0 is None:
|
|
104
|
+
raise ValueError(f"no records and no start time in {path.name}")
|
|
105
|
+
|
|
106
|
+
file_id = (messages.get("file_id_mesgs") or [{}])[0]
|
|
107
|
+
activity_id = str(file_id.get("serial_number", ""))
|
|
108
|
+
# Garmin Connect's activityId is not in the file; recover it from the
|
|
109
|
+
# index by matching start time, else from a '<...>_<id>.fit' filename.
|
|
110
|
+
entry = None
|
|
111
|
+
stem_id = path.stem.rsplit("_", 1)[-1]
|
|
112
|
+
if stem_id.isdigit() and stem_id in index:
|
|
113
|
+
entry = index[stem_id]
|
|
114
|
+
activity_id = stem_id
|
|
115
|
+
else:
|
|
116
|
+
start_utc_probe = datetime.fromtimestamp(
|
|
117
|
+
t0 + FIT_EPOCH_OFFSET, tz=timezone.utc
|
|
118
|
+
).strftime("%Y-%m-%d %H:%M:%S")
|
|
119
|
+
for aid, e in index.items():
|
|
120
|
+
if e.get("startTimeGMT") == start_utc_probe:
|
|
121
|
+
entry, activity_id = e, aid
|
|
122
|
+
break
|
|
123
|
+
|
|
124
|
+
start_dt_utc = datetime.fromtimestamp(t0 + FIT_EPOCH_OFFSET, tz=timezone.utc)
|
|
125
|
+
start_utc = start_dt_utc.strftime("%Y-%m-%d %H:%M:%S")
|
|
126
|
+
start_local, utc_offset_min = start_utc, None
|
|
127
|
+
if entry:
|
|
128
|
+
gmt = _parse_connect_time(entry["startTimeGMT"])
|
|
129
|
+
local = _parse_connect_time(entry["startTimeLocal"])
|
|
130
|
+
utc_offset_min = int((local - gmt).total_seconds() // 60)
|
|
131
|
+
start_local = entry["startTimeLocal"]
|
|
132
|
+
else:
|
|
133
|
+
# activity message carries the device's local timestamp
|
|
134
|
+
act = (messages.get("activity_mesgs") or [{}])[0]
|
|
135
|
+
if "local_timestamp" in act and "timestamp" in act:
|
|
136
|
+
utc_offset_min = int((act["local_timestamp"] - act["timestamp"]) // 60)
|
|
137
|
+
start_local = (
|
|
138
|
+
start_dt_utc + timedelta(minutes=utc_offset_min)
|
|
139
|
+
).strftime("%Y-%m-%d %H:%M:%S")
|
|
140
|
+
|
|
141
|
+
header = {"connect_index": entry}
|
|
142
|
+
for key, msgs in messages.items():
|
|
143
|
+
if isinstance(key, str) and key.endswith("_mesgs") and key not in NON_HEADER_KEYS:
|
|
144
|
+
header[key] = _jsonable(msgs)
|
|
145
|
+
|
|
146
|
+
summaries = messages.get("dive_summary_mesgs") or []
|
|
147
|
+
session = (messages.get("session_mesgs") or [{}])[0]
|
|
148
|
+
duration = entry.get("duration") if entry else session.get("total_elapsed_time")
|
|
149
|
+
max_depth = max((r.get("depth") or 0 for r in records), default=None)
|
|
150
|
+
if max_depth is None and session.get("max_depth") is not None:
|
|
151
|
+
max_depth = session["max_depth"]
|
|
152
|
+
avg_depth = next(
|
|
153
|
+
(s.get("avg_depth") for s in summaries if s.get("avg_depth") is not None), None
|
|
154
|
+
)
|
|
155
|
+
mode = entry.get("activityType", {}).get("typeKey") if entry else None
|
|
156
|
+
|
|
157
|
+
intervals = [
|
|
158
|
+
records[i + 1]["timestamp"] - records[i]["timestamp"]
|
|
159
|
+
for i in range(min(len(records) - 1, 50))
|
|
160
|
+
]
|
|
161
|
+
interval_ms = int(sorted(intervals)[len(intervals) // 2] * 1000) if intervals else None
|
|
162
|
+
|
|
163
|
+
cur = con.execute(
|
|
164
|
+
"INSERT INTO source_dives (source, artifact_id, source_key, content_hash,"
|
|
165
|
+
" decoder_version, start_time_utc, start_time_local, utc_offset_min,"
|
|
166
|
+
" duration_s, max_depth_m, avg_depth_m, sample_interval_ms, mode, header_json)"
|
|
167
|
+
" VALUES ('garmin',?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
168
|
+
(
|
|
169
|
+
artifact_id,
|
|
170
|
+
activity_id,
|
|
171
|
+
digest,
|
|
172
|
+
DECODER_VERSION,
|
|
173
|
+
start_utc,
|
|
174
|
+
start_local,
|
|
175
|
+
utc_offset_min,
|
|
176
|
+
duration,
|
|
177
|
+
max_depth,
|
|
178
|
+
avg_depth,
|
|
179
|
+
interval_ms,
|
|
180
|
+
mode,
|
|
181
|
+
json.dumps(_jsonable(header), separators=(",", ":")),
|
|
182
|
+
),
|
|
183
|
+
)
|
|
184
|
+
dive_id = cur.lastrowid
|
|
185
|
+
|
|
186
|
+
sample_rows = []
|
|
187
|
+
for rec in records:
|
|
188
|
+
row = {col: None for col in RECORD_COLUMNS.values()}
|
|
189
|
+
extra = {}
|
|
190
|
+
lat = lon = None
|
|
191
|
+
for k, v in rec.items():
|
|
192
|
+
if k == "timestamp":
|
|
193
|
+
continue
|
|
194
|
+
elif k == "position_lat":
|
|
195
|
+
lat = v * SEMICIRCLE
|
|
196
|
+
elif k == "position_long":
|
|
197
|
+
lon = v * SEMICIRCLE
|
|
198
|
+
elif isinstance(k, str) and k in RECORD_COLUMNS:
|
|
199
|
+
row[RECORD_COLUMNS[k]] = v
|
|
200
|
+
else:
|
|
201
|
+
extra[str(k)] = _jsonable(v)
|
|
202
|
+
sample_rows.append(
|
|
203
|
+
(
|
|
204
|
+
dive_id,
|
|
205
|
+
float(rec["timestamp"] - t0),
|
|
206
|
+
row["depth_m"], row["temp_c"], row["heart_rate"],
|
|
207
|
+
row["absolute_pressure"], lat, lon,
|
|
208
|
+
row["next_stop_depth_m"], row["next_stop_time_s"], row["tts_s"],
|
|
209
|
+
row["ndl_s"], row["cns_pct"], row["n2_load"], row["po2"],
|
|
210
|
+
row["ascent_rate"], row["air_time_remaining_s"],
|
|
211
|
+
row["pressure_sac"], row["volume_sac"], row["rmv"],
|
|
212
|
+
json.dumps(extra, separators=(",", ":")) if extra else None,
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
con.executemany(
|
|
216
|
+
"INSERT INTO garmin_samples (source_dive_id, t_s, depth_m, temp_c, heart_rate,"
|
|
217
|
+
" absolute_pressure, lat, lon, next_stop_depth_m, next_stop_time_s, tts_s,"
|
|
218
|
+
" ndl_s, cns_pct, n2_load, po2, ascent_rate, air_time_remaining_s,"
|
|
219
|
+
" pressure_sac, volume_sac, rmv, extra_json)"
|
|
220
|
+
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
221
|
+
sample_rows,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
for gi, gas in enumerate(messages.get("dive_gas_mesgs") or []):
|
|
225
|
+
mode_str = str(gas.get("mode", ""))
|
|
226
|
+
circuit = {"open_circuit": "oc", "closed_circuit": "cc"}.get(mode_str)
|
|
227
|
+
con.execute(
|
|
228
|
+
"INSERT INTO gases (source_dive_id, gas_index, o2_pct, he_pct, circuit,"
|
|
229
|
+
" enabled, used, source_json) VALUES (?,?,?,?,?,?,?,?)",
|
|
230
|
+
(
|
|
231
|
+
dive_id,
|
|
232
|
+
gas.get("message_index", gi),
|
|
233
|
+
gas.get("oxygen_content"),
|
|
234
|
+
gas.get("helium_content"),
|
|
235
|
+
circuit,
|
|
236
|
+
1 if gas.get("status") == "enabled" else 0,
|
|
237
|
+
None,
|
|
238
|
+
json.dumps(_jsonable(gas), separators=(",", ":")),
|
|
239
|
+
),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
event_rows = []
|
|
243
|
+
for kind, key in (("fit_event", "event_mesgs"), ("fit_dive_alarm", "dive_alarm_mesgs")):
|
|
244
|
+
for msg in messages.get(key) or []:
|
|
245
|
+
ts = msg.get("timestamp")
|
|
246
|
+
event_rows.append(
|
|
247
|
+
(
|
|
248
|
+
dive_id,
|
|
249
|
+
float(ts - t0) if isinstance(ts, (int, float)) else None,
|
|
250
|
+
kind,
|
|
251
|
+
json.dumps(_jsonable(msg), separators=(",", ":")),
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
con.executemany(
|
|
255
|
+
"INSERT INTO events (source_dive_id, t_s, kind, payload_json) VALUES (?,?,?,?)",
|
|
256
|
+
event_rows,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
undecoded = []
|
|
260
|
+
for key, msgs in messages.items():
|
|
261
|
+
if isinstance(key, str) and key.endswith("_mesgs"):
|
|
262
|
+
continue
|
|
263
|
+
for seq, msg in enumerate(msgs):
|
|
264
|
+
undecoded.append(
|
|
265
|
+
(
|
|
266
|
+
dive_id,
|
|
267
|
+
"fit_mesg",
|
|
268
|
+
str(key),
|
|
269
|
+
seq,
|
|
270
|
+
None,
|
|
271
|
+
json.dumps(_jsonable(msg), separators=(",", ":")),
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
con.executemany(
|
|
275
|
+
"INSERT INTO undecoded_payloads (source_dive_id, container, type_key, seq,"
|
|
276
|
+
" payload, fields_json) VALUES (?,?,?,?,?,?)",
|
|
277
|
+
undecoded,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
return "ingested"
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def ingest_garmin_dir(
|
|
284
|
+
con: sqlite3.Connection,
|
|
285
|
+
archive_dir: Path,
|
|
286
|
+
fit_dir: Path,
|
|
287
|
+
index_name: str = "dives_index.json",
|
|
288
|
+
) -> dict:
|
|
289
|
+
index_path = fit_dir / index_name
|
|
290
|
+
index = load_index(index_path)
|
|
291
|
+
if index_path.exists():
|
|
292
|
+
archive_file(con, archive_dir, "garmin_index", index_path)
|
|
293
|
+
|
|
294
|
+
stats = {"ingested": 0, "skipped": 0, "failed": 0}
|
|
295
|
+
for path in sorted(fit_dir.glob("*.fit")):
|
|
296
|
+
try:
|
|
297
|
+
with con:
|
|
298
|
+
result = ingest_fit_file(con, archive_dir, path, index)
|
|
299
|
+
stats[result] += 1
|
|
300
|
+
except Exception as e:
|
|
301
|
+
stats["failed"] += 1
|
|
302
|
+
print(f"FAILED {path.name}: {type(e).__name__}: {e}")
|
|
303
|
+
return stats
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Ingest a Shearwater Cloud database (dive_data.db / 'Export Database').
|
|
2
|
+
|
|
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.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import sqlite3
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from ..decode import pnf
|
|
19
|
+
from ..store.db import source_dive_exists
|
|
20
|
+
from .archive import archive_file
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _details_row(con: sqlite3.Connection, dive_id: str) -> dict:
|
|
24
|
+
row = con.execute(
|
|
25
|
+
"SELECT * FROM dive_details WHERE DiveId = ?", (dive_id,)
|
|
26
|
+
).fetchone()
|
|
27
|
+
if row is None:
|
|
28
|
+
return {}
|
|
29
|
+
return {k: row[k] for k in row.keys()}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def ingest_shearwater_db(
|
|
33
|
+
con: sqlite3.Connection, archive_dir: Path, db_path: Path
|
|
34
|
+
) -> dict:
|
|
35
|
+
archive_file(con, archive_dir, "shearwater_db", db_path)
|
|
36
|
+
|
|
37
|
+
src = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
|
38
|
+
src.row_factory = sqlite3.Row
|
|
39
|
+
|
|
40
|
+
stats = {"ingested": 0, "skipped": 0, "failed": 0}
|
|
41
|
+
rows = src.execute(
|
|
42
|
+
"SELECT log_id, file_name, format, format_version,"
|
|
43
|
+
" calculated_values_from_samples, data_bytes_1, data_bytes_2, data_bytes_3"
|
|
44
|
+
" FROM log_data"
|
|
45
|
+
).fetchall()
|
|
46
|
+
|
|
47
|
+
artifact_id = con.execute(
|
|
48
|
+
"SELECT id FROM raw_artifacts WHERE kind='shearwater_db' AND original_path=?"
|
|
49
|
+
" ORDER BY id DESC LIMIT 1",
|
|
50
|
+
(str(db_path),),
|
|
51
|
+
).fetchone()[0]
|
|
52
|
+
|
|
53
|
+
for row in rows:
|
|
54
|
+
try:
|
|
55
|
+
with con:
|
|
56
|
+
result = _ingest_log(con, artifact_id, src, row)
|
|
57
|
+
stats[result] += 1
|
|
58
|
+
except Exception as e:
|
|
59
|
+
stats["failed"] += 1
|
|
60
|
+
print(f"FAILED {row['file_name']}: {type(e).__name__}: {e}")
|
|
61
|
+
|
|
62
|
+
src.close()
|
|
63
|
+
return stats
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _ingest_log(
|
|
67
|
+
con: sqlite3.Connection,
|
|
68
|
+
artifact_id: int,
|
|
69
|
+
src: sqlite3.Connection,
|
|
70
|
+
row: sqlite3.Row,
|
|
71
|
+
) -> str:
|
|
72
|
+
if row["format"] != "sw-pnf":
|
|
73
|
+
raise ValueError(f"unsupported log format {row['format']!r}")
|
|
74
|
+
|
|
75
|
+
blob = row["data_bytes_1"]
|
|
76
|
+
digest = hashlib.sha256(blob).hexdigest()
|
|
77
|
+
if source_dive_exists(con, "shearwater", digest):
|
|
78
|
+
return "skipped"
|
|
79
|
+
|
|
80
|
+
dive = pnf.decode(blob)
|
|
81
|
+
details = _details_row(src, row["log_id"])
|
|
82
|
+
b2 = json.loads(row["data_bytes_2"]) if row["data_bytes_2"] else {}
|
|
83
|
+
b3 = json.loads(row["data_bytes_3"]) if row["data_bytes_3"] else {}
|
|
84
|
+
calc = (
|
|
85
|
+
json.loads(row["calculated_values_from_samples"])
|
|
86
|
+
if row["calculated_values_from_samples"]
|
|
87
|
+
else {}
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
serial = details.get("SerialNumber")
|
|
91
|
+
if not serial and "computer_serial" in dive.header:
|
|
92
|
+
serial = f"{dive.header['computer_serial']:08X}"
|
|
93
|
+
dive_number = details.get("DiveNumber") or b3.get("DiveNumber")
|
|
94
|
+
source_key = f"{serial}#{dive_number}"
|
|
95
|
+
|
|
96
|
+
# DIVE_START_TIME is the local wall clock stored as a fake-UTC epoch.
|
|
97
|
+
start_epoch = b2.get("DIVE_START_TIME") or b3.get("StartTime")
|
|
98
|
+
start_local = datetime.fromtimestamp(start_epoch, tz=timezone.utc).strftime(
|
|
99
|
+
"%Y-%m-%d %H:%M:%S"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
header = {
|
|
103
|
+
"pnf": dive.header,
|
|
104
|
+
"log_data": {
|
|
105
|
+
"log_id": row["log_id"],
|
|
106
|
+
"file_name": row["file_name"],
|
|
107
|
+
"format": row["format"],
|
|
108
|
+
"format_version": row["format_version"],
|
|
109
|
+
"data_bytes_2": b2,
|
|
110
|
+
"data_bytes_3": b3,
|
|
111
|
+
"calculated_values_from_samples": calc,
|
|
112
|
+
},
|
|
113
|
+
"dive_details": details,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
cur = con.execute(
|
|
117
|
+
"INSERT INTO source_dives (source, artifact_id, source_key, content_hash,"
|
|
118
|
+
" decoder_version, start_time_utc, start_time_local, utc_offset_min,"
|
|
119
|
+
" duration_s, max_depth_m, avg_depth_m, sample_interval_ms, mode,"
|
|
120
|
+
" header_json, raw_blob)"
|
|
121
|
+
" VALUES ('shearwater',?,?,?,?,NULL,?,NULL,?,?,?,?,?,?,?)",
|
|
122
|
+
(
|
|
123
|
+
artifact_id,
|
|
124
|
+
source_key,
|
|
125
|
+
digest,
|
|
126
|
+
pnf.DECODER_VERSION,
|
|
127
|
+
start_local,
|
|
128
|
+
dive.header.get("divetime_s"),
|
|
129
|
+
dive.header.get("max_depth_m"),
|
|
130
|
+
calc.get("AverageDepth"),
|
|
131
|
+
dive.sample_interval_ms,
|
|
132
|
+
dive.header.get("divemode"),
|
|
133
|
+
json.dumps(header, separators=(",", ":")),
|
|
134
|
+
blob,
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
dive_id = cur.lastrowid
|
|
138
|
+
|
|
139
|
+
con.executemany(
|
|
140
|
+
"INSERT INTO shearwater_samples (source_dive_id, t_s, depth_m, temp_c,"
|
|
141
|
+
" 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,"
|
|
143
|
+
" sensor1_ppo2, sensor2_ppo2, sensor3_ppo2, battery_v, status_flags,"
|
|
144
|
+
" tank0_psi, tank1_psi, gas_time_min, extra_json)"
|
|
145
|
+
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
146
|
+
[
|
|
147
|
+
(
|
|
148
|
+
dive_id, s.t_s, s.depth_m, s.temp_c,
|
|
149
|
+
s.stop_depth_m, s.stop_or_ndl_min, s.tts_min, s.in_deco,
|
|
150
|
+
s.avg_ppo2, s.o2_pct, s.he_pct,
|
|
151
|
+
s.setpoint, s.cns_pct, s.gf99, s.ceiling_m,
|
|
152
|
+
s.sensor1_raw, s.sensor2_raw, s.sensor3_raw,
|
|
153
|
+
s.sensor1_ppo2, s.sensor2_ppo2, s.sensor3_ppo2,
|
|
154
|
+
s.battery_v, s.status_flags,
|
|
155
|
+
s.tank0_psi, s.tank1_psi, s.gas_time_min,
|
|
156
|
+
json.dumps(s.extra, separators=(",", ":")) if s.extra else None,
|
|
157
|
+
)
|
|
158
|
+
for s in dive.samples
|
|
159
|
+
],
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
for gas in dive.gases:
|
|
163
|
+
if gas.o2_pct == 0 and gas.he_pct == 0:
|
|
164
|
+
continue
|
|
165
|
+
con.execute(
|
|
166
|
+
"INSERT INTO gases (source_dive_id, gas_index, o2_pct, he_pct, circuit,"
|
|
167
|
+
" enabled, used, source_json) VALUES (?,?,?,?,?,?,NULL,NULL)",
|
|
168
|
+
(
|
|
169
|
+
dive_id,
|
|
170
|
+
gas.index,
|
|
171
|
+
gas.o2_pct,
|
|
172
|
+
gas.he_pct,
|
|
173
|
+
"cc" if gas.diluent else "oc",
|
|
174
|
+
1 if gas.enabled else 0,
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
tank_profile = details.get("TankProfileData")
|
|
179
|
+
if tank_profile:
|
|
180
|
+
try:
|
|
181
|
+
profile = json.loads(tank_profile)
|
|
182
|
+
except (TypeError, ValueError):
|
|
183
|
+
profile = None
|
|
184
|
+
for seg in (profile or {}).get("GasProfiles") or []:
|
|
185
|
+
con.execute(
|
|
186
|
+
"INSERT INTO gas_segments (source_dive_id, start_s, end_s, o2_pct,"
|
|
187
|
+
" he_pct, circuit, avg_depth_m, source_json) VALUES (?,?,?,?,?,?,?,?)",
|
|
188
|
+
(
|
|
189
|
+
dive_id,
|
|
190
|
+
seg.get("StartTimeInSeconds"),
|
|
191
|
+
seg.get("EndTimeInSeconds"),
|
|
192
|
+
seg.get("O2Percent"),
|
|
193
|
+
seg.get("HePercent"),
|
|
194
|
+
seg.get("CircuitMode"),
|
|
195
|
+
seg.get("AverageDepthInMeters"),
|
|
196
|
+
json.dumps(seg, separators=(",", ":")),
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
con.executemany(
|
|
201
|
+
"INSERT INTO events (source_dive_id, t_s, kind, payload_json) VALUES (?,?,?,?)",
|
|
202
|
+
[
|
|
203
|
+
(
|
|
204
|
+
dive_id,
|
|
205
|
+
e.t_s,
|
|
206
|
+
"pnf_info_event",
|
|
207
|
+
json.dumps(
|
|
208
|
+
{
|
|
209
|
+
"event": e.event,
|
|
210
|
+
"timestamp": e.timestamp,
|
|
211
|
+
"w1": e.w1,
|
|
212
|
+
"w2": e.w2,
|
|
213
|
+
"raw_hex": e.raw_hex,
|
|
214
|
+
},
|
|
215
|
+
separators=(",", ":"),
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
for e in dive.events
|
|
219
|
+
],
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
con.executemany(
|
|
223
|
+
"INSERT INTO undecoded_payloads (source_dive_id, container, type_key, seq,"
|
|
224
|
+
" payload, fields_json) VALUES (?,?,?,?,?,NULL)",
|
|
225
|
+
[
|
|
226
|
+
(dive_id, "pnf_record", type_key, seq, raw)
|
|
227
|
+
for type_key, seq, raw in dive.unknown_records
|
|
228
|
+
],
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
return "ingested"
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Parse Shearwater Cloud 'Shearwater XML' exports.
|
|
2
|
+
|
|
3
|
+
Used as an independent cross-check of the PNF decoder (verify), not as a
|
|
4
|
+
third sample series: the XML is a lossy rendering of the same native log.
|
|
5
|
+
Files declare encoding="utf-16" but are written as ASCII; parse accordingly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import xml.etree.ElementTree as ET
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
# AI columns carry text sentinels when no transmitter is paired
|
|
15
|
+
_TEXT_SENTINELS = {"AI is off", "N/A", "Not paired", "No comms", ""}
|
|
16
|
+
|
|
17
|
+
_NUMERIC_SAMPLE_FIELDS = (
|
|
18
|
+
"currentTime",
|
|
19
|
+
"currentDepth",
|
|
20
|
+
"firstStopDepth",
|
|
21
|
+
"firstStopTime",
|
|
22
|
+
"ttsMins",
|
|
23
|
+
"averagePPO2",
|
|
24
|
+
"fractionO2",
|
|
25
|
+
"fractionHe",
|
|
26
|
+
"currentNdl",
|
|
27
|
+
"waterTemp",
|
|
28
|
+
"batteryVoltage",
|
|
29
|
+
"sensor1Millivolts",
|
|
30
|
+
"sensor2Millivolts",
|
|
31
|
+
"sensor3Millivolts",
|
|
32
|
+
"sac",
|
|
33
|
+
"sad",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _num(text: str | None) -> float | None:
|
|
38
|
+
if text is None or text.strip() in _TEXT_SENTINELS:
|
|
39
|
+
return None
|
|
40
|
+
try:
|
|
41
|
+
return float(text)
|
|
42
|
+
except ValueError:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_xml(path: Path) -> dict:
|
|
47
|
+
text = path.read_text(encoding="ascii", errors="replace")
|
|
48
|
+
text = re.sub(r"^<\?xml[^>]*\?>", "", text, count=1)
|
|
49
|
+
root = ET.fromstring(text)
|
|
50
|
+
log = root.find("diveLog")
|
|
51
|
+
|
|
52
|
+
header = {
|
|
53
|
+
el.tag: (el.text or "").strip()
|
|
54
|
+
for el in log
|
|
55
|
+
if el.tag != "diveLogRecords" and len(el) == 0
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
samples = []
|
|
59
|
+
for rec in log.find("diveLogRecords") or []:
|
|
60
|
+
sample = {}
|
|
61
|
+
for el in rec:
|
|
62
|
+
if el.tag in _NUMERIC_SAMPLE_FIELDS:
|
|
63
|
+
sample[el.tag] = _num(el.text)
|
|
64
|
+
else:
|
|
65
|
+
sample[el.tag] = (el.text or "").strip()
|
|
66
|
+
samples.append(sample)
|
|
67
|
+
|
|
68
|
+
return {"path": str(path), "header": header, "samples": samples}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def normalize_start(value: str) -> str:
|
|
72
|
+
"""Normalize a Shearwater startDate to 'YYYY-MM-DD HH:MM:SS'.
|
|
73
|
+
|
|
74
|
+
Accepts the export's native 'dd-MM-yyyy HH:mm:ss' as well as ISO
|
|
75
|
+
'yyyy-MM-ddTHH:mm:ss' (files repaired for Garmin Connect upload)."""
|
|
76
|
+
value = value.strip().replace("T", " ")
|
|
77
|
+
m = re.match(r"^(\d{2})-(\d{2})-(\d{4}) (\d{2}:\d{2}:\d{2})$", value)
|
|
78
|
+
if m:
|
|
79
|
+
return f"{m.group(3)}-{m.group(2)}-{m.group(1)} {m.group(4)}"
|
|
80
|
+
return value
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def load_xml_dir(xml_dir: Path) -> dict[str, dict]:
|
|
84
|
+
"""Map normalized start wall-clock -> parsed XML for every export.
|
|
85
|
+
|
|
86
|
+
Keyed by start time, not dive number: the on-unit dive counter can be
|
|
87
|
+
reset, so numbers repeat across a collection."""
|
|
88
|
+
dives = {}
|
|
89
|
+
for path in sorted(xml_dir.glob("*.xml")):
|
|
90
|
+
parsed = parse_xml(path)
|
|
91
|
+
dives[normalize_start(parsed["header"]["startDate"])] = parsed
|
|
92
|
+
return dives
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|