282 lines
10 KiB
Python
282 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Validate/import completed Android cardio calibration profiles V1."""
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sqlite3
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
from trainlog_sqlite import connect_database
|
|
|
|
FORMAT = "trainlog-cardio-calibrations"
|
|
VERSION = 1
|
|
MAX_BYTES = 4 * 1024 * 1024
|
|
MAX_CALIBRATIONS = 128
|
|
UUID4 = r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
|
|
CALIBRATION_ID = re.compile(rf"^cal_{UUID4}$")
|
|
SESSION_ID = re.compile(rf"^se_{UUID4}$")
|
|
ENTRY_ID = re.compile(rf"^sxe_{UUID4}$")
|
|
CAPTURE_ID = re.compile(rf"^hrc_{UUID4}$")
|
|
CALIBRATION_EXERCISE_ID = "ex_ca1b4a7e-1c2d-4f00-8a11-000000000001"
|
|
RECOVERY_OFFSETS = (60, 120, 180)
|
|
|
|
|
|
class CardioCalibrationError(ValueError):
|
|
pass
|
|
|
|
|
|
def fail(message: str) -> None:
|
|
raise CardioCalibrationError(message)
|
|
|
|
|
|
def unique_object(pairs):
|
|
result = {}
|
|
for key, value in pairs:
|
|
if key in result:
|
|
fail("duplicate JSON field: " + key)
|
|
result[key] = value
|
|
return result
|
|
|
|
|
|
def timestamp(value: object) -> tuple[str, datetime]:
|
|
if not isinstance(value, str) or re.search(r"(?:Z|[+-][0-9]{2}:[0-9]{2})$", value) is None:
|
|
fail("invalid cardio calibration timestamp")
|
|
try:
|
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
except ValueError as error:
|
|
raise CardioCalibrationError("invalid cardio calibration timestamp") from error
|
|
if parsed.utcoffset() is None:
|
|
fail("cardio calibration timestamp lacks offset")
|
|
return value, parsed
|
|
|
|
|
|
def validate(root: object) -> dict:
|
|
if not isinstance(root, dict) or set(root) != {
|
|
"format", "version", "generated_at", "calibrations"
|
|
}:
|
|
fail("invalid cardio calibration envelope")
|
|
if root["format"] != FORMAT or type(root["version"]) is not int or root["version"] != VERSION:
|
|
fail("unsupported cardio calibration format")
|
|
timestamp(root["generated_at"])
|
|
calibrations = root["calibrations"]
|
|
if not isinstance(calibrations, list) or len(calibrations) > MAX_CALIBRATIONS:
|
|
fail("cardio calibration bound exceeded")
|
|
seen = set()
|
|
for item in calibrations:
|
|
keys = {
|
|
"calibration_id", "protocol_version", "session_id", "entry_id",
|
|
"started_at", "effort_end_at", "ended_at", "heart_rate_capture_id",
|
|
"observed_peak_bpm", "recovery",
|
|
}
|
|
if not isinstance(item, dict) or set(item) != keys:
|
|
fail("invalid cardio calibration object")
|
|
calibration_id = item["calibration_id"]
|
|
if (
|
|
not isinstance(calibration_id, str)
|
|
or not CALIBRATION_ID.fullmatch(calibration_id)
|
|
or calibration_id in seen
|
|
):
|
|
fail("invalid or duplicate cardio calibration identity")
|
|
seen.add(calibration_id)
|
|
if type(item["protocol_version"]) is not int or item["protocol_version"] != 1:
|
|
fail("unsupported cardio calibration protocol")
|
|
if not isinstance(item["session_id"], str) or not SESSION_ID.fullmatch(item["session_id"]):
|
|
fail("invalid cardio calibration session identity")
|
|
if not isinstance(item["entry_id"], str) or not ENTRY_ID.fullmatch(item["entry_id"]):
|
|
fail("invalid cardio calibration entry identity")
|
|
if (
|
|
not isinstance(item["heart_rate_capture_id"], str)
|
|
or not CAPTURE_ID.fullmatch(item["heart_rate_capture_id"])
|
|
):
|
|
fail("invalid cardio calibration capture identity")
|
|
_, started = timestamp(item["started_at"])
|
|
_, effort_end = timestamp(item["effort_end_at"])
|
|
_, ended = timestamp(item["ended_at"])
|
|
if not started <= effort_end <= ended:
|
|
fail("invalid cardio calibration interval")
|
|
if type(item["observed_peak_bpm"]) is not int or not 0 <= item["observed_peak_bpm"] <= 65535:
|
|
fail("invalid observed peak BPM")
|
|
recovery = item["recovery"]
|
|
if not isinstance(recovery, list) or len(recovery) > 3:
|
|
fail("invalid cardio calibration recovery")
|
|
offsets = []
|
|
for point in recovery:
|
|
if not isinstance(point, dict) or set(point) != {
|
|
"target_offset_seconds", "observed_at", "bpm"
|
|
}:
|
|
fail("invalid recovery point")
|
|
offset = point["target_offset_seconds"]
|
|
if type(offset) is not int or offset not in RECOVERY_OFFSETS or offset in offsets:
|
|
fail("invalid recovery offset")
|
|
offsets.append(offset)
|
|
_, observed = timestamp(point["observed_at"])
|
|
if observed < effort_end + timedelta(seconds=offset) or observed > ended:
|
|
fail("recovery point outside factual interval")
|
|
if type(point["bpm"]) is not int or not 0 <= point["bpm"] <= 65535:
|
|
fail("invalid recovery BPM")
|
|
if offsets != sorted(offsets):
|
|
fail("recovery points must be ordered")
|
|
return root
|
|
|
|
|
|
def load(path: Path) -> dict:
|
|
if path.stat().st_size > MAX_BYTES:
|
|
fail("cardio calibration artifact exceeds 4 MiB")
|
|
try:
|
|
return validate(
|
|
json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object)
|
|
)
|
|
except OSError as error:
|
|
raise CardioCalibrationError("cannot read cardio calibration artifact") from error
|
|
|
|
|
|
def _verify_source_facts(db: sqlite3.Connection, item: dict) -> None:
|
|
session = db.execute(
|
|
"SELECT id,started_at,ended_at,session_kind FROM sessions WHERE session_id=?",
|
|
(item["session_id"],),
|
|
).fetchone()
|
|
if session is None or session[3] != "cardio":
|
|
fail("calibration session is missing or not cardio")
|
|
if session[1] != item["started_at"] or session[2] != item["ended_at"]:
|
|
fail("calibration session bounds differ from canonical history")
|
|
|
|
occurrence = db.execute(
|
|
"SELECT e.exercise_id FROM session_exercises se "
|
|
"JOIN exercises e ON e.id=se.exercise_row_id "
|
|
"WHERE se.session_row_id=? AND se.entry_id=?",
|
|
(session[0], item["entry_id"]),
|
|
).fetchone()
|
|
if occurrence is None or occurrence[0] != CALIBRATION_EXERCISE_ID:
|
|
fail("calibration occurrence does not match reserved exercise")
|
|
|
|
capture = db.execute(
|
|
"SELECT context_kind,context_id,started_at,ended_at FROM heart_rate_captures "
|
|
"WHERE capture_id=?",
|
|
(item["heart_rate_capture_id"],),
|
|
).fetchone()
|
|
if (
|
|
capture is None
|
|
or capture[0] != "cardio"
|
|
or capture[1] != item["session_id"]
|
|
or capture[2] != item["started_at"]
|
|
or capture[3] != item["ended_at"]
|
|
):
|
|
fail("calibration heart-rate capture does not match session")
|
|
|
|
_, effort_end = timestamp(item["effort_end_at"])
|
|
samples = db.execute(
|
|
"SELECT observed_at,bpm FROM heart_rate_samples WHERE capture_id=? ORDER BY sequence",
|
|
(item["heart_rate_capture_id"],),
|
|
).fetchall()
|
|
before_effort_end = [
|
|
bpm
|
|
for observed_at, bpm in samples
|
|
if timestamp(observed_at)[1] <= effort_end
|
|
]
|
|
if not before_effort_end or max(before_effort_end) != item["observed_peak_bpm"]:
|
|
fail("observed peak BPM does not match raw capture")
|
|
|
|
sample_pairs = {(observed_at, bpm) for observed_at, bpm in samples}
|
|
for point in item["recovery"]:
|
|
if (point["observed_at"], point["bpm"]) not in sample_pairs:
|
|
fail("recovery point is not a raw heart-rate sample")
|
|
|
|
|
|
def _same(db: sqlite3.Connection, item: dict) -> bool:
|
|
row = db.execute(
|
|
"SELECT protocol_version,session_id,entry_id,started_at,effort_end_at,ended_at,"
|
|
"heart_rate_capture_id,observed_peak_bpm FROM cardio_calibrations "
|
|
"WHERE calibration_id=?",
|
|
(item["calibration_id"],),
|
|
).fetchone()
|
|
if row is None:
|
|
return False
|
|
expected = (
|
|
item["protocol_version"],
|
|
item["session_id"],
|
|
item["entry_id"],
|
|
item["started_at"],
|
|
item["effort_end_at"],
|
|
item["ended_at"],
|
|
item["heart_rate_capture_id"],
|
|
item["observed_peak_bpm"],
|
|
)
|
|
if tuple(row) != expected:
|
|
fail("calibration identity reused with different metadata")
|
|
stored = [
|
|
tuple(row)
|
|
for row in db.execute(
|
|
"SELECT target_offset_seconds,observed_at,bpm FROM cardio_calibration_recovery "
|
|
"WHERE calibration_id=? ORDER BY target_offset_seconds",
|
|
(item["calibration_id"],),
|
|
)
|
|
]
|
|
incoming = [
|
|
(p["target_offset_seconds"], p["observed_at"], p["bpm"])
|
|
for p in item["recovery"]
|
|
]
|
|
if stored != incoming:
|
|
fail("calibration identity reused with different recovery")
|
|
return True
|
|
|
|
|
|
def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
|
|
applied = unchanged = 0
|
|
for item in root["calibrations"]:
|
|
_verify_source_facts(db, item)
|
|
if _same(db, item):
|
|
unchanged += 1
|
|
continue
|
|
db.execute(
|
|
"INSERT INTO cardio_calibrations("
|
|
"calibration_id,protocol_version,session_id,entry_id,started_at,effort_end_at,"
|
|
"ended_at,heart_rate_capture_id,observed_peak_bpm,imported_at"
|
|
") VALUES(?,?,?,?,?,?,?,?,?,?)",
|
|
(
|
|
item["calibration_id"],
|
|
item["protocol_version"],
|
|
item["session_id"],
|
|
item["entry_id"],
|
|
item["started_at"],
|
|
item["effort_end_at"],
|
|
item["ended_at"],
|
|
item["heart_rate_capture_id"],
|
|
item["observed_peak_bpm"],
|
|
root["generated_at"],
|
|
),
|
|
)
|
|
db.executemany(
|
|
"INSERT INTO cardio_calibration_recovery("
|
|
"calibration_id,target_offset_seconds,observed_at,bpm) VALUES(?,?,?,?)",
|
|
[
|
|
(
|
|
item["calibration_id"],
|
|
point["target_offset_seconds"],
|
|
point["observed_at"],
|
|
point["bpm"],
|
|
)
|
|
for point in item["recovery"]
|
|
],
|
|
)
|
|
applied += 1
|
|
return applied, unchanged
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("input", type=Path)
|
|
parser.add_argument("--database", required=True, type=Path)
|
|
args = parser.parse_args()
|
|
root = load(args.input)
|
|
with connect_database(args.database) as db:
|
|
if db.execute("PRAGMA user_version").fetchone()[0] not in (33, 34, 35, 36):
|
|
fail("desktop schema v33-v36 required")
|
|
db.execute("BEGIN IMMEDIATE")
|
|
apply(db, root)
|
|
db.commit()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|