feat: Update activity metrics handling to prioritize legacy columns over EAV
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s

- Enhanced the `merge_column_backed_and_eav_metrics` function to ensure that when both legacy columns and EAV values are present, the legacy column takes precedence.
- Revised documentation in `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` and `ACTIVITY_SCALAR_KANON_TABLE.md` to reflect the new reading logic and clarify the handling of metrics.
- Updated the `activity_data_canon.py` to specify the new merge behavior, ensuring consistency in data retrieval.
- Added unit tests to validate the new logic, confirming that legacy columns are preferred when available.
This commit is contained in:
Lars 2026-04-17 15:49:38 +02:00
parent 38797d687d
commit 680ecd1c06
6 changed files with 111 additions and 23 deletions

View File

@ -155,6 +155,18 @@ Phasen sind **sequentiell** wo „Abhängigkeit“ steht; Teile können parallel
**Abhängigkeit:** Phase A + B (sonst Lücken beim Lesen).
**Analyse (2026-04-16, nur Ist-Review):** Es gibt **keinen aktiven** Schreibpfad mehr, der `activity_log`-Spalten für `source_field`-Parameter **dauerhaft nach EAV spiegelt**.
| Prüfpunkt | Ergebnis |
|-----------|----------|
| `sync_column_backed_session_metrics` | Nur noch **Definition** in `activity_session_metrics.py`, als veraltet markiert; **keine Aufrufer** im Repo (grep). Laufzeit-Sync: **abgestellt**. |
| `run_activity_post_write_hooks` / `…_import` | Nur **Auto-Eval** (optional); Kommentar: **kein** Spalte→EAV-Sync. |
| Universal-CSV (`executor.py`) | Kernfelder → `activity_log` (`activity_csv_registry_updates_from_mapped` + `update_activity_columns` / Insert); EAV → `upsert_session_metrics_from_csv_mapped`. Registry-Keys werden **nicht** nach EAV geschrieben; bei `source_field` wird EAV **übersprungen**, wenn die Spalte **bereits befüllt** ist — vermeidet bewusst doppelte Speicherung. |
| REST `PUT /metrics` | Kommentar in Code: **kein** `sync_column_backed` nach EAV-Ersatz. |
| Migrationen 055 / 057 | **Einmaliger** Backfill/Schwenk, kein fortlaufender Sync. |
**Lesepfad (2026-04-16):** `merge_column_backed_and_eav_metrics` bevorzugt **immer** `activity_log`, wenn ein kanonischer Spaltenwert existiert: zuerst `source_field`, dann Registry-Spalte gleichen Keys, dann Legacy-Spalten für EAV-primäre Parameter, zuletzt EAV. Doppelte physische Schreiborte sind damit in der effektiven Sicht **ohne EAV-Vorrang** behoben.
---
### Phase D Composite MVP
@ -200,4 +212,4 @@ Phasen sind **sequentiell** wo „Abhängigkeit“ steht; Teile können parallel
---
**Version:** 1.3 · Phase-B-Export an `enrich_sessions_with_metrics` angebunden (`exportdata.py`).
**Version:** 1.5 · Merge: activity_log (Registry + Legacy-Spalten) vor EAV bei Lesen.

View File

@ -46,19 +46,19 @@ Schreibpfad: Universal-CSV und API sollen diese Keys auf **`activity_log`** mapp
`ACTIVITY_EAV_PRIMARY_PARAMETER_KEYS` in `activity_data_canon.py`. **`training_parameters.source_field`** = NULL (nach Kanon / Migration 057): kanonischer Speicher ist **`activity_session_metrics`**.
| Parameter-Key (`training_parameters.key`) | Legacy-Spalte `activity_log` (nur Lesefallback) | Kanonische Quelle | Lesefallback |
|-------------------------------------------|-------------------------------------------------|-------------------|--------------|
| `min_hr` | `hr_min` | **EAV** | Spalte, wenn EAV leer |
| `pace_min_per_km` | `pace_min_per_km` | **EAV** | Spalte, wenn EAV leer |
| `cadence` | `cadence` | **EAV** | Spalte, wenn EAV leer |
| `avg_power` | `avg_power` | **EAV** | Spalte, wenn EAV leer |
| `elevation_gain` | `elevation_gain` | **EAV** | Spalte, wenn EAV leer |
| `temperature_celsius` | `temperature_celsius` | **EAV** | Spalte, wenn EAV leer |
| `humidity_percent` | `humidity_percent` | **EAV** | Spalte, wenn EAV leer |
| `avg_hr_percent` | `avg_hr_percent` | **EAV** | Spalte, wenn EAV leer |
| `kcal_per_km` | `kcal_per_km` | **EAV** | Spalte, wenn EAV leer |
| Parameter-Key (`training_parameters.key`) | Legacy-Spalte `activity_log` | Schreib-Kanon (Ziel) |
|-------------------------------------------|------------------------------|------------------------|
| `min_hr` | `hr_min` | **EAV** |
| `pace_min_per_km` | `pace_min_per_km` | **EAV** |
| `cadence` | `cadence` | **EAV** |
| `avg_power` | `avg_power` | **EAV** |
| `elevation_gain` | `elevation_gain` | **EAV** |
| `temperature_celsius` | `temperature_celsius` | **EAV** |
| `humidity_percent` | `humidity_percent` | **EAV** |
| `avg_hr_percent` | `avg_hr_percent` | **EAV** |
| `kcal_per_km` | `kcal_per_km` | **EAV** |
Merge-Implementierung: `merge_column_backed_and_eav_metrics` in `activity_session_metrics.py`.
**Lesen:** `merge_column_backed_and_eav_metrics` — wenn Legacy-Spalte **und** EAV einen Wert haben, **gewinnt die Spalte** (kanonische `activity_log`-Sicht). EAV nur, wenn die Spalte leer/nicht koerzierbar ist.
---

View File

@ -31,7 +31,7 @@ ACTIVITY_MODULE_REGISTRY_FIELD_KEYS: Final[frozenset[str]] = get_activity_module
ACTIVITY_LOG_PATCHABLE_COLUMNS: Final[frozenset[str]] = ACTIVITY_MODULE_REGISTRY_FIELD_KEYS - {"date"}
# Parameter-Keys (training_parameters.key), die primär in EAV geführt werden; source_field nach Migration 057 NULL.
# Lesefallback: activity_log-Spalte unter ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM, falls EAV leer.
# Lesen (Merge): activity_log-Legacy-Spalte schlägt EAV, wenn beide befüllt; sonst EAV.
ACTIVITY_EAV_PRIMARY_PARAMETER_KEYS: Final[frozenset[str]] = frozenset(
{
"min_hr",
@ -46,7 +46,7 @@ ACTIVITY_EAV_PRIMARY_PARAMETER_KEYS: Final[frozenset[str]] = frozenset(
}
)
# Spaltenname activity_log für Legacy-Lesefallback (Merge), wenn EAV für den Parameter fehlt.
# Spaltenname activity_log für Legacy-Merge (Vorrang vor EAV bei gesetztem Spaltenwert).
ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM: Final[Dict[str, str]] = {
"min_hr": "hr_min",
"pace_min_per_km": "pace_min_per_km",

View File

@ -1,5 +1,5 @@
"""
Zentrale Persistenz für activity_log + EAV-Nebenwirkungen (Eval, SpaltenEAV).
Zentrale Persistenz für activity_log + EAV-Nebenwirkungen (Eval).
Alle Schreibpfade (REST, Universal-CSV, Legacy-Upload) laufen hier zusammen.

View File

@ -323,9 +323,15 @@ def merge_column_backed_and_eav_metrics(
"""
Effektive Metrikliste **nur** für Parameter aus ``schema`` (Kategorie + Trainingstyp / tcp+ttp).
Pro Schema-Parameter mit ``source_field`` hat ``activity_log`` Vorrang, wenn die Spalte befüllt und
koerzierbar ist; sonst EAV, sonst Legacy-Spalte. EAV-Zeilen zu Parametern, die nicht im Schema sind,
werden nicht ausgegeben (Darstellung und Auswertung folgen ausschließlich dem konfigurierten Profil).
Kanon beim Lesen: **activity_log** schlägt EAV, sobald ein passender Spaltenwert existiert und
koerzierbar ist in dieser Reihenfolge:
1. ``source_field`` Spalte
2. Parameter-Key = Registry-Kernfeld (``ACTIVITY_MODULE_REGISTRY_FIELD_KEYS``) gleichnamige Spalte
3. EAV-primäre Keys Legacy-Spalte laut ``ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM``
4. sonst EAV
EAV-Zeilen zu Parametern, die nicht im Schema sind, werden nicht ausgegeben.
"""
eav_by_key = {m["key"]: m for m in eav_metrics}
merged: List[Dict[str, Any]] = []
@ -360,10 +366,23 @@ def merge_column_backed_and_eav_metrics(
if used_column:
continue
if k in eav_by_key:
merged.append(dict(eav_by_key[k]))
keys_handled.add(k)
continue
if k in ACTIVITY_MODULE_REGISTRY_FIELD_KEYS and k in header and header[k] is not None:
try:
val = _coerce_raw_value_for_parameter(dt, header[k])
merged.append(
{
"training_parameter_id": tid,
"key": k,
"data_type": dt,
"unit": unit,
"value": val,
}
)
keys_handled.add(k)
continue
except (TypeError, ValueError):
pass
legacy_col = ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM.get(k)
if legacy_col and legacy_col in header and header[legacy_col] is not None:
@ -379,9 +398,14 @@ def merge_column_backed_and_eav_metrics(
}
)
keys_handled.add(k)
continue
except (TypeError, ValueError):
pass
if k in eav_by_key:
merged.append(dict(eav_by_key[k]))
keys_handled.add(k)
merged.sort(key=lambda x: x["key"])
return merged

View File

@ -307,6 +307,58 @@ def test_merge_eav_primary_falls_back_to_legacy_hr_min_column():
assert out[0]["value"] == 88
def test_merge_eav_primary_prefers_legacy_column_over_eav_when_both():
"""Kanon: bei min_hr + hr_min und EAV-Zeile gewinnt activity_log (hr_min)."""
schema = [
{
"training_parameter_id": 9,
"key": "min_hr",
"data_type": "integer",
"unit": "bpm",
"validation_rules": {},
"source_field": None,
}
]
eav = [
{
"training_parameter_id": 9,
"key": "min_hr",
"data_type": "integer",
"unit": "bpm",
"value": 100,
}
]
out = merge_column_backed_and_eav_metrics({"hr_min": 88}, schema, eav)
assert len(out) == 1
assert out[0]["value"] == 88
def test_merge_registry_key_prefers_activity_log_column_over_eav():
"""Parameter-Key = Registry-Feld (z. B. duration_min): Spalte vor EAV."""
schema = [
{
"training_parameter_id": 3,
"key": "duration_min",
"data_type": "float",
"unit": "min",
"validation_rules": {},
"source_field": None,
}
]
eav = [
{
"training_parameter_id": 3,
"key": "duration_min",
"data_type": "float",
"unit": "min",
"value": 99.0,
}
]
out = merge_column_backed_and_eav_metrics({"duration_min": 45.0}, schema, eav)
assert len(out) == 1
assert out[0]["value"] == 45.0
@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema")
def test_upsert_csv_skips_eav_when_source_field_maps_activity_log(mock_schema):
"""Parameter mit source_field: kanonisch activity_log — kein doppeltes EAV (z. B. avg_hr → hr_avg)."""