diff --git a/apps/platforms/src/lib/longevity/motore/fisici.ts b/apps/platforms/src/lib/longevity/motore/fisici.ts new file mode 100644 index 0000000..f610174 --- /dev/null +++ b/apps/platforms/src/lib/longevity/motore/fisici.ts @@ -0,0 +1,139 @@ +/** + * Dalle misure grezze del check-up (registrate coi test_id `f_*` di `registro.ts`) ai + * punteggi 0-100 di sotto-dominio, con lo stesso raggruppamento che l'oracolo fa in + * `demo()` (`tests/longevity/riferimento/isl_scoring_engine.py`): ogni sotto-dominio è + * una chiamata a una funzione di `test-fisici.ts`, con gli argomenti giusti presi dalla + * mappa delle misure. + * + * A differenza del questionario, queste voci NON passano da `applicaCurva`: le sette curve + * generiche (`bell`, `lin_dec`, ecc.) prendono un solo valore, e quasi tutte le funzioni qui + * sotto ne vogliono almeno due (il valore grezzo più età e/o sesso, a volte anche il peso + * corporeo di un'altra misura). Estendere `applicaCurva` per farci passare età e sesso è + * lavoro del piano degli import (vedi l'intestazione di `test-fisici.ts`), non di questa + * revisione: qui il calcolo resta una funzione dedicata, non una curva dichiarata nel dato. + * + * REGOLA UNICA, la stessa di tutto il motore: un sotto-dominio a cui manca anche un solo + * ingresso (compresa l'età, dove la funzione la richiede) NON compare nel risultato — mai + * un valore di ripiego. È lo stesso principio di `applicaCurva`/`paramsValidi`, qui + * applicato a funzioni con più argomenti invece che a una curva coi parametri incompleti. + * + * FUORI DAL PERIMETRO (dichiarato, non dimenticato): spirometria (serve un calcolatore + * GLI che non esiste ancora), milfit, il 2km walk, il sit-to-stand, la trazione isometrica. + * L'oracolo li usa nella demo con segnaposto o non li usa affatto nei pesi correnti; + * restano fuori finché non arriva un test_id e una decisione su come raccoglierli. + */ + +import type { Sesso } from './test-fisici'; +import { clamp } from './curve'; +import { + scoreHandgrip, scorePushup, scoreSollevamentoSuPeso, TIERS_ROW_M, TIERS_ROW_F, + TIERS_SQUAT_M, TIERS_SQUAT_F, scorePlank, scoreGrassoPercento, scoreMuscoloPercento, + scoreWhr, scoreVo2max, vo2maxDaMutt, vo2maxDaStepTest, scoreSpo2, scorePressione, + scoreRecuperoCardiaco, scoreFlamingo, scoreSitAndReach, scoreBackScratch, scoreMobilitaSpalla, +} from './test-fisici'; + +export type { Sesso }; + +/** + * `m` è la mappa test_id -> valore grezzo più recente e valido (vedi `misureValideAlla` in + * `motore/index.ts`), `eta` l'età alla data della sessione (nullable come in tutto il + * motore: senza età gli score che la richiedono restano assenti, mai calcolati a zero). + * + * Ordine dei blocchi: uguale a `demo()` nell'oracolo (forza, composizione, cardio, + * recupero, stabilità), così un confronto riga per riga fra questo file e quello resta + * diretto. + */ +export function sottoDominiFisici( + m: Map, eta: number | null, sesso: Sesso +): Record { + const out: Record = {}; + + // --- Forza & Struttura ---------------------------------------------------- + const handgrip = m.get('f_handgrip_kg'); + if (handgrip !== undefined && eta !== null) out.handgrip = scoreHandgrip(handgrip, eta, sesso); + + const pushup = m.get('f_pushup_rip'); + if (pushup !== undefined && eta !== null) out.spinta = scorePushup(pushup, eta, sesso); + + const pesoKg = m.get('f_peso_kg'); + const rowCarico = m.get('f_row_carico_kg'); + const rowRip = m.get('f_row_rip'); + if (rowCarico !== undefined && pesoKg !== undefined && rowRip !== undefined) { + out.trazione = scoreSollevamentoSuPeso(rowCarico, pesoKg, rowRip, TIERS_ROW_M, TIERS_ROW_F, sesso); + } + + const squatCarico = m.get('f_squat_carico_kg'); + const squatRip = m.get('f_squat_rip'); + if (squatCarico !== undefined && pesoKg !== undefined && squatRip !== undefined) { + out.arti_inferiori = scoreSollevamentoSuPeso(squatCarico, pesoKg, squatRip, TIERS_SQUAT_M, TIERS_SQUAT_F, sesso); + } + + // Il plank pesa su DUE assi (core in Forza & Struttura, plank in Stabilità & Mobilità): + // confermato da Nicola il 22/08, vedi il commento su PESI_ASSE in registro.ts. Stessa + // misura, stessa funzione, due chiavi nel risultato — non è una duplicazione per errore. + const plank = m.get('f_plank_sec'); + if (plank !== undefined) { + const punteggio = scorePlank(plank, sesso); + out.core = punteggio; + out.plank = punteggio; + } + + // --- Composizione Corporea ------------------------------------------------- + const grasso = m.get('f_grasso_pct'); + if (grasso !== undefined) out.grasso = scoreGrassoPercento(grasso, sesso); + + const muscolo = m.get('f_muscolo_pct'); + if (muscolo !== undefined) out.muscolo = scoreMuscoloPercento(muscolo, sesso); + + const whr = m.get('f_whr'); + if (whr !== undefined) out.whr = scoreWhr(whr, sesso); + + // --- Cardio-Respiratorio ----------------------------------------------------- + // VO2max: diretto se misurato, altrimenti stimato da MUTT, altrimenti dallo step test — + // il primo presente vince, come nella cascata di fonti dell'oracolo (che nella demo usa + // solo MUTT, ma le tre vie coesistono nel resto del file Python). + const vo2Diretto = m.get('f_vo2max'); + const muttVam = m.get('f_mutt_vam'); + const stepHr = m.get('f_step_hr_recovery'); + let vo2max: number | undefined; + if (vo2Diretto !== undefined) vo2max = vo2Diretto; + else if (muttVam !== undefined) vo2max = vo2maxDaMutt(muttVam); + else if (stepHr !== undefined) vo2max = vo2maxDaStepTest(stepHr, sesso); + if (vo2max !== undefined && eta !== null) out.vo2max = scoreVo2max(vo2max, eta, sesso); + + const spo2 = m.get('f_spo2_pct'); + if (spo2 !== undefined) out.wellness_tower_cardio = scoreSpo2(spo2); + + // --- Recupero & Sistema Nervoso / Energia & Regolazione Stress --------------- + // hrv è già un punteggio 0-100 (lo calcola lo Stress Index a monte, non una curva di + // questo motore): qui si limita soltanto, non si ricalcola. Pesa su DUE assi (Recupero + // e Energia, vedi PESI_ASSE) con la stessa chiave 'hrv' — un solo valore in `out`, letto + // da entrambi in cascata.ts. + const hrv = m.get('f_hrv_score'); + if (hrv !== undefined) out.hrv = clamp(hrv); + + const sistolica = m.get('f_pa_sistolica'); + const diastolica = m.get('f_pa_diastolica'); + if (sistolica !== undefined && diastolica !== undefined) out.pressione = scorePressione(sistolica, diastolica); + + const hrr = m.get('f_hrr_1min_bpm'); + if (hrr !== undefined) out.hrr = scoreRecuperoCardiaco(hrr); + + // --- Stabilità & Mobilità Funzionale ------------------------------------------ + const flamingo = m.get('f_flamingo_cadute'); + if (flamingo !== undefined) out.flamingo = scoreFlamingo(flamingo); + + const sitReach = m.get('f_sit_reach_cm'); + if (sitReach !== undefined) out.sit_and_reach = scoreSitAndReach(sitReach, sesso); + + const backScratch = m.get('f_back_scratch_cm'); + if (backScratch !== undefined && eta !== null) out.back_scratch = scoreBackScratch(backScratch, eta, sesso); + + const outreach = m.get('f_spalla_outreach_deg'); + const buckling = m.get('f_spalla_buckling_deg'); + if (outreach !== undefined && buckling !== undefined) { + out.wellness_tower_shoulder = scoreMobilitaSpalla(outreach, buckling); + } + + return out; +} diff --git a/apps/platforms/src/lib/longevity/motore/test-fisici.ts b/apps/platforms/src/lib/longevity/motore/test-fisici.ts index 446c33c..4693929 100644 --- a/apps/platforms/src/lib/longevity/motore/test-fisici.ts +++ b/apps/platforms/src/lib/longevity/motore/test-fisici.ts @@ -15,18 +15,15 @@ * comparabili fra protocolli), la seconda perché non è mai chiamata da * nessun peso. * - * ⚠️ NON COLLEGATE AL MOTORE. Queste venti funzioni sono corrette e verificate - * contro l'oracolo (`tests/longevity/motore-fisici.test.ts`), ma oggi sono - * irraggiungibili in produzione: `applicaCurva` (`motore/index.ts`) smista solo - * sulle sette curve generiche dichiarate nel vocabolario di `registro.ts` - * (`bell`, `lin_dec`, `inc_plateau`, `x10`, `x10_inv`, `decstep`, `incstep`) e - * non conosce nessun `test_id` fisico; e la sua firma prende `(voce, valore)`, - * senza modo di passare età e sesso, che quasi tutte queste funzioni - * richiedono. Agganciarle è lavoro del piano degli import, non di questa - * revisione, e richiederà di ESTENDERE il vocabolario delle curve e di far - * arrivare età/sesso fino ad `applicaCurva` — quindi non sarà "una modifica ai - * dati" nel senso in cui lo intende la spec (registro senza migrazioni): serve - * anche codice nuovo nel motore, non solo righe nuove in `registro_test`. + * COLLEGATE AL MOTORE tramite `motore/fisici.ts` (`sottoDominiFisici`), non + * tramite `applicaCurva`: `applicaCurva` smista solo sulle sette curve + * generiche dichiarate nel vocabolario di `registro.ts` (`bell`, `lin_dec`, + * `inc_plateau`, `x10`, `x10_inv`, `decstep`, `incstep`) e la sua firma prende + * `(voce, valore)`, senza modo di passare età e sesso che quasi tutte queste + * funzioni richiedono — estenderla resta lavoro futuro, non fatto qui. + * `sottoDominiFisici` aggira il problema chiamando queste funzioni + * direttamente, con gli argomenti che servono presi dalla mappa delle misure + * del check-up (`registro.ts`, voci `f_*`). */ import { clamp, lerp, arrotonda1 } from './curve'; diff --git a/apps/platforms/src/lib/longevity/registro.ts b/apps/platforms/src/lib/longevity/registro.ts index 3204c26..e17530f 100644 --- a/apps/platforms/src/lib/longevity/registro.ts +++ b/apps/platforms/src/lib/longevity/registro.ts @@ -93,6 +93,71 @@ const QUESTIONARIO: VoceRegistro[] = [ asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 90, attivo_da: DA }, ]; +// I test fisici del check-up: misure GREZZE, non punteggi. A differenza del questionario +// non passano da `applicaCurva` (nessuna `curva`/`params` qui: quelle sette curve generiche +// non sanno cosa farsene di un'età o di un sesso) — il calcolo è in `motore/fisici.ts` +// (`sottoDominiFisici`), che è il porting dell'oracolo per queste ventiquattro voci. Per +// questo `sotto_dominio` resta assente: il sotto-dominio non è un dato del registro come +// per il questionario, è il NOME della chiave che `sottoDominiFisici` restituisce. +// `asse` invece è valorizzato: serve solo all'interfaccia (raggruppare il modulo del +// check-up per sezione), nessun codice lo legge oggi. Due voci alimentano DUE +// sotto-domini dalla stessa misura (f_plank_sec -> core E plank, f_hrv_score -> hrv sia +// in Recupero che in Energia): l'asse riportato qui è quello principale, l'altro si vede +// in `sottoDominiFisici`. +// `range_min`/`range_max` sono limiti di PLAUSIBILITÀ FISIOLOGICA, larghi apposta: non sono +// soglie cliniche (quelle vivono nelle funzioni di `test-fisici.ts`), servono solo a +// intercettare un errore di battitura in fase di inserimento (es. 480 kg di squat). +const FISICI: VoceRegistro[] = [ + { test_id: 'f_peso_kg', etichetta: 'Peso corporeo', unita: 'kg', tipo_valore: 'num', + asse: 'Composizione Corporea', range_min: 30, range_max: 250, attivo_da: DA }, + { test_id: 'f_handgrip_kg', etichetta: 'Forza di presa (handgrip, dinamometro)', unita: 'kg', tipo_valore: 'num', + asse: 'Forza & Struttura', range_min: 0, range_max: 100, attivo_da: DA }, + { test_id: 'f_pushup_rip', etichetta: 'Push-up, ripetizioni massime', tipo_valore: 'num', + asse: 'Forza & Struttura', range_min: 0, range_max: 150, attivo_da: DA }, + { test_id: 'f_row_carico_kg', etichetta: 'Trazione/rematore, carico', unita: 'kg', tipo_valore: 'num', + asse: 'Forza & Struttura', range_min: 0, range_max: 300, attivo_da: DA }, + { test_id: 'f_row_rip', etichetta: 'Trazione/rematore, ripetizioni (test 5RM)', tipo_valore: 'num', + asse: 'Forza & Struttura', range_min: 1, range_max: 20, attivo_da: DA }, + { test_id: 'f_squat_carico_kg', etichetta: 'Squat, carico', unita: 'kg', tipo_valore: 'num', + asse: 'Forza & Struttura', range_min: 0, range_max: 400, attivo_da: DA }, + { test_id: 'f_squat_rip', etichetta: 'Squat, ripetizioni (test 5RM)', tipo_valore: 'num', + asse: 'Forza & Struttura', range_min: 1, range_max: 20, attivo_da: DA }, + { test_id: 'f_plank_sec', etichetta: 'Plank, tenuta', unita: 'sec', tipo_valore: 'num', + asse: 'Forza & Struttura', range_min: 0, range_max: 1200, attivo_da: DA }, + { test_id: 'f_grasso_pct', etichetta: 'Massa grassa', unita: '%', tipo_valore: 'num', + asse: 'Composizione Corporea', range_min: 2, range_max: 60, attivo_da: DA }, + { test_id: 'f_muscolo_pct', etichetta: 'Massa muscolare', unita: '%', tipo_valore: 'num', + asse: 'Composizione Corporea', range_min: 20, range_max: 70, attivo_da: DA }, + { test_id: 'f_whr', etichetta: 'Rapporto vita-fianchi (WHR)', tipo_valore: 'num', + asse: 'Composizione Corporea', range_min: 0.5, range_max: 1.5, attivo_da: DA }, + { test_id: 'f_vo2max', etichetta: 'VO2max (misura diretta)', unita: 'ml/kg/min', tipo_valore: 'num', + asse: 'Cardio-Respiratorio', range_min: 10, range_max: 90, attivo_da: DA }, + { test_id: 'f_mutt_vam', etichetta: 'VAM al test MUTT/tapis (per stimare il VO2max)', unita: 'm/min', tipo_valore: 'num', + asse: 'Cardio-Respiratorio', range_min: 50, range_max: 400, attivo_da: DA }, + { test_id: 'f_step_hr_recovery', etichetta: 'Frequenza cardiaca di recupero, Queen\'s College Step Test', + unita: 'bpm', tipo_valore: 'num', asse: 'Cardio-Respiratorio', range_min: 40, range_max: 220, attivo_da: DA }, + { test_id: 'f_spo2_pct', etichetta: 'Saturazione O2 (SpO2)', unita: '%', tipo_valore: 'num', + asse: 'Cardio-Respiratorio', range_min: 50, range_max: 100, attivo_da: DA }, + { test_id: 'f_hrv_score', etichetta: 'HRV, punteggio (Stress Index)', tipo_valore: 'num', + asse: 'Recupero & Sistema Nervoso', range_min: 0, range_max: 100, attivo_da: DA }, + { test_id: 'f_pa_sistolica', etichetta: 'Pressione arteriosa sistolica', unita: 'mmHg', tipo_valore: 'num', + asse: 'Recupero & Sistema Nervoso', range_min: 70, range_max: 250, attivo_da: DA }, + { test_id: 'f_pa_diastolica', etichetta: 'Pressione arteriosa diastolica', unita: 'mmHg', tipo_valore: 'num', + asse: 'Recupero & Sistema Nervoso', range_min: 40, range_max: 150, attivo_da: DA }, + { test_id: 'f_hrr_1min_bpm', etichetta: 'Recupero cardiaco a 1 minuto post-sforzo', unita: 'bpm', tipo_valore: 'num', + asse: 'Recupero & Sistema Nervoso', range_min: 0, range_max: 100, attivo_da: DA }, + { test_id: 'f_flamingo_cadute', etichetta: 'Test del fenicottero, numero di cadute', tipo_valore: 'num', + asse: 'Stabilità & Mobilità Funzionale', range_min: 0, range_max: 50, attivo_da: DA }, + { test_id: 'f_sit_reach_cm', etichetta: 'Sit and reach', unita: 'cm', tipo_valore: 'num', + asse: 'Stabilità & Mobilità Funzionale', range_min: -30, range_max: 50, attivo_da: DA }, + { test_id: 'f_back_scratch_cm', etichetta: 'Back scratch (mobilità spalle)', unita: 'cm', tipo_valore: 'num', + asse: 'Stabilità & Mobilità Funzionale', range_min: -40, range_max: 20, attivo_da: DA }, + { test_id: 'f_spalla_outreach_deg', etichetta: 'Wellness Tower spalla, outreach', unita: 'deg', tipo_valore: 'num', + asse: 'Stabilità & Mobilità Funzionale', range_min: 0, range_max: 180, attivo_da: DA }, + { test_id: 'f_spalla_buckling_deg', etichetta: 'Wellness Tower spalla, buckling', unita: 'deg', tipo_valore: 'num', + asse: 'Stabilità & Mobilità Funzionale', range_min: 0, range_max: 180, attivo_da: DA }, +]; + export function seedRegistro(db: Database.Database): void { const ins = db.prepare( `INSERT OR REPLACE INTO registro_test @@ -114,6 +179,7 @@ export function seedRegistro(db: Database.Database): void { } }); tx(QUESTIONARIO); + tx(FISICI); } // Pesi presi dal motore del cliente (AXIS_SUBDOMAIN_WEIGHTS, MACRO_SCORE_WEIGHTS,