Files
openrun/tests/unit/test_race_plan.py
2026-05-19 08:34:22 -04:00

117 lines
4.4 KiB
Python

"""Tests for race-plan helpers: `personal_records` and `calibrate_tl_per_km`."""
from __future__ import annotations
import numpy as np
import pandas as pd
from openrun.model import calibrate_tl_per_km, personal_records
def _act(aid: int, dist_km: float, pace: float, when: str = "2026-01-01") -> dict:
"""Build a single-activity dict matching `load_activities` output columns."""
duration_s = pace * 60.0 * dist_km
return {
"activity_id": aid,
"start_time_local": pd.Timestamp(when),
"distance_km": dist_km,
"pace_min_per_km": pace,
"moving_duration_s": duration_s,
}
# ---------------------------------------------------------------------------
# personal_records
# ---------------------------------------------------------------------------
def test_personal_records_picks_fastest_in_bin() -> None:
"""Three 10K candidates: returned row must be the one with the lowest pace."""
acts = pd.DataFrame([
_act(1, 10.0, pace=5.0),
_act(2, 10.2, pace=4.5), # winner
_act(3, 9.8, pace=4.8),
])
pr = personal_records(acts, distance_bins_km=(10.0,))
assert len(pr) == 1
assert pr.loc[0, "activity_id"] == 2
assert pr.loc[0, "pace_min_per_km"] == 4.5
def test_personal_records_respects_tolerance_band() -> None:
"""±5% band on 10K = [9.5, 10.5]. An 11K activity is too far out to qualify."""
acts = pd.DataFrame([
_act(1, 10.0, pace=5.0),
_act(2, 11.0, pace=3.5), # faster but outside tolerance
])
pr = personal_records(acts, distance_bins_km=(10.0,))
assert pr.loc[0, "activity_id"] == 1
def test_personal_records_skips_bins_with_no_qualifying_activity() -> None:
acts = pd.DataFrame([_act(1, 5.0, pace=5.0)])
pr = personal_records(acts, distance_bins_km=(5.0, 10.0, 21.0975))
assert list(pr["bin_km"]) == [5.0]
def test_personal_records_ignores_nan_pace() -> None:
acts = pd.DataFrame([
{**_act(1, 10.0, pace=5.0), "pace_min_per_km": np.nan}, # masked by loader
_act(2, 10.0, pace=5.5),
])
pr = personal_records(acts, distance_bins_km=(10.0,))
assert pr.loc[0, "activity_id"] == 2
def test_personal_records_empty_input_returns_empty_frame() -> None:
pr = personal_records(pd.DataFrame())
assert pr.empty
assert set(pr.columns) >= {"bin_km", "activity_id", "pace_min_per_km"}
# ---------------------------------------------------------------------------
# calibrate_tl_per_km
# ---------------------------------------------------------------------------
def _insert_activity(conn, aid: int, distance_m: float, training_load: float,
activity_type: str = "running", when: str = "2026-05-01") -> None:
conn.execute(
"""INSERT INTO activities
(activity_id, start_time_local, activity_type, distance_m, training_load, raw, fetched_at)
VALUES (?, ?, ?, ?, ?, '{}', 'now')""",
(aid, when, activity_type, distance_m, training_load),
)
def test_calibrate_tl_per_km_returns_median_iqr(tmp_conn) -> None:
"""Median of synthetic tl/km values equals the constructed truth."""
# 5 runs, each 10 km, with training loads chosen so tl/km = 8, 9, 10, 11, 12.
for i, tl_per_km in enumerate([8.0, 9.0, 10.0, 11.0, 12.0], start=1):
_insert_activity(tmp_conn, aid=i, distance_m=10_000, training_load=10 * tl_per_km)
tmp_conn.commit()
result = calibrate_tl_per_km(tmp_conn, lookback_days=None)
assert result["n"] == 5
assert result["median"] == 10.0
assert result["q1"] == 9.0
assert result["q3"] == 11.0
def test_calibrate_tl_per_km_excludes_short_distance(tmp_conn) -> None:
_insert_activity(tmp_conn, aid=1, distance_m=10_000, training_load=100)
_insert_activity(tmp_conn, aid=2, distance_m=500, training_load=50) # too short
tmp_conn.commit()
assert calibrate_tl_per_km(tmp_conn, lookback_days=None)["n"] == 1
def test_calibrate_tl_per_km_excludes_other_activity_types(tmp_conn) -> None:
_insert_activity(tmp_conn, aid=1, distance_m=10_000, training_load=100, activity_type="running")
_insert_activity(tmp_conn, aid=2, distance_m=10_000, training_load=100, activity_type="cycling")
tmp_conn.commit()
assert calibrate_tl_per_km(tmp_conn, lookback_days=None)["n"] == 1
def test_calibrate_tl_per_km_returns_nan_when_empty(tmp_conn) -> None:
result = calibrate_tl_per_km(tmp_conn, lookback_days=None)
assert result["n"] == 0
assert np.isnan(result["median"])