Separazione: le aree riservate diventano un'app propria (apps/platforms)
Campus, Stress Index e Longevity escono dal sito e diventano apps.insanitylab.it, con utenti, sessioni, login e database propri. Deciso da Adriano il 03/09/2026: una repo sola, due applicazioni autosufficienti, cosi' che ognuna resti estraibile. Misura che ha guidato il taglio: le tre aree condividevano col sito SOLO l'identita' - ~170 righe fra auth.ts, middleware.ts e safe-next.ts. Layout, librerie e dati erano gia' disgiunti. Lato piattaforme: - ruoli piattaforme/cliente/trainer/admin, senza quelli redazionali - canAccessPath con default CHIUSO (nel sito era aperto: li' le rotte non elencate erano di chiunque fosse loggato, qui ogni sezione e' di qualcuno) - tutta l'app protetta, si elencano le eccezioni invece dei prefissi protetti - /api risponde 401/403, non un redirect verso una pagina di login - tre database distinti: utenti, dati clinici senza nomi, mappatura coi nomi Lato sito: - ROLES tornano i tre redazionali; via le regole e il login pubblico - il login del pannello RESPINGE un account emigrato invece di aprirgli una sessione: senza, landingFor lo mandava su /admin, il middleware glielo negava, e si formava un ciclo di redirect che sembra un guasto del sito - l'header non mostra piu' un menu delle piattaforme: un link che esce Verificato: sito 20 file / 146 test, piattaforme 26 file / 197 test, entrambi i build passano, e l'app girata davvero in locale - il ruolo piattaforme entra in Campus e viene rimbalzato da Longevity, il cliente il contrario. Non ancora in produzione: manca il record DNS di apps.insanitylab.it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
# L'oracolo del motore di calcolo
|
||||
|
||||
`isl_scoring_engine.py` è il motore di punteggio **scritto dal cliente** (In-Sanity Lab),
|
||||
ricevuto il 2026-08-21. Non è codice nostro e non va modificato: sta qui perché è
|
||||
l'**oracolo** contro cui verifichiamo il porting in TypeScript.
|
||||
|
||||
Il porting delle curve è la parte più delicata della piattaforma: un coefficiente
|
||||
trascritto male produce un punteggio sanitario sbagliato che sembra plausibile e che
|
||||
nessun test funzionale intercetterebbe, perché diventerebbe il valore "atteso" di tutto
|
||||
ciò che viene dopo. La fedeltà non è garantita dalla trascrizione, ma dal confronto
|
||||
numerico: `genera-riferimento.py` esegue questo motore su una griglia di casi e scrive
|
||||
`riferimento.json`, che il test TypeScript rilegge per confrontare i due risultati.
|
||||
|
||||
Serve `python3` (solo libreria standard: il motore importa unicamente `dataclasses` e
|
||||
`typing`).
|
||||
|
||||
## Rigenerare il riferimento
|
||||
|
||||
python3 tests/longevity/riferimento/genera-riferimento.py
|
||||
|
||||
Va rifatto solo se il cliente manda una versione nuova del motore. In quel caso il
|
||||
confronto dirà da sé quali curve sono cambiate.
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Esegue il motore del cliente su una griglia fitta di ingressi e scrive i risultati
|
||||
in riferimento.json. È il metro contro cui si misura il porting in TypeScript:
|
||||
non serve a provare che l'oracolo è giusto, ma che il nostro porting gli è fedele.
|
||||
|
||||
Uso: python3 tests/longevity/riferimento/genera-riferimento.py
|
||||
"""
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
QUI = pathlib.Path(__file__).parent
|
||||
sys.path.insert(0, str(QUI))
|
||||
|
||||
import isl_scoring_engine as m
|
||||
|
||||
casi = []
|
||||
|
||||
|
||||
def prova(fn_nome, *args):
|
||||
"""Esegue una funzione dell'oracolo e registra ingressi e uscita."""
|
||||
fn = getattr(m, fn_nome)
|
||||
casi.append({"fn": fn_nome, "args": list(args), "atteso": fn(*args)})
|
||||
|
||||
|
||||
def griglia(inizio, fine, passo):
|
||||
"""Valori da inizio a fine compreso, con arrotondamento pulito."""
|
||||
n, v = [], inizio
|
||||
while v <= fine + 1e-9:
|
||||
n.append(round(v, 4))
|
||||
v += passo
|
||||
return n
|
||||
|
||||
|
||||
# --- curve generiche del questionario: tutto il dominio, non un campione ---
|
||||
for v in griglia(0, 14, 0.5):
|
||||
prova("score_bell_curve", v, 4, 7, 9, 12) # q_ore_sonno
|
||||
prova("score_decreasing", v, 0, 7) # q_caffeina e gemelle
|
||||
prova("score_decreasing", v, 7, 14) # q_alcol_life: la soglia voluta
|
||||
for v in griglia(0, 10, 0.5):
|
||||
prova("score_direct_x10", v)
|
||||
for v in griglia(0, 7, 0.5):
|
||||
prova("score_increasing_plateau", v, 0, 4)
|
||||
prova("score_increasing_plateau", v, 2, 6) # worst != 0: l'offset e' l'unico parametro a rischio refuso
|
||||
|
||||
# --- composizione corporea: attorno ai confini delle bande ---
|
||||
for sesso in ("M", "F"):
|
||||
for v in griglia(0, 45, 0.5):
|
||||
prova("score_fat_percent", v, sesso)
|
||||
for v in griglia(20, 60, 0.5):
|
||||
prova("score_muscle_percent", v, sesso)
|
||||
for v in griglia(0.5, 1.3, 0.01):
|
||||
prova("score_whr", v, sesso)
|
||||
|
||||
# --- cardio ---
|
||||
for v in griglia(85, 100, 1):
|
||||
prova("score_spo2", v)
|
||||
for sbp in griglia(90, 200, 5):
|
||||
for dbp in griglia(50, 120, 5):
|
||||
prova("score_blood_pressure", sbp, dbp)
|
||||
for v in griglia(0, 40, 1):
|
||||
prova("score_hrr", v)
|
||||
for sesso in ("M", "F"):
|
||||
for eta in griglia(20, 75, 5):
|
||||
for v in griglia(15, 70, 2.5):
|
||||
prova("score_vo2max", v, eta, sesso)
|
||||
for v in griglia(120, 200, 5):
|
||||
prova("vo2max_from_step_test", v, "M")
|
||||
prova("vo2max_from_step_test", v, "F")
|
||||
for v in griglia(100, 300, 10):
|
||||
prova("vo2max_from_mutt", v)
|
||||
for w in griglia(50, 200, 10):
|
||||
prova("vo2max_from_milfit", w, 75)
|
||||
# caso base + variazione di un parametro alla volta: un punto solo non separa i 5 coefficienti
|
||||
BASE_2KM = (15.5, 130, 40, 24) # tempo_min, hr, eta, bmi
|
||||
prova("vo2max_from_2km_walk", *BASE_2KM)
|
||||
for tempo in griglia(10, 25, 2.5):
|
||||
prova("vo2max_from_2km_walk", tempo, BASE_2KM[1], BASE_2KM[2], BASE_2KM[3])
|
||||
for hr in griglia(100, 160, 10):
|
||||
prova("vo2max_from_2km_walk", BASE_2KM[0], hr, BASE_2KM[2], BASE_2KM[3])
|
||||
for eta in griglia(20, 75, 5):
|
||||
prova("vo2max_from_2km_walk", BASE_2KM[0], BASE_2KM[1], eta, BASE_2KM[3])
|
||||
for bmi in griglia(18, 32, 2):
|
||||
prova("vo2max_from_2km_walk", BASE_2KM[0], BASE_2KM[1], BASE_2KM[2], bmi)
|
||||
|
||||
# --- forza ---
|
||||
for sesso in ("M", "F"):
|
||||
for eta in griglia(25, 75, 5):
|
||||
for v in griglia(10, 70, 2.5):
|
||||
prova("score_handgrip", v, eta, sesso)
|
||||
for v in griglia(0, 60, 2):
|
||||
prova("score_pushup", v, eta, sesso)
|
||||
for v in griglia(0, 250, 5):
|
||||
prova("score_plank", v, sesso)
|
||||
confini_plank = [79, 97, 122, 157, 201] if sesso == "M" else [35, 63, 84, 108, 142]
|
||||
for v in confini_plank: # confini esatti delle bande: costano zero, tolgono ogni dubbio
|
||||
prova("score_plank", v, sesso)
|
||||
for v in griglia(0, 120, 5):
|
||||
prova("score_flexed_arm_hang", v, sesso)
|
||||
for v in griglia(0, 60, 2):
|
||||
prova("score_sit_to_stand_1min", v, 40, sesso)
|
||||
for v in griglia(-30, 30, 1):
|
||||
prova("score_sit_and_reach", v, sesso)
|
||||
for eta in griglia(25, 75, 5):
|
||||
for v in griglia(-30, 20, 2):
|
||||
prova("score_back_scratch", v, eta, sesso)
|
||||
|
||||
# --- sollevamenti sui rapporti col peso corporeo ---
|
||||
for sesso in ("M", "F"):
|
||||
for carico in griglia(20, 200, 10):
|
||||
prova("score_bw_ratio_lift", carico, 80, 5, m.TIERS_BENCH_M, m.TIERS_BENCH_F, sesso)
|
||||
prova("score_bw_ratio_lift", carico, 80, 5, m.TIERS_SQUAT_M, m.TIERS_SQUAT_F, sesso)
|
||||
prova("score_bw_ratio_lift", carico, 80, 5, m.TIERS_ROW_M, m.TIERS_ROW_F, sesso)
|
||||
|
||||
# --- stabilità ---
|
||||
for v in griglia(0, 30, 1):
|
||||
prova("score_flamingo", v)
|
||||
for a in griglia(0, 180, 10):
|
||||
prova("score_shoulder_mobility_wt", a, a)
|
||||
|
||||
uscita = QUI / "riferimento.json"
|
||||
uscita.write_text(json.dumps(
|
||||
{"generato_da": "isl_scoring_engine.py", "casi": casi},
|
||||
indent=1, ensure_ascii=False,
|
||||
))
|
||||
print(f"{len(casi)} casi scritti in {uscita}")
|
||||
@@ -0,0 +1,537 @@
|
||||
"""
|
||||
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()
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user