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.
- straplog-0.2.0/.github/workflows/publish.yml +36 -0
- straplog-0.2.0/.gitignore +9 -0
- straplog-0.2.0/CLAUDE.md +20 -0
- straplog-0.2.0/LICENSE +21 -0
- straplog-0.2.0/PKG-INFO +74 -0
- straplog-0.2.0/README.md +55 -0
- straplog-0.2.0/pyproject.toml +32 -0
- straplog-0.2.0/src/straplog/__init__.py +10 -0
- straplog-0.2.0/src/straplog/_sqlite.py +26 -0
- straplog-0.2.0/src/straplog/catalog.py +67 -0
- straplog-0.2.0/src/straplog/cli.py +101 -0
- straplog-0.2.0/src/straplog/db.py +183 -0
- straplog-0.2.0/src/straplog/export.py +151 -0
- straplog-0.2.0/src/straplog/hrv.py +147 -0
- straplog-0.2.0/src/straplog/plot.py +129 -0
- straplog-0.2.0/tests/conftest.py +93 -0
- straplog-0.2.0/tests/test_catalog_cli.py +85 -0
- straplog-0.2.0/tests/test_db.py +99 -0
- straplog-0.2.0/tests/test_export.py +91 -0
- straplog-0.2.0/tests/test_hrv.py +77 -0
- straplog-0.2.0/uv.lock +1370 -0
|
@@ -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
|
straplog-0.2.0/CLAUDE.md
ADDED
|
@@ -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.
|
straplog-0.2.0/PKG-INFO
ADDED
|
@@ -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
|
straplog-0.2.0/README.md
ADDED
|
@@ -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
|