From 66f9d12d992ffff722546a6bba7eebb8ed08c6ee Mon Sep 17 00:00:00 2001 From: Sergey Shchukin Date: Thu, 9 Apr 2026 02:41:44 +0300 Subject: [PATCH] Add HealthKit full-sync pipeline with recovery model, load_state_v2 and readiness computation --- README.md | 48 +++-- backend/README.md | 5 +- backend/ROADMAP.md | 5 +- backend/backend/app.py | 80 +++++++- backend/backend/schemas/healthkit.py | 51 +++++ .../backend/services/health_recovery_daily.py | 176 ++++++++++++++++++ backend/backend/services/healthkit_ingest.py | 36 ++++ .../backend/services/healthkit_pipeline.py | 68 +++++++ .../backend/services/healthkit_processing.py | 166 +++++++++++++++++ backend/backend/services/load_state_v2.py | 161 ++++++++++++++++ backend/backend/services/readiness_daily.py | 160 ++++++++++++++++ docs/ai/GLOSSARY.md | 25 ++- docs/ai/SYSTEM_MAP.md | 23 +-- docs/architecture/ARCHITECTURE.md | 33 ++-- docs/architecture/OPEN_DECISIONS.md | 36 ++-- docs/data/DATA_MODEL.md | 45 ++++- docs/models/METRICS.md | 96 +++++++--- docs/models/READINESS_MODEL.md | 92 ++++++--- docs/models/RIDE_BRIEFING.md | 28 +-- docs/models/current-metrics-methodology.md | 154 +++++++++++---- docs/models/model_v2_architecture.md | 8 +- sql/analytics/sql_load_state_daily_v2.sql | 21 +++ sql/analytics/sql_readiness_daily.sql | 21 +++ sql/health/sql_health_hrv_sample.sql | 14 ++ sql/health/sql_health_recovery_daily.sql | 21 +++ sql/health/sql_health_resting_hr_daily.sql | 15 ++ sql/health/sql_health_sleep_night.sql | 22 +++ sql/health/sql_health_weight_measurement.sql | 14 ++ sql/ingestion/sql_healthkit_ingest_raw.sql | 14 ++ 29 files changed, 1451 insertions(+), 187 deletions(-) create mode 100644 backend/backend/schemas/healthkit.py create mode 100644 backend/backend/services/health_recovery_daily.py create mode 100644 backend/backend/services/healthkit_ingest.py create mode 100644 backend/backend/services/healthkit_pipeline.py create mode 100644 backend/backend/services/healthkit_processing.py create mode 100644 backend/backend/services/load_state_v2.py create mode 100644 backend/backend/services/readiness_daily.py create mode 100644 sql/analytics/sql_load_state_daily_v2.sql create mode 100644 sql/analytics/sql_readiness_daily.sql create mode 100644 sql/health/sql_health_hrv_sample.sql create mode 100644 sql/health/sql_health_recovery_daily.sql create mode 100644 sql/health/sql_health_resting_hr_daily.sql create mode 100644 sql/health/sql_health_sleep_night.sql create mode 100644 sql/health/sql_health_weight_measurement.sql create mode 100644 sql/ingestion/sql_healthkit_ingest_raw.sql diff --git a/README.md b/README.md index 1ad6155..03e320d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > A system for analyzing training load, estimating athlete state, and supporting training decisions. > -> `signal -> state -> readiness -> decision` +> `signal -> load/recovery state -> readiness -> decision` ## Core Idea @@ -14,8 +14,8 @@ It is an engineering system designed to support decisions through explicit, repr ## What Human Engine Does - Collects training data -- Estimates physiological state -- Calculates readiness +- Estimates load and recovery state +- Calculates readiness and good-day probability - Supports training load decisions ## What the System Is @@ -41,7 +41,10 @@ The system is currently in a stabilization phase. The current setup includes: - Backend built with FastAPI - PostgreSQL - Strava ingestion pipeline +- Health recovery ingestion and normalization - Raw data storage +- Daily load and recovery feature layer +- Model V2 architecture for readiness - Docker deployment - Public API exposed through a VPS @@ -58,19 +61,22 @@ See: [docs/ai/CURRENT_PRIORITIES.md](docs/ai/CURRENT_PRIORITIES.md) ### Data Flow ```text -Strava - | - v -Webhook - | - v -Backend - | - v -PostgreSQL - | - v -Metrics / Models (next) +Strava + HealthKit + | + v + Backend + | + v + PostgreSQL + | + v +Normalized / Daily Features + | + v +Model V2 + | + v +Readiness / Insights ``` ### Infrastructure @@ -115,8 +121,9 @@ docs/ system documentation ### Core Docs - [backend/README.md](backend/README.md) -- [backend/ARCHITECTURE.md](backend/ARCHITECTURE.md) +- [docs/architecture/ARCHITECTURE.md](docs/architecture/ARCHITECTURE.md) - [backend/ROADMAP.md](backend/ROADMAP.md) +- [docs/models/model_v2_architecture.md](docs/models/model_v2_architecture.md) ### Product and AI Context @@ -128,9 +135,10 @@ docs/ system documentation ## Short Roadmap - Streams ingestion -- Feature extraction -- Training load metrics: TSS, CTL, ATL -- Readiness model +- Recovery data normalization +- Load model v2: nonlinear load, fitness, fast/slow fatigue +- Readiness model v2 +- Good day probability - Prediction engine - iOS client diff --git a/backend/README.md b/backend/README.md index b5d4a25..1848fb9 100644 --- a/backend/README.md +++ b/backend/README.md @@ -112,8 +112,9 @@ sql_*.sql Ближайшие этапы развития проекта: - загрузка streams данных из Strava -- расчет тренировочных метрик (TSS, CTL, ATL) -- модель тренировочной адаптации +- нормализация recovery-данных +- расчет тренировочных метрик и load state v2 +- readiness model v2 и probability layer - API для аналитики - мобильное приложение (iOS) diff --git a/backend/ROADMAP.md b/backend/ROADMAP.md index c264470..481dfa6 100644 --- a/backend/ROADMAP.md +++ b/backend/ROADMAP.md @@ -2,7 +2,8 @@ Next steps • activity streams ingestion • feature extraction -• training load metrics -• CTL / ATL +• recovery normalization +• training load metrics and load state v2 +• readiness and good day probability • performance model • iOS client diff --git a/backend/backend/app.py b/backend/backend/app.py index fa09d8d..455d2de 100644 --- a/backend/backend/app.py +++ b/backend/backend/app.py @@ -30,7 +30,13 @@ fetch_activity_streams, list_activities, ) - +from backend.schemas.healthkit import HealthIngestResponse, HealthSyncPayload +from backend.services.healthkit_ingest import save_healthkit_ingest_raw +from backend.services.healthkit_processing import process_latest_healthkit_raw +from backend.services.health_recovery_daily import recompute_health_recovery_daily_for_date +from backend.services.load_state_v2 import recompute_load_state_daily_v2 +from backend.services.readiness_daily import recompute_readiness_daily_for_date +from backend.services.healthkit_pipeline import ingest_and_process_healthkit_payload app = FastAPI(title="Human Engine API", version="0.1.0") @@ -1150,3 +1156,75 @@ def debug_daily_readiness(user_id: str): except psycopg.Error as e: raise HTTPException(status_code=500, detail=f"db error: {e}") +@app.post("/api/v1/healthkit/ingest/{user_id}", response_model=HealthIngestResponse) +def ingest_healthkit_payload(user_id: str, payload: HealthSyncPayload): + try: + save_healthkit_ingest_raw(user_id=user_id, payload=payload) + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"failed to persist healthkit payload: {str(e)[:300]}", + ) from e + + return HealthIngestResponse( + user_id=user_id, + sleep_nights_count=len(payload.sleepNights), + resting_hr_count=len(payload.restingHeartRateDaily), + hrv_count=len(payload.hrvSamples), + latest_weight_included=payload.latestWeight is not None, + ) + +@app.post("/api/v1/healthkit/process-latest/{user_id}") +def process_latest_healthkit_payload(user_id: str): + return process_latest_healthkit_raw(user_id=user_id) + + +@app.post("/api/v1/healthkit/recovery-daily/{user_id}/{target_date}") +def recompute_health_recovery_daily_endpoint(user_id: str, target_date: str): + return recompute_health_recovery_daily_for_date( + user_id=user_id, + target_date=target_date, + ) + + +@app.post("/api/v1/model/load-state-v2/{user_id}") +def recompute_load_state_daily_v2_endpoint(user_id: str): + return recompute_load_state_daily_v2(user_id=user_id) + + +@app.post("/api/v1/model/readiness-daily/{user_id}/{target_date}") +def recompute_readiness_daily_endpoint(user_id: str, target_date: str): + return recompute_readiness_daily_for_date( + user_id=user_id, + target_date=target_date, + ) + + +@app.post("/api/v1/healthkit/ingest-and-process/{user_id}") +def ingest_and_process_healthkit_payload_endpoint(user_id: str, payload: HealthSyncPayload): + try: + return ingest_and_process_healthkit_payload( + user_id=user_id, + payload=payload, + ) + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"failed to ingest and process healthkit payload: {str(e)[:300]}", + ) from e + + +@app.post("/api/v1/healthkit/full-sync/{user_id}") +def full_sync_healthkit_payload_endpoint(user_id: str, payload: HealthSyncPayload): + try: + result = ingest_and_process_healthkit_payload( + user_id=user_id, + payload=payload, + ) + print("FULL_SYNC_RESULT:", result) + return result + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"failed to run healthkit full sync: {str(e)[:300]}", + ) from e \ No newline at end of file diff --git a/backend/backend/schemas/healthkit.py b/backend/backend/schemas/healthkit.py new file mode 100644 index 0000000..a4797c1 --- /dev/null +++ b/backend/backend/schemas/healthkit.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from datetime import date, datetime +from typing import List, Optional + +from pydantic import BaseModel, Field + + +class SleepNightDTO(BaseModel): + wakeDate: date + sleepStart: datetime + sleepEnd: datetime + totalSleepMinutes: float + awakeMinutes: float + coreMinutes: float + remMinutes: float + deepMinutes: float + inBedMinutes: Optional[float] = None + + +class RestingHRDailyDTO(BaseModel): + date: date + bpm: float + + +class HRVSampleDTO(BaseModel): + startAt: datetime + valueMs: float + + +class LatestWeightDTO(BaseModel): + measuredAt: datetime + kilograms: float + + +class HealthSyncPayload(BaseModel): + generatedAt: datetime + timezone: str = Field(min_length=1) + sleepNights: List[SleepNightDTO] = Field(default_factory=list) + restingHeartRateDaily: List[RestingHRDailyDTO] = Field(default_factory=list) + hrvSamples: List[HRVSampleDTO] = Field(default_factory=list) + latestWeight: Optional[LatestWeightDTO] = None + + +class HealthIngestResponse(BaseModel): + ok: bool = True + user_id: str + sleep_nights_count: int + resting_hr_count: int + hrv_count: int + latest_weight_included: bool \ No newline at end of file diff --git a/backend/backend/services/health_recovery_daily.py b/backend/backend/services/health_recovery_daily.py new file mode 100644 index 0000000..ba80544 --- /dev/null +++ b/backend/backend/services/health_recovery_daily.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import HTTPException + +from backend.db import get_conn + + +def _compute_recovery_score_simple( + sleep_minutes: float | None, + resting_hr_bpm: float | None, + hrv_daily_median_ms: float | None, +) -> float | None: + # Простейшая baseline-free версия recovery score. + # Это временный heuristic score, не финальная модель. + if sleep_minutes is None and resting_hr_bpm is None and hrv_daily_median_ms is None: + return None + + score = 50.0 + + if sleep_minutes is not None: + # 8 часов сна как условная хорошая точка. + score += min(20.0, max(-20.0, (sleep_minutes - 480.0) / 12.0)) + + if resting_hr_bpm is not None: + # Чем ниже resting HR, тем лучше, в очень грубой форме. + score += min(15.0, max(-15.0, (60.0 - resting_hr_bpm) * 1.2)) + + if hrv_daily_median_ms is not None: + # Чем выше HRV, тем лучше, без baseline пока очень грубо. + score += min(15.0, max(-15.0, (hrv_daily_median_ms - 50.0) * 0.5)) + + return max(0.0, min(100.0, round(score, 1))) + + +def recompute_health_recovery_daily_for_date(user_id: str, target_date: str) -> dict[str, Any]: + with get_conn() as conn: + with conn.cursor() as cur: + # Sleep for target_date + cur.execute( + """ + select + total_sleep_minutes, + awake_minutes, + rem_minutes, + deep_minutes + from health_sleep_night + where user_id = %s + and wake_date = %s; + """, + (user_id, target_date), + ) + sleep_row = cur.fetchone() + + # Resting HR for target_date + cur.execute( + """ + select bpm + from health_resting_hr_daily + where user_id = %s + and date = %s; + """, + (user_id, target_date), + ) + resting_hr_row = cur.fetchone() + + # HRV median for target_date + cur.execute( + """ + select percentile_cont(0.5) within group (order by value_ms) + from health_hrv_sample + where user_id = %s + and sample_start_at::date = %s; + """, + (user_id, target_date), + ) + hrv_row = cur.fetchone() + + # Latest weight on or before target_date + cur.execute( + """ + select kilograms + from health_weight_measurement + where user_id = %s + and measured_at::date <= %s + order by measured_at desc + limit 1; + """, + (user_id, target_date), + ) + weight_row = cur.fetchone() + + sleep_minutes = None + awake_minutes = None + rem_minutes = None + deep_minutes = None + + if sleep_row: + sleep_minutes, awake_minutes, rem_minutes, deep_minutes = sleep_row + + resting_hr_bpm = resting_hr_row[0] if resting_hr_row else None + hrv_daily_median_ms = hrv_row[0] if hrv_row and hrv_row[0] is not None else None + weight_kg = weight_row[0] if weight_row else None + + if ( + sleep_minutes is None + and resting_hr_bpm is None + and hrv_daily_median_ms is None + and weight_kg is None + ): + raise HTTPException( + status_code=404, + detail=f"no health data found for user_id={user_id} date={target_date}", + ) + + recovery_score_simple = _compute_recovery_score_simple( + sleep_minutes=sleep_minutes, + resting_hr_bpm=resting_hr_bpm, + hrv_daily_median_ms=hrv_daily_median_ms, + ) + + cur.execute( + """ + insert into health_recovery_daily ( + user_id, + date, + sleep_minutes, + awake_minutes, + rem_minutes, + deep_minutes, + resting_hr_bpm, + hrv_daily_median_ms, + weight_kg, + recovery_score_simple, + updated_at + ) + values ( + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, now() + ) + on conflict (user_id, date) do update set + sleep_minutes = excluded.sleep_minutes, + awake_minutes = excluded.awake_minutes, + rem_minutes = excluded.rem_minutes, + deep_minutes = excluded.deep_minutes, + resting_hr_bpm = excluded.resting_hr_bpm, + hrv_daily_median_ms = excluded.hrv_daily_median_ms, + weight_kg = excluded.weight_kg, + recovery_score_simple = excluded.recovery_score_simple, + updated_at = now(); + """, + ( + user_id, + target_date, + sleep_minutes, + awake_minutes, + rem_minutes, + deep_minutes, + resting_hr_bpm, + hrv_daily_median_ms, + weight_kg, + recovery_score_simple, + ), + ) + conn.commit() + + return { + "ok": True, + "user_id": user_id, + "date": target_date, + "sleep_minutes": sleep_minutes, + "resting_hr_bpm": resting_hr_bpm, + "hrv_daily_median_ms": hrv_daily_median_ms, + "weight_kg": weight_kg, + "recovery_score_simple": recovery_score_simple, + } \ No newline at end of file diff --git a/backend/backend/services/healthkit_ingest.py b/backend/backend/services/healthkit_ingest.py new file mode 100644 index 0000000..fa42d6c --- /dev/null +++ b/backend/backend/services/healthkit_ingest.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import json + +from backend.db import get_conn +from backend.schemas.healthkit import HealthSyncPayload + + +def save_healthkit_ingest_raw(*, user_id: str, payload: HealthSyncPayload) -> None: + # Сохраняем payload как есть в raw-слой. + # Это базовый ingestion для дальнейшей нормализации и recovery processing. + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute( + """ + insert into healthkit_ingest_raw ( + user_id, + generated_at, + timezone, + payload_json + ) + values ( + %s, + %s, + %s, + %s::jsonb + ); + """, + ( + user_id, + payload.generatedAt, + payload.timezone, + json.dumps(payload.model_dump(mode="json")), + ), + ) + conn.commit() \ No newline at end of file diff --git a/backend/backend/services/healthkit_pipeline.py b/backend/backend/services/healthkit_pipeline.py new file mode 100644 index 0000000..50a355c --- /dev/null +++ b/backend/backend/services/healthkit_pipeline.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from typing import Any + +from backend.schemas.healthkit import HealthSyncPayload +from backend.services.health_recovery_daily import recompute_health_recovery_daily_for_date +from backend.services.healthkit_ingest import save_healthkit_ingest_raw +from backend.services.healthkit_processing import process_latest_healthkit_raw +from backend.services.readiness_daily import recompute_readiness_daily_for_date + + +def _collect_affected_dates(payload: HealthSyncPayload) -> list[str]: + dates: set[str] = set() + + for item in payload.sleepNights: + dates.add(str(item.wakeDate)) + + for item in payload.restingHeartRateDaily: + dates.add(str(item.date)) + + for item in payload.hrvSamples: + dates.add(item.startAt.date().isoformat()) + + if payload.latestWeight is not None: + dates.add(payload.latestWeight.measuredAt.date().isoformat()) + + return sorted(dates) + + +def ingest_and_process_healthkit_payload(user_id: str, payload: HealthSyncPayload) -> dict[str, Any]: + # 1. Raw ingest + save_healthkit_ingest_raw(user_id=user_id, payload=payload) + + # 2. Latest raw -> normalized tables + processing_result = process_latest_healthkit_raw(user_id=user_id) + + # 3. Determine affected dates from payload + affected_dates = _collect_affected_dates(payload) + + recovery_results = [] + readiness_results = [] + + # 4. Recompute recovery + readiness for all affected dates + for target_date in affected_dates: + recovery_result = recompute_health_recovery_daily_for_date( + user_id=user_id, + target_date=target_date, + ) + recovery_results.append(recovery_result) + + readiness_result = recompute_readiness_daily_for_date( + user_id=user_id, + target_date=target_date, + ) + readiness_results.append(readiness_result) + + return { + "ok": True, + "user_id": user_id, + "affected_dates": affected_dates, + "sleep_nights_count": len(payload.sleepNights), + "resting_hr_count": len(payload.restingHeartRateDaily), + "hrv_count": len(payload.hrvSamples), + "latest_weight_included": payload.latestWeight is not None, + "normalized": processing_result, + "recovery_days_recomputed": len(recovery_results), + "readiness_days_recomputed": len(readiness_results), + } \ No newline at end of file diff --git a/backend/backend/services/healthkit_processing.py b/backend/backend/services/healthkit_processing.py new file mode 100644 index 0000000..2e8d580 --- /dev/null +++ b/backend/backend/services/healthkit_processing.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import HTTPException + +from backend.db import get_conn + + +def process_latest_healthkit_raw(user_id: str) -> dict[str, Any]: + # Берем последний raw payload пользователя и раскладываем по нормализованным таблицам. + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute( + """ + select payload_json + from healthkit_ingest_raw + where user_id = %s + order by received_at desc, id desc + limit 1; + """, + (user_id,), + ) + row = cur.fetchone() + + if not row: + raise HTTPException( + status_code=404, + detail=f"healthkit raw payload not found for user_id={user_id}", + ) + + payload = row[0] + + sleep_nights = payload.get("sleepNights", []) + resting_hr_daily = payload.get("restingHeartRateDaily", []) + hrv_samples = payload.get("hrvSamples", []) + latest_weight = payload.get("latestWeight") + + sleep_count = 0 + resting_hr_count = 0 + hrv_count = 0 + weight_count = 0 + + for item in sleep_nights: + cur.execute( + """ + insert into health_sleep_night ( + user_id, + wake_date, + sleep_start_at, + sleep_end_at, + total_sleep_minutes, + awake_minutes, + core_minutes, + rem_minutes, + deep_minutes, + in_bed_minutes, + updated_at + ) + values ( + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, now() + ) + on conflict (user_id, wake_date) do update set + sleep_start_at = excluded.sleep_start_at, + sleep_end_at = excluded.sleep_end_at, + total_sleep_minutes = excluded.total_sleep_minutes, + awake_minutes = excluded.awake_minutes, + core_minutes = excluded.core_minutes, + rem_minutes = excluded.rem_minutes, + deep_minutes = excluded.deep_minutes, + in_bed_minutes = excluded.in_bed_minutes, + updated_at = now(); + """, + ( + user_id, + item["wakeDate"], + item["sleepStart"], + item["sleepEnd"], + item["totalSleepMinutes"], + item["awakeMinutes"], + item["coreMinutes"], + item["remMinutes"], + item["deepMinutes"], + item.get("inBedMinutes"), + ), + ) + sleep_count += 1 + + for item in resting_hr_daily: + cur.execute( + """ + insert into health_resting_hr_daily ( + user_id, + date, + bpm, + updated_at + ) + values ( + %s, %s, %s, now() + ) + on conflict (user_id, date) do update set + bpm = excluded.bpm, + updated_at = now(); + """, + ( + user_id, + item["date"], + item["bpm"], + ), + ) + resting_hr_count += 1 + + for item in hrv_samples: + cur.execute( + """ + insert into health_hrv_sample ( + user_id, + sample_start_at, + value_ms + ) + values ( + %s, %s, %s + ) + on conflict (user_id, sample_start_at) do update set + value_ms = excluded.value_ms; + """, + ( + user_id, + item["startAt"], + item["valueMs"], + ), + ) + hrv_count += 1 + + if latest_weight is not None: + cur.execute( + """ + insert into health_weight_measurement ( + user_id, + measured_at, + kilograms + ) + values ( + %s, %s, %s + ) + on conflict (user_id, measured_at) do update set + kilograms = excluded.kilograms; + """, + ( + user_id, + latest_weight["measuredAt"], + latest_weight["kilograms"], + ), + ) + weight_count = 1 + + conn.commit() + + return { + "ok": True, + "user_id": user_id, + "sleep_nights_processed": sleep_count, + "resting_hr_processed": resting_hr_count, + "hrv_processed": hrv_count, + "weight_processed": weight_count, + } \ No newline at end of file diff --git a/backend/backend/services/load_state_v2.py b/backend/backend/services/load_state_v2.py new file mode 100644 index 0000000..5ae009c --- /dev/null +++ b/backend/backend/services/load_state_v2.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import math +from typing import Any + +from backend.db import get_conn + + +TAU_FITNESS = 40.0 +TAU_FATIGUE_FAST = 4.0 +TAU_FATIGUE_SLOW = 9.0 + +WEIGHT_FATIGUE_FAST = 0.65 +WEIGHT_FATIGUE_SLOW = 0.35 + + +def _transform_tss_nonlinear(tss: float | None) -> float: + return tss or 0.0 + + +def recompute_load_state_daily_v2(user_id: str) -> dict[str, Any]: + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute( + """ + with bounds as ( + select + coalesce(t.min_date, r.min_recovery_date) as min_date, + greatest( + coalesce(t.max_training_date, r.max_recovery_date), + coalesce(r.max_recovery_date, t.max_training_date) + ) as max_date + from ( + select + min(date) as min_date, + max(date) as max_training_date + from daily_training_load + where user_id = %s + ) t + cross join ( + select + min(date) as min_recovery_date, + max(date) as max_recovery_date + from health_recovery_daily + where user_id = %s + ) r + ), + calendar as ( + select generate_series( + (select min_date from bounds), + (select max_date from bounds), + interval '1 day' + )::date as date + ) + select + c.date, + coalesce(dtl.tss, 0) as tss + from calendar c + left join daily_training_load dtl + on dtl.user_id = %s + and dtl.date = c.date + order by c.date asc; + """, + (user_id, user_id, user_id), + ) + rows = cur.fetchall() + + if not rows: + return { + "ok": True, + "user_id": user_id, + "days_processed": 0, + "last_date": None, + } + + fitness_prev = 0.0 + fatigue_fast_prev = 0.0 + fatigue_slow_prev = 0.0 + + processed = 0 + last_date = None + + for row_date, tss in rows: + load_input_nonlinear = _transform_tss_nonlinear(tss) + + fitness = fitness_prev + ( + load_input_nonlinear - fitness_prev + ) / TAU_FITNESS + fatigue_fast = fatigue_fast_prev + ( + load_input_nonlinear - fatigue_fast_prev + ) / TAU_FATIGUE_FAST + fatigue_slow = fatigue_slow_prev + ( + load_input_nonlinear - fatigue_slow_prev + ) / TAU_FATIGUE_SLOW + + fatigue_total = ( + WEIGHT_FATIGUE_FAST * fatigue_fast + + WEIGHT_FATIGUE_SLOW * fatigue_slow + ) + freshness = fitness - fatigue_total + + cur.execute( + """ + insert into load_state_daily_v2 ( + user_id, + date, + tss, + load_input_nonlinear, + fitness, + fatigue_fast, + fatigue_slow, + fatigue_total, + freshness, + version, + updated_at + ) + values ( + %s, %s, %s, %s, %s, %s, %s, %s, %s, 'v2', now() + ) + on conflict (user_id, date, version) do update set + tss = excluded.tss, + load_input_nonlinear = excluded.load_input_nonlinear, + fitness = excluded.fitness, + fatigue_fast = excluded.fatigue_fast, + fatigue_slow = excluded.fatigue_slow, + fatigue_total = excluded.fatigue_total, + freshness = excluded.freshness, + updated_at = now(); + """, + ( + user_id, + row_date, + tss, + load_input_nonlinear, + fitness, + fatigue_fast, + fatigue_slow, + fatigue_total, + freshness, + ), + ) + + fitness_prev = fitness + fatigue_fast_prev = fatigue_fast + fatigue_slow_prev = fatigue_slow + + processed += 1 + last_date = row_date + + conn.commit() + + return { + "ok": True, + "user_id": user_id, + "days_processed": processed, + "last_date": str(last_date) if last_date else None, + "last_fitness": fitness_prev, + "last_fatigue_fast": fatigue_fast_prev, + "last_fatigue_slow": fatigue_slow_prev, + "last_freshness": freshness, + } diff --git a/backend/backend/services/readiness_daily.py b/backend/backend/services/readiness_daily.py new file mode 100644 index 0000000..4c4b3a9 --- /dev/null +++ b/backend/backend/services/readiness_daily.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import json +from typing import Any + +from fastapi import HTTPException + +from backend.db import get_conn + + +def _clamp(value: float, low: float, high: float) -> float: + return max(low, min(high, value)) + + +def _normalize_freshness(freshness: float | None) -> float | None: + # Переводим freshness в грубую шкалу 0..100. + # Это временная эвристика для V2, пока без probability calibration. + if freshness is None: + return None + + return _clamp(50.0 + freshness, 0.0, 100.0) + + +def _describe_readiness_status(score: float | None) -> str: + if score is None: + return "n/a" + if score <= 24: + return "Высокая усталость" + if score <= 44: + return "Нагрузка" + if score <= 64: + return "Нормальная готовность" + if score <= 84: + return "Хорошая готовность" + return "Очень свежий" + + +def recompute_readiness_daily_for_date(user_id: str, target_date: str) -> dict[str, Any]: + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute( + """ + select freshness + from load_state_daily_v2 + where user_id = %s + and date = %s + and version = 'v2'; + """, + (user_id, target_date), + ) + load_row = cur.fetchone() + + cur.execute( + """ + select recovery_score_simple + from health_recovery_daily + where user_id = %s + and date = %s; + """, + (user_id, target_date), + ) + recovery_row = cur.fetchone() + + freshness = load_row[0] if load_row else None + recovery_score_simple = recovery_row[0] if recovery_row else None + + if freshness is None and recovery_score_simple is None: + raise HTTPException( + status_code=404, + detail=f"no load or recovery data found for user_id={user_id} date={target_date}", + ) + + freshness_norm = _normalize_freshness(freshness) + + # V2 baseline formula: + # readiness = 60% load-state + 40% recovery-state + if freshness_norm is None: + readiness_score_raw = recovery_score_simple + elif recovery_score_simple is None: + readiness_score_raw = freshness_norm + else: + readiness_score_raw = 0.6 * freshness_norm + 0.4 * recovery_score_simple + + readiness_score = ( + _clamp(round(readiness_score_raw, 1), 0.0, 100.0) + if readiness_score_raw is not None + else None + ) + + good_day_probability = ( + round(readiness_score / 100.0, 3) + if readiness_score is not None + else None + ) + + status_text = _describe_readiness_status(readiness_score) + + explanation_json = { + "freshness": freshness, + "freshness_norm": freshness_norm, + "recovery_score_simple": recovery_score_simple, + "weights": { + "freshness_norm": 0.6, + "recovery_score_simple": 0.4, + }, + "formula": "0.6 * freshness_norm + 0.4 * recovery_score_simple", + } + + cur.execute( + """ + insert into readiness_daily ( + user_id, + date, + freshness, + recovery_score_simple, + readiness_score_raw, + readiness_score, + good_day_probability, + status_text, + explanation_json, + version, + updated_at + ) + values ( + %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, 'v2', now() + ) + on conflict (user_id, date, version) do update set + freshness = excluded.freshness, + recovery_score_simple = excluded.recovery_score_simple, + readiness_score_raw = excluded.readiness_score_raw, + readiness_score = excluded.readiness_score, + good_day_probability = excluded.good_day_probability, + status_text = excluded.status_text, + explanation_json = excluded.explanation_json, + updated_at = now(); + """, + ( + user_id, + target_date, + freshness, + recovery_score_simple, + readiness_score_raw, + readiness_score, + good_day_probability, + status_text, + json.dumps(explanation_json), + ), + ) + conn.commit() + + return { + "ok": True, + "user_id": user_id, + "date": target_date, + "freshness": freshness, + "recovery_score_simple": recovery_score_simple, + "readiness_score": readiness_score, + "good_day_probability": good_day_probability, + "status_text": status_text, + } \ No newline at end of file diff --git a/docs/ai/GLOSSARY.md b/docs/ai/GLOSSARY.md index 47f3fcb..4c97ab2 100644 --- a/docs/ai/GLOSSARY.md +++ b/docs/ai/GLOSSARY.md @@ -50,7 +50,9 @@ Компоненты состояния: - Fitness (долгосрочная адаптация) -- Fatigue (краткосрочная усталость) +- Fatigue Fast (быстрый отклик усталости) +- Fatigue Slow (накопление усталости сериями тренировок) +- Fatigue Total = Fast + Slow --- @@ -60,9 +62,9 @@ Оценка текущей готовности к нагрузке. Основывается на: -- fatigue -- fitness -- недавней активности +- load state +- recovery state +- недавней активности Должна быть: - детерминированной @@ -71,6 +73,17 @@ --- +### Good Day Probability +Вероятностное представление readiness. + +Используется для: + +- мягкого rule-based маппинга в рекомендации +- снижения зависимости от жестких порогов +- explainable decision support + +--- + ### Recommendation Результат работы системы. @@ -102,7 +115,7 @@ ### Pipeline Последовательность обработки данных: -ingestion → storage → features → models → decision +ingestion → storage → normalized features → models → decision --- @@ -179,4 +192,4 @@ Large Language Model. - простых моделей - явной логики -- минимальной магии \ No newline at end of file +- минимальной магии diff --git a/docs/ai/SYSTEM_MAP.md b/docs/ai/SYSTEM_MAP.md index 439ded4..80be548 100644 --- a/docs/ai/SYSTEM_MAP.md +++ b/docs/ai/SYSTEM_MAP.md @@ -15,14 +15,12 @@ Data Engine ↓ Data Storage ↓ -Feature Extraction +Normalized / Daily Features ↓ -Physiology Model +Load Model + Recovery Model ↓ Readiness Engine ↓ -Prediction Engine (future) -↓ Recommendation ↓ Workout Outcome @@ -65,9 +63,11 @@ Model update Оценивает состояние человека. -- physiology model -- training load -- fitness / fatigue +- load model v2 +- recovery model +- fitness / fast fatigue / slow fatigue +- readiness score +- good day probability --- @@ -151,9 +151,10 @@ Human Engine — это не один алгоритм. ### Далее: -- feature layer -- physiology model -- readiness +- normalized and daily feature layer +- load state v2 +- recovery-aware readiness +- good day probability - prediction - adaptive training @@ -168,4 +169,4 @@ Human Engine — это не один алгоритм. Если не вписывается: - либо он лишний -- либо схема нарушена \ No newline at end of file +- либо схема нарушена diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index a907f7e..88fae8f 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -26,11 +26,11 @@ Backend (Data Engine) ↓ PostgreSQL (Storage) ↓ -Processing / Features (future) +Normalized / Daily Features ↓ -Modeling (future) +Model V2 ↓ -Decision layer (future) +Insight / Decision layer --- @@ -142,22 +142,26 @@ strava_activity_raw ### 6.2 Processing layer (planned) -- feature extraction -- базовые метрики +- normalizing source data +- daily feature extraction +- базовые метрики нагрузки и восстановления --- -### 6.3 Modeling layer (planned) +### 6.3 Modeling layer (implemented / evolving) -- physiology model -- training load -- fitness / fatigue +- `load_state_daily_v2` +- `readiness_daily` +- load model with nonlinear load input +- fitness + fast / slow fatigue +- readiness from load state and recovery state +- `good_day_probability` as probability layer --- ### 6.4 Decision layer (planned) -- readiness +- daily readiness summary - recommendation - ride briefing @@ -234,10 +238,9 @@ strava_activity_raw Следующие шаги: -- feature layer -- расчет метрик (TSS, CTL, ATL) -- physiology model -- readiness +- feature and normalization layer +- model v2 stabilization +- readiness and probability calibration - prediction --- @@ -271,4 +274,4 @@ strava_activity_raw Если компонент не вписывается: - либо он лишний -- либо архитектура нарушена \ No newline at end of file +- либо архитектура нарушена diff --git a/docs/architecture/OPEN_DECISIONS.md b/docs/architecture/OPEN_DECISIONS.md index 450ae61..8eccd7f 100644 --- a/docs/architecture/OPEN_DECISIONS.md +++ b/docs/architecture/OPEN_DECISIONS.md @@ -15,35 +15,39 @@ ### Context -Readiness — ключевая метрика системы. +Базовая структура readiness в model v2 уже определена: -Но не определено: +- `LoadState + RecoveryState -> Readiness -> GoodDayProbability` +- load state использует `fitness`, `fatigue_fast`, `fatigue_slow`, `freshness` +- recovery state использует sleep / HRV / resting HR aggregates + +Но все еще не определены: -- точная формула -- набор входных параметров -- баланс между простотой и точностью +- точные веса readiness formula +- калибровка probability thresholds +- схема zone mapping --- ### Options -1. Простая модель (CTL / ATL / TSB аналог) -2. Расширенная модель (учет HR, HRV, сна) -3. Гибридная модель +1. Минимальная v2: `freshness + recovery_score_simple` +2. Расширенная v2+: `freshness + recovery_score + hrv_dev + rhr_dev + sleep_score` +3. Версионируемый гибрид с explicit calibration --- ### Open questions -- какой минимальный набор данных нужен -- насколько модель должна быть объяснимой -- допустима ли аппроксимация +- какой минимальный recovery input обязателен +- как калибровать probability без black-box логики +- как versioning readiness model отражать в storage --- ### Status -open +partially resolved --- @@ -134,9 +138,9 @@ open ### Options -1. Strava-only (MVP) -2. Multi-source aggregation -3. Приоритет Apple Health +1. Strava-only +2. Strava + HealthKit recovery layer +3. Multi-source aggregation with explicit source priority --- @@ -301,4 +305,4 @@ open → decision → ADR После принятия решения: - перенос в ARCHITECTURE_DECISIONS.md -- обновление архитектуры при необходимости \ No newline at end of file +- обновление архитектуры при необходимости diff --git a/docs/data/DATA_MODEL.md b/docs/data/DATA_MODEL.md index 5cb2f85..8aa12f6 100644 --- a/docs/data/DATA_MODEL.md +++ b/docs/data/DATA_MODEL.md @@ -156,7 +156,7 @@ Streams данных: ### 5.3 daily_fitness_state -Состояние: +Legacy-состояние (V1 baseline): - CTL - ATL @@ -164,12 +164,41 @@ Streams данных: --- -### 5.4 readiness_state +### 5.4 health_recovery_daily + +Дневная recovery-агрегация: + +- sleep_minutes +- resting_hr_bpm +- hrv_daily_median_ms +- weight_kg +- recovery_score_simple + +--- + +### 5.5 load_state_daily_v2 + +Load model v2: + +- tss +- load_input_nonlinear +- fitness +- fatigue_fast +- fatigue_slow +- fatigue_total +- freshness + +--- + +### 5.6 readiness_daily Оценка готовности: -- readiness score -- readiness zone +- freshness +- recovery_score +- readiness_score +- good_day_probability +- explanation_json --- @@ -181,7 +210,9 @@ Streams данных: - ingest_job → activity_raw (1:1) - activity_raw → activity_metrics (1:1) - activity_metrics → daily_training_load (N:1) -- daily_training_load → fitness_state (N:1) +- daily_training_load → load_state_daily_v2 (N:1) +- health_recovery_daily → readiness_daily (N:1) +- load_state_daily_v2 → readiness_daily (N:1) --- @@ -197,7 +228,7 @@ Metrics ↓ Daily aggregates ↓ -Fitness state +Load state + Recovery state ↓ Readiness @@ -261,4 +292,4 @@ Readiness - как делать пересчет - как организовать versioning -(см. OPEN_DECISIONS.md) \ No newline at end of file +(см. OPEN_DECISIONS.md) diff --git a/docs/models/METRICS.md b/docs/models/METRICS.md index 7e679cf..d8ffaed 100644 --- a/docs/models/METRICS.md +++ b/docs/models/METRICS.md @@ -69,35 +69,63 @@ TSS = (Duration × NP × IF) / (FTP × 3600) × 100 --- -### 4.1 Daily Load +### 4.1 Daily Training Load Сумма TSS за день. --- -### 4.2 Acute Training Load (ATL) +### 4.2 Nonlinear Load Input -Краткосрочная нагрузка. +Для model v2 дневная нагрузка подается в load model через нелинейную функцию. -Экспоненциальное скользящее среднее: +Формула: -- период ~7 дней +```text +load_input = A * (1 - exp(-B * TSS)) +``` --- -### 4.3 Chronic Training Load (CTL) +### 4.3 Fitness -Долгосрочная нагрузка. +Долгосрочная адаптационная компонента. Экспоненциальное скользящее среднее: -- период ~42 дня +- `tau_fitness ≈ 40` + +--- + +### 4.4 Fatigue Fast + +Короткая компонента усталости. + +- `tau_fatigue_fast ≈ 2` + +--- + +### 4.5 Fatigue Slow + +Средняя по длительности компонента усталости. + +- `tau_fatigue_slow ≈ 7` + +--- + +### 4.6 Fatigue Total + +```text +fatigue_total = fatigue_fast + fatigue_slow +``` --- -### 4.4 Training Stress Balance (TSB) +### 4.7 Freshness -TSB = CTL - ATL +```text +freshness = fitness - fatigue_total +``` --- @@ -107,36 +135,51 @@ TSB = CTL - ATL ### 5.1 Fatigue -≈ ATL +В model v2 представлена как: + +- `fatigue_fast` +- `fatigue_slow` +- `fatigue_total` --- ### 5.2 Fitness -≈ CTL +`fitness` остается отдельной сглаженной компонентой load state. --- ### 5.3 Form -≈ TSB +В качестве основной прикладной метрики model v2 использует `freshness`. + +Legacy-метрики `CTL / ATL / TSB` могут использоваться только как reference baseline или для обратной совместимости. --- -## 6. Readiness (initial model) +## 6. Readiness (model v2) Readiness — ключевая метрика системы. -Начальная модель: +В model v2 readiness: + +- не равна `freshness` +- рассчитывается из `load_state + recovery_state` +- может сопровождаться `good_day_probability` -- основана на TSB -- корректируется по правилам +Базовая формула: -Пример: +```text +readiness_score_raw = + w1 * freshness + + w2 * recovery_score_simple +``` -- TSB < -20 → низкая готовность -- TSB ~ 0 → нормальная -- TSB > 10 → высокая +Probability layer: + +```text +good_day_probability = sigmoid(readiness_score_raw) +``` --- @@ -154,14 +197,14 @@ Readiness — ключевая метрика системы. Планируется добавить: -- HR-based метрики -- HRV -- сон -- recovery score +- `sleep_score_simple` +- `hrv_dev` +- `rhr_dev` +- уточненную калибровку probability / readiness zones Но: -- только после стабилизации базовой модели +- только без потери прозрачности и versioning --- @@ -171,4 +214,3 @@ Readiness — ключевая метрика системы. - фиксировать версию - не менять исторические расчеты - diff --git a/docs/models/READINESS_MODEL.md b/docs/models/READINESS_MODEL.md index b4a8f87..3b5d95e 100644 --- a/docs/models/READINESS_MODEL.md +++ b/docs/models/READINESS_MODEL.md @@ -34,34 +34,60 @@ Модель использует следующие метрики: -- CTL (fitness) -- ATL (fatigue) -- TSB (form) +- `freshness` из `load_state_daily_v2` +- `recovery_score_simple` из `health_recovery_daily` - recent training load +Дополнительно в расширенной версии могут использоваться: + +- `sleep_score_simple` +- `hrv_dev` +- `rhr_dev` + --- ## 4. Core logic Основная идея: -> readiness определяется балансом нагрузки и восстановления +> readiness определяется не только нагрузкой, а сочетанием load state и recovery state + +Базовая формула v2: + +```text +readiness_score_raw = + w1 * freshness + + w2 * recovery_score_simple +``` -Базовая формула: +Где: -TSB = CTL - ATL +- `freshness = fitness - fatigue_total` +- `fatigue_total = fatigue_fast + fatigue_slow` + +Расширенная формула v2+: + +```text +readiness_score_raw = + w1 * freshness + + w2 * recovery_score + + w3 * hrv_dev + - w4 * rhr_dev + + w5 * sleep_score +``` --- ## 5. Readiness zones -Модель делит состояние на зоны: +Модель делит состояние на зоны на основе `readiness_score` и/или `good_day_probability`. ### 5.1 Low readiness Условие: -- TSB < -20 +- низкий `readiness_score` +- низкая `good_day_probability` Интерпретация: @@ -78,7 +104,8 @@ TSB = CTL - ATL Условие: -- -20 ≤ TSB ≤ +5 +- средний `readiness_score` +- умеренная `good_day_probability` Интерпретация: @@ -94,7 +121,8 @@ TSB = CTL - ATL Условие: -- TSB > +5 +- высокий `readiness_score` +- высокая `good_day_probability` Интерпретация: @@ -108,7 +136,7 @@ TSB = CTL - ATL ## 6. Adjustments -Базовая модель может корректироваться: +Базовая модель уже включает recovery-контур и может быть расширена: --- @@ -142,12 +170,26 @@ TSB = CTL - ATL --- +### 6.4 Recovery signals + +Если: + +- sleep ниже baseline +- HRV ниже baseline +- resting HR выше baseline + +→ снижать readiness даже при приемлемом `freshness` + +--- + ## 7. Output Результат модели: -- readiness score (число или категория) -- тренировочная рекомендация +- `readiness_score` +- `good_day_probability` +- readiness zone +- вход для тренировочной рекомендации --- @@ -164,11 +206,9 @@ TSB = CTL - ATL Текущая модель: -- не учитывает HR / HRV -- не учитывает сон -- не учитывает субъективное состояние - -Это упрощенная модель (MVP). +- использует простой recovery score как текущий recovery-контур +- пока не фиксирует окончательную калибровку весов и зон +- требует дальнейшей верификации на реальных данных --- @@ -176,10 +216,9 @@ TSB = CTL - ATL Планируется: -- HR-based корректировки -- HRV -- sleep -- адаптивная модель +- калибровка весов `freshness` и `recovery_score_simple` +- явные `sleep_score_simple`, `hrv_dev`, `rhr_dev` +- уточнение зон и probability thresholds Но: @@ -194,9 +233,10 @@ TSB = CTL - ATL проверять: 1. входные данные -2. расчет CTL / ATL -3. расчет TSB -4. примененные корректировки +2. расчет `load_state_daily_v2` +3. расчет `health_recovery_daily` +4. формирование `readiness_score_raw` +5. примененные правила маппинга в zone / probability --- @@ -208,4 +248,4 @@ TSB = CTL - ATL - не нарушать deterministic поведение - быть обоснованным -Иначе — не добавлять. \ No newline at end of file +Иначе — не добавлять. diff --git a/docs/models/RIDE_BRIEFING.md b/docs/models/RIDE_BRIEFING.md index 325d67e..7b15fde 100644 --- a/docs/models/RIDE_BRIEFING.md +++ b/docs/models/RIDE_BRIEFING.md @@ -37,16 +37,17 @@ Ride briefing строится на основе: - readiness zone - readiness score +- good_day_probability +- freshness +- recovery signals - recent training load - consecutive training days -- recovery signals, если они есть в модели Минимально достаточный вход для MVP: -- CTL -- ATL -- TSB -- корректировки readiness +- `readiness_daily` +- `load_state_daily_v2` +- recovery summary из `health_recovery_daily` --- @@ -81,8 +82,8 @@ Ride briefing должен содержать: Примеры: -- высокая накопленная усталость -- нормальный баланс нагрузки и восстановления +- высокая накопленная усталость при слабом recovery signal +- сбалансированное состояние load и recovery - хорошее восстановление после снижения нагрузки --- @@ -104,7 +105,7 @@ Ride briefing должен содержать: Если readiness = low: - recommendation = rest или easy -- explanation = усталость превышает желаемый уровень +- explanation = сочетание freshness и recovery указывает на низкую готовность --- @@ -113,7 +114,7 @@ Ride briefing должен содержать: Если readiness = moderate: - recommendation = moderate -- explanation = состояние позволяет выполнить обычную нагрузку +- explanation = load state и recovery state допускают обычную нагрузку --- @@ -122,7 +123,7 @@ Ride briefing должен содержать: Если readiness = high: - recommendation = hard или key workout -- explanation = текущий баланс указывает на высокую готовность +- explanation = load state стабилен, recovery поддерживает высокую готовность --- @@ -132,7 +133,8 @@ Ride briefing должен содержать: - был резкий всплеск нагрузки - несколько дней подряд были тренировки -- наблюдается накопление fatigue +- recovery signals ухудшились +- наблюдается накопление `fatigue_total` Итоговая рекомендация может быть повышена только в пределах заранее определенных правил. @@ -146,7 +148,7 @@ Ride briefing должен содержать: - Status: Moderate readiness - Recommendation: Moderate load -- Reason: Balanced training load with no major fatigue signal +- Reason: Balanced load state with adequate recovery signal Для пользовательского интерфейса могут существовать разные представления, но логическая структура должна оставаться одинаковой. @@ -212,4 +214,4 @@ Ride briefing является частью deterministic core. - сохранять объяснимость - сохранять воспроизводимость -- не превращать вывод в black box \ No newline at end of file +- не превращать вывод в black box diff --git a/docs/models/current-metrics-methodology.md b/docs/models/current-metrics-methodology.md index c16f6a7..769fb92 100644 --- a/docs/models/current-metrics-methodology.md +++ b/docs/models/current-metrics-methodology.md @@ -1,8 +1,8 @@ # Human Engine — Current Metrics Methodology -**Version:** v1 -**Status:** baseline -**Last updated:** 2026-03-30 +**Version:** v2 baseline +**Status:** active baseline +**Last updated:** 2026-04-08 # Human Engine — методика расчета текущих метрик @@ -10,7 +10,7 @@ Этот документ кратко описывает, как в текущей версии Human Engine рассчитываются основные метрики тренировки и метрики состояния. -Документ фиксирует **текущий базовый подход**, а не целевую финальную модель. Если реализация изменится, методику нужно обновить вместе с кодом. +Документ фиксирует **текущий базовый подход для model v2**. Если реализация изменится, методику нужно обновить вместе с кодом. ## Область действия @@ -25,9 +25,12 @@ 2. **Метрики состояния** - Fitness - - Fatigue + - Fatigue Fast + - Fatigue Slow + - Fatigue Total - Freshness - Readiness + - Good Day Probability - текстовый статус --- @@ -173,10 +176,13 @@ TSS = (duration_sec * NP * IF) / (FTP * 3600) * 100 ## 3. Метрики состояния -Текущие метрики состояния в Human Engine основаны на простой модели тренировочной нагрузки с двумя сглаженными компонентами: +Текущие метрики состояния в Human Engine основаны на load model v2 и recovery-aware readiness: +- нелинейный дневной вход нагрузки -> `load_input` - долгосрочная нагрузка -> `Fitness` -- краткосрочная нагрузка -> `Fatigue` +- быстрая усталость -> `Fatigue Fast` +- средняя по длительности усталость -> `Fatigue Slow` +- итоговая готовность -> `Readiness` ### 3.1. Fitness @@ -187,10 +193,10 @@ TSS = (duration_sec * NP * IF) / (FTP * 3600) * 100 Типовая форма обновления: ```text -Fitness_new = Fitness_prev + alpha_fitness * (TSS - Fitness_prev) +Fitness_new = Fitness_prev + (load_input - Fitness_prev) / tau_fitness ``` -где `alpha_fitness` — коэффициент медленного сглаживания. +Где `tau_fitness ≈ 40` дней. Смысл: @@ -199,33 +205,68 @@ Fitness_new = Fitness_prev + alpha_fitness * (TSS - Fitness_prev) --- -### 3.2. Fatigue +### 3.2. Nonlinear load input -`Fatigue` отражает краткосрочную накопленную усталость. +Перед обновлением состояния дневной `TSS` преобразуется нелинейно: + +```text +load_input = A * (1 - exp(-B * TSS)) +``` + +Смысл: + +- рост нагрузки остается монотонным; +- очень большие значения `TSS` не раздувают модель линейно; +- 200 TSS не интерпретируется как строго 2 x 100 TSS. + +--- + +### 3.3. Fatigue Fast + +`Fatigue Fast` отражает быстрый отклик усталости на недавнюю нагрузку. Типовая форма обновления: ```text -Fatigue_new = Fatigue_prev + alpha_fatigue * (TSS - Fatigue_prev) +FatigueFast_new = FatigueFast_prev + (load_input - FatigueFast_prev) / tau_fatigue_fast ``` -где `alpha_fatigue` больше, чем `alpha_fitness`. +Где `tau_fatigue_fast ≈ 2` дня. -Смысл: +--- + +### 3.4. Fatigue Slow + +`Fatigue Slow` отражает накопление усталости сериями тренировок. + +Типовая форма обновления: -- `Fatigue` реагирует на тренировки быстрее; -- при отдыхе падает быстрее, чем `Fitness`. +```text +FatigueSlow_new = FatigueSlow_prev + (load_input - FatigueSlow_prev) / tau_fatigue_slow +``` + +Где `tau_fatigue_slow ≈ 7` дней. --- -### 3.3. Freshness +### 3.5. Fatigue Total + +Итоговая усталость в model v2: + +```text +FatigueTotal = FatigueFast + FatigueSlow +``` + +--- + +### 3.6. Freshness `Freshness` считается как разница между долгосрочной и краткосрочной компонентой. Формула: ```text -Freshness = Fitness - Fatigue +Freshness = Fitness - FatigueTotal ``` Интерпретация: @@ -233,28 +274,53 @@ Freshness = Fitness - Fatigue - положительное значение означает более свежее состояние; - отрицательное значение означает накопленную усталость. -Это упрощенная модель, которая не разделяет физиологические компоненты усталости и не использует сон, HRV или субъективное самочувствие. +Это уже не единственный вход в readiness. В model v2 `Freshness` описывает load state, но не заменяет recovery state. --- -### 3.4. Readiness +### 3.7. Readiness `Readiness` — производная прикладная метрика Human Engine, переводящая текущее состояние в шкалу готовности. -На текущем этапе `Readiness` опирается прежде всего на `Freshness` и отображается в шкале от 0 до 100. +На текущем этапе `Readiness` опирается на сочетание `Freshness` и простого recovery score. Общий принцип: -- чем ниже `Freshness`, тем ниже `Readiness`; -- чем ближе состояние к восстановленному, тем выше `Readiness`. +- низкий `Freshness` снижает `Readiness`; +- слабый recovery signal также снижает `Readiness`, даже если load state выглядит приемлемо; +- хорошее восстановление поддерживает высокий `Readiness`. + +Базовая формула: + +```text +Readiness_raw = + w1 * Freshness + + w2 * RecoveryScoreSimple +``` ### Важно -Точная функция преобразования `Freshness -> Readiness` должна быть зафиксирована в коде отдельно. В рамках текущей методики важно лишь то, что `Readiness` является **производной пользовательской метрикой**, а не прямой физиологической величиной. +Точная нормализация `Readiness_raw -> Readiness` должна быть зафиксирована в коде отдельно. В рамках текущей методики важно, что `Readiness` является **производной пользовательской метрикой**, а не прямой физиологической величиной. + +--- + +### 3.8. Good Day Probability + +Для вероятностного слоя model v2 используется: + +```text +GoodDayProbability = sigmoid(Readiness_raw) +``` + +Эта метрика нужна для: + +- более мягкого маппинга в рекомендации; +- снижения зависимости от жестких порогов; +- дальнейшей калибровки decision layer. --- -### 3.5. Текстовый статус +### 3.9. Текстовый статус Текстовый статус является интерпретацией `Readiness` для интерфейса. @@ -282,14 +348,13 @@ Freshness = Fitness - Fatigue ### Ограничения текущего подхода -1. Модель опирается в основном на `TSS` и не учитывает напрямую: - - сон - - HRV - - субъективное состояние - - тип интервалов - - различие между видами усталости +1. Recovery-контур пока использует простой aggregated score и еще не раскладывается полностью на: + - `sleep_score_simple` + - `hrv_dev` + - `rhr_dev` + - расширенные contextual signals -2. `Readiness` пока не является физиологически полной моделью готовности. Это прикладная оценка на базе нагрузки. +2. `Readiness` пока не является физиологически полной моделью готовности. Это прикладная оценка на базе нагрузки и базового recovery layer. 3. При отсутствии сырого ряда мощности точность `NP`, `IF` и `TSS` зависит от внешнего источника. @@ -318,9 +383,12 @@ Freshness = Fitness - Fatigue После применения новой тренировки система обновляет: - `fitness` -- `fatigue` +- `fatigue_fast` +- `fatigue_slow` +- `fatigue_total` - `freshness` - `readiness` +- `good_day_probability` - `status_label` Именно эти значения затем используются в интерфейсе и в будущих моделях рекомендаций. @@ -343,11 +411,12 @@ Garmin и другие внешние системы используются к В следующих итерациях методику нужно дополнить: -1. точной формулой или кодовой функцией перевода `Freshness` в `Readiness`; -2. конкретными коэффициентами сглаживания для `Fitness` и `Fatigue`; -3. правилами обработки пропусков и нулей в power stream; -4. политикой выбора и обновления `FTP`; -5. протоколом верификации против Garmin. +1. точной формулой нормализации `Readiness_raw` и `GoodDayProbability`; +2. конкретными коэффициентами `A` и `B` для `load_input`; +3. правилами расчета `RecoveryScoreSimple` и его компонентов; +4. правилами обработки пропусков и нулей в power stream; +5. политикой выбора и обновления `FTP`; +6. протоколом верификации против Garmin. --- @@ -359,10 +428,15 @@ power data + FTP -> NP -> IF -> TSS + -> load_input -> Fitness - -> Fatigue + -> Fatigue Fast + -> Fatigue Slow + -> Fatigue Total -> Freshness + + RecoveryScoreSimple -> Readiness + -> GoodDayProbability -> Status ``` @@ -370,6 +444,6 @@ power data + FTP ## Статус документа -Версия: draft v1 +Версия: draft v2 baseline Назначение: зафиксировать текущую baseline-логику расчета метрик в Human Engine Язык: русский diff --git a/docs/models/model_v2_architecture.md b/docs/models/model_v2_architecture.md index 6fce3e1..a209436 100644 --- a/docs/models/model_v2_architecture.md +++ b/docs/models/model_v2_architecture.md @@ -259,6 +259,12 @@ good_day_probability = sigmoid(readiness_score_raw) # 8. Roadmap +- стабилизировать `load_state_daily_v2` +- зафиксировать формулу `readiness_score_raw` +- откалибровать `good_day_probability` +- добавить `sleep_score_simple`, `hrv_dev`, `rhr_dev` +- синхронизировать recommendation / ride briefing с probability layer + ## Phase 1 - load_state_daily_v2 - fast / slow fatigue @@ -296,4 +302,4 @@ Model V2: Сначала объясняет нагрузку Потом корректируется сигналами восстановления -Это базовая архитектура Human Engine. \ No newline at end of file +Это базовая архитектура Human Engine. diff --git a/sql/analytics/sql_load_state_daily_v2.sql b/sql/analytics/sql_load_state_daily_v2.sql new file mode 100644 index 0000000..79c5c5a --- /dev/null +++ b/sql/analytics/sql_load_state_daily_v2.sql @@ -0,0 +1,21 @@ +create table if not exists load_state_daily_v2 ( + id bigserial primary key, + user_id text not null, + date date not null, + tss double precision not null default 0, + load_input_nonlinear double precision, + fitness double precision, + fatigue_fast double precision, + fatigue_slow double precision, + fatigue_total double precision, + freshness double precision, + version text not null default 'v2', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create unique index if not exists uq_load_state_daily_v2_user_date_version + on load_state_daily_v2(user_id, date, version); + +create index if not exists idx_load_state_daily_v2_user_date + on load_state_daily_v2(user_id, date desc); \ No newline at end of file diff --git a/sql/analytics/sql_readiness_daily.sql b/sql/analytics/sql_readiness_daily.sql new file mode 100644 index 0000000..23ac39f --- /dev/null +++ b/sql/analytics/sql_readiness_daily.sql @@ -0,0 +1,21 @@ +create table if not exists readiness_daily ( + id bigserial primary key, + user_id text not null, + date date not null, + freshness double precision, + recovery_score_simple double precision, + readiness_score_raw double precision, + readiness_score double precision, + good_day_probability double precision, + status_text text, + explanation_json jsonb, + version text not null default 'v2', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create unique index if not exists uq_readiness_daily_user_date_version + on readiness_daily(user_id, date, version); + +create index if not exists idx_readiness_daily_user_date + on readiness_daily(user_id, date desc); \ No newline at end of file diff --git a/sql/health/sql_health_hrv_sample.sql b/sql/health/sql_health_hrv_sample.sql new file mode 100644 index 0000000..28941fe --- /dev/null +++ b/sql/health/sql_health_hrv_sample.sql @@ -0,0 +1,14 @@ +create table if not exists health_hrv_sample ( + id bigserial primary key, + user_id text not null, + sample_start_at timestamptz not null, + value_ms double precision not null, + source text not null default 'healthkit', + created_at timestamptz not null default now() +); + +create unique index if not exists uq_health_hrv_sample_user_start_at + on health_hrv_sample(user_id, sample_start_at); + +create index if not exists idx_health_hrv_sample_user_start_at + on health_hrv_sample(user_id, sample_start_at desc); \ No newline at end of file diff --git a/sql/health/sql_health_recovery_daily.sql b/sql/health/sql_health_recovery_daily.sql new file mode 100644 index 0000000..e3e2dbd --- /dev/null +++ b/sql/health/sql_health_recovery_daily.sql @@ -0,0 +1,21 @@ +create table if not exists health_recovery_daily ( + id bigserial primary key, + user_id text not null, + date date not null, + sleep_minutes double precision, + awake_minutes double precision, + rem_minutes double precision, + deep_minutes double precision, + resting_hr_bpm double precision, + hrv_daily_median_ms double precision, + weight_kg double precision, + recovery_score_simple double precision, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create unique index if not exists uq_health_recovery_daily_user_date + on health_recovery_daily(user_id, date); + +create index if not exists idx_health_recovery_daily_user_date + on health_recovery_daily(user_id, date desc); \ No newline at end of file diff --git a/sql/health/sql_health_resting_hr_daily.sql b/sql/health/sql_health_resting_hr_daily.sql new file mode 100644 index 0000000..3f1846f --- /dev/null +++ b/sql/health/sql_health_resting_hr_daily.sql @@ -0,0 +1,15 @@ +create table if not exists health_resting_hr_daily ( + id bigserial primary key, + user_id text not null, + date date not null, + bpm double precision not null, + source text not null default 'healthkit', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create unique index if not exists uq_health_resting_hr_daily_user_date + on health_resting_hr_daily(user_id, date); + +create index if not exists idx_health_resting_hr_daily_user_date + on health_resting_hr_daily(user_id, date desc); \ No newline at end of file diff --git a/sql/health/sql_health_sleep_night.sql b/sql/health/sql_health_sleep_night.sql new file mode 100644 index 0000000..526909d --- /dev/null +++ b/sql/health/sql_health_sleep_night.sql @@ -0,0 +1,22 @@ +create table if not exists health_sleep_night ( + id bigserial primary key, + user_id text not null, + wake_date date not null, + sleep_start_at timestamptz not null, + sleep_end_at timestamptz not null, + total_sleep_minutes double precision not null, + awake_minutes double precision not null, + core_minutes double precision not null, + rem_minutes double precision not null, + deep_minutes double precision not null, + in_bed_minutes double precision, + source text not null default 'healthkit', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create unique index if not exists uq_health_sleep_night_user_wake_date + on health_sleep_night(user_id, wake_date); + +create index if not exists idx_health_sleep_night_user_wake_date + on health_sleep_night(user_id, wake_date desc); \ No newline at end of file diff --git a/sql/health/sql_health_weight_measurement.sql b/sql/health/sql_health_weight_measurement.sql new file mode 100644 index 0000000..4873709 --- /dev/null +++ b/sql/health/sql_health_weight_measurement.sql @@ -0,0 +1,14 @@ +create table if not exists health_weight_measurement ( + id bigserial primary key, + user_id text not null, + measured_at timestamptz not null, + kilograms double precision not null, + source text not null default 'healthkit', + created_at timestamptz not null default now() +); + +create unique index if not exists uq_health_weight_measurement_user_measured_at + on health_weight_measurement(user_id, measured_at); + +create index if not exists idx_health_weight_measurement_user_measured_at + on health_weight_measurement(user_id, measured_at desc); \ No newline at end of file diff --git a/sql/ingestion/sql_healthkit_ingest_raw.sql b/sql/ingestion/sql_healthkit_ingest_raw.sql new file mode 100644 index 0000000..e38295a --- /dev/null +++ b/sql/ingestion/sql_healthkit_ingest_raw.sql @@ -0,0 +1,14 @@ +create table if not exists healthkit_ingest_raw ( + id bigserial primary key, + user_id text not null, + generated_at timestamptz not null, + timezone text not null, + payload_json jsonb not null, + received_at timestamptz not null default now() +); + +create index if not exists idx_healthkit_ingest_raw_user_id + on healthkit_ingest_raw(user_id); + +create index if not exists idx_healthkit_ingest_raw_received_at + on healthkit_ingest_raw(received_at desc); \ No newline at end of file