From 6f926e319703fa912115d9f3c1afa05bf5f1287b Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 22 Aug 2026 09:09:23 +0200 Subject: [PATCH] longevity: le curve dei test fisici, verificate contro l oracolo --- src/lib/longevity/motore/test-fisici.ts | 285 ++++++++++++++++++++++++ tests/longevity/motore-fisici.test.ts | 71 ++++++ 2 files changed, 356 insertions(+) create mode 100644 src/lib/longevity/motore/test-fisici.ts create mode 100644 tests/longevity/motore-fisici.test.ts diff --git a/src/lib/longevity/motore/test-fisici.ts b/src/lib/longevity/motore/test-fisici.ts new file mode 100644 index 0000000..b85ef42 --- /dev/null +++ b/src/lib/longevity/motore/test-fisici.ts @@ -0,0 +1,285 @@ +/** + * Curve dei test fisici: da misura grezza (kg al dinamometro, secondi di + * plank, VO2max stimato...) a punteggio 0-100. + * + * Porting letterale delle funzioni dell'oracolo del cliente + * (`tests/longevity/riferimento/isl_scoring_engine.py`, sezioni «Composizione + * Corporea», «Cardio-Respiratorio», «Recupero & Sistema Nervoso», «Forza & + * Struttura», «Stabilità & Mobilità»). Coefficienti, soglie e tabelle sono + * copiati tali e quali e verificati contro `riferimento.json` in + * `tests/longevity/motore-fisici.test.ts`. Non "aggiustare" nulla qui dentro: + * l'oracolo è la definizione, non una proposta. + * + * `score_agility_ms` e `score_generic_range_local` NON sono portate: la prima + * perché il cliente ha rimosso l'agilità dallo score il 13/08 (scale non + * comparabili fra protocolli), la seconda perché non è mai chiamata da + * nessun peso. + */ + +import { clamp, lerp } from './curve'; + +export type Sesso = 'M' | 'F'; + +/** Come `sex.strip().lower().startswith("m")` nell'oracolo. */ +function eMaschio(sesso: Sesso): boolean { + return sesso.trim().toLowerCase().startsWith('m'); +} + +/** + * Arrotonda a una cifra decimale, come `round(x, 1)` in Python: sui pareggi + * esatti (x.x5) Python arrotonda al pari (banker's rounding), non sempre in + * su come `Math.round`. Le curve a gradini dell'oracolo (plank, flamingo, + * sollevamenti) producono pareggi reali — es. `lerp` di 190 su score_plank + * dà 86.25 esatto, e l'oracolo lo porta a 86.2, non 86.3. + */ +function arrotonda1(x: number): number { + const scaled = x * 10; + const pavimento = Math.floor(scaled); + const diff = scaled - pavimento; + if (Math.abs(diff - 0.5) < 1e-9) { + return (pavimento % 2 === 0 ? pavimento : pavimento + 1) / 10; + } + return Math.round(scaled) / 10; +} + +// --- Composizione Corporea ------------------------------------------------- + +/** Curva a campana ACE. Il picco è su Atleti/Fitness, non sul grasso più basso. Come `score_fat_percent`. */ +export function scoreGrassoPercento(fatPct: number, sesso: Sesso): number { + const m = eMaschio(sesso); + const bands: [number, number, number][] = m + ? [[0, 2, 40], [2, 5, 65], [5, 13, 100], [13, 17, 90], [17, 24, 65], [24, 35, 30], [35, 200, 10]] + : [[0, 10, 40], [10, 13, 65], [13, 20, 100], [20, 24, 90], [24, 31, 65], [31, 40, 30], [40, 200, 10]]; + for (const [lo, hi, val] of bands) { + if (lo <= fatPct && fatPct < hi) return val; + } + return 50; +} + +/** Crescente con plateau: uomini 42.9-52.4%, donne 37.8-46.2% (standard device). Come `score_muscle_percent`. */ +export function scoreMuscoloPercento(musclePct: number, sesso: Sesso): number { + const [lo, hi] = eMaschio(sesso) ? [42.9, 52.4] : [37.8, 46.2]; + if (musclePct >= hi) return 100; + if (musclePct <= lo - 10) return 20; + return arrotonda1(lerp(musclePct, lo - 10, hi, 20, 100)); +} + +/** Come `score_whr`. */ +export function scoreWhr(whr: number, sesso: Sesso): number { + const threshold = eMaschio(sesso) ? 0.9 : 0.85; + if (whr <= threshold - 0.1) return 100; + if (whr <= threshold) return arrotonda1(lerp(whr, threshold - 0.1, threshold, 100, 60)); + return clamp(arrotonda1(60 - (whr - threshold) * 300)); +} + +// --- Cardio-Respiratorio ---------------------------------------------------- + +/** Queen's College Step Test (McArdle et al. 1972). Come `vo2max_from_step_test`. */ +export function vo2maxDaStepTest(hrRecovery: number, sesso: Sesso): number { + if (eMaschio(sesso)) return 111.33 - 0.42 * hrRecovery; + return 65.81 - 0.1847 * hrRecovery; +} + +/** UKK 2km Walking Test (Laukkanen/Oja). Come `vo2max_from_2km_walk`. */ +export function vo2maxDa2kmWalk(tempoMin: number, hr: number, eta: number, bmi: number): number { + return 116.2 - 2.98 * tempoMin - 0.11 * hr - 0.14 * eta - 0.39 * bmi; +} + +/** Formula ACSM su VAM raggiunta al tapis. Teorico/stimato. Come `vo2max_from_mutt`. */ +export function vo2maxDaMutt(vamMMin: number): number { + return 0.2 * vamMMin + 3.5; +} + +/** Formula ACSM cicloergometro. Teorico/stimato, valida 50-200W. Come `vo2max_from_milfit`. */ +export function vo2maxDaMilfit(watt: number, pesoKg: number): number { + return (10.8 * watt) / pesoKg + 7; +} + +/** ACSM/FRIEND Registry, bande approssimate per fascia d'età e sesso. Come `score_vo2max`. */ +export function scoreVo2max(vo2max: number, eta: number, sesso: Sesso): number { + const m = eMaschio(sesso); + // soglie 'buono' per decade (uomini), interpolate da FRIEND Registry + const goodThresholdsM: Record = { 20: 46, 30: 43, 40: 42, 50: 38, 60: 35, 70: 30 }; + const goodThresholdsF: Record = { 20: 38, 30: 37, 40: 34, 50: 30, 60: 27, 70: 23 }; + const table = m ? goodThresholdsM : goodThresholdsF; + const decade = Math.min(70, Math.max(20, Math.floor(eta / 10) * 10)); + const good = table[decade]; + const poor = good * 0.65; + const superior = good * 1.35; + if (vo2max <= poor) return clamp(arrotonda1(lerp(vo2max, 0, poor, 10, 40))); + if (vo2max <= good) return arrotonda1(lerp(vo2max, poor, good, 40, 70)); + return clamp(arrotonda1(lerp(vo2max, good, superior, 70, 100))); +} + +/** Saturazione O2: 95-100% normale, sotto 90% campanello d'allarme clinico. Come `score_spo2`. */ +export function scoreSpo2(spo2Pct: number): number { + if (spo2Pct >= 97) return 100; + if (spo2Pct >= 95) return 85; + if (spo2Pct >= 90) return 50; + return 15; +} + +// --- Recupero & Sistema Nervoso -------------------------------------------- + +/** ESC/ESH 2018. Vale la categoria più alta tra sistolica e diastolica. Come `score_blood_pressure`. */ +export function scorePressione(sistolica: number, diastolica: number): number { + if (sistolica >= 180 || diastolica >= 110) return 5; + if (sistolica >= 160 || diastolica >= 100) return 25; + if (sistolica >= 140 || diastolica >= 90) return 45; + if (sistolica >= 130 || diastolica >= 85) return 70; + if (sistolica >= 120 || diastolica >= 80) return 90; + return 100; +} + +/** Recupero cardiaco 1' post-sforzo. >=12bpm considerato normale (Cole et al. 1999). Come `score_hrr`. */ +export function scoreRecuperoCardiaco(caloBpm1min: number): number { + if (caloBpm1min >= 12) return clamp(arrotonda1(lerp(caloBpm1min, 12, 30, 70, 100))); + return clamp(arrotonda1(lerp(caloBpm1min, 0, 12, 20, 70))); +} + +// --- Forza & Struttura ------------------------------------------------------- + +/** + * Approssimazione da NIH Toolbox / Dodds et al. Curva discendente con l'età. + * Come `score_handgrip`. + * + * L'oracolo usa un `for...else` di Python: se il ciclo trova l'intervallo fra + * due ancore, fa `break` con `mean_val` calcolato; se lo esaurisce senza mai + * entrare nel ramo, l'`else` del `for` scatta e vale l'ultima ancora. In + * TypeScript non esiste il for-else: lo si riproduce con un flag. + */ +export function scoreHandgrip(kg: number, eta: number, sesso: Sesso): number { + const m = eMaschio(sesso); + // ancore (età, valore medio kg) approssimate dalla letteratura citata + const anchorsM: [number, number][] = [[25, 49.7], [40, 46], [60, 38], [75, 30]]; + const anchorsF: [number, number][] = [[25, 30], [40, 28], [60, 24], [75, 18.7]]; + const anchors = m ? anchorsM : anchorsF; + const etaC = clamp(eta, 25, 75); + let meanVal: number | undefined; + for (let i = 0; i < anchors.length - 1; i++) { + const [aEta, aVal] = anchors[i]; + const [bEta, bVal] = anchors[i + 1]; + if (aEta <= etaC && etaC <= bEta) { + meanVal = lerp(etaC, aEta, bEta, aVal, bVal); + break; + } + } + if (meanVal === undefined) meanVal = anchors[anchors.length - 1][1]; + // media = punteggio 70; +/- 40% della media copre la banda 20-100 + const ratio = meanVal ? kg / meanVal : 1; + return clamp(arrotonda1(lerp(ratio, 0.5, 1.5, 20, 100))); +} + +/** ACSM/CSEP, bande approssimate per decade. Come `score_pushup`. */ +export function scorePushup(reps: number, eta: number, sesso: Sesso): number { + const m = eMaschio(sesso); + const baseGoodM = 22; + const baseGoodF = 15; // 35-39 anni, 'buono' minimo + const decadeOffset = Math.max(0, Math.floor((eta - 35) / 10)) * 2.5; // calo ~2.5 rip/decade dopo i 35 + const good = (m ? baseGoodM : baseGoodF) - decadeOffset; + const superior = good * 1.6; + const poor = good * 0.5; + if (reps <= poor) return clamp(arrotonda1(lerp(reps, 0, poor, 10, 40))); + if (reps <= good) return arrotonda1(lerp(reps, poor, good, 40, 70)); + return clamp(arrotonda1(lerp(reps, good, superior, 70, 100))); +} + +/** Generico per 5RM->1RM(Brzycki)->rapporto peso corporeo, su tiers (beg/nov/int/adv/elite). Come `score_bw_ratio_lift`. */ +export function scoreSollevamentoSuPeso( + caricoKg: number, + pesoKg: number, + rip: number, + tiersM: [number, number][], + tiersF: [number, number][], + sesso: Sesso +): number { + const rm1 = (caricoKg * 36) / (37 - rip); // Brzycki + const ratio = rm1 / pesoKg; + const tiers = eMaschio(sesso) ? tiersM : tiersF; + // tiers = [(soglia_ratio, punteggio), ...] crescente + let prevR = 0; + let prevS = 10; + for (const [r, s] of tiers) { + if (ratio <= r) return arrotonda1(lerp(ratio, prevR, r, prevS, s)); + prevR = r; + prevS = s; + } + return 100; +} + +export const TIERS_BENCH_M: [number, number][] = [[0.5, 30], [1.0, 60], [1.25, 80], [1.5, 100]]; +export const TIERS_BENCH_F: [number, number][] = [[0.3, 30], [0.6, 60], [0.75, 80], [1.0, 100]]; +export const TIERS_SQUAT_M: [number, number][] = [[0.75, 30], [1.5, 60], [1.75, 80], [2.0, 100]]; +export const TIERS_SQUAT_F: [number, number][] = [[0.5, 30], [1.1, 60], [1.3, 80], [1.5, 100]]; +export const TIERS_ROW_M: [number, number][] = [[0.45, 30], [0.70, 60], [0.95, 80], [1.20, 100]]; +export const TIERS_ROW_F: [number, number][] = [[0.30, 30], [0.45, 60], [0.60, 80], [0.80, 100]]; + +/** Come `score_flexed_arm_hang`. */ +export function scoreTrazioneIsometrica(sec: number, sesso: Sesso): number { + const [good, superior] = eMaschio(sesso) ? [45, 70] : [25, 50]; + if (sec <= good * 0.4) return clamp(arrotonda1(lerp(sec, 0, good * 0.4, 10, 40))); + if (sec <= good) return arrotonda1(lerp(sec, good * 0.4, good, 40, 70)); + return clamp(arrotonda1(lerp(sec, good, superior, 70, 100))); +} + +/** Reference equation adulti 18-95 (Zalewski et al.-style). Come `score_sit_to_stand_1min`. */ +export function scoreSitToStand(reps: number, eta: number, sesso: Sesso, bmi = 24): number { + const sexCode = eMaschio(sesso) ? 0 : 1; + const predicted = 61.53 - 0.34 * eta - 3.57 * sexCode - 0.33 * bmi; + const ratio = predicted ? reps / predicted : 1; + return clamp(arrotonda1(lerp(ratio, 0.5, 1.3, 20, 100))); +} + +/** Come `score_plank`. */ +export function scorePlank(sec: number, sesso: Sesso): number { + const bandsM: [number, number][] = [[79, 20], [97, 40], [122, 55], [157, 75], [201, 90]]; + const bandsF: [number, number][] = [[35, 20], [63, 40], [84, 55], [108, 75], [142, 90]]; + const bands = eMaschio(sesso) ? bandsM : bandsF; + let prevT = 0; + let prevS = 10; + for (const [t, s] of bands) { + if (sec <= t) return arrotonda1(lerp(sec, prevT, t, prevS, s)); + prevT = t; + prevS = s; + } + return 100; +} + +// --- Stabilità & Mobilità ---------------------------------------------------- + +/** + * 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 età. + * Approssimazione da dichiarare, non tabella completa. Come `score_back_scratch`. + */ +export function scoreBackScratch(cm: number, eta: number, sesso: Sesso): number { + const m = eMaschio(sesso); + const anchorEta = 62; + const anchorCm = m ? -8.6 : -1.8; + const declinePerYear = 0.8; // cm peggioramento per anno di età in più + const expected = anchorCm - (eta - anchorEta) * declinePerYear; + // punteggio: 0/pieno contatto (0cm) o sovrapposizione (positivo) = ottimo + const diff = cm - expected; + return clamp(arrotonda1(lerp(diff, -15, 15, 20, 100))); +} + +/** Wellness Tower, riferimento 180 gradi (outreach/buckling). Come `score_shoulder_mobility_wt`. */ +export function scoreMobilitaSpalla(outreachDeg: number, bucklingDeg: number): number { + const avg = (outreachDeg + bucklingDeg) / 2; + return clamp(arrotonda1((avg / 180) * 100)); +} + +/** Decrescente: meno cadute = meglio. Come `score_flamingo`. */ +export function scoreFlamingo(cadute: number): number { + if (cadute <= 3) return 100; + if (cadute <= 7) return arrotonda1(lerp(cadute, 3, 7, 100, 80)); + if (cadute <= 15) return arrotonda1(lerp(cadute, 7, 15, 80, 50)); + return clamp(arrotonda1(lerp(cadute, 15, 30, 50, 10))); +} + +/** Come `score_sit_and_reach`. */ +export function scoreSitAndReach(cm: number, sesso: Sesso): number { + const median = eMaschio(sesso) ? 24 : 31; + return clamp(arrotonda1(lerp(cm, median - 20, median + 10, 20, 100))); +} diff --git a/tests/longevity/motore-fisici.test.ts b/tests/longevity/motore-fisici.test.ts new file mode 100644 index 0000000..e5c4eb8 --- /dev/null +++ b/tests/longevity/motore-fisici.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import * as F from '../../src/lib/longevity/motore/test-fisici'; + +const RIF = JSON.parse( + readFileSync(join(process.cwd(), 'tests/longevity/riferimento/riferimento.json'), 'utf8') +) as { casi: { fn: string; args: unknown[]; atteso: number }[] }; + +/** Ogni funzione dell'oracolo con la sua gemella in TypeScript. */ +const COPPIE: [string, (...a: never[]) => number][] = [ + ['score_fat_percent', F.scoreGrassoPercento as never], + ['score_muscle_percent', F.scoreMuscoloPercento as never], + ['score_whr', F.scoreWhr as never], + ['score_vo2max', F.scoreVo2max as never], + ['score_spo2', F.scoreSpo2 as never], + ['score_blood_pressure', F.scorePressione as never], + ['score_hrr', F.scoreRecuperoCardiaco as never], + ['score_handgrip', F.scoreHandgrip as never], + ['score_pushup', F.scorePushup as never], + ['score_flexed_arm_hang', F.scoreTrazioneIsometrica as never], + ['score_sit_to_stand_1min', F.scoreSitToStand as never], + ['score_plank', F.scorePlank as never], + ['score_back_scratch', F.scoreBackScratch as never], + ['score_shoulder_mobility_wt', F.scoreMobilitaSpalla as never], + ['score_flamingo', F.scoreFlamingo as never], + ['score_sit_and_reach', F.scoreSitAndReach as never], + ['vo2max_from_step_test', F.vo2maxDaStepTest as never], + ['vo2max_from_2km_walk', F.vo2maxDa2kmWalk as never], + ['vo2max_from_mutt', F.vo2maxDaMutt as never], + ['vo2max_from_milfit', F.vo2maxDaMilfit as never], +]; + +describe('curve dei test fisici, confrontate con l oracolo caso per caso', () => { + for (const [nomePython, fnTs] of COPPIE) { + it(`${nomePython} combacia su tutti i casi del riferimento`, () => { + const casi = RIF.casi.filter((c) => c.fn === nomePython); + expect(casi.length, `nessun caso per ${nomePython}: la griglia non lo copre`).toBeGreaterThan(0); + const divergenti: string[] = []; + for (const c of casi) { + const ottenuto = (fnTs as (...a: unknown[]) => number)(...c.args); + if (Math.abs(ottenuto - c.atteso) > 0.05) { + divergenti.push(`${nomePython}(${c.args.join(', ')}): atteso ${c.atteso}, ottenuto ${ottenuto}`); + } + } + expect(divergenti.slice(0, 5).join('\n')).toBe(''); + }); + } + + it('i sollevamenti sul peso corporeo combaciano su tutte e tre le tabelle', () => { + const casi = RIF.casi.filter((c) => c.fn === 'score_bw_ratio_lift'); + expect(casi.length).toBeGreaterThan(50); + const perTabella = (nome: string) => + ({ bench: [F.TIERS_BENCH_M, F.TIERS_BENCH_F], squat: [F.TIERS_SQUAT_M, F.TIERS_SQUAT_F], + row: [F.TIERS_ROW_M, F.TIERS_ROW_F] } as Record)[nome]; + for (const c of casi) { + const [carico, peso, rip, tiersM, tiersF, sesso] = c.args as [number, number, number, unknown, unknown, 'M' | 'F']; + const ottenuto = F.scoreSollevamentoSuPeso( + carico, peso, rip, + tiersM as [number, number][], tiersF as [number, number][], sesso + ); + expect(ottenuto, `carico ${carico} ${sesso}`).toBeCloseTo(c.atteso, 1); + } + expect(perTabella('bench')).toBeTruthy(); + }); + + it('l agilita NON e stata portata: il cliente l ha esclusa dallo score', () => { + expect((F as Record).scoreAgilita).toBeUndefined(); + expect((F as Record).scoreAgilityMs).toBeUndefined(); + }); +});