## Audit-Ergebnis ### Zentrale Befunde 1. **Überlappungen werden nur bei exakt gleichen Datensätzen entfernt — nicht fachlich** - `canonical_records()` dedupliziert nur nach `(metric, source_name, start_date, end_date, unit)` und behält die höchste `id` (`scripts/health/apple_health_analytics.py:59–77`). - Tages-, Stunden- und Wochenaggregate mit unterschiedlichen Intervallen bleiben gleichzeitig bestehen. - Dadurch können insbesondere Summenmetriken doppelt gezählt werden. 2. **Wochenwerte sind derzeit nur heuristisch erkannt und falsch auf der Zeitachse** - `is_weekly_row()` erkennt genau einen Dateinamen und Mitternachtszeitpunkt (`apple_health_analytics.py:49–56`). - Der Wochenwert wird durch sieben geteilt, aber nur dem Startdatum zugeordnet (`:90–97`), statt sieben Tage als Schätzung abzubilden. - Tageswerte überschreiben den Wochenanteil nicht; beide können in dieselbe Tagesaggregation gelangen. 3. **Metriksemantik liegt bei den Consumern statt zentral** - `daily_series(metric, mode)` akzeptiert beliebig `sum` oder `avg` (`apple_health_analytics.py:80–100`). - Inkonsistenz: `sleep_analysis` wird im Dashboard summiert (`health_dashboard_v3.py:38`), im Arztbericht gemittelt (`generate_doctor_report.py:75`). - Gewicht wird als Tagesdurchschnitt behandelt, obwohl „letzte Messung des Tages“ fachlich sinnvoller ist. - Herzfrequenz-/Atem-/SpO₂-Werte werden ungewichtet gemittelt; Intervalllängen und Messabdeckung bleiben unberücksichtigt. 4. **Keine Source-Precedence** - `source_name` dient nur als Teil des Deduplikationsschlüssels. - Überlappende Werte von Watch, iPhone oder Drittanbieter können gemeinsam summiert beziehungsweise gemittelt werden. - Eine universelle Reihenfolge darf nicht implizit erfunden werden; sie muss metrikspezifisch konfiguriert und nachvollziehbar ausgegeben werden. 5. **Timezone- und DST-Semantik fehlt** - Der Tag wird durch `str(start_date)[:10]` bestimmt (`apple_health_analytics.py:92`). - UTC-Offsets, naive Zeitstempel, lokale Mitternacht, Sommerzeit sowie Intervalle über Mitternacht werden nicht verarbeitet. - `stable_only` verwendet die Host-Zeitzone und nicht die Export-/Benutzer-Zeitzone (`:82,95`). - Tests und historische Reports sind wegen des impliziten `datetime.now()` nicht reproduzierbar. 6. **Einheiten werden nicht ausreichend normalisiert** - Nur `kJ → kcal` ist implementiert (`:88–89`). - Gemischte Distanz-, Schlafdauer-, HRV- oder SpO₂-Einheiten können zusammengeführt werden. - `unit_for()` liefert lediglich die häufigste Roh-Einheit (`:103–114`), unabhängig davon, ob einzelne Werte bereits konvertiert wurden. 7. **Coverage und Missingness sind nicht unterscheidbar** - `daily_series()` gibt nur Tage mit Werten zurück. - Fehlender Export, fehlender Tag, unzureichende Tagesabdeckung, Wochen-Schätzung und provisorischer aktueller Tag sind nicht unterscheidbar. - Dashboard und Arztbericht berechnen Mittelwert/Min/Max nur über vorhandene Punkte (`health_dashboard_v3.py:199–208`, `generate_doctor_report.py:180–190`), ohne den Nenner oder Lücken offenzulegen. - Die Dashboard-Spalte „Records“ enthält tatsächlich die Zahl aggregierter Tage (`health_dashboard_v3.py:689`). 8. **Importer erfasst notwendige Analytics-Metadaten nicht** - `normalize_record()` speichert keine normalisierten UTC-Zeitpunkte, Zeitzone, Aggregationsperiode oder Aggregationsart (`apple_health_import.py:121–157`). - Container-Metadaten aus dem jeweiligen Metric-Block werden in `iter_json_records()` nur teilweise vererbt (`:167–185`). - `apple_health_records` besitzt keine `import_file_id`; Analytics entscheidet deshalb über globale `id`, obwohl der Modulkommentar von `import_file_id/id` spricht. - Positiv: Rohdaten bleiben erhalten, Record-Hash-Idempotenz und Sprint-0-Transaktionssicherheit sind vorhanden. 9. **DB-Verbindung der Consumer ist irreführend** - `apple_daily(con, ...)` und `apple_series(c, ...)` nehmen eine Connection entgegen, verwenden sie aber nicht; Analytics öffnet eine separate Verbindung (`generate_doctor_report.py:174–177`, `health_dashboard_v3.py:199–208`, `apple_health_analytics.py:34–37`). - Damit fehlt ein konsistenter SQLite-Snapshot und Unit-Tests benötigen globale Pfad-Manipulation. --- # Sprint‑1: Analytics‑v2 ## 1. Zentrale Metrik-Registry In `scripts/health/apple_health_analytics.py` eine unveränderliche `METRIC_SPECS`-Registry einführen: | Metrik | Tagessemantik | Kanonische Einheit | |---|---|---| | `step_count` | Summe nicht überlappender Beiträge | `count` | | `walking_running_distance` | Summe | `m` oder einheitlich `km` | | `active_energy`, `basal_energy_burned` | Summe | `kcal` | | `apple_exercise_time`, `apple_stand_time` | Summe der Dauer | `min` | | `flights_climbed` | Summe | `count` | | `heart_rate` | zeitgewichtetes Mittel bei Intervallen, sonst Stichprobenmittel | `count/min` | | `resting_heart_rate` | bevorzugtes vollständiges Tagesaggregat, sonst Stichprobenmittel | `count/min` | | `heart_rate_variability` | Tagesmittel kompatibler SDNN-Messungen | `ms` | | `respiratory_rate` | zeitgewichtet beziehungsweise Stichprobenmittel | `count/min` | | `blood_oxygen_saturation` | Mittel nach strikter Einheitsnormalisierung | `%` | | `physical_effort` | Mittel nur innerhalb derselben kanonischen Einheit | explizit aus Export | | `weight_body_mass` | letzte stabile Messung des lokalen Tages | `kg` | | `sleep_analysis` | Dauer der Vereinigungsmenge eingeschlafener Intervalle; Tag = lokales Aufwachdatum | `h` | Unbekannte Metriken oder inkompatible Einheiten: nicht stillschweigend aggregieren, sondern `unsupported_metric` beziehungsweise `unit_ambiguous` ausgeben. ## 2. Additive Import-Metadaten `apple_health_import.ensure_schema()` und `database/schema.sql` ausschließlich additiv erweitern: - `import_file_id` - `start_utc`, `end_utc` - `timezone_name` - `local_start_date` - `aggregation_period`: `point|intraday|day|week|unknown` - `aggregation_kind`: `sum|mean|latest|duration|unknown` - `source_class` - `parser_version` - `normalization_status`, `normalization_error` Wichtig: - `raw_json`, bestehende Spalten und bisheriger `record_hash` bleiben unverändert. - Historische Zeilen werden lazy oder per idempotentem Backfill normalisiert. - Ohne Offset wird die konfigurierte Export-Zeitzone verwendet und mit `timezone_assumed` markiert. - Aggregationsperiode primär aus Exportmetadaten, nicht aus Dateinamen ableiten. Dateiname nur als explizit markierter Legacy-Fallback. ## 3. Source- und Repräsentationspräzedenz Deterministische Reihenfolge pro Metrik und lokalem Tag: 1. Vollständiges, stabiles Tagesaggregat. 2. Nicht überlappende Intraday-/Rohintervalle. 3. Wochenaggregat als Schätzung. 4. Unbekannte/mehrdeutige Repräsentation nur mit Quality-Flag. Innerhalb einer Repräsentationsstufe: 1. Metrikspezifisch konfigurierte `source_name`-/`device`-Priorität. 2. Höhere Normalisierungsqualität. 3. Neuester erfolgreicher Import als Revision. 4. Höchste Record-ID nur als letzter deterministischer Tie-Breaker. Unbekannte Quellen dürfen bekannte Quellen bei Überlappung nicht addieren. Sie bleiben auditierbar und erzeugen `source_conflict`, bis eine Policy existiert. ## 4. Überlappungsschutz - Alle Intervalle intern als halboffen `[start_utc, end_utc)` behandeln. - Exakte Revisionen anhand einer Observation Identity ohne `value`/Dateiname erkennen; neueste Revision gewinnt. - Pro Tag genau **eine Repräsentationsstufe** verwenden. - Niedriger priorisierte Quellen bei zeitlicher Überlappung nicht zusätzlich zählen. - Summenwerte nur dann proportional auf Intervallsegmente verteilen, wenn die Registry dies ausdrücklich erlaubt; sonst `partial_overlap_unresolved`. - Schlafstadien zunächst nach Kategorie filtern, dann Intervall-Vereinigungsmenge bilden. - Wochenaggregate gleichmäßig auf sieben lokale Kalendertage verteilen, als `estimated_weekly` markieren und für jeden vorhandenen echten Tageswert vollständig verdrängen. - Invariante: Ein UTC-Zeitsegment darf pro Metrik höchstens einmal zum Tageswert beitragen. ## 5. Zeitzone und Stabilität Neue API-Parameter: ```python daily_metric( metric, start_date, end_date, *, timezone="Europe/Zurich", as_of=None, stable_only=True, include_estimates=False, connection=None, ) ``` Regeln: - Offsetbehaftete Zeitstempel zuerst nach UTC normalisieren. - Lokale Tagesgrenzen mit `zoneinfo.ZoneInfo` erzeugen; DST-Tage haben real 23 beziehungsweise 25 Stunden. - Naive Zeitstempel als konfigurierte Export-Zeitzone interpretieren und markieren. - Aktueller lokaler Tag bleibt `provisional`. - `as_of` ist injizierbar; keine versteckte Abhängigkeit von `datetime.now()`. - Cross-midnight-Summen werden entlang lokaler Tagesgrenzen geteilt; Schlaf wird dem lokalen Aufwachdatum zugeordnet. ## 6. Coverage/Missingness-Vertrag V2 liefert keine bloße `dict[str, float]`, sondern beispielsweise: ```python DailyMetricResult( metric, unit, timezone, points=[ DailyPoint( date, value, status, # observed|estimated|missing|provisional|invalid coverage_ratio, sample_count, source_tier, quality_flags, ) ], observed_days, estimated_days, missing_days, provisional_days, ) ``` Semantik: - Vollständiger angeforderter Kalenderbereich wird ausgegeben. - Fehlend ist `None`, niemals implizit null. - Summen: Coverage aus Vereinigungsdauer der verwendeten Intraday-Intervalle; vollständiges Tagesaggregat = `1.0`. - Stichprobenmetriken: `sample_count` und Quality-Klasse ausgeben; keine erfundene zeitliche Coverage, wenn Messfrequenz unbekannt ist. - Wochenwerte zählen als `estimated`, nicht als echte Abdeckung. - Summary-Statistiken standardmäßig nur über stabile `observed`-Tage. - Geschätzte Werte nur bei explizitem `include_estimates=True`, getrennt ausgewiesen. - „Letzter Wert“ = letzter stabiler beobachteter Punkt; fehlt dieser, klar `nicht verfügbar`. ## 7. Consumer-Umstellung ### `health_dashboard_v3.py` - `apple_series()` (`:199–208`) auf das strukturierte V2-Ergebnis umstellen. - Tabelle: „beobachtete Tage“, „geschätzt“, „fehlend“, Coverage statt „Records“. - Fehlende Werte als Chart-Lücken (`null`), Wochen-Schätzungen als separate gestrichelte Serie. - Text `Aggregation ... pro Tag/Woche` (`:334`) durch exakte Registry-Semantik ersetzen. ### `generate_doctor_report.py` - `APPLE_METRICS` (`:70–81`) enthält nur Labels; Aggregation kommt ausschließlich aus der Registry. - Summary weist Beobachtungs-/Fehltage aus. - Keine Min/Max/Ø-Aussage bei unzureichender Coverage. - Schlafsemantik identisch zum Dashboard. ### Snapshot-Konsistenz - V2-Funktionen akzeptieren eine bestehende SQLite-Connection. - Ein Dashboard-/Report-Lauf liest Apple Health aus demselben Snapshot. ## 8. Rückwärtskompatibilität und Rollout - `daily_series(metric, mode="avg", stable_only=True)` und `unit_for()` zunächst als Wrapper erhalten. - Wrapper validiert `mode` gegen `METRIC_SPECS`; widersprüchliche Modi erzeugen Warning beziehungsweise Fehler im Testbetrieb. - Feature-Flag: `APPLE_HEALTH_ANALYTICS_VERSION=v1|v2`. - Reihenfolge: 1. V2 plus synthetische Tests implementieren. 2. V1/V2 parallel auf synthetischen Fixtures vergleichen. 3. Consumer auf strukturierte API umstellen. 4. V2 als Default aktivieren. 5. V1 erst in einem späteren Sprint entfernen. - Keine Migration darf Raw Records löschen oder Record-Hashes neu berechnen. --- ## Verbindliche Sprint‑1-Testfälle Neue Datei: `tests/test_apple_health_analytics_v2.py`, ausschließlich synthetische Daten. 1. Exakte Duplikate aus zwei Exporten werden einmal gezählt. 2. Neuere Revision desselben Beobachtungsschlüssels gewinnt deterministisch. 3. Tagesaggregat verdrängt Stundenaggregate desselben Tages. 4. Stundenintervalle verschiedener Quellen werden bei Überlappung nicht addiert. 5. Konfigurierte Source-Priorität gewinnt unabhängig von Insert-Reihenfolge. 6. Unbekannte gleichrangige Quellen ergeben `source_conflict`. 7. Wochenwert erzeugt sieben `estimated_weekly`-Punkte. 8. Echter Tageswert ersetzt genau den betreffenden Wochen-Schätzwert. 9. Gemischte Wochen-, Tages- und Intraday-Exporte zählen kein Zeitsegment doppelt. 10. Cross-midnight-Summen werden korrekt auf lokale Tage verteilt. 11. Schlafintervalle/-stadien werden vereinigt und dem Aufwachdatum zugeordnet. 12. DST-Frühlingstag mit 23 Stunden. 13. DST-Herbsttag mit 25 Stunden und wiederholter Stunde. 14. Offsetbehaftete und naive Zeitstempel liefern bei definierter Export-Zeitzone dasselbe lokale Datum; naive Daten tragen Flag. 15. `as_of` macht Stable-Cutoff reproduzierbar; aktueller Tag bleibt provisional. 16. Fehlende Kalendertage erscheinen als `value=None/status=missing`. 17. Fehlend wird nicht als null in Mittelwert oder Summe einbezogen. 18. Wochen-Schätzungen werden standardmäßig nicht in Summary-Statistiken einbezogen. 19. `kJ/kcal`, Distanz-, Dauer- und Gewichts-Konvertierungen. 20. Ambige SpO₂-/Physical-Effort-Einheiten werden verworfen und markiert. 21. Gewicht verwendet letzte stabile Tagesmessung, nicht Mittelwert. 22. V1-Wrapper behält Rückgabetyp und `stable_only`-Verhalten. 23. Additive Schema-Migration ist auf alter und bereits migrierter DB idempotent. 24. Importer übernimmt Metric-Block-Zeitzone und Aggregationsmetadaten. 25. Property-Test: kein ausgewähltes UTC-Segment überlappt ein anderes ausgewähltes Segment derselben Metrik. 26. Dashboard-/Report-Contract-Tests für Lücken, Schätzkennzeichnung und Coverage-Texte. ## Verifikation - Bestehende Sprint-0-Suite: **16 Tests bestanden**. - `py_compile` für Analytics, Importer, Dashboard und Arztbericht: **erfolgreich**. - Git-Arbeitsbaum blieb sauber. - **Keine Dateien erstellt oder verändert; keine Patientendatenbank und keine Patientenwerte inspiziert oder ausgegeben.**