nicholasg1997
feat: add ACWR danger zone classifications and summary rendering
2bdf9d9
from rate_my_run.models import TrainingSummary
def format_pace(pace_min_per_km: float | None) -> str:
"""5.02 -> '5:01/km'. Returns 'n/a' for missing pace."""
if pace_min_per_km is None:
return "n/a"
minutes = int(pace_min_per_km)
seconds = round((pace_min_per_km - minutes) * 60)
if seconds == 60:
minutes, seconds = minutes + 1, 0
return f"{minutes}:{seconds:02d}/km"
SIGNAL_DESCRIPTIONS = {
"mileage_spike": "Weekly mileage rose more than 10% versus last week.",
"potential_fatigue": "Mileage increased while pace slowed — a possible sign of fatigue or under-recovery.",
"returning_after_break": "The athlete is returning to running after a notable break.",
"increasing_consistency": "The number of runs per week is increasing.",
"acwr_danger_zone": "Training load (ACWR) is in the danger zone — weekly mileage is spiking too fast versus the 4-week average, raising overuse-injury risk.",
"acwr_undertraining": "Training load (ACWR) is low — recent volume is below the 4-week average, so fitness may be stagnating.",
}
def acwr_band(acwr: float) -> str:
"""Plain-English band for an ACWR value (thresholds mirror analytics.ACWR_*)."""
if acwr < 0.80:
return "under-training (fitness may stagnate)"
if acwr <= 1.30:
return "the sweet spot (safe, progressive load)"
if acwr <= 1.50:
return "elevated (monitor recovery)"
return "danger zone (mileage spiking too fast; high injury risk)"
def summary_to_text(summary: TrainingSummary, goal: str | None = None, name: str|None = None) -> str:
"""Render a TrainingSummary into neutral English for the coaching model."""
lines: list[str] = []
this_week = summary.weeks[-1]
if goal:
lines.append(f"Athlete's stated goal: {goal}")
lines.append("")
if name:
lines.append(f"Athlete's name: {name}")
lines.append("")
# --- Current week ---
lines.append("CURRENT WEEK")
lines.append(f"- Runs: {this_week.num_runs}")
lines.append(f"- Distance: {this_week.distance_km:.1f} km")
lines.append(f"- Longest run: {this_week.longest_run_km:.1f} km")
if this_week.avg_pace_min_per_km is not None:
lines.append(f"- Average pace: {format_pace(this_week.avg_pace_min_per_km)}")
else:
lines.append("- Average pace: no runs this week")
lines.append("")
# --- Trends ---
lines.append("TRENDS")
if summary.mileage_change_pct is None:
lines.append("- Weekly mileage: no comparison (no runs last week).")
else:
direction = "up" if summary.mileage_change_pct >= 0 else "down"
lines.append(f"- Weekly mileage {direction} {abs(summary.mileage_change_pct):.0f}% vs last week.")
if summary.pace_trend == 'insufficient_data':
lines.append("- Weekly pace: insufficient data for pace trend analysis.")
elif summary.pace_trend == 'improving':
lines.append("- Weekly pace: weekly pace this week has improved compared to the recent baseline.")
elif summary.pace_trend == 'plateauing':
lines.append("- Weekly pace: weekly pace is plateauing as it has not changed significantly compared to the recent baseline.")
elif summary.pace_trend == 'declining':
lines.append("- Weekly pace: weekly pace this week has declined compared to the recent baseline.")
if summary.acwr is not None:
lines.append(f"- Training load (ACWR): {summary.acwr:.2f}{acwr_band(summary.acwr)}.")
lines.append(f"- Days since last run: {summary.days_since_last_run}")
for i, week in enumerate(summary.weeks[-5:-1][::-1]):
lines.append(f"- {i+1} Weeks ago -> {week.__str__()}")
# --- Signals ---
if summary.signals:
lines.append("")
lines.append("SIGNALS")
for s in summary.signals:
lines.append(f"- {SIGNAL_DESCRIPTIONS.get(s, s)}")
return "\n".join(lines)