Add matched training signals, daily recovery and performance analysis
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user