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.
Files changed (30) hide show
  1. bottomtime-0.2.0/.claude/settings.local.json +30 -0
  2. {bottomtime-0.1.0 → bottomtime-0.2.0}/PKG-INFO +54 -3
  3. {bottomtime-0.1.0 → bottomtime-0.2.0}/README.md +51 -2
  4. {bottomtime-0.1.0 → bottomtime-0.2.0}/pyproject.toml +2 -1
  5. {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/__init__.py +1 -1
  6. {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/api.py +71 -1
  7. bottomtime-0.2.0/src/bottomtime/cli.py +336 -0
  8. {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/decode/pnf.py +18 -1
  9. {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/match/correlate.py +1 -1
  10. bottomtime-0.2.0/src/bottomtime/plot.py +163 -0
  11. bottomtime-0.2.0/tests/test_cli.py +151 -0
  12. bottomtime-0.1.0/src/bottomtime/cli.py +0 -107
  13. {bottomtime-0.1.0 → bottomtime-0.2.0}/.github/workflows/test.yml +0 -0
  14. {bottomtime-0.1.0 → bottomtime-0.2.0}/.gitignore +0 -0
  15. {bottomtime-0.1.0 → bottomtime-0.2.0}/LICENSE +0 -0
  16. {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/decode/__init__.py +0 -0
  17. {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/ingest/__init__.py +0 -0
  18. {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/ingest/archive.py +0 -0
  19. {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/ingest/garmin_fit.py +0 -0
  20. {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/ingest/shearwater_db.py +0 -0
  21. {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/ingest/shearwater_xml.py +0 -0
  22. {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/match/__init__.py +0 -0
  23. {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/match/matcher.py +0 -0
  24. {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/store/__init__.py +0 -0
  25. {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/store/db.py +0 -0
  26. {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/store/schema.py +0 -0
  27. {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/verify/__init__.py +0 -0
  28. {bottomtime-0.1.0 → bottomtime-0.2.0}/src/bottomtime/verify/crosscheck.py +0 -0
  29. {bottomtime-0.1.0 → bottomtime-0.2.0}/tests/test_correlate.py +0 -0
  30. {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.1.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 — including per-sample **GF99, deco ceiling, CNS and battery
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 — issue reports with a
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 — including per-sample **GF99, deco ceiling, CNS and battery
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 — issue reports with a
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.1.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]
@@ -1,6 +1,6 @@
1
1
  """bottomtime: lossless dive-log store for multi-computer divers."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
4
4
 
5
5
  from .api import list_dives, load_dive
6
6
 
@@ -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
- "header": json.loads(sd["header_json"]),
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` — nothing is dropped.
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. Robust where NCC is unstable (low-variance pool
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