Spaces:
Runtime error
Runtime error
File size: 7,463 Bytes
d64fd55 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 | from typing import Optional, Dict, List, Literal
from pydantic import BaseModel, Field
from datetime import date
from domain.training.weekly_snapshot import WeeklySnapshot
from domain.training.weekly_trend import WeeklyTrend
from domain.runner_positioning import TrainingPhase
from _app.presentation.ui_text import get_text
class WeeklyPositioning(BaseModel):
"""
Application-layer model for Positioning Intelligence v1.
Narrative-focused assessment of the runner's current standing.
"""
status: Literal["CONSTRUCTIVE_ADAPTATION", "PRODUCTIVE_LOAD", "STRAIN", "PLATEAU", "BASELINE_BUILDING"]
signal_strength: float = 1.0 # 0.0 to 1.0
rationale: str
training_phase: TrainingPhase = TrainingPhase.BASE
@property
def status_value(self) -> str:
return self.status
class PositioningEngine:
"""
Engine to compute high-level positioning status from snapshots and trends.
"""
def detect_training_phase(self, snapshot: WeeklySnapshot, trend: WeeklyTrend) -> TrainingPhase:
"""
Infer training phase from workload and trend signals.
"""
distance_delta = trend.distance_delta_pct or 0
pace_delta = trend.pace_delta_s_per_km or 0
run_count = snapshot.run_count
# consistency = snapshot.consistency_score or 0
# Recovery phase (very low activity and sharp drop in load)
if run_count <= 1 and distance_delta < -80:
return TrainingPhase.RECOVERY
# Plateau phase (load dropped or adaptation stalled)
if distance_delta < -20:
return TrainingPhase.PLATEAU
# Build phase (clear volume increase)
if distance_delta > 5:
return TrainingPhase.BUILD
# Peak phase (performance improving without load reduction)
if pace_delta < -5 and distance_delta >= 0:
return TrainingPhase.PEAK
# Default: base training
return TrainingPhase.BASE
def compute(self, snapshot: WeeklySnapshot, trend: WeeklyTrend) -> WeeklyPositioning:
if not trend or not trend.comparison_available:
return WeeklyPositioning(
status="BASELINE_BUILDING",
rationale="positioning_rationale_building",
signal_strength=0.1,
training_phase=TrainingPhase.BASE
)
# 1. Logic to determine status
# This is a v1 heuristic mapping
status = "PLATEAU"
rationale_key = "positioning_rationale_plateau"
if trend.comparison_available:
# Constructive Adaptation: Increasing load with positive/stable consistency
if trend.distance_delta_pct > 5.0 and trend.consistency_delta >= -5.0:
status = "CONSTRUCTIVE_ADAPTATION"
rationale_key = "positioning_rationale_constructive_adaptation"
# Strain: High load increase or high HR increase
elif trend.distance_delta_pct > 20.0 or (trend.hr_delta and trend.hr_delta > 5.0):
status = "STRAIN"
rationale_key = "positioning_rationale_strain"
# Productive Load: Stable or slightly increasing load
elif trend.distance_delta_pct >= -5.0:
status = "PRODUCTIVE_LOAD"
rationale_key = "positioning_rationale_productive_load"
else:
status = "BASELINE_BUILDING"
rationale_key = "positioning_rationale_building"
training_phase = self.detect_training_phase(snapshot, trend)
return WeeklyPositioning(
status=status,
rationale=rationale_key, # Store key here
signal_strength=1.0 if trend.comparison_available else 0.5,
training_phase=training_phase
)
def build_positioning_view(
snapshot: WeeklySnapshot,
trend: WeeklyTrend,
positioning: WeeklyPositioning,
goal_progress: Optional[dict] = None,
language: str = "en"
) -> dict:
"""
Builds a UI-ready positioning view using BOTH absolute state (snapshot)
and relative changes (trend). This restores coaching-quality output.
"""
positioning = PositioningEngine().compute(snapshot, trend)
# --- Absolute context (snapshot) ---
total_distance = snapshot.total_distance_km or 0
run_count = snapshot.run_count or 0
avg_pace = snapshot.avg_pace_sec_per_km
avg_hr = snapshot.avg_hr
# --- Relative context (trend) ---
distance_delta = trend.distance_delta_pct or 0
pace_delta = trend.pace_delta_s_per_km or 0
hr_delta = trend.hr_delta or 0
freq_delta = int(trend.frequency_delta or 0)
consistency_delta = trend.consistency_delta or 0
# --- Headline (context-aware) ---
if run_count <= 1:
headline = get_text("positioning_headline_building", language)
elif total_distance < 5:
headline = f"Your training volume is currently low at {total_distance:.1f} km."
else:
status_lower = positioning.status.lower()
headline = get_text(f"positioning_headline_{status_lower}", language)
# --- State ---
status_icons = {
"CONSTRUCTIVE_ADAPTATION": "π’",
"PRODUCTIVE_LOAD": "π‘",
"STRAIN": "π΄",
"PLATEAU": "βͺ",
"BASELINE_BUILDING": "βͺ",
}
status_icon = status_icons.get(positioning.status, "βͺ")
status_name = get_text(f"positioning_status_{positioning.status.lower()}", language)
# --- Health ---
if hr_delta > 5:
health_signal = f"π΄ {get_text('health_strain', language)}"
else:
health_signal = f"π’ {get_text('health_stable', language)}"
# --- Goal trajectory ---
if goal_progress:
if positioning.status == "BASELINE_BUILDING":
goal_trajectory = get_text("trajectory_establishing", language)
elif positioning.status == "CONSTRUCTIVE_ADAPTATION":
goal_trajectory = f"π― {get_text('trajectory_improving', language)}"
else:
goal_trajectory = f"π― {get_text('trajectory_maintaining', language)}"
else:
goal_trajectory = get_text("trajectory_no_goal", language)
# --- Forward focus ---
forward_focus = get_text(f"positioning_forward_focus_{positioning.status.lower()}", language)
# --- Narrative insight ---
insight = get_text(positioning.rationale, language)
# --- Evidence (enriched with absolute + relative) ---
evidence = {
"distance": f"{total_distance:.1f} km ({distance_delta:+.1f}%)",
"pace": f"{pace_delta:+.1f} sec/km" if pace_delta else None,
"hr": f"{hr_delta:+.1f} bpm" if hr_delta else None,
"frequency": f"{run_count} runs ({freq_delta:+d})",
"consistency": f"{int(consistency_delta):+d}",
}
# Remove empty evidence fields
evidence = {k: v for k, v in evidence.items() if v is not None}
return {
"headline": headline,
"state": f"{status_icon} {status_name}",
"health_signal": health_signal,
"goal_trajectory": goal_trajectory,
"training_phase": positioning.training_phase.value if hasattr(positioning.training_phase, "value") else positioning.training_phase,
"forward_focus": forward_focus,
"trajectory": get_text(f"positioning_trajectory_{positioning.status.lower()}", language),
"insight": insight,
"evidence": evidence if positioning.status != "BASELINE_BUILDING" else None,
}
|