feat: add relaxed arm circumference measurement and update related features #91

Merged
Lars merged 1 commits from develop into main 2026-04-19 10:44:18 +02:00
16 changed files with 122 additions and 33 deletions

View File

@ -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)

View File

@ -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',

View File

@ -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)

View File

@ -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';

View File

@ -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

View File

@ -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(

View File

@ -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}}',
],

View File

@ -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')

View File

@ -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()]

View File

@ -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', ''),

View File

@ -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

View File

@ -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([])

View File

@ -387,7 +387,7 @@ export default function MeasureWizard() {
<div>
<div style={{fontSize:16,fontWeight:600}}>Umfänge messen</div>
<div style={{fontSize:12,color:'var(--text3)',marginTop:2}}>
8 Messpunkte · Hals, Brust, Taille, Bauch, Hüfte, Oberschenkel, Wade, Oberarm
9 Messpunkte · inkl. Oberarm kontrahiert + Oberarm entspannt
</div>
</div>
<ChevronRight size={20} style={{marginLeft:'auto',color:'var(--text3)'}}/>

View File

@ -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 */}
<Section title="Umfänge" open={openSections.circum} onToggle={()=>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 => (
<NumInput key={p.id} label={p.label}
guideText={p.where.length > 50 ? p.where.substring(0,48)+'…' : p.where}

View File

@ -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 = {

View File

@ -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'
},
]