straplog 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.
@@ -0,0 +1,36 @@
1
+ # Build, test, and publish to PyPI on a version tag (v0.2.0, ...).
2
+ # Auth is PyPI trusted publishing (OIDC): no token in the repo. The PyPI
3
+ # publisher is registered as personalscience/straplog-py, publish.yml, env `pypi`.
4
+ name: publish
5
+
6
+ on:
7
+ push:
8
+ tags: ["v*"]
9
+
10
+ jobs:
11
+ build:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: astral-sh/setup-uv@v6
16
+ - name: Tag matches package version
17
+ run: test "v$(uv version --short)" = "$GITHUB_REF_NAME"
18
+ - run: uv run --group dev pytest -q
19
+ - run: uv build
20
+ - uses: actions/upload-artifact@v4
21
+ with:
22
+ name: dist
23
+ path: dist/
24
+
25
+ publish:
26
+ needs: build
27
+ runs-on: ubuntu-latest
28
+ environment: pypi
29
+ permissions:
30
+ id-token: write
31
+ steps:
32
+ - uses: actions/download-artifact@v4
33
+ with:
34
+ name: dist
35
+ path: dist/
36
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .pytest_cache/
4
+ dist/
5
+ .venv/
6
+ *.sqlite
7
+ .claude/settings.local.json
8
+ .claude/skills/
9
+ .claude/rules/
@@ -0,0 +1,20 @@
1
+ # CLAUDE.md — straplog-py
2
+
3
+ Public Python package `straplog` (PyPI): reads StrapLog iOS-app exports (Polar H10 RR in SQLite) and holds the shared HRV algorithms for the StrapLog skills (`personalscience/straplog-skills`). Split out of the app repo's `tools/py` on 2026-10-18 with history.
4
+
5
+ ## Commands
6
+
7
+ ```
8
+ uv run --group dev pytest -q · uv build · uv publish (TestPyPI first)
9
+ ```
10
+
11
+ ## Rules that the tree does not tell you
12
+
13
+ - The file format contract is `docs/SCHEMA.md` in the StrapLog app repo (`~/dev/ios projects/straplog`), enforced there by `SchemaTests.docsMatch`. A schema bump lands in the app first, then here.
14
+ - `straplog.hrv` must stay format-agnostic: no imports from `db`/`export`.
15
+ - Metric semantics are fixed for log continuity (hrv-resting's CSV log): ddof=1, `hr_mean` = mean of beat HRs. Changing them is a breaking change.
16
+ - Never commit real exports (`*.sqlite` is ignored): they are personal health data. Tests use synthetic fixtures (`tests/conftest.py`).
17
+
18
+ ## Vault
19
+
20
+ - Plan: `~/.claude/plans/straplog-skills-consolidation.md`
straplog-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Richard Sprague / Personal Science, Inc.
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,74 @@
1
+ Metadata-Version: 2.5
2
+ Name: straplog
3
+ Version: 0.2.0
4
+ Summary: Read StrapLog exports (Polar H10 RR intervals in SQLite) and compute HRV: library and CLI.
5
+ Project-URL: Homepage, https://github.com/personalscience/straplog-py
6
+ Author: Richard Sprague
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: dfa-alpha1,heart-rate-variability,hrv,polar-h10,rr-intervals
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
13
+ Requires-Python: >=3.11
14
+ Requires-Dist: numpy
15
+ Requires-Dist: pandas
16
+ Provides-Extra: plot
17
+ Requires-Dist: plotnine; extra == 'plot'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # straplog
21
+
22
+ Read [StrapLog](https://github.com/personalscience) exports and compute heart-rate variability.
23
+
24
+ StrapLog is an iOS app that records beat-to-beat RR intervals from a Polar H10 chest strap. It writes each recording as one self-contained SQLite file (`<YYMMDD-HHMM>-<name>.sqlite`) to its iCloud folder, plus an `index.json` catalog. This package reads those files and provides the HRV algorithms used by the StrapLog Claude Code skills.
25
+
26
+ ## Install
27
+
28
+ ```sh
29
+ uv add straplog # or: pip install straplog
30
+ uv add 'straplog[plot]' # + the plotnine house chart style
31
+ ```
32
+
33
+ ## Use
34
+
35
+ ```python
36
+ from straplog import db, hrv
37
+
38
+ recs = db.list_recordings("251018-0712-REST.sqlite")
39
+ rr, events, start_local, name = db.load_recording("251018-0712-REST.sqlite", recs[0]["pk"])
40
+
41
+ clean = hrv.clean_rr(rr["rr_ms"], pct_thresh=0.20)
42
+ hrv.time_domain(clean) # n, hr_mean, sdnn_ms, rmssd_ms, ln_rmssd, pnn50_pct, ...
43
+ hrv.dfa_alpha1(clean)
44
+ hrv.rolling_features(rr) # 2-min windows, 1-min stride: hr_bpm, alpha1, rmssd
45
+ ```
46
+
47
+ | Module | Role |
48
+ |---|---|
49
+ | `straplog.export` | A file as stored: epoch ms UTC, typed events |
50
+ | `straplog.catalog` | List a folder of exports via `index.json` |
51
+ | `straplog.db` | Load beats and markers as DataFrames in local time |
52
+ | `straplog.hrv` | Artifact filter, time-domain metrics, DFA α1, sliding windows. Works on any RR series |
53
+ | `straplog.plot` | House chart style (plotnine; `plot` extra) |
54
+ | `straplog.cli` | `straplog ls / rr / events / meta` |
55
+
56
+ ## CLI
57
+
58
+ ```sh
59
+ straplog ls --activity sleep --since 14d
60
+ straplog rr 251018-0712-REST --json
61
+ straplog events 251018-0712-REST --markers
62
+ ```
63
+
64
+ A bare export name resolves against the StrapLog iCloud folder.
65
+
66
+ ## Conventions
67
+
68
+ - RR in ms. The H10 sends RR in 1/1024 s; `rr_1024` is canonical in the file and `rr_ms` is derived.
69
+ - SDNN, SDSD and HR SD are sample statistics (ddof=1). `hr_mean` is the mean of per-beat HR, not `60000 / mean_rr_ms`.
70
+ - The artifact filter applies a 300–2000 ms gate, then an iterative 5-beat median filter. The default threshold is 0.25 (workouts); use 0.20 for resting readings.
71
+
72
+ ## License
73
+
74
+ MIT
@@ -0,0 +1,55 @@
1
+ # straplog
2
+
3
+ Read [StrapLog](https://github.com/personalscience) exports and compute heart-rate variability.
4
+
5
+ StrapLog is an iOS app that records beat-to-beat RR intervals from a Polar H10 chest strap. It writes each recording as one self-contained SQLite file (`<YYMMDD-HHMM>-<name>.sqlite`) to its iCloud folder, plus an `index.json` catalog. This package reads those files and provides the HRV algorithms used by the StrapLog Claude Code skills.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ uv add straplog # or: pip install straplog
11
+ uv add 'straplog[plot]' # + the plotnine house chart style
12
+ ```
13
+
14
+ ## Use
15
+
16
+ ```python
17
+ from straplog import db, hrv
18
+
19
+ recs = db.list_recordings("251018-0712-REST.sqlite")
20
+ rr, events, start_local, name = db.load_recording("251018-0712-REST.sqlite", recs[0]["pk"])
21
+
22
+ clean = hrv.clean_rr(rr["rr_ms"], pct_thresh=0.20)
23
+ hrv.time_domain(clean) # n, hr_mean, sdnn_ms, rmssd_ms, ln_rmssd, pnn50_pct, ...
24
+ hrv.dfa_alpha1(clean)
25
+ hrv.rolling_features(rr) # 2-min windows, 1-min stride: hr_bpm, alpha1, rmssd
26
+ ```
27
+
28
+ | Module | Role |
29
+ |---|---|
30
+ | `straplog.export` | A file as stored: epoch ms UTC, typed events |
31
+ | `straplog.catalog` | List a folder of exports via `index.json` |
32
+ | `straplog.db` | Load beats and markers as DataFrames in local time |
33
+ | `straplog.hrv` | Artifact filter, time-domain metrics, DFA α1, sliding windows. Works on any RR series |
34
+ | `straplog.plot` | House chart style (plotnine; `plot` extra) |
35
+ | `straplog.cli` | `straplog ls / rr / events / meta` |
36
+
37
+ ## CLI
38
+
39
+ ```sh
40
+ straplog ls --activity sleep --since 14d
41
+ straplog rr 251018-0712-REST --json
42
+ straplog events 251018-0712-REST --markers
43
+ ```
44
+
45
+ A bare export name resolves against the StrapLog iCloud folder.
46
+
47
+ ## Conventions
48
+
49
+ - RR in ms. The H10 sends RR in 1/1024 s; `rr_1024` is canonical in the file and `rr_ms` is derived.
50
+ - SDNN, SDSD and HR SD are sample statistics (ddof=1). `hr_mean` is the mean of per-beat HR, not `60000 / mean_rr_ms`.
51
+ - The artifact filter applies a 300–2000 ms gate, then an iterative 5-beat median filter. The default threshold is 0.25 (workouts); use 0.20 for resting readings.
52
+
53
+ ## License
54
+
55
+ MIT
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "straplog"
3
+ version = "0.2.0"
4
+ description = "Read StrapLog exports (Polar H10 RR intervals in SQLite) and compute HRV: library and CLI."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "Richard Sprague" }]
9
+ requires-python = ">=3.11"
10
+ dependencies = ["numpy", "pandas"]
11
+ keywords = ["hrv", "polar-h10", "rr-intervals", "dfa-alpha1", "heart-rate-variability"]
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3",
14
+ "Topic :: Scientific/Engineering :: Medical Science Apps.",
15
+ "Intended Audience :: Science/Research",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ plot = ["plotnine"]
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/personalscience/straplog-py"
23
+
24
+ [project.scripts]
25
+ straplog = "straplog.cli:main"
26
+
27
+ [dependency-groups]
28
+ dev = ["pytest", "plotnine"]
29
+
30
+ [build-system]
31
+ requires = ["hatchling"]
32
+ build-backend = "hatchling.build"
@@ -0,0 +1,10 @@
1
+ """Read StrapLog exports: one single-file SQLite snapshot per recording.
2
+
3
+ `straplog.export` reads a file as stored (epoch ms UTC, typed markers);
4
+ `straplog.catalog` lists a folder of exports; `straplog.db` loads into the
5
+ HRV skills' Apple-epoch local-time convention; `straplog.hrv` holds the
6
+ format-agnostic HRV algorithms; `straplog.plot` is the house chart style
7
+ (needs the `plot` extra); `straplog.cli` is the agent tool surface.
8
+ The schema is docs/SCHEMA.md in the StrapLog app repo.
9
+ """
10
+ __version__ = "0.2.0"
@@ -0,0 +1,26 @@
1
+ """Opening a StrapLog export safely."""
2
+ from __future__ import annotations
3
+
4
+ import sqlite3
5
+ from pathlib import Path
6
+
7
+
8
+ def connect(db_path: Path) -> sqlite3.Connection:
9
+ """Open read-only and immutable: no -wal/-shm is ever created beside the
10
+ user's export."""
11
+ return sqlite3.connect(f"{Path(db_path).resolve().as_uri()}?immutable=1",
12
+ uri=True)
13
+
14
+
15
+ def require_snapshot(db_path: Path) -> None:
16
+ """A StrapLog -wal means the live database, which an immutable read would
17
+ see only partly. Exports are always single files."""
18
+ if Path(str(db_path) + "-wal").exists():
19
+ raise SystemExit(
20
+ f"{db_path} has a -wal sidecar, so it is StrapLog's live database. "
21
+ "Use an exported snapshot (iCloud Drive > StrapLog > <date>-<name>.sqlite, on the Mac "
22
+ "~/Library/Mobile Documents/iCloud~com~personalscience~straplog/Documents/).")
23
+
24
+
25
+ def columns(con: sqlite3.Connection, table: str) -> set[str]:
26
+ return {r[1] for r in con.execute(f"PRAGMA table_info({table})")}
@@ -0,0 +1,67 @@
1
+ """Find recordings in a folder of exports: `index.json` first, then a scan.
2
+
3
+ The app rewrites `index.json` after every export and delete (ExportIndex),
4
+ so an agent reads one file instead of opening each snapshot. Exports the
5
+ index does not list (older builds, files copied in from elsewhere) are
6
+ read directly, each as the recording it is named after, so a folder without
7
+ an index still lists completely and without duplicates.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ import time
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+
17
+ from . import export
18
+
19
+ DEFAULT_DIR = Path.home() / "Library/Mobile Documents/iCloud~com~personalscience~straplog/Documents"
20
+ INDEX = "index.json"
21
+
22
+
23
+ def entries(where: Path = DEFAULT_DIR) -> list[dict]:
24
+ """Index entries, newest first, each with `path` and `downloaded` added.
25
+ `where` is a folder of exports or a single export file."""
26
+ where = Path(where).expanduser()
27
+ if where.is_file():
28
+ return [_located(e, where.parent) for e in export.recordings(where)]
29
+ indexed = []
30
+ if (where / INDEX).exists():
31
+ indexed = json.loads((where / INDEX).read_text())["recordings"]
32
+ listed = {e["file"] for e in indexed}
33
+ scanned = [e for f in sorted(where.glob("*.sqlite")) if f.name not in listed
34
+ for e in export.recordings(f, named_only=True)]
35
+ rows = [_located(e, where) for e in indexed + scanned]
36
+ return sorted(rows, key=lambda e: (e["started_at_ms"], e["id"]), reverse=True)
37
+
38
+
39
+ def select(rows: list[dict], activity: str | None = None, since: str | None = None,
40
+ limit: int | None = None, now_ms: int | None = None) -> list[dict]:
41
+ if activity:
42
+ rows = [e for e in rows if e["activity"] == activity]
43
+ if since:
44
+ cutoff = since_ms(since, now_ms if now_ms is not None else int(time.time() * 1000))
45
+ rows = [e for e in rows if e["started_at_ms"] >= cutoff]
46
+ return rows[:limit] if limit else rows
47
+
48
+
49
+ def since_ms(since: str, now_ms: int) -> int:
50
+ """`14d`, `12h`, `90m` back from now, or an ISO date/time (local if naive)."""
51
+ m = re.fullmatch(r"(\d+)([dhm])", since)
52
+ if m:
53
+ return now_ms - int(m[1]) * {"d": 86_400_000, "h": 3_600_000, "m": 60_000}[m[2]]
54
+ return int(datetime.fromisoformat(since).astimezone().timestamp() * 1000)
55
+
56
+
57
+ def resolve(file: str, folder: Path | None = None) -> Path:
58
+ """A path, or a bare export name looked up in `folder` (default DEFAULT_DIR)."""
59
+ p = Path(file).expanduser()
60
+ if p.exists() or p.parent != Path("."):
61
+ return p
62
+ return Path(folder or DEFAULT_DIR) / file
63
+
64
+
65
+ def _located(entry: dict, folder: Path) -> dict:
66
+ path = folder / entry["file"]
67
+ return {**entry, "path": str(path), "downloaded": path.exists()}
@@ -0,0 +1,101 @@
1
+ """`straplog`: the agent tool surface over StrapLog exports.
2
+
3
+ straplog ls [FOLDER|FILE] [--activity sleep] [--since 14d] [--limit N] [--json]
4
+ straplog rr FILE [--id N] [--json] beats: t_ms, rr_ms
5
+ straplog events FILE [--id N] [--markers] [--json]
6
+ straplog meta FILE
7
+
8
+ FILE is a path or a bare export name in the iCloud folder. Times are epoch ms
9
+ UTC; local time = UTC + tz_offset_s (`ls` prints it as `started_local`).
10
+ Tables print as CSV unless --json.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import csv
16
+ import json
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ from . import catalog, export
21
+
22
+ LS_COLUMNS = ("started_local", "activity", "name", "duration_ms", "beats", "coverage", "labels", "file")
23
+
24
+
25
+ def main(argv: list[str] | None = None) -> None:
26
+ args = _parser().parse_args(argv)
27
+ args.run(args)
28
+
29
+
30
+ def _ls(args) -> None:
31
+ rows = catalog.select(catalog.entries(args.where), args.activity, args.since, args.limit)
32
+ if args.json:
33
+ _json(rows)
34
+ else:
35
+ _csv([{**r, "labels": " ".join(r["labels"])} for r in rows], LS_COLUMNS)
36
+
37
+
38
+ def _rr(args) -> None:
39
+ _table(export.beats(_file(args), args.id), ("t_ms", "rr_ms"), args.json)
40
+
41
+
42
+ def _events(args) -> None:
43
+ _table(export.events(_file(args), args.id, args.markers), ("at_ms", "kind", "label", "note"), args.json)
44
+
45
+
46
+ def _meta(args) -> None:
47
+ path = _file(args)
48
+ _json({"meta": export.meta(path), "recordings": export.recordings(path)})
49
+
50
+
51
+ def _file(args) -> Path:
52
+ path = catalog.resolve(args.file)
53
+ if not path.exists():
54
+ raise SystemExit(f"No such export: {path}")
55
+ return path
56
+
57
+
58
+ def _table(rows: list[dict], cols: tuple[str, ...], as_json: bool) -> None:
59
+ _json(rows) if as_json else _csv(rows, cols)
60
+
61
+
62
+ def _json(value) -> None:
63
+ json.dump(value, sys.stdout, indent=2, ensure_ascii=False)
64
+ sys.stdout.write("\n")
65
+
66
+
67
+ def _csv(rows: list[dict], cols: tuple[str, ...]) -> None:
68
+ w = csv.DictWriter(sys.stdout, cols, extrasaction="ignore", lineterminator="\n")
69
+ w.writeheader()
70
+ w.writerows(rows)
71
+
72
+
73
+ def _parser() -> argparse.ArgumentParser:
74
+ p = argparse.ArgumentParser(prog="straplog", description=__doc__,
75
+ formatter_class=argparse.RawDescriptionHelpFormatter)
76
+ sub = p.add_subparsers(required=True, metavar="COMMAND")
77
+
78
+ ls = sub.add_parser("ls", help="list recordings, newest first")
79
+ ls.add_argument("where", nargs="?", type=Path, default=catalog.DEFAULT_DIR,
80
+ help="folder of exports or one export (default: the iCloud folder)")
81
+ ls.add_argument("--activity", help="e.g. sleep, other")
82
+ ls.add_argument("--since", help="14d, 12h, 90m, or an ISO date")
83
+ ls.add_argument("--limit", type=int)
84
+ ls.add_argument("--json", action="store_true")
85
+ ls.set_defaults(run=_ls)
86
+
87
+ for name, run, help_ in (("rr", _rr, "beats: t_ms, rr_ms"),
88
+ ("events", _events, "events: at_ms, kind, label, note"),
89
+ ("meta", _meta, "meta table and index entry, as JSON")):
90
+ c = sub.add_parser(name, help=help_)
91
+ c.add_argument("file")
92
+ c.set_defaults(run=run)
93
+ if name != "meta":
94
+ c.add_argument("--id", type=int, help="recording id (only for multi-recording files)")
95
+ c.add_argument("--json", action="store_true")
96
+ sub.choices["events"].add_argument("--markers", action="store_true", help="markers only")
97
+ return p
98
+
99
+
100
+ if __name__ == "__main__":
101
+ main()
@@ -0,0 +1,183 @@
1
+ """Read a StrapLog export in the HRV Logger loaders' conventions.
2
+
3
+ StrapLog (the Polar H10 logger in ~/dev/iOS Projects/StrapLog) writes a
4
+ single-file SQLite snapshot: `recording`, `notification`, `beat`, `event`,
5
+ `motion`, `meta` (its docs/SCHEMA.md is the source of truth). Its times are
6
+ Unix epoch ms UTC plus a per-recording `tz_offset_s`.
7
+
8
+ The HRV Logger loaders in these skills work in Apple-epoch seconds of LOCAL
9
+ wall-clock (Core Data stored local time as if it were UTC). This module maps
10
+ StrapLog onto that convention, so every analysis downstream is unchanged:
11
+
12
+ t = (t_end_ms_est / 1000 + tz_offset_s) - 978307200
13
+
14
+ Skills import it as `import straplog.db as straplog_db`; this replaced the
15
+ copies `_hrv_shared/sync.sh` used to push into each skill's scripts/.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import sqlite3
20
+ from pathlib import Path
21
+
22
+ import numpy as np
23
+ import pandas as pd
24
+
25
+ from ._sqlite import columns, connect as _connect, require_snapshot as _require_snapshot
26
+
27
+ APPLE_EPOCH_OFFSET = 978307200.0 # 2001-01-01 -> unix
28
+ # Consecutive H10 notifications arrive ~1 s apart; a longer silence is a
29
+ # Bluetooth dropout, and that time counts against coverage.
30
+ NOTIFICATION_GAP_SEC = 3.0
31
+
32
+
33
+ def is_straplog(db_path: Path) -> bool:
34
+ """True for a StrapLog file (it has a `beat` table); False for HRV Logger."""
35
+ con = _connect(db_path)
36
+ try:
37
+ return con.execute(
38
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='beat'"
39
+ ).fetchone() is not None
40
+ finally:
41
+ con.close()
42
+
43
+
44
+ def _local_apple(ms, tz_offset_s):
45
+ return ms / 1000.0 + tz_offset_s - APPLE_EPOCH_OFFSET
46
+
47
+
48
+ def _recording(con: sqlite3.Connection, rid: int) -> dict:
49
+ row = con.execute(
50
+ "SELECT name, started_at_ms, ended_at_ms, tz_offset_s FROM recording "
51
+ "WHERE id=?", (rid,)).fetchone()
52
+ if row is None:
53
+ raise SystemExit(f"Recording {rid} not found.")
54
+ return dict(zip(("name", "started_at_ms", "ended_at_ms", "tz_offset_s"), row))
55
+
56
+
57
+ def _coverage(received_ms: np.ndarray, start_ms: int, end_ms: int) -> float | None:
58
+ """Fraction of the recording's wall-clock span covered by notifications:
59
+ the sum of inter-notification intervals no longer than
60
+ NOTIFICATION_GAP_SEC, over end - start. Time before the first and after
61
+ the last notification is uncovered."""
62
+ span = end_ms - start_ms
63
+ if span <= 0 or received_ms.size < 2:
64
+ return None
65
+ d = np.diff(received_ms)
66
+ return float(d[d <= NOTIFICATION_GAP_SEC * 1000].sum() / span)
67
+
68
+
69
+ def coverage(db_path: Path, rid: int) -> float | None:
70
+ _require_snapshot(db_path)
71
+ con = _connect(db_path)
72
+ try:
73
+ rec = _recording(con, rid)
74
+ rx = np.array([r[0] for r in con.execute(
75
+ "SELECT received_at_ms FROM notification WHERE recording_id=? "
76
+ "ORDER BY id", (rid,))], dtype=float)
77
+ finally:
78
+ con.close()
79
+ end = rec["ended_at_ms"] or (int(rx[-1]) if rx.size else rec["started_at_ms"])
80
+ return _coverage(rx, rec["started_at_ms"], end)
81
+
82
+
83
+ def list_recordings(db_path: Path) -> list[dict]:
84
+ """One dict per recording with the keys the HRV Logger listers share:
85
+ pk, name, start_local (ISO, local wall-clock), n_beats, duration_min,
86
+ avg_hr, coverage. Each skill adds its own extra keys."""
87
+ _require_snapshot(db_path)
88
+ con = _connect(db_path)
89
+ try:
90
+ recs = con.execute(
91
+ "SELECT id, name, started_at_ms, ended_at_ms, tz_offset_s "
92
+ "FROM recording ORDER BY started_at_ms").fetchall()
93
+ beats = {r[0]: r[1:] for r in con.execute(
94
+ "SELECT recording_id, COUNT(*), AVG(rr_ms) FROM beat "
95
+ "GROUP BY recording_id")}
96
+ rx: dict[int, list[int]] = {}
97
+ for rid, ms in con.execute(
98
+ "SELECT recording_id, received_at_ms FROM notification ORDER BY id"):
99
+ rx.setdefault(rid, []).append(ms)
100
+ finally:
101
+ con.close()
102
+
103
+ out = []
104
+ for rid, name, start_ms, end_ms, tz in recs:
105
+ n, mean_rr = beats.get(rid, (0, None))
106
+ r = np.array(rx.get(rid, []), dtype=float)
107
+ end = end_ms or (int(r[-1]) if r.size else start_ms)
108
+ cov = _coverage(r, start_ms, end)
109
+ out.append({
110
+ "pk": rid,
111
+ "name": name or "",
112
+ "start_local": pd.to_datetime(start_ms + tz * 1000, unit="ms").isoformat(),
113
+ "n_beats": int(n),
114
+ "duration_min": round((end - start_ms) / 60000.0, 1),
115
+ "avg_hr": round(60000.0 / mean_rr, 1) if mean_rr else None,
116
+ "coverage": None if cov is None else round(cov, 3),
117
+ })
118
+ return out
119
+
120
+
121
+ def load_rr(db_path: Path, rid: int) -> pd.DataFrame:
122
+ """Beats as DataFrame[t, rr_ms], t in Apple-epoch local seconds.
123
+
124
+ Ordered by beat id (true beat order). The H10 sends no beat timestamps, so
125
+ `t_end_ms_est` is anchored on phone receipt, and BLE delivery jitter
126
+ leaves ~2 % of beats up to ~0.9 s earlier than the beat before. `t` is
127
+ clamped non-decreasing so window and gap logic never sees time run
128
+ backwards; rr_ms itself is exact.
129
+ """
130
+ _require_snapshot(db_path)
131
+ con = _connect(db_path)
132
+ try:
133
+ tz = _recording(con, rid)["tz_offset_s"]
134
+ rr = pd.read_sql_query(
135
+ "SELECT t_end_ms_est AS t, rr_ms FROM beat WHERE recording_id=? "
136
+ "ORDER BY id", con, params=(rid,))
137
+ finally:
138
+ con.close()
139
+ rr["t"] = _local_apple(rr["t"].astype(float).cummax(), tz)
140
+ return rr
141
+
142
+
143
+ def load_events(db_path: Path, rid: int) -> pd.DataFrame:
144
+ """User markers as DataFrame[t, note]: StrapLog's `marker` events, which
145
+ are what HRV Logger's ZEVENT rows were. Lifecycle events (start, connect,
146
+ snapshot, ...) are not user notes and are left out.
147
+
148
+ From schema v3 a marker has a preset `label` (`a`, `awake`, ...), a free
149
+ `note`, or both; `note` here is the label, then ` · note` if both, which
150
+ matches v2 files where the label was the note."""
151
+ _require_snapshot(db_path)
152
+ con = _connect(db_path)
153
+ try:
154
+ tz = _recording(con, rid)["tz_offset_s"]
155
+ cols = columns(con, "event")
156
+ text = ("COALESCE(label || COALESCE(' · ' || note, ''), note, '')"
157
+ if "label" in cols else "COALESCE(note, '')")
158
+ ev = pd.read_sql_query(
159
+ f"SELECT at_ms AS t, {text} AS note FROM event "
160
+ "WHERE recording_id=? AND kind='marker' ORDER BY at_ms",
161
+ con, params=(rid,))
162
+ finally:
163
+ con.close()
164
+ ev["t"] = _local_apple(ev["t"].astype(float), tz)
165
+ return ev
166
+
167
+
168
+ def load_recording(db_path: Path, rid: int):
169
+ """(rr[t, rr_ms, t_rel], ev[t, note, t_rel], start_local, name), the shape
170
+ of h10-session's HRV Logger `load_recording`. t_rel is seconds from the
171
+ first beat; start_local is a naive local-wall-clock Timestamp."""
172
+ rr = load_rr(db_path, rid)
173
+ ev = load_events(db_path, rid)
174
+ con = _connect(db_path)
175
+ try:
176
+ name = _recording(con, rid)["name"] or ""
177
+ finally:
178
+ con.close()
179
+ t0 = rr["t"].iloc[0] if len(rr) else 0.0
180
+ rr["t_rel"] = rr["t"] - t0
181
+ ev["t_rel"] = ev["t"] - t0
182
+ start_local = pd.to_datetime(t0 + APPLE_EPOCH_OFFSET, unit="s")
183
+ return rr, ev, start_local, name