feat(schema, csv_parser): Update activity log schema and parsing logic
All checks were successful
Deploy Development / deploy (push) Successful in 48s
Build Test / pytest-backend (push) Successful in 3s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s

- Increased precision for `kcal_active`, `kcal_resting`, `hr_avg`, and `hr_max` fields in the activity log schema.
- Added a new function `_activity_hr_bpm` to validate heart rate values during CSV import, ensuring they fall within plausible ranges.
- Updated the CSV parser to utilize the new heart rate validation function for improved data integrity.
- Enhanced the type converter to accommodate additional aliases for energy fields in CSV imports.
- Added a test to verify conversion of active energy from kJ to kcal, ensuring accurate data handling.
This commit is contained in:
Lars 2026-04-11 06:41:23 +02:00
parent 08a2485f43
commit 6945b748cb
7 changed files with 70 additions and 11 deletions

View File

@ -776,6 +776,16 @@ def _sf_act(val: Any) -> float | None:
return None return None
def _activity_hr_bpm(val: Any) -> float | None:
"""Plausible Herzfrequenz (Import); größere Werte oft Fehlzuordnung (z. B. Schrittzahl) → NUMERIC-Overflow."""
v = _sf_act(val)
if v is None:
return None
if v < 20 or v > 280:
return None
return v
def _looks_like_time_only(s: str) -> bool: def _looks_like_time_only(s: str) -> bool:
t = s.strip() t = s.strip()
if not t or " " in t: if not t or " " in t:
@ -867,8 +877,8 @@ def _import_activity(
kcal_a = _sf_act(mapped.get("kcal_active")) kcal_a = _sf_act(mapped.get("kcal_active"))
kcal_r = _sf_act(mapped.get("kcal_resting")) kcal_r = _sf_act(mapped.get("kcal_resting"))
hr_a = _sf_act(mapped.get("hr_avg")) hr_a = _activity_hr_bpm(mapped.get("hr_avg"))
hr_m = _sf_act(mapped.get("hr_max")) hr_m = _activity_hr_bpm(mapped.get("hr_max"))
dist = _sf_act(mapped.get("distance_km")) dist = _sf_act(mapped.get("distance_km"))
wtype = str(activity_type).strip() wtype = str(activity_type).strip()

View File

@ -526,7 +526,7 @@ def _activity_alias_db_field(csv_col: str) -> str | None:
return "distance_km" return "distance_km"
if "aktive_energie" in n or "active_energy" in n: if "aktive_energie" in n or "active_energy" in n:
return "kcal_active" return "kcal_active"
if "ruheenergie" in n or "resting_energy" in n: if "ruheeintr" in n or "ruheenergie" in n or "resting_energy" in n:
return "kcal_resting" return "kcal_resting"
if ("herzfrequenz" in n or "heart_rate" in n) and ("max" in low or "max" in n): if ("herzfrequenz" in n or "heart_rate" in n) and ("max" in low or "max" in n):
return "hr_max" return "hr_max"

View File

@ -145,7 +145,7 @@ INSERT INTO csv_field_mappings (
SELECT SELECT
NULL, true, 'activity', 'Apple Health Workout Export (Deutsch)', NULL, true, 'activity', 'Apple Health Workout Export (Deutsch)',
'Apple Health CSV-Export für Workouts (Deutsch). Automatisches Training-Type-Mapping.', 'Apple Health CSV-Export für Workouts (Deutsch). Automatisches Training-Type-Mapping.',
ARRAY['Aktive Energie (kcal)', 'Dauer', 'Durchschnittliche Herzfrequenz (bpm)', 'Ende', 'Start', 'Strecke (km)', 'Trainingsart']::TEXT[], ARRAY['Aktive Energie (kJ)', 'Aktive Energie (kcal)', 'Dauer', 'Durchschnittliche Herzfrequenz (bpm)', 'Ende', 'Ruheeinträge (kJ)', 'Start', 'Strecke (km)', 'Trainingsart']::TEXT[],
',', 'utf-8', true, ',', 'utf-8', true,
'{ '{
"Trainingsart": "activity_type", "Trainingsart": "activity_type",
@ -154,15 +154,18 @@ SELECT
"Dauer": "duration_min", "Dauer": "duration_min",
"Strecke (km)": "distance_km", "Strecke (km)": "distance_km",
"Aktive Energie (kcal)": "kcal_active", "Aktive Energie (kcal)": "kcal_active",
"Aktive Energie (kJ)": "kcal_active",
"Ruheeinträge (kJ)": "kcal_resting",
"Durchschnittliche Herzfrequenz (bpm)": "hr_avg" "Durchschnittliche Herzfrequenz (bpm)": "hr_avg"
}'::JSONB, }'::JSONB,
'{ '{
"start_time": {"type": "datetime", "format": "yyyy-mm-dd HH:MM:SS", "extract": "date_and_time", "flexible": true}, "start_time": {"type": "datetime", "format": "yyyy-mm-dd HH:MM:SS", "extract": "date_and_time", "flexible": true},
"end_time": {"type": "datetime", "format": "yyyy-mm-dd HH:MM:SS", "flexible": true}, "end_time": {"type": "datetime", "format": "yyyy-mm-dd HH:MM:SS", "flexible": true},
"duration_min": {"type": "duration", "format": "HH:MM:SS", "target_unit": "minutes"}, "duration_min": {"type": "duration", "format": "HH:MM:SS", "target_unit": "minutes"},
"distance_km": {"type": "float", "decimal_separator": ","}, "distance_km": {"type": "float", "decimal_separator": ",", "flexible": true},
"kcal_active": {"type": "float", "decimal_separator": ","}, "kcal_active": {"type": "float", "decimal_separator": ".", "flexible": true, "source_unit": "kj"},
"hr_avg": {"type": "int"} "kcal_resting": {"type": "float", "decimal_separator": ".", "flexible": true, "source_unit": "kj"},
"hr_avg": {"type": "int", "flexible": true}
}'::JSONB }'::JSONB
WHERE NOT EXISTS ( WHERE NOT EXISTS (
SELECT 1 FROM csv_field_mappings f SELECT 1 FROM csv_field_mappings f

View File

@ -0,0 +1,8 @@
-- Apple-/Import-Werte: Energie oft >1000 (kJ oder kcal), HR-Felder NUMERIC(5,2) zu eng für Fehlzuordnungen.
-- Idempotent: Typ nur anheben, nie verkleinern.
ALTER TABLE activity_log
ALTER COLUMN kcal_active TYPE NUMERIC(12, 2),
ALTER COLUMN kcal_resting TYPE NUMERIC(12, 2),
ALTER COLUMN hr_avg TYPE NUMERIC(6, 2),
ALTER COLUMN hr_max TYPE NUMERIC(6, 2);

View File

@ -0,0 +1,26 @@
-- Apple Health Workout (Deutsch): „Aktive Energie (kJ)“ / „Ruheeinträge (kJ)“ → DB speichert kcal.
-- type_conversions.source_unit „kj“ nutzt field_units (1/4.184).
UPDATE csv_field_mappings
SET
field_mappings = COALESCE(field_mappings, '{}'::jsonb)
|| '{"Aktive Energie (kJ)": "kcal_active", "Ruheeinträge (kJ)": "kcal_resting"}'::jsonb,
type_conversions = jsonb_set(
jsonb_set(
COALESCE(type_conversions, '{}'::jsonb),
'{kcal_active}',
COALESCE(type_conversions->'kcal_active', '{}'::jsonb) || '{"source_unit": "kj"}'::jsonb,
true
),
'{kcal_resting}',
COALESCE(
type_conversions->'kcal_resting',
'{"type": "float", "decimal_separator": ",", "flexible": true}'::jsonb
) || '{"source_unit": "kj"}'::jsonb,
true
)
WHERE is_system = true
AND profile_id IS NULL
AND module = 'activity'
AND mapping_name = 'Apple Health Workout Export (Deutsch)'
AND type_conversions IS NOT NULL;

View File

@ -160,10 +160,10 @@ CREATE TABLE IF NOT EXISTS activity_log (
end_time TIME, end_time TIME,
activity_type VARCHAR(50) NOT NULL, activity_type VARCHAR(50) NOT NULL,
duration_min NUMERIC(6,2), duration_min NUMERIC(6,2),
kcal_active NUMERIC(7,2), kcal_active NUMERIC(12,2),
kcal_resting NUMERIC(7,2), kcal_resting NUMERIC(12,2),
hr_avg NUMERIC(5,2), hr_avg NUMERIC(6,2),
hr_max NUMERIC(5,2), hr_max NUMERIC(6,2),
distance_km NUMERIC(7,2), distance_km NUMERIC(7,2),
rpe INTEGER CHECK (rpe >= 1 AND rpe <= 10), rpe INTEGER CHECK (rpe >= 1 AND rpe <= 10),
source VARCHAR(20) DEFAULT 'manual', source VARCHAR(20) DEFAULT 'manual',

View File

@ -266,6 +266,18 @@ def test_iso_yyyy_mm_dd_dateutil_fallback_not_dayfirst_swapped():
assert d.month == 4 and d.day == 9 assert d.month == 4 and d.day == 9
def test_activity_kcal_active_kj_source_unit_to_kcal():
"""Apple-DE: Aktive Energie als kJ; DB-Feld ist kcal."""
spec = {
"type": "float",
"decimal_separator": ".",
"flexible": True,
"source_unit": "kj",
}
v = convert_value("4184", "kcal_active", spec, module="activity")
assert abs(v - 1000.0) < 0.02
def test_source_unit_choices_include_custom_at_end(): def test_source_unit_choices_include_custom_at_end():
opts = source_unit_choices_for_field("nutrition", "protein_g") opts = source_unit_choices_for_field("nutrition", "protein_g")
assert opts[-1]["id"] == "custom" assert opts[-1]["id"] == "custom"