Files
Stride/app/recording_windows.py

95 lines
4.3 KiB
Python

"""Non-overlapping continuous 30-second windows, persisted during import/backfill."""
import math
from itertools import pairwise
from .analytics import enrich, numeric, running_cost
SENSORS = ("heart_rate", "cadence", "power", "stride_length", "ground_contact",
"vertical_oscillation")
def pack(intervals, offset, moving_offset):
duration = sum(i["dt"] for i in intervals)
distance = sum(i["ds"] for i in intervals)
speed = distance / duration
result = {"duration": duration, "distance": distance, "elapsed": offset / 60,
"moving_offset": moving_offset / 60}
for field in SENSORS:
usable = [i for i in intervals if numeric(i.get(field)) and i[field] > 0]
time = sum(i["dt"] for i in usable)
result[field] = sum(i[field] * i["dt"] for i in usable) / time if time else None
result[field + "_coverage"] = time / duration * 100
result["sensor_coverage"] = result["heart_rate_coverage"]
hr_seconds = {}
for item in intervals:
if numeric(item.get("heart_rate")) and item["heart_rate"] > 0:
key = str(round(item["heart_rate"]))
hr_seconds[key] = hr_seconds.get(key, 0) + item["dt"]
result["hr_seconds"] = hr_seconds
result["pace_variability"] = math.sqrt(
sum((i["ds"] / i["dt"] - speed) ** 2 * i["dt"] for i in intervals) / duration
) / speed * 100
grades = [i for i in intervals if numeric(i.get("grade"))]
time = sum(i["dt"] for i in grades)
coverage = time / duration * 100
result["terrain_coverage"] = coverage
joint = [i for i in grades if numeric(i.get("heart_rate")) and i["heart_rate"] > 0]
joint_time = sum(i["dt"] for i in joint)
result["joint_coverage"] = joint_time / duration * 100
result["joint_hr"] = sum(i["heart_rate"] * i["dt"] for i in joint) / joint_time if joint_time else None
result["joint_speed"] = sum(i["ds"] for i in joint) / joint_time if joint_time else None
result["joint_grade"] = sum(i["grade"] * i["dt"] for i in joint) / joint_time if joint_time else None
mean = sum(i["grade"] * i["dt"] for i in grades) / time if time else None
result["grade"] = mean if coverage >= 80 else None
result["grade_variability"] = math.sqrt(
sum((i["grade"] - mean) ** 2 * i["dt"] for i in grades) / time
) if coverage >= 80 else None
equivalent = sum(i["ds"] * running_cost(i["grade"] / 100) / running_cost(0)
for i in grades)
result["gap"] = 1000 * time / equivalent if coverage >= 80 and equivalent > 0 else None
result["hr_artifact_percent"] = sum(i["dt"] for i in intervals if i["hr_artifact"]) / duration * 100
return result
def analyze(samples):
rows = enrich(samples)
origin = next((r["time"] for r in rows if numeric(r.get("time"))), 0)
result, pending, elapsed, moving = [], [], 0.0, 0.0
offset = moving_start = 0.0
def flush():
if pending:
result.append(pack(pending, offset, moving_start))
for before, after in pairwise(rows):
valid = all(numeric(r.get(k)) for r in (before, after) for k in ("time", "distance"))
dt = after["time"] - before["time"] if valid else 0
ds = after["distance"] - before["distance"] if valid else 0
if not (0 < dt <= 10 and 0.5 <= ds / dt <= 15):
flush()
pending, elapsed = [], 0.0
continue
hr, previous_hr = after.get("heart_rate"), before.get("heart_rate")
artifact = numeric(hr) and (not 30 <= hr <= 240 or
numeric(previous_hr) and abs(hr - previous_hr) / dt > 20)
interval = {k: after.get(k) for k in SENSORS}
interval.update(grade=after.get("grade"), hr_artifact=bool(artifact))
if artifact:
interval["heart_rate"] = None
remaining = dt
while remaining > 1e-9:
if not pending:
offset = before["time"] - origin + dt - remaining
moving_start = moving
portion = min(remaining, 30 - elapsed)
pending.append({**interval, "dt": portion, "ds": ds * portion / dt})
remaining -= portion
elapsed += portion
moving += portion
if elapsed >= 30 - 1e-9:
flush()
pending, elapsed = [], 0.0
flush()
return result