bottomtime 0.1.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.
@@ -0,0 +1,20 @@
1
+ name: test
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.10", "3.14"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: ${{ matrix.python-version }}
19
+ - run: pip install -e .[dev]
20
+ - run: pytest -q
@@ -0,0 +1,10 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ .pytest_cache/
6
+
7
+ # Personal dive data lives outside version control
8
+ data/
9
+ archive/
10
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Snehan Kekre
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: bottomtime
3
+ Version: 0.1.0
4
+ Summary: Lossless dive-log store: ingest, decode, and reconcile dives from Garmin (FIT) and Shearwater (Petrel Native Format) dive computers
5
+ Project-URL: Homepage, https://github.com/snehankekre/bottomtime
6
+ Project-URL: Issues, https://github.com/snehankekre/bottomtime/issues
7
+ Author: Snehan Kekre
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: decompression,dive-log,diving,fit,garmin,scuba,shearwater,uddf
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Scientific/Engineering
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: garmin-fit-sdk>=21.0
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest; extra == 'dev'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # bottomtime
22
+
23
+ A lossless dive-log store for divers who log with more than one computer.
24
+
25
+ Tec divers often wear two dive computers (say, a Garmin Descent on one wrist
26
+ and a Shearwater on the other). Each records the same dive differently: the
27
+ Garmin logs at 1 Hz with heart rate, GPS, CNS and tissue loading; the
28
+ Shearwater logs its native record with GF99, deco ceiling, ppO2 sensors and
29
+ per-sample battery. No existing interchange format (UDDF, DL7, Subsurface)
30
+ preserves the union, so exporting to any of them throws data away.
31
+
32
+ bottomtime keeps everything:
33
+
34
+ - **Ingests Garmin FIT files** (official garmin-fit-sdk). Known dive channels
35
+ become typed columns; unknown fields and Garmin's undocumented message
36
+ types are preserved raw, per sample and per message.
37
+ - **Decodes Shearwater's Petrel Native Format** directly from a Shearwater
38
+ Cloud database (`dive_data.db`, the "Export Database" output) with a pure
39
+ Python decoder — including per-sample **GF99, deco ceiling, CNS and battery
40
+ voltage** that Shearwater's own XML/CSV exports omit. Unknown record types
41
+ are preserved raw.
42
+ - **Archives every source file verbatim**, content-addressed by SHA-256. The
43
+ database is a decoded view; the archive is the source of truth.
44
+ - **Reconciles dual-computer dives**: interval overlap plus depth-profile
45
+ cross-correlation links the two logs of the same physical dive (storing
46
+ clock offset and residual skew) without ever merging or resampling the
47
+ original series.
48
+ - **Verifies itself**: the decoder is cross-checked per-sample against
49
+ Shearwater XML exports, against Shearwater Cloud's own computed values
50
+ (EndGF99), and matched dives are checked for depth agreement.
51
+
52
+ Everything lands in a single SQLite file with a stable schema, ready for SQL,
53
+ pandas, or whatever you analyze with.
54
+
55
+ ## Install
56
+
57
+ ```sh
58
+ pip install bottomtime
59
+ ```
60
+
61
+ ## Usage
62
+
63
+ ```sh
64
+ # create a store
65
+ bottomtime init
66
+
67
+ # ingest a directory of Garmin FIT files
68
+ # (optionally with a Garmin Connect index JSON for true UTC offsets and metadata)
69
+ bottomtime ingest garmin ~/dives/garmin-fits
70
+
71
+ # ingest a Shearwater Cloud database export
72
+ bottomtime ingest shearwater ~/dives/dive_data.db
73
+
74
+ # link dual-computer dives and build the canonical dive list
75
+ bottomtime match
76
+
77
+ # run the verification suite (XML dir optional but recommended)
78
+ bottomtime verify --xml-dir ~/dives/shearwater-xml
79
+
80
+ bottomtime status
81
+ ```
82
+
83
+ All commands are idempotent: re-running an ingest skips already-stored dives,
84
+ so syncing after a dive trip only adds what's new.
85
+
86
+ ## Schema in one breath
87
+
88
+ `raw_artifacts` (archived files) → `source_dives` (one row per computer log,
89
+ with the verbatim header and, for Shearwater, the raw PNF blob) →
90
+ `garmin_samples` / `shearwater_samples` (wide, per-source, never resampled) +
91
+ `gases`, `gas_segments`, `events`, `undecoded_payloads` (raw bytes of
92
+ anything not yet understood) → `matches` (dual-computer links) → `dives` +
93
+ `dive_members` (canonical dives). Views `v_dive_summary` and
94
+ `v_samples_unified` cover the common queries.
95
+
96
+ ## Notes on the PNF decoder
97
+
98
+ The decoder follows libdivecomputer's `shearwater_predator_parser.c` for the
99
+ documented fields and adds empirically verified mappings for GF99 (byte 25),
100
+ deco ceiling (byte 24), battery voltage (byte 18) and @+5 TTS (bytes 26-27),
101
+ validated against Shearwater Cloud's displayed values across hundreds of
102
+ 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
104
+ failing blob are very welcome.
105
+
106
+ ## License
107
+
108
+ MIT
@@ -0,0 +1,88 @@
1
+ # bottomtime
2
+
3
+ A lossless dive-log store for divers who log with more than one computer.
4
+
5
+ Tec divers often wear two dive computers (say, a Garmin Descent on one wrist
6
+ and a Shearwater on the other). Each records the same dive differently: the
7
+ Garmin logs at 1 Hz with heart rate, GPS, CNS and tissue loading; the
8
+ Shearwater logs its native record with GF99, deco ceiling, ppO2 sensors and
9
+ per-sample battery. No existing interchange format (UDDF, DL7, Subsurface)
10
+ preserves the union, so exporting to any of them throws data away.
11
+
12
+ bottomtime keeps everything:
13
+
14
+ - **Ingests Garmin FIT files** (official garmin-fit-sdk). Known dive channels
15
+ become typed columns; unknown fields and Garmin's undocumented message
16
+ types are preserved raw, per sample and per message.
17
+ - **Decodes Shearwater's Petrel Native Format** directly from a Shearwater
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
20
+ voltage** that Shearwater's own XML/CSV exports omit. Unknown record types
21
+ are preserved raw.
22
+ - **Archives every source file verbatim**, content-addressed by SHA-256. The
23
+ database is a decoded view; the archive is the source of truth.
24
+ - **Reconciles dual-computer dives**: interval overlap plus depth-profile
25
+ cross-correlation links the two logs of the same physical dive (storing
26
+ clock offset and residual skew) without ever merging or resampling the
27
+ original series.
28
+ - **Verifies itself**: the decoder is cross-checked per-sample against
29
+ Shearwater XML exports, against Shearwater Cloud's own computed values
30
+ (EndGF99), and matched dives are checked for depth agreement.
31
+
32
+ Everything lands in a single SQLite file with a stable schema, ready for SQL,
33
+ pandas, or whatever you analyze with.
34
+
35
+ ## Install
36
+
37
+ ```sh
38
+ pip install bottomtime
39
+ ```
40
+
41
+ ## Usage
42
+
43
+ ```sh
44
+ # create a store
45
+ bottomtime init
46
+
47
+ # ingest a directory of Garmin FIT files
48
+ # (optionally with a Garmin Connect index JSON for true UTC offsets and metadata)
49
+ bottomtime ingest garmin ~/dives/garmin-fits
50
+
51
+ # ingest a Shearwater Cloud database export
52
+ bottomtime ingest shearwater ~/dives/dive_data.db
53
+
54
+ # link dual-computer dives and build the canonical dive list
55
+ bottomtime match
56
+
57
+ # run the verification suite (XML dir optional but recommended)
58
+ bottomtime verify --xml-dir ~/dives/shearwater-xml
59
+
60
+ bottomtime status
61
+ ```
62
+
63
+ All commands are idempotent: re-running an ingest skips already-stored dives,
64
+ so syncing after a dive trip only adds what's new.
65
+
66
+ ## Schema in one breath
67
+
68
+ `raw_artifacts` (archived files) → `source_dives` (one row per computer log,
69
+ with the verbatim header and, for Shearwater, the raw PNF blob) →
70
+ `garmin_samples` / `shearwater_samples` (wide, per-source, never resampled) +
71
+ `gases`, `gas_segments`, `events`, `undecoded_payloads` (raw bytes of
72
+ anything not yet understood) → `matches` (dual-computer links) → `dives` +
73
+ `dive_members` (canonical dives). Views `v_dive_summary` and
74
+ `v_samples_unified` cover the common queries.
75
+
76
+ ## Notes on the PNF decoder
77
+
78
+ The decoder follows libdivecomputer's `shearwater_predator_parser.c` for the
79
+ documented fields and adds empirically verified mappings for GF99 (byte 25),
80
+ deco ceiling (byte 24), battery voltage (byte 18) and @+5 TTS (bytes 26-27),
81
+ validated against Shearwater Cloud's displayed values across hundreds of
82
+ 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
84
+ failing blob are very welcome.
85
+
86
+ ## License
87
+
88
+ MIT
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "bottomtime"
7
+ version = "0.1.0"
8
+ description = "Lossless dive-log store: ingest, decode, and reconcile dives from Garmin (FIT) and Shearwater (Petrel Native Format) dive computers"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ authors = [{ name = "Snehan Kekre" }]
14
+ keywords = ["diving", "scuba", "dive-log", "garmin", "shearwater", "fit", "uddf", "decompression"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: End Users/Desktop",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Scientific/Engineering",
20
+ ]
21
+ dependencies = ["garmin-fit-sdk>=21.0"]
22
+
23
+ [project.optional-dependencies]
24
+ dev = ["pytest"]
25
+
26
+ [project.scripts]
27
+ bottomtime = "bottomtime.cli:main"
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/snehankekre/bottomtime"
31
+ Issues = "https://github.com/snehankekre/bottomtime/issues"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/bottomtime"]
@@ -0,0 +1,7 @@
1
+ """bottomtime: lossless dive-log store for multi-computer divers."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .api import list_dives, load_dive
6
+
7
+ __all__ = ["load_dive", "list_dives", "__version__"]
@@ -0,0 +1,88 @@
1
+ """Small read API for analysis code.
2
+
3
+ import bottomtime
4
+ dive = bottomtime.load_dive("data/dives.db", 291)
5
+ dive["sources"]["garmin"]["samples"]["depth_m"]
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sqlite3
12
+ from pathlib import Path
13
+
14
+ _SAMPLE_TABLES = {"garmin": "garmin_samples", "shearwater": "shearwater_samples"}
15
+
16
+
17
+ def _columns_of(con: sqlite3.Connection, table: str) -> list[str]:
18
+ return [r[1] for r in con.execute(f"PRAGMA table_info({table})")]
19
+
20
+
21
+ def load_dive(db_path: str | Path, dive_number: int) -> dict:
22
+ """Load one canonical dive with every member source log's samples as
23
+ column-oriented lists (ready for plotting / DataFrame construction)."""
24
+ con = sqlite3.connect(db_path)
25
+ con.row_factory = sqlite3.Row
26
+ try:
27
+ dive = con.execute(
28
+ "SELECT * FROM dives WHERE dive_number=?", (dive_number,)
29
+ ).fetchone()
30
+ if dive is None:
31
+ raise KeyError(f"no dive with number {dive_number}")
32
+ out = {k: dive[k] for k in dive.keys()}
33
+ out["sources"] = {}
34
+ members = con.execute(
35
+ "SELECT sd.* FROM source_dives sd JOIN dive_members dm"
36
+ " ON dm.source_dive_id = sd.id WHERE dm.dive_id=?",
37
+ (dive["id"],),
38
+ ).fetchall()
39
+ for sd in members:
40
+ table = _SAMPLE_TABLES[sd["source"]]
41
+ cols = [c for c in _columns_of(con, table) if c != "source_dive_id"]
42
+ rows = con.execute(
43
+ f"SELECT {', '.join(cols)} FROM {table}"
44
+ " WHERE source_dive_id=? ORDER BY t_s",
45
+ (sd["id"],),
46
+ ).fetchall()
47
+ samples = {c: [r[i] for r in rows] for i, c in enumerate(cols)}
48
+ entry = {
49
+ "source_dive_id": sd["id"],
50
+ "source_key": sd["source_key"],
51
+ "start_time_utc": sd["start_time_utc"],
52
+ "start_time_local": sd["start_time_local"],
53
+ "duration_s": sd["duration_s"],
54
+ "max_depth_m": sd["max_depth_m"],
55
+ "mode": sd["mode"],
56
+ "header": json.loads(sd["header_json"]),
57
+ "samples": samples,
58
+ }
59
+ key = sd["source"]
60
+ if key in out["sources"]: # several logs from one source (splits)
61
+ existing = out["sources"][key]
62
+ if isinstance(existing, list):
63
+ existing.append(entry)
64
+ else:
65
+ out["sources"][key] = [existing, entry]
66
+ else:
67
+ out["sources"][key] = entry
68
+ return out
69
+ finally:
70
+ con.close()
71
+
72
+
73
+ def list_dives(db_path: str | Path, include_tests: bool = False) -> list[dict]:
74
+ con = sqlite3.connect(db_path)
75
+ con.row_factory = sqlite3.Row
76
+ try:
77
+ where = "" if include_tests else "WHERE d.is_test = 0"
78
+ return [
79
+ {k: r[k] for k in r.keys()}
80
+ for r in con.execute(
81
+ "SELECT d.*, group_concat(sd.source) AS sources FROM dives d"
82
+ " JOIN dive_members dm ON dm.dive_id = d.id"
83
+ " JOIN source_dives sd ON sd.id = dm.source_dive_id"
84
+ f" {where} GROUP BY d.id ORDER BY d.start_time_utc"
85
+ )
86
+ ]
87
+ finally:
88
+ con.close()
@@ -0,0 +1,107 @@
1
+ """bottomtime command line interface.
2
+
3
+ bottomtime init
4
+ bottomtime ingest garmin <fit-dir>
5
+ bottomtime ingest shearwater <dive_data.db>
6
+ bottomtime match
7
+ bottomtime verify [--xml-dir DIR]
8
+ bottomtime status
9
+
10
+ All commands are idempotent: re-running an ingest skips anything already
11
+ stored (content-hash keyed), match recomputes only 'auto' links.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ from .store import db as store_db
21
+
22
+
23
+ def main(argv: list[str] | None = None) -> int:
24
+ parser = argparse.ArgumentParser(prog="bottomtime")
25
+ parser.add_argument("--db", default="data/dives.db", help="path to the dive store")
26
+ parser.add_argument(
27
+ "--archive", default="archive", help="directory for verbatim raw source copies"
28
+ )
29
+ sub = parser.add_subparsers(dest="command", required=True)
30
+
31
+ sub.add_parser("init", help="create an empty dive store")
32
+
33
+ p_ingest = sub.add_parser("ingest", help="ingest a data source")
34
+ ingest_sub = p_ingest.add_subparsers(dest="source", required=True)
35
+ p_garmin = ingest_sub.add_parser("garmin", help="a directory of FIT files")
36
+ p_garmin.add_argument("fit_dir", type=Path)
37
+ p_garmin.add_argument(
38
+ "--index",
39
+ default="dives_index.json",
40
+ help="Garmin Connect index JSON filename inside fit-dir",
41
+ )
42
+ p_sw = ingest_sub.add_parser("shearwater", help="a Shearwater Cloud dive_data.db")
43
+ p_sw.add_argument("db_path", type=Path)
44
+
45
+ sub.add_parser("match", help="match dual-computer dives, build canonical dives")
46
+
47
+ p_verify = sub.add_parser("verify", help="run the verification suite")
48
+ p_verify.add_argument("--xml-dir", type=Path, default=None,
49
+ help="directory of Shearwater XML exports to cross-check")
50
+
51
+ sub.add_parser("status", help="summarize the store")
52
+
53
+ args = parser.parse_args(argv)
54
+ con = store_db.connect(args.db)
55
+ archive_dir = Path(args.archive)
56
+
57
+ if args.command == "init":
58
+ print(f"initialized {args.db}")
59
+ return 0
60
+
61
+ if args.command == "ingest" and args.source == "garmin":
62
+ from .ingest.garmin_fit import ingest_garmin_dir
63
+
64
+ stats = ingest_garmin_dir(con, archive_dir, args.fit_dir, args.index)
65
+ print(f"garmin: {stats}")
66
+ return 1 if stats["failed"] else 0
67
+
68
+ if args.command == "ingest" and args.source == "shearwater":
69
+ from .ingest.shearwater_db import ingest_shearwater_db
70
+
71
+ stats = ingest_shearwater_db(con, archive_dir, args.db_path)
72
+ print(f"shearwater: {stats}")
73
+ return 1 if stats["failed"] else 0
74
+
75
+ if args.command == "match":
76
+ from .match.matcher import run_match
77
+
78
+ stats = run_match(con)
79
+ print(f"match: {stats}")
80
+ return 0
81
+
82
+ if args.command == "verify":
83
+ from .verify.crosscheck import run_verify
84
+
85
+ return run_verify(con, args.xml_dir)
86
+
87
+ if args.command == "status":
88
+ for line in con.execute(
89
+ "SELECT source || ' source dives: ' || count(*) FROM source_dives GROUP BY source"
90
+ ):
91
+ print(line[0])
92
+ for line in con.execute(
93
+ "SELECT 'canonical dives: ' || count(*) || ' (' ||"
94
+ " sum(CASE WHEN is_test THEN 1 ELSE 0 END) || ' test)' FROM dives"
95
+ ):
96
+ print(line[0])
97
+ for line in con.execute(
98
+ "SELECT 'matched pairs: ' || count(*) FROM matches WHERE status IN ('auto','confirmed')"
99
+ ):
100
+ print(line[0])
101
+ return 0
102
+
103
+ return 2
104
+
105
+
106
+ if __name__ == "__main__":
107
+ sys.exit(main())