""" IN-SANITY LAB — Motore di calcolo Longevity Score =================================================== Implementa la cascata a 4 livelli definita nel foglio "Pesi e Formule" del Registro Test: 1. Sotto-metrica grezza -> punteggio 0-100 (normalizzazione test-specifica) 2. Punteggi sotto-metrica -> punteggio Asse (media pesata, rinormalizzata se mancano dati) 3. Punteggi Asse -> 3 Macro-score (Performance / Energy / Recovery) 4. Punteggi Asse -> Fitness Age Regola di copertura (uguale a ogni livello): se un elemento manca, il suo peso si ridistribuisce sugli elementi disponibili. Se la copertura di peso disponibile scende sotto il 40%, l'elemento calcolato viene marcato 'insufficiente' invece che pieno. Questo modulo NON include l'interfaccia (dashboard) - produce solo i numeri. Si integra con: - parse_wellness_tower.py (dati automatici Composizione/Cardio/Recupero) - analyze_calibre.py (VO2max diretto, gia' esistente) - inserimento manuale / futura web app (resto dei test) Uso rapido: vedi la funzione demo() in fondo al file. """ from dataclasses import dataclass, field from typing import Optional MIN_COVERAGE = 0.40 # sotto questa soglia di peso disponibile, l'elemento e' 'insufficiente' # ========================================================================= # LIVELLO 1 — Normalizzazione sotto-metrica -> 0-100 # ========================================================================= def clamp(x, lo=0, hi=100): return max(lo, min(hi, x)) def _lerp(x, x0, x1, y0, y1): if x1 == x0: return y0 t = (x - x0) / (x1 - x0) t = max(0, min(1, t)) return y0 + t * (y1 - y0) # --- Composizione Corporea ------------------------------------------------- def score_fat_percent(fat_pct, sex): """Curva a campana ACE. Il picco e' su Atleti/Fitness, non sul grasso piu basso.""" m = sex.strip().lower().startswith("m") bands = [ (0, 2, 40), (2, 5, 65), (5, 13, 100), (13, 17, 90), (17, 24, 65), (24, 35, 30), (35, 200, 10), ] if m else [ (0, 10, 40), (10, 13, 65), (13, 20, 100), (20, 24, 90), (24, 31, 65), (31, 40, 30), (40, 200, 10), ] for lo, hi, val in bands: if lo <= fat_pct < hi: return val return 50 def score_muscle_percent(muscle_pct, sex): """Crescente con plateau: uomini 42.9-52.4%, donne 37.8-46.2% (standard device).""" lo, hi = (42.9, 52.4) if sex.strip().lower().startswith("m") else (37.8, 46.2) if muscle_pct >= hi: return 100 if muscle_pct <= lo - 10: return 20 return round(_lerp(muscle_pct, lo - 10, hi, 20, 100), 1) def score_whr(whr, sex): threshold = 0.90 if sex.strip().lower().startswith("m") else 0.85 if whr <= threshold - 0.10: return 100 if whr <= threshold: return round(_lerp(whr, threshold - 0.10, threshold, 100, 60), 1) return clamp(round(60 - (whr - threshold) * 300, 1)) # --- Cardio-Respiratorio ---------------------------------------------------- def vo2max_from_step_test(hr_recovery, sex): """Queen's College Step Test (McArdle et al. 1972).""" if sex.strip().lower().startswith("m"): return 111.33 - 0.42 * hr_recovery return 65.81 - 0.1847 * hr_recovery def vo2max_from_2km_walk(tempo_min, hr, eta, bmi): """UKK 2km Walking Test (Laukkanen/Oja).""" return 116.2 - 2.98 * tempo_min - 0.11 * hr - 0.14 * eta - 0.39 * bmi def vo2max_from_mutt(vam_m_min): """Formula ACSM su VAM raggiunta al tapis. Etichettare come 'teorico/stimato'.""" return 0.2 * vam_m_min + 3.5 def vo2max_from_milfit(watt, peso_kg): """Formula ACSM cicloergometro. Etichettare come 'teorico/stimato'. Valida 50-200W.""" return (10.8 * watt) / peso_kg + 7 def score_vo2max(vo2max, eta, sex): """ACSM/FRIEND Registry, bande approssimate per fascia d'eta e sesso.""" m = sex.strip().lower().startswith("m") # soglie 'buono' per decade (uomini), interpolate da FRIEND Registry good_thresholds_m = {20: 46, 30: 43, 40: 42, 50: 38, 60: 35, 70: 30} good_thresholds_f = {20: 38, 30: 37, 40: 34, 50: 30, 60: 27, 70: 23} table = good_thresholds_m if m else good_thresholds_f decade = min(70, max(20, (eta // 10) * 10)) good = table[decade] poor = good * 0.65 superior = good * 1.35 if vo2max <= poor: return clamp(round(_lerp(vo2max, 0, poor, 10, 40), 1)) if vo2max <= good: return round(_lerp(vo2max, poor, good, 40, 70), 1) return clamp(round(_lerp(vo2max, good, superior, 70, 100), 1)) def score_spo2(spo2_pct): """Saturazione O2: 95-100% normale, sotto 90% campanello d'allarme clinico.""" if spo2_pct >= 97: return 100 if spo2_pct >= 95: return 85 if spo2_pct >= 90: return 50 return 15 # --- Recupero & Sistema Nervoso -------------------------------------------- def score_blood_pressure(sbp, dbp): """ESC/ESH 2018. Vale la categoria piu alta tra sistolica e diastolica.""" if sbp >= 180 or dbp >= 110: return 5 if sbp >= 160 or dbp >= 100: return 25 if sbp >= 140 or dbp >= 90: return 45 if sbp >= 130 or dbp >= 85: return 70 if sbp >= 120 or dbp >= 80: return 90 return 100 def score_hrr(calo_bpm_1min): """Recupero cardiaco 1' post-sforzo. >=12bpm considerato normale (Cole et al. 1999).""" if calo_bpm_1min >= 12: return clamp(round(_lerp(calo_bpm_1min, 12, 30, 70, 100), 1)) return clamp(round(_lerp(calo_bpm_1min, 0, 12, 20, 70), 1)) # --- Forza & Struttura ------------------------------------------------------- def score_handgrip(kg, eta, sex): """Approssimazione da NIH Toolbox / Dodds et al. Curva discendente con l'eta.""" m = sex.strip().lower().startswith("m") # ancore (eta, valore medio kg) approssimate dalla letteratura citata anchors_m = [(25, 49.7), (40, 46), (60, 38), (75, 30)] anchors_f = [(25, 30), (40, 28), (60, 24), (75, 18.7)] anchors = anchors_m if m else anchors_f eta_c = clamp(eta, 25, 75) for i in range(len(anchors) - 1): a_eta, a_val = anchors[i] b_eta, b_val = anchors[i + 1] if a_eta <= eta_c <= b_eta: mean_val = _lerp(eta_c, a_eta, b_eta, a_val, b_val) break else: mean_val = anchors[-1][1] # media = punteggio 70; +/- 40% della media copre la banda 20-100 ratio = kg / mean_val if mean_val else 1 return clamp(round(_lerp(ratio, 0.5, 1.5, 20, 100), 1)) def score_pushup(reps, eta, sex): """ACSM/CSEP, bande approssimate per decade.""" m = sex.strip().lower().startswith("m") base_good_m, base_good_f = 22, 15 # 35-39 anni, 'buono' minimo decade_offset = max(0, (eta - 35) // 10) * 2.5 # calo ~2.5 rip/decade dopo i 35 good = (base_good_m if m else base_good_f) - decade_offset superior = good * 1.6 poor = good * 0.5 if reps <= poor: return clamp(round(_lerp(reps, 0, poor, 10, 40), 1)) if reps <= good: return round(_lerp(reps, poor, good, 40, 70), 1) return clamp(round(_lerp(reps, good, superior, 70, 100), 1)) def score_bw_ratio_lift(carico_kg, peso_corporeo_kg, rip, tiers_m, tiers_f, sex): """Generico per 5RM->1RM(Brzycki)->rapporto peso corporeo, su tiers (beg/nov/int/adv/elite).""" rm1 = carico_kg * 36 / (37 - rip) # Brzycki ratio = rm1 / peso_corporeo_kg tiers = tiers_m if sex.strip().lower().startswith("m") else tiers_f # tiers = [(soglia_ratio, punteggio), ...] crescente prev_r, prev_s = 0, 10 for r, s in tiers: if ratio <= r: return round(_lerp(ratio, prev_r, r, prev_s, s), 1) prev_r, prev_s = r, s return 100 TIERS_BENCH_M = [(0.5, 30), (1.0, 60), (1.25, 80), (1.5, 100)] TIERS_BENCH_F = [(0.3, 30), (0.6, 60), (0.75, 80), (1.0, 100)] TIERS_SQUAT_M = [(0.75, 30), (1.5, 60), (1.75, 80), (2.0, 100)] TIERS_SQUAT_F = [(0.5, 30), (1.1, 60), (1.3, 80), (1.5, 100)] TIERS_ROW_M = [(0.45, 30), (0.70, 60), (0.95, 80), (1.20, 100)] TIERS_ROW_F = [(0.30, 30), (0.45, 60), (0.60, 80), (0.80, 100)] def score_flexed_arm_hang(sec, sex): good, superior = (45, 70) if sex.strip().lower().startswith("m") else (25, 50) if sec <= good * 0.4: return clamp(round(_lerp(sec, 0, good * 0.4, 10, 40), 1)) if sec <= good: return round(_lerp(sec, good * 0.4, good, 40, 70), 1) return clamp(round(_lerp(sec, good, superior, 70, 100), 1)) def score_sit_to_stand_1min(reps, eta, sex, bmi=24): """Reference equation adulti 18-95 (Zalewski et al.-style).""" sex_code = 0 if sex.strip().lower().startswith("m") else 1 predicted = 61.53 - 0.34 * eta - 3.57 * sex_code - 0.33 * bmi ratio = reps / predicted if predicted else 1 return clamp(round(_lerp(ratio, 0.5, 1.3, 20, 100), 1)) def score_plank(sec, sex): bands_m = [(79, 20), (97, 40), (122, 55), (157, 75), (201, 90)] bands_f = [(35, 20), (63, 40), (84, 55), (108, 75), (142, 90)] bands = bands_m if sex.strip().lower().startswith("m") else bands_f prev_t, prev_s = 0, 10 for t, s in bands: if sec <= t: return round(_lerp(sec, prev_t, t, prev_s, s), 1) prev_t, prev_s = t, s return 100 # --- Stabilità & Mobilità ---------------------------------------------------- def score_back_scratch(cm, eta, sex): """Rikli & Jones (Senior Fitness Test) + studio norvegese per fascia under 60. Positivo=sovrapposizione dita, negativo=distanza. Anchor a 62 anni (dato solido), estrapolato linearmente (~4cm/5 anni) per le altre eta. Approssimazione da dichiarare, non tabella completa.""" m = sex.strip().lower().startswith("m") anchor_eta, anchor_cm = 62, (-8.6 if m else -1.8) decline_per_year = 0.8 # cm peggioramento per anno di eta in piu expected = anchor_cm - (eta - anchor_eta) * decline_per_year # punteggio: 0/pieno contatto (0cm) o sovrapposizione (positivo) = ottimo diff = cm - expected return clamp(round(_lerp(diff, -15, 15, 20, 100), 1)) def score_shoulder_mobility_wt(outreach_deg, buckling_deg): """Wellness Tower, riferimento 180 gradi (outreach/buckling).""" avg = (outreach_deg + buckling_deg) / 2 return clamp(round(avg / 180 * 100, 1)) def score_flamingo(cadute): """Decrescente: meno cadute = meglio.""" if cadute <= 3: return 100 if cadute <= 7: return round(_lerp(cadute, 3, 7, 100, 80), 1) if cadute <= 15: return round(_lerp(cadute, 7, 15, 80, 50), 1) return clamp(round(_lerp(cadute, 15, 30, 50, 10), 1)) def score_sit_and_reach(cm, sex): median = 24 if sex.strip().lower().startswith("m") else 31 return clamp(round(_lerp(cm, median - 20, median + 10, 20, 100), 1)) def score_agility_ms(ms, eta, sex): """Blomkvist et al. 2017, decrescente (meno ms = meglio).""" base = 250 if sex.strip().lower().startswith("m") else 265 decade_add = max(0, (eta - 30) // 10) * 35 expected = base + decade_add ratio = expected / ms if ms else 1 return clamp(round(_lerp(ratio, 0.7, 1.3, 20, 100), 1)) # --- Questionario: curve generiche ----------------------------------------- def score_bell_curve(value, low, peak_low, peak_high, high): """Curva a campana: picco tra peak_low e peak_high, decresce ai lati.""" if peak_low <= value <= peak_high: return 100 if value < peak_low: return clamp(round(_lerp(value, low, peak_low, 10, 100), 1)) return clamp(round(_lerp(value, peak_high, high, 100, 10), 1)) def score_decreasing(value, best, worst): return clamp(round(_lerp(value, best, worst, 100, 0), 1)) def score_increasing_plateau(value, worst, plateau_start): return clamp(round(_lerp(value, worst, plateau_start, 20, 100), 1)) def score_direct_x10(value_0_10): return clamp(round(value_0_10 * 10, 1)) # ========================================================================= # LIVELLO 2 — Sotto-metriche -> punteggio Asse # ========================================================================= @dataclass class WeightedScore: score: Optional[float] # None se dato mancante weight: float # peso nominale (dalla config) def aggregate(items: list[WeightedScore]): """Rinormalizza sui pesi disponibili. Ritorna (punteggio, copertura, stato).""" total_weight = sum(i.weight for i in items) available = [i for i in items if i.score is not None] available_weight = sum(i.weight for i in available) coverage = available_weight / total_weight if total_weight else 0 if not available: return None, 0.0, "insufficiente" weighted_sum = sum(i.score * i.weight for i in available) score = weighted_sum / available_weight status = "insufficiente" if coverage < MIN_COVERAGE else "ok" return round(score, 1), round(coverage, 2), status # Config pesi sotto-dominio -> asse (dal foglio "Pesi e Formule") AXIS_SUBDOMAIN_WEIGHTS = { "Forza & Struttura": { "handgrip": 0.25, "spinta": 0.20, "trazione": 0.20, "arti_inferiori": 0.20, "core": 0.15, }, "Composizione Corporea": { "grasso": 0.40, "muscolo": 0.35, "whr": 0.25, }, "Cardio-Respiratorio": { "vo2max": 0.60, "spirometria": 0.20, "wellness_tower_cardio": 0.20, }, "Recupero & Sistema Nervoso": { "hrv": 0.50, "pressione": 0.15, "hrr": 0.15, "questionario_sonno": 0.20, }, "Energia & Regolazione Stress": { "hrv": 0.50, "questionario_energia_stress": 0.50, }, "Stabilità & Mobilità Funzionale": { "flamingo": 0.35, "sit_and_reach": 0.25, "plank": 0.20, "back_scratch": 0.14, "wellness_tower_shoulder": 0.06, }, "Stile di Vita & Sonno": { "questionario_lifestyle": 1.00, }, } # Config pesi asse -> macro-score MACRO_SCORE_WEIGHTS = { "PERFORMANCE": { "Forza & Struttura": 0.25, "Cardio-Respiratorio": 0.25, "Composizione Corporea": 0.15, "Stabilità & Mobilità Funzionale": 0.20, "Recupero & Sistema Nervoso": 0.10, "Energia & Regolazione Stress": 0.05, "Stile di Vita & Sonno": 0.0, }, "ENERGY": { "Energia & Regolazione Stress": 0.30, "Stile di Vita & Sonno": 0.20, "Recupero & Sistema Nervoso": 0.25, "Cardio-Respiratorio": 0.15, "Composizione Corporea": 0.10, "Forza & Struttura": 0.0, "Stabilità & Mobilità Funzionale": 0.0, }, "RECOVERY": { "Recupero & Sistema Nervoso": 0.45, "Energia & Regolazione Stress": 0.15, "Stile di Vita & Sonno": 0.20, "Stabilità & Mobilità Funzionale": 0.15, "Forza & Struttura": 0.05, "Cardio-Respiratorio": 0.0, "Composizione Corporea": 0.0, }, } # Config pesi Fitness Age (nota: Handgrip ed HRV entrano isolati, non tramite l'intero asse) FITNESS_AGE_WEIGHTS = { "cardio": 0.30, "handgrip_isolato": 0.20, "hrv_isolato": 0.20, "forza_resto": 0.15, "composizione": 0.10, "stabilita": 0.05, } def compute_axis(axis_name, subdomain_scores: dict): """subdomain_scores: {nome_sottodominio: score_0_100 or None}""" weights = AXIS_SUBDOMAIN_WEIGHTS[axis_name] items = [WeightedScore(subdomain_scores.get(k), w) for k, w in weights.items()] score, coverage, status = aggregate(items) return {"score": score, "coverage": coverage, "status": status} # ========================================================================= # LIVELLO 3 — 7 Assi -> 3 Macro-score # ========================================================================= def compute_macro_scores(axis_scores: dict): """axis_scores: {nome_asse: {'score':..,'status':..}}""" results = {} for macro_name, weights in MACRO_SCORE_WEIGHTS.items(): items = [ WeightedScore(axis_scores[axis]["score"] if axis_scores[axis]["status"] != "insufficiente" else None, w) for axis, w in weights.items() if w > 0 ] score, coverage, status = aggregate(items) results[macro_name] = {"score": score, "coverage": coverage, "status": status} return results # ========================================================================= # LIVELLO 4 — Fitness Age # ========================================================================= def compute_fitness_age(eta_anagrafica, cardio_score, handgrip_score, hrv_score, forza_resto_score, composizione_score, stabilita_score): items = [ WeightedScore(cardio_score, FITNESS_AGE_WEIGHTS["cardio"]), WeightedScore(handgrip_score, FITNESS_AGE_WEIGHTS["handgrip_isolato"]), WeightedScore(hrv_score, FITNESS_AGE_WEIGHTS["hrv_isolato"]), WeightedScore(forza_resto_score, FITNESS_AGE_WEIGHTS["forza_resto"]), WeightedScore(composizione_score, FITNESS_AGE_WEIGHTS["composizione"]), WeightedScore(stabilita_score, FITNESS_AGE_WEIGHTS["stabilita"]), ] composite, coverage, status = aggregate(items) if composite is None: return {"fitness_age": None, "coverage": coverage, "status": "insufficiente"} fitness_age = eta_anagrafica - (composite - 50) * 0.4 return {"fitness_age": round(fitness_age, 1), "composite": composite, "coverage": coverage, "status": status} # ========================================================================= # DEMO END-TO-END # ========================================================================= def demo(): """Esempio completo su un cliente fittizio (M, 38 anni, dati plausibili).""" eta, sex, peso = 38, "M", 90.3 # --- Livello 1: normalizzazione sotto-metriche --- forza = { "handgrip": score_handgrip(kg=48, eta=eta, sex=sex), "spinta": score_pushup(reps=24, eta=eta, sex=sex), "trazione": score_bw_ratio_lift(80, peso, 5, TIERS_ROW_M, TIERS_ROW_F, sex), "arti_inferiori": score_bw_ratio_lift(140, peso, 5, TIERS_SQUAT_M, TIERS_SQUAT_F, sex), "core": score_plank(sec=110, sex=sex), } composizione = { "grasso": score_fat_percent(12.8, sex), "muscolo": score_muscle_percent(48.8, sex), "whr": score_whr(0.71, sex), } vo2 = vo2max_from_mutt(vam_m_min=230) cardio = { "vo2max": score_vo2max(vo2, eta, sex), "spirometria": 75, # placeholder: da calcolatore GLI dedicato "wellness_tower_cardio": score_spo2(98), } recupero = { "hrv": 68, # placeholder: score gia' normalizzato da ISL HRV Monitor "pressione": score_blood_pressure(114, 81), "hrr": score_hrr(calo_bpm_1min=15), "questionario_sonno": 72, # placeholder aggregato risposte questionario } energia = { "hrv": 65, "questionario_energia_stress": 60, } stabilita = { "flamingo": score_flamingo(cadute=5), "sit_and_reach": score_sit_and_reach(cm=20, sex=sex), "plank": score_plank(sec=110, sex=sex), "back_scratch": score_back_scratch(cm=-5, eta=eta, sex=sex), "wellness_tower_shoulder": score_shoulder_mobility_wt(180, 180), } stile_vita = { "questionario_lifestyle": 70, } # --- Livello 2: sotto-metriche -> assi --- axis_scores = { "Forza & Struttura": compute_axis("Forza & Struttura", forza), "Composizione Corporea": compute_axis("Composizione Corporea", composizione), "Cardio-Respiratorio": compute_axis("Cardio-Respiratorio", cardio), "Recupero & Sistema Nervoso": compute_axis("Recupero & Sistema Nervoso", recupero), "Energia & Regolazione Stress": compute_axis("Energia & Regolazione Stress", energia), "Stabilità & Mobilità Funzionale": compute_axis("Stabilità & Mobilità Funzionale", stabilita), "Stile di Vita & Sonno": compute_axis("Stile di Vita & Sonno", stile_vita), } print("=== RADAR — 7 ASSI ===") for name, res in axis_scores.items(): print(f" {name:35s} {res['score']:>6} (copertura {res['coverage']*100:.0f}%, {res['status']})") # --- Livello 3: assi -> 3 macro-score --- macro = compute_macro_scores(axis_scores) print("\n=== MACRO-SCORE ===") for name, res in macro.items(): print(f" {name:12s} {res['score']:>6} (copertura {res['coverage']*100:.0f}%, {res['status']})") # --- Livello 4: Fitness Age --- fa = compute_fitness_age( eta_anagrafica=eta, cardio_score=axis_scores["Cardio-Respiratorio"]["score"], handgrip_score=forza["handgrip"], hrv_score=recupero["hrv"], forza_resto_score=axis_scores["Forza & Struttura"]["score"], composizione_score=axis_scores["Composizione Corporea"]["score"], stabilita_score=axis_scores["Stabilità & Mobilità Funzionale"]["score"], ) print(f"\n=== FITNESS AGE ===") print(f" Eta anagrafica: {eta} -> Fitness Age: {fa['fitness_age']} (composite {fa['composite']}, copertura {fa['coverage']*100:.0f}%)") def score_generic_range_local(value, low, high): if high == low: return 50 pct = (value - low) / (high - low) return clamp(round(pct * 100, 1)) if __name__ == "__main__": demo()