Add matched training signals, daily recovery and performance analysis
This commit is contained in:
@@ -72,6 +72,9 @@ def create_app(config=None):
|
||||
from .analysis_library import library
|
||||
|
||||
app.register_blueprint(library)
|
||||
from .training import training
|
||||
|
||||
app.register_blueprint(training)
|
||||
from .planner import planner
|
||||
|
||||
app.register_blueprint(planner)
|
||||
@@ -84,6 +87,9 @@ def create_app(config=None):
|
||||
from .display import register_cli as register_display_cli
|
||||
|
||||
register_display_cli(app)
|
||||
from .training_signals import register_cli as register_training_cli
|
||||
|
||||
register_training_cli(app)
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
|
||||
+77
-11
@@ -7,7 +7,7 @@ from types import SimpleNamespace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from .extensions import db
|
||||
from .models import Activity
|
||||
from .models import Activity, DailyHealth, User
|
||||
from .stats import METRICS, format_value, metric_value, period_bounds
|
||||
from .stats import aggregate as legacy_aggregate
|
||||
from .stats_analysis import describe, evaluate, expression_tree, statistic, valid
|
||||
@@ -16,6 +16,12 @@ from .stats_dsl import QueryError
|
||||
|
||||
def catalog_for(q):
|
||||
catalog = dict(METRICS)
|
||||
if q["source"] in {"days", "health"}:
|
||||
for days in (7, 28):
|
||||
for field in ("distance", "count"):
|
||||
key = f"rolling_{days}d_{field}"
|
||||
label, unit, _ = catalog[key]
|
||||
catalog[key] = (label, unit, f"Complete recorded running {field} over this local day and the preceding {days - 1} local days, before filters. Rest days are included.")
|
||||
for spec in q["derived"]:
|
||||
catalog[spec["name"]] = (
|
||||
spec["name"].replace("_", " ").capitalize(),
|
||||
@@ -37,6 +43,9 @@ def summarize(rows, key, q):
|
||||
return len(rows), len(rows)
|
||||
values = [value(row, field) for row in rows]
|
||||
coverage = sum(valid(number) for number in values)
|
||||
if function == "latest":
|
||||
last = max(rows, key=lambda r: (r.instant, r.id)) if rows else None
|
||||
return (value(last, field) if last else None), coverage
|
||||
if function == "weighted_mean":
|
||||
pairs = [
|
||||
(value(row, field), row.distance_m if field == "pace" else row.duration_s)
|
||||
@@ -71,7 +80,7 @@ def formatted(number, key, q):
|
||||
return "—"
|
||||
spec = q["aggregates"].get(key)
|
||||
raw = spec["field"] if spec and spec["function"] not in {"count", "stddev", "sum"} else key
|
||||
if raw in {"pace", "gap"}:
|
||||
if raw in {"pace", "gap"} or raw == "effort_time" or raw.startswith("best_"):
|
||||
return format_value(number, "pace")
|
||||
if key == "count" or (spec and spec["function"] == "count"):
|
||||
return f"{number:,.0f}"
|
||||
@@ -158,6 +167,12 @@ def prepare(raw, zone, q, catalog):
|
||||
calories=(row.metrics or {}).get("total_calories"),
|
||||
training_effect=(row.metrics or {}).get("total_training_effect"),
|
||||
)
|
||||
row.values.update({key: (row.session_context or {}).get(key)
|
||||
for key in ("perceived_effort", "fatigue")})
|
||||
for km in (1, 2, 5, 10):
|
||||
efforts = [e.get("seconds") for e in row.best_efforts or []
|
||||
if e.get("distance") == km * 1000 and valid(e.get("seconds"))]
|
||||
row.values[f"best_{km}km"] = min(efforts) if efforts else None
|
||||
for field in (
|
||||
"pace",
|
||||
"heart_rate",
|
||||
@@ -210,6 +225,8 @@ def prepare(raw, zone, q, catalog):
|
||||
|
||||
|
||||
def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, raw_group=False):
|
||||
from .training_sources import evidence as evidence_coverage
|
||||
|
||||
zone, catalog = ZoneInfo(timezone), catalog_for(q)
|
||||
start, end, prior_start = period_bounds(q, timezone)
|
||||
prior_end = start
|
||||
@@ -235,6 +252,9 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
|
||||
Activity.avg_hr,
|
||||
Activity.analysis_summary,
|
||||
Activity.metrics,
|
||||
Activity.best_efforts,
|
||||
Activity.session_context,
|
||||
*([Activity.route_geometry] if q["route"] else []),
|
||||
)
|
||||
.where(
|
||||
Activity.owner_id == owner_id,
|
||||
@@ -243,7 +263,7 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
|
||||
)
|
||||
.order_by(Activity.started_at, Activity.id)
|
||||
)
|
||||
if q["source"] != "runs" and not q["all_time"]:
|
||||
if q["source"] not in {"runs", "days", "health"} and not q["all_time"]:
|
||||
lower = min(
|
||||
start,
|
||||
prior_start if q["compare"] else start,
|
||||
@@ -252,14 +272,47 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
|
||||
statement = statement.where(Activity.started_at >= boundary(lower))
|
||||
raw = db.session.execute(statement).all()
|
||||
history = prepare(raw, zone, q, catalog)
|
||||
if q["source"] != "runs":
|
||||
from .stats_sources import expand
|
||||
user = db.session.get(User, owner_id)
|
||||
if user and user.hr_bands:
|
||||
for row in history:
|
||||
intervals = (row.analysis_summary or {}).get("intervals", [])
|
||||
usable = any(unit.get("hr_seconds") for unit in intervals)
|
||||
for name in ("lower", "middle", "upper"):
|
||||
row.values[name + "_hr_minutes"] = 0.0 if usable else None
|
||||
for unit in intervals:
|
||||
for hr, seconds in unit.get("hr_seconds", {}).items():
|
||||
hr = float(hr)
|
||||
name = "lower" if hr < user.hr_bands[0] else "middle" if hr < user.hr_bands[1] else "upper"
|
||||
row.values[name + "_hr_minutes"] += seconds / 60
|
||||
if q["route"]:
|
||||
from .route_benchmarks import similar
|
||||
|
||||
history = expand(history, q, catalog)
|
||||
reference = db.session.scalar(db.select(Activity.route_geometry).where(
|
||||
Activity.id == q["route"], Activity.owner_id == owner_id))
|
||||
if reference is None:
|
||||
raise QueryError("Choose one of your own recorded routes.")
|
||||
history = [row for row in history if similar(reference, row.route_geometry)]
|
||||
if q["all_time"] and history:
|
||||
start = history[0].day
|
||||
prior_start = start
|
||||
if q["all_time"] and q["source"] in {"days", "health"}:
|
||||
first_health = db.session.scalar(db.select(db.func.min(DailyHealth.day)).where(
|
||||
DailyHealth.owner_id == owner_id, DailyHealth.day < end))
|
||||
if first_health:
|
||||
start = min(start, first_health) if history else first_health
|
||||
prior_start = start
|
||||
if q["source"] in {"days", "health"}:
|
||||
from .training_sources import daily
|
||||
|
||||
lower = min(start, prior_start if q["compare"] else start,
|
||||
start - timedelta(days=q["baseline"] or 0))
|
||||
lower -= timedelta(days=max((spec.get("window", {}).get("days", 0)
|
||||
for spec in q["derived"]), default=0))
|
||||
history = daily(history, owner_id, zone, lower, end, q, catalog)
|
||||
elif q["source"] != "runs":
|
||||
from .stats_sources import expand
|
||||
|
||||
history = expand(history, q, catalog)
|
||||
def included(row):
|
||||
return (
|
||||
row.distance_m >= q["minimum"] * 1000
|
||||
@@ -270,6 +323,12 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
|
||||
and (not q["filter_groups"] or any(matches(row, group) for group in q["filter_groups"]))
|
||||
)
|
||||
|
||||
if q["reduce"]:
|
||||
from .training_sources import reduce_runs
|
||||
|
||||
history = reduce_runs([row for row in history if included(row)], q["reduce"], catalog)
|
||||
included = lambda row: True
|
||||
|
||||
current = ordered(
|
||||
[row for row in history if start <= row.day < end and included(row)], q["sort"]
|
||||
)
|
||||
@@ -417,8 +476,8 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
|
||||
number, coverage = summarize(current, key, q)
|
||||
before, _ = summarize(prior, key, q)
|
||||
title, unit, definition = descriptors[key]
|
||||
if unit == "runs":
|
||||
unit = q["source"]
|
||||
if unit == "runs" and key != "sessions" and not key.startswith("rolling_"):
|
||||
unit = "runs" if q["reduce"] else q["source"]
|
||||
cards.append(
|
||||
{
|
||||
"key": key,
|
||||
@@ -480,8 +539,8 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
|
||||
return results
|
||||
|
||||
title, unit, _ = descriptors[key]
|
||||
if unit == "runs":
|
||||
unit = q["source"]
|
||||
if unit == "runs" and key != "sessions" and not key.startswith("rolling_"):
|
||||
unit = "runs" if q["reduce"] else q["source"]
|
||||
pace_axis = unit == "/km"
|
||||
if q["trend"] == "percent_change":
|
||||
unit, pace_axis = "%", False
|
||||
@@ -507,6 +566,10 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
|
||||
"reference": q["reference"],
|
||||
"dates": [token.isoformat() for token in tokens] if group == "day" else [],
|
||||
"source": q["source"],
|
||||
"timezone": timezone,
|
||||
"timestamps": [next(row.instant.timestamp() * 1000 for row in current if row.id == token)
|
||||
for token in tokens] if group == "run" else [],
|
||||
"coverage": [evidence_coverage(current_groups[token], q["source"]) for token in tokens],
|
||||
}
|
||||
if q["cohorts"]:
|
||||
cohorts = [{"label": cohort["name"], "values": []} for cohort in q["cohorts"]]
|
||||
@@ -613,6 +676,7 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
|
||||
"distance": f"{row.distance_m / 1000:.2f}",
|
||||
"values": [formatted(value(row, field), field, q) for field in inputs],
|
||||
"raw_values": [value(row, field) for field in inputs],
|
||||
"activity_ids": getattr(row, "activity_ids", []),
|
||||
"previous_run": {
|
||||
"id": row.previous.id,
|
||||
"title": row.previous.title,
|
||||
@@ -624,7 +688,9 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
|
||||
|
||||
return {
|
||||
"cards": cards,
|
||||
"source": q["source"],
|
||||
"source": "matched runs" if q["reduce"] else q["source"],
|
||||
"coverage": evidence_coverage(current, q["source"]),
|
||||
"group_coverage": [evidence_coverage(current_groups[token], q["source"]) for token in tokens],
|
||||
"chart": chart,
|
||||
"insights": insights,
|
||||
"groups": groups,
|
||||
|
||||
+3
-1
@@ -7,7 +7,7 @@ import click
|
||||
|
||||
from .analytics import analyse, numeric
|
||||
|
||||
ANALYSIS_VERSION = 2
|
||||
ANALYSIS_VERSION = 3
|
||||
CHART_LIMIT = 750
|
||||
ROUTE_TARGET = 1400
|
||||
CHART_KEYS = (
|
||||
@@ -105,6 +105,7 @@ def compact_routes(samples):
|
||||
|
||||
def persist_analysis(activity):
|
||||
from .effort_segments import analyze
|
||||
from .recording_windows import analyze as continuous_windows
|
||||
|
||||
analysis = analyse(activity)
|
||||
effort, segments = analyze(activity.samples or [])
|
||||
@@ -113,6 +114,7 @@ def persist_analysis(activity):
|
||||
)
|
||||
activity.analysis_summary = {key: analysis[key] for key in ("summary", "terrain", "laps")}
|
||||
activity.analysis_summary["segments"] = segments
|
||||
activity.analysis_summary["intervals"] = continuous_windows(activity.samples or []) if activity.sport == "running" else []
|
||||
routes = compact_routes(analysis["samples"])
|
||||
activity.route_geometry = [[sample["point"] for sample in segment] for segment in routes]
|
||||
chart_indices = evenly_spaced(len(analysis["samples"]), CHART_LIMIT)
|
||||
|
||||
@@ -16,9 +16,11 @@ class User(UserMixin, db.Model):
|
||||
# Assigned only by local setup / operator CLI. Never accepted from registration input.
|
||||
local_account_key = db.Column(db.String(64), unique=True)
|
||||
created_at = db.Column(db.DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
hr_bands = db.Column(db.JSON)
|
||||
activities = db.relationship(
|
||||
"Activity", backref="owner", lazy="select", cascade="all, delete-orphan"
|
||||
)
|
||||
daily_health = db.relationship("DailyHealth", cascade="all, delete-orphan")
|
||||
|
||||
def set_password(self, password):
|
||||
self.password_hash = generate_password_hash(password)
|
||||
@@ -38,6 +40,9 @@ class GarminConnection(db.Model):
|
||||
last_success = db.Column(db.DateTime(timezone=True))
|
||||
message = db.Column(db.String(200), nullable=False, default="Sync queued.")
|
||||
failures = db.Column(db.Integer, nullable=False, default=0)
|
||||
health_enabled = db.Column(db.Boolean, nullable=False, default=False, server_default=db.false())
|
||||
health_message = db.Column(db.String(200), nullable=False, default="Daily recovery sync is off.",
|
||||
server_default="Daily recovery sync is off.")
|
||||
|
||||
|
||||
class Activity(db.Model):
|
||||
@@ -51,6 +56,7 @@ class Activity(db.Model):
|
||||
duration_s = db.Column(db.Float, nullable=False, default=0)
|
||||
elevation_m = db.Column(db.Float, nullable=False, default=0)
|
||||
avg_hr = db.Column(db.Float)
|
||||
session_context = db.Column(db.JSON, nullable=False, default=dict, server_default="{}")
|
||||
metrics = db.Column(db.JSON, nullable=False, default=dict, server_default="{}")
|
||||
visibility = db.Column(db.String(12), nullable=False, default="private")
|
||||
share_token = db.Column(db.String(64), unique=True)
|
||||
@@ -123,6 +129,16 @@ class SavedAnalysis(db.Model):
|
||||
)
|
||||
|
||||
|
||||
class DailyHealth(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
owner_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True)
|
||||
day = db.Column(db.Date, nullable=False)
|
||||
values = db.Column(db.JSON, nullable=False, default=dict)
|
||||
fetched_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
||||
default=lambda: datetime.now(UTC))
|
||||
__table_args__ = (db.UniqueConstraint("owner_id", "day"),)
|
||||
|
||||
|
||||
class Friendship(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
low_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Bounded optional daily Garmin imports; provider failures never disclose sessions."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from .analytics import numeric
|
||||
from .extensions import db
|
||||
from .models import DailyHealth
|
||||
|
||||
|
||||
def normalize(stats, hrv, sleep):
|
||||
summary = (hrv or {}).get("hrvSummary") or {}
|
||||
dto = (sleep or {}).get("dailySleepDTO") or {}
|
||||
candidates = {"resting_hr": (stats or {}).get("restingHeartRate"),
|
||||
"hrv": summary.get("lastNightAvg"),
|
||||
"sleep_hours": dto.get("sleepTimeSeconds"),
|
||||
"stress": (stats or {}).get("averageStressLevel")}
|
||||
values = {}
|
||||
for key, number in candidates.items():
|
||||
if not numeric(number):
|
||||
continue
|
||||
if key == "sleep_hours":
|
||||
number /= 3600
|
||||
low, high = {"resting_hr": (25, 220), "hrv": (1, 500),
|
||||
"sleep_hours": (0, 24), "stress": (0, 100)}[key]
|
||||
if low <= number <= high:
|
||||
values[key] = number
|
||||
return values
|
||||
|
||||
|
||||
def sync_health(api, user, days=90, limit=5):
|
||||
today = datetime.now(UTC).astimezone(ZoneInfo(user.timezone)).date()
|
||||
first = today - timedelta(days=days - 1)
|
||||
known = {row.day: row for row in db.session.scalars(db.select(DailyHealth).where(
|
||||
DailyHealth.owner_id == user.id, DailyHealth.day >= first))}
|
||||
# Refresh today/yesterday; then walk backwards through missing history.
|
||||
dates = [today, today - timedelta(days=1)]
|
||||
dates += [today - timedelta(days=i) for i in range(2, days)
|
||||
if today - timedelta(days=i) not in known or
|
||||
not known[today - timedelta(days=i)].values.get("_complete")]
|
||||
dates = [day for day in dates if day >= first][:limit]
|
||||
imported = failures = 0
|
||||
for day in dates:
|
||||
documents = []
|
||||
day_failures = 0
|
||||
for method in ("get_stats", "get_hrv_data", "get_sleep_data"):
|
||||
try:
|
||||
document = getattr(api, method)(day.isoformat())
|
||||
documents.append(document if isinstance(document, dict) else {})
|
||||
except Exception: # noqa: BLE001 -- never log provider responses or credentials
|
||||
documents.append({})
|
||||
failures += 1
|
||||
day_failures += 1
|
||||
values = normalize(*documents)
|
||||
if day_failures and not values:
|
||||
# Retry later; stop the batch instead of hammering a failing provider.
|
||||
break
|
||||
row = known.get(day)
|
||||
if not row:
|
||||
row = DailyHealth(owner_id=user.id, day=day, values={})
|
||||
db.session.add(row)
|
||||
row.values = {**row.values, **values, "_complete": day_failures == 0}
|
||||
row.fetched_at = datetime.now(UTC)
|
||||
db.session.commit()
|
||||
imported += 1
|
||||
if day_failures:
|
||||
break
|
||||
return {"days": imported, "failures": failures,
|
||||
"message": f"Daily recovery: {imported} dates checked." +
|
||||
(" Some observations unavailable; will retry." if failures else
|
||||
" Missing watch observations remain missing.")}
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Approximate same-start, same-direction full-course matching, without raw samples."""
|
||||
|
||||
import math
|
||||
from itertools import pairwise
|
||||
|
||||
from .analytics import numeric
|
||||
|
||||
|
||||
def distance(a, b):
|
||||
lat1, lat2 = math.radians(a[0]), math.radians(b[0])
|
||||
dlat, dlon = lat2 - lat1, math.radians(b[1] - a[1])
|
||||
h = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
|
||||
return 12742000 * math.asin(math.sqrt(min(1, h)))
|
||||
|
||||
|
||||
def resample(segments):
|
||||
# A recording break must not invent a connecting route.
|
||||
if not isinstance(segments, list) or len(segments) != 1:
|
||||
return None
|
||||
points = segments[0]
|
||||
if len(points) < 2 or any(not isinstance(p, list) or len(p) != 2 or
|
||||
not all(numeric(v) for v in p) or
|
||||
not -90 <= p[0] <= 90 or not -180 <= p[1] <= 180 for p in points):
|
||||
return None
|
||||
cumulative = [0.0]
|
||||
for a, b in pairwise(points):
|
||||
cumulative.append(cumulative[-1] + distance(a, b))
|
||||
total = cumulative[-1]
|
||||
if total < 500:
|
||||
return None
|
||||
result, cursor = [], 0
|
||||
for i in range(33):
|
||||
target = total * i / 32
|
||||
while cursor < len(points) - 2 and cumulative[cursor + 1] < target:
|
||||
cursor += 1
|
||||
span = cumulative[cursor + 1] - cumulative[cursor]
|
||||
fraction = (target - cumulative[cursor]) / span if span else 0
|
||||
result.append([a + (b - a) * fraction for a, b in zip(points[cursor], points[cursor + 1], strict=True)])
|
||||
return total, result
|
||||
|
||||
|
||||
def similar(reference, candidate):
|
||||
a, b = resample(reference), resample(candidate)
|
||||
if not a or not b or abs(b[0] / a[0] - 1) > .1:
|
||||
return False
|
||||
differences = sorted(distance(x, y) for x, y in zip(a[1], b[1], strict=True))
|
||||
return (distance(a[1][0], b[1][0]) <= 100 and distance(a[1][-1], b[1][-1]) <= 100
|
||||
and sum(differences) / len(differences) <= 100 and differences[29] <= 200)
|
||||
+6
-1
@@ -550,7 +550,7 @@ def garmin_action(action):
|
||||
|
||||
from .sync import connect, session_lock
|
||||
|
||||
if action not in {"connect", "sync", "schedule", "disconnect"}:
|
||||
if action not in {"connect", "sync", "schedule", "disconnect", "health"}:
|
||||
abort(404)
|
||||
try:
|
||||
if action == "connect":
|
||||
@@ -572,6 +572,11 @@ def garmin_action(action):
|
||||
elif action == "schedule":
|
||||
connection.scheduled = request.form.get("enabled") == "yes"
|
||||
connection.next_sync = datetime.now(UTC)
|
||||
elif action == "health":
|
||||
connection.health_enabled = request.form.get("enabled") == "yes"
|
||||
connection.health_message = "Daily recovery sync queued." if connection.health_enabled else "Daily recovery sync is off."
|
||||
if connection.health_enabled:
|
||||
connection.pending = True
|
||||
else:
|
||||
db.session.delete(connection)
|
||||
current_user.local_account_key = None
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
const colors = ['#f48459', '#8ccbb4', '#9cb8e8', '#d2ace8', '#e0c375', '#75cad6'];
|
||||
const unitLabel = unit => unit==='time'?'min:sec':unit;
|
||||
const pace = value => { const seconds = Math.round(value); return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; };
|
||||
const number = (value, unit) => value == null ? '—' : unit === '/km' ? pace(value) : new Intl.NumberFormat(undefined, {maximumFractionDigits:2}).format(value);
|
||||
const number = (value, unit) => value == null ? '—' : ['/km','time'].includes(unit) ? pace(value) : new Intl.NumberFormat(undefined, {maximumFractionDigits:2}).format(value);
|
||||
function element(tag, className, text) {
|
||||
const node = document.createElement(tag); if (className) node.className = className; if (text != null) node.textContent = text; return node;
|
||||
}
|
||||
@@ -28,8 +29,8 @@
|
||||
function render(container, data, onBucket, {compact=false, scatterOnly=false}={}) {
|
||||
const instances = [], cleanups = [];
|
||||
container.replaceChildren();
|
||||
const chart = (canvas, config) => {config.plugins=[...(config.plugins||[]),{id:'strideCanvasBackground',beforeDraw(c){const ctx=c.ctx;ctx.save();ctx.globalCompositeOperation='destination-over';ctx.fillStyle='#202629';ctx.fillRect(0,0,c.width,c.height);ctx.restore();}}];const instance = new Chart(canvas, config); instances.push(instance); return instance; };
|
||||
const axes = (unit, reverse=false, title=unit) => ({title:{display:true,text:title,color:'#aab5b7'},reverse,ticks:{color:'#aab5b7',maxTicksLimit:6,callback:v=>number(v,unit)},grid:{color:'#354044'}});
|
||||
const chart = (canvas, config) => {if(data.coverage&&!data.scatter){const plugins=config.options.plugins||(config.options.plugins={});const tooltip=plugins.tooltip||(plugins.tooltip={});const callbacks=tooltip.callbacks||(tooltip.callbacks={});callbacks.afterBody=items=>{const coverage=data.coverage[items[0]?.dataIndex];return coverage?`${coverage.sessions} independent sessions · ${coverage.minutes} usable minutes`:'';};}config.plugins=[...(config.plugins||[]),{id:'strideCanvasBackground',beforeDraw(c){const ctx=c.ctx;ctx.save();ctx.globalCompositeOperation='destination-over';ctx.fillStyle='#202629';ctx.fillRect(0,0,c.width,c.height);ctx.restore();}}];const instance = new Chart(canvas, config); instances.push(instance); return instance; };
|
||||
const axes = (unit, reverse=false, title=unitLabel(unit)) => ({title:{display:true,text:title,color:'#aab5b7'},reverse,ticks:{color:'#aab5b7',maxTicksLimit:6,callback:v=>number(v,unit)},grid:{color:'#354044'}});
|
||||
const base = {responsive:true,maintainAspectRatio:false,animation:false,plugins:{legend:{labels:{color:'#c5cecf'}}},scales:{x:{ticks:{color:'#aab5b7',maxTicksLimit:compact?4:7},grid:{display:false}}}};
|
||||
function frame(title) {
|
||||
const panel = element('div', 'analysis-chart');
|
||||
@@ -64,13 +65,16 @@
|
||||
else {
|
||||
const groups=data.display==='overlay'?[data.series]:data.series.map(s=>[s]);
|
||||
groups.forEach(seriesGroup=>{
|
||||
const canvas=frame(seriesGroup.map(s=>`${s.label} (${s.unit})`).join(' / '));const datasets=[],scales={...base.scales};const units=[...new Set(seriesGroup.map(s=>s.unit))];
|
||||
const canvas=frame(seriesGroup.map(s=>`${s.label} (${unitLabel(s.unit)})`).join(' / '));const datasets=[],scales={...base.scales};const units=[...new Set(seriesGroup.map(s=>s.unit))];
|
||||
units.forEach((unit,i)=>{scales[`y${i}`]={...axes(unit),position:i%2?'right':'left',grid:{color:'#354044',drawOnChartArea:i===0}};});
|
||||
seriesGroup.forEach((series,index)=>{
|
||||
const color=colors[index%colors.length], axis=`y${units.indexOf(series.unit)}`;const automatic=['bin','weekday','hour','run','all'].includes(data.group)||['distance','duration','elevation','count'].includes(series.key)?'bar':'line';const type=data.type==='auto'?automatic:data.type==='area'?'line':data.type;
|
||||
const timeAxis=data.group==='run'&&data.timestamps?.length&&['line','area'].includes(data.type);
|
||||
const values=timeAxis?series.values.map((y,i)=>({x:data.timestamps[i],y})):series.values;
|
||||
if(timeAxis)scales.x={type:'linear',min:Math.min(...data.timestamps)-(data.timestamps.length===1?43200000:0),max:Math.max(...data.timestamps)+(data.timestamps.length===1?43200000:0),ticks:{color:'#aab5b7',maxTicksLimit:compact?4:7,callback:v=>new Intl.DateTimeFormat(undefined,{timeZone:data.timezone||'UTC',month:'short',day:'numeric'}).format(new Date(v))},grid:{display:false}};
|
||||
if(series.baseline?.value!=null){const b=series.baseline;datasets.push({label:'Baseline range lower',data:data.labels.map(()=>b.low),borderWidth:0,pointRadius:0,yAxisID:axis,type:'line',baseline:true},{label:'Baseline range',data:data.labels.map(()=>b.high),borderWidth:0,pointRadius:0,fill:'-1',backgroundColor:'#8ccbb41c',yAxisID:axis,type:'line',baseline:true},{label:`${series.label} baseline`,data:data.labels.map(()=>b.value),borderColor:'#8ccbb4',borderDash:[4,4],pointRadius:0,borderWidth:1,yAxisID:axis,type:'line',baseline:true});}
|
||||
if(data.reference?.field===series.key)datasets.push({label:'Reference',data:data.labels.map(()=>data.reference.value),borderColor:'#d2ace8',borderDash:[3,3],pointRadius:0,yAxisID:axis,type:'line',baseline:true});
|
||||
datasets.push({label:series.label,data:series.values,type,yAxisID:axis,borderColor:color,backgroundColor:color+(type==='bar'?'bb':'33'),borderWidth:2,pointRadius:c=>series.baseline?.flags[c.dataIndex]?6:2,pointStyle:c=>series.baseline?.flags[c.dataIndex]?'triangle':'circle',tension:.15,spanGaps:false,fill:data.type==='area',unit:series.unit});
|
||||
if(data.reference?.field===series.key)datasets.push({label:'Reference',data:data.labels.map((_,i)=>timeAxis?{x:data.timestamps[i],y:data.reference.value}:data.reference.value),borderColor:'#d2ace8',borderDash:[3,3],pointRadius:0,yAxisID:axis,type:'line',baseline:true});
|
||||
datasets.push({label:series.label,data:values,type,yAxisID:axis,borderColor:color,backgroundColor:color+(type==='bar'?'bb':'33'),borderWidth:2,pointRadius:c=>series.baseline?.flags[c.dataIndex]?6:2,pointStyle:c=>series.baseline?.flags[c.dataIndex]?'triangle':'circle',tension:.15,spanGaps:false,fill:data.type==='area',unit:series.unit});
|
||||
if(series.previous.length)datasets.push({label:`${series.label} · previous`,data:series.previous,type,yAxisID:axis,borderColor:'#aab5b7',borderDash:[5,5],borderWidth:1,pointRadius:1,backgroundColor:'#aab5b722',previous:true,unit:series.unit});
|
||||
});
|
||||
chart(canvas,{type:'line',data:{labels:data.labels,datasets},options:{...base,scales,plugins:{legend:{labels:{color:'#c5cecf',filter:item=>!['Baseline range lower','Baseline range'].includes(item.text)}},tooltip:{filter:c=>!c.dataset.baseline,callbacks:{label:c=>`${c.dataset.label}: ${number(c.parsed.y,c.dataset.unit)} ${c.dataset.unit}`}}},onClick:(_,points,c)=>{const p=points.find(p=>!c.data.datasets[p.datasetIndex].baseline&&!c.data.datasets[p.datasetIndex].previous);if(p)onBucket?.(data.labels[p.index]);}}});
|
||||
@@ -82,7 +86,7 @@
|
||||
const finite=values=>values.filter(v=>typeof v==='number'&&Number.isFinite(v));
|
||||
const colorValues=finite(s.current.map(p=>p.color)), sizeValues=finite(s.current.map(p=>p.size));const extent=values=>values.length?[Math.min(...values),Math.max(...values)]:[0,0];const [cmin,cmax]=extent(colorValues),[smin,smax]=extent(sizeValues);
|
||||
const scale=(v,min,max)=>v==null?.5:max===min?.5:Math.max(0,Math.min(1,(v-min)/(max-min)));
|
||||
chart(canvas,{type:'scatter',data:{datasets:[{label:'Current',data:s.current,backgroundColor:c=>s.color?`hsl(${25+160*scale(c.raw?.color,cmin,cmax)} 65% 65%)`:colors[0],pointRadius:c=>s.size?3+6*scale(c.raw?.size,smin,smax):4},...(s.previous.length?[{label:'Previous',data:s.previous,backgroundColor:'#aab5b7',pointRadius:3}]:[])]},options:{...base,scales:{x:axes(s.x[1],false,`${s.x[0]} (${s.x[1]})`),y:axes(s.y[1],false,`${s.y[0]} (${s.y[1]})`)},plugins:{legend:{labels:{color:'#c5cecf'}},tooltip:{callbacks:{label:c=>[c.raw.label,`${number(c.raw.x,s.x[1])} ${s.x[1]} / ${number(c.raw.y,s.y[1])} ${s.y[1]}`,...(s.color?[`${s.color[0]}: ${number(c.raw.color,s.color[1])} ${s.color[1]}`]:[]),...(s.size?[`${s.size[0]}: ${number(c.raw.size,s.size[1])} ${s.size[1]}`]:[])]}}},onClick:(_,points,c)=>{if(points.length)location.assign(`/activities/${c.data.datasets[points[0].datasetIndex].data[points[0].index].id}`);}}});
|
||||
chart(canvas,{type:'scatter',data:{datasets:[{label:'Current',data:s.current,backgroundColor:c=>s.color?`hsl(${25+160*scale(c.raw?.color,cmin,cmax)} 65% 65%)`:colors[0],pointRadius:c=>s.size?3+6*scale(c.raw?.size,smin,smax):4},...(s.previous.length?[{label:'Previous',data:s.previous,backgroundColor:'#aab5b7',pointRadius:3}]:[])]},options:{...base,scales:{x:axes(s.x[1],false,`${s.x[0]} (${s.x[1]})`),y:axes(s.y[1],false,`${s.y[0]} (${s.y[1]})`)},plugins:{legend:{labels:{color:'#c5cecf'}},tooltip:{callbacks:{label:c=>[c.raw.label,`${number(c.raw.x,s.x[1])} ${s.x[1]} / ${number(c.raw.y,s.y[1])} ${s.y[1]}`,...(s.color?[`${s.color[0]}: ${number(c.raw.color,s.color[1])} ${s.color[1]}`]:[]),...(s.size?[`${s.size[0]}: ${number(c.raw.size,s.size[1])} ${s.size[1]}`]:[])]}}},onClick:(_,points,c)=>{if(points.length){const id=c.data.datasets[points[0].datasetIndex].data[points[0].index].id;if(id)location.assign(`/activities/${id}`);}}}});
|
||||
if(s.color||s.size)container.append(element('p','field-help',`${s.color?`Color: ${s.color[0]} · ${number(cmin,s.color[1])} (warm) to ${number(cmax,s.color[1])} (cool). `:''}${s.size?`Size: ${s.size[0]} · ${number(smin,s.size[1])} to ${number(smax,s.size[1])}.`:''}`));
|
||||
}
|
||||
return ()=>{instances.forEach(c=>c.destroy());cleanups.forEach(f=>f());};
|
||||
|
||||
@@ -48,7 +48,10 @@
|
||||
document.getElementById('new-analysis').onclick=()=>location.assign('/stats');
|
||||
document.getElementById('matched-effort-form').onsubmit=event=>{
|
||||
event.preventDefault();const fields=Object.fromEntries(new FormData(event.target));
|
||||
const text=`${fields.source}\n during last ${fields.weeks} weeks\n where pace between ${fields.pace_low} and ${fields.pace_high} /km\n and elevation_per_km <= ${fields.hills} m/km\n and has heart_rate\n${fields.source==='segments'?' and pace_variability <= 5 %\n and sensor_coverage >= 80 %\n and duration >= 4 min\n':''} summarize weighted_mean(heart_rate) as "Matched-pace HR"\n by week\n compare none\n baseline previous ${fields.baseline} days\n flag above baseline 1.5 sd min ${fields.minimum} runs sustained 2 buckets\n chart line`;
|
||||
const windows=fields.source==='intervals';
|
||||
const filters=windows?` reduce by run min ${fields.matched} min\n where pace between ${fields.pace_low} and ${fields.pace_high} /km\n and elapsed >= ${fields.warmup} min\n and grade between -${fields.grade} and ${fields.grade} %\n and grade_variability <= 2 %\n and pace_variability <= 15 %\n and sensor_coverage >= 90 %\n and duration >= 25 s\n and hr_artifact_percent <= 0 %\n`:` where pace between ${fields.pace_low} and ${fields.pace_high} /km\n and elevation_per_km <= ${fields.hills} m/km\n and has heart_rate\n`;
|
||||
const baseline=fields.use_baseline?` baseline previous ${fields.baseline} days\n flag above baseline 1.5 sd min ${fields.minimum} runs sustained 2 buckets\n`:'';
|
||||
const text=`${fields.source}\n during last ${fields.weeks} weeks\n${filters} summarize ${windows?'mean':'weighted_mean'}(heart_rate) as "Matched-pace HR"\n by ${fields.use_baseline?'week':'run'}\n sort by date asc\n compare none\n${baseline} chart line`;
|
||||
location.assign('/stats?'+new URLSearchParams({query:text}));
|
||||
};
|
||||
document.querySelectorAll('[data-presentation]').forEach(control=>control.onchange=()=>{
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
const url=(bucket,scope='group')=>'/stats?'+new URLSearchParams({analysis:id,query:result.analysis.query,...(bucket?{bucket,scope}:{})})+'#studio-evidence';
|
||||
const data=result.results;
|
||||
let dispose=null;
|
||||
if(!data.count)body.append(el('p','field-help','No runs match this analysis yet. Explore to adjust the filters.'));
|
||||
if(!data.count)body.append(el('p','field-help','No observations match this analysis yet. Explore to adjust the filters.'));
|
||||
else if(widget.dataset.view==='chart'&&(data.chart.type!=='none'||data.chart.scatter))dispose=StrideCharts.render(body,data.chart,bucket=>location.assign(url(bucket,'trend')),{compact:true});
|
||||
else if(widget.dataset.view==='value'||(widget.dataset.view==='chart'&&data.chart.type==='none')){
|
||||
const values=el('div','live-widget-values');data.cards.forEach(card=>{const item=el('div');item.append(el('span','eyebrow',card.label),el('strong','',`${card.value} ${card.unit}`),el('small','field-help',`${card.coverage} of ${data.count} usable ${data.source}`));values.append(item);});body.append(values);
|
||||
@@ -19,7 +19,8 @@
|
||||
const wrap=el('div','activity-table-wrap'),table=el('table','activity-table'),head=el('thead'),tr=el('tr');['Group',data.source,...data.cards.map(c=>`${c.label} · ${c.unit}`)].forEach(t=>tr.append(el('th','',t)));head.append(tr);table.append(head);const tbody=el('tbody');data.groups.slice(-12).forEach(group=>{const row=el('tr'),cell=el('td'),a=el('a','',group.label);a.href=url(group.label);cell.append(a);row.append(cell);[group.count,...group.values].forEach(v=>row.append(el('td','',v)));tbody.append(row);});table.append(tbody);wrap.append(table);body.append(wrap);if(data.groups.length>12)body.append(el('p','field-help','Last 12 displayed groups · Explore for all results.'));
|
||||
}
|
||||
StrideCharts.insights(insights,data.insights,bucket=>location.assign(url(bucket)));
|
||||
widget.querySelector('.widget-status').textContent=`${data.count} matching ${data.source} · ${data.start} – ${data.end} · updated ${new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}`;
|
||||
const coverage=data.coverage;
|
||||
widget.querySelector('.widget-status').textContent=`${data.count} qualifying ${data.source==='matched runs'?'runs':data.source}${coverage?` · ${coverage.sessions} independent sessions · ${coverage.minutes} usable minutes`:''} · ${data.start} – ${data.end} · updated ${new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}`;
|
||||
states.set(id,{result,dispose});
|
||||
}
|
||||
async function refresh(){
|
||||
|
||||
@@ -26,6 +26,15 @@
|
||||
['runs', 'Your recorded running activities'],
|
||||
['laps', 'Recorded lap inputs from compact analysis'],
|
||||
['segments', 'Five-minute moving segments from full-resolution recordings'],
|
||||
['intervals', 'Continuous 30-second moving windows, with elapsed offset and terrain'],
|
||||
['efforts', 'Recorded best-effort windows at 1, 2, 5 and 10 km'],
|
||||
['days', 'Daily workload and rest days, using local calendar windows'],
|
||||
['health', 'Private daily recovery observations from Garmin'],
|
||||
['reduce by run min 3 min', 'Match recording windows first; one observation per qualifying session'],
|
||||
['long run distance >= 10 km', 'Choose the qualifying long-run threshold for daily continuity'],
|
||||
['route similar to 1', 'Approximate full-course match to one of your own recorded routes'],
|
||||
['where elapsed >= 10 min', 'Exclude the first ten elapsed recording minutes'],
|
||||
['and grade between -2 and 2 %', 'Require approximately flat smoothed terrain'],
|
||||
['cohort "Easy" where heart_rate <= 145 bpm', 'First-match partitions for a stacked additive summary'],
|
||||
['compare previous year', 'Same local-date interval in the preceding year'],
|
||||
['during last 12 weeks', 'Rolling period ending today'],
|
||||
|
||||
+2
-1
@@ -30,8 +30,9 @@
|
||||
tbody.replaceChildren();
|
||||
result.rows.forEach(row => {
|
||||
const tr = document.createElement('tr'); const td = document.createElement('td');
|
||||
const a = document.createElement('a'); a.href = `/activities/${row.id}`; a.textContent = `${row.title} · ${row.date}`;
|
||||
const a = document.createElement(row.id?'a':'span'); if(row.id)a.href = `/activities/${row.id}`; a.textContent = `${row.title} · ${row.date}`;
|
||||
td.append(a); tr.append(td);
|
||||
(row.activity_ids||[]).forEach(id=>{const link=document.createElement('a');link.href=`/activities/${id}`;link.textContent=` · Run #${id}`;td.append(link);});
|
||||
if (root.dataset.showPrevious === 'true') {
|
||||
const detail=document.createElement('small');detail.className='previous-run';
|
||||
detail.append('Previous: ');
|
||||
|
||||
@@ -48,3 +48,6 @@ a.record-badge:focus-visible { outline: 2px solid #f3cb78; outline-offset: 3px;
|
||||
@media(max-width:680px){.app-header{height:auto;min-height:72px;padding:16px 18px;gap:12px;flex-wrap:wrap}.app-header .brand{font-size:25px;gap:8px}.app-header .brand-mark{width:28px;height:28px;font-size:25px}.header-nav{gap:0}.header-nav a{padding:8px;font-size:12px}.header-actions{gap:8px}.header-import{order:1;padding:8px 10px;font-size:11px}.header-import .icon{display:none}.app-header .brand-dot{margin-left:-6px}.account-dropdown-menu{width:220px}.app-shell .page{padding-top:25px}.page-heading h1{font-size:30px}}
|
||||
|
||||
@media(max-width:680px){.header-import{display:none}}
|
||||
|
||||
/* Keep visually hidden table labels within the scroll container on mobile. */
|
||||
.activity-table-wrap{position:relative}
|
||||
|
||||
@@ -70,6 +70,9 @@ DEFAULT = {
|
||||
"reference": None,
|
||||
"source": "runs",
|
||||
"cohorts": [],
|
||||
"reduce": None,
|
||||
"long_run": 10.0,
|
||||
"route": None,
|
||||
"comparison": "period",
|
||||
}
|
||||
|
||||
@@ -132,6 +135,12 @@ def query_text(q):
|
||||
else:
|
||||
period = f"during last {q['weeks']} weeks"
|
||||
lines = [q["source"], " " + period]
|
||||
if q["reduce"]:
|
||||
lines.append(f" reduce by run min {number_text(q['reduce'])} min")
|
||||
if q["long_run"] != 10:
|
||||
lines.append(f" long run distance >= {number_text(q['long_run'])} km")
|
||||
if q["route"]:
|
||||
lines.append(f" route similar to {q['route']}")
|
||||
for spec in q["derived"]:
|
||||
lines.append(f" let {spec['name']} = {spec['expression']}")
|
||||
if q["end"] and not q["start"]:
|
||||
|
||||
+11
-1
@@ -8,6 +8,7 @@ import statistics
|
||||
from .stats_dsl import UNITS, QueryError, parse_dsl
|
||||
|
||||
FUNCTIONS = {
|
||||
"latest",
|
||||
"sum",
|
||||
"mean",
|
||||
"weighted_mean",
|
||||
@@ -134,6 +135,13 @@ EXTRA_UNITS = {
|
||||
}
|
||||
|
||||
|
||||
from .training_metrics import METRICS as TRAINING_METRICS
|
||||
from .training_metrics import UNITS as TRAINING_UNITS
|
||||
|
||||
EXTRA_METRICS.update(TRAINING_METRICS)
|
||||
EXTRA_UNITS.update(TRAINING_UNITS)
|
||||
|
||||
|
||||
def valid(value):
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
|
||||
|
||||
@@ -261,7 +269,7 @@ def parse_analysis(text, metrics, defaults):
|
||||
function, field = match.groups()
|
||||
if function not in FUNCTIONS or (not field and function != "count"):
|
||||
raise QueryError(
|
||||
"Use sum, mean, weighted_mean, median, min, max, stddev, count or p10/p25/p75/p90/p95/p99.",
|
||||
"Use latest, sum, mean, weighted_mean, median, min, max, stddev, count or p10/p25/p75/p90/p95/p99.",
|
||||
line,
|
||||
)
|
||||
if field and field not in catalog:
|
||||
@@ -531,6 +539,8 @@ def describe(key, catalog, aggregates):
|
||||
)
|
||||
+ ". Percentiles use linear interpolation; stddev is population standard deviation. Missing values are excluded."
|
||||
)
|
||||
if function == "latest":
|
||||
definition = "Value on the latest input by local date/time, including a missing value on that input. Sorting does not change which input is latest."
|
||||
return label, unit, definition
|
||||
|
||||
|
||||
|
||||
+31
-3
@@ -37,6 +37,9 @@ def extract(text):
|
||||
"source": "runs",
|
||||
"cohorts": [],
|
||||
"comparison": "period",
|
||||
"reduce": None,
|
||||
"long_run": 10.0,
|
||||
"route": None,
|
||||
}
|
||||
seen, lines = set(), text.splitlines()
|
||||
|
||||
@@ -47,11 +50,26 @@ def extract(text):
|
||||
|
||||
for index, source in enumerate(lines):
|
||||
line, number = source.strip(), index + 1
|
||||
if line in {"runs", "laps", "segments"}:
|
||||
if line in {"runs", "laps", "segments", "intervals", "efforts", "days", "health"}:
|
||||
once("source", number)
|
||||
options["source"] = line
|
||||
lines[index] = "runs"
|
||||
continue
|
||||
elif match := re.fullmatch(r"reduce by run min (\d+(?:\.\d+)?) min", line):
|
||||
once("reduce", number)
|
||||
minutes = float(match[1])
|
||||
if not 1 <= minutes <= 180:
|
||||
raise QueryError("Use 1–180 matched minutes per run.", number)
|
||||
options["reduce"] = minutes
|
||||
elif match := re.fullmatch(r"long run distance >= (\d+(?:\.\d+)?) km", line):
|
||||
once("long_run", number)
|
||||
distance = float(match[1])
|
||||
if not 1 <= distance <= 100:
|
||||
raise QueryError("Use a long-run threshold of 1–100 km.", number)
|
||||
options["long_run"] = distance
|
||||
elif match := re.fullmatch(r"route similar to (\d+)", line):
|
||||
once("route", number)
|
||||
options["route"] = int(match[1])
|
||||
elif line == "compare previous year":
|
||||
options["comparison"] = "year"
|
||||
lines[index] = "compare previous period"
|
||||
@@ -138,6 +156,16 @@ def extract(text):
|
||||
|
||||
|
||||
def validate(q, catalog):
|
||||
if q["flag"] and q["source"] in {"laps", "segments", "intervals"} and not q["reduce"]:
|
||||
raise QueryError("Reduce recordings to independent runs before using a deviation flag.")
|
||||
if q["reduce"] and q["source"] not in {"intervals", "segments"}:
|
||||
raise QueryError("reduce by run requires intervals or segments.")
|
||||
if q["reduce"] and (q["derived"] or q["cohorts"]):
|
||||
raise QueryError("Reduce matched recordings before adding calculations or cohorts.")
|
||||
if q["route"] and q["source"] in {"days", "health"}:
|
||||
raise QueryError("Route matching requires a recording or runs source.")
|
||||
if q["source"] in {"days", "health"} and q["group"] in {"run", "hour"}:
|
||||
raise QueryError("Daily observations require calendar, all, or numeric grouping.")
|
||||
if q["plot"] and q["chart"] == "none":
|
||||
raise QueryError("A scatter plot conflicts with chart none.")
|
||||
if q["cohorts"] and (
|
||||
@@ -167,7 +195,7 @@ def validate(q, catalog):
|
||||
)
|
||||
if q["compare"]:
|
||||
raise QueryError("Stacked cohorts show the current period; use compare none.")
|
||||
if q["source"] != "runs":
|
||||
if q["source"] not in {"runs", "days", "health"}:
|
||||
temporal = {
|
||||
"gap_since_previous_run",
|
||||
"distance_change",
|
||||
@@ -231,7 +259,7 @@ def validate(q, catalog):
|
||||
*q["extra_metrics"],
|
||||
]
|
||||
supported = lambda key: (
|
||||
q["aggregates"][key]["function"] not in {"sum", "count", "stddev"}
|
||||
q["aggregates"][key]["function"] not in {"sum", "count", "stddev", "latest"}
|
||||
if key in q["aggregates"]
|
||||
else key not in additive
|
||||
)
|
||||
|
||||
@@ -56,7 +56,7 @@ def enrich(chart, q, keys, tokens, groups, history, included, start, measure, ca
|
||||
continue
|
||||
# Comparing totals/counts to a distribution of individual runs is meaningless.
|
||||
supported = (
|
||||
spec["function"] not in {"sum", "count", "stddev"}
|
||||
spec["function"] not in {"sum", "count", "stddev", "latest"}
|
||||
if spec
|
||||
else field not in {"count", "distance", "duration", "elevation", "calories"}
|
||||
)
|
||||
@@ -111,7 +111,7 @@ def enrich(chart, q, keys, tokens, groups, history, included, start, measure, ca
|
||||
"label": series["label"],
|
||||
"unit": series["unit"],
|
||||
"baseline": center,
|
||||
"source": q["source"],
|
||||
"source": "runs" if q["reduce"] else q["source"],
|
||||
"low": low,
|
||||
"high": high,
|
||||
"count": len(numbers),
|
||||
|
||||
@@ -5,7 +5,7 @@ PRESETS = [
|
||||
"name": "HR in steady segments",
|
||||
"category": "Effort & recovery",
|
||||
"description": "Match five-minute moving segments by pace, speed consistency and HR coverage.",
|
||||
"query": 'segments\n during last 12 weeks\n where pace between 5:20 and 5:40 /km\n and pace_variability <= 5 %\n and sensor_coverage >= 80 %\n and duration >= 4 min\n summarize weighted_mean(heart_rate) as "Steady-segment HR"\n by week\n compare none\n baseline previous 90 days\n flag above baseline 1.5 sd min 5 runs sustained 2 buckets\n chart line',
|
||||
"query": 'segments\n during last 12 weeks\n reduce by run min 3 min\n where pace between 5:20 and 5:40 /km\n and pace_variability <= 5 %\n and sensor_coverage >= 80 %\n and duration >= 4 min\n summarize weighted_mean(heart_rate) as "Steady-segment HR"\n by week\n compare none\n baseline previous 90 days\n flag above baseline 1.5 sd min 5 runs sustained 2 buckets\n chart line',
|
||||
},
|
||||
{
|
||||
"name": "Speed/HR decoupling",
|
||||
@@ -92,3 +92,11 @@ PRESETS = [
|
||||
"query": "runs\n during last 6 months\n summarize median(distance), median(pace), count()\n by weekday\n having count() >= 3 runs\n compare none\n chart bar",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Editable advanced examples. No account observations or inferred HR boundaries.
|
||||
from .training_signals import signal_queries
|
||||
|
||||
PRESETS.extend({"name": d["name"], "category": "Deep training signals",
|
||||
"description": d["notes"], "query": d["query"]}
|
||||
for d in signal_queries())
|
||||
|
||||
+13
-2
@@ -12,7 +12,12 @@ def expand(history, q, catalog):
|
||||
(spec["name"], expression_tree(spec["expression"], catalog)[0]) for spec in q["derived"]
|
||||
]
|
||||
for activity in history:
|
||||
for index, unit in enumerate((activity.analysis_summary or {}).get(q["source"], [])):
|
||||
source = q["source"]
|
||||
units = (activity.analysis_summary or {}).get(source, [])
|
||||
if source == "efforts":
|
||||
units = [{"distance": e["distance"], "duration": e["seconds"],
|
||||
"effort_time": e["seconds"]} for e in activity.best_efforts or []]
|
||||
for index, unit in enumerate(units):
|
||||
if (
|
||||
not isinstance(unit, dict)
|
||||
or not valid(unit.get("distance"))
|
||||
@@ -23,6 +28,8 @@ def expand(history, q, catalog):
|
||||
continue
|
||||
row = copy(activity)
|
||||
row.activity_id = activity.id
|
||||
row.parent_title = activity.title
|
||||
row.recording = unit
|
||||
row.id = activity.id * 1000000 + index
|
||||
row.title = (
|
||||
f"{activity.title} · {'lap' if q['source'] == 'laps' else 'segment'} {index + 1}"
|
||||
@@ -42,12 +49,16 @@ def expand(history, q, catalog):
|
||||
pace=row.duration_s * 1000 / row.distance_m,
|
||||
speed=row.distance_m / row.duration_s * 3.6,
|
||||
heart_rate=row.avg_hr,
|
||||
matched_minutes=row.duration_s / 60,
|
||||
matched_windows=1,
|
||||
elevation=row.elevation_m,
|
||||
elevation_per_km=row.elevation_m / (row.distance_m / 1000)
|
||||
if valid(row.elevation_m)
|
||||
else None,
|
||||
)
|
||||
for key in ("gap", "pace_variability", "heart_rate_drift", "sensor_coverage"):
|
||||
for key in catalog:
|
||||
if key in {"distance", "duration", "pace", "count", "speed", "heart_rate", "elevation", "elevation_per_km", "matched_minutes", "matched_windows"}:
|
||||
continue
|
||||
row.values[key] = unit.get(key)
|
||||
row.analysis_summary = row.values
|
||||
row.previous = None
|
||||
|
||||
+45
@@ -101,6 +101,19 @@ def run_connection(connection_id):
|
||||
count = import_cache(user, current_app.config["DATA_ROOT"])
|
||||
if result["failed"]:
|
||||
raise SyncError("Some activities failed; sync will retry.")
|
||||
db.session.commit()
|
||||
if connection.health_enabled:
|
||||
from .recovery import sync_health
|
||||
|
||||
try:
|
||||
health = sync_health(api, user)
|
||||
connection.health_message = health["message"]
|
||||
except Exception: # noqa: BLE001 -- optional provider data cannot block activities
|
||||
db.session.rollback()
|
||||
connection.health_message = "Daily recovery unavailable; will retry. Activity sync completed."
|
||||
finally:
|
||||
connection.tokens = cipher().encrypt(api.client.dumps().encode()).decode()
|
||||
db.session.commit()
|
||||
connection.last_success = now
|
||||
connection.failures = 0
|
||||
connection.message = f"Sync complete. {count} new activities imported."
|
||||
@@ -150,6 +163,38 @@ def run_due():
|
||||
|
||||
|
||||
def register_cli(app):
|
||||
@app.cli.command("backfill-health")
|
||||
@click.option("--email", required=True)
|
||||
@click.option("--days", default=90, type=click.IntRange(1, 365))
|
||||
def health_backfill(email, days):
|
||||
from .recovery import sync_health
|
||||
|
||||
logging.getLogger("garminconnect").disabled = True
|
||||
user = db.session.scalar(db.select(User).filter_by(email=email.strip().lower()))
|
||||
connection = db.session.scalar(db.select(GarminConnection).filter_by(
|
||||
user_id=user.id)) if user else None
|
||||
if not connection:
|
||||
raise click.ClickException("No connected account found.")
|
||||
try:
|
||||
with session_lock(user.id):
|
||||
api = Garmin()
|
||||
authenticated = False
|
||||
try:
|
||||
api.login(cipher().decrypt(connection.tokens.encode()).decode())
|
||||
authenticated = True
|
||||
result = sync_health(api, user, days, days)
|
||||
connection.health_enabled = True
|
||||
connection.health_message = result["message"]
|
||||
click.echo(result["message"])
|
||||
finally:
|
||||
if authenticated:
|
||||
connection.tokens = cipher().encrypt(api.client.dumps().encode()).decode()
|
||||
db.session.commit()
|
||||
except Timeout:
|
||||
raise click.ClickException("A sync is running; retry after it finishes.") from None
|
||||
except Exception: # noqa: BLE001 -- suppress provider credentials at the operator boundary
|
||||
raise click.ClickException("Daily recovery import failed; retry or reconnect.") from None
|
||||
|
||||
@app.cli.command("sync-worker")
|
||||
@click.option("--once", is_flag=True, help="Process currently due syncs and exit.")
|
||||
def worker(once):
|
||||
|
||||
@@ -18,15 +18,19 @@
|
||||
<summary>Build a comparable-effort investigation</summary>
|
||||
<form id="matched-effort-form">
|
||||
<div class="analysis-settings">
|
||||
<label>Compare inputs<select name="source"><option value="runs">Whole runs</option><option value="segments">Steady moving segments</option></select></label>
|
||||
<label>Compare inputs<select name="source"><option value="intervals">Matched continuous windows · one point per run</option><option value="runs">Whole runs</option></select></label>
|
||||
<label>Pace from · min/km<input name="pace_low" value="5:20" pattern="[0-9]+:[0-5][0-9]" required></label>
|
||||
<label>Pace to · min/km<input name="pace_high" value="5:40" pattern="[0-9]+:[0-5][0-9]" required></label>
|
||||
<label>Max ascent per km · m/km<input name="hills" type="number" min="0" max="1000" value="10" required></label>
|
||||
<label>Whole-run max ascent · m/km<input name="hills" type="number" min="0" max="1000" value="10" required></label>
|
||||
<label>Window max absolute grade · %<input name="grade" type="number" min="0" max="15" value="2" required></label>
|
||||
<label>Exclude first elapsed minutes<input name="warmup" type="number" min="0" max="60" value="10" required></label>
|
||||
<label>Matched minutes per run<input name="matched" type="number" min="1" max="180" value="3" required></label>
|
||||
<label>Current window · weeks<input name="weeks" type="number" min="1" max="104" value="12" required></label>
|
||||
<label>Earlier baseline · days<input name="baseline" type="number" min="7" max="732" value="90" required></label>
|
||||
<label>Minimum usable inputs per week<input name="minimum" type="number" min="2" max="1000" value="3" required></label>
|
||||
<label>Minimum independent runs per week<input name="minimum" type="number" min="2" max="1000" value="3" required></label>
|
||||
<label><input name="use_baseline" type="checkbox"> Add an earlier baseline and persistent weekly deviation rule</label>
|
||||
</div>
|
||||
<p class="field-help">Segments also require ≤5% speed variation, ≥80% moving HR coverage and ≥4 minutes. Matching pace and hills narrows the cohort; conditions can still differ.</p>
|
||||
<p class="field-help">Continuous windows require ≤15% speed variation, ≤2 percentage points of grade variation, ≥90% HR coverage and ≥25 seconds. Each qualifying run contributes one observation after matching. An earlier baseline needs at least ten independent runs; it may be unavailable for a short history.</p>
|
||||
<button class="button primary" type="submit">Explore matched heart rate</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{% if owner_view %}<section class="panel settings-card"><span class="eyebrow">PRIVATE SESSION CONTEXT</span><h2>How did this run feel?</h2>
|
||||
<form method="post" action="{{ url_for('training.context', activity_id=activity.id) }}" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<label>Perceived effort (1–10)<input name="perceived_effort" type="number" min="1" max="10" value="{{ activity.session_context.get('perceived_effort', '') }}"></label>
|
||||
<label>Fatigue before the run (0–10)<input name="fatigue" type="number" min="0" max="10" value="{{ activity.session_context.get('fatigue', '') }}"></label>
|
||||
<label>Notes<textarea name="notes" maxlength="2000" placeholder="Sleep, weather, soreness, interruptions, session intent…">{{ activity.session_context.get('notes', '') }}</textarea></label>
|
||||
<p class="field-help">These observations stay private when you share a run. Numeric observations are available in Stats.</p><button class="button primary">Save session context</button></form></section>{% endif %}
|
||||
@@ -0,0 +1,8 @@
|
||||
<section class="panel settings-card"><span class="eyebrow">EFFORT DISTRIBUTION</span><h2>Your heart-rate bands</h2>
|
||||
<p class="field-help">Set two boundaries for your lower, middle and upper effort bands. Use your own chosen thresholds; these labels do not infer physiological zones.</p>
|
||||
<form method="post" action="{{ url_for('training.bands') }}" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<label>Lower / middle boundary (bpm)<input name="lower" type="number" min="30" max="239" value="{{ current_user.hr_bands[0] if current_user.hr_bands else '' }}"></label>
|
||||
<label>Middle / upper boundary (bpm)<input name="upper" type="number" min="31" max="240" value="{{ current_user.hr_bands[1] if current_user.hr_bands else '' }}"></label>
|
||||
<p class="field-help">Recorded HR at a boundary belongs to the higher band. Clear both fields to remove your bands. Stats exposes actual recorded minutes in each band.</p><button class="button primary">Save bands</button></form></section>
|
||||
{% if connection %}<section class="panel settings-card"><span class="eyebrow">DAILY RECOVERY</span><h2>Recovery observations</h2><p>Import private resting HR, overnight HRV, sleep duration and daily stress from Garmin.</p><p class="field-help">{{ connection.health_message }} Historical observations fill gradually during hourly sync. Missing watch observations remain missing.</p>
|
||||
<form method="post" action="{{ url_for('web.garmin_action', action='health') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input type="hidden" name="enabled" value="{{ 'no' if connection.health_enabled else 'yes' }}"><button class="button secondary">{{ 'Pause' if connection.health_enabled else 'Enable' }} daily recovery sync</button></form></section>{% endif %}
|
||||
@@ -48,5 +48,6 @@
|
||||
<div class="detail-bottom" id="splits"><section class="panel"><div class="section-head"><h2>Laps & splits</h2><span class="subtle-label">{{ analysis.laps|length }} recorded laps</span></div><div class="activity-table-wrap"><table class="splits-table"><thead><tr><th>LAP</th><th>DISTANCE</th><th>TIME</th><th>PACE</th><th>GAP EST.</th><th>HR</th></tr></thead><tbody>{% for lap in analysis.laps %}<tr><td>{{ loop.index }}</td><td>{{ '%.2f'|format(lap.distance / 1000) }} km</td><td>{{ lap.duration|duration }}</td><td>{{ pace_text(lap.pace) }}</td><td>{{ pace_text(lap.gap) }}</td><td>{{ lap.heart_rate|int if lap.heart_rate else '—' }}</td></tr>{% else %}<tr><td colspan="6">No laps recorded.</td></tr>{% endfor %}</tbody></table></div></section>
|
||||
{% if owner_view %}<section class="panel publishing-panel"><span class="eyebrow">YOUR RUN, YOUR CHOICE</span><h2>Sharing settings</h2><form method="post" action="{{ url_for('web.edit_activity',activity_id=activity.id) }}" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label>Activity title<input name="title" value="{{ activity.title }}" maxlength="160" required></label><label>Who can see this activity in Stride?<select name="visibility"><option value="private" {{ 'selected' if activity.visibility == 'private' }}>Only me</option><option value="friends" {{ 'selected' if activity.visibility == 'friends' }}>Accepted friends</option></select></label><p class="field-help">Accepted friends see the full route and measurements of shared activities. Public link access is separate: anyone with a copied link can view this run, including its full route and measurements, until you disable the link.</p><button class="button primary">Save changes {{ icon('arrow') }}</button></form><form id="revoke-share" method="post" action="{{ url_for('web.revoke_activity_share',activity_id=activity.id) }}" {{ 'hidden' if not activity.share_token }}><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button secondary">Disable public link</button></form></section>{% endif %}</div>
|
||||
<details class="panel methodology" id="method"><summary>About these numbers <span>Recorded data vs. estimates +</span></summary><div><p><strong>GAP is an estimate, not a Garmin or Strava value.</strong> We use the <a href="https://doi.org/10.1152/japplphysiol.01177.2001" target="_blank" rel="noopener noreferrer">Minetti running energy-cost model</a> to estimate an equivalent flat pace from the recorded slope. Elevation uses a five-sample median and a roughly 60 m distance window. Grades outside ±30%, missing data, and recording gaps over 10 seconds are excluded. GAP is only calculated for running.</p><p>Average GAP divides time by equivalent flat distance over eligible moving samples. Coverage indicates how much recorded distance contributed; lap GAP uses the same method. Stops and missing data can make its sample coverage differ from timer pace. Wind, surface, heat, fatigue and individual running economy are not modelled.</p><p><strong>Heart rate stays measured.</strong> A universal grade correction would imply precision we don't have. Terrain summaries show time-weighted recorded HR, without trying to remove the effect of hills. Cadence is converted from running cycles/min to steps/min. Missing measurements are shown as unavailable, never zero-filled.</p><p>Map colours use this activity’s 10th–90th percentile range, with extremes saturated. Direction arrows follow recording order; they are not navigation instructions. Routes are simplified for display, preserving segment endpoints and GPS gaps. Map measurements and chart traces are sampled for display; computations use all available records.</p></div></details>
|
||||
{% include '_session_context.html' %}
|
||||
{% endblock %}
|
||||
{% block scripts %}<script src="{{ url_for('static',filename='vendor/leaflet.js') }}"></script><script src="{{ url_for('static',filename='vendor/chart.umd.js') }}"></script><script src="{{ url_for('static',filename='activity.js') }}"></script>{% endblock %}
|
||||
|
||||
@@ -16,4 +16,5 @@
|
||||
<form method="post" enctype="multipart/form-data" action="{{ url_for('web.garmin_action', action='connect') }}" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label>Garmin session<input type="file" name="tokens" accept=".json" required></label><button class="button primary">Connect Garmin</button></form>
|
||||
<p class="field-help">Disconnect deletes the server session and stops scheduled sync. It keeps imported activities and does not revoke other session copies.</p>
|
||||
</div></section></div>
|
||||
<div class="settings-grid">{% include '_training_settings.html' %}</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% block title %}Stats studio · Stride{% endblock %}
|
||||
{% block head %}<link rel="stylesheet" href="{{ url_for('static', filename='analysis.css') }}"><link rel="stylesheet" href="{{ url_for('static', filename='vendor/codemirror.css') }}"><link rel="stylesheet" href="{{ url_for('static', filename='vendor/show-hint.css') }}"><link rel="stylesheet" href="{{ url_for('static', filename='stats.css') }}">{% endblock %}
|
||||
{% block content %}
|
||||
<div id="stats-studio" data-owner="{{ current_user.id }}" data-source="{{ query.source }}" data-show-previous="{{ 'true' if results and results.show_previous else 'false' }}" data-query="{{ query_text }}" data-csrf="{{ csrf_token() }}" data-validate-url="{{ url_for('web.stats_validate') }}">
|
||||
<div id="stats-studio" data-owner="{{ current_user.id }}" data-source="{{ results.source if results else query.source }}" data-show-previous="{{ 'true' if results and results.show_previous else 'false' }}" data-query="{{ query_text }}" data-csrf="{{ csrf_token() }}" data-validate-url="{{ url_for('web.stats_validate') }}">
|
||||
<div class="page-heading"><div><span class="eyebrow">YOUR RUNNING / STATS STUDIO</span><h1>{{ selected_analysis.name if selected_analysis else "Your runs. Your questions." }}</h1><p>Write a query, explore the pattern, and inspect the runs behind it.</p></div><div class="query-actions"><button type="button" class="button secondary" id="save-view">Save analysis</button><button type="button" class="button primary" id="pin-analysis">Pin live analysis</button></div></div>
|
||||
{% include '_analysis_workspace.html' %}
|
||||
<script id="query-language" type="application/json">{{ language|tojson }}</script>
|
||||
@@ -11,7 +11,7 @@
|
||||
<div id="query-editor"><label class="sr-only" for="query-input">Running stats query</label><textarea id="query-input" name="query" spellcheck="false" maxlength="4000" rows="9">{{ submitted_query if submitted_query else query_text }}</textarea></div>
|
||||
<div class="studio-row query-state"><span id="query-validation" role="status">Checking query...</span><span class="query-shortcut"><kbd>Ctrl / Cmd Enter</kbd> to run</span></div>
|
||||
<div id="query-diagnostics" aria-live="polite">{% if error %}<p class="studio-error" role="alert">{{ error }}</p>{% endif %}</div>
|
||||
<div class="studio-row studio-bottom"><span class="field-help">{{ 'Whole-run metrics' if query.source == 'runs' else 'Recorded laps' if query.source == 'laps' else 'Five-minute moving segments' }} · {{ current_user.timezone }}<br>Lower pace values mean faster running.</span><button class="button primary" id="run-query" type="submit">Run query {{ icon('arrow') }}</button></div>
|
||||
<div class="studio-row studio-bottom"><span class="field-help">{{ query.source|capitalize }} · {{ current_user.timezone }}<br>Lower pace values mean faster running.</span><button class="button primary" id="run-query" type="submit">Run query {{ icon('arrow') }}</button></div>
|
||||
<details class="query-help"><summary>Language reference & examples</summary><div class="query-reference"><div><h3>Filter and compare</h3><pre>during last 6 months
|
||||
where distance between 8 and 20 km
|
||||
and pace <= 5:30 /km
|
||||
@@ -29,7 +29,7 @@ size by distance
|
||||
baseline previous 90 days
|
||||
flag above baseline 1.5 sd min 3 runs sustained 2 buckets
|
||||
let hr_14d = rolling_mean(heart_rate, 14 days)
|
||||
summarize mean(heart_rate) as "Matched HR"</pre><p class="field-help">Sources: runs, laps, segments. Charts: auto, line, bar, area, histogram, box, heatmap, stacked, none. Use display overlay to combine series. Functions: sum, mean, weighted_mean, median, min, max, count, population stddev, and p10/p25/p75/p90/p95/p99. take limits ranked runs before grouping; limit limits groups. Trends also accept cumulative, change and percent_change. Arithmetic uses km, hours and seconds/km. Missing inputs and zero divisors give missing values.</p></div><div><h3>Metrics · select up to six</h3><div class="metric-reference">{% for key, info in metrics.items() %}<button type="button" class="metric-insert" data-metric="{{ key }}" title="{{ info[2] }}"><code>{{ key }}</code><span>{{ info[1] }}</span></button>{% endfor %}</div><p class="field-help">duration accepts h, min or s; distance accepts km or m. Use has / missing to select recorded sensor coverage.</p></div></div></details>
|
||||
summarize mean(heart_rate) as "Matched HR"</pre><p class="field-help">Sources: runs, laps, segments, intervals, efforts, days, health. Charts: auto, line, bar, area, histogram, box, heatmap, stacked, none. Use display overlay to combine series. Functions: sum, mean, weighted_mean, median, min, max, count, population stddev, and p10/p25/p75/p90/p95/p99. take limits ranked runs before grouping; limit limits groups. Trends also accept cumulative, change and percent_change. Arithmetic uses km, hours and seconds/km. Missing inputs and zero divisors give missing values.</p></div><div><h3>Metrics · select up to six</h3><div class="metric-reference">{% for key, info in metrics.items() %}<button type="button" class="metric-insert" data-metric="{{ key }}" title="{{ info[2] }}"><code>{{ key }}</code><span>{{ info[1] }}</span></button>{% endfor %}</div><p class="field-help">duration accepts h, min or s; distance accepts km or m. Use has / missing to select recorded sensor coverage.</p></div></div></details>
|
||||
<details id="guided-controls" class="query-help"><summary>Guided controls</summary><p class="field-help">Adjust the basic clauses without removing additional metrics and filters.</p>
|
||||
<div class="studio-sentence">Show <label class="sr-only" for="metric">Primary metric</label><select id="metric" name="metric">{% if query.metric not in metrics %}<option value="{{ query.metric }}" selected>{{ query.metric }}</option>{% endif %}{% for key, info in metrics.items() %}<option value="{{ key }}" {{ 'selected' if query.metric == key }}>{{ info[0]|lower }}</option>{% endfor %}</select> and <label class="sr-only" for="secondary">Second metric</label><select id="secondary" name="secondary">{% if query.secondary not in metrics and query.secondary != 'none' %}<option value="{{ query.secondary }}" selected>{{ query.secondary }}</option>{% endif %}<option value="none" {{ 'selected' if query.secondary == 'none' }}>no second metric</option>{% for key, info in metrics.items() %}<option value="{{ key }}" {{ 'selected' if query.secondary == key }}>{{ info[0]|lower }}</option>{% endfor %}</select><br>
|
||||
{% if query.all_time or query.start or query.days or query.months %}Change the period to the last{% else %}from runs during the last{% endif %} <label class="sr-only" for="weeks">Number of weeks</label><input id="weeks" name="weeks" type="number" min="1" max="104" value="{{ query.weeks }}" required> weeks, grouped by <label class="sr-only" for="group">Grouping</label><select id="group" name="group">{% if query.group == 'bin' %}<option value="bin" selected disabled>{{ query.bin.field }} bins</option>{% endif %}{% for grouping in language.groups %}<option value="{{ grouping }}" {{ 'selected' if query.group == grouping }}>{{ grouping }}</option>{% endfor %}</select>.</div>
|
||||
@@ -44,17 +44,17 @@ summarize mean(heart_rate) as "Matched HR"</pre><p class="field-help">Sources: r
|
||||
</details>
|
||||
</form>
|
||||
{% if results %}
|
||||
<div class="studio-row result-heading"><div><h2>{{ results.count }} matching {{ query.source }}</h2><p>{{ results.start }} – {{ results.end }} · {{ '%.1f'|format(results.distance) }} km{% if query.compare %}<br>Compared with {{ results.previous_start }} – {{ results.previous_end }} · {{ results.prior_count }} runs{% endif %}</p></div></div>
|
||||
<div class="studio-row result-heading"><div><h2>{{ results.count }} matching {{ results.source }}</h2><p>{{ results.start }} – {{ results.end }} · {{ '%.1f'|format(results.distance) }} km{% if query.compare %}<br>Compared with {{ results.previous_start }} – {{ results.previous_end }} · {{ results.prior_count }} runs{% endif %}</p></div></div>
|
||||
{% if not results.count %}<div class="panel studio-empty"><h2>No runs match this question.</h2><p>Try a longer period or widen the distance and heart rate filters.</p></div>{% else %}
|
||||
<div class="studio-metrics">{% for card in results.cards %}<article class="stat-card"><span class="eyebrow">{{ card.label }}</span><div class="stat-value">{{ card.value }}<small>{{ card.unit }}</small></div>{% if query.compare %}<p>Previous: {{ card.previous }} {{ card.unit }}{% if card.delta %} · {{ card.delta }} {{ 'sec/km' if card.unit == '/km' else card.unit }}{% endif %}</p>{% endif %}<p>{{ card.coverage }} of {{ results.count }} {{ query.source }} with usable data</p><details><summary>How this is calculated</summary><p>{{ card.definition }}{% if card.key in ('cadence','power','ground_contact','stride_length') %} Coverage counts runs, not sensor recording time.{% endif %}</p></details></article>{% endfor %}</div>
|
||||
{% if results.coverage %}<p class="field-help">Evidence: {{ results.coverage.sessions }} independent running sessions · {{ results.coverage.minutes }} recorded moving/timer minutes. Daily recovery observations are separate from running-session coverage.</p>{% endif %}<div class="studio-metrics">{% for card in results.cards %}<article class="stat-card"><span class="eyebrow">{{ card.label }}</span><div class="stat-value">{{ card.value }}<small>{{ card.unit }}</small></div>{% if query.compare %}<p>Previous: {{ card.previous }} {{ card.unit }}{% if card.delta %} · {{ card.delta }} {{ 'sec/km' if card.unit == '/km' else card.unit }}{% endif %}</p>{% endif %}<p>{{ card.coverage }} of {{ results.count }} {{ results.source }} with usable data</p><details><summary>How this is calculated</summary><p>{{ card.definition }}{% if card.key in ('cadence','power','ground_contact','stride_length') %} Coverage counts runs, not sensor recording time.{% endif %}</p></details></article>{% endfor %}</div>
|
||||
<section class="panel analysis-results"><div class="section-head"><div><span class="eyebrow">EXPLORE / VISUALIZE</span><h2>Your question, visualized</h2></div><div class="query-actions"><label>Chart <select data-presentation="chart" aria-label="Chart type">{% for type in ('auto','line','bar','area','histogram','box','heatmap','stacked','none') %}<option value="{{ type }}" {{ 'selected' if query.chart == type }}>{{ type|capitalize }}</option>{% endfor %}</select></label><label>Series <select data-presentation="display" aria-label="Series layout"><option value="separate" {{ 'selected' if query.display == 'separate' }}>Separate</option><option value="overlay" {{ 'selected' if query.display == 'overlay' }}>Overlay</option></select></label></div></div><div id="analysis-plots" class="analysis-plots"></div><p class="studio-chart-help">Select a point, box or calendar day to inspect contributing runs. Distribution charts show individual run inputs. Solid: current; dashed: previous. {% if query.trend %}Trend: {{ query.trend }}; cards and tables retain untransformed values.{% endif %}</p></section>
|
||||
<section id="analysis-insights" class="analysis-insights" aria-label="Baseline and deviations"></section>
|
||||
{% endif %}
|
||||
<section class="panel studio-groups"><div class="section-head"><div><span class="eyebrow">ANALYSIS / GROUPED RESULTS</span><h2>{{ query.group|capitalize }} breakdown</h2></div><a class="button secondary" href="{{ url_for('web.stats_export',query=query_text,kind='groups') }}">Export CSV</a></div><div class="activity-table-wrap"><table class="activity-table"><thead><tr><th>GROUP</th><th>{{ query.source|upper }}</th>{% for card in results.cards %}<th>{{ card.label|upper }} · {{ card.unit }}</th>{% endfor %}</tr></thead><tbody>{% for group in results.groups %}<tr><td><button type="button" class="group-drill" data-bucket="{{ group.label }}">{{ group.label }}</button></td><td>{{ group.count }}</td>{% for value in group['values'] %}<td>{{ value }}</td>{% endfor %}</tr>{% endfor %}</tbody></table></div></section>
|
||||
<section class="panel studio-evidence" id="studio-evidence"><div class="section-head"><div><span class="eyebrow">BEHIND THE NUMBERS</span><h2 id="evidence-heading">Contributing {{ query.source }}</h2></div><div class="studio-row"><a class="button secondary" href="{{ url_for('web.stats_export',query=query_text,kind='runs') }}">Export runs</a><label class="studio-period" for="evidence-period">Period <select id="evidence-period"><option value="">All results</option>{% for label in results.chart.labels + results.chart.drill_labels %}<option>{{ label }}</option>{% endfor %}</select></label><button type="button" class="text-button" id="clear-selection" hidden>Show all results</button></div></div><div class="activity-table-wrap"><table class="activity-table"><thead><tr><th>{{ query.source|upper }} / LOCAL DATE</th><th>DISTANCE</th>{% for card in results.evidence_columns %}<th>{{ card.label|upper }} · {{ card.unit }}</th>{% endfor %}</tr></thead><tbody id="evidence-body">{% for row in results.rows %}<tr><td><a href="{{ url_for('web.activity',activity_id=row.id) }}"><strong>{{ row.title }}</strong><br><small>{{ row.date }}</small></a>{% if results.show_previous %}<small class="previous-run">Previous: {% if row.previous_run %}<a href="{{ url_for('web.activity',activity_id=row.previous_run.id) }}">{{ row.previous_run.date }} · {{ row.previous_run.title }}</a>{% else %}No earlier recorded run{% endif %}</small>{% endif %}</td><td>{{ row.distance }} km</td>{% for value in row['values'] %}<td>{{ value }}</td>{% endfor %}</tr>{% else %}<tr><td colspan="{{ results.cards|length + 2 }}">No matching runs.</td></tr>{% endfor %}</tbody></table></div><div class="studio-pagination"><span>Page {{ results.page }} of {{ results.pages }} · up to 25 {{ query.source }} per page</span>{% if results.page > 1 %}<a class="button secondary" href="{{ url_for('web.stats',query=query_text,page=results.page-1) }}">Previous</a>{% endif %}{% if results.page < results.pages %}<a class="button secondary" href="{{ url_for('web.stats',query=query_text,page=results.page+1) }}">Next</a>{% endif %}</div></section>
|
||||
<section class="panel studio-groups"><div class="section-head"><div><span class="eyebrow">ANALYSIS / GROUPED RESULTS</span><h2>{{ query.group|capitalize }} breakdown</h2></div><a class="button secondary" href="{{ url_for('web.stats_export',query=query_text,kind='groups') }}">Export CSV</a></div><div class="activity-table-wrap"><table class="activity-table"><thead><tr><th>GROUP</th><th>{{ results.source|upper }}</th>{% for card in results.cards %}<th>{{ card.label|upper }} · {{ card.unit }}</th>{% endfor %}</tr></thead><tbody>{% for group in results.groups %}<tr><td><button type="button" class="group-drill" data-bucket="{{ group.label }}">{{ group.label }}</button></td><td>{{ group.count }}</td>{% for value in group['values'] %}<td>{{ value }}</td>{% endfor %}</tr>{% endfor %}</tbody></table></div></section>
|
||||
<section class="panel studio-evidence" id="studio-evidence"><div class="section-head"><div><span class="eyebrow">BEHIND THE NUMBERS</span><h2 id="evidence-heading">Contributing {{ results.source }}</h2></div><div class="studio-row"><a class="button secondary" href="{{ url_for('web.stats_export',query=query_text,kind='runs') }}">Export inputs</a><label class="studio-period" for="evidence-period">Period <select id="evidence-period"><option value="">All results</option>{% for label in results.chart.labels + results.chart.drill_labels %}<option>{{ label }}</option>{% endfor %}</select></label><button type="button" class="text-button" id="clear-selection" hidden>Show all results</button></div></div><div class="activity-table-wrap"><table class="activity-table"><thead><tr><th>{{ results.source|upper }} / LOCAL DATE</th><th>DISTANCE</th>{% for card in results.evidence_columns %}<th>{{ card.label|upper }} · {{ card.unit }}</th>{% endfor %}</tr></thead><tbody id="evidence-body">{% for row in results.rows %}<tr><td>{% if row.id %}<a href="{{ url_for('web.activity',activity_id=row.id) }}"><strong>{{ row.title }}</strong><br><small>{{ row.date }}</small></a>{% else %}<strong>{{ row.title }}</strong><br><small>{{ row.date }}</small>{% for id in row.activity_ids %}<a href="{{ url_for('web.activity', activity_id=id) }}"> · Run #{{ id }}</a>{% endfor %}{% endif %}{% if results.show_previous %}<small class="previous-run">Previous: {% if row.previous_run %}<a href="{{ url_for('web.activity',activity_id=row.previous_run.id) }}">{{ row.previous_run.date }} · {{ row.previous_run.title }}</a>{% else %}No earlier recorded run{% endif %}</small>{% endif %}</td><td>{{ row.distance }} km</td>{% for value in row['values'] %}<td>{{ value }}</td>{% endfor %}</tr>{% else %}<tr><td colspan="{{ results.cards|length + 2 }}">No matching runs.</td></tr>{% endfor %}</tbody></table></div><div class="studio-pagination"><span>Page {{ results.page }} of {{ results.pages }} · up to 25 {{ query.source }} per page</span>{% if results.page > 1 %}<a class="button secondary" href="{{ url_for('web.stats',query=query_text,page=results.page-1) }}">Previous</a>{% endif %}{% if results.page < results.pages %}<a class="button secondary" href="{{ url_for('web.stats',query=query_text,page=results.page+1) }}">Next</a>{% endif %}</div></section>
|
||||
<script id="stats-data" type="application/json">{{ results.chart|tojson }}</script><script id="insights-data" type="application/json">{{ results.insights|tojson }}</script>
|
||||
{% endif %}
|
||||
<p class="analysis-semantics field-help">Sources: runs, recorded laps, or five-minute moving segments. Segments exclude recording gaps and pauses; partial segments remain queryable. Gaps, rolling metrics and streaks use complete run history before filters. Arithmetic and filters apply per run; summaries apply per group. Contributing runs show the individual input values for each summary. Empty sums remain missing; count is zero. Group ranking/limits and having select the displayed cohort before chart trends.</p><div id="studio-status" role="status" class="field-help"></div>
|
||||
<p class="analysis-semantics field-help">Sources include runs, laps, moving segments, continuous windows, best efforts and daily training/recovery observations. Filters select inputs before optional per-run reduction; summaries then apply per group. Recording windows from the same session are dependent. Daily zeros mean no recorded running, rather than no other training; missing recovery and sensor observations stay missing. Run-history calculations use complete history before filters. Empty sums remain missing; count is zero.</p><div id="studio-status" role="status" class="field-help"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}<script src="{{ url_for('static',filename='vendor/codemirror.js') }}"></script><script src="{{ url_for('static',filename='vendor/show-hint.js') }}"></script><script src="{{ url_for('static',filename='stats-editor.js') }}"></script><script src="{{ url_for('static',filename='vendor/chart.umd.js') }}"></script><script src="{{ url_for('static',filename='analysis-charts.js') }}"></script><script src="{{ url_for('static',filename='stats.js') }}"></script><script src="{{ url_for('static',filename='analysis-library.js') }}"></script>{% endblock %}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Private training preferences and session observations."""
|
||||
|
||||
from flask import Blueprint, abort, flash, redirect, request, url_for
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
from .extensions import db
|
||||
from .models import Activity
|
||||
|
||||
training = Blueprint("training", __name__)
|
||||
|
||||
|
||||
@training.post("/training/bands")
|
||||
@login_required
|
||||
def bands():
|
||||
raw = [request.form.get("lower", "").strip(), request.form.get("upper", "").strip()]
|
||||
if not any(raw):
|
||||
current_user.hr_bands = None
|
||||
else:
|
||||
try:
|
||||
low, high = [int(v) for v in raw]
|
||||
except ValueError:
|
||||
abort(400)
|
||||
if not 30 <= low < high <= 240:
|
||||
abort(400)
|
||||
current_user.hr_bands = [low, high]
|
||||
db.session.commit()
|
||||
flash("Personal heart-rate bands saved.")
|
||||
return redirect(url_for("web.settings"))
|
||||
|
||||
|
||||
@training.post("/activities/<int:activity_id>/context")
|
||||
@login_required
|
||||
def context(activity_id):
|
||||
row = db.first_or_404(db.select(Activity).where(Activity.id == activity_id,
|
||||
Activity.owner_id == current_user.id))
|
||||
values = {}
|
||||
for field, low in (("perceived_effort", 1), ("fatigue", 0)):
|
||||
raw = request.form.get(field, "").strip()
|
||||
if raw:
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError:
|
||||
abort(400)
|
||||
if not low <= value <= 10:
|
||||
abort(400)
|
||||
values[field] = value
|
||||
notes = request.form.get("notes", "").strip()
|
||||
if len(notes) > 2000:
|
||||
abort(400)
|
||||
values["notes"] = notes
|
||||
row.session_context = values
|
||||
db.session.commit()
|
||||
flash("Private session context saved.")
|
||||
return redirect(url_for("web.activity", activity_id=row.id))
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Explicit units for recording, performance and daily recovery observations."""
|
||||
|
||||
METRICS = {}
|
||||
UNITS = {}
|
||||
|
||||
|
||||
def metric(key, label, unit, definition, conversions=None):
|
||||
METRICS[key] = (label, unit, definition)
|
||||
UNITS[key] = conversions or {unit: 1}
|
||||
|
||||
|
||||
for key, label in (("elapsed", "Elapsed recording offset"),
|
||||
("moving_offset", "Moving-time offset"),
|
||||
("matched_minutes", "Matched moving minutes")):
|
||||
metric(key, label, "min", "Minutes since recording start, or usable matched moving time.",
|
||||
{"min": 1, "s": 1 / 60, "h": 60})
|
||||
for key, label in (("grade", "Smoothed grade"), ("grade_variability", "Grade variation"),
|
||||
("terrain_coverage", "Terrain coverage"),
|
||||
("joint_coverage", "Joint HR and terrain coverage"),
|
||||
("hr_artifact_percent", "Suspect HR time"),
|
||||
("long_run_share", "Largest run share of 7-day volume")):
|
||||
metric(key, label, "%", "Recording-derived percentage; missing observations stay missing.")
|
||||
metric("matched_windows", "Matched windows", "windows", "Usable continuous recording windows.")
|
||||
metric("sessions", "Sessions", "runs", "Number of recorded running sessions on this local day.")
|
||||
metric("days_since_run", "Days since running", "days", "Local days since the latest recorded run.")
|
||||
metric("days_since_long_run", "Days since long run", "days",
|
||||
"Local days since a recorded run meeting the query's long-run distance threshold.")
|
||||
metric("effort_time", "Best effort time", "time", "Elapsed recorded best-effort window, in seconds.",
|
||||
{"s": 1, "min": 60})
|
||||
for km in (1, 2, 5, 10):
|
||||
metric(f"best_{km}km", f"Best {km} km", "time",
|
||||
"Fastest elapsed window within this run, not a standardized race result.",
|
||||
{"s": 1, "min": 60})
|
||||
for key, label, unit in (("resting_hr", "Resting HR", "bpm"),
|
||||
("hrv", "Overnight HRV", "ms"),
|
||||
("sleep_hours", "Sleep", "h"),
|
||||
("stress", "Daily stress", "score"),
|
||||
("perceived_effort", "Perceived effort", "score"),
|
||||
("fatigue", "Reported fatigue", "score")):
|
||||
metric(key, label, unit, "Private recorded daily observation or owner-entered session context; no diagnosis.")
|
||||
for sensor, unit in (("cadence", "spm"), ("power", "W"), ("ground_contact", "ms"),
|
||||
("stride_length", "m"), ("vertical_oscillation", "cm")):
|
||||
metric(sensor + "_coverage", sensor.replace("_", " ").title() + " coverage", "%",
|
||||
"Percent of this continuous moving window with a usable positive sensor measurement.")
|
||||
for band in ("lower", "middle", "upper"):
|
||||
metric(band + "_hr_minutes", band.title() + " HR minutes", "min",
|
||||
"Time in individually configured HR bands; unavailable until thresholds are set.")
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Reviewable, editable training analyses; no inferred medical or HR-zone thresholds."""
|
||||
|
||||
import click
|
||||
|
||||
from .extensions import db
|
||||
from .models import Activity, SavedAnalysis, User
|
||||
from .stats import explore, parse_query, query_text
|
||||
|
||||
|
||||
def signal_queries(pace_low="6:30", pace_high="7:00", long_run=10, route_id=None):
|
||||
match = (f"where pace between {pace_low} and {pace_high} /km\n"
|
||||
"and elapsed >= 10 min\nand duration >= 25 s\n"
|
||||
"and grade between -2 and 2 %\nand grade_variability <= 2 %\n"
|
||||
"and pace_variability <= 15 %\nand sensor_coverage >= 90 %\n"
|
||||
"and hr_artifact_percent <= 0 %")
|
||||
common = "during last 16 weeks\n"
|
||||
definitions = [
|
||||
("HR at matched flat effort", "intervals\n" + common +
|
||||
"reduce by run min 3 min\n" + match +
|
||||
'\nsummarize mean(heart_rate) as "Matched HR"\nby run\nsort by date asc\ncompare none\nchart line',
|
||||
"full", "After ten elapsed minutes; matched continuous windows with flat, consistent terrain and speed. At least three matched minutes per session. Each run contributes one observation. HR lag, weather and session intent remain possible explanations; this is not a diagnosis."),
|
||||
("Training rhythm", "runs\n" + common +
|
||||
'summarize sum(distance) as "Weekly km", count() as "Sessions"\nby week\ncompare none\nchart bar\ndisplay overlay',
|
||||
"half", "Weeks start Monday. The current week is incomplete. Recorded running only; other training or unimported runs may explain gaps."),
|
||||
("Performance ladder", "runs\n" + common +
|
||||
'summarize min(best_5km) as "Best 5 km", min(best_10km) as "Best 10 km"\nby month\ncompare none\nchart line\ndisplay overlay',
|
||||
"half", "Fastest recorded elapsed windows inside each month's activities. Missing distances stay missing. These are not standardized races or terrain-matched performances."),
|
||||
("Long-run continuity", "days\n" + common +
|
||||
f"long run distance >= {long_run:g} km\n" +
|
||||
'summarize latest(days_since_long_run) as "Days since long run", latest(rolling_28d_distance) as "28-day km"\nby day\ncompare none\nchart line\ndisplay overlay',
|
||||
"half", f"Long run means at least {long_run:g} km; edit this threshold. Daily values advance on rest days. No earlier qualifying run is missing rather than a fabricated gap. The 28-day window includes today and the preceding 27 local days."),
|
||||
("Endurance fade", "intervals\n" + common +
|
||||
"reduce by run min 20 min\nwhere elapsed >= 10 min\nand duration >= 25 s\n"
|
||||
"and grade between -2 and 2 %\nand grade_variability <= 2 %\n"
|
||||
"and pace_variability <= 15 %\nand sensor_coverage >= 90 %\n"
|
||||
"and hr_artifact_percent <= 0 %\n"
|
||||
'summarize heart_rate_drift as "Post-warm-up decoupling"\nby run\nsort by date asc\ncompare none\nchart line\nreference heart_rate_drift = 0 %',
|
||||
"half", "Matched moving-time halves after warm-up, with at least ten minutes of usable HR and terrain in each half. Hidden when halves differ more than 10% in speed or one percentage point in mean grade. Filtered windows need not be adjacent; the result remains descriptive."),
|
||||
("Daily workload and consistency", "days\n" + common +
|
||||
'summarize latest(rolling_7d_distance), latest(rolling_28d_distance), latest(run_day_streak)\nby day\ncompare none\nchart line\ndisplay overlay',
|
||||
"half", "Daily workload includes rest days, uses local calendar windows and includes all recorded runs before filters. Streak returns to zero on a rest day."),
|
||||
("Longest run each week", "runs\n" + common +
|
||||
'summarize max(distance), count()\nby week\ncompare none\nchart bar',
|
||||
"half", "Weekly longest recorded distance and session count; inspect the full week's volume for concentration."),
|
||||
("Short-distance performance", "runs\n" + common +
|
||||
'summarize min(best_1km), min(best_2km)\nby month\ncompare none\nchart line\ndisplay overlay',
|
||||
"half", "Fastest recorded 1 km and 2 km elapsed windows; effort and terrain may differ."),
|
||||
("Recovery context", "health\n" + common +
|
||||
'summarize resting_hr, hrv, sleep_hours, stress\nby day\ncompare none\nchart line',
|
||||
"full", "Private Garmin daily observations, without interpolation or a composite readiness score. Empty plots mean the watch/provider has no usable observations. Configure recovery sync in Settings."),
|
||||
("Personal HR-band minutes", "runs\n" + common +
|
||||
'summarize sum(lower_hr_minutes), sum(middle_hr_minutes), sum(upper_hr_minutes)\nby week\ncompare none\nchart bar\ndisplay overlay',
|
||||
"full", "Actual recorded moving seconds in your own HR bands, excluding suspect HR intervals. Set both boundaries in Settings; no thresholds are assumed."),
|
||||
("Mechanics at matched effort", "intervals\n" + common +
|
||||
"reduce by run min 3 min\n" + match +
|
||||
'\nand cadence_coverage >= 80 %\nand ground_contact_coverage >= 80 %\n'
|
||||
'summarize mean(cadence), mean(ground_contact), mean(stride_length)\nby run\nsort by date asc\ncompare none\nchart line',
|
||||
"full", "Recording-level mechanics matched to the same flat pace band after warm-up. Missing measurements stay missing. Differences are observations, not technique prescriptions."),
|
||||
("Recording evidence quality", "intervals\n" + common +
|
||||
'summarize mean(sensor_coverage), mean(terrain_coverage), mean(hr_artifact_percent)\nby week\ncompare none\nchart line\ndisplay overlay',
|
||||
"full", "Coverage and coarse HR jump/range checks across continuous windows. These checks cannot detect every sensor artifact, including cadence lock."),
|
||||
]
|
||||
if route_id:
|
||||
definitions.append(("Repeated-route benchmark", "runs\n" + common +
|
||||
f"route similar to {route_id}\n" +
|
||||
'summarize pace, heart_rate\nby run\nsort by date asc\ncompare none\nchart line',
|
||||
"full", "Approximate full-course matching against the reference run: same start/direction/end, length within 10%, mean course separation at most 100 m and 90th-percentile separation at most 200 m. Recording breaks are excluded. Inspect contributing routes before interpreting a trend."))
|
||||
return [{"name": name, "query": source, "width": width, "notes": notes,
|
||||
"pinned": i < 5, "view": "chart", "position": i,
|
||||
"collection": "Training signals"} for i, (name, source, width, notes) in enumerate(definitions)]
|
||||
|
||||
|
||||
def install(user, definitions):
|
||||
existing = {r.name: r for r in db.session.scalars(db.select(SavedAnalysis).where(
|
||||
SavedAnalysis.owner_id == user.id, SavedAnalysis.collection == "Training signals"))}
|
||||
created = 0
|
||||
count = db.session.scalar(db.select(db.func.count(SavedAnalysis.id)).where(
|
||||
SavedAnalysis.owner_id == user.id))
|
||||
if count + sum(d["name"] not in existing for d in definitions) > 100:
|
||||
raise ValueError("Keep at most 100 account analyses; remove an unused analysis first.")
|
||||
for definition in definitions:
|
||||
if definition["name"] in existing:
|
||||
continue # Preserve any edits made in the account library.
|
||||
q = parse_query({"query": definition["query"]})
|
||||
explore(user.id, user.timezone, q)
|
||||
row = SavedAnalysis(owner_id=user.id, **{**definition, "query": query_text(q)})
|
||||
db.session.add(row)
|
||||
created += 1
|
||||
db.session.commit()
|
||||
return created
|
||||
|
||||
|
||||
def register_cli(app):
|
||||
@app.cli.command("install-training-signals")
|
||||
@click.option("--email", required=True)
|
||||
@click.option("--pace-low", default="6:30")
|
||||
@click.option("--pace-high", default="7:00")
|
||||
@click.option("--long-run", default=10.0, type=click.FloatRange(1, 100))
|
||||
def install_signals(email, pace_low, pace_high, long_run):
|
||||
user = db.session.scalar(db.select(User).filter_by(email=email.strip().lower()))
|
||||
if not user:
|
||||
raise click.ClickException("Account not found.")
|
||||
reference = db.session.scalar(db.select(Activity.id).where(
|
||||
Activity.owner_id == user.id, Activity.has_route.is_(True),
|
||||
Activity.sport == "running").order_by(Activity.started_at.desc()).limit(1))
|
||||
try:
|
||||
count = install(user, signal_queries(pace_low, pace_high, long_run, reference))
|
||||
except ValueError as error:
|
||||
db.session.rollback()
|
||||
raise click.ClickException(str(error)) from None
|
||||
click.echo(f"Created {count} editable analyses; the first five are pinned.")
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Owner-scoped daily observations and matched recording reductions."""
|
||||
|
||||
from collections import defaultdict
|
||||
from copy import copy
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
from .extensions import db
|
||||
from .models import DailyHealth
|
||||
from .stats_analysis import evaluate, expression_tree, valid
|
||||
|
||||
|
||||
def daily(history, owner_id, zone, start, end, q, catalog):
|
||||
by_day = defaultdict(list)
|
||||
for row in history:
|
||||
by_day[row.day].append(row)
|
||||
observations = {r.day: r for r in db.session.scalars(
|
||||
db.select(DailyHealth).where(DailyHealth.owner_id == owner_id,
|
||||
DailyHealth.day >= start, DailyHealth.day < end))}
|
||||
result = []
|
||||
last_run = last_long = None
|
||||
streak = 0
|
||||
earlier = [r for r in history if r.day < start]
|
||||
if earlier:
|
||||
last_run = max(r.day for r in earlier)
|
||||
longs = [r.day for r in earlier if r.distance_m >= q["long_run"] * 1000]
|
||||
last_long = max(longs) if longs else None
|
||||
day = start - timedelta(days=1)
|
||||
while by_day[day]:
|
||||
streak += 1
|
||||
day -= timedelta(days=1)
|
||||
for index in range((end - start).days):
|
||||
day = start + timedelta(days=index)
|
||||
rows = by_day[day]
|
||||
if rows:
|
||||
last_run = day
|
||||
streak += 1
|
||||
else:
|
||||
streak = 0
|
||||
if any(r.distance_m >= q["long_run"] * 1000 for r in rows):
|
||||
last_long = day
|
||||
values = {key: None for key in catalog}
|
||||
distance = sum(r.distance_m for r in rows)
|
||||
duration = sum(r.duration_s for r in rows)
|
||||
values.update(distance=distance / 1000, duration=duration / 3600,
|
||||
sessions=len(rows), count=1, run_day_streak=streak,
|
||||
days_since_run=(day - last_run).days if last_run else None,
|
||||
days_since_long_run=(day - last_long).days if last_long else None)
|
||||
for days in (7, 28):
|
||||
window = [r for d in range(days) for r in by_day[day - timedelta(days=d)]]
|
||||
values[f"rolling_{days}d_distance"] = sum(r.distance_m for r in window) / 1000
|
||||
values[f"rolling_{days}d_count"] = len(window)
|
||||
if days == 7:
|
||||
total = sum(r.distance_m for r in window)
|
||||
values["long_run_share"] = max(r.distance_m for r in window) / total * 100 if total else None
|
||||
for field in ("perceived_effort", "fatigue"):
|
||||
known = [r.values[field] for r in rows if valid(r.values.get(field))]
|
||||
values[field] = sum(known) / len(known) if known else None
|
||||
health = observations.get(day)
|
||||
if health:
|
||||
values.update({key: value for key, value in health.values.items() if key in catalog})
|
||||
if q["source"] == "health" and not (health and any(valid(v) for v in health.values.values())):
|
||||
continue
|
||||
instant = datetime.combine(day, datetime.min.time(), zone)
|
||||
row = SimpleNamespace(id=day.toordinal(), day=day, local=instant, instant=instant,
|
||||
started_at=instant, title=f"Training day · {day}",
|
||||
distance_m=distance, duration_s=duration, elevation_m=None,
|
||||
avg_hr=None, analysis_summary=values, values=values, previous=None,
|
||||
activity_id=None, activity_ids=[r.id for r in rows])
|
||||
result.append(row)
|
||||
for spec in q["derived"]:
|
||||
if "window" in spec:
|
||||
config = spec["window"]
|
||||
from .stats_analysis import statistic
|
||||
|
||||
for row in result:
|
||||
window = [r.values.get(config["field"]) for r in result
|
||||
if row.day - timedelta(days=config["days"]) < r.day <= row.day]
|
||||
row.values[spec["name"]] = statistic(window, config["function"])
|
||||
else:
|
||||
tree = expression_tree(spec["expression"], catalog)[0]
|
||||
for row in result:
|
||||
row.values[spec["name"]] = evaluate(tree, row.values)
|
||||
return result
|
||||
|
||||
|
||||
def reduce_runs(rows, minimum, catalog):
|
||||
"""Each session contributes exactly one observation after recording filters."""
|
||||
groups = defaultdict(list)
|
||||
for row in rows:
|
||||
groups[row.activity_id].append(row)
|
||||
result = []
|
||||
for activity_id, units in groups.items():
|
||||
units.sort(key=lambda r: r.values.get("elapsed") or r.values.get("moving_offset") or 0)
|
||||
duration = sum(r.duration_s for r in units)
|
||||
if duration + 1e-7 < minimum * 60:
|
||||
continue
|
||||
row = copy(units[0])
|
||||
row.id = row.activity_id = activity_id
|
||||
row.title = getattr(row, "parent_title", row.title)
|
||||
row.distance_m = sum(r.distance_m for r in units)
|
||||
row.duration_s = duration
|
||||
values = {key: None for key in catalog}
|
||||
for key in catalog:
|
||||
coverage_key = "sensor_coverage" if key == "heart_rate" else key + "_coverage"
|
||||
pairs = [(r.values.get(key), r.duration_s * r.values.get(coverage_key, 100) / 100)
|
||||
for r in units if valid(r.values.get(key))]
|
||||
weight = sum(w for _, w in pairs)
|
||||
values[key] = sum(v * w for v, w in pairs) / weight if weight else None
|
||||
values.update(distance=row.distance_m / 1000, duration=duration / 3600,
|
||||
pace=duration * 1000 / row.distance_m, count=1,
|
||||
matched_minutes=duration / 60, matched_windows=len(units))
|
||||
# Post-filter halves of matched moving time. Require >=10 minutes each,
|
||||
# comparable pace (10%) and mean signed grade (one percentage point).
|
||||
halves = [[0.0, 0.0, 0.0, 0.0, 0.0] for _ in range(2)]
|
||||
elapsed = 0.0
|
||||
for unit in units:
|
||||
hr, grade = unit.values.get("heart_rate"), unit.values.get("grade")
|
||||
recorded = getattr(unit, "recording", {})
|
||||
fraction = recorded.get("joint_coverage", 100) / 100
|
||||
hr = recorded.get("joint_hr", hr)
|
||||
grade = recorded.get("joint_grade", grade)
|
||||
speed = recorded.get("joint_speed", unit.distance_m / unit.duration_s)
|
||||
for index, low, high in ((0, 0, duration / 2), (1, duration / 2, duration)):
|
||||
dt = max(0, min(elapsed + unit.duration_s, high) - max(elapsed, low))
|
||||
if dt and valid(hr) and valid(grade) and valid(speed):
|
||||
dt *= fraction
|
||||
part = halves[index]
|
||||
part[0] += dt
|
||||
part[1] += speed * dt
|
||||
part[2] += hr * dt
|
||||
part[3] += grade * dt
|
||||
part[4] += dt
|
||||
elapsed += unit.duration_s
|
||||
drift = None
|
||||
if all(p[0] >= 600 and p[0] >= duration * .4 and p[1] > 0 and p[2] > 0 for p in halves):
|
||||
first, second = halves
|
||||
speeds = [p[1] / p[0] for p in halves]
|
||||
grades = [p[3] / p[0] for p in halves]
|
||||
if abs(speeds[1] / speeds[0] - 1) <= .1 and abs(grades[1] - grades[0]) <= 1:
|
||||
drift = (1 - (second[1] / second[2]) / (first[1] / first[2])) * 100
|
||||
values["heart_rate_drift"] = drift
|
||||
row.avg_hr = values["heart_rate"]
|
||||
row.values = row.analysis_summary = values
|
||||
result.append(row)
|
||||
return result
|
||||
|
||||
|
||||
def evidence(rows, source):
|
||||
ids = {getattr(row, "activity_id", row.id) for row in rows}
|
||||
ids.discard(None)
|
||||
if source in {"days", "health"}:
|
||||
ids = {i for row in rows for i in row.activity_ids}
|
||||
return {"sessions": len(ids), "inputs": len(rows),
|
||||
"minutes": round(sum(row.duration_s for row in rows) / 60, 1)}
|
||||
@@ -1,6 +1,24 @@
|
||||
# Dokku deployment
|
||||
|
||||
App: `stride`; database: `stride-db`; domain: `stride.peterstockings.com`.
|
||||
|
||||
Training-signal releases migrate private daily recovery and session preferences
|
||||
automatically. Back up PostgreSQL before release, then recompute older recordings:
|
||||
|
||||
```sh
|
||||
dokku run stride env FLASK_APP=app flask backfill-analysis --batch-size 20
|
||||
dokku run stride env FLASK_APP=app flask install-training-signals --email ACCOUNT
|
||||
dokku run stride env FLASK_APP=app flask backfill-health --email ACCOUNT --days 90
|
||||
```
|
||||
|
||||
The installer validates queries before saving, pins five analyses and preserves
|
||||
existing edits on repeat runs. Pace and long-run thresholds are editable CLI
|
||||
options and account queries. Recovery backfill uses the existing encrypted
|
||||
session and account lock, enables recovery only for that account and leaves
|
||||
missing watch observations missing. Other accounts can opt in through Settings;
|
||||
recovery failures do not stop activity imports. Never run a local copy of the
|
||||
same Garmin session concurrently with production. Analysis version 3 persists
|
||||
continuous windows; no dashboard query loads full recording samples.
|
||||
The Dockerfile installs locked dependencies on Python 3.12. Dokku runs database
|
||||
migrations in the predeploy step, then one web container and one sync worker.
|
||||
|
||||
|
||||
@@ -1,5 +1,117 @@
|
||||
# Run analysis DSL
|
||||
|
||||
## Deep training signals
|
||||
|
||||
`intervals` reads persisted, non-overlapping continuous moving windows of up to
|
||||
30 seconds. Recording breaks over ten seconds, distance resets, stops and
|
||||
implausible movement end a window; windows never join across those breaks.
|
||||
Filter `duration >= 25 s` to remove short fragments. `elapsed` and
|
||||
`moving_offset` are minutes since recording start and usable moving start.
|
||||
Elapsed offsets use relative time even when FIT timestamps are Unix timestamps.
|
||||
|
||||
```text
|
||||
intervals
|
||||
during last 16 weeks
|
||||
reduce by run min 3 min
|
||||
where elapsed >= 10 min
|
||||
and duration >= 25 s
|
||||
and pace between 6:30 and 7:00 /km
|
||||
and grade between -2 and 2 %
|
||||
and grade_variability <= 2 %
|
||||
and pace_variability <= 15 %
|
||||
and sensor_coverage >= 90 %
|
||||
and hr_artifact_percent = 0 %
|
||||
summarize mean(heart_rate) as "Matched HR"
|
||||
by run
|
||||
sort by date asc
|
||||
compare none
|
||||
chart line
|
||||
```
|
||||
|
||||
Filters run **before** `reduce by run`. A session needs the specified total
|
||||
matched moving minutes to contribute one observation. Within-session HR and
|
||||
mechanics are weighted by usable sensor time; `mean` across reduced observations
|
||||
weights each session equally. `count()` then counts independent sessions rather
|
||||
than windows. Matched windows can be separated in time. This is different from
|
||||
requiring one uninterrupted three-minute section. Charts show distinct-session
|
||||
and usable-minute coverage, and run plots preserve calendar spacing.
|
||||
|
||||
`grade` is duration-weighted signed grade using the existing smoothed elevation
|
||||
model; `grade_variability` is its population SD in percentage points. GAP and
|
||||
grade require at least 80% terrain coverage. Window-level cadence, power,
|
||||
stride length, vertical oscillation and ground contact include sensor coverage
|
||||
metrics. HR outside 30–240 bpm or a jump exceeding 20 bpm per second is excluded
|
||||
and counted in `hr_artifact_percent`. These coarse checks do not detect every
|
||||
sensor artifact or cadence lock. Terrain, HR lag, weather and session intent
|
||||
remain confounders; the matcher does not claim to remove them.
|
||||
|
||||
After reduction, `heart_rate_drift` is recomputed over matched moving-time
|
||||
halves, excluding any filtered warm-up. Each half needs at least ten minutes of
|
||||
joint usable HR and terrain, and 80% of the half's time. Halves must differ by
|
||||
at most 10% in speed and one percentage point of mean grade. Otherwise the
|
||||
result is missing. Positive means lower speed/HR efficiency later. This is a
|
||||
descriptive comparison, not an overtraining diagnosis. Recording-level flags
|
||||
require reduction to independent sessions; a baseline still needs ten runs.
|
||||
|
||||
`efforts` expands the persisted best elapsed windows. Use `where distance = 5 km`
|
||||
and `min(effort_time)` for a 5 km progression. Whole runs expose `best_1km`,
|
||||
`best_2km`, `best_5km`, and `best_10km`, which accept second/minute filters and
|
||||
display elapsed min:sec. No attempt at that distance remains missing. These
|
||||
efforts are windows inside runs, not standardized races.
|
||||
|
||||
```text
|
||||
days
|
||||
during last 16 weeks
|
||||
long run distance >= 10 km
|
||||
summarize latest(days_since_long_run), latest(rolling_28d_distance)
|
||||
by day
|
||||
compare none
|
||||
chart line
|
||||
display overlay
|
||||
```
|
||||
|
||||
`days` includes rest days. `sessions`, `days_since_run`, `days_since_long_run`,
|
||||
`run_day_streak`, and `long_run_share` expose continuity and concentration.
|
||||
`long_run_share` is the largest individual run's share of the current seven-day
|
||||
distance. Daily 7/28-day windows include the current local day plus the previous
|
||||
6/27 local days, including history before the query. Whole-run rolling metrics
|
||||
continue to use elapsed 168/672-hour windows. Missing prior long-run history
|
||||
does not become a fabricated gap. `latest(field)` selects the latest input by
|
||||
timestamp regardless of sort order, retaining missing values on that input;
|
||||
it is useful for current dashboard values and cannot define a baseline band.
|
||||
|
||||
`health` reads owner-only Garmin resting HR, overnight HRV, sleep duration and
|
||||
stress observations (`resting_hr`, `hrv`, `sleep_hours`, `stress`). Enable the
|
||||
optional recovery sync in Settings. The worker refreshes today/yesterday and
|
||||
backfills a bounded batch of earlier dates. Provider gaps and unsupported watch
|
||||
observations stay missing; no readiness score is inferred. Daily inputs link
|
||||
to their contributing runs when present; rest/recovery-only days do not invent
|
||||
activity links. Daily sources also support calendar rolling calculations.
|
||||
|
||||
Private activity notes expose `perceived_effort` (1–10) and `fatigue` (0–10).
|
||||
They are never shown on public or friend activity pages. Settings accepts two
|
||||
personal HR boundaries. `lower_hr_minutes`, `middle_hr_minutes`, and
|
||||
`upper_hr_minutes` sum actual usable moving sample time, rather than assigning
|
||||
an entire run/window according to its average HR. Boundary HR belongs to the
|
||||
higher band. Missing thresholds mean missing results; physiological zones are
|
||||
never assumed.
|
||||
|
||||
`route similar to ID` restricts recordings to an approximate full-course match
|
||||
against one of your own runs. The comparison resamples compact geometry along
|
||||
distance, requires endpoints within 100 m, length within 10%, mean course
|
||||
separation ≤100 m and 90th-percentile separation ≤200 m. Different directions
|
||||
and recording breaks are excluded; geometry shorter than 500 m is unavailable.
|
||||
This is an exploratory same-start course matcher, not exact named-segment
|
||||
recognition. Always inspect the contributing courses.
|
||||
|
||||
Operators can run `flask --app app install-training-signals --email ACCOUNT`
|
||||
to save editable examples and pin the first five. `--pace-low`, `--pace-high`
|
||||
and `--long-run` customize matching. Re-running preserves existing library
|
||||
edits. Recompute old recordings with `backfill-analysis` (version 3).
|
||||
`backfill-health --email ACCOUNT --days 90` imports bounded recovery history
|
||||
using the normal account session lock and encrypted token rotation; only this
|
||||
account's recovery sync is enabled.
|
||||
|
||||
## Account library and live dashboard
|
||||
|
||||
Save an analysis with a name, investigation collection and notes. **Save as new**
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Daily recovery, individual effort bands and private session context."""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "7c6e32b491ad"
|
||||
down_revision = "f31a0c9286bd"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table("garmin_connection") as batch:
|
||||
batch.add_column(sa.Column("health_enabled", sa.Boolean(), nullable=False,
|
||||
server_default=sa.false()))
|
||||
batch.add_column(sa.Column("health_message", sa.String(200), nullable=False,
|
||||
server_default="Daily recovery sync is off."))
|
||||
with op.batch_alter_table("user") as batch:
|
||||
batch.add_column(sa.Column("hr_bands", sa.JSON()))
|
||||
with op.batch_alter_table("activity") as batch:
|
||||
batch.add_column(sa.Column("session_context", sa.JSON(), nullable=False,
|
||||
server_default="{}"))
|
||||
op.create_table("daily_health",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("owner_id", sa.Integer(), sa.ForeignKey("user.id"), nullable=False),
|
||||
sa.Column("day", sa.Date(), nullable=False),
|
||||
sa.Column("values", sa.JSON(), nullable=False),
|
||||
sa.Column("fetched_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.UniqueConstraint("owner_id", "day"))
|
||||
op.create_index("ix_daily_health_owner_id", "daily_health", ["owner_id"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("daily_health")
|
||||
with op.batch_alter_table("activity") as batch:
|
||||
batch.drop_column("session_context")
|
||||
with op.batch_alter_table("user") as batch:
|
||||
batch.drop_column("hr_bands")
|
||||
with op.batch_alter_table("garmin_connection") as batch:
|
||||
batch.drop_column("health_message")
|
||||
batch.drop_column("health_enabled")
|
||||
@@ -40,7 +40,7 @@ def test_presets_roundtrip_and_execute(app, preset):
|
||||
assert parsed(query_text(q)) == q
|
||||
with app.app_context():
|
||||
result = explore(1, "Australia/Sydney", q)
|
||||
assert result["source"] == q["source"]
|
||||
assert result["source"] == ("matched runs" if q["reduce"] else q["source"])
|
||||
client = app.test_client()
|
||||
login(client)
|
||||
assert client.get("/stats", query_string={"query": preset["query"]}).status_code == 200
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
from datetime import UTC, date, datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import event
|
||||
from test_app import login
|
||||
|
||||
from app.display import persist_analysis
|
||||
from app.extensions import db
|
||||
from app.models import Activity, DailyHealth, SavedAnalysis, User
|
||||
from app.recording_windows import analyze
|
||||
from app.recovery import normalize, sync_health
|
||||
from app.route_benchmarks import similar
|
||||
from app.stats import explore, parse_query, query_text
|
||||
from app.stats_dsl import QueryError
|
||||
from app.training_signals import install, signal_queries
|
||||
from app.training_sources import reduce_runs
|
||||
|
||||
pytest_plugins = ("test_app",)
|
||||
|
||||
|
||||
def parsed(source):
|
||||
return parse_query({"query": source})
|
||||
|
||||
|
||||
def recording(seconds=900, hr=140, origin=1_700_000_000, speed=2.5):
|
||||
return [{"time": origin + i, "distance": speed * i, "elevation": 20,
|
||||
"heart_rate": hr, "cadence": 160, "ground_contact": 300,
|
||||
"stride_length": 1, "speed": speed} for i in range(seconds + 1)]
|
||||
|
||||
|
||||
def add_run(day, distance=5000, duration=1800, **kwargs):
|
||||
row = Activity(owner_id=1, source_key=str(day), title=str(day), sport="running",
|
||||
started_at=datetime.fromisoformat(day).replace(tzinfo=UTC),
|
||||
distance_m=distance, duration_s=duration, avg_hr=140, **kwargs)
|
||||
db.session.add(row)
|
||||
return row
|
||||
|
||||
|
||||
def test_windows_preserve_epoch_offsets_duration_and_sensor_time():
|
||||
units = analyze(recording(905))
|
||||
assert len(units) == 31
|
||||
assert sum(u["duration"] for u in units) == 905
|
||||
assert sum(u["distance"] for u in units) == pytest.approx(2262.5)
|
||||
assert units[20]["elapsed"] == 10
|
||||
assert units[20]["moving_offset"] == 10
|
||||
assert units[-1]["duration"] == 5
|
||||
assert all(u["grade"] == 0 for u in units)
|
||||
assert sum(sum(u["hr_seconds"].values()) for u in units) == 905
|
||||
|
||||
|
||||
def test_windows_do_not_bridge_stops_or_recording_breaks():
|
||||
rows = recording(20)
|
||||
rows += [{**r, "time": r["time"] + 40, "distance": r["distance"] + 50}
|
||||
for r in recording(20)[1:]]
|
||||
units = analyze(rows)
|
||||
assert [u["duration"] for u in units] == [20, 19]
|
||||
assert units[1]["elapsed"] == pytest.approx(41 / 60)
|
||||
rows = recording(60)
|
||||
for r in rows[20:41]:
|
||||
r["distance"] = 50
|
||||
units = analyze(rows)
|
||||
assert all(u["duration"] <= 30 for u in units)
|
||||
assert sum(u["duration"] for u in units) < 60
|
||||
|
||||
|
||||
def test_sensor_artifacts_and_missing_terrain_are_not_filled():
|
||||
rows = recording(30)
|
||||
rows[10]["heart_rate"] = 250
|
||||
for row in rows:
|
||||
row["elevation"] = None
|
||||
unit = analyze(rows)[0]
|
||||
assert unit["sensor_coverage"] < 100
|
||||
assert unit["hr_artifact_percent"] > 0
|
||||
assert "250" not in unit["hr_seconds"]
|
||||
assert unit["grade"] is unit["gap"] is None
|
||||
assert unit["terrain_coverage"] == 0
|
||||
|
||||
|
||||
def test_matched_sessions_are_independent_and_filters_precede_reduction(app):
|
||||
with app.app_context():
|
||||
db.session.query(Activity).delete()
|
||||
first = add_run("2026-09-01", samples=recording(1800, 150))
|
||||
second = add_run("2026-09-02", samples=recording(900, 130))
|
||||
db.session.flush()
|
||||
for row in (first, second):
|
||||
persist_analysis(row)
|
||||
db.session.commit()
|
||||
q = parsed("intervals\nduring 2026-09-01 to 2026-09-03\nreduce by run min 3 min\n"
|
||||
"where elapsed >= 10 min\nand duration >= 25 s\nand grade between -2 and 2 %\n"
|
||||
"summarize mean(heart_rate), count()\nby all\ncompare none")
|
||||
result = explore(1, "UTC", q)
|
||||
assert result["count"] == result["coverage"]["sessions"] == 2
|
||||
assert result["cards"][0]["value"] == "140.0"
|
||||
assert result["coverage"]["minutes"] == 25
|
||||
assert {row["id"] for row in result["rows"]} == {first.id, second.id}
|
||||
q["reduce"] = 6
|
||||
assert explore(1, "UTC", q)["count"] == 1
|
||||
|
||||
|
||||
def unit(activity_id, index, hr=140, speed=2.5, grade=0):
|
||||
values = {"heart_rate": hr, "sensor_coverage": 100, "grade": grade,
|
||||
"elapsed": index / 2, "pace": 1000 / speed}
|
||||
return SimpleNamespace(id=activity_id * 1000 + index, activity_id=activity_id,
|
||||
title="window", parent_title="run", duration_s=30,
|
||||
distance_m=30 * speed, values=values)
|
||||
|
||||
|
||||
def test_post_warmup_decoupling_requires_comparable_halves():
|
||||
rows = [unit(1, i, 130 if i < 20 else 143) for i in range(40)]
|
||||
catalog = {k: None for k in ["heart_rate", "heart_rate_drift", "grade", "pace"]}
|
||||
reduced = reduce_runs(rows, 20, catalog)[0]
|
||||
assert reduced.values["heart_rate_drift"] == pytest.approx(100 / 11)
|
||||
assert reduce_runs(rows[:39], 20, catalog) == []
|
||||
for row in rows[20:]:
|
||||
row.distance_m *= 1.2
|
||||
assert reduce_runs(rows, 20, catalog)[0].values["heart_rate_drift"] is None
|
||||
for row in rows:
|
||||
row.values["grade"] = None
|
||||
assert reduce_runs(rows, 20, catalog)[0].values["heart_rate_drift"] is None
|
||||
|
||||
|
||||
def test_daily_windows_include_rest_days_and_prior_history(app):
|
||||
with app.app_context():
|
||||
db.session.query(Activity).delete()
|
||||
add_run("2026-09-01", 10000)
|
||||
add_run("2026-09-02", 5000)
|
||||
add_run("2026-09-03", 5000)
|
||||
db.session.commit()
|
||||
q = parsed("days\nduring 2026-09-02 to 2026-09-09\n"
|
||||
"summarize latest(rolling_7d_distance), latest(run_day_streak), latest(days_since_long_run)\n"
|
||||
"by day\ncompare none")
|
||||
result = explore(1, "UTC", q)
|
||||
assert len(result["groups"]) == 8
|
||||
assert [g["raw_values"] for g in result["groups"][:3]] == [[15, 2, 1], [20, 3, 2], [20, 0, 3]]
|
||||
assert result["groups"][-1]["raw_values"] == [5, 0, 8]
|
||||
assert result["cards"][0]["value"] == "5.0"
|
||||
assert result["rows"][0]["id"] is None
|
||||
assert result["coverage"]["sessions"] == 2
|
||||
q["long_run"] = 20
|
||||
assert explore(1, "UTC", q)["cards"][-1]["value"] == "—"
|
||||
|
||||
|
||||
def test_daily_timezone_and_latest_missing_value(app):
|
||||
with app.app_context():
|
||||
db.session.query(Activity).delete()
|
||||
add_run("2026-09-01T23:30", 10000)
|
||||
db.session.add_all([DailyHealth(owner_id=1, day=date(2026, 9, 2), values={"resting_hr": 50}),
|
||||
DailyHealth(owner_id=1, day=date(2026, 9, 3), values={"sleep_hours": 8}),
|
||||
DailyHealth(owner_id=2, day=date(2026, 9, 3), values={"resting_hr": 999})])
|
||||
db.session.commit()
|
||||
q = parsed("health\nduring 2026-09-02 to 2026-09-03\nsummarize latest(resting_hr), latest(sleep_hours)\nby day\ncompare none")
|
||||
result = explore(1, "Australia/Sydney", q)
|
||||
assert result["count"] == 2
|
||||
assert [c["value"] for c in result["cards"]] == ["—", "8.0"]
|
||||
assert result["groups"][0]["raw_values"][0] == 50
|
||||
assert result["coverage"]["sessions"] == 1
|
||||
|
||||
|
||||
def test_actual_hr_band_seconds_are_not_window_average_classifications(app):
|
||||
with app.app_context():
|
||||
db.session.query(Activity).delete()
|
||||
row = add_run("2026-09-02", samples=recording(60))
|
||||
for i, sample in enumerate(row.samples):
|
||||
sample["heart_rate"] = 130 if i <= 30 else 150
|
||||
persist_analysis(row)
|
||||
db.session.get(User, 1).hr_bands = [140, 160]
|
||||
db.session.commit()
|
||||
q = parsed("runs\nduring all time\nsummarize sum(lower_hr_minutes), sum(middle_hr_minutes), sum(upper_hr_minutes)\nby all\ncompare none")
|
||||
result = explore(1, "UTC", q)
|
||||
assert result["groups"][0]["raw_values"] == [0.5, 0.5, 0]
|
||||
db.session.get(User, 1).hr_bands = None
|
||||
db.session.commit()
|
||||
assert explore(1, "UTC", q)["groups"][0]["raw_values"] == [None, None, None]
|
||||
|
||||
|
||||
def test_efforts_and_performance_metrics_preserve_missing_distances(app):
|
||||
with app.app_context():
|
||||
row = db.session.get(Activity, 1)
|
||||
row.best_efforts = [{"distance": 1000, "seconds": 301}, {"distance": 5000, "seconds": 1800}]
|
||||
db.session.commit()
|
||||
q = parsed("efforts\nduring all time\nwhere distance = 5 km\nsummarize min(effort_time)\nby all\ncompare none")
|
||||
result = explore(1, "UTC", q)
|
||||
assert result["cards"][0]["value"] == "30:00"
|
||||
assert result["rows"][0]["id"] == 1
|
||||
q = parsed("runs\nduring all time\nsummarize min(best_5km), min(best_10km)\nby month\ncompare none")
|
||||
assert [c["value"] for c in explore(1, "UTC", q)["cards"]] == ["30:00", "—"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("definition", signal_queries(route_id=1), ids=lambda d: d["name"])
|
||||
def test_proposed_queries_roundtrip_execute_and_render(app, definition):
|
||||
q = parsed(definition["query"])
|
||||
assert parsed(query_text(q)) == q
|
||||
with app.app_context():
|
||||
explore(1, "Australia/Sydney", q)
|
||||
client = app.test_client()
|
||||
login(client)
|
||||
assert client.get("/stats", query_string={"query": definition["query"]}).status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["runs", "days", "health", "efforts"])
|
||||
def test_reduction_rejects_non_recording_sources(source):
|
||||
with pytest.raises(QueryError):
|
||||
parsed(source + "\nreduce by run min 3 min\nsummarize heart_rate")
|
||||
|
||||
|
||||
def test_queries_never_load_recordings_or_other_accounts(app):
|
||||
with app.app_context():
|
||||
statements = []
|
||||
def capture(_conn, _cursor, statement, _params, _context, _many):
|
||||
statements.append(statement)
|
||||
event.listen(db.engine, "before_cursor_execute", capture)
|
||||
try:
|
||||
for source in ("runs", "intervals", "efforts", "days", "health"):
|
||||
explore(1, "UTC", parsed(source + "\nduring last 16 weeks\nsummarize heart_rate\nby day"))
|
||||
finally:
|
||||
event.remove(db.engine, "before_cursor_execute", capture)
|
||||
assert not any("activity.samples" in sql or "activity.display_data" in sql for sql in statements)
|
||||
|
||||
|
||||
def test_route_matching_keeps_direction_and_rejects_breaks_and_other_owners(app):
|
||||
line = [[[-33, 151], [-33, 151.005], [-33, 151.01]]]
|
||||
assert similar(line, line)
|
||||
assert not similar(line, [list(reversed(line[0]))])
|
||||
assert not similar(line, [line[0][:2], line[0][2:]])
|
||||
with app.app_context():
|
||||
db.session.get(Activity, 1).route_geometry = line
|
||||
other = Activity(owner_id=2, source_key="other-route", title="other", sport="running",
|
||||
started_at=datetime(2026, 9, 30, tzinfo=UTC), route_geometry=line)
|
||||
db.session.add(other)
|
||||
db.session.commit()
|
||||
q = parsed(f"runs\nduring all time\nroute similar to {other.id}\nsummarize pace")
|
||||
with pytest.raises(QueryError, match="own recorded"):
|
||||
explore(1, "UTC", q)
|
||||
|
||||
|
||||
def test_installation_is_idempotent_private_and_keeps_edits(app):
|
||||
with app.app_context():
|
||||
user = db.session.get(User, 1)
|
||||
definitions = signal_queries()
|
||||
assert install(user, definitions) == 12
|
||||
rows = db.session.scalars(db.select(SavedAnalysis).filter_by(owner_id=1)).all()
|
||||
assert sum(r.pinned for r in rows) == 5
|
||||
rows[0].notes = "My annotation"
|
||||
db.session.commit()
|
||||
assert install(user, definitions) == 0
|
||||
assert rows[0].notes == "My annotation"
|
||||
client = app.test_client()
|
||||
login(client, "other")
|
||||
assert client.get("/stats/analyses").json["analyses"] == []
|
||||
|
||||
|
||||
def test_private_context_and_bands_validate_and_do_not_leak(app):
|
||||
client = app.test_client()
|
||||
login(client)
|
||||
assert client.post("/training/bands", data={"lower": 145, "upper": 140}).status_code == 400
|
||||
assert client.post("/training/bands", data={"lower": 140, "upper": 160}).status_code == 302
|
||||
assert client.post("/activities/1/context", data={"perceived_effort": 11}).status_code == 400
|
||||
assert client.post("/activities/1/context", data={"perceived_effort": 7, "fatigue": 3, "notes": "PRIVATE-CONTEXT"}).status_code == 302
|
||||
assert b"PRIVATE-CONTEXT" in client.get("/activities/1").data
|
||||
with app.app_context():
|
||||
db.session.get(Activity, 1).share_token = "test-context-token"
|
||||
db.session.commit()
|
||||
assert b"PRIVATE-CONTEXT" not in app.test_client().get("/shared/runs/test-context-token").data
|
||||
other = app.test_client()
|
||||
login(other, "other")
|
||||
assert other.post("/activities/1/context", data={"notes": "attack"}).status_code == 404
|
||||
app.config["WTF_CSRF_ENABLED"] = True
|
||||
assert client.post("/activities/1/context", data={}).status_code == 400
|
||||
assert client.post("/training/bands", data={}).status_code == 400
|
||||
|
||||
|
||||
def test_recovery_mapping_does_not_turn_provider_missing_sentinels_into_data():
|
||||
assert normalize({"restingHeartRate": None, "averageStressLevel": -1}, {}, {}) == {}
|
||||
assert normalize({"restingHeartRate": 55, "averageStressLevel": 32},
|
||||
{"hrvSummary": {"lastNightAvg": 48}},
|
||||
{"dailySleepDTO": {"sleepTimeSeconds": 28800}}) == {
|
||||
"resting_hr": 55, "stress": 32, "hrv": 48, "sleep_hours": 8}
|
||||
|
||||
|
||||
def test_daily_import_is_bounded_owner_scoped_and_stops_on_failure(app):
|
||||
class API:
|
||||
calls = 0
|
||||
fail = False
|
||||
def get_stats(self, _day):
|
||||
self.calls += 1
|
||||
if self.fail:
|
||||
raise RuntimeError("PROVIDER-SECRET")
|
||||
return {"restingHeartRate": 50}
|
||||
def get_hrv_data(self, _day):
|
||||
self.calls += 1
|
||||
return {"hrvSummary": {"lastNightAvg": 45}}
|
||||
def get_sleep_data(self, _day):
|
||||
self.calls += 1
|
||||
return {"dailySleepDTO": {"sleepTimeSeconds": 28800}}
|
||||
with app.app_context():
|
||||
api = API()
|
||||
user = db.session.get(User, 1)
|
||||
assert sync_health(api, user, days=30, limit=4)["days"] == 4
|
||||
assert api.calls == 12
|
||||
assert db.session.scalar(db.select(db.func.count(DailyHealth.id)).where(DailyHealth.owner_id == 1)) == 4
|
||||
assert db.session.scalar(db.select(db.func.count(DailyHealth.id)).where(DailyHealth.owner_id == 2)) == 0
|
||||
api.fail = True
|
||||
result = sync_health(api, user, days=30, limit=10)
|
||||
assert result["days"] == 1 and result["failures"] == 1
|
||||
assert "PROVIDER-SECRET" not in result["message"]
|
||||
+30
-1
@@ -10,7 +10,36 @@ from garminconnect import GarminConnectAuthenticationError
|
||||
from app import create_app
|
||||
from app.extensions import db
|
||||
from app.models import GarminConnection, User
|
||||
from app.sync import run_due, session_lock
|
||||
from app.sync import cipher, run_due, session_lock
|
||||
|
||||
|
||||
def test_optional_recovery_failure_does_not_break_activity_sync(connected, monkeypatch):
|
||||
app, _client, _api, _sync = connected
|
||||
def failed_health(*_args):
|
||||
raise RuntimeError("PRIVATE-PROVIDER-RESPONSE")
|
||||
monkeypatch.setattr("app.recovery.sync_health", failed_health)
|
||||
with app.app_context():
|
||||
connection = db.session.scalar(db.select(GarminConnection))
|
||||
connection.health_enabled = True
|
||||
db.session.commit()
|
||||
assert run_due() == 1
|
||||
assert connection.failures == 0 and connection.last_success
|
||||
assert "Activity sync completed" in connection.health_message
|
||||
assert "PRIVATE-PROVIDER" not in connection.health_message
|
||||
|
||||
|
||||
def test_recovery_login_failure_preserves_encrypted_session(connected):
|
||||
app, _client, api, _sync = connected
|
||||
api.login.side_effect = RuntimeError("PRIVATE-LOGIN-RESPONSE")
|
||||
with app.app_context():
|
||||
original = db.session.scalar(db.select(GarminConnection)).tokens
|
||||
result = app.test_cli_runner().invoke(args=["backfill-health", "--email", "owner@example.test", "--days", "2"])
|
||||
assert result.exit_code == 1 and "PRIVATE-LOGIN" not in result.output
|
||||
with app.app_context():
|
||||
connection = db.session.scalar(db.select(GarminConnection))
|
||||
assert connection.tokens == original
|
||||
assert not connection.health_enabled
|
||||
assert cipher().decrypt(connection.tokens.encode())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
Reference in New Issue
Block a user