538 lines
20 KiB
Python
538 lines
20 KiB
Python
"""
|
|
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()
|