straplog 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
straplog/__init__.py ADDED
@@ -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"
straplog/_sqlite.py ADDED
@@ -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})")}
straplog/catalog.py ADDED
@@ -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()}
straplog/cli.py ADDED
@@ -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()
straplog/db.py ADDED
@@ -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
straplog/export.py ADDED
@@ -0,0 +1,151 @@
1
+ """One StrapLog export as stored: epoch ms UTC, typed markers.
2
+
3
+ Snapshots hold one recording each (the app's `Snapshot`). Exports from
4
+ before that held every recording on the phone; the file is still named after
5
+ one of them (`file_name`), and that one is the default everywhere. Every
6
+ reader also takes a recording id.
7
+ Schema v2 files have no `activity`, `context` or `event.label`; they read
8
+ as activity `other` with labels left in `note`, as the app's v3 migration does.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import sqlite3
14
+ from collections.abc import Iterator
15
+ from contextlib import contextmanager
16
+ from datetime import datetime, timedelta, timezone
17
+ from pathlib import Path
18
+
19
+ from ._sqlite import columns, connect, require_snapshot
20
+
21
+ # Must match Coverage.defaultGapThresholdMs in StrapLogCore.
22
+ GAP_THRESHOLD_MS = 3000
23
+ # meta values the app writes as JSON (ExportMeta).
24
+ JSON_META = ("preset", "event_kinds")
25
+
26
+
27
+ def meta(path: Path) -> dict:
28
+ """The `meta` table, with ExportMeta's JSON values decoded."""
29
+ with _open(path) as con:
30
+ rows = dict(con.execute("SELECT key, value FROM meta"))
31
+ for k in JSON_META:
32
+ if k in rows:
33
+ rows[k] = json.loads(rows[k])
34
+ return rows
35
+
36
+
37
+ def recordings(path: Path, named_only: bool = False) -> list[dict]:
38
+ """One `index.json` entry (ExportIndexEntry) per recording, newest first,
39
+ computed from the file itself. `named_only` keeps just the recording the
40
+ file is named after, when there is one."""
41
+ path = Path(path)
42
+ with _open(path) as con:
43
+ recs = _recordings(con)
44
+ if named_only:
45
+ recs = [r for r in recs if r[0] == _named(recs, path)] or recs
46
+ out = []
47
+ for rid, name, act, start, end, tz in recs:
48
+ (n,) = con.execute("SELECT COUNT(*) FROM beat WHERE recording_id=?", (rid,)).fetchone()
49
+ entry = {
50
+ "file": path.name, "id": rid, "name": name, "activity": act,
51
+ "started_at_ms": start, "ended_at_ms": end, "tz_offset_s": tz,
52
+ "started_local": _local_iso(start, tz),
53
+ "duration_ms": None if end is None else end - start,
54
+ "beats": n, "coverage": round(_app_coverage(con, rid), 4),
55
+ "labels": [e["label"] for e in _events(con, rid, markers_only=True) if e["label"]],
56
+ }
57
+ out.append({k: v for k, v in entry.items() if v is not None})
58
+ return out
59
+
60
+
61
+ def file_name(name: str, started_at_ms: int, tz_offset_s: int) -> str:
62
+ """StrapLogCore's `Snapshot.fileName`: `YYMMDD-HHMM-<name>.sqlite` in the
63
+ recording's local time, the name's other characters replaced by `_`."""
64
+ local = datetime.fromtimestamp(started_at_ms / 1000 + tz_offset_s, timezone.utc)
65
+ safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in name)
66
+ stamp = local.strftime("%y%m%d-%H%M")
67
+ return f"{stamp}-{safe}.sqlite" if safe else f"{stamp}.sqlite"
68
+
69
+
70
+ def beats(path: Path, rid: int | None = None) -> list[dict]:
71
+ """Beats in true order: `t_ms` (estimated beat end, epoch ms UTC) and
72
+ `rr_ms` (exact). The H10 sends no beat times, so `t_ms` is anchored on
73
+ phone receipt and clamped non-decreasing against BLE delivery jitter."""
74
+ with _open(path) as con:
75
+ rid = _one_recording(con, rid, path)
76
+ rows = con.execute("SELECT t_end_ms_est, rr_ms FROM beat WHERE recording_id=? "
77
+ "ORDER BY id", (rid,)).fetchall()
78
+ out, last = [], None
79
+ for t, rr in rows:
80
+ last = t if last is None else max(last, t)
81
+ out.append({"t_ms": last, "rr_ms": rr})
82
+ return out
83
+
84
+
85
+ def events(path: Path, rid: int | None = None, markers_only: bool = False) -> list[dict]:
86
+ """Events in time order: `at_ms`, `kind`, `label` (v3 markers), `note`."""
87
+ with _open(path) as con:
88
+ return _events(con, _one_recording(con, rid, path), markers_only)
89
+
90
+
91
+ def _events(con: sqlite3.Connection, rid: int, markers_only: bool) -> list[dict]:
92
+ label = "label" if "label" in columns(con, "event") else "NULL"
93
+ where = " AND kind='marker'" if markers_only else ""
94
+ return [dict(zip(("at_ms", "kind", "label", "note"), r)) for r in con.execute(
95
+ f"SELECT at_ms, kind, {label}, note FROM event WHERE recording_id=?{where} "
96
+ "ORDER BY at_ms, id", (rid,))]
97
+
98
+
99
+ def _app_coverage(con: sqlite3.Connection, rid: int) -> float:
100
+ """StrapLogCore's `Coverage.fraction`: first-to-last notification span
101
+ minus silences over GAP_THRESHOLD_MS, over that span; 0 if empty. (The
102
+ HRV skills' `straplog.db.coverage` divides by the recording span instead.)"""
103
+ t = [r[0] for r in con.execute("SELECT received_at_ms FROM notification "
104
+ "WHERE recording_id=? ORDER BY received_at_ms", (rid,))]
105
+ span = t[-1] - t[0] if len(t) > 1 else 0
106
+ if span <= 0:
107
+ return 0.0
108
+ gaps = sum(b - a for a, b in zip(t, t[1:]) if b - a > GAP_THRESHOLD_MS)
109
+ return (span - gaps) / span
110
+
111
+
112
+ def _recordings(con: sqlite3.Connection) -> list[tuple]:
113
+ """(id, name, activity, started_at_ms, ended_at_ms, tz_offset_s), newest first."""
114
+ activity = "activity" if "activity" in columns(con, "recording") else "'other'"
115
+ return con.execute(
116
+ f"SELECT id, name, {activity}, started_at_ms, ended_at_ms, tz_offset_s "
117
+ "FROM recording ORDER BY started_at_ms DESC, id DESC").fetchall()
118
+
119
+
120
+ def _named(recs: list[tuple], path: Path) -> int | None:
121
+ """The id of the recording `path` is named after, if any."""
122
+ return next((r[0] for r in recs if file_name(r[1], r[3], r[5]) == Path(path).name), None)
123
+
124
+
125
+ def _one_recording(con: sqlite3.Connection, rid: int | None, path: Path) -> int:
126
+ recs = _recordings(con)
127
+ ids = sorted(r[0] for r in recs)
128
+ if rid is None:
129
+ rid = ids[0] if len(ids) == 1 else _named(recs, path)
130
+ if rid is None:
131
+ raise SystemExit(f"File holds recordings {ids}; pick one with --id.")
132
+ if rid not in ids:
133
+ raise SystemExit(f"Recording {rid} not found; the file holds {ids}.")
134
+ return rid
135
+
136
+
137
+ def _local_iso(ms: int, tz_offset_s: int) -> str:
138
+ """ISO 8601 with offset, as Swift's ISO8601DateFormatter writes it."""
139
+ dt = datetime.fromtimestamp(ms / 1000, timezone(timedelta(seconds=tz_offset_s)))
140
+ return dt.replace(microsecond=0).isoformat().replace("+00:00", "Z")
141
+
142
+
143
+ @contextmanager
144
+ def _open(path: Path) -> Iterator[sqlite3.Connection]:
145
+ """A checked, immutable, read-only connection."""
146
+ require_snapshot(path)
147
+ con = connect(path)
148
+ try:
149
+ yield con
150
+ finally:
151
+ con.close()
straplog/hrv.py ADDED
@@ -0,0 +1,147 @@
1
+ """HRV algorithms over RR intervals in ms. Format-agnostic: nothing here
2
+ knows about StrapLog files, so it works on any RR series.
3
+
4
+ One copy of what the H10 skills (h10-session, hrv-overnight, hrv-ramp-test,
5
+ hrv-resting) each used to carry: the artifact filter, time-domain metrics,
6
+ DFA alpha1 and the sliding-window feature table.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+
13
+ RR_MIN_MS = 300.0 # physiological gate: 200 bpm
14
+ RR_MAX_MS = 2000.0 # 30 bpm
15
+ PCT_THRESH_DEFAULT = 0.25 # workout; resting readings use a stricter 0.20
16
+
17
+ DFA_SCALE_MIN = 4 # short-term alpha1 box sizes, in beats
18
+ DFA_SCALE_MAX = 16
19
+
20
+ WINDOW_SEC_DEFAULT = 120.0
21
+ STRIDE_SEC_DEFAULT = 60.0
22
+ MIN_BEATS_PER_WIN = 60
23
+
24
+
25
+ def artifact_mask(rr_ms, pct_thresh: float = PCT_THRESH_DEFAULT) -> np.ndarray:
26
+ """Boolean keep-mask over the input beats: physiological gate, then an
27
+ iterative (up to 3 passes) 5-beat median filter dropping beats more than
28
+ `pct_thresh` from the local median. In the spirit of Altini's 'Workout'
29
+ correction: salvages high-HR segments without erasing real variability.
30
+
31
+ A mask rather than the filtered values, so callers can carry per-beat
32
+ timestamps through the filter and slice by time afterwards."""
33
+ x = np.asarray(rr_ms, dtype=float).copy()
34
+ x[(x < RR_MIN_MS) | (x > RR_MAX_MS)] = np.nan
35
+ for _ in range(3):
36
+ s = pd.Series(x)
37
+ med = s.rolling(5, center=True, min_periods=3).median()
38
+ bad = ((s - med).abs() / med) > pct_thresh
39
+ if not bad.any():
40
+ break
41
+ x[bad.to_numpy()] = np.nan
42
+ return ~np.isnan(x)
43
+
44
+
45
+ def clean_rr(rr_ms, pct_thresh: float = PCT_THRESH_DEFAULT) -> np.ndarray:
46
+ """The beats `artifact_mask` keeps."""
47
+ x = np.asarray(rr_ms, dtype=float)
48
+ return x[artifact_mask(x, pct_thresh)]
49
+
50
+
51
+ def time_domain(rr_ms) -> dict:
52
+ """Time-domain HRV over already-cleaned RR intervals (ms). SDNN, SDSD and
53
+ HR SD are sample statistics (ddof=1), the HRV-literature convention.
54
+ Fewer than 2 beats gives NaN for everything but n."""
55
+ rr = np.asarray(rr_ms, dtype=float)
56
+ n = int(rr.size)
57
+ if n < 2:
58
+ return {"n": n} | dict.fromkeys(
59
+ ("mean_rr_ms", "hr_mean", "hr_sd", "hr_min", "hr_max", "sdnn_ms",
60
+ "rmssd_ms", "ln_rmssd", "pnn50_pct", "sdsd_ms"), float("nan"))
61
+ d = np.diff(rr)
62
+ hr = 60000.0 / rr
63
+ rmssd = float(np.sqrt(np.mean(d ** 2)))
64
+ return {
65
+ "n": n,
66
+ "mean_rr_ms": float(rr.mean()),
67
+ "hr_mean": float(hr.mean()), # mean of beat HRs, not 60000/mean_rr_ms
68
+ "hr_sd": float(hr.std(ddof=1)),
69
+ "hr_min": float(hr.min()),
70
+ "hr_max": float(hr.max()),
71
+ "sdnn_ms": float(rr.std(ddof=1)),
72
+ "rmssd_ms": rmssd,
73
+ "ln_rmssd": float(np.log(rmssd)) if rmssd > 0 else float("nan"),
74
+ "pnn50_pct": float(100.0 * np.mean(np.abs(d) > 50)),
75
+ "sdsd_ms": float(d.std(ddof=1)) if d.size > 1 else float("nan"),
76
+ }
77
+
78
+
79
+ def dfa_alpha1(rr_ms, scale_min: int = DFA_SCALE_MIN,
80
+ scale_max: int = DFA_SCALE_MAX) -> float:
81
+ """Short-term detrended fluctuation exponent (alpha1) of a cleaned RR
82
+ series; NaN when there are too few beats to fit the scales."""
83
+ rr = np.asarray(rr_ms, dtype=float)
84
+ n_total = len(rr)
85
+ if n_total < scale_max * 4:
86
+ return np.nan
87
+
88
+ y = np.cumsum(rr - rr.mean())
89
+ scales = np.arange(scale_min, scale_max + 1)
90
+ F = np.full(len(scales), np.nan)
91
+
92
+ for i, n in enumerate(scales):
93
+ nb = n_total // n
94
+ if nb < 4:
95
+ continue
96
+ segs = y[: nb * n].reshape(nb, n)
97
+ x = np.arange(n)
98
+ x_mean = x.mean()
99
+ x_var = ((x - x_mean) ** 2).sum()
100
+ seg_mean = segs.mean(axis=1, keepdims=True)
101
+ cov = ((segs - seg_mean) * (x - x_mean)).sum(axis=1) / x_var
102
+ intercept = seg_mean.squeeze() - cov * x_mean
103
+ trend = np.outer(cov, x) + intercept[:, None]
104
+ F[i] = np.sqrt(((segs - trend) ** 2).mean())
105
+
106
+ mask = np.isfinite(F) & (F > 0)
107
+ if mask.sum() < 4:
108
+ return np.nan
109
+ slope, _ = np.polyfit(np.log(scales[mask]), np.log(F[mask]), 1)
110
+ return float(slope)
111
+
112
+
113
+ def rolling_features(rr_df: pd.DataFrame,
114
+ window_sec: float = WINDOW_SEC_DEFAULT,
115
+ stride_sec: float = STRIDE_SEC_DEFAULT,
116
+ pct_thresh: float = PCT_THRESH_DEFAULT,
117
+ min_beats: int = MIN_BEATS_PER_WIN) -> pd.DataFrame:
118
+ """Sliding windows over rr_df[t_rel, rr_ms], anchored at t_rel=0.
119
+ Artifact correction runs per window, so a burst of bad beats doesn't drag
120
+ a neighbouring window. Columns: t_center, t_min, hr_bpm, alpha1, rmssd,
121
+ n_beats, n_raw. Windows with fewer than `min_beats` beats are skipped."""
122
+ t_end = rr_df["t_rel"].max()
123
+ centers = np.arange(window_sec / 2, t_end - window_sec / 2 + 1, stride_sec)
124
+ t_arr = rr_df["t_rel"].to_numpy()
125
+ rr_arr = rr_df["rr_ms"].to_numpy()
126
+
127
+ rows = []
128
+ for c in centers:
129
+ lo, hi = c - window_sec / 2, c + window_sec / 2
130
+ raw = rr_arr[(t_arr >= lo) & (t_arr < hi)]
131
+ if raw.size < min_beats:
132
+ continue
133
+ clean = clean_rr(raw, pct_thresh)
134
+ if clean.size < min_beats:
135
+ continue
136
+ d = np.diff(clean)
137
+ rows.append({
138
+ "t_center": c,
139
+ "t_min": c / 60.0,
140
+ "hr_bpm": 60000.0 / clean.mean(),
141
+ "alpha1": dfa_alpha1(clean),
142
+ "rmssd": float(np.sqrt((d ** 2).mean())) if d.size else np.nan,
143
+ "n_beats": clean.size,
144
+ "n_raw": raw.size,
145
+ })
146
+ return pd.DataFrame(rows, columns=["t_center", "t_min", "hr_bpm", "alpha1",
147
+ "rmssd", "n_beats", "n_raw"])
straplog/plot.py ADDED
@@ -0,0 +1,129 @@
1
+ """
2
+ House chart style for the H10 skills (plotnine): system-sans typography, a
3
+ recessive grid, hairline axes, and a validated categorical palette.
4
+ Needs the `plot` extra: `pip install straplog[plot]`.
5
+
6
+ from straplog import plot as chart_style
7
+ from plotnine import ggplot, aes, geom_line
8
+ p = (ggplot(df, aes("x", "y"))
9
+ + geom_line(color=chart_style.SERIES["blue"])
10
+ + chart_style.theme_house())
11
+ p.save(path, dpi=chart_style.DPI, width=..., height=..., verbose=False)
12
+
13
+ plotnine has no global rcParams: every ``ggplot`` needs
14
+ ``+ chart_style.theme_house()`` and every ``.save()`` an explicit
15
+ ``dpi=chart_style.DPI``.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import numpy as np
21
+ from plotnine import (
22
+ aes, annotate, element_blank, element_line, element_rect, element_text,
23
+ theme, theme_minimal,
24
+ )
25
+
26
+ # ---------- palette --------------------------------------------------------
27
+ # Categorical slots in fixed order. Assign by slot, never cycle: the ordering
28
+ # is what keeps adjacent pairs distinguishable under colour-vision deficiency.
29
+
30
+ SERIES = {
31
+ "blue": "#2a78d6",
32
+ "green": "#008300",
33
+ "magenta": "#e87ba4",
34
+ "yellow": "#eda100",
35
+ "aqua": "#1baf7a",
36
+ "orange": "#eb6834",
37
+ "violet": "#4a3aa7",
38
+ "red": "#e34948",
39
+ }
40
+ SERIES_ORDER = list(SERIES.values())
41
+
42
+ # Chrome and text. Series colours identify marks; text stays in ink.
43
+ INK = {
44
+ "surface": "#fcfcfb",
45
+ "primary": "#0b0b0b", # titles
46
+ "secondary": "#52514e", # axis titles, annotations
47
+ "muted": "#898781", # tick labels, de-emphasised marks
48
+ "grid": "#e1e0d9",
49
+ "baseline": "#c3c2b7", # axis spines / reference lines
50
+ }
51
+
52
+ # Reserved for state, never for a series.
53
+ STATUS = {
54
+ "good": "#0ca30c",
55
+ "warning": "#fab219",
56
+ "serious": "#ec835a",
57
+ "critical": "#d03b3b",
58
+ }
59
+
60
+ # HR training-zone bands (Z1..Z5), painted as backgrounds behind the trace.
61
+ # Cool->warm rather than a single-hue ramp: "hot = hard" is the training
62
+ # convention and carries real meaning here. Chroma is held low so the bands
63
+ # stay behind the data. Use at full opacity - alpha is baked into these steps.
64
+ ZONE_BANDS = ["#eef4fb", "#e9f3ec", "#fbf4e2", "#fbeade", "#fae4e4"]
65
+
66
+ # Sequential blue ramp, light -> dark, for continuous magnitude.
67
+ SEQUENTIAL_BLUE = [
68
+ "#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5",
69
+ "#2a78d6", "#256abf", "#184f95", "#0d366b",
70
+ ]
71
+
72
+ FONT_FAMILY = "Helvetica Neue" # falls back per-system font resolution
73
+ DPI = 150 # pass explicitly to every p.save(...)
74
+
75
+
76
+ def theme_house(base_size: int = 10):
77
+ """The plotnine equivalent of the old apply(): chain onto every ggplot."""
78
+ return theme_minimal(base_size=base_size) + theme(
79
+ text=element_text(family=FONT_FAMILY, color=INK["primary"]),
80
+ plot_title=element_text(size=base_size * 1.3, weight="bold",
81
+ color=INK["primary"]),
82
+ axis_title=element_text(size=base_size, color=INK["secondary"]),
83
+ axis_text=element_text(size=base_size * 0.9, color=INK["muted"]),
84
+ plot_background=element_rect(fill=INK["surface"], color=INK["surface"]),
85
+ panel_background=element_rect(fill=INK["surface"], color=INK["surface"]),
86
+ panel_grid_major=element_line(color=INK["grid"], linewidth=0.6),
87
+ panel_grid_minor=element_blank(),
88
+ axis_ticks=element_blank(),
89
+ legend_background=element_blank(),
90
+ legend_key=element_blank(),
91
+ legend_title=element_text(size=base_size * 0.9, color=INK["secondary"]),
92
+ legend_text=element_text(size=base_size * 0.9, color=INK["secondary"]),
93
+ strip_background=element_blank(),
94
+ )
95
+
96
+
97
+ # ---------- shared layer helpers -------------------------------------------
98
+
99
+ def zone_band_layers(zones, hr_max: float, hr_rest: float):
100
+ """zones: list of (name, lo_frac, hi_frac) Karvonen tuples, e.g. the
101
+ ZONES constant each analysis script already defines. Returns a list of
102
+ ``annotate("rect", ...)`` layers, cool->warm, meant to be added first so
103
+ they render behind the data (mirrors the old ax.axhspan loop)."""
104
+ reserve = max(1.0, hr_max - hr_rest)
105
+ layers = []
106
+ for (_name, lo, hi), color in zip(zones, ZONE_BANDS):
107
+ y_lo = hr_rest + lo * reserve
108
+ y_hi = hr_rest + hi * reserve
109
+ layers.append(annotate("rect", xmin=-np.inf, xmax=np.inf,
110
+ ymin=y_lo, ymax=y_hi, fill=color))
111
+ return layers
112
+
113
+
114
+ def event_marker_layers(ev_df, x_col: str = "t_min", label_col: str = None,
115
+ y_top: float = None, color: str = None):
116
+ """Vertical dotted lines for each row in ev_df (an events dataframe with
117
+ at least an x_col), mirroring the old per-row ax.axvline + optional rotated
118
+ ax.text. If label_col is given, adds rotated top-anchored text -- y_top must
119
+ be supplied (plotnine has no "top of current axis" primitive; the caller
120
+ computes the y-range from the data first, e.g. ``feat['hr_bpm'].max()``)."""
121
+ from plotnine import geom_text, geom_vline
122
+ color = color or INK["baseline"]
123
+ layers = [geom_vline(data=ev_df, mapping=aes(xintercept=x_col),
124
+ color=color, linetype="dotted", size=0.9)]
125
+ if label_col and y_top is not None:
126
+ layers.append(geom_text(data=ev_df, mapping=aes(x=x_col, label=label_col),
127
+ y=y_top, angle=90, ha="left", va="top",
128
+ size=7, color=color))
129
+ return layers
@@ -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,13 @@
1
+ straplog/__init__.py,sha256=LYWfbA7CJErKb4gSESt_Kc0kL11TgVgYqHoYQuG6Feg,515
2
+ straplog/_sqlite.py,sha256=KmfNObKVaPv1JXxdmyFiSaM-CxWWQSFkdD2G1VAyCZc,1041
3
+ straplog/catalog.py,sha256=FUWt0AHU5-PbMTbvoJ7_5hcSwkaa4YcWIl0OoQDXv38,2737
4
+ straplog/cli.py,sha256=lyXJoS6IQdfem96nvhVe4QZGHnhEAUoCT5QuaF8imAM,3471
5
+ straplog/db.py,sha256=_7S_pM034c9wIhjj0Vu62Uynf4D4Vl2pfOP1pO1Z0Yo,7191
6
+ straplog/export.py,sha256=lftH1rKqCjprOuCc4G2b0za8NiBtvmj8JCcujK0wVLs,6538
7
+ straplog/hrv.py,sha256=pfSgywsnd4p3sKBQ7TkOUXzPRciU43jNfcAcsSfYb40,5751
8
+ straplog/plot.py,sha256=admt_6BccJkV-EnVPY0dH_lTaHGK1BvpY90cJBbExBQ,5476
9
+ straplog-0.2.0.dist-info/METADATA,sha256=mnbaeHsfPyS0CpA2Sc4G5oQtqxwmST_oMMvwKYlmsTA,2818
10
+ straplog-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
11
+ straplog-0.2.0.dist-info/entry_points.txt,sha256=3SZ854FhYa2gmW2j7UPBMs8Znt9vUSaKWVTm7Ri8qNQ,47
12
+ straplog-0.2.0.dist-info/licenses/LICENSE,sha256=XK9ghHHPPHRHgA-C8F9Y4IQx7rloGHGuDc1ZK4w0eLU,1097
13
+ straplog-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ straplog = straplog.cli:main
@@ -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.