"""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