diff --git a/backend/calculations/body_metrics.py b/backend/calculations/body_metrics.py index af1a138..386fb76 100644 --- a/backend/calculations/body_metrics.py +++ b/backend/calculations/body_metrics.py @@ -223,6 +223,11 @@ def calculate_arm_28d_delta(profile_id: str) -> Optional[float]: return _calculate_circumference_delta(profile_id, 'c_arm', 28) +def calculate_arm_relaxed_28d_delta(profile_id: str) -> Optional[float]: + """28-day relaxed arm circumference change (cm).""" + return _calculate_circumference_delta(profile_id, 'c_arm_relaxed', 28) + + def calculate_thigh_28d_delta(profile_id: str) -> Optional[float]: """Calculate 28-day thigh circumference change (cm)""" delta = _calculate_circumference_delta(profile_id, 'c_thigh', 28) diff --git a/backend/data_layer/__init__.py b/backend/data_layer/__init__.py index b834a9e..2398f71 100644 --- a/backend/data_layer/__init__.py +++ b/backend/data_layer/__init__.py @@ -70,6 +70,7 @@ __all__ = [ 'calculate_hip_28d_delta', 'calculate_chest_28d_delta', 'calculate_arm_28d_delta', + 'calculate_arm_relaxed_28d_delta', 'calculate_thigh_28d_delta', 'calculate_waist_hip_ratio', 'calculate_recomposition_quadrant', diff --git a/backend/data_layer/body_metrics.py b/backend/data_layer/body_metrics.py index 3fcbaba..e2bbdd0 100644 --- a/backend/data_layer/body_metrics.py +++ b/backend/data_layer/body_metrics.py @@ -370,7 +370,8 @@ def get_circumference_summary_data( ('c_hip', 'Hüfte'), ('c_thigh', 'Oberschenkel'), ('c_calf', 'Wade'), - ('c_arm', 'Arm') + ('c_arm', 'Oberarm kontrahiert'), + ('c_arm_relaxed', 'Oberarm'), ] measurements = [] @@ -401,7 +402,7 @@ def get_circumference_summary_data( }) # Calculate confidence based on how many points we have - confidence = calculate_confidence(len(measurements), 8, "general") + confidence = calculate_confidence(len(measurements), 9, "general") if not measurements: return { @@ -640,10 +641,15 @@ def calculate_chest_28d_delta(profile_id: str) -> Optional[float]: def calculate_arm_28d_delta(profile_id: str) -> Optional[float]: - """Calculate 28-day arm circumference change (cm)""" + """28-Tage-Delta Oberarm kontrahiert (c_arm), cm.""" return _calculate_circumference_delta(profile_id, 'c_arm', 28) +def calculate_arm_relaxed_28d_delta(profile_id: str) -> Optional[float]: + """28-Tage-Delta Oberarm entspannt (c_arm_relaxed), cm.""" + return _calculate_circumference_delta(profile_id, 'c_arm_relaxed', 28) + + def calculate_thigh_28d_delta(profile_id: str) -> Optional[float]: """Calculate 28-day thigh circumference change (cm)""" delta = _calculate_circumference_delta(profile_id, 'c_thigh', 28) diff --git a/backend/migrations/059_circumference_c_arm_relaxed.sql b/backend/migrations/059_circumference_c_arm_relaxed.sql new file mode 100644 index 0000000..ec74459 --- /dev/null +++ b/backend/migrations/059_circumference_c_arm_relaxed.sql @@ -0,0 +1,5 @@ +-- Zusätzlicher Umfang: Oberarm entspannt (c_arm = historisch / Oberarm kontrahiert) +ALTER TABLE circumference_log ADD COLUMN IF NOT EXISTS c_arm_relaxed NUMERIC(5,2); + +COMMENT ON COLUMN circumference_log.c_arm IS 'Oberarmumfang kontrahiert/angespannt (bestehende Daten)'; +COMMENT ON COLUMN circumference_log.c_arm_relaxed IS 'Oberarmumfang entspannt'; diff --git a/backend/models.py b/backend/models.py index b0462ad..8adac6a 100644 --- a/backend/models.py +++ b/backend/models.py @@ -50,6 +50,7 @@ class CircumferenceEntry(BaseModel): c_thigh: Optional[float] = None c_calf: Optional[float] = None c_arm: Optional[float] = None + c_arm_relaxed: Optional[float] = None notes: Optional[str] = None photo_id: Optional[str] = None diff --git a/backend/placeholder_registrations/body_metrics.py b/backend/placeholder_registrations/body_metrics.py index 15a6372..9898558 100644 --- a/backend/placeholder_registrations/body_metrics.py +++ b/backend/placeholder_registrations/body_metrics.py @@ -18,9 +18,10 @@ Body Composition (5): - waist_hip_ratio - recomposition_quadrant -Circumference Deltas (5): +Circumference Deltas (6): - waist_28d_delta -- arm_28d_delta +- arm_28d_delta (Oberarm kontrahiert, c_arm) +- arm_relaxed_28d_delta (Oberarm entspannt, c_arm_relaxed) - chest_28d_delta - hip_28d_delta - thigh_28d_delta @@ -1033,14 +1034,14 @@ def register_body_metrics(): arm_28d_delta_metadata = PlaceholderMetadata( key="arm_28d_delta", - description="Armumfang Änderung 28d (cm)", + description="Oberarm kontrahiert (c_arm): Umfangs-Änderung 28d (cm)", resolver_function="_safe_float('arm_28d_delta', decimals=1)", data_layer_function="calculate_arm_28d_delta", semantic_contract=( - "Liefert die Veränderung des Armumfangs in Zentimetern über 28 Tage. " - "Positive Werte bedeuten Zunahme, negative Werte Reduktion." + "Veränderung des kontrahierten/angespannten Oberarmumfangs (Spalte c_arm) in cm über 28 Tage. " + "Entspricht historischen Einträgen „Arm“ vor Einführung des zweiten Messpunkts." ), - business_meaning="Ergänzender Umfangsindikator für detaillierte Körperentwicklungsanalysen", + business_meaning="Arm-Umfang unter Anspannung (z. B. leicht gebeugter Arm, Bizeps leicht aktiv)", unit="cm", example_output="+0.6", **circumference_delta_common @@ -1054,6 +1055,30 @@ def register_body_metrics(): arm_28d_delta_metadata.set_evidence("example_output", EvidenceType.DRAFT_DERIVED) register_placeholder(arm_28d_delta_metadata) + # ── arm_relaxed_28d_delta ──────────────────────────────────────────────── + + arm_relaxed_28d_delta_metadata = PlaceholderMetadata( + key="arm_relaxed_28d_delta", + description="Oberarm entspannt (c_arm_relaxed): Umfangs-Änderung 28d (cm)", + resolver_function="_safe_float('arm_relaxed_28d_delta', decimals=1)", + data_layer_function="calculate_arm_relaxed_28d_delta", + semantic_contract=( + "Veränderung des entspannten Oberarmumfangs (Spalte c_arm_relaxed) in cm über 28 Tage." + ), + business_meaning="Arm-Umfang bei locker hängendem Arm ohne zusätzliche Muskelanspannung", + unit="cm", + example_output="+0.3", + **circumference_delta_common + ) + arm_relaxed_28d_delta_metadata.evidence.update(circ_delta_evidence) + arm_relaxed_28d_delta_metadata.set_evidence("resolver_function", EvidenceType.CODE_DERIVED) + arm_relaxed_28d_delta_metadata.set_evidence("data_layer_function", EvidenceType.CODE_DERIVED) + arm_relaxed_28d_delta_metadata.set_evidence("semantic_contract", EvidenceType.DRAFT_DERIVED) + arm_relaxed_28d_delta_metadata.set_evidence("business_meaning", EvidenceType.DRAFT_DERIVED) + arm_relaxed_28d_delta_metadata.set_evidence("unit", EvidenceType.CODE_DERIVED) + arm_relaxed_28d_delta_metadata.set_evidence("example_output", EvidenceType.DRAFT_DERIVED) + register_placeholder(arm_relaxed_28d_delta_metadata) + # ── chest_28d_delta ────────────────────────────────────────────────────── chest_28d_delta_metadata = PlaceholderMetadata( diff --git a/backend/placeholder_resolver.py b/backend/placeholder_resolver.py index 8f8973d..4444254 100644 --- a/backend/placeholder_resolver.py +++ b/backend/placeholder_resolver.py @@ -749,7 +749,8 @@ _SAFE_FLOAT_NONE_REASON: Dict[str, str] = { "waist_28d_delta": "Taillen-Delta 28 Tage nicht berechenbar (zwei auswertbare Messungen nötig)", "hip_28d_delta": "Hüft-Delta 28 Tage nicht berechenbar", "chest_28d_delta": "Brust-Delta 28 Tage nicht berechenbar", - "arm_28d_delta": "Arm-Delta 28 Tage nicht berechenbar", + "arm_28d_delta": "Oberarm kontrahiert: Delta 28 Tage nicht berechenbar", + "arm_relaxed_28d_delta": "Oberarm entspannt: Delta 28 Tage nicht berechenbar", "thigh_28d_delta": "Oberschenkel-Delta 28 Tage nicht berechenbar", "waist_hip_ratio": "Taille-Hüfte-Verhältnis nicht berechenbar", "energy_balance_7d": ( @@ -913,6 +914,7 @@ def _safe_float(func_name: str, profile_id: str, decimals: int = 1) -> str: 'hip_28d_delta': body_metrics.calculate_hip_28d_delta, 'chest_28d_delta': body_metrics.calculate_chest_28d_delta, 'arm_28d_delta': body_metrics.calculate_arm_28d_delta, + 'arm_relaxed_28d_delta': body_metrics.calculate_arm_relaxed_28d_delta, 'thigh_28d_delta': body_metrics.calculate_thigh_28d_delta, 'waist_hip_ratio': body_metrics.calculate_waist_hip_ratio, 'energy_balance_7d': nutrition_metrics.calculate_energy_balance_7d, @@ -1556,6 +1558,7 @@ PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = { '{{hip_28d_delta}}': lambda pid: _safe_float('hip_28d_delta', pid), '{{chest_28d_delta}}': lambda pid: _safe_float('chest_28d_delta', pid), '{{arm_28d_delta}}': lambda pid: _safe_float('arm_28d_delta', pid), + '{{arm_relaxed_28d_delta}}': lambda pid: _safe_float('arm_relaxed_28d_delta', pid), '{{thigh_28d_delta}}': lambda pid: _safe_float('thigh_28d_delta', pid), '{{waist_hip_ratio}}': lambda pid: _safe_float('waist_hip_ratio', pid, decimals=3), '{{recomposition_quadrant}}': lambda pid: _safe_str('recomposition_quadrant', pid), @@ -1785,7 +1788,7 @@ def get_available_placeholders(categories: Optional[List[str]] = None) -> Dict[s '{{weight_7d_median}}', '{{weight_28d_slope}}', '{{weight_90d_slope}}', '{{fm_28d_change}}', '{{lbm_28d_change}}', '{{waist_28d_delta}}', '{{hip_28d_delta}}', '{{chest_28d_delta}}', - '{{arm_28d_delta}}', '{{thigh_28d_delta}}', + '{{arm_28d_delta}}', '{{arm_relaxed_28d_delta}}', '{{thigh_28d_delta}}', '{{waist_hip_ratio}}', '{{recomposition_quadrant}}', '{{body_progress_score}}', ], diff --git a/backend/routers/circumference.py b/backend/routers/circumference.py index 41c06fa..4b3aa6c 100644 --- a/backend/routers/circumference.py +++ b/backend/routers/circumference.py @@ -67,10 +67,10 @@ def upsert_circ(e: CircumferenceEntry, x_profile_id: Optional[str]=Header(defaul # INSERT new entry eid = str(uuid.uuid4()) cur.execute("""INSERT INTO circumference_log - (id,profile_id,date,c_neck,c_chest,c_waist,c_belly,c_hip,c_thigh,c_calf,c_arm,notes,photo_id,created) - VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""", + (id,profile_id,date,c_neck,c_chest,c_waist,c_belly,c_hip,c_thigh,c_calf,c_arm,c_arm_relaxed,notes,photo_id,created) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""", (eid,pid,d['date'],d['c_neck'],d['c_chest'],d['c_waist'],d['c_belly'], - d['c_hip'],d['c_thigh'],d['c_calf'],d['c_arm'],d['notes'],d['photo_id'])) + d['c_hip'],d['c_thigh'],d['c_calf'],d['c_arm'],d.get('c_arm_relaxed'),d['notes'],d['photo_id'])) # Phase 2: Increment usage counter (only for new entries) increment_feature_usage(pid, 'circumference_entries') diff --git a/backend/routers/exportdata.py b/backend/routers/exportdata.py index 5fd9e40..eff5f0c 100644 --- a/backend/routers/exportdata.py +++ b/backend/routers/exportdata.py @@ -312,14 +312,38 @@ Datumsformat: YYYY-MM-DD cur.execute("SELECT id, date, weight, note, source, created FROM weight_log WHERE profile_id=%s ORDER BY date", (pid,)) write_csv(zf, "weight.csv", [r2d(r) for r in cur.fetchall()], ['id','date','weight','note','source','created']) - cur.execute("SELECT id, date, c_waist, c_hip, c_chest, c_neck, c_arm, c_thigh, c_calf, notes, created FROM circumference_log WHERE profile_id=%s ORDER BY date", (pid,)) + cur.execute( + "SELECT id, date, c_waist, c_hip, c_chest, c_neck, c_arm, c_arm_relaxed, c_thigh, c_calf, notes, created FROM circumference_log WHERE profile_id=%s ORDER BY date", + (pid,), + ) rows = [r2d(r) for r in cur.fetchall()] for r in rows: r['waist'] = r.pop('c_waist', None); r['hip'] = r.pop('c_hip', None) r['chest'] = r.pop('c_chest', None); r['neck'] = r.pop('c_neck', None) - r['upper_arm'] = r.pop('c_arm', None); r['thigh'] = r.pop('c_thigh', None) + r['upper_arm_contracted'] = r.pop('c_arm', None) + r['upper_arm_relaxed'] = r.pop('c_arm_relaxed', None) + r['thigh'] = r.pop('c_thigh', None) r['calf'] = r.pop('c_calf', None); r['forearm'] = None; r['note'] = r.pop('notes', None) - write_csv(zf, "circumferences.csv", rows, ['id','date','waist','hip','chest','neck','upper_arm','thigh','calf','forearm','note','created']) + write_csv( + zf, + "circumferences.csv", + rows, + [ + 'id', + 'date', + 'waist', + 'hip', + 'chest', + 'neck', + 'upper_arm_contracted', + 'upper_arm_relaxed', + 'thigh', + 'calf', + 'forearm', + 'note', + 'created', + ], + ) cur.execute("SELECT id, date, sf_chest, sf_abdomen, sf_thigh, sf_triceps, sf_subscap, sf_suprailiac, sf_axilla, sf_method, body_fat_pct, notes, created FROM caliper_log WHERE profile_id=%s ORDER BY date", (pid,)) rows = [r2d(r) for r in cur.fetchall()] diff --git a/backend/routers/importdata.py b/backend/routers/importdata.py index d98a48a..24d82f1 100644 --- a/backend/routers/importdata.py +++ b/backend/routers/importdata.py @@ -112,12 +112,17 @@ async def import_zip( csv_data = zf.read('data/circumferences.csv').decode('utf-8-sig') reader = csv.DictReader(io.StringIO(csv_data), delimiter=';') for row in reader: + _ua_contr = ( + row.get('upper_arm_contracted') + or row.get('upper_arm') + ) + _ua_rel = row.get('upper_arm_relaxed') cur.execute(""" INSERT INTO circumference_log ( profile_id, date, c_waist, c_hip, c_chest, c_neck, - c_arm, c_thigh, c_calf, notes, created + c_arm, c_arm_relaxed, c_thigh, c_calf, notes, created ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ON CONFLICT (profile_id, date) DO NOTHING """, ( pid, @@ -126,7 +131,8 @@ async def import_zip( float(row['hip']) if row.get('hip') else None, float(row['chest']) if row.get('chest') else None, float(row['neck']) if row.get('neck') else None, - float(row['upper_arm']) if row.get('upper_arm') else None, + float(_ua_contr) if _ua_contr not in (None, '') else None, + float(_ua_rel) if _ua_rel not in (None, '') else None, float(row['thigh']) if row.get('thigh') else None, float(row['calf']) if row.get('calf') else None, row.get('note', ''), diff --git a/backend/schema.sql b/backend/schema.sql index b422532..1ccb2c1 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -104,6 +104,7 @@ CREATE TABLE IF NOT EXISTS circumference_log ( c_thigh NUMERIC(5,2), c_calf NUMERIC(5,2), c_arm NUMERIC(5,2), + c_arm_relaxed NUMERIC(5,2), notes TEXT, photo_id UUID, created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP diff --git a/frontend/src/pages/CircumScreen.jsx b/frontend/src/pages/CircumScreen.jsx index 63a5211..0360dcc 100644 --- a/frontend/src/pages/CircumScreen.jsx +++ b/frontend/src/pages/CircumScreen.jsx @@ -6,10 +6,10 @@ import { CIRCUMFERENCE_POINTS } from '../utils/guideData' import UsageBadge from '../components/UsageBadge' import dayjs from 'dayjs' -const FIELDS = ['c_neck','c_chest','c_waist','c_belly','c_hip','c_thigh','c_calf','c_arm'] -const LABELS = {c_neck:'Hals',c_chest:'Brust',c_waist:'Taille',c_belly:'Bauch',c_hip:'Hüfte',c_thigh:'Oberschenkel',c_calf:'Wade',c_arm:'Oberarm'} +const FIELDS = ['c_neck','c_chest','c_waist','c_belly','c_hip','c_thigh','c_calf','c_arm','c_arm_relaxed'] +const LABELS = {c_neck:'Hals',c_chest:'Brust',c_waist:'Taille',c_belly:'Bauch',c_hip:'Hüfte',c_thigh:'Oberschenkel',c_calf:'Wade',c_arm:'Oberarm kontrahiert',c_arm_relaxed:'Oberarm'} -function empty() { return {date:dayjs().format('YYYY-MM-DD'), c_neck:'',c_chest:'',c_waist:'',c_belly:'',c_hip:'',c_thigh:'',c_calf:'',c_arm:'',notes:'',photo_id:''} } +function empty() { return {date:dayjs().format('YYYY-MM-DD'), c_neck:'',c_chest:'',c_waist:'',c_belly:'',c_hip:'',c_thigh:'',c_calf:'',c_arm:'',c_arm_relaxed:'',notes:'',photo_id:''} } export default function CircumScreen() { const [entries, setEntries] = useState([]) diff --git a/frontend/src/pages/MeasureWizard.jsx b/frontend/src/pages/MeasureWizard.jsx index 93e56f3..36bb3fe 100644 --- a/frontend/src/pages/MeasureWizard.jsx +++ b/frontend/src/pages/MeasureWizard.jsx @@ -387,7 +387,7 @@ export default function MeasureWizard() {
Umfänge messen
- 8 Messpunkte · Hals, Brust, Taille, Bauch, Hüfte, Oberschenkel, Wade, Oberarm + 9 Messpunkte · inkl. Oberarm kontrahiert + Oberarm entspannt
diff --git a/frontend/src/pages/NewMeasurement.jsx b/frontend/src/pages/NewMeasurement.jsx index e481682..dab6b04 100644 --- a/frontend/src/pages/NewMeasurement.jsx +++ b/frontend/src/pages/NewMeasurement.jsx @@ -53,7 +53,7 @@ export default function NewMeasurement() { date: dayjs().format('YYYY-MM-DD'), weight: null, c_neck:null, c_chest:null, c_waist:null, c_belly:null, - c_hip:null, c_thigh:null, c_calf:null, c_arm:null, + c_hip:null, c_thigh:null, c_calf:null, c_arm:null, c_arm_relaxed:null, sf_method:'jackson3', sf_chest:null, sf_axilla:null, sf_triceps:null, sf_subscap:null, sf_suprailiac:null, sf_abdomen:null, sf_thigh:null, @@ -132,7 +132,7 @@ export default function NewMeasurement() { {/* Umfänge */}
toggle('circum')} - hint="Hals, Brust, Taille, Bauch, Hüfte, Oberschenkel, Wade, Oberarm"> + hint="Hals, Brust, Taille, Bauch, Hüfte, Oberschenkel, Wade, Oberarm (kontrahiert + entspannt)"> {CIRCUMFERENCE_POINTS.map(p => ( 50 ? p.where.substring(0,48)+'…' : p.where} diff --git a/frontend/src/utils/calc.js b/frontend/src/utils/calc.js index 9e24cb3..621c463 100644 --- a/frontend/src/utils/calc.js +++ b/frontend/src/utils/calc.js @@ -228,11 +228,16 @@ export const CIRCUMFERENCE_GUIDE = [ posture:'Aufrecht, Gewicht gleichmäßig verteilt', how:'Waagerecht, Muskel entspannt', tip:'Morgens messen – abends schwellen Beine durch Wassereinlagerungen an' }, - { id:'c_arm', name:'Oberarm', color:'#1D9E75', + { id:'c_arm', name:'Oberarm kontrahiert', color:'#1D9E75', where:'Dickste Stelle, Mitte zwischen Schultergelenk und Ellenbogen', - posture:'Arm locker hängen lassen und entspannen', + posture:'Ellenbogen leicht gebeugt, Bizeps leicht angespannt', how:'Waagerecht, senkrecht zur Längsachse', - tip:'Immer denselben Arm (rechts); auch angespannt messen und notieren' }, + tip:'Bestehende Messreihen = kontrahiert' }, + { id:'c_arm_relaxed', name:'Oberarm', color:'#2E7D57', + where:'Gleiche Stelle wie kontrahiert', + posture:'Arm locker hängen, ohne Anspannung', + how:'Waagerecht', + tip:'Entspannte Messung für Vergleich zur Kontraktionsmessung' }, ] export const CALIPER_GUIDE = { diff --git a/frontend/src/utils/guideData.js b/frontend/src/utils/guideData.js index f64bf66..e4ca0b0 100644 --- a/frontend/src/utils/guideData.js +++ b/frontend/src/utils/guideData.js @@ -50,11 +50,18 @@ export const CIRCUMFERENCE_POINTS = [ tip: 'Morgens messen – gegen Abend schwellen Beine durch Wassereinlagerungen an' }, { - id: 'c_arm', label: 'Oberarm', color: '#1D9E75', + id: 'c_arm', label: 'Oberarm kontrahiert', color: '#1D9E75', where: 'An der dicksten Stelle des Oberarms – Mitte zwischen Schultergelenk und Ellenbogen', - posture: 'Arm locker hängen lassen und entspannen', - how: 'Waagerecht anlegen, senkrecht zur Längsachse des Arms', - tip: 'Immer denselben Arm messen (meist rechts) – beide Werte notieren (entspannt & angespannt)' + posture: 'Ellenbogen leicht gebeugt, Bizeps leicht anspannen (wie leichter „Kraft-Griff“)', + how: 'Waagerecht anlegen, Maßband ohne Luftspalt, nicht einschneidend', + tip: 'Historische Einträge beziehen sich auf diesen Wert (angespannter Arm)' + }, + { + id: 'c_arm_relaxed', label: 'Oberarm', color: '#2E7D57', + where: 'Gleiche Stelle wie beim kontrahierten Messpunkt – dickste Stelle, Mitte Oberarm', + posture: 'Arm locker und gerade hängen lassen, Schulter entspannt, kein Spannen', + how: 'Waagerecht anlegen; vorher tief einatmen und normal ausatmen, dann messen', + tip: 'Typischer „Alltags“-Umfang ohne extra Muskelanspannung' }, ]