longevity: le curve dei test fisici, verificate contro l oracolo
This commit is contained in:
@@ -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<number, number> = { 20: 46, 30: 43, 40: 42, 50: 38, 60: 35, 70: 30 };
|
||||
const goodThresholdsF: Record<number, number> = { 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)));
|
||||
}
|
||||
@@ -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<string, unknown[]>)[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<string, unknown>).scoreAgilita).toBeUndefined();
|
||||
expect((F as Record<string, unknown>).scoreAgilityMs).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user