bottomtime 0.1.0__tar.gz → 0.2.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.2.0/.claude/settings.local.json +30 -0
- {bottomtime-0.1.0 → bottomtime-0.2.0}/PKG-INFO +54 -3
- {bottomtime-0.1.0 → bottomtime-0.2.0}/README.md +51 -2
- {bottomtime-0.1.0 → bottomtime-0.2.0}/pyproject.toml +2 -1
- {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/__init__.py +1 -1
- {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/api.py +71 -1
- bottomtime-0.2.0/src/bottomtime/cli.py +336 -0
- {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/decode/pnf.py +18 -1
- {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/match/correlate.py +1 -1
- bottomtime-0.2.0/src/bottomtime/plot.py +163 -0
- bottomtime-0.2.0/tests/test_cli.py +151 -0
- bottomtime-0.1.0/src/bottomtime/cli.py +0 -107
- {bottomtime-0.1.0 → bottomtime-0.2.0}/.github/workflows/test.yml +0 -0
- {bottomtime-0.1.0 → bottomtime-0.2.0}/.gitignore +0 -0
- {bottomtime-0.1.0 → bottomtime-0.2.0}/LICENSE +0 -0
- {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/decode/__init__.py +0 -0
- {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/ingest/__init__.py +0 -0
- {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/ingest/archive.py +0 -0
- {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/ingest/garmin_fit.py +0 -0
- {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/ingest/shearwater_db.py +0 -0
- {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/ingest/shearwater_xml.py +0 -0
- {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/match/__init__.py +0 -0
- {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/match/matcher.py +0 -0
- {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/store/__init__.py +0 -0
- {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/store/db.py +0 -0
- {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/store/schema.py +0 -0
- {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/verify/__init__.py +0 -0
- {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/verify/crosscheck.py +0 -0
- {bottomtime-0.1.0 → bottomtime-0.2.0}/tests/test_correlate.py +0 -0
- {bottomtime-0.1.0 → bottomtime-0.2.0}/tests/test_pnf.py +0 -0
|
@@ -0,0 +1,30 @@
|
|
|
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,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bottomtime
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.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
|
|
@@ -16,6 +16,8 @@ Requires-Python: >=3.10
|
|
|
16
16
|
Requires-Dist: garmin-fit-sdk>=21.0
|
|
17
17
|
Provides-Extra: dev
|
|
18
18
|
Requires-Dist: pytest; extra == 'dev'
|
|
19
|
+
Provides-Extra: plot
|
|
20
|
+
Requires-Dist: matplotlib>=3.5; extra == 'plot'
|
|
19
21
|
Description-Content-Type: text/markdown
|
|
20
22
|
|
|
21
23
|
# bottomtime
|
|
@@ -36,7 +38,7 @@ bottomtime keeps everything:
|
|
|
36
38
|
types are preserved raw, per sample and per message.
|
|
37
39
|
- **Decodes Shearwater's Petrel Native Format** directly from a Shearwater
|
|
38
40
|
Cloud database (`dive_data.db`, the "Export Database" output) with a pure
|
|
39
|
-
Python decoder
|
|
41
|
+
Python decoder, including per-sample **GF99, deco ceiling, CNS and battery
|
|
40
42
|
voltage** that Shearwater's own XML/CSV exports omit. Unknown record types
|
|
41
43
|
are preserved raw.
|
|
42
44
|
- **Archives every source file verbatim**, content-addressed by SHA-256. The
|
|
@@ -80,6 +82,47 @@ bottomtime verify --xml-dir ~/dives/shearwater-xml
|
|
|
80
82
|
bottomtime status
|
|
81
83
|
```
|
|
82
84
|
|
|
85
|
+
## Looking at your dives
|
|
86
|
+
|
|
87
|
+
Once the store is built, interrogate it from the CLI:
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
bottomtime list # canonical dive table (--all includes test dives)
|
|
91
|
+
bottomtime show 291 # one dive: computers, channels, gases, match quality
|
|
92
|
+
bottomtime export 291 -o d.csv # per-source sample series (also --format json)
|
|
93
|
+
bottomtime plot 291 -o d.png # aligned dual-computer profile + GF99/ppO2 panels
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`plot` needs matplotlib: `pip install 'bottomtime[plot]'`. Alignment uses the
|
|
97
|
+
matcher's clock offset and residual skew, so both computers' profiles sit on
|
|
98
|
+
one time axis without resampling either.
|
|
99
|
+
|
|
100
|
+
Or from Python:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
import bottomtime
|
|
104
|
+
import pandas as pd
|
|
105
|
+
|
|
106
|
+
for d in bottomtime.list_dives("data/dives.db"):
|
|
107
|
+
print(d["dive_number"], d["start_time_utc"], d["max_depth_m"], d["sources"])
|
|
108
|
+
|
|
109
|
+
dive = bottomtime.load_dive("data/dives.db", 291)
|
|
110
|
+
sw = pd.DataFrame(dive["sources"]["shearwater"]["samples"])
|
|
111
|
+
sw.plot(x="t_s", y=["depth_m", "ceiling_m"])
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`load_dive` returns everything about one dive: per-source samples
|
|
115
|
+
(column-oriented, ready for DataFrames), gases, events, the verbatim decoded
|
|
116
|
+
headers, computer model/firmware/serial, and the match metadata.
|
|
117
|
+
|
|
118
|
+
And it's just SQLite; the views cover the common queries directly:
|
|
119
|
+
|
|
120
|
+
```sh
|
|
121
|
+
sqlite3 data/dives.db "SELECT dive_number, max_depth_m FROM v_dive_summary
|
|
122
|
+
WHERE is_test=0 ORDER BY max_depth_m DESC LIMIT 10"
|
|
123
|
+
datasette data/dives.db # instant web UI + JSON API, if you have datasette
|
|
124
|
+
```
|
|
125
|
+
|
|
83
126
|
All commands are idempotent: re-running an ingest skips already-stored dives,
|
|
84
127
|
so syncing after a dive trip only adds what's new.
|
|
85
128
|
|
|
@@ -100,9 +143,17 @@ documented fields and adds empirically verified mappings for GF99 (byte 25),
|
|
|
100
143
|
deco ceiling (byte 24), battery voltage (byte 18) and @+5 TTS (bytes 26-27),
|
|
101
144
|
validated against Shearwater Cloud's displayed values across hundreds of
|
|
102
145
|
dives. Bytes without a known meaning are preserved per sample. If your dives
|
|
103
|
-
disagree, `bottomtime verify` will say so loudly
|
|
146
|
+
disagree, `bottomtime verify` will say so loudly; issue reports with a
|
|
104
147
|
failing blob are very welcome.
|
|
105
148
|
|
|
149
|
+
PNF is shared across the Predator/Petrel firmware family. The empirical
|
|
150
|
+
mappings above have so far been validated on **Perdix 2** and **Petrel 3**
|
|
151
|
+
logs (the latter integrated with a Choptima CCR). Other models, and
|
|
152
|
+
standalone vs. CCR-integrated units, may populate fields differently;
|
|
153
|
+
unknown bytes are always preserved, and `bottomtime verify` cross-checks
|
|
154
|
+
every decoded value, so drift on other hardware is detected rather than
|
|
155
|
+
silently mis-decoded.
|
|
156
|
+
|
|
106
157
|
## License
|
|
107
158
|
|
|
108
159
|
MIT
|
|
@@ -16,7 +16,7 @@ bottomtime keeps everything:
|
|
|
16
16
|
types are preserved raw, per sample and per message.
|
|
17
17
|
- **Decodes Shearwater's Petrel Native Format** directly from a Shearwater
|
|
18
18
|
Cloud database (`dive_data.db`, the "Export Database" output) with a pure
|
|
19
|
-
Python decoder
|
|
19
|
+
Python decoder, including per-sample **GF99, deco ceiling, CNS and battery
|
|
20
20
|
voltage** that Shearwater's own XML/CSV exports omit. Unknown record types
|
|
21
21
|
are preserved raw.
|
|
22
22
|
- **Archives every source file verbatim**, content-addressed by SHA-256. The
|
|
@@ -60,6 +60,47 @@ bottomtime verify --xml-dir ~/dives/shearwater-xml
|
|
|
60
60
|
bottomtime status
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
+
## Looking at your dives
|
|
64
|
+
|
|
65
|
+
Once the store is built, interrogate it from the CLI:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
bottomtime list # canonical dive table (--all includes test dives)
|
|
69
|
+
bottomtime show 291 # one dive: computers, channels, gases, match quality
|
|
70
|
+
bottomtime export 291 -o d.csv # per-source sample series (also --format json)
|
|
71
|
+
bottomtime plot 291 -o d.png # aligned dual-computer profile + GF99/ppO2 panels
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`plot` needs matplotlib: `pip install 'bottomtime[plot]'`. Alignment uses the
|
|
75
|
+
matcher's clock offset and residual skew, so both computers' profiles sit on
|
|
76
|
+
one time axis without resampling either.
|
|
77
|
+
|
|
78
|
+
Or from Python:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
import bottomtime
|
|
82
|
+
import pandas as pd
|
|
83
|
+
|
|
84
|
+
for d in bottomtime.list_dives("data/dives.db"):
|
|
85
|
+
print(d["dive_number"], d["start_time_utc"], d["max_depth_m"], d["sources"])
|
|
86
|
+
|
|
87
|
+
dive = bottomtime.load_dive("data/dives.db", 291)
|
|
88
|
+
sw = pd.DataFrame(dive["sources"]["shearwater"]["samples"])
|
|
89
|
+
sw.plot(x="t_s", y=["depth_m", "ceiling_m"])
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`load_dive` returns everything about one dive: per-source samples
|
|
93
|
+
(column-oriented, ready for DataFrames), gases, events, the verbatim decoded
|
|
94
|
+
headers, computer model/firmware/serial, and the match metadata.
|
|
95
|
+
|
|
96
|
+
And it's just SQLite; the views cover the common queries directly:
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
sqlite3 data/dives.db "SELECT dive_number, max_depth_m FROM v_dive_summary
|
|
100
|
+
WHERE is_test=0 ORDER BY max_depth_m DESC LIMIT 10"
|
|
101
|
+
datasette data/dives.db # instant web UI + JSON API, if you have datasette
|
|
102
|
+
```
|
|
103
|
+
|
|
63
104
|
All commands are idempotent: re-running an ingest skips already-stored dives,
|
|
64
105
|
so syncing after a dive trip only adds what's new.
|
|
65
106
|
|
|
@@ -80,9 +121,17 @@ documented fields and adds empirically verified mappings for GF99 (byte 25),
|
|
|
80
121
|
deco ceiling (byte 24), battery voltage (byte 18) and @+5 TTS (bytes 26-27),
|
|
81
122
|
validated against Shearwater Cloud's displayed values across hundreds of
|
|
82
123
|
dives. Bytes without a known meaning are preserved per sample. If your dives
|
|
83
|
-
disagree, `bottomtime verify` will say so loudly
|
|
124
|
+
disagree, `bottomtime verify` will say so loudly; issue reports with a
|
|
84
125
|
failing blob are very welcome.
|
|
85
126
|
|
|
127
|
+
PNF is shared across the Predator/Petrel firmware family. The empirical
|
|
128
|
+
mappings above have so far been validated on **Perdix 2** and **Petrel 3**
|
|
129
|
+
logs (the latter integrated with a Choptima CCR). Other models, and
|
|
130
|
+
standalone vs. CCR-integrated units, may populate fields differently;
|
|
131
|
+
unknown bytes are always preserved, and `bottomtime verify` cross-checks
|
|
132
|
+
every decoded value, so drift on other hardware is detected rather than
|
|
133
|
+
silently mis-decoded.
|
|
134
|
+
|
|
86
135
|
## License
|
|
87
136
|
|
|
88
137
|
MIT
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "bottomtime"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.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"
|
|
@@ -21,6 +21,7 @@ classifiers = [
|
|
|
21
21
|
dependencies = ["garmin-fit-sdk>=21.0"]
|
|
22
22
|
|
|
23
23
|
[project.optional-dependencies]
|
|
24
|
+
plot = ["matplotlib>=3.5"]
|
|
24
25
|
dev = ["pytest"]
|
|
25
26
|
|
|
26
27
|
[project.scripts]
|
|
@@ -11,9 +11,47 @@ import json
|
|
|
11
11
|
import sqlite3
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
|
|
14
|
+
from .decode.pnf import COMPUTER_MODELS
|
|
15
|
+
|
|
14
16
|
_SAMPLE_TABLES = {"garmin": "garmin_samples", "shearwater": "shearwater_samples"}
|
|
15
17
|
|
|
16
18
|
|
|
19
|
+
def computer_info(source: str, header: dict) -> dict:
|
|
20
|
+
"""Model / firmware / serial of the computer that produced a source log,
|
|
21
|
+
extracted from its verbatim header."""
|
|
22
|
+
if source == "garmin":
|
|
23
|
+
file_id = (header.get("file_id_mesgs") or [{}])[0]
|
|
24
|
+
product = file_id.get("garmin_product") or file_id.get("product_name")
|
|
25
|
+
model = str(product).replace("_", " ").title() if product else None
|
|
26
|
+
firmware = next(
|
|
27
|
+
(
|
|
28
|
+
d.get("software_version")
|
|
29
|
+
for d in header.get("device_info_mesgs") or []
|
|
30
|
+
if d.get("software_version")
|
|
31
|
+
),
|
|
32
|
+
None,
|
|
33
|
+
)
|
|
34
|
+
serial = file_id.get("serial_number")
|
|
35
|
+
return {"model": model, "firmware": firmware, "serial": serial}
|
|
36
|
+
|
|
37
|
+
pnf_header = header.get("pnf") or {}
|
|
38
|
+
model_id = pnf_header.get("computer_model")
|
|
39
|
+
model = (
|
|
40
|
+
COMPUTER_MODELS.get(model_id, f"model {model_id}")
|
|
41
|
+
if model_id is not None
|
|
42
|
+
else None
|
|
43
|
+
)
|
|
44
|
+
firmware = pnf_header.get("computer_firmware")
|
|
45
|
+
serial = (header.get("dive_details") or {}).get("SerialNumber")
|
|
46
|
+
if not serial and pnf_header.get("computer_serial") is not None:
|
|
47
|
+
serial = f"{pnf_header['computer_serial']:08X}"
|
|
48
|
+
return {
|
|
49
|
+
"model": model,
|
|
50
|
+
"firmware": f"v{firmware}" if firmware is not None else None,
|
|
51
|
+
"serial": serial,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
17
55
|
def _columns_of(con: sqlite3.Connection, table: str) -> list[str]:
|
|
18
56
|
return [r[1] for r in con.execute(f"PRAGMA table_info({table})")]
|
|
19
57
|
|
|
@@ -45,6 +83,7 @@ def load_dive(db_path: str | Path, dive_number: int) -> dict:
|
|
|
45
83
|
(sd["id"],),
|
|
46
84
|
).fetchall()
|
|
47
85
|
samples = {c: [r[i] for r in rows] for i, c in enumerate(cols)}
|
|
86
|
+
header = json.loads(sd["header_json"])
|
|
48
87
|
entry = {
|
|
49
88
|
"source_dive_id": sd["id"],
|
|
50
89
|
"source_key": sd["source_key"],
|
|
@@ -52,9 +91,28 @@ def load_dive(db_path: str | Path, dive_number: int) -> dict:
|
|
|
52
91
|
"start_time_local": sd["start_time_local"],
|
|
53
92
|
"duration_s": sd["duration_s"],
|
|
54
93
|
"max_depth_m": sd["max_depth_m"],
|
|
94
|
+
"sample_interval_ms": sd["sample_interval_ms"],
|
|
55
95
|
"mode": sd["mode"],
|
|
56
|
-
"
|
|
96
|
+
"computer": computer_info(sd["source"], header),
|
|
97
|
+
"header": header,
|
|
57
98
|
"samples": samples,
|
|
99
|
+
"gases": [
|
|
100
|
+
{k: g[k] for k in g.keys()}
|
|
101
|
+
for g in con.execute(
|
|
102
|
+
"SELECT gas_index, o2_pct, he_pct, circuit, enabled, used"
|
|
103
|
+
" FROM gases WHERE source_dive_id=? ORDER BY gas_index",
|
|
104
|
+
(sd["id"],),
|
|
105
|
+
)
|
|
106
|
+
],
|
|
107
|
+
"events": [
|
|
108
|
+
{"t_s": e["t_s"], "kind": e["kind"],
|
|
109
|
+
"payload": json.loads(e["payload_json"])}
|
|
110
|
+
for e in con.execute(
|
|
111
|
+
"SELECT t_s, kind, payload_json FROM events"
|
|
112
|
+
" WHERE source_dive_id=? ORDER BY t_s",
|
|
113
|
+
(sd["id"],),
|
|
114
|
+
)
|
|
115
|
+
],
|
|
58
116
|
}
|
|
59
117
|
key = sd["source"]
|
|
60
118
|
if key in out["sources"]: # several logs from one source (splits)
|
|
@@ -65,6 +123,18 @@ def load_dive(db_path: str | Path, dive_number: int) -> dict:
|
|
|
65
123
|
out["sources"][key] = [existing, entry]
|
|
66
124
|
else:
|
|
67
125
|
out["sources"][key] = entry
|
|
126
|
+
out["matches"] = [
|
|
127
|
+
{k: m[k] for k in m.keys()}
|
|
128
|
+
for m in con.execute(
|
|
129
|
+
"SELECT m.garmin_source_dive_id, m.shearwater_source_dive_id,"
|
|
130
|
+
" m.clock_offset_s, m.residual_skew_s, m.xcorr_score,"
|
|
131
|
+
" m.duration_delta_s, m.max_depth_delta_m, m.method, m.status"
|
|
132
|
+
" FROM matches m JOIN dive_members dm"
|
|
133
|
+
" ON dm.source_dive_id = m.garmin_source_dive_id"
|
|
134
|
+
" WHERE dm.dive_id=? AND m.status IN ('auto','confirmed')",
|
|
135
|
+
(dive["id"],),
|
|
136
|
+
)
|
|
137
|
+
]
|
|
68
138
|
return out
|
|
69
139
|
finally:
|
|
70
140
|
con.close()
|
|
@@ -0,0 +1,336 @@
|
|
|
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
|
+
bottomtime list [--all]
|
|
10
|
+
bottomtime show <dive-number>
|
|
11
|
+
bottomtime export <dive-number> [--format csv|json] [-o FILE]
|
|
12
|
+
bottomtime plot <dive-number> [-o FILE]
|
|
13
|
+
|
|
14
|
+
All commands are idempotent: re-running an ingest skips anything already
|
|
15
|
+
stored (content-hash keyed), match recomputes only 'auto' links.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import sys
|
|
23
|
+
from datetime import datetime, timedelta
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from .store import db as store_db
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _fmt_duration(seconds) -> str:
|
|
30
|
+
if seconds is None:
|
|
31
|
+
return "?"
|
|
32
|
+
s = int(round(seconds))
|
|
33
|
+
h, rem = divmod(s, 3600)
|
|
34
|
+
m, sec = divmod(rem, 60)
|
|
35
|
+
return f"{h}:{m:02d}:{sec:02d}" if h else f"{m}:{sec:02d}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _fmt_local(start_utc, utc_offset_min, fmt="%Y-%m-%d %H:%M") -> str:
|
|
39
|
+
if start_utc is None:
|
|
40
|
+
return "?"
|
|
41
|
+
dt = datetime.strptime(start_utc, "%Y-%m-%d %H:%M:%S")
|
|
42
|
+
if utc_offset_min is not None:
|
|
43
|
+
dt += timedelta(minutes=utc_offset_min)
|
|
44
|
+
return dt.strftime(fmt)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _fmt_offset(utc_offset_min) -> str:
|
|
48
|
+
if utc_offset_min is None:
|
|
49
|
+
return "UTC?"
|
|
50
|
+
sign = "+" if utc_offset_min >= 0 else "-"
|
|
51
|
+
h, m = divmod(abs(utc_offset_min), 60)
|
|
52
|
+
return f"UTC{sign}{h:02d}:{m:02d}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _iter_source_entries(dive: dict):
|
|
56
|
+
for source, value in dive["sources"].items():
|
|
57
|
+
for entry in value if isinstance(value, list) else [value]:
|
|
58
|
+
yield source, entry
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _computer_label(info: dict) -> str:
|
|
62
|
+
parts = [info.get("model") or "unknown"]
|
|
63
|
+
if info.get("firmware") is not None:
|
|
64
|
+
parts.append(str(info["firmware"]))
|
|
65
|
+
return " ".join(parts)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _cmd_list(con, include_tests: bool) -> int:
|
|
69
|
+
from .api import computer_info
|
|
70
|
+
|
|
71
|
+
where = "" if include_tests else "WHERE is_test = 0"
|
|
72
|
+
dives = con.execute(
|
|
73
|
+
f"SELECT * FROM v_dive_summary {where} ORDER BY start_time_utc"
|
|
74
|
+
).fetchall()
|
|
75
|
+
members = {
|
|
76
|
+
r["id"]: r
|
|
77
|
+
for r in con.execute(
|
|
78
|
+
"SELECT id, source, mode, header_json FROM source_dives"
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
rows = []
|
|
82
|
+
for d in dives:
|
|
83
|
+
computers, mode = [], None
|
|
84
|
+
for sid in (int(i) for i in d["source_dive_ids"].split(",")):
|
|
85
|
+
sd = members[sid]
|
|
86
|
+
info = computer_info(sd["source"], json.loads(sd["header_json"]))
|
|
87
|
+
computers.append(info.get("model") or sd["source"])
|
|
88
|
+
if sd["source"] == "shearwater" or mode is None:
|
|
89
|
+
mode = sd["mode"] or mode
|
|
90
|
+
depth = f"{d['max_depth_m']:.1f}" if d["max_depth_m"] is not None else "?"
|
|
91
|
+
rows.append(
|
|
92
|
+
(
|
|
93
|
+
"T" if d["is_test"] else str(d["dive_number"]),
|
|
94
|
+
_fmt_local(d["start_time_utc"], d["utc_offset_min"]),
|
|
95
|
+
_fmt_duration(d["duration_s"]),
|
|
96
|
+
depth,
|
|
97
|
+
mode or "?",
|
|
98
|
+
" + ".join(sorted(set(computers))),
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
if not rows:
|
|
102
|
+
print("no dives (run ingest + match first)")
|
|
103
|
+
return 0
|
|
104
|
+
header = ("#", "start (local)", "duration", "max m", "mode", "computers")
|
|
105
|
+
widths = [max(len(header[i]), *(len(r[i]) for r in rows)) for i in range(len(header))]
|
|
106
|
+
fmt = " ".join(
|
|
107
|
+
f"{{:{'>' if i in (0, 2, 3) else '<'}{widths[i]}}}" for i in range(len(header))
|
|
108
|
+
)
|
|
109
|
+
print(fmt.format(*header))
|
|
110
|
+
for r in rows:
|
|
111
|
+
print(fmt.format(*r))
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _cmd_show(db_path: str, dive_number: int) -> int:
|
|
116
|
+
from .api import load_dive
|
|
117
|
+
|
|
118
|
+
dive = load_dive(db_path, dive_number)
|
|
119
|
+
depth = f"{dive['max_depth_m']:.1f} m" if dive["max_depth_m"] is not None else "?"
|
|
120
|
+
print(
|
|
121
|
+
f"dive {dive['dive_number']} "
|
|
122
|
+
f"{_fmt_local(dive['start_time_utc'], dive['utc_offset_min'])} "
|
|
123
|
+
f"({_fmt_offset(dive['utc_offset_min'])}) "
|
|
124
|
+
f"duration {_fmt_duration(dive['duration_s'])} max depth {depth}"
|
|
125
|
+
)
|
|
126
|
+
for source, e in _iter_source_entries(dive):
|
|
127
|
+
info = e["computer"]
|
|
128
|
+
serial = f" serial {info['serial']}" if info.get("serial") else ""
|
|
129
|
+
print(f"\n [{source}] {_computer_label(info)}{serial}"
|
|
130
|
+
f" (source dive {e['source_dive_id']}, key {e['source_key']})")
|
|
131
|
+
interval = (
|
|
132
|
+
f"{e['sample_interval_ms'] / 1000:g} s"
|
|
133
|
+
if e["sample_interval_ms"] is not None
|
|
134
|
+
else "?"
|
|
135
|
+
)
|
|
136
|
+
n = len(e["samples"].get("t_s", []))
|
|
137
|
+
e_depth = f"{e['max_depth_m']:.1f} m" if e["max_depth_m"] is not None else "?"
|
|
138
|
+
print(
|
|
139
|
+
f" start {e['start_time_local']} local "
|
|
140
|
+
f"duration {_fmt_duration(e['duration_s'])} max {e_depth} "
|
|
141
|
+
f"mode {e['mode'] or '?'} {n} samples @ {interval}"
|
|
142
|
+
)
|
|
143
|
+
channels = sorted(
|
|
144
|
+
c
|
|
145
|
+
for c, vals in e["samples"].items()
|
|
146
|
+
if c not in ("t_s", "extra_json") and any(v is not None for v in vals)
|
|
147
|
+
)
|
|
148
|
+
if channels:
|
|
149
|
+
print(f" channels: {', '.join(channels)}")
|
|
150
|
+
gases = [
|
|
151
|
+
f"{(g['o2_pct'] or 0):g}/{(g['he_pct'] or 0):g}"
|
|
152
|
+
+ (f" {g['circuit']}" if g["circuit"] else "")
|
|
153
|
+
for g in e["gases"]
|
|
154
|
+
if g["o2_pct"] or g["he_pct"]
|
|
155
|
+
]
|
|
156
|
+
if gases:
|
|
157
|
+
print(f" gases: {', '.join(gases)}")
|
|
158
|
+
if e["events"]:
|
|
159
|
+
print(f" events: {len(e['events'])}")
|
|
160
|
+
for m in dive["matches"]:
|
|
161
|
+
skew = f"{m['residual_skew_s']:g} s" if m["residual_skew_s"] is not None else "?"
|
|
162
|
+
score = f"{m['xcorr_score']:.3f}" if m["xcorr_score"] is not None else "?"
|
|
163
|
+
delta = (
|
|
164
|
+
f"{m['max_depth_delta_m']:.2f} m"
|
|
165
|
+
if m["max_depth_delta_m"] is not None
|
|
166
|
+
else "?"
|
|
167
|
+
)
|
|
168
|
+
print(
|
|
169
|
+
f"\n match garmin#{m['garmin_source_dive_id']} <-> "
|
|
170
|
+
f"shearwater#{m['shearwater_source_dive_id']}: "
|
|
171
|
+
f"clock offset {m['clock_offset_s']:+d} s, xcorr {score}, "
|
|
172
|
+
f"residual skew {skew}, depth delta {delta} ({m['status']})"
|
|
173
|
+
)
|
|
174
|
+
return 0
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _cmd_export(db_path: str, dive_number: int, fmt: str, out: Path | None) -> int:
|
|
178
|
+
from .api import load_dive
|
|
179
|
+
|
|
180
|
+
dive = load_dive(db_path, dive_number)
|
|
181
|
+
if fmt == "json":
|
|
182
|
+
text = json.dumps(dive, separators=(",", ":"), default=str)
|
|
183
|
+
if out:
|
|
184
|
+
out.write_text(text)
|
|
185
|
+
print(f"wrote {out}")
|
|
186
|
+
else:
|
|
187
|
+
print(text)
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
import csv
|
|
191
|
+
|
|
192
|
+
columns: list[str] = []
|
|
193
|
+
for _source, e in _iter_source_entries(dive):
|
|
194
|
+
for c in e["samples"]:
|
|
195
|
+
if c not in columns:
|
|
196
|
+
columns.append(c)
|
|
197
|
+
fh = out.open("w", newline="") if out else sys.stdout
|
|
198
|
+
try:
|
|
199
|
+
writer = csv.writer(fh)
|
|
200
|
+
writer.writerow(["source", "source_dive_id"] + columns)
|
|
201
|
+
for source, e in _iter_source_entries(dive):
|
|
202
|
+
samples = e["samples"]
|
|
203
|
+
n = len(samples.get("t_s", []))
|
|
204
|
+
for i in range(n):
|
|
205
|
+
writer.writerow(
|
|
206
|
+
[source, e["source_dive_id"]]
|
|
207
|
+
+ [
|
|
208
|
+
samples[c][i] if c in samples else None
|
|
209
|
+
for c in columns
|
|
210
|
+
]
|
|
211
|
+
)
|
|
212
|
+
finally:
|
|
213
|
+
if out:
|
|
214
|
+
fh.close()
|
|
215
|
+
print(f"wrote {out}")
|
|
216
|
+
return 0
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def main(argv: list[str] | None = None) -> int:
|
|
220
|
+
parser = argparse.ArgumentParser(prog="bottomtime")
|
|
221
|
+
parser.add_argument("--db", default="data/dives.db", help="path to the dive store")
|
|
222
|
+
parser.add_argument(
|
|
223
|
+
"--archive", default="archive", help="directory for verbatim raw source copies"
|
|
224
|
+
)
|
|
225
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
226
|
+
|
|
227
|
+
sub.add_parser("init", help="create an empty dive store")
|
|
228
|
+
|
|
229
|
+
p_ingest = sub.add_parser("ingest", help="ingest a data source")
|
|
230
|
+
ingest_sub = p_ingest.add_subparsers(dest="source", required=True)
|
|
231
|
+
p_garmin = ingest_sub.add_parser("garmin", help="a directory of FIT files")
|
|
232
|
+
p_garmin.add_argument("fit_dir", type=Path)
|
|
233
|
+
p_garmin.add_argument(
|
|
234
|
+
"--index",
|
|
235
|
+
default="dives_index.json",
|
|
236
|
+
help="Garmin Connect index JSON filename inside fit-dir",
|
|
237
|
+
)
|
|
238
|
+
p_sw = ingest_sub.add_parser("shearwater", help="a Shearwater Cloud dive_data.db")
|
|
239
|
+
p_sw.add_argument("db_path", type=Path)
|
|
240
|
+
|
|
241
|
+
sub.add_parser("match", help="match dual-computer dives, build canonical dives")
|
|
242
|
+
|
|
243
|
+
p_verify = sub.add_parser("verify", help="run the verification suite")
|
|
244
|
+
p_verify.add_argument("--xml-dir", type=Path, default=None,
|
|
245
|
+
help="directory of Shearwater XML exports to cross-check")
|
|
246
|
+
|
|
247
|
+
sub.add_parser("status", help="summarize the store")
|
|
248
|
+
|
|
249
|
+
p_list = sub.add_parser("list", help="list canonical dives")
|
|
250
|
+
p_list.add_argument("--all", action="store_true", help="include test dives")
|
|
251
|
+
|
|
252
|
+
p_show = sub.add_parser("show", help="show one dive in detail")
|
|
253
|
+
p_show.add_argument("dive_number", type=int)
|
|
254
|
+
|
|
255
|
+
p_export = sub.add_parser("export", help="export a dive's sample series")
|
|
256
|
+
p_export.add_argument("dive_number", type=int)
|
|
257
|
+
p_export.add_argument("--format", choices=("csv", "json"), default="csv")
|
|
258
|
+
p_export.add_argument("-o", "--out", type=Path, default=None)
|
|
259
|
+
|
|
260
|
+
p_plot = sub.add_parser("plot", help="plot a dive (requires matplotlib)")
|
|
261
|
+
p_plot.add_argument("dive_number", type=int)
|
|
262
|
+
p_plot.add_argument("-o", "--out", type=Path, default=None,
|
|
263
|
+
help="write an image instead of opening a window")
|
|
264
|
+
|
|
265
|
+
args = parser.parse_args(argv)
|
|
266
|
+
con = store_db.connect(args.db)
|
|
267
|
+
archive_dir = Path(args.archive)
|
|
268
|
+
|
|
269
|
+
if args.command == "init":
|
|
270
|
+
print(f"initialized {args.db}")
|
|
271
|
+
return 0
|
|
272
|
+
|
|
273
|
+
if args.command == "ingest" and args.source == "garmin":
|
|
274
|
+
from .ingest.garmin_fit import ingest_garmin_dir
|
|
275
|
+
|
|
276
|
+
stats = ingest_garmin_dir(con, archive_dir, args.fit_dir, args.index)
|
|
277
|
+
print(f"garmin: {stats}")
|
|
278
|
+
return 1 if stats["failed"] else 0
|
|
279
|
+
|
|
280
|
+
if args.command == "ingest" and args.source == "shearwater":
|
|
281
|
+
from .ingest.shearwater_db import ingest_shearwater_db
|
|
282
|
+
|
|
283
|
+
stats = ingest_shearwater_db(con, archive_dir, args.db_path)
|
|
284
|
+
print(f"shearwater: {stats}")
|
|
285
|
+
return 1 if stats["failed"] else 0
|
|
286
|
+
|
|
287
|
+
if args.command == "match":
|
|
288
|
+
from .match.matcher import run_match
|
|
289
|
+
|
|
290
|
+
stats = run_match(con)
|
|
291
|
+
print(f"match: {stats}")
|
|
292
|
+
return 0
|
|
293
|
+
|
|
294
|
+
if args.command == "verify":
|
|
295
|
+
from .verify.crosscheck import run_verify
|
|
296
|
+
|
|
297
|
+
return run_verify(con, args.xml_dir)
|
|
298
|
+
|
|
299
|
+
if args.command == "list":
|
|
300
|
+
return _cmd_list(con, args.all)
|
|
301
|
+
|
|
302
|
+
if args.command in ("show", "export", "plot"):
|
|
303
|
+
try:
|
|
304
|
+
if args.command == "show":
|
|
305
|
+
return _cmd_show(args.db, args.dive_number)
|
|
306
|
+
if args.command == "export":
|
|
307
|
+
return _cmd_export(args.db, args.dive_number, args.format, args.out)
|
|
308
|
+
from .plot import plot_dive
|
|
309
|
+
|
|
310
|
+
plot_dive(args.db, args.dive_number, args.out)
|
|
311
|
+
return 0
|
|
312
|
+
except KeyError as e:
|
|
313
|
+
print(e.args[0] if e.args else e, file=sys.stderr)
|
|
314
|
+
return 1
|
|
315
|
+
|
|
316
|
+
if args.command == "status":
|
|
317
|
+
for line in con.execute(
|
|
318
|
+
"SELECT source || ' source dives: ' || count(*) FROM source_dives GROUP BY source"
|
|
319
|
+
):
|
|
320
|
+
print(line[0])
|
|
321
|
+
for line in con.execute(
|
|
322
|
+
"SELECT 'canonical dives: ' || count(*) || ' (' ||"
|
|
323
|
+
" sum(CASE WHEN is_test THEN 1 ELSE 0 END) || ' test)' FROM dives"
|
|
324
|
+
):
|
|
325
|
+
print(line[0])
|
|
326
|
+
for line in con.execute(
|
|
327
|
+
"SELECT 'matched pairs: ' || count(*) FROM matches WHERE status IN ('auto','confirmed')"
|
|
328
|
+
):
|
|
329
|
+
print(line[0])
|
|
330
|
+
return 0
|
|
331
|
+
|
|
332
|
+
return 2
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
if __name__ == "__main__":
|
|
336
|
+
sys.exit(main())
|
|
@@ -14,7 +14,7 @@ fields sit at legacy-offset + 1 (the PNF type byte), opening/closing record
|
|
|
14
14
|
fields at the absolute offsets used for their legacy 128-byte block.
|
|
15
15
|
|
|
16
16
|
Record types this decoder does not understand are returned raw in
|
|
17
|
-
`PnfDive.unknown_records
|
|
17
|
+
`PnfDive.unknown_records`; nothing is dropped.
|
|
18
18
|
|
|
19
19
|
DECODER_VERSION history:
|
|
20
20
|
1 initial port of the libdivecomputer field set
|
|
@@ -65,6 +65,23 @@ DIVE_MODES = {
|
|
|
65
65
|
|
|
66
66
|
DECO_MODELS = {0: "gf", 1: "vpmb", 2: "vpmb_gfs", 3: "dciem"}
|
|
67
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
|
+
|
|
68
85
|
# Sample bytes not (yet) assigned a meaning; kept per sample as b<offset>.
|
|
69
86
|
# 18 (battery), 24 (ceiling), 25 (gf99), 26-27 (@+5) were identified
|
|
70
87
|
# empirically and promoted in v2. 30-31 read 0xFFFF (sentinel) so far.
|
|
@@ -52,7 +52,7 @@ def median_abs_delta(
|
|
|
52
52
|
lag_s: float = 0.0,
|
|
53
53
|
) -> float | None:
|
|
54
54
|
"""Median |depth difference| over the overlapping window, with series_b
|
|
55
|
-
shifted by lag_s.
|
|
55
|
+
shifted by lag_s. Stable where NCC is unstable (low-variance pool
|
|
56
56
|
profiles): two computers on the same body must agree on depth."""
|
|
57
57
|
if not series_a or not series_b:
|
|
58
58
|
return None
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Plot one canonical dive: both computers' depth profiles overlaid on a
|
|
2
|
+
shared clock (using the matcher's backfilled UTC start times), plus deco and
|
|
3
|
+
ppO2 panels when those channels are present.
|
|
4
|
+
|
|
5
|
+
matplotlib is an optional dependency: pip install 'bottomtime[plot]'.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .api import load_dive
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _parse(ts: str) -> datetime:
|
|
17
|
+
return datetime.strptime(ts, "%Y-%m-%d %H:%M:%S")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _iter_source_entries(dive: dict):
|
|
21
|
+
for source, value in dive["sources"].items():
|
|
22
|
+
for entry in value if isinstance(value, list) else [value]:
|
|
23
|
+
yield source, entry
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _nan(values: list) -> list[float]:
|
|
27
|
+
return [float("nan") if v is None else float(v) for v in values]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _has(entry: dict, channel: str) -> bool:
|
|
31
|
+
return any(v is not None for v in entry["samples"].get(channel, []))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def aligned_series(dive: dict) -> list[dict]:
|
|
35
|
+
"""One dict per source log: label plus x (seconds since the canonical
|
|
36
|
+
dive's start). Alignment uses each log's backfilled UTC start plus, for
|
|
37
|
+
Shearwater logs, the matcher's residual clock skew (the wall clock's
|
|
38
|
+
error beyond the whole-hour zone offset)."""
|
|
39
|
+
t0 = _parse(dive["start_time_utc"])
|
|
40
|
+
skew = {
|
|
41
|
+
m["shearwater_source_dive_id"]: m["residual_skew_s"] or 0.0
|
|
42
|
+
for m in dive.get("matches", [])
|
|
43
|
+
}
|
|
44
|
+
out = []
|
|
45
|
+
for source, entry in _iter_source_entries(dive):
|
|
46
|
+
start = entry["start_time_utc"] or dive["start_time_utc"]
|
|
47
|
+
base = (_parse(start) - t0).total_seconds()
|
|
48
|
+
if source == "shearwater":
|
|
49
|
+
base += skew.get(entry["source_dive_id"], 0.0)
|
|
50
|
+
model = entry["computer"].get("model") or source
|
|
51
|
+
out.append(
|
|
52
|
+
{
|
|
53
|
+
"source": source,
|
|
54
|
+
"label": model,
|
|
55
|
+
"x": [base + t for t in entry["samples"]["t_s"]],
|
|
56
|
+
"entry": entry,
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def plot_dive(db_path: str | Path, dive_number: int, out: Path | None = None):
|
|
63
|
+
dive = load_dive(db_path, dive_number)
|
|
64
|
+
series = aligned_series(dive)
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
import matplotlib
|
|
68
|
+
|
|
69
|
+
if out:
|
|
70
|
+
matplotlib.use("Agg")
|
|
71
|
+
import matplotlib.pyplot as plt
|
|
72
|
+
except ImportError:
|
|
73
|
+
raise SystemExit(
|
|
74
|
+
"bottomtime plot requires matplotlib: pip install 'bottomtime[plot]'"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
panels = ["depth"]
|
|
78
|
+
if any(_has(s["entry"], "gf99") for s in series):
|
|
79
|
+
panels.append("gf99")
|
|
80
|
+
ppo2_channels = ("sensor1_ppo2", "sensor2_ppo2", "sensor3_ppo2",
|
|
81
|
+
"avg_ppo2", "po2", "setpoint")
|
|
82
|
+
if any(_has(s["entry"], c) for s in series for c in ppo2_channels):
|
|
83
|
+
panels.append("ppo2")
|
|
84
|
+
|
|
85
|
+
fig, axes = plt.subplots(
|
|
86
|
+
len(panels), 1, sharex=True, figsize=(11, 2.8 * len(panels)), squeeze=False
|
|
87
|
+
)
|
|
88
|
+
axes = [ax for (ax,) in axes]
|
|
89
|
+
ax_by_panel = dict(zip(panels, axes))
|
|
90
|
+
|
|
91
|
+
ax = ax_by_panel["depth"]
|
|
92
|
+
for s in series:
|
|
93
|
+
samples = s["entry"]["samples"]
|
|
94
|
+
x_min = [x / 60.0 for x in s["x"]]
|
|
95
|
+
ax.plot(x_min, _nan(samples["depth_m"]), label=s["label"], linewidth=1.2)
|
|
96
|
+
if _has(s["entry"], "ceiling_m"):
|
|
97
|
+
ax.plot(
|
|
98
|
+
x_min,
|
|
99
|
+
_nan(samples["ceiling_m"]),
|
|
100
|
+
linestyle="--",
|
|
101
|
+
linewidth=0.9,
|
|
102
|
+
label=f"{s['label']} ceiling",
|
|
103
|
+
)
|
|
104
|
+
ax.invert_yaxis()
|
|
105
|
+
ax.set_ylabel("depth (m)")
|
|
106
|
+
ax.legend(loc="lower right", fontsize=8)
|
|
107
|
+
ax.grid(True, alpha=0.3)
|
|
108
|
+
|
|
109
|
+
if "gf99" in ax_by_panel:
|
|
110
|
+
ax = ax_by_panel["gf99"]
|
|
111
|
+
for s in series:
|
|
112
|
+
if _has(s["entry"], "gf99"):
|
|
113
|
+
ax.plot(
|
|
114
|
+
[x / 60.0 for x in s["x"]],
|
|
115
|
+
_nan(s["entry"]["samples"]["gf99"]),
|
|
116
|
+
linewidth=1.0,
|
|
117
|
+
label=f"{s['label']} GF99",
|
|
118
|
+
)
|
|
119
|
+
ax.set_ylabel("GF99 (%)")
|
|
120
|
+
ax.legend(loc="upper right", fontsize=8)
|
|
121
|
+
ax.grid(True, alpha=0.3)
|
|
122
|
+
|
|
123
|
+
if "ppo2" in ax_by_panel:
|
|
124
|
+
ax = ax_by_panel["ppo2"]
|
|
125
|
+
for s in series:
|
|
126
|
+
samples = s["entry"]["samples"]
|
|
127
|
+
x_min = [x / 60.0 for x in s["x"]]
|
|
128
|
+
for channel, style in (
|
|
129
|
+
("sensor1_ppo2", "-"),
|
|
130
|
+
("sensor2_ppo2", "-"),
|
|
131
|
+
("sensor3_ppo2", "-"),
|
|
132
|
+
("po2", "-"),
|
|
133
|
+
("setpoint", ":"),
|
|
134
|
+
):
|
|
135
|
+
if _has(s["entry"], channel):
|
|
136
|
+
ax.plot(
|
|
137
|
+
x_min,
|
|
138
|
+
_nan(samples[channel]),
|
|
139
|
+
style,
|
|
140
|
+
linewidth=0.9,
|
|
141
|
+
label=f"{s['label']} {channel}",
|
|
142
|
+
)
|
|
143
|
+
ax.set_ylabel("ppO2 (bar)")
|
|
144
|
+
ax.legend(loc="upper right", fontsize=8)
|
|
145
|
+
ax.grid(True, alpha=0.3)
|
|
146
|
+
|
|
147
|
+
axes[-1].set_xlabel("runtime (min)")
|
|
148
|
+
depth = (
|
|
149
|
+
f"max {dive['max_depth_m']:.1f} m"
|
|
150
|
+
if dive["max_depth_m"] is not None
|
|
151
|
+
else ""
|
|
152
|
+
)
|
|
153
|
+
fig.suptitle(
|
|
154
|
+
f"dive {dive['dive_number']} {dive['start_time_utc']} UTC {depth}".rstrip()
|
|
155
|
+
)
|
|
156
|
+
fig.tight_layout()
|
|
157
|
+
|
|
158
|
+
if out:
|
|
159
|
+
fig.savefig(out, dpi=150)
|
|
160
|
+
print(f"wrote {out}")
|
|
161
|
+
else:
|
|
162
|
+
plt.show()
|
|
163
|
+
return fig
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from bottomtime import cli
|
|
8
|
+
from bottomtime.api import computer_info, load_dive
|
|
9
|
+
from bottomtime.plot import aligned_series
|
|
10
|
+
from bottomtime.store import db as store_db
|
|
11
|
+
|
|
12
|
+
GARMIN_HEADER = {
|
|
13
|
+
"file_id_mesgs": [{"garmin_product": "descent_mk3", "serial_number": 12345}],
|
|
14
|
+
"device_info_mesgs": [{"software_version": 25.21}],
|
|
15
|
+
}
|
|
16
|
+
SHEARWATER_HEADER = {
|
|
17
|
+
"pnf": {"computer_model": 11, "computer_firmware": 100,
|
|
18
|
+
"computer_serial": 0xA823C228},
|
|
19
|
+
"dive_details": {"SerialNumber": "A823C228"},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
def store(tmp_path):
|
|
25
|
+
db_path = tmp_path / "dives.db"
|
|
26
|
+
con = store_db.connect(db_path)
|
|
27
|
+
with con:
|
|
28
|
+
con.execute(
|
|
29
|
+
"INSERT INTO raw_artifacts (id, kind, sha256, size_bytes, original_path,"
|
|
30
|
+
" archive_path, imported_at) VALUES (1,'fit','x',1,'a','b','now')"
|
|
31
|
+
)
|
|
32
|
+
con.execute(
|
|
33
|
+
"INSERT INTO source_dives (id, source, artifact_id, source_key,"
|
|
34
|
+
" content_hash, decoder_version, start_time_utc, start_time_local,"
|
|
35
|
+
" utc_offset_min, duration_s, max_depth_m, sample_interval_ms, mode,"
|
|
36
|
+
" header_json) VALUES (1,'garmin',1,'act1','h1',1,"
|
|
37
|
+
" '2026-07-06 03:47:01','2026-07-06 11:47:01',480,120,20.0,1000,"
|
|
38
|
+
" 'multi_gas_diving',?)",
|
|
39
|
+
(json.dumps(GARMIN_HEADER),),
|
|
40
|
+
)
|
|
41
|
+
con.execute(
|
|
42
|
+
"INSERT INTO source_dives (id, source, artifact_id, source_key,"
|
|
43
|
+
" content_hash, decoder_version, start_time_utc, start_time_local,"
|
|
44
|
+
" utc_offset_min, duration_s, max_depth_m, sample_interval_ms, mode,"
|
|
45
|
+
" header_json) VALUES (2,'shearwater',1,'A823C228#1','h2',2,"
|
|
46
|
+
" '2026-07-06 03:43:52','2026-07-06 11:43:52',480,110,20.2,10000,"
|
|
47
|
+
" 'oc_tec',?)",
|
|
48
|
+
(json.dumps(SHEARWATER_HEADER),),
|
|
49
|
+
)
|
|
50
|
+
con.executemany(
|
|
51
|
+
"INSERT INTO garmin_samples (source_dive_id, t_s, depth_m) VALUES (1,?,?)",
|
|
52
|
+
[(t, t * 0.5) for t in range(5)],
|
|
53
|
+
)
|
|
54
|
+
con.executemany(
|
|
55
|
+
"INSERT INTO shearwater_samples (source_dive_id, t_s, depth_m, gf99)"
|
|
56
|
+
" VALUES (2,?,?,?)",
|
|
57
|
+
[(t * 10.0, t * 5.0, 10.0 + t) for t in range(3)],
|
|
58
|
+
)
|
|
59
|
+
con.execute(
|
|
60
|
+
"INSERT INTO gases (source_dive_id, gas_index, o2_pct, he_pct, circuit,"
|
|
61
|
+
" enabled) VALUES (2, 0, 21, 35, 'oc', 1)"
|
|
62
|
+
)
|
|
63
|
+
con.execute(
|
|
64
|
+
"INSERT INTO events (source_dive_id, t_s, kind, payload_json)"
|
|
65
|
+
" VALUES (2, 0, 'pnf_info_event', '{}')"
|
|
66
|
+
)
|
|
67
|
+
con.execute(
|
|
68
|
+
"INSERT INTO matches (garmin_source_dive_id, shearwater_source_dive_id,"
|
|
69
|
+
" clock_offset_s, residual_skew_s, xcorr_score, duration_delta_s,"
|
|
70
|
+
" max_depth_delta_m, method, status, matched_at)"
|
|
71
|
+
" VALUES (1, 2, 28800, 190.0, 0.999, 10, 0.2, 'test', 'auto', 'now')"
|
|
72
|
+
)
|
|
73
|
+
con.execute(
|
|
74
|
+
"INSERT INTO dives (id, dive_number, start_time_utc, utc_offset_min,"
|
|
75
|
+
" duration_s, max_depth_m, is_test) VALUES"
|
|
76
|
+
" (1, 1, '2026-07-06 03:47:01', 480, 120, 20.2, 0)"
|
|
77
|
+
)
|
|
78
|
+
con.execute(
|
|
79
|
+
"INSERT INTO dives (id, dive_number, start_time_utc, utc_offset_min,"
|
|
80
|
+
" duration_s, max_depth_m, is_test) VALUES"
|
|
81
|
+
" (2, NULL, '2026-07-06 05:00:00', 480, 30, 1.0, 1)"
|
|
82
|
+
)
|
|
83
|
+
con.executemany(
|
|
84
|
+
"INSERT INTO dive_members (dive_id, source_dive_id) VALUES (?,?)",
|
|
85
|
+
[(1, 1), (1, 2)],
|
|
86
|
+
)
|
|
87
|
+
con.close()
|
|
88
|
+
return db_path
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_computer_info():
|
|
92
|
+
g = computer_info("garmin", GARMIN_HEADER)
|
|
93
|
+
assert g == {"model": "Descent Mk3", "firmware": 25.21, "serial": 12345}
|
|
94
|
+
s = computer_info("shearwater", SHEARWATER_HEADER)
|
|
95
|
+
assert s == {"model": "Perdix 2", "firmware": "v100", "serial": "A823C228"}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_list(store, capsys):
|
|
99
|
+
assert cli.main(["--db", str(store), "list"]) == 0
|
|
100
|
+
out = capsys.readouterr().out
|
|
101
|
+
assert "Descent Mk3 + Perdix 2" in out
|
|
102
|
+
assert "2026-07-06 11:47" in out # local time
|
|
103
|
+
assert "oc_tec" in out # shearwater mode preferred
|
|
104
|
+
assert "T" not in out.splitlines()[0] # header, then only dive 1
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_show(store, capsys):
|
|
108
|
+
assert cli.main(["--db", str(store), "show", "1"]) == 0
|
|
109
|
+
out = capsys.readouterr().out
|
|
110
|
+
assert "dive 1" in out
|
|
111
|
+
assert "Descent Mk3 25.21" in out
|
|
112
|
+
assert "Perdix 2 v100" in out
|
|
113
|
+
assert "gases: 21/35 oc" in out
|
|
114
|
+
assert "clock offset +28800 s" in out
|
|
115
|
+
assert "gf99" in out # channel coverage
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_show_missing_dive(store, capsys):
|
|
119
|
+
assert cli.main(["--db", str(store), "show", "99"]) == 1
|
|
120
|
+
assert "no dive with number 99" in capsys.readouterr().err
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_export_json(store, tmp_path, capsys):
|
|
124
|
+
out_file = tmp_path / "d1.json"
|
|
125
|
+
assert cli.main(
|
|
126
|
+
["--db", str(store), "export", "1", "--format", "json", "-o", str(out_file)]
|
|
127
|
+
) == 0
|
|
128
|
+
data = json.loads(out_file.read_text())
|
|
129
|
+
assert sorted(data["sources"]) == ["garmin", "shearwater"]
|
|
130
|
+
assert data["sources"]["garmin"]["samples"]["depth_m"] == [0.0, 0.5, 1.0, 1.5, 2.0]
|
|
131
|
+
assert data["matches"][0]["clock_offset_s"] == 28800
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_export_csv_stdout(store, capsys):
|
|
135
|
+
assert cli.main(["--db", str(store), "export", "1"]) == 0
|
|
136
|
+
rows = list(csv.reader(io.StringIO(capsys.readouterr().out)))
|
|
137
|
+
header = rows[0]
|
|
138
|
+
assert header[:3] == ["source", "source_dive_id", "t_s"]
|
|
139
|
+
assert "gf99" in header
|
|
140
|
+
body = rows[1:]
|
|
141
|
+
assert len(body) == 8 # 5 garmin + 3 shearwater, never merged
|
|
142
|
+
assert {r[0] for r in body} == {"garmin", "shearwater"}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_aligned_series_applies_clock_offset_and_skew(store):
|
|
146
|
+
dive = load_dive(store, 1)
|
|
147
|
+
series = {s["source"]: s for s in aligned_series(dive)}
|
|
148
|
+
assert series["garmin"]["x"][0] == 0.0
|
|
149
|
+
# shearwater start_utc is 189 s before the dive start; +190 s skew -> +1 s
|
|
150
|
+
assert series["shearwater"]["x"][0] == pytest.approx(1.0)
|
|
151
|
+
assert series["shearwater"]["label"] == "Perdix 2"
|
|
@@ -1,107 +0,0 @@
|
|
|
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())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|