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 +10 -0
- straplog/_sqlite.py +26 -0
- straplog/catalog.py +67 -0
- straplog/cli.py +101 -0
- straplog/db.py +183 -0
- straplog/export.py +151 -0
- straplog/hrv.py +147 -0
- straplog/plot.py +129 -0
- straplog-0.2.0.dist-info/METADATA +74 -0
- straplog-0.2.0.dist-info/RECORD +13 -0
- straplog-0.2.0.dist-info/WHEEL +4 -0
- straplog-0.2.0.dist-info/entry_points.txt +2 -0
- straplog-0.2.0.dist-info/licenses/LICENSE +21 -0
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,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.
|