Spaces:
Running
Running
File size: 5,537 Bytes
557ee65 | 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 | from datetime import date, timedelta
from typing import List, Dict, Any, Optional, Union
import uuid
import config
from domain.training.planned_session import PlannedSession
from domain.training.run import Run
from persistence.repositories.planned_session_repository import PlannedSessionRepository
from persistence.repositories.goal_repo import SqlGoalRepository
from persistence.repositories.runner_repo import RunnerRepository
from engines import structure_engine
from observability import logger as obs_logger
from observability import components as obs_components
class StructureService:
"""
Orchestrates weekly structure integrity tracking.
Gated by storage capability.
"""
def __init__(
self,
planned_repo: PlannedSessionRepository,
goal_repo: SqlGoalRepository,
runner_repo: RunnerRepository,
):
self.planned_repo = planned_repo
self.goal_repo = goal_repo
self.runner_repo = runner_repo
def ensure_week_initialized(
self, runner_id: uuid.UUID, week_start: date
) -> List[PlannedSession]:
"""
Generates and persists a template if no sessions exist for the week.
"""
with obs_logger.start_span("structure_service.ensure_week_initialized", component=obs_components.SERVICE):
sessions = self.planned_repo.get_sessions_for_week(runner_id, week_start)
if not sessions:
obs_logger.log_event(
"info",
"No sessions found for week, generating template",
event="week_template_needed",
)
# Fetch context for template generation
goal = self.goal_repo.get_active_goal(runner_id)
profile = self.runner_repo.get_runner_profile(runner_id)
sessions = structure_engine.generate_week_template(
runner_id=runner_id, week_start=week_start, goal=goal, profile=profile
)
self.planned_repo.create_sessions_bulk(sessions)
obs_logger.log_event(
"info",
"Week template generated and persisted",
event="week_template_generated",
fields={"runner_id": str(runner_id), "week_start": week_start.isoformat()},
)
return sessions
def process_run_for_structure(self, run: Run, runner_id: uuid.UUID) -> None:
"""
Matches a new run to planned sessions and updates status.
"""
with obs_logger.start_span("structure_service.process_run", component=obs_components.SERVICE):
if not run.start_time:
obs_logger.log_event(
"warning",
"Skipping structure processing: run missing start_time",
fields={"run_id": str(run.id)},
)
return
# Determine week_start (Monday)
run_date = run.start_time.date()
week_start = run_date - timedelta(days=run_date.weekday())
sessions = self.ensure_week_initialized(runner_id, week_start)
match_id = structure_engine.match_run_to_session(run, sessions)
if match_id:
self.planned_repo.mark_completed(match_id, run.id)
obs_logger.log_event(
"info",
"Run matched to planned session",
event="session_matched",
fields={"run_id": str(run.id), "session_id": str(match_id)},
)
def compute_structure_status(
self, runner_id: uuid.UUID, week_start: date, weekly_volume: float, goal_volume: float
) -> Dict[str, Any]:
"""
Computes structure DTO for UI/Snapshot.
Does not persist.
"""
# Always return a DTO, even if storage is disabled (will be empty)
sessions = self.planned_repo.get_sessions_for_week(runner_id, week_start)
if not sessions:
return {
"weekday_completed": 0,
"weekday_total": 0,
"long_run_completed": False,
"classification": "reset_week",
"km_remaining": max(0.0, goal_volume - weekly_volume),
}
weekday_sessions = [s for s in sessions if s.session_type == "weekday"]
long_run_session = next((s for s in sessions if s.session_type == "long_run"), None)
weekday_completed = sum(1 for s in weekday_sessions if s.completed_run_id)
long_run_completed = (
long_run_session.completed_run_id is not None if long_run_session else False
)
with obs_logger.start_span("structure_engine.classify_week", component=obs_components.DOMAIN):
classification = structure_engine.classify_week(sessions, weekly_volume, goal_volume)
obs_logger.log_event(
"info",
"Week structure classified",
event="week_classified",
fields={
"runner_id": str(runner_id),
"week_start": week_start.isoformat(),
"classification": classification,
},
)
return {
"weekday_completed": weekday_completed,
"weekday_total": len(weekday_sessions),
"long_run_completed": long_run_completed,
"classification": classification,
"km_remaining": max(0.0, goal_volume - weekly_volume),
}
|