/** * La cascata a quattro livelli: dalle sotto-metriche normalizzate ai * punteggi di sotto-dominio, ai sette assi del radar, ai tre macro-score * (Performance/Energy/Recovery), fino alla Fitness Age. * * Porting di `aggregate`, `compute_axis`, `compute_macro_scores` e * `compute_fitness_age` dall'oracolo del cliente * (`tests/longevity/riferimento/isl_scoring_engine.py`, sezioni LIVELLO 2-4). * Regola di copertura, uguale a ogni livello: se un elemento manca, il suo * peso si ridistribuisce su quelli disponibili; se la copertura di peso * disponibile scende sotto `COPERTURA_MINIMA`, l'elemento e' insufficiente. * * DUE DIVERGENZE VOLUTE dall'oracolo: * * 1) Nell'oracolo `aggregate()` restituisce il punteggio pieno anche quando lo * dichiara insufficiente (la docstring dice il contrario di cio' che il * codice fa) - chi consuma deve ricordarsi di guardare lo stato, e prima o * poi qualcuno non lo fa. Qui il tipo `Punteggio` chiude la trappola: nel * ramo 'insufficiente' il campo `valore` non esiste, quindi la regola di * prodotto - un asse con dati insufficienti si mostra tratteggiato, mai * con un numero pieno fasullo - e' impossibile da violare per distrazione. * * 2) Nel calcolo della Fitness Age (vedi `motore/index.ts`, `valoreAsse`) * escludiamo gli assi 'insufficiente' dal composito: `cardio`, * `forza_resto`, `composizione` e `stabilita` entrano solo se il rispettivo * asse e' 'ok'. L'oracolo invece li include comunque - per lo stesso motivo * del punto 1: `compute_axis` restituisce uno `score` numerico anche * quando lo stato e' "insufficiente", e `compute_fitness_age` lo riceve e * lo usa senza controllare lo stato. E' il comportamento GIUSTO (un asse * sotto soglia non e' un dato affidabile da far pesare sull'eta biologica), * ma cambia il numero: su un profilo con assi parzialmente insufficienti, * verificato a mano, la nostra Fitness Age e' 25,0 contro 34,8 dell'oracolo * - quasi dieci anni di scarto. Chi confronta il nostro referto col Python * del cliente su un profilo parziale concludera' che il porting e' rotto: * non lo e', diverge di proposito qui. * * Tutto il resto e' fedele all'oracolo. */ import { COPERTURA_MINIMA } from '../db'; import { arrotonda1 } from './curve'; /** Esito di un'aggregazione pesata: 'ok' porta il valore, 'insufficiente' no. */ export type Punteggio = | { stato: 'ok'; valore: number; copertura: number } | { stato: 'insufficiente'; copertura: number }; /** Una voce da aggregare: punteggio 0-100 (o null se il dato manca) e il suo peso nominale. */ export type VocePesata = { punteggio: number | null; peso: number }; /** * Media pesata delle voci disponibili, con i pesi delle voci mancanti * ridistribuiti su quelle presenti. Come `aggregate` nell'oracolo, con la * differenza di tipo descritta sopra. * * ATTENZIONE (bug corretto, trovato in revisione): il confronto con la soglia * usa la copertura GREZZA (`pesoDisponibile / pesoTotale`), non quella * arrotondata. L'arrotondamento a due decimali (`arrotonda1(.., 2)`) serve * solo al valore esposto nel campo `copertura` del risultato. Confrontare la * copertura gia' arrotondata avrebbe fatto passare per 'ok' una copertura * grezza appena sotto 0.40 che arrotonda a 0.40 (es. 0.396) - esattamente il * numero pieno fasullo che il tipo `Punteggio` esiste per impedire. */ export function aggrega(voci: VocePesata[]): Punteggio { const pesoTotale = voci.reduce((s, v) => s + v.peso, 0); const disponibili = voci.filter((v) => v.punteggio !== null); const pesoDisponibile = disponibili.reduce((s, v) => s + v.peso, 0); const coperturaGrezza = pesoTotale ? pesoDisponibile / pesoTotale : 0; const copertura = arrotonda1(coperturaGrezza, 2); if (disponibili.length === 0) { return { stato: 'insufficiente', copertura: 0 }; } if (coperturaGrezza < COPERTURA_MINIMA) { return { stato: 'insufficiente', copertura }; } const sommaPesata = disponibili.reduce((s, v) => s + (v.punteggio as number) * v.peso, 0); const valore = arrotonda1(sommaPesata / pesoDisponibile); return { stato: 'ok', valore, copertura }; } /** * Livello 2: punteggi di sotto-dominio -> punteggio Asse. Come `compute_axis` * nell'oracolo: `pesi` e' la config del foglio "Pesi e Formule" per quell'asse, * `punteggi` i valori 0-100 disponibili (assenti = mancanti). */ export function calcolaAsse(pesi: Record, punteggi: Record): Punteggio { const voci: VocePesata[] = Object.entries(pesi).map(([chiave, peso]) => ({ punteggio: punteggi[chiave] ?? null, peso, })); return aggrega(voci); } /** * Livello 3: i sette Assi -> un macro-score (Performance/Energy/Recovery). * Come `compute_macro_scores` nell'oracolo: un asse insufficiente non entra * col suo valore (che qui non esiste nemmeno), entra come mancante e il suo * peso si ridistribuisce. */ export function calcolaMacro(pesiMacro: Record, assi: Record): Punteggio { const voci: VocePesata[] = Object.entries(pesiMacro).map(([asse, peso]) => { const p = assi[asse]; return { punteggio: p && p.stato === 'ok' ? p.valore : null, peso }; }); return aggrega(voci); } /** * Livello 4: Fitness Age. Come `compute_fitness_age` nell'oracolo: un * composito di sei elementi (handgrip e HRV isolati, non tramite l'intero * asse) da cui `eta - (composito - 50) * 0.4`. Se il composito e' * insufficiente non c'e' Fitness Age da mostrare. * * `etaAnagrafica` e' `number | null` perche' `sessioni.eta_alla_data` e' * nullable nello schema: l'apertura di una sessione e il salvataggio di un * questionario accettano l'eta come opzionale. Senza eta la Fitness Age NON * ESISTE - deve restituire `null`, mai un numero calcolato con un ripiego * (es. 0), che produrrebbe un'eta di forma negativa dichiarata valida. Gli * assi e i macro-score non dipendono dall'eta e restano calcolabili a monte: * solo questo ultimo livello si ferma. */ export function calcolaFitnessAge( etaAnagrafica: number | null, pesi: Record, voci: Record ): { fitnessAge: number | null; composito: Punteggio } { const vociPesate: VocePesata[] = Object.entries(pesi).map(([chiave, peso]) => ({ punteggio: voci[chiave] ?? null, peso, })); const composito = aggrega(vociPesate); if (composito.stato === 'insufficiente' || etaAnagrafica === null) { return { fitnessAge: null, composito }; } const fitnessAge = arrotonda1(etaAnagrafica - (composito.valore - 50) * 0.4); return { fitnessAge, composito }; }