From 02d0224e5a7942b97e6fd4b897ef97673ceb49c4 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 21 Aug 2026 18:41:06 +0200 Subject: [PATCH 01/62] docs: design della piattaforma IN-SANITY LONGEVITY PROJECT Fascicolo unico del cliente dentro il sito esistente, ramo separato e nessun deploy. Tre database: sito, identity (codice <-> nome) e longevity (misure, mai un nome) - cosi' l'export per le statistiche e' gia' pseudonimizzato per costruzione, come chiesto da Nicola il 21/08. Le misure sono un registro (una riga per valore) e i test sono dato, non codice: togliere l'agilita' o passare l'HRR da 2' a 1' non e' una migrazione. I pesi stanno in tabella versionata, cosi' il congelamento degli score storici ha davvero qualcosa dietro. Punteggi congelati con due versioni distinte (domande e modello di calcolo). Il tipo di ritorno del motore rende impossibile mostrare un numero pieno dove la regola vuole il tratteggio. --- docs/specs/2026-08-21-longevity-design.md | 401 ++++++++++++++++++++++ 1 file changed, 401 insertions(+) create mode 100644 docs/specs/2026-08-21-longevity-design.md diff --git a/docs/specs/2026-08-21-longevity-design.md b/docs/specs/2026-08-21-longevity-design.md new file mode 100644 index 0000000..9dc5e9e --- /dev/null +++ b/docs/specs/2026-08-21-longevity-design.md @@ -0,0 +1,401 @@ +# IN-SANITY LONGEVITY PROJECT — piattaforma dati cliente + +**Data:** 2026-08-21 +**Stato:** design approvato, implementazione non iniziata +**Branch:** `feat/longevity` — `main` non viene toccato, nessun deploy +**Committente:** In-Sanity Lab (Nicola Antonelli) — sviluppo Tielogic + +--- + +## 1. Cos'è, e come si chiama + +Il nome esiste già e viene dal cliente: **IN-SANITY LONGEVITY PROJECT** è +l'iniziativa — *«servizio premium basato su dati, con monitoraggio +longitudinale»* — e **IN-SANITY LONGEVITY SCORE** è il sistema di punteggio che +ne è il prodotto di punta. Questa piattaforma è l'implementazione software del +primo. Nel codice il nome tecnico è **`longevity`**. + +Non è una raccolta di applicazioni separate: è **un fascicolo del cliente**. +Questionario, check-up in sala, Wellness Tower e VALD non sono quattro +sottosistemi, sono **quattro sorgenti che scrivono nello stesso posto**; +dashboard, vista trainer e punteggi sono **viste e funzioni derivate** di quel +posto. È la differenza fra progettare per dati e progettare per schermate, e +determina tutto quello che segue. + +## 2. Perimetro + +**Dentro:** anagrafica cliente, questionario in pagina, registrazione delle +misure da tutte le sorgenti, motore di calcolo degli score, dashboard cliente, +vista trainer, export per le statistiche. + +**Fuori, e per ragioni diverse:** + +- **I percentili interni ISL** — non per scelta: la documentazione del cliente + dichiara che sotto 15 campioni sono inutilizzabili e sotto 30 provvisori. + Vanno costruiti quando i dati esistono, non prima. +- **Gli 8 indicatori posturali** della Wellness Tower — esistono solo nel PDF + stampato, non nell'export a 55 colonne. Il cliente li ha già esclusi dallo + score. +- **Il blocco Trimestre / 2 Mesocicli** — dichiarato aperto dal cliente stesso e + ancora in analisi da parte sua. +- **Il deploy** — la piattaforma si sviluppa e si prova in locale. Quando mettere + qualcosa in produzione è una decisione separata, che non appartiene a questo + documento. + +## 3. Collocazione + +La piattaforma vive **dentro l'applicazione Astro esistente**, come già fanno +Biohacking Campus e Stress Index. Non è un secondo servizio, non è un secondo +container. + +``` +src/pages/longevity/ rotte visibili +src/pages/api/longevity/ endpoint +src/lib/longevity/ modello dati, motore, import +tests/longevity/ test +``` + +Il motivo per cui non è un progetto separato è che l'autenticazione, le sessioni +e l'anagrafica utenti esistono già qui e funzionano in produzione: rifarle altrove +significherebbe mantenerne due. + +⚠️ **Il calcolo è in TypeScript, non in Python.** Il motore fornito dal cliente +(`isl_scoring_engine.py`, 537 righe) è aritmetica pura — interpolazioni lineari, +tabelle di soglie, medie pesate — e importa solo `dataclasses` e `typing`: nessuna +libreria numerica. Portarlo in TypeScript costa poco e evita un secondo runtime +dentro un'immagine `node:22-slim` a processo unico. La stessa scelta vale per il +prototipo del questionario, che calcola già in JavaScript: tenere le formule in +due linguaggi è il modo silenzioso di farle divergere. + +## 4. I tre database + +SQLite, tre file distinti, aperti da connessioni distinte. + +| File | Contiene | Non contiene | +|---|---|---| +| `insanitylab.db` | il sito: articoli, contenuti, **utenti e sessioni** | niente di clinico | +| `identity.db` | `client_code` ↔ `user_id`, nome, cognome, nascita, recapiti | nessuna misura | +| `longevity.db` | misure, questionari, punteggi, registro test | **nessun nome, mai** | + +La proprietà che ne discende è la richiesta esplicita del cliente del 21/08: *«la +mappatura codice↔nome va tenuta in un posto separato e più ristretto rispetto +all'export usato per le statistiche, così anche chi ha accesso ai dati clinici +non può risalire all'identità»*. Qui l'export per le statistiche **è** +`longevity.db`: già pseudonimizzato per costruzione, senza una funzione di +anonimizzazione che qualcuno debba ricordarsi di chiamare. + +**Vincolo di implementazione, verificabile con un test:** esiste **un solo +modulo** che apre insieme `identity` e `longevity`. Nessun altro punto del codice +importa entrambe le connessioni. + +## 5. Modello dati + +```sql +-- ============ identity.db ============ +CREATE TABLE clienti ( + client_code TEXT PRIMARY KEY, -- es. 'ISL-0007' + user_id INTEGER, -- users.id in insanitylab.db (nessuna FK: file diverso) + nome TEXT NOT NULL, + cognome TEXT NOT NULL, + data_nascita TEXT, -- ISO; resta QUI, non passa in longevity + email TEXT, + telefono TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE UNIQUE INDEX idx_clienti_user ON clienti (user_id); + +-- ============ longevity.db ============ +CREATE TABLE soggetti ( + client_code TEXT PRIMARY KEY, + sesso TEXT NOT NULL CHECK (sesso IN ('M','F')), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE sessioni ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_code TEXT NOT NULL REFERENCES soggetti(client_code), + data TEXT NOT NULL, + tipo TEXT NOT NULL CHECK (tipo IN ('checkup','questionario')), + eta_alla_data INTEGER, -- l'età serve al motore; la data di nascita no + operatore TEXT, + note TEXT, + quest_version TEXT, -- solo per tipo='questionario' + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX idx_sessioni_cliente ON sessioni (client_code, data DESC); + +CREATE TABLE misure ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sessione_id INTEGER NOT NULL REFERENCES sessioni(id) ON DELETE CASCADE, + client_code TEXT NOT NULL REFERENCES soggetti(client_code), + test_id TEXT NOT NULL REFERENCES registro_test(test_id), + valore_num REAL, + valore_txt TEXT, -- per le risposte non numeriche + unita TEXT, + fonte TEXT NOT NULL CHECK (fonte IN + ('manuale','questionario','wellness_tower','vald','calibre','stress_index')), + fuori_range INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX idx_misure_cliente_test ON misure (client_code, test_id); +CREATE INDEX idx_misure_sessione ON misure (sessione_id); + +CREATE TABLE registro_test ( + test_id TEXT PRIMARY KEY, -- 'handgrip_dx', 'plank', 'q_sonno_ore' + etichetta TEXT NOT NULL, + unita TEXT, + tipo_valore TEXT NOT NULL CHECK (tipo_valore IN ('num','txt')), + curva TEXT, -- 'bell','lin_dec','inc_plateau','x10','decstep','incstep' + params TEXT, -- JSON dei parametri della curva + asse TEXT, -- uno dei 7 assi; NULL = non entra nello score + sotto_dominio TEXT, -- a QUALE sotto-dominio contribuisce; il peso sta in `pesi` + range_min REAL, -- atteso, non vincolante: fuori range si marca + range_max REAL, + attivo_da TEXT NOT NULL, + attivo_a TEXT, -- NULL = ancora attivo + note TEXT +); + +CREATE TABLE pesi ( + model_version TEXT NOT NULL, + livello TEXT NOT NULL CHECK (livello IN ('asse','macro','fitness_age')), + contenitore TEXT NOT NULL, -- l'asse, il macro-score, o 'fitness_age' + elemento TEXT NOT NULL, -- il sotto-dominio, o l'asse, o la voce isolata + peso REAL NOT NULL, + PRIMARY KEY (model_version, livello, contenitore, elemento) +); + +CREATE TABLE profilo_note ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_code TEXT NOT NULL REFERENCES soggetti(client_code), + sessione_id INTEGER REFERENCES sessioni(id), + campo_id TEXT NOT NULL, -- 'farmaci', 'problematiche_attuali', 'obiettivi' + testo TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE score ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_code TEXT NOT NULL REFERENCES soggetti(client_code), + sessione_id INTEGER NOT NULL REFERENCES sessioni(id), + tipo TEXT NOT NULL CHECK (tipo IN ('asse','macro','fitness_age')), + nome TEXT NOT NULL, -- 'Forza & Struttura', 'PERFORMANCE', ... + valore REAL, -- NULL quando stato='insufficiente' + copertura REAL NOT NULL, + stato TEXT NOT NULL CHECK (stato IN ('ok','insufficiente')), + quest_version TEXT, + model_version TEXT NOT NULL, + calcolato_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX idx_score_cliente ON score (client_code, calcolato_at DESC); +``` + +### Perché l'età e non la data di nascita + +Il motore ha bisogno dell'età — `score_handgrip(kg, eta, sex)`, +`score_vo2max(vo2max, eta, sex)`, le bande per decade — ma gli serve **un numero +al momento della misura**, non una data. Sesso ed età non identificano nessuno; +una data di nascita esatta, combinata con pochi altri campi, quasi sì. Costa una +colonna e toglie un quasi-identificatore dal database clinico. + +### Perché il peso non sta sulla riga del test + +Un sotto-dominio può essere coperto da **test alternativi**: la *spinta* si +misura con i push-up o con il 5RM di panca, a seconda del livello del cliente, e +il peso — 20% dentro *Forza & Struttura* — è del sotto-dominio, non del singolo +test. Se il peso stesse sulla riga del test si ripeterebbe su ogni alternativa, +e prima o poi due copie divergerebbero. + +I pesi stanno quindi in una tabella loro, **con la `model_version` nella chiave**. +È ciò che rende reale il congelamento del §6: uno score del passato si può +rileggere con i pesi che erano in vigore quando è stato calcolato, invece che con +quelli di oggi. Senza questo, `model_version` sarebbe un'etichetta senza niente +dietro. + +⚠️ Nota per l'implementazione: nel motore Python `plank` compare in **due** assi +— `core` in *Forza & Struttura* (0.15) e `plank` in *Stabilità & Mobilità* (0.20). +È lo stesso doppio conteggio ammesso dal cliente per l'handgrip nella Fitness Age, +ma quello del plank **non è dichiarato da nessuna parte**. Questo schema lo +rappresenta senza problemi (due righe in `pesi`), però va segnalato al cliente +invece che riprodotto in silenzio. + +### Perché il registro dei test è dato e non codice + +Il cliente ha già i test in forma di registro: un foglio di 30 righe con fonte e +stato per ciascuno. Qui diventa una tabella, e la conseguenza è che **il set dei +test è mobile senza migrazioni**, cosa che è già successa tre volte in un mese: +l'agilità è stata rimossa dallo score, la capacità vitale pure, e l'HRR è passato +dalla finestra a 2' a quella a 1'. + +Con questo modello: disattivare un test è valorizzare `attivo_a`; cambiare la +finestra dell'HRR è **un `test_id` nuovo che convive col vecchio**, e le +misurazioni già fatte restano leggibili e restano marcate per quello che sono, +invece di diventare confrontabili per sbaglio con le nuove. + +## 6. Versioni e congelamento + +Decisione del cliente, 21/08: quando cambia una formula i punteggi storici si +**congelano**, non si ricalcolano. La ragione è di prodotto ed è corretta — se +lo score storico cambia, il cliente non distingue più il proprio miglioramento +da un cambio di matematica, e il confronto nel tempo perde valore. + +L'implementazione ha due conseguenze: + +1. **Gli score si salvano**, non si ricalcolano a ogni visualizzazione. La + tabella `score` è la memoria di ciò che il cliente ha visto. +2. **Le misure grezze si salvano tutte**, quindi un ricalcolo con formule nuove + resta sempre possibile in lettura, per analisi, senza sovrascrivere niente. + +**Due versioni, non una.** Ogni score porta `quest_version` (quali domande) e +`model_version` (quali curve e quali pesi). Il cliente ne ha implementata una +sola perché dal suo lato coincidevano, ma i pesi degli assi possono cambiare +senza che cambi una domanda: con una versione sola un punteggio del passato non +saprebbe dire quale delle due cose è cambiata. + +⚠️ **Il congelamento sposta il problema del confronto, non lo elimina.** Due +compilazioni con versioni diverse restano non confrontabili anche se nessuno le +ha riscritte. Il grafico della progressione deve quindi **dichiarare +visivamente il cambio di versione**, altrimenti il cliente vede una linea +continua di numeri calcolati con matematiche diverse — che è l'equivoco che la +decisione voleva evitare. + +## 7. Flusso dei dati + +### Ingresso: un solo varco + +``` +questionario in pagina ─┐ +gestionale trainer ─┤ +import file (WT, VALD) ─┼─→ registraMisure(sessione, misure[]) ─→ misure +API (VALD Hub) ─┘ ↑ valida contro registro_test +``` + +`registraMisure` è l'unica funzione che scrive in `misure`. Valida ogni riga +contro il registro: **un `test_id` sconosciuto viene rifiutato**, non scritto in +silenzio. Un valore fuori dal range atteso viene invece **scritto e marcato** +(`fuori_range = 1`), perché scartarlo perderebbe un dato vero e clamparlo lo +falserebbe — vedi §9. + +### Calcolo + +Il motore legge misure e registro, e produce gli score seguendo la cascata a +quattro livelli del cliente: sotto-metrica → asse → macro-score → Fitness Age, +con rinormalizzazione dei pesi disponibili a ogni livello e soglia di copertura +al **40%**. + +Il tipo di ritorno impedisce la trappola presente nel motore Python, dove +`aggregate()` restituisce il punteggio pieno **anche quando lo dichiara +insufficiente**: + +```ts +type Punteggio = + | { stato: 'ok'; valore: number; copertura: number } + | { stato: 'insufficiente'; copertura: number } // non c'è nessun valore da leggere +``` + +La regola di prodotto — *«tratteggiato, mai un numero pieno fasullo»* — diventa +così impossibile da violare per distrazione: chi disegna il radar non ha il campo +da cui prendere il numero. + +### Uscita + +Dashboard cliente (radar a 7 assi, 3 macro-score, Fitness Age), vista trainer, ed +export per le statistiche — che è il contenuto di `longevity.db`, senza passare +da nessuna funzione di anonimizzazione. + +## 8. Ruoli e accessi + +Il sito ha oggi `admin | superuser | user | piattaforme`, e nessuno di questi è +il cliente finale né il trainer. Se ne aggiungono **due**: `cliente` e `trainer`. + +| Rotta | Chi entra | +|---|---| +| `/longevity/io` | `cliente` (solo il proprio fascicolo), `trainer`, `admin` | +| `/longevity/gestionale` | `trainer`, `admin` | +| `/api/longevity/**` | come sopra, verificato lato server | + +**Il cliente vede solo i propri dati, e non è una regola di interfaccia:** gli +endpoint ricavano il `client_code` dalla sessione e **mai** dalla richiesta. Un +client_code che arriva dal browser viene ignorato. + +## 9. Errori e casi limite + +**Misura fuori dal range atteso: si registra e si marca.** Il caso è già +successo — la capacità vitale di 5148 mL su un fondoscala dichiarato di +2000-3000 mL non era una prestazione eccezionale, era il range del fornitore +sbagliato, e clamparla a 100 produceva un dato apparentemente ottimo. Le misure +marcate finiscono in una lista che il trainer vede; non entrano nello score +finché qualcuno non le conferma. + +**Scala non comparabile: si esclude dal registro, non si corregge nel codice.** +L'agilità a 548 ms contro i 250 attesi dava 20/100 su Stabilità — falso, e la +causa era il protocollo diverso. Un caso così si risolve disattivando il test nel +registro, non aggiungendo un fattore di correzione da qualche parte. + +**Copertura sotto il 40%:** l'elemento è `insufficiente` e non ha un valore. + +**Test sconosciuto in ingresso:** errore esplicito, la sessione non viene scritta +a metà. + +## 10. Test + +Il test principale è il **verificatore contro il motore Python**: gli stessi +ingressi devono produrre gli stessi numeri, e ogni curva va confrontata su tutto +il suo dominio, non su un caso singolo. È l'unico modo per sapere che il porting +non ha spostato niente. + +Poi: le regole di copertura e rinormalizzazione, il rifiuto dei test sconosciuti, +la marcatura del fuori range, l'isolamento dei tre database (nessun modulo, a +parte quello designato, apre insieme identity e longevity), e il fatto che un +`client_code` proveniente dalla richiesta non venga mai onorato. + +⚠️ **I dati reali di Donata e Nicola non entrano nelle fixture.** Sono dati +sanitari di due persone identificabili, e le fixture stanno in git, dove restano +per sempre e le legge chiunque abbia accesso al repository. I casi di prova si +costruiscono sintetici; i referti veri restano dove sono, per il confronto a mano +quando serve. + +## 11. Punti aperti che il codice non risolve + +Questi non sono dettagli implementativi: sono decisioni che spettano al cliente, +e finché non arrivano il codice si comporterà come qui dichiarato — non come +capita. + +1. **Base giuridica per i dati sanitari (art. 9 GDPR).** Il campo `consenso` del + questionario riguarda le comunicazioni promozionali, non il trattamento di + dati di salute, e la finalità statistica è ulteriore rispetto a quella di + cura. Serve un consenso esplicito dedicato. **Non è un blocco allo sviluppo, + è un blocco alla messa in produzione con clienti veri.** +2. **Chi è il medico supervisore**, nominato nella specifica del questionario. Se + è un professionista sanitario, cambia la base giuridica utilizzabile + (art. 9.2.h), in meglio. +3. **La regola dei buchi nel questionario.** Oggi esiste in due versioni: il + prototipo annulla un sotto-dominio solo se non c'è **nessuna** risposta, il + motore usa il 40%. La piattaforma userà **una soglia unica, dichiarata nel + registro come parametro**, e in attesa della risposta assume il **40%**, + coerente con la regola dei sette assi. +4. **Il cambio di versione sul grafico** della progressione (§6). +5. **Il registro delle versioni**: cosa cambia a ogni incremento di + `quest_version` e `model_version`, altrimenti fra sei mesi «v1.3» non dirà + niente a nessuno. +6. **Nomi dei Livelli 1/2/3** — SPARK/FLUX/APEX o PULSE/FLOW/PEAK, aperti da + sessioni precedenti del cliente. Il livello è già una colonna del profilo. +7. **Export CSV/API di Stress Index**, l'unico canale la cui automazione non è + confermata. L'HRV entra nel motore come passthrough, quindi senza quel canale + va inserito a mano. + +## 12. Decisioni prese, e da chi + +| Decisione | Chi | Quando | +|---|---|---| +| Un record per compilazione + export Excel | Nicola | 21/08 | +| Versione del questionario su ogni record | Nicola | 21/08 | +| Formula cambiata → punteggi storici congelati | Nicola | 21/08 | +| Export pseudonimizzato, mappatura separata e ristretta | Nicola | 21/08 | +| Curva alcol 0-7 = 100 (soglia NIAAA/WHO), voluta | Nicola | 21/08 | +| La piattaforma vive dentro il sito, area riservata | Adriano | 13/08 | +| Ramo separato, nessun deploy | Adriano | 21/08 | +| Tre database separati | Adriano | 21/08 | +| Registro di misure invece di colonne fisse | Adriano | 21/08 | +| Il progetto è unico: un framework sui dati del cliente | Adriano | 21/08 | +| Calcolo in TypeScript, non Python | Tielogic (verificato sul codice) | 21/08 | From 534833c767b2bca34f1de3886b24359807a7edbe Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 21 Aug 2026 19:05:07 +0200 Subject: [PATCH 02/62] docs: piano di implementazione dello strato dati longevity Sette task in TDD: schema dei due database, registro test e pesi versionati, registraMisure come unico varco, anagrafica che tiene separata identita' e clinica, ruoli cliente/trainer, salvataggio del questionario, export. Alla fine esiste una libreria testata e nessuna pagina: il motore di calcolo e le schermate sono i due piani successivi. --- .../plans/2026-08-21-longevity-strato-dati.md | 1294 +++++++++++++++++ 1 file changed, 1294 insertions(+) create mode 100644 docs/plans/2026-08-21-longevity-strato-dati.md diff --git a/docs/plans/2026-08-21-longevity-strato-dati.md b/docs/plans/2026-08-21-longevity-strato-dati.md new file mode 100644 index 0000000..07e3c18 --- /dev/null +++ b/docs/plans/2026-08-21-longevity-strato-dati.md @@ -0,0 +1,1294 @@ +# Longevity — strato dati: piano di implementazione + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** costruire lo strato dati del fascicolo cliente — i due database, il registro dei test, la registrazione delle misure, l'anagrafica pseudonimizzata e il salvataggio del questionario — testato e senza interfaccia. + +**Architecture:** due file SQLite affiancati a quello del sito. `identity.db` tiene il legame fra codice cliente e persona; `longevity.db` tiene misure, questionari e punteggi e non contiene mai un nome. Le misure sono un registro (una riga per valore, da qualunque sorgente) validato contro una tabella di test che è dato e non codice. Un solo modulo apre entrambe le connessioni. + +**Tech Stack:** TypeScript, better-sqlite3, vitest. Nessuna dipendenza nuova. + +**Spec:** `docs/specs/2026-08-21-longevity-design.md` + +## Global Constraints + +- Branch **`feat/longevity`**. `main` non si tocca, non si deploya, non si pusha senza che Adriano lo chieda. +- Nessuna dipendenza npm nuova: better-sqlite3 e vitest ci sono già. +- Si segue il pattern di `src/lib/db.ts`: funzione `createXDb(path?)`, `journal_mode = WAL`, `foreign_keys = ON`, schema idempotente eseguito all'apertura. +- Test in `tests/longevity/`, eseguiti con `npm test` (vitest). I test usano `':memory:'`. +- **Un solo modulo** (`src/lib/longevity/anagrafica.ts`) può importare insieme le due connessioni. Un test lo verifica. +- **Nessun dato reale di persone nelle fixture.** I casi di prova sono sintetici. +- Soglia di copertura: **0.40**, dichiarata come costante esportata, mai scritta a mano nei rami. +- `model_version` iniziale: **`v1.0`**. `quest_version` iniziale: **`v1.0`** (è quella che Nicola ha già messo nel prototipo). + +## Perimetro di questo piano + +Questo piano costruisce **lo strato dati e basta**: alla fine esiste una libreria testata che sa registrare un cliente, registrare misure da qualunque sorgente, salvare una compilazione di questionario ed esportare i dati pseudonimizzati. + +**Fuori da questo piano**, e ognuno avrà il suo: il motore di calcolo degli score (porting delle curve dal Python, con verificatore), le pagine web del questionario e della dashboard, gli import da Wellness Tower/VALD/Calibre. + +Il motivo dell'ordine è che tutto il resto legge o scrive questi dati: costruirlo per primo evita che tre pezzi inventino tre modelli diversi. + +## Struttura dei file + +| File | Responsabilità | +|---|---| +| `src/lib/longevity/db.ts` | apertura e schema dei due database | +| `src/lib/longevity/registro.ts` | registro dei test e tabella pesi: seed e query | +| `src/lib/longevity/misure.ts` | `registraMisure`, l'unico varco di scrittura | +| `src/lib/longevity/anagrafica.ts` | **l'unico** modulo che unisce identity e longevity | +| `src/lib/longevity/questionario-def.ts` | i 20 campi punteggiati come dato | +| `src/lib/longevity/questionario.ts` | salvataggio di una compilazione | +| `src/lib/longevity/export.ts` | export pseudonimizzato | +| `src/lib/auth.ts` | *(modifica)* ruoli `cliente` e `trainer` | + +--- + +### Task 1: Schema dei due database + +**Files:** +- Create: `src/lib/longevity/db.ts` +- Test: `tests/longevity/db.test.ts` + +**Interfaces:** +- Consumes: niente +- Produces: `createLongevityDb(path?: string): Database.Database`, `createIdentityDb(path?: string): Database.Database`, `COPERTURA_MINIMA: number` (0.40) + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/longevity/db.test.ts +import { describe, it, expect } from 'vitest'; +import { createLongevityDb, createIdentityDb, COPERTURA_MINIMA } from '../../src/lib/longevity/db'; + +describe('schema longevity', () => { + it('crea le tabelle previste dalla spec', () => { + const db = createLongevityDb(':memory:'); + const nomi = (db.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as { name: string }[]) + .map((r) => r.name); + expect(nomi).toEqual( + expect.arrayContaining(['soggetti', 'sessioni', 'misure', 'registro_test', 'pesi', 'profilo_note', 'score']) + ); + }); + + it('rifiuta un sesso non previsto', () => { + const db = createLongevityDb(':memory:'); + expect(() => + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'X')`).run() + ).toThrow(); + }); + + it('rifiuta una misura con test_id non nel registro', () => { + const db = createLongevityDb(':memory:'); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run(); + db.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES ('ISL-0001', '2026-08-21', 'checkup')`).run(); + expect(() => + db.prepare( + `INSERT INTO misure (sessione_id, client_code, test_id, valore_num, fonte) + VALUES (1, 'ISL-0001', 'test_inventato', 10, 'manuale')` + ).run() + ).toThrow(); + }); + + it('uno score insufficiente puo non avere valore', () => { + const db = createLongevityDb(':memory:'); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run(); + db.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES ('ISL-0001', '2026-08-21', 'checkup')`).run(); + db.prepare( + `INSERT INTO score (client_code, sessione_id, tipo, nome, valore, copertura, stato, model_version) + VALUES ('ISL-0001', 1, 'asse', 'Forza & Struttura', NULL, 0.2, 'insufficiente', 'v1.0')` + ).run(); + const row = db.prepare(`SELECT valore, stato FROM score`).get() as { valore: number | null; stato: string }; + expect(row.valore).toBeNull(); + expect(row.stato).toBe('insufficiente'); + }); + + it('identity tiene la persona, longevity non la conosce', () => { + const id = createIdentityDb(':memory:'); + const cols = (id.prepare(`PRAGMA table_info(clienti)`).all() as { name: string }[]).map((c) => c.name); + expect(cols).toEqual(expect.arrayContaining(['client_code', 'user_id', 'nome', 'cognome', 'data_nascita'])); + + const lg = createLongevityDb(':memory:'); + const tutte = (lg.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as { name: string }[]) + .flatMap((t) => (lg.prepare(`PRAGMA table_info(${t.name})`).all() as { name: string }[]).map((c) => c.name)); + expect(tutte).not.toContain('nome'); + expect(tutte).not.toContain('cognome'); + expect(tutte).not.toContain('data_nascita'); + }); + + it('la soglia di copertura e 0.40', () => { + expect(COPERTURA_MINIMA).toBe(0.4); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- tests/longevity/db.test.ts` +Expected: FAIL — "Cannot find module '../../src/lib/longevity/db'" + +- [ ] **Step 3: Write minimal implementation** + +```ts +// src/lib/longevity/db.ts +import Database from 'better-sqlite3'; +import { mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; + +/** Sotto questa copertura di peso un elemento calcolato e' 'insufficiente' e non ha valore. */ +export const COPERTURA_MINIMA = 0.4; + +const SCHEMA_LONGEVITY = ` +CREATE TABLE IF NOT EXISTS soggetti ( + client_code TEXT PRIMARY KEY, + sesso TEXT NOT NULL CHECK (sesso IN ('M','F')), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE TABLE IF NOT EXISTS registro_test ( + test_id TEXT PRIMARY KEY, + etichetta TEXT NOT NULL, + unita TEXT, + tipo_valore TEXT NOT NULL CHECK (tipo_valore IN ('num','txt')), + curva TEXT, + params TEXT, + asse TEXT, + sotto_dominio TEXT, + range_min REAL, + range_max REAL, + attivo_da TEXT NOT NULL, + attivo_a TEXT, + note TEXT +); +CREATE TABLE IF NOT EXISTS sessioni ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_code TEXT NOT NULL REFERENCES soggetti(client_code), + data TEXT NOT NULL, + tipo TEXT NOT NULL CHECK (tipo IN ('checkup','questionario')), + eta_alla_data INTEGER, + operatore TEXT, + note TEXT, + quest_version TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_sessioni_cliente ON sessioni (client_code, data DESC); +CREATE TABLE IF NOT EXISTS misure ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sessione_id INTEGER NOT NULL REFERENCES sessioni(id) ON DELETE CASCADE, + client_code TEXT NOT NULL REFERENCES soggetti(client_code), + test_id TEXT NOT NULL REFERENCES registro_test(test_id), + valore_num REAL, + valore_txt TEXT, + unita TEXT, + fonte TEXT NOT NULL CHECK (fonte IN + ('manuale','questionario','wellness_tower','vald','calibre','stress_index')), + fuori_range INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_misure_cliente_test ON misure (client_code, test_id); +CREATE INDEX IF NOT EXISTS idx_misure_sessione ON misure (sessione_id); +CREATE TABLE IF NOT EXISTS pesi ( + model_version TEXT NOT NULL, + livello TEXT NOT NULL CHECK (livello IN ('asse','macro','fitness_age')), + contenitore TEXT NOT NULL, + elemento TEXT NOT NULL, + peso REAL NOT NULL, + PRIMARY KEY (model_version, livello, contenitore, elemento) +); +CREATE TABLE IF NOT EXISTS profilo_note ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_code TEXT NOT NULL REFERENCES soggetti(client_code), + sessione_id INTEGER REFERENCES sessioni(id), + campo_id TEXT NOT NULL, + testo TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE TABLE IF NOT EXISTS score ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_code TEXT NOT NULL REFERENCES soggetti(client_code), + sessione_id INTEGER NOT NULL REFERENCES sessioni(id), + tipo TEXT NOT NULL CHECK (tipo IN ('asse','macro','fitness_age')), + nome TEXT NOT NULL, + valore REAL, + copertura REAL NOT NULL, + stato TEXT NOT NULL CHECK (stato IN ('ok','insufficiente')), + quest_version TEXT, + model_version TEXT NOT NULL, + calcolato_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_score_cliente ON score (client_code, calcolato_at DESC); +`; + +const SCHEMA_IDENTITY = ` +CREATE TABLE IF NOT EXISTS clienti ( + client_code TEXT PRIMARY KEY, + user_id INTEGER, + nome TEXT NOT NULL, + cognome TEXT NOT NULL, + data_nascita TEXT, + email TEXT, + telefono TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_clienti_user ON clienti (user_id); +`; + +function apri(p: string, schema: string): Database.Database { + if (p !== ':memory:') mkdirSync(dirname(p), { recursive: true }); + const db = new Database(p); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + db.exec(schema); + return db; +} + +export function createLongevityDb(path?: string): Database.Database { + return apri(path ?? process.env.LONGEVITY_DB_PATH ?? 'data/longevity.db', SCHEMA_LONGEVITY); +} + +export function createIdentityDb(path?: string): Database.Database { + return apri(path ?? process.env.IDENTITY_DB_PATH ?? 'data/identity.db', SCHEMA_IDENTITY); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- tests/longevity/db.test.ts` +Expected: PASS, 6 test + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/longevity/db.ts tests/longevity/db.test.ts +git commit -m "longevity: schema dei due database, identity separato da longevity" +``` + +--- + +### Task 2: Registro dei test e pesi — seed e query + +**Files:** +- Create: `src/lib/longevity/registro.ts` +- Test: `tests/longevity/registro.test.ts` + +**Interfaces:** +- Consumes: `createLongevityDb` da Task 1 +- Produces: + - `type Curva = 'bell' | 'lin_dec' | 'inc_plateau' | 'x10' | 'x10_inv' | 'decstep' | 'incstep'` + - `type VoceRegistro = { test_id: string; etichetta: string; unita?: string; tipo_valore: 'num'|'txt'; curva?: Curva; params?: unknown; asse?: string; sotto_dominio?: string; range_min?: number; range_max?: number; attivo_da: string; attivo_a?: string }` + - `seedRegistro(db: Database.Database): void` + - `seedPesi(db: Database.Database, modelVersion: string): void` + - `testAttivi(db: Database.Database, alla: string): VoceRegistro[]` + - `esisteTest(db: Database.Database, testId: string): boolean` + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/longevity/registro.test.ts +import { describe, it, expect } from 'vitest'; +import { createLongevityDb } from '../../src/lib/longevity/db'; +import { seedRegistro, seedPesi, testAttivi, esisteTest } from '../../src/lib/longevity/registro'; + +describe('registro dei test', () => { + it('carica i 20 campi punteggiati del questionario', () => { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + const q = db.prepare(`SELECT COUNT(*) n FROM registro_test WHERE test_id LIKE 'q_%'`).get() as { n: number }; + expect(q.n).toBe(20); + }); + + it('alcol_life porta la soglia 7-14 voluta da Nicola', () => { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + const row = db.prepare(`SELECT curva, params FROM registro_test WHERE test_id = 'q_alcol_life'`) + .get() as { curva: string; params: string }; + expect(row.curva).toBe('lin_dec'); + expect(JSON.parse(row.params)).toEqual({ best: 7, worst: 14 }); + }); + + it('calo_pomeridiano usa la curva invertita, la quinta che al Python manca', () => { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + const row = db.prepare(`SELECT curva FROM registro_test WHERE test_id = 'q_calo_pomeridiano'`) + .get() as { curva: string }; + expect(row.curva).toBe('x10_inv'); + }); + + it('un test disattivato non e piu attivo dopo la sua data', () => { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + db.prepare(`UPDATE registro_test SET attivo_a = '2026-08-01' WHERE test_id = 'q_sigarette'`).run(); + const attivi = testAttivi(db, '2026-08-21').map((t) => t.test_id); + expect(attivi).not.toContain('q_sigarette'); + expect(testAttivi(db, '2026-07-01').map((t) => t.test_id)).toContain('q_sigarette'); + }); + + it('esisteTest distingue un test vero da uno inventato', () => { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + expect(esisteTest(db, 'q_ore_sonno')).toBe(true); + expect(esisteTest(db, 'q_inventato')).toBe(false); + }); + + it('i pesi di ogni asse sommano a 1', () => { + const db = createLongevityDb(':memory:'); + seedPesi(db, 'v1.0'); + const righe = db.prepare( + `SELECT contenitore, ROUND(SUM(peso), 6) tot FROM pesi + WHERE model_version = 'v1.0' AND livello = 'asse' GROUP BY contenitore` + ).all() as { contenitore: string; tot: number }[]; + expect(righe.length).toBe(7); + for (const r of righe) expect(r.tot).toBe(1); + }); + + it('i pesi sono legati alla versione del modello', () => { + const db = createLongevityDb(':memory:'); + seedPesi(db, 'v1.0'); + seedPesi(db, 'v2.0'); + const n = db.prepare(`SELECT COUNT(DISTINCT model_version) n FROM pesi`).get() as { n: number }; + expect(n.n).toBe(2); + }); + + it('il plank pesa su due assi: va visto, non nascosto', () => { + const db = createLongevityDb(':memory:'); + seedPesi(db, 'v1.0'); + const righe = db.prepare( + `SELECT contenitore FROM pesi WHERE model_version='v1.0' AND livello='asse' AND elemento IN ('core','plank')` + ).all() as { contenitore: string }[]; + expect(righe.map((r) => r.contenitore).sort()) + .toEqual(['Forza & Struttura', 'Stabilità & Mobilità Funzionale']); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- tests/longevity/registro.test.ts` +Expected: FAIL — modulo `registro` non trovato + +- [ ] **Step 3: Write minimal implementation** + +```ts +// src/lib/longevity/registro.ts +import type Database from 'better-sqlite3'; + +export type Curva = 'bell' | 'lin_dec' | 'inc_plateau' | 'x10' | 'x10_inv' | 'decstep' | 'incstep'; + +export type VoceRegistro = { + test_id: string; + etichetta: string; + unita?: string; + tipo_valore: 'num' | 'txt'; + curva?: Curva; + params?: unknown; + asse?: string; + sotto_dominio?: string; + range_min?: number; + range_max?: number; + attivo_da: string; + attivo_a?: string; +}; + +const DA = '2026-01-01'; + +// I 20 campi punteggiati, presi dal prototipo del cliente (questionario_longevity_score.html). +// Gli id portano il prefisso q_ per distinguerli dai test fisici; i params sono nella forma +// dichiarata per ciascuna curva, non nell'array posizionale del prototipo. +const QUESTIONARIO: VoceRegistro[] = [ + { test_id: 'q_ore_sonno', etichetta: 'Ore di sonno per notte (media)', unita: 'h', tipo_valore: 'num', + curva: 'bell', params: { low: 4, peakLow: 7, peakHigh: 9, high: 12 }, + asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 14, attivo_da: DA }, + { test_id: 'q_min_addorm', etichetta: 'Minuti per addormentarti', unita: 'min', tipo_valore: 'num', + curva: 'decstep', params: { steps: [[15, 100], [30, 75], [60, 50], [999, 25]] }, + asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 180, attivo_da: DA }, + { test_id: 'q_risvegli', etichetta: 'Risvegli notturni (numero)', tipo_valore: 'num', + curva: 'decstep', params: { steps: [[0, 100], [1, 80], [2, 50], [999, 25]] }, + asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 10, attivo_da: DA }, + { test_id: 'q_riposato', etichetta: 'Quanto ti senti riposato al risveglio', tipo_valore: 'num', + curva: 'x10', params: {}, asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', + range_min: 0, range_max: 10, attivo_da: DA }, + { test_id: 'q_caffeina', etichetta: 'Caffeina dopo le 16:00 (volte/settimana)', tipo_valore: 'num', + curva: 'lin_dec', params: { best: 0, worst: 7 }, + asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 14, attivo_da: DA }, + { test_id: 'q_sonnolenza_diurna', etichetta: 'Sonnolenza/fatica a concentrarti durante il giorno (volte/settimana)', + tipo_valore: 'num', curva: 'lin_dec', params: { best: 0, worst: 7 }, + asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 14, attivo_da: DA }, + + { test_id: 'q_energia_media', etichetta: 'Energia media nella giornata, ultima settimana', tipo_valore: 'num', + curva: 'x10', params: {}, asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', + range_min: 0, range_max: 10, attivo_da: DA }, + { test_id: 'q_esaurimento', etichetta: 'Episodi di esaurimento senza motivo fisico (volte/sett.)', tipo_valore: 'num', + curva: 'lin_dec', params: { best: 0, worst: 7 }, + asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 14, attivo_da: DA }, + { test_id: 'q_calo_pomeridiano', etichetta: 'Quanto forte e il calo di energia dal mattino al pomeriggio', + tipo_valore: 'num', curva: 'x10_inv', params: {}, + asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 10, attivo_da: DA }, + + { test_id: 'q_sopraffatto', etichetta: 'Sopraffatto da imprevisti (volte/mese)', tipo_valore: 'num', + curva: 'decstep', params: { steps: [[2, 100], [5, 70], [10, 40], [999, 20]] }, + asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 30, attivo_da: DA }, + { test_id: 'q_controllo', etichetta: 'Percezione di controllo sulla tua vita', tipo_valore: 'num', + curva: 'x10', params: {}, asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', + range_min: 0, range_max: 10, attivo_da: DA }, + { test_id: 'q_sicurezza_gestione', etichetta: 'Ti senti sicuro/a nella tua capacita di gestire i problemi personali', + tipo_valore: 'num', curva: 'x10', params: {}, + asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 10, attivo_da: DA }, + { test_id: 'q_tensione', etichetta: 'Tensione/irritabilita (volte/settimana)', tipo_valore: 'num', + curva: 'lin_dec', params: { best: 0, worst: 7 }, + asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 14, attivo_da: DA }, + { test_id: 'q_pensieri_lavoro', etichetta: 'Ore/giorno di pensieri di lavoro fuori orario', unita: 'h', + tipo_valore: 'num', curva: 'lin_dec', params: { best: 0, worst: 4 }, + asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 8, attivo_da: DA }, + + { test_id: 'q_attivita', etichetta: 'Giorni/settimana attivita fisica extra ISL', tipo_valore: 'num', + curva: 'incstep', params: { steps: [[0, 20], [2, 50], [4, 80], [999, 100]] }, + asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 7, attivo_da: DA }, + { test_id: 'q_alimentazione', etichetta: 'Qualita percepita alimentazione abituale', tipo_valore: 'num', + curva: 'x10', params: {}, asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', + range_min: 0, range_max: 10, attivo_da: DA }, + { test_id: 'q_sigarette', etichetta: 'Sigarette al giorno', tipo_valore: 'num', + curva: 'decstep', params: { steps: [[0, 100], [5, 60], [10, 40], [20, 20], [999, 0]] }, + asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 40, attivo_da: DA }, + // Soglia 0-7 = punteggio pieno: scelta deliberata del cliente (basso rischio NIAAA/WHO), + // confermata il 21/08. Non e' un refuso per 0=100 -> 7=0. + { test_id: 'q_alcol_life', etichetta: 'Unita alcoliche/settimana', tipo_valore: 'num', + curva: 'lin_dec', params: { best: 7, worst: 14 }, + asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 30, attivo_da: DA }, + { test_id: 'q_luce', etichetta: 'Ore/giorno luce naturale/esterno', unita: 'h', tipo_valore: 'num', + curva: 'incstep', params: { steps: [[0.5, 60], [1, 90], [999, 100]], zeroVal: 20 }, + asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 8, attivo_da: DA }, + { test_id: 'q_schermi', etichetta: 'Minuti schermi nell\'ora pre-sonno', unita: 'min', tipo_valore: 'num', + curva: 'decstep', params: { steps: [[15, 85], [30, 70], [60, 40], [999, 10]], zeroVal: 100 }, + asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 90, attivo_da: DA }, +]; + +export function seedRegistro(db: Database.Database): void { + const ins = db.prepare( + `INSERT OR REPLACE INTO registro_test + (test_id, etichetta, unita, tipo_valore, curva, params, asse, sotto_dominio, + range_min, range_max, attivo_da, attivo_a, note) + VALUES (@test_id, @etichetta, @unita, @tipo_valore, @curva, @params, @asse, @sotto_dominio, + @range_min, @range_max, @attivo_da, @attivo_a, @note)` + ); + const tx = db.transaction((voci: VoceRegistro[]) => { + for (const v of voci) { + ins.run({ + test_id: v.test_id, etichetta: v.etichetta, unita: v.unita ?? null, + tipo_valore: v.tipo_valore, curva: v.curva ?? null, + params: v.params === undefined ? null : JSON.stringify(v.params), + asse: v.asse ?? null, sotto_dominio: v.sotto_dominio ?? null, + range_min: v.range_min ?? null, range_max: v.range_max ?? null, + attivo_da: v.attivo_da, attivo_a: v.attivo_a ?? null, note: null, + }); + } + }); + tx(QUESTIONARIO); +} + +// Pesi presi dal motore del cliente (AXIS_SUBDOMAIN_WEIGHTS, MACRO_SCORE_WEIGHTS, +// FITNESS_AGE_WEIGHTS). I macro con peso 0 non si inseriscono: assenza e zero sono +// la stessa cosa per la rinormalizzazione, e una riga a zero confonde chi legge. +const PESI_ASSE: Record> = { + '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 }, +}; + +const PESI_MACRO: Record> = { + 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, + }, + 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, + }, + 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, + }, +}; + +const PESI_FITNESS_AGE: Record = { + cardio: 0.30, handgrip_isolato: 0.20, hrv_isolato: 0.20, + forza_resto: 0.15, composizione: 0.10, stabilita: 0.05, +}; + +export function seedPesi(db: Database.Database, modelVersion: string): void { + const ins = db.prepare( + `INSERT OR REPLACE INTO pesi (model_version, livello, contenitore, elemento, peso) + VALUES (?, ?, ?, ?, ?)` + ); + const tx = db.transaction(() => { + for (const [asse, sd] of Object.entries(PESI_ASSE)) + for (const [el, p] of Object.entries(sd)) ins.run(modelVersion, 'asse', asse, el, p); + for (const [macro, assi] of Object.entries(PESI_MACRO)) + for (const [el, p] of Object.entries(assi)) ins.run(modelVersion, 'macro', macro, el, p); + for (const [el, p] of Object.entries(PESI_FITNESS_AGE)) + ins.run(modelVersion, 'fitness_age', 'fitness_age', el, p); + }); + tx(); +} + +export function testAttivi(db: Database.Database, alla: string): VoceRegistro[] { + const righe = db.prepare( + `SELECT * FROM registro_test + WHERE attivo_da <= ? AND (attivo_a IS NULL OR attivo_a > ?) + ORDER BY test_id` + ).all(alla, alla) as Record[]; + return righe.map((r) => ({ + ...r, + params: r.params ? JSON.parse(r.params as string) : undefined, + })) as VoceRegistro[]; +} + +export function esisteTest(db: Database.Database, testId: string): boolean { + const r = db.prepare(`SELECT 1 FROM registro_test WHERE test_id = ?`).get(testId); + return r !== undefined; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- tests/longevity/registro.test.ts` +Expected: PASS, 8 test + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/longevity/registro.ts tests/longevity/registro.test.ts +git commit -m "longevity: registro dei test e pesi versionati come dato" +``` + +--- + +### Task 3: registraMisure — l'unico varco di scrittura + +**Files:** +- Create: `src/lib/longevity/misure.ts` +- Test: `tests/longevity/misure.test.ts` + +**Interfaces:** +- Consumes: `createLongevityDb` (Task 1), `esisteTest`, `seedRegistro` (Task 2) +- Produces: + - `type Fonte = 'manuale'|'questionario'|'wellness_tower'|'vald'|'calibre'|'stress_index'` + - `type MisuraIn = { test_id: string; valore_num?: number; valore_txt?: string; unita?: string }` + - `apriSessione(db, s: { client_code: string; data: string; tipo: 'checkup'|'questionario'; eta_alla_data?: number; operatore?: string; quest_version?: string }): number` + - `registraMisure(db, sessioneId: number, fonte: Fonte, misure: MisuraIn[]): { scritte: number; fuoriRange: string[] }` + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/longevity/misure.test.ts +import { describe, it, expect } from 'vitest'; +import { createLongevityDb } from '../../src/lib/longevity/db'; +import { seedRegistro } from '../../src/lib/longevity/registro'; +import { apriSessione, registraMisure } from '../../src/lib/longevity/misure'; + +function dbPronto() { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run(); + return db; +} + +describe('registrazione delle misure', () => { + it('scrive le misure di una sessione', () => { + const db = dbPronto(); + const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-21', tipo: 'questionario', quest_version: 'v1.0' }); + const esito = registraMisure(db, s, 'questionario', [ + { test_id: 'q_ore_sonno', valore_num: 7.5 }, + { test_id: 'q_riposato', valore_num: 8 }, + ]); + expect(esito.scritte).toBe(2); + const n = db.prepare(`SELECT COUNT(*) n FROM misure WHERE sessione_id = ?`).get(s) as { n: number }; + expect(n.n).toBe(2); + }); + + it('rifiuta un test sconosciuto e non scrive niente del lotto', () => { + const db = dbPronto(); + const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-21', tipo: 'checkup' }); + expect(() => + registraMisure(db, s, 'manuale', [ + { test_id: 'q_ore_sonno', valore_num: 7 }, + { test_id: 'test_inventato', valore_num: 1 }, + ]) + ).toThrow(/test_inventato/); + const n = db.prepare(`SELECT COUNT(*) n FROM misure`).get() as { n: number }; + expect(n.n).toBe(0); + }); + + it('un valore fuori dal range atteso si scrive e si marca', () => { + const db = dbPronto(); + const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-21', tipo: 'questionario' }); + const esito = registraMisure(db, s, 'questionario', [{ test_id: 'q_ore_sonno', valore_num: 26 }]); + expect(esito.fuoriRange).toEqual(['q_ore_sonno']); + const row = db.prepare(`SELECT fuori_range FROM misure WHERE test_id = 'q_ore_sonno'`).get() as { fuori_range: number }; + expect(row.fuori_range).toBe(1); + }); + + it('la sessione porta la versione del questionario', () => { + const db = dbPronto(); + const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-21', tipo: 'questionario', quest_version: 'v1.0' }); + const row = db.prepare(`SELECT quest_version FROM sessioni WHERE id = ?`).get(s) as { quest_version: string }; + expect(row.quest_version).toBe('v1.0'); + }); + + it('il client_code della misura viene dalla sessione, non da chi chiama', () => { + const db = dbPronto(); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0002', 'M')`).run(); + const s = apriSessione(db, { client_code: 'ISL-0002', data: '2026-08-21', tipo: 'checkup' }); + registraMisure(db, s, 'manuale', [{ test_id: 'q_riposato', valore_num: 5 }]); + const row = db.prepare(`SELECT client_code FROM misure`).get() as { client_code: string }; + expect(row.client_code).toBe('ISL-0002'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- tests/longevity/misure.test.ts` +Expected: FAIL — modulo `misure` non trovato + +- [ ] **Step 3: Write minimal implementation** + +```ts +// src/lib/longevity/misure.ts +import type Database from 'better-sqlite3'; +import { esisteTest } from './registro'; + +export type Fonte = 'manuale' | 'questionario' | 'wellness_tower' | 'vald' | 'calibre' | 'stress_index'; + +export type MisuraIn = { + test_id: string; + valore_num?: number; + valore_txt?: string; + unita?: string; +}; + +export function apriSessione( + db: Database.Database, + s: { + client_code: string; data: string; tipo: 'checkup' | 'questionario'; + eta_alla_data?: number; operatore?: string; quest_version?: string; + } +): number { + const r = db.prepare( + `INSERT INTO sessioni (client_code, data, tipo, eta_alla_data, operatore, quest_version) + VALUES (?, ?, ?, ?, ?, ?)` + ).run(s.client_code, s.data, s.tipo, s.eta_alla_data ?? null, s.operatore ?? null, s.quest_version ?? null); + return Number(r.lastInsertRowid); +} + +/** + * L'unico punto in cui si scrive nella tabella `misure`. + * Un test_id sconosciuto fa fallire l'intero lotto: meglio un errore subito che una + * sessione scritta a meta'. Un valore fuori dal range atteso invece si scrive e si + * marca — scartarlo perderebbe un dato vero, clamparlo lo falserebbe. + */ +export function registraMisure( + db: Database.Database, + sessioneId: number, + fonte: Fonte, + misure: MisuraIn[] +): { scritte: number; fuoriRange: string[] } { + const sess = db.prepare(`SELECT client_code FROM sessioni WHERE id = ?`).get(sessioneId) as + { client_code: string } | undefined; + if (!sess) throw new Error(`sessione ${sessioneId} inesistente`); + + for (const m of misure) { + if (!esisteTest(db, m.test_id)) { + throw new Error(`test_id non nel registro: ${m.test_id}`); + } + } + + const range = db.prepare(`SELECT range_min, range_max FROM registro_test WHERE test_id = ?`); + const ins = db.prepare( + `INSERT INTO misure (sessione_id, client_code, test_id, valore_num, valore_txt, unita, fonte, fuori_range) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ); + const fuoriRange: string[] = []; + + const tx = db.transaction(() => { + for (const m of misure) { + const r = range.get(m.test_id) as { range_min: number | null; range_max: number | null }; + let fuori = 0; + if (m.valore_num !== undefined) { + if ((r.range_min !== null && m.valore_num < r.range_min) || + (r.range_max !== null && m.valore_num > r.range_max)) { + fuori = 1; + fuoriRange.push(m.test_id); + } + } + ins.run(sessioneId, sess.client_code, m.test_id, + m.valore_num ?? null, m.valore_txt ?? null, m.unita ?? null, fonte, fuori); + } + }); + tx(); + + return { scritte: misure.length, fuoriRange }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- tests/longevity/misure.test.ts` +Expected: PASS, 5 test + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/longevity/misure.ts tests/longevity/misure.test.ts +git commit -m "longevity: registraMisure, unico varco con validazione sul registro" +``` + +--- + +### Task 4: Anagrafica — l'unico modulo che unisce identity e longevity + +**Files:** +- Create: `src/lib/longevity/anagrafica.ts` +- Test: `tests/longevity/anagrafica.test.ts` + +**Interfaces:** +- Consumes: `createLongevityDb`, `createIdentityDb` (Task 1) +- Produces: + - `creaCliente(identity, longevity, dati: { nome: string; cognome: string; sesso: 'M'|'F'; data_nascita?: string; email?: string; telefono?: string; user_id?: number }): string` — restituisce il `client_code` + - `codicePerUtente(identity, userId: number): string | null` + - `etaAllaData(dataNascita: string, alla: string): number` + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/longevity/anagrafica.test.ts +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { createLongevityDb, createIdentityDb } from '../../src/lib/longevity/db'; +import { creaCliente, codicePerUtente, etaAllaData } from '../../src/lib/longevity/anagrafica'; + +describe('anagrafica pseudonimizzata', () => { + it('il nome sta in identity, il sesso in longevity, e i due non si mescolano', () => { + const id = createIdentityDb(':memory:'); + const lg = createLongevityDb(':memory:'); + const code = creaCliente(id, lg, { nome: 'Mario', cognome: 'Rossi', sesso: 'M', data_nascita: '1988-03-04' }); + + const inIdentity = id.prepare(`SELECT nome, cognome FROM clienti WHERE client_code = ?`).get(code) as + { nome: string; cognome: string }; + expect(inIdentity.nome).toBe('Mario'); + + const inLongevity = lg.prepare(`SELECT sesso FROM soggetti WHERE client_code = ?`).get(code) as { sesso: string }; + expect(inLongevity.sesso).toBe('M'); + + // in longevity non deve esistere nessuna colonna che contenga il nome + const dump = JSON.stringify(lg.prepare(`SELECT * FROM soggetti`).all()); + expect(dump).not.toContain('Mario'); + expect(dump).not.toContain('Rossi'); + expect(dump).not.toContain('1988-03-04'); + }); + + it('assegna codici progressivi e non riusa i vecchi', () => { + const id = createIdentityDb(':memory:'); + const lg = createLongevityDb(':memory:'); + const a = creaCliente(id, lg, { nome: 'A', cognome: 'A', sesso: 'F' }); + const b = creaCliente(id, lg, { nome: 'B', cognome: 'B', sesso: 'M' }); + expect(a).toMatch(/^ISL-\d{4}$/); + expect(b).not.toBe(a); + }); + + it('ritrova il codice a partire dall utente del sito', () => { + const id = createIdentityDb(':memory:'); + const lg = createLongevityDb(':memory:'); + const code = creaCliente(id, lg, { nome: 'C', cognome: 'C', sesso: 'F', user_id: 42 }); + expect(codicePerUtente(id, 42)).toBe(code); + expect(codicePerUtente(id, 99)).toBeNull(); + }); + + it('calcola l eta alla data della sessione', () => { + expect(etaAllaData('1988-03-04', '2026-08-21')).toBe(38); + expect(etaAllaData('1988-12-31', '2026-08-21')).toBe(37); // compleanno non ancora passato + }); + + it('nessun altro modulo apre entrambe le connessioni', () => { + const dir = join(process.cwd(), 'src/lib/longevity'); + const colpevoli: string[] = []; + for (const f of readdirSync(dir)) { + if (!f.endsWith('.ts') || f === 'anagrafica.ts' || f === 'db.ts') continue; + const src = readFileSync(join(dir, f), 'utf8'); + if (src.includes('createIdentityDb') && src.includes('createLongevityDb')) colpevoli.push(f); + } + expect(colpevoli).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- tests/longevity/anagrafica.test.ts` +Expected: FAIL — modulo `anagrafica` non trovato + +- [ ] **Step 3: Write minimal implementation** + +```ts +// src/lib/longevity/anagrafica.ts +// +// QUESTO e' l'unico modulo autorizzato ad aprire insieme identity e longevity. +// La separazione dei due database e' la garanzia che chi legge i dati clinici non +// risalga alla persona (richiesta del cliente, 21/08): un secondo punto di giunzione +// la annullerebbe in silenzio. Un test in tests/longevity/anagrafica.test.ts lo verifica. +import type Database from 'better-sqlite3'; + +export function creaCliente( + identity: Database.Database, + longevity: Database.Database, + dati: { + nome: string; cognome: string; sesso: 'M' | 'F'; + data_nascita?: string; email?: string; telefono?: string; user_id?: number; + } +): string { + const ultimo = identity.prepare( + `SELECT client_code FROM clienti ORDER BY client_code DESC LIMIT 1` + ).get() as { client_code: string } | undefined; + const n = ultimo ? Number(ultimo.client_code.slice(4)) + 1 : 1; + const code = `ISL-${String(n).padStart(4, '0')}`; + + identity.prepare( + `INSERT INTO clienti (client_code, user_id, nome, cognome, data_nascita, email, telefono) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run(code, dati.user_id ?? null, dati.nome, dati.cognome, + dati.data_nascita ?? null, dati.email ?? null, dati.telefono ?? null); + + longevity.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, ?)`).run(code, dati.sesso); + + return code; +} + +export function codicePerUtente(identity: Database.Database, userId: number): string | null { + const r = identity.prepare(`SELECT client_code FROM clienti WHERE user_id = ?`).get(userId) as + { client_code: string } | undefined; + return r?.client_code ?? null; +} + +/** L'eta' al momento della misura: e' cio' che serve al motore, e non e' un quasi-identificatore. */ +export function etaAllaData(dataNascita: string, alla: string): number { + const n = new Date(dataNascita); + const d = new Date(alla); + let eta = d.getFullYear() - n.getFullYear(); + const m = d.getMonth() - n.getMonth(); + if (m < 0 || (m === 0 && d.getDate() < n.getDate())) eta--; + return eta; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- tests/longevity/anagrafica.test.ts` +Expected: PASS, 5 test + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/longevity/anagrafica.ts tests/longevity/anagrafica.test.ts +git commit -m "longevity: anagrafica, unico punto di giunzione fra identita e clinica" +``` + +--- + +### Task 5: Ruoli `cliente` e `trainer` + +**Files:** +- Modify: `src/lib/auth.ts` (type `Role` alla riga 8, array `RULES` alla riga ~56, `canAccessAdminPath`) +- Test: `tests/longevity/ruoli.test.ts` + +**Interfaces:** +- Consumes: `canAccessAdminPath` esistente +- Produces: `Role` esteso con `'cliente' | 'trainer'` + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/longevity/ruoli.test.ts +import { describe, it, expect } from 'vitest'; +import { canAccessAdminPath } from '../../src/lib/auth'; + +describe('accesso alle rotte longevity', () => { + it('il cliente entra nel proprio spazio', () => { + expect(canAccessAdminPath('cliente', '/longevity/io')).toBe(true); + }); + + it('il cliente NON entra nel gestionale', () => { + expect(canAccessAdminPath('cliente', '/longevity/gestionale')).toBe(false); + }); + + it('il trainer entra in entrambi', () => { + expect(canAccessAdminPath('trainer', '/longevity/io')).toBe(true); + expect(canAccessAdminPath('trainer', '/longevity/gestionale')).toBe(true); + }); + + it('il cliente non entra nelle altre piattaforme ne nel blog', () => { + expect(canAccessAdminPath('cliente', '/campus')).toBe(false); + expect(canAccessAdminPath('cliente', '/admin/content')).toBe(false); + expect(canAccessAdminPath('cliente', '/admin/posts')).toBe(false); + }); + + it('il ruolo piattaforme non eredita longevity', () => { + expect(canAccessAdminPath('piattaforme', '/longevity/gestionale')).toBe(false); + }); + + it('admin passa sempre', () => { + expect(canAccessAdminPath('admin', '/longevity/gestionale')).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- tests/longevity/ruoli.test.ts` +Expected: FAIL — `canAccessAdminPath('cliente', '/longevity/gestionale')` restituisce `true` (la regola finale lascia passare i loggati) + +- [ ] **Step 3: Write minimal implementation** + +In `src/lib/auth.ts`, riga 8, estendere il tipo: + +```ts +export type Role = 'admin' | 'superuser' | 'user' | 'piattaforme' | 'cliente' | 'trainer'; +``` + +Nell'array `RULES`, aggiungere in fondo (prima della chiusura `];`): + +```ts + // Longevity: il cliente vede solo il proprio spazio, il gestionale e' del trainer. + [/^\/longevity\/gestionale(\/|$)/, ['admin', 'trainer']], + [/^\/api\/longevity\/gestionale(\/|$)/, ['admin', 'trainer']], + [/^\/longevity(\/|$)/, ['admin', 'trainer', 'cliente']], + [/^\/api\/longevity(\/|$)/, ['admin', 'trainer', 'cliente']], +``` + +In `canAccessAdminPath`, prima del `return true` finale, aggiungere la stessa clausola che esiste per `piattaforme`: + +```ts + // cliente e trainer non sono utenti del sito: fuori dalle rotte elencate non passano. + if (role === 'cliente' || role === 'trainer') return pathname === '/admin/logout'; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test` +Expected: PASS — i 6 test nuovi e **tutti quelli esistenti**, in particolare `tests/auth.test.ts` e `tests/auth-piattaforme.test.ts` + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/auth.ts tests/longevity/ruoli.test.ts +git commit -m "longevity: ruoli cliente e trainer con le rispettive rotte" +``` + +--- + +### Task 6: Salvataggio di una compilazione del questionario + +**Files:** +- Create: `src/lib/longevity/questionario.ts` +- Test: `tests/longevity/questionario.test.ts` + +**Interfaces:** +- Consumes: `apriSessione`, `registraMisure` (Task 3), `seedRegistro` (Task 2) +- Produces: + - `QUEST_VERSION: string` (`'v1.0'`) + - `CAMPI_LIBERI: string[]` (`['farmaci','problematiche_attuali','problematiche_pregresse','obiettivi']`) + - `salvaCompilazione(db, input: { client_code: string; data: string; eta?: number; risposte: Record; liberi?: Record }): number` + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/longevity/questionario.test.ts +import { describe, it, expect } from 'vitest'; +import { createLongevityDb } from '../../src/lib/longevity/db'; +import { seedRegistro } from '../../src/lib/longevity/registro'; +import { salvaCompilazione, QUEST_VERSION } from '../../src/lib/longevity/questionario'; + +function dbPronto() { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run(); + return db; +} + +describe('compilazione del questionario', () => { + it('salva le risposte punteggiate come misure', () => { + const db = dbPronto(); + const s = salvaCompilazione(db, { + client_code: 'ISL-0001', data: '2026-08-21', + risposte: { q_ore_sonno: 7.5, q_riposato: 8, q_alcol_life: 3 }, + }); + const n = db.prepare(`SELECT COUNT(*) n FROM misure WHERE sessione_id = ? AND fonte = 'questionario'`) + .get(s) as { n: number }; + expect(n.n).toBe(3); + }); + + it('marchia la compilazione con la versione del questionario', () => { + const db = dbPronto(); + const s = salvaCompilazione(db, { client_code: 'ISL-0001', data: '2026-08-21', risposte: { q_riposato: 6 } }); + const row = db.prepare(`SELECT tipo, quest_version FROM sessioni WHERE id = ?`).get(s) as + { tipo: string; quest_version: string }; + expect(row.tipo).toBe('questionario'); + expect(row.quest_version).toBe(QUEST_VERSION); + }); + + it('i campi liberi vanno in profilo_note, non fra le misure', () => { + const db = dbPronto(); + const s = salvaCompilazione(db, { + client_code: 'ISL-0001', data: '2026-08-21', + risposte: { q_riposato: 6 }, + liberi: { farmaci: 'nessuno', obiettivi: 'dormire meglio' }, + }); + const note = db.prepare(`SELECT campo_id, testo FROM profilo_note WHERE sessione_id = ? ORDER BY campo_id`) + .all(s) as { campo_id: string; testo: string }[]; + expect(note.map((n) => n.campo_id)).toEqual(['farmaci', 'obiettivi']); + const misure = db.prepare(`SELECT test_id FROM misure WHERE sessione_id = ?`).all(s) as { test_id: string }[]; + expect(misure.map((m) => m.test_id)).toEqual(['q_riposato']); + }); + + it('ogni compilazione e un record nuovo, non un aggiornamento', () => { + const db = dbPronto(); + salvaCompilazione(db, { client_code: 'ISL-0001', data: '2026-08-21', risposte: { q_riposato: 6 } }); + salvaCompilazione(db, { client_code: 'ISL-0001', data: '2026-09-21', risposte: { q_riposato: 9 } }); + const n = db.prepare(`SELECT COUNT(*) n FROM sessioni WHERE tipo = 'questionario'`).get() as { n: number }; + expect(n.n).toBe(2); + }); + + it('una risposta con id sconosciuto fa fallire la compilazione intera', () => { + const db = dbPronto(); + expect(() => + salvaCompilazione(db, { + client_code: 'ISL-0001', data: '2026-08-21', + risposte: { q_riposato: 6, q_domanda_nuova: 3 }, + }) + ).toThrow(/q_domanda_nuova/); + const n = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number }; + expect(n.n).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- tests/longevity/questionario.test.ts` +Expected: FAIL — modulo `questionario` non trovato + +- [ ] **Step 3: Write minimal implementation** + +```ts +// src/lib/longevity/questionario.ts +import type Database from 'better-sqlite3'; +import { apriSessione, registraMisure } from './misure'; +import { esisteTest } from './registro'; + +/** Versione del set di domande. Si incrementa quando cambia una domanda. */ +export const QUEST_VERSION = 'v1.0'; + +/** + * Campi testuali che si salvano ma non entrano MAI in una formula + * (indicazione esplicita del cliente, punto 7 della specifica). + */ +export const CAMPI_LIBERI = ['farmaci', 'problematiche_attuali', 'problematiche_pregresse', 'obiettivi']; + +export function salvaCompilazione( + db: Database.Database, + input: { + client_code: string; data: string; eta?: number; + risposte: Record; + liberi?: Record; + } +): number { + // Validare PRIMA di aprire la sessione: altrimenti un id sbagliato lascia + // in giro una sessione vuota. + for (const id of Object.keys(input.risposte)) { + if (!esisteTest(db, id)) throw new Error(`test_id non nel registro: ${id}`); + } + + const sessioneId = apriSessione(db, { + client_code: input.client_code, + data: input.data, + tipo: 'questionario', + eta_alla_data: input.eta, + quest_version: QUEST_VERSION, + }); + + registraMisure( + db, sessioneId, 'questionario', + Object.entries(input.risposte).map(([test_id, valore_num]) => ({ test_id, valore_num })) + ); + + if (input.liberi) { + const ins = db.prepare( + `INSERT INTO profilo_note (client_code, sessione_id, campo_id, testo) VALUES (?, ?, ?, ?)` + ); + const tx = db.transaction(() => { + for (const [campo, testo] of Object.entries(input.liberi!)) { + if (testo.trim() === '') continue; + ins.run(input.client_code, sessioneId, campo, testo); + } + }); + tx(); + } + + return sessioneId; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- tests/longevity/questionario.test.ts` +Expected: PASS, 5 test + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/longevity/questionario.ts tests/longevity/questionario.test.ts +git commit -m "longevity: salvataggio compilazione questionario, versionata" +``` + +--- + +### Task 7: Export pseudonimizzato + +**Files:** +- Create: `src/lib/longevity/export.ts` +- Test: `tests/longevity/export.test.ts` + +**Interfaces:** +- Consumes: `createLongevityDb` (Task 1) +- Produces: `esportaMisure(db): { intestazioni: string[]; righe: (string|number|null)[][] }`, `esportaCsv(db): string` + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/longevity/export.test.ts +import { describe, it, expect } from 'vitest'; +import { createLongevityDb } from '../../src/lib/longevity/db'; +import { seedRegistro } from '../../src/lib/longevity/registro'; +import { salvaCompilazione } from '../../src/lib/longevity/questionario'; +import { esportaMisure, esportaCsv } from '../../src/lib/longevity/export'; + +function dbConDati() { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run(); + salvaCompilazione(db, { + client_code: 'ISL-0001', data: '2026-08-21', eta: 35, + risposte: { q_ore_sonno: 7, q_riposato: 8 }, + liberi: { farmaci: 'nessuno' }, + }); + return db; +} + +describe('export per le statistiche', () => { + it('esporta una riga per misura con codice, non con nome', () => { + const db = dbConDati(); + const out = esportaMisure(db); + expect(out.intestazioni).toEqual( + ['client_code', 'sesso', 'eta', 'data', 'tipo_sessione', 'quest_version', 'test_id', 'valore', 'unita', 'fonte', 'fuori_range'] + ); + expect(out.righe.length).toBe(2); + expect(out.righe[0][0]).toBe('ISL-0001'); + }); + + it('l export non contiene campi identificativi', () => { + const db = dbConDati(); + const testo = JSON.stringify(esportaMisure(db)); + for (const vietato of ['nome', 'cognome', 'email', 'telefono', 'data_nascita']) { + expect(testo).not.toContain(vietato); + } + }); + + it('i campi liberi restano fuori dall export statistico', () => { + const db = dbConDati(); + expect(JSON.stringify(esportaMisure(db))).not.toContain('nessuno'); + }); + + it('produce un CSV con intestazione e una riga per misura', () => { + const db = dbConDati(); + const righe = esportaCsv(db).trim().split('\n'); + expect(righe.length).toBe(3); // intestazione + 2 misure + expect(righe[0]).toBe('client_code,sesso,eta,data,tipo_sessione,quest_version,test_id,valore,unita,fonte,fuori_range'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- tests/longevity/export.test.ts` +Expected: FAIL — modulo `export` non trovato + +- [ ] **Step 3: Write minimal implementation** + +```ts +// src/lib/longevity/export.ts +// +// L'export per le statistiche legge SOLO longevity.db, che per costruzione non contiene +// nomi: non serve nessuna funzione di anonimizzazione, e non c'e' niente da ricordarsi. +// I campi liberi (farmaci, patologie) restano fuori: sono per il coach, non per l'analisi. +import type Database from 'better-sqlite3'; + +const INTESTAZIONI = [ + 'client_code', 'sesso', 'eta', 'data', 'tipo_sessione', + 'quest_version', 'test_id', 'valore', 'unita', 'fonte', 'fuori_range', +]; + +export function esportaMisure(db: Database.Database): { + intestazioni: string[]; + righe: (string | number | null)[][]; +} { + const righe = db.prepare( + `SELECT sg.client_code, sg.sesso, s.eta_alla_data, s.data, s.tipo, s.quest_version, + m.test_id, COALESCE(m.valore_num, m.valore_txt) AS valore, m.unita, m.fonte, m.fuori_range + FROM misure m + JOIN sessioni s ON s.id = m.sessione_id + JOIN soggetti sg ON sg.client_code = m.client_code + ORDER BY s.data, m.id` + ).raw().all() as (string | number | null)[][]; + + return { intestazioni: INTESTAZIONI, righe }; +} + +export function esportaCsv(db: Database.Database): string { + const { intestazioni, righe } = esportaMisure(db); + const cella = (v: string | number | null) => { + if (v === null) return ''; + const s = String(v); + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; + }; + return [intestazioni.join(','), ...righe.map((r) => r.map(cella).join(','))].join('\n') + '\n'; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test` +Expected: PASS — tutta la suite, vecchia e nuova + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/longevity/export.ts tests/longevity/export.test.ts +git commit -m "longevity: export pseudonimizzato per costruzione" +``` + +--- + +## Cosa esiste alla fine di questo piano + +Una libreria testata che sa: aprire i due database, tenere il registro dei test e i pesi versionati, registrare misure da qualunque sorgente validandole, creare un cliente tenendo separata l'identità dalla clinica, salvare una compilazione di questionario con la sua versione, ed esportare i dati già pseudonimizzati. + +**Non esiste ancora**: nessuna pagina, nessun punteggio calcolato. Sono i due piani successivi — prima il motore (porting delle curve dal Python con il verificatore), poi le pagine. + +## Nota per chi eseguirà il piano successivo + +Il registro creato al Task 2 contiene `curva` e `params` per tutti e 20 i campi del questionario, nella forma dichiarata per tipo di curva (`bell`, `lin_dec`, `decstep`, `incstep`, `x10`, `x10_inv`). Il motore leggerà di lì, non da costanti nel codice. + +⚠️ `x10_inv` è **la quinta curva che manca al motore Python**: serve a `q_calo_pomeridiano` e nel prototipo è `scale10_inv`, cioè `(10 - valore) × 10`. Il porting deve implementarla, non cercarla nel file del cliente. From bdc44dd3c54df32e57091cb72c60c93bdfd46003 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 21 Aug 2026 19:11:44 +0200 Subject: [PATCH 03/62] chore: ignora gli artefatti di lavoro dei subagenti in .superpowers/ --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index a08afe9..6692393 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ data/*.db* uploads/* !uploads/.gitkeep assets-src/ + +# Artefatti di lavoro dei subagenti (ledger, brief, review package) +.superpowers/ From f16012c8bd80968346d1857df988d6178a5560eb Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 21 Aug 2026 19:15:17 +0200 Subject: [PATCH 04/62] longevity: schema dei due database, identity separato da longevity --- src/lib/longevity/db.ts | 117 +++++++++++++++++++++++++++++++++++++ tests/longevity/db.test.ts | 63 ++++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 src/lib/longevity/db.ts create mode 100644 tests/longevity/db.test.ts diff --git a/src/lib/longevity/db.ts b/src/lib/longevity/db.ts new file mode 100644 index 0000000..1be61e4 --- /dev/null +++ b/src/lib/longevity/db.ts @@ -0,0 +1,117 @@ +import Database from 'better-sqlite3'; +import { mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; + +/** Sotto questa copertura di peso un elemento calcolato e' 'insufficiente' e non ha valore. */ +export const COPERTURA_MINIMA = 0.4; + +const SCHEMA_LONGEVITY = ` +CREATE TABLE IF NOT EXISTS soggetti ( + client_code TEXT PRIMARY KEY, + sesso TEXT NOT NULL CHECK (sesso IN ('M','F')), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE TABLE IF NOT EXISTS registro_test ( + test_id TEXT PRIMARY KEY, + etichetta TEXT NOT NULL, + unita TEXT, + tipo_valore TEXT NOT NULL CHECK (tipo_valore IN ('num','txt')), + curva TEXT, + params TEXT, + asse TEXT, + sotto_dominio TEXT, + range_min REAL, + range_max REAL, + attivo_da TEXT NOT NULL, + attivo_a TEXT, + note TEXT +); +CREATE TABLE IF NOT EXISTS sessioni ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_code TEXT NOT NULL REFERENCES soggetti(client_code), + data TEXT NOT NULL, + tipo TEXT NOT NULL CHECK (tipo IN ('checkup','questionario')), + eta_alla_data INTEGER, + operatore TEXT, + note TEXT, + quest_version TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_sessioni_cliente ON sessioni (client_code, data DESC); +CREATE TABLE IF NOT EXISTS misure ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sessione_id INTEGER NOT NULL REFERENCES sessioni(id) ON DELETE CASCADE, + client_code TEXT NOT NULL REFERENCES soggetti(client_code), + test_id TEXT NOT NULL REFERENCES registro_test(test_id), + valore_num REAL, + valore_txt TEXT, + unita TEXT, + fonte TEXT NOT NULL CHECK (fonte IN + ('manuale','questionario','wellness_tower','vald','calibre','stress_index')), + fuori_range INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_misure_cliente_test ON misure (client_code, test_id); +CREATE INDEX IF NOT EXISTS idx_misure_sessione ON misure (sessione_id); +CREATE TABLE IF NOT EXISTS pesi ( + model_version TEXT NOT NULL, + livello TEXT NOT NULL CHECK (livello IN ('asse','macro','fitness_age')), + contenitore TEXT NOT NULL, + elemento TEXT NOT NULL, + peso REAL NOT NULL, + PRIMARY KEY (model_version, livello, contenitore, elemento) +); +CREATE TABLE IF NOT EXISTS profilo_note ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_code TEXT NOT NULL REFERENCES soggetti(client_code), + sessione_id INTEGER REFERENCES sessioni(id), + campo_id TEXT NOT NULL, + testo TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE TABLE IF NOT EXISTS score ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_code TEXT NOT NULL REFERENCES soggetti(client_code), + sessione_id INTEGER NOT NULL REFERENCES sessioni(id), + tipo TEXT NOT NULL CHECK (tipo IN ('asse','macro','fitness_age')), + nome TEXT NOT NULL, + valore REAL, + copertura REAL NOT NULL, + stato TEXT NOT NULL CHECK (stato IN ('ok','insufficiente')), + quest_version TEXT, + model_version TEXT NOT NULL, + calcolato_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_score_cliente ON score (client_code, calcolato_at DESC); +`; + +const SCHEMA_IDENTITY = ` +CREATE TABLE IF NOT EXISTS clienti ( + client_code TEXT PRIMARY KEY, + user_id INTEGER, + nome TEXT NOT NULL, + cognome TEXT NOT NULL, + data_nascita TEXT, + email TEXT, + telefono TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_clienti_user ON clienti (user_id); +`; + +function apri(p: string, schema: string): Database.Database { + if (p !== ':memory:') mkdirSync(dirname(p), { recursive: true }); + const db = new Database(p); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + db.exec(schema); + return db; +} + +export function createLongevityDb(path?: string): Database.Database { + return apri(path ?? process.env.LONGEVITY_DB_PATH ?? 'data/longevity.db', SCHEMA_LONGEVITY); +} + +export function createIdentityDb(path?: string): Database.Database { + return apri(path ?? process.env.IDENTITY_DB_PATH ?? 'data/identity.db', SCHEMA_IDENTITY); +} diff --git a/tests/longevity/db.test.ts b/tests/longevity/db.test.ts new file mode 100644 index 0000000..e8b3bf2 --- /dev/null +++ b/tests/longevity/db.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; +import { createLongevityDb, createIdentityDb, COPERTURA_MINIMA } from '../../src/lib/longevity/db'; + +describe('schema longevity', () => { + it('crea le tabelle previste dalla spec', () => { + const db = createLongevityDb(':memory:'); + const nomi = (db.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as { name: string }[]) + .map((r) => r.name); + expect(nomi).toEqual( + expect.arrayContaining(['soggetti', 'sessioni', 'misure', 'registro_test', 'pesi', 'profilo_note', 'score']) + ); + }); + + it('rifiuta un sesso non previsto', () => { + const db = createLongevityDb(':memory:'); + expect(() => + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'X')`).run() + ).toThrow(); + }); + + it('rifiuta una misura con test_id non nel registro', () => { + const db = createLongevityDb(':memory:'); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run(); + db.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES ('ISL-0001', '2026-08-21', 'checkup')`).run(); + expect(() => + db.prepare( + `INSERT INTO misure (sessione_id, client_code, test_id, valore_num, fonte) + VALUES (1, 'ISL-0001', 'test_inventato', 10, 'manuale')` + ).run() + ).toThrow(); + }); + + it('uno score insufficiente puo non avere valore', () => { + const db = createLongevityDb(':memory:'); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run(); + db.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES ('ISL-0001', '2026-08-21', 'checkup')`).run(); + db.prepare( + `INSERT INTO score (client_code, sessione_id, tipo, nome, valore, copertura, stato, model_version) + VALUES ('ISL-0001', 1, 'asse', 'Forza & Struttura', NULL, 0.2, 'insufficiente', 'v1.0')` + ).run(); + const row = db.prepare(`SELECT valore, stato FROM score`).get() as { valore: number | null; stato: string }; + expect(row.valore).toBeNull(); + expect(row.stato).toBe('insufficiente'); + }); + + it('identity tiene la persona, longevity non la conosce', () => { + const id = createIdentityDb(':memory:'); + const cols = (id.prepare(`PRAGMA table_info(clienti)`).all() as { name: string }[]).map((c) => c.name); + expect(cols).toEqual(expect.arrayContaining(['client_code', 'user_id', 'nome', 'cognome', 'data_nascita'])); + + const lg = createLongevityDb(':memory:'); + // Esclude la tabella score (che ha una colonna nome legittima per il nome dello score calcolato). + // Controlla che le colonne identificative della PERSONA non stiano nel longevity. + const colonne = (lg.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name != 'score'`).all() as { name: string }[]) + .flatMap((t) => (lg.prepare(`PRAGMA table_info(${t.name})`).all() as { name: string }[]).map((c) => c.name)); + expect(colonne).not.toContain('cognome'); + expect(colonne).not.toContain('data_nascita'); + }); + + it('la soglia di copertura e 0.40', () => { + expect(COPERTURA_MINIMA).toBe(0.4); + }); +}); From ad2fc0d510a90d656afbe962c50bca1a770760f5 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 21 Aug 2026 19:17:03 +0200 Subject: [PATCH 05/62] fix: rinomina score.nome in score.elemento per coerenza con tabella pesi --- src/lib/longevity/db.ts | 2 +- tests/longevity/db.test.ts | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/lib/longevity/db.ts b/src/lib/longevity/db.ts index 1be61e4..639e697 100644 --- a/src/lib/longevity/db.ts +++ b/src/lib/longevity/db.ts @@ -74,7 +74,7 @@ CREATE TABLE IF NOT EXISTS score ( client_code TEXT NOT NULL REFERENCES soggetti(client_code), sessione_id INTEGER NOT NULL REFERENCES sessioni(id), tipo TEXT NOT NULL CHECK (tipo IN ('asse','macro','fitness_age')), - nome TEXT NOT NULL, + elemento TEXT NOT NULL, valore REAL, copertura REAL NOT NULL, stato TEXT NOT NULL CHECK (stato IN ('ok','insufficiente')), diff --git a/tests/longevity/db.test.ts b/tests/longevity/db.test.ts index e8b3bf2..959adb2 100644 --- a/tests/longevity/db.test.ts +++ b/tests/longevity/db.test.ts @@ -35,7 +35,7 @@ describe('schema longevity', () => { db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run(); db.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES ('ISL-0001', '2026-08-21', 'checkup')`).run(); db.prepare( - `INSERT INTO score (client_code, sessione_id, tipo, nome, valore, copertura, stato, model_version) + `INSERT INTO score (client_code, sessione_id, tipo, elemento, valore, copertura, stato, model_version) VALUES ('ISL-0001', 1, 'asse', 'Forza & Struttura', NULL, 0.2, 'insufficiente', 'v1.0')` ).run(); const row = db.prepare(`SELECT valore, stato FROM score`).get() as { valore: number | null; stato: string }; @@ -49,12 +49,11 @@ describe('schema longevity', () => { expect(cols).toEqual(expect.arrayContaining(['client_code', 'user_id', 'nome', 'cognome', 'data_nascita'])); const lg = createLongevityDb(':memory:'); - // Esclude la tabella score (che ha una colonna nome legittima per il nome dello score calcolato). - // Controlla che le colonne identificative della PERSONA non stiano nel longevity. - const colonne = (lg.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name != 'score'`).all() as { name: string }[]) + const tutte = (lg.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as { name: string }[]) .flatMap((t) => (lg.prepare(`PRAGMA table_info(${t.name})`).all() as { name: string }[]).map((c) => c.name)); - expect(colonne).not.toContain('cognome'); - expect(colonne).not.toContain('data_nascita'); + expect(tutte).not.toContain('nome'); + expect(tutte).not.toContain('cognome'); + expect(tutte).not.toContain('data_nascita'); }); it('la soglia di copertura e 0.40', () => { From 1821a0a8d4a93fba074f8fc5c6ee80f73512b562 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 21 Aug 2026 19:17:42 +0200 Subject: [PATCH 06/62] spec: score.nome diventa score.elemento, coerente con la tabella pesi --- docs/specs/2026-08-21-longevity-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/2026-08-21-longevity-design.md b/docs/specs/2026-08-21-longevity-design.md index 9dc5e9e..eb4739c 100644 --- a/docs/specs/2026-08-21-longevity-design.md +++ b/docs/specs/2026-08-21-longevity-design.md @@ -179,7 +179,7 @@ CREATE TABLE score ( client_code TEXT NOT NULL REFERENCES soggetti(client_code), sessione_id INTEGER NOT NULL REFERENCES sessioni(id), tipo TEXT NOT NULL CHECK (tipo IN ('asse','macro','fitness_age')), - nome TEXT NOT NULL, -- 'Forza & Struttura', 'PERFORMANCE', ... + elemento TEXT NOT NULL, -- 'Forza & Struttura', 'PERFORMANCE', ... valore REAL, -- NULL quando stato='insufficiente' copertura REAL NOT NULL, stato TEXT NOT NULL CHECK (stato IN ('ok','insufficiente')), From 370dcbda56c9bf3b66245f3b96810fdcdd0935fe Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 21 Aug 2026 19:22:32 +0200 Subject: [PATCH 07/62] longevity: registro dei test e pesi versionati come dato --- src/lib/longevity/registro.ts | 181 +++++++++++++++++++++++++++++++ tests/longevity/registro.test.ts | 74 +++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 src/lib/longevity/registro.ts create mode 100644 tests/longevity/registro.test.ts diff --git a/src/lib/longevity/registro.ts b/src/lib/longevity/registro.ts new file mode 100644 index 0000000..d934a5f --- /dev/null +++ b/src/lib/longevity/registro.ts @@ -0,0 +1,181 @@ +import type Database from 'better-sqlite3'; + +export type Curva = 'bell' | 'lin_dec' | 'inc_plateau' | 'x10' | 'x10_inv' | 'decstep' | 'incstep'; + +export type VoceRegistro = { + test_id: string; + etichetta: string; + unita?: string; + tipo_valore: 'num' | 'txt'; + curva?: Curva; + params?: unknown; + asse?: string; + sotto_dominio?: string; + range_min?: number; + range_max?: number; + attivo_da: string; + attivo_a?: string; +}; + +const DA = '2026-01-01'; + +// I 20 campi punteggiati, presi dal prototipo del cliente (questionario_longevity_score.html). +// Gli id portano il prefisso q_ per distinguerli dai test fisici; i params sono nella forma +// dichiarata per ciascuna curva, non nell'array posizionale del prototipo. +const QUESTIONARIO: VoceRegistro[] = [ + { test_id: 'q_ore_sonno', etichetta: 'Ore di sonno per notte (media)', unita: 'h', tipo_valore: 'num', + curva: 'bell', params: { low: 4, peakLow: 7, peakHigh: 9, high: 12 }, + asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 14, attivo_da: DA }, + { test_id: 'q_min_addorm', etichetta: 'Minuti per addormentarti', unita: 'min', tipo_valore: 'num', + curva: 'decstep', params: { steps: [[15, 100], [30, 75], [60, 50], [999, 25]] }, + asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 180, attivo_da: DA }, + { test_id: 'q_risvegli', etichetta: 'Risvegli notturni (numero)', tipo_valore: 'num', + curva: 'decstep', params: { steps: [[0, 100], [1, 80], [2, 50], [999, 25]] }, + asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 10, attivo_da: DA }, + { test_id: 'q_riposato', etichetta: 'Quanto ti senti riposato al risveglio', tipo_valore: 'num', + curva: 'x10', params: {}, asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', + range_min: 0, range_max: 10, attivo_da: DA }, + { test_id: 'q_caffeina', etichetta: 'Caffeina dopo le 16:00 (volte/settimana)', tipo_valore: 'num', + curva: 'lin_dec', params: { best: 0, worst: 7 }, + asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 14, attivo_da: DA }, + { test_id: 'q_sonnolenza_diurna', etichetta: 'Sonnolenza/fatica a concentrarti durante il giorno (volte/settimana)', + tipo_valore: 'num', curva: 'lin_dec', params: { best: 0, worst: 7 }, + asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 14, attivo_da: DA }, + + { test_id: 'q_energia_media', etichetta: 'Energia media nella giornata, ultima settimana', tipo_valore: 'num', + curva: 'x10', params: {}, asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', + range_min: 0, range_max: 10, attivo_da: DA }, + { test_id: 'q_esaurimento', etichetta: 'Episodi di esaurimento senza motivo fisico (volte/sett.)', tipo_valore: 'num', + curva: 'lin_dec', params: { best: 0, worst: 7 }, + asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 14, attivo_da: DA }, + { test_id: 'q_calo_pomeridiano', etichetta: 'Quanto forte e il calo di energia dal mattino al pomeriggio', + tipo_valore: 'num', curva: 'x10_inv', params: {}, + asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 10, attivo_da: DA }, + + { test_id: 'q_sopraffatto', etichetta: 'Sopraffatto da imprevisti (volte/mese)', tipo_valore: 'num', + curva: 'decstep', params: { steps: [[2, 100], [5, 70], [10, 40], [999, 20]] }, + asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 30, attivo_da: DA }, + { test_id: 'q_controllo', etichetta: 'Percezione di controllo sulla tua vita', tipo_valore: 'num', + curva: 'x10', params: {}, asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', + range_min: 0, range_max: 10, attivo_da: DA }, + { test_id: 'q_sicurezza_gestione', etichetta: 'Ti senti sicuro/a nella tua capacita di gestire i problemi personali', + tipo_valore: 'num', curva: 'x10', params: {}, + asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 10, attivo_da: DA }, + { test_id: 'q_tensione', etichetta: 'Tensione/irritabilita (volte/settimana)', tipo_valore: 'num', + curva: 'lin_dec', params: { best: 0, worst: 7 }, + asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 14, attivo_da: DA }, + { test_id: 'q_pensieri_lavoro', etichetta: 'Ore/giorno di pensieri di lavoro fuori orario', unita: 'h', + tipo_valore: 'num', curva: 'lin_dec', params: { best: 0, worst: 4 }, + asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 8, attivo_da: DA }, + + { test_id: 'q_attivita', etichetta: 'Giorni/settimana attivita fisica extra ISL', tipo_valore: 'num', + curva: 'incstep', params: { steps: [[0, 20], [2, 50], [4, 80], [999, 100]] }, + asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 7, attivo_da: DA }, + { test_id: 'q_alimentazione', etichetta: 'Qualita percepita alimentazione abituale', tipo_valore: 'num', + curva: 'x10', params: {}, asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', + range_min: 0, range_max: 10, attivo_da: DA }, + { test_id: 'q_sigarette', etichetta: 'Sigarette al giorno', tipo_valore: 'num', + curva: 'decstep', params: { steps: [[0, 100], [5, 60], [10, 40], [20, 20], [999, 0]] }, + asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 40, attivo_da: DA }, + // Soglia 0-7 = punteggio pieno: scelta deliberata del cliente (basso rischio NIAAA/WHO), + // confermata il 21/08. Non e' un refuso per 0=100 -> 7=0. + { test_id: 'q_alcol_life', etichetta: 'Unita alcoliche/settimana', tipo_valore: 'num', + curva: 'lin_dec', params: { best: 7, worst: 14 }, + asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 30, attivo_da: DA }, + { test_id: 'q_luce', etichetta: 'Ore/giorno luce naturale/esterno', unita: 'h', tipo_valore: 'num', + curva: 'incstep', params: { steps: [[0.5, 60], [1, 90], [999, 100]], zeroVal: 20 }, + asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 8, attivo_da: DA }, + { test_id: 'q_schermi', etichetta: 'Minuti schermi nell\'ora pre-sonno', unita: 'min', tipo_valore: 'num', + curva: 'decstep', params: { steps: [[15, 85], [30, 70], [60, 40], [999, 10]], zeroVal: 100 }, + asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 90, attivo_da: DA }, +]; + +export function seedRegistro(db: Database.Database): void { + const ins = db.prepare( + `INSERT OR REPLACE INTO registro_test + (test_id, etichetta, unita, tipo_valore, curva, params, asse, sotto_dominio, + range_min, range_max, attivo_da, attivo_a, note) + VALUES (@test_id, @etichetta, @unita, @tipo_valore, @curva, @params, @asse, @sotto_dominio, + @range_min, @range_max, @attivo_da, @attivo_a, @note)` + ); + const tx = db.transaction((voci: VoceRegistro[]) => { + for (const v of voci) { + ins.run({ + test_id: v.test_id, etichetta: v.etichetta, unita: v.unita ?? null, + tipo_valore: v.tipo_valore, curva: v.curva ?? null, + params: v.params === undefined ? null : JSON.stringify(v.params), + asse: v.asse ?? null, sotto_dominio: v.sotto_dominio ?? null, + range_min: v.range_min ?? null, range_max: v.range_max ?? null, + attivo_da: v.attivo_da, attivo_a: v.attivo_a ?? null, note: null, + }); + } + }); + tx(QUESTIONARIO); +} + +// Pesi presi dal motore del cliente (AXIS_SUBDOMAIN_WEIGHTS, MACRO_SCORE_WEIGHTS, +// FITNESS_AGE_WEIGHTS). I macro con peso 0 non si inseriscono: assenza e zero sono +// la stessa cosa per la rinormalizzazione, e una riga a zero confonde chi legge. +const PESI_ASSE: Record> = { + '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 }, +}; + +const PESI_MACRO: Record> = { + 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, + }, + 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, + }, + 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, + }, +}; + +const PESI_FITNESS_AGE: Record = { + cardio: 0.30, handgrip_isolato: 0.20, hrv_isolato: 0.20, + forza_resto: 0.15, composizione: 0.10, stabilita: 0.05, +}; + +export function seedPesi(db: Database.Database, modelVersion: string): void { + const ins = db.prepare( + `INSERT OR REPLACE INTO pesi (model_version, livello, contenitore, elemento, peso) + VALUES (?, ?, ?, ?, ?)` + ); + const tx = db.transaction(() => { + for (const [asse, sd] of Object.entries(PESI_ASSE)) + for (const [el, p] of Object.entries(sd)) ins.run(modelVersion, 'asse', asse, el, p); + for (const [macro, assi] of Object.entries(PESI_MACRO)) + for (const [el, p] of Object.entries(assi)) ins.run(modelVersion, 'macro', macro, el, p); + for (const [el, p] of Object.entries(PESI_FITNESS_AGE)) + ins.run(modelVersion, 'fitness_age', 'fitness_age', el, p); + }); + tx(); +} + +export function testAttivi(db: Database.Database, alla: string): VoceRegistro[] { + const righe = db.prepare( + `SELECT * FROM registro_test + WHERE attivo_da <= ? AND (attivo_a IS NULL OR attivo_a > ?) + ORDER BY test_id` + ).all(alla, alla) as Record[]; + return righe.map((r) => ({ + ...r, + params: r.params ? JSON.parse(r.params as string) : undefined, + })) as VoceRegistro[]; +} + +export function esisteTest(db: Database.Database, testId: string): boolean { + const r = db.prepare(`SELECT 1 FROM registro_test WHERE test_id = ?`).get(testId); + return r !== undefined; +} diff --git a/tests/longevity/registro.test.ts b/tests/longevity/registro.test.ts new file mode 100644 index 0000000..4c7135c --- /dev/null +++ b/tests/longevity/registro.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { createLongevityDb } from '../../src/lib/longevity/db'; +import { seedRegistro, seedPesi, testAttivi, esisteTest } from '../../src/lib/longevity/registro'; + +describe('registro dei test', () => { + it('carica i 20 campi punteggiati del questionario', () => { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + const q = db.prepare(`SELECT COUNT(*) n FROM registro_test WHERE test_id LIKE 'q_%'`).get() as { n: number }; + expect(q.n).toBe(20); + }); + + it('alcol_life porta la soglia 7-14 voluta da Nicola', () => { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + const row = db.prepare(`SELECT curva, params FROM registro_test WHERE test_id = 'q_alcol_life'`) + .get() as { curva: string; params: string }; + expect(row.curva).toBe('lin_dec'); + expect(JSON.parse(row.params)).toEqual({ best: 7, worst: 14 }); + }); + + it('calo_pomeridiano usa la curva invertita, la quinta che al Python manca', () => { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + const row = db.prepare(`SELECT curva FROM registro_test WHERE test_id = 'q_calo_pomeridiano'`) + .get() as { curva: string }; + expect(row.curva).toBe('x10_inv'); + }); + + it('un test disattivato non e piu attivo dopo la sua data', () => { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + db.prepare(`UPDATE registro_test SET attivo_a = '2026-08-01' WHERE test_id = 'q_sigarette'`).run(); + const attivi = testAttivi(db, '2026-08-21').map((t) => t.test_id); + expect(attivi).not.toContain('q_sigarette'); + expect(testAttivi(db, '2026-07-01').map((t) => t.test_id)).toContain('q_sigarette'); + }); + + it('esisteTest distingue un test vero da uno inventato', () => { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + expect(esisteTest(db, 'q_ore_sonno')).toBe(true); + expect(esisteTest(db, 'q_inventato')).toBe(false); + }); + + it('i pesi di ogni asse sommano a 1', () => { + const db = createLongevityDb(':memory:'); + seedPesi(db, 'v1.0'); + const righe = db.prepare( + `SELECT contenitore, ROUND(SUM(peso), 6) tot FROM pesi + WHERE model_version = 'v1.0' AND livello = 'asse' GROUP BY contenitore` + ).all() as { contenitore: string; tot: number }[]; + expect(righe.length).toBe(7); + for (const r of righe) expect(r.tot).toBe(1); + }); + + it('i pesi sono legati alla versione del modello', () => { + const db = createLongevityDb(':memory:'); + seedPesi(db, 'v1.0'); + seedPesi(db, 'v2.0'); + const n = db.prepare(`SELECT COUNT(DISTINCT model_version) n FROM pesi`).get() as { n: number }; + expect(n.n).toBe(2); + }); + + it('il plank pesa su due assi: va visto, non nascosto', () => { + const db = createLongevityDb(':memory:'); + seedPesi(db, 'v1.0'); + const righe = db.prepare( + `SELECT contenitore FROM pesi WHERE model_version='v1.0' AND livello='asse' AND elemento IN ('core','plank')` + ).all() as { contenitore: string }[]; + expect(righe.map((r) => r.contenitore).sort()) + .toEqual(['Forza & Struttura', 'Stabilità & Mobilità Funzionale']); + }); +}); From a18bb7c02f44c9e751db4b5d7436023e2dc317c4 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 21 Aug 2026 19:27:41 +0200 Subject: [PATCH 08/62] longevity: registraMisure, unico varco con validazione sul registro --- src/lib/longevity/misure.ts | 74 ++++++++++++++++++++++++++++++++++ tests/longevity/misure.test.ts | 63 +++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 src/lib/longevity/misure.ts create mode 100644 tests/longevity/misure.test.ts diff --git a/src/lib/longevity/misure.ts b/src/lib/longevity/misure.ts new file mode 100644 index 0000000..dbad9d6 --- /dev/null +++ b/src/lib/longevity/misure.ts @@ -0,0 +1,74 @@ +import type Database from 'better-sqlite3'; +import { esisteTest } from './registro'; + +export type Fonte = 'manuale' | 'questionario' | 'wellness_tower' | 'vald' | 'calibre' | 'stress_index'; + +export type MisuraIn = { + test_id: string; + valore_num?: number; + valore_txt?: string; + unita?: string; +}; + +export function apriSessione( + db: Database.Database, + s: { + client_code: string; data: string; tipo: 'checkup' | 'questionario'; + eta_alla_data?: number; operatore?: string; quest_version?: string; + } +): number { + const r = db.prepare( + `INSERT INTO sessioni (client_code, data, tipo, eta_alla_data, operatore, quest_version) + VALUES (?, ?, ?, ?, ?, ?)` + ).run(s.client_code, s.data, s.tipo, s.eta_alla_data ?? null, s.operatore ?? null, s.quest_version ?? null); + return Number(r.lastInsertRowid); +} + +/** + * L'unico punto in cui si scrive nella tabella `misure`. + * Un test_id sconosciuto fa fallire l'intero lotto: meglio un errore subito che una + * sessione scritta a metà. Un valore fuori dal range atteso invece si scrive e si + * marca — scartarlo perderebbe un dato vero, clamparlo lo falserebbe. + */ +export function registraMisure( + db: Database.Database, + sessioneId: number, + fonte: Fonte, + misure: MisuraIn[] +): { scritte: number; fuoriRange: string[] } { + const sess = db.prepare(`SELECT client_code FROM sessioni WHERE id = ?`).get(sessioneId) as + { client_code: string } | undefined; + if (!sess) throw new Error(`sessione ${sessioneId} inesistente`); + + for (const m of misure) { + if (!esisteTest(db, m.test_id)) { + throw new Error(`test_id non nel registro: ${m.test_id}`); + } + } + + const range = db.prepare(`SELECT range_min, range_max FROM registro_test WHERE test_id = ?`); + const ins = db.prepare( + `INSERT INTO misure (sessione_id, client_code, test_id, valore_num, valore_txt, unita, fonte, fuori_range) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ); + const fuoriRange: string[] = []; + + const tx = db.transaction(() => { + for (const m of misure) { + const r = range.get(m.test_id) as { range_min: number | null; range_max: number | null }; + let fuori = 0; + if (m.valore_num !== undefined) { + if ((r.range_min !== null && m.valore_num < r.range_min) || + (r.range_max !== null && m.valore_num > r.range_max)) { + fuori = 1; + fuoriRange.push(m.test_id); + } + } + ins.run(sessioneId, sess.client_code, m.test_id, + m.valore_num ?? null, m.valore_txt ?? null, m.unita ?? null, fonte, fuori); + } + }); + tx(); + + return { scritte: misure.length, fuoriRange }; +} diff --git a/tests/longevity/misure.test.ts b/tests/longevity/misure.test.ts new file mode 100644 index 0000000..1b36839 --- /dev/null +++ b/tests/longevity/misure.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; +import { createLongevityDb } from '../../src/lib/longevity/db'; +import { seedRegistro } from '../../src/lib/longevity/registro'; +import { apriSessione, registraMisure } from '../../src/lib/longevity/misure'; + +function dbPronto() { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run(); + return db; +} + +describe('registrazione delle misure', () => { + it('scrive le misure di una sessione', () => { + const db = dbPronto(); + const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-21', tipo: 'questionario', quest_version: 'v1.0' }); + const esito = registraMisure(db, s, 'questionario', [ + { test_id: 'q_ore_sonno', valore_num: 7.5 }, + { test_id: 'q_riposato', valore_num: 8 }, + ]); + expect(esito.scritte).toBe(2); + const n = db.prepare(`SELECT COUNT(*) n FROM misure WHERE sessione_id = ?`).get(s) as { n: number }; + expect(n.n).toBe(2); + }); + + it('rifiuta un test sconosciuto e non scrive niente del lotto', () => { + const db = dbPronto(); + const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-21', tipo: 'checkup' }); + expect(() => + registraMisure(db, s, 'manuale', [ + { test_id: 'q_ore_sonno', valore_num: 7 }, + { test_id: 'test_inventato', valore_num: 1 }, + ]) + ).toThrow(/test_inventato/); + const n = db.prepare(`SELECT COUNT(*) n FROM misure`).get() as { n: number }; + expect(n.n).toBe(0); + }); + + it('un valore fuori dal range atteso si scrive e si marca', () => { + const db = dbPronto(); + const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-21', tipo: 'questionario' }); + const esito = registraMisure(db, s, 'questionario', [{ test_id: 'q_ore_sonno', valore_num: 26 }]); + expect(esito.fuoriRange).toEqual(['q_ore_sonno']); + const row = db.prepare(`SELECT fuori_range FROM misure WHERE test_id = 'q_ore_sonno'`).get() as { fuori_range: number }; + expect(row.fuori_range).toBe(1); + }); + + it('la sessione porta la versione del questionario', () => { + const db = dbPronto(); + const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-21', tipo: 'questionario', quest_version: 'v1.0' }); + const row = db.prepare(`SELECT quest_version FROM sessioni WHERE id = ?`).get(s) as { quest_version: string }; + expect(row.quest_version).toBe('v1.0'); + }); + + it('il client_code della misura viene dalla sessione, non da chi chiama', () => { + const db = dbPronto(); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0002', 'M')`).run(); + const s = apriSessione(db, { client_code: 'ISL-0002', data: '2026-08-21', tipo: 'checkup' }); + registraMisure(db, s, 'manuale', [{ test_id: 'q_riposato', valore_num: 5 }]); + const row = db.prepare(`SELECT client_code FROM misure`).get() as { client_code: string }; + expect(row.client_code).toBe('ISL-0002'); + }); +}); From 7779c6b8309c32a1f8de2168642d1577edc2b873 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 21 Aug 2026 19:31:15 +0200 Subject: [PATCH 09/62] test: verifica che fuoriRange non trunca il valore numerico --- tests/longevity/misure.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/longevity/misure.test.ts b/tests/longevity/misure.test.ts index 1b36839..6769511 100644 --- a/tests/longevity/misure.test.ts +++ b/tests/longevity/misure.test.ts @@ -41,8 +41,9 @@ describe('registrazione delle misure', () => { const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-21', tipo: 'questionario' }); const esito = registraMisure(db, s, 'questionario', [{ test_id: 'q_ore_sonno', valore_num: 26 }]); expect(esito.fuoriRange).toEqual(['q_ore_sonno']); - const row = db.prepare(`SELECT fuori_range FROM misure WHERE test_id = 'q_ore_sonno'`).get() as { fuori_range: number }; + const row = db.prepare(`SELECT valore_num, fuori_range FROM misure WHERE test_id = 'q_ore_sonno'`).get() as { valore_num: number; fuori_range: number }; expect(row.fuori_range).toBe(1); + expect(row.valore_num).toBe(26); }); it('la sessione porta la versione del questionario', () => { From 99433120717180283bc6466a888f8465980c80c7 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 21 Aug 2026 19:33:23 +0200 Subject: [PATCH 10/62] longevity: anagrafica, unico punto di giunzione fra identita e clinica Implementa il modulo che unisce i due database (identity e longevity) rispettando la separazione richiesta dal cliente: pseudonimizzazione dei dati clinici. Il modulo esporta tre funzioni: - creaCliente: registra un soggetto con il suo codice progressivo ISL-NNNN - codicePerUtente: risale dal user_id del sito al client_code - etaAllaData: calcola l'eta' al momento della misura (non la data di nascita) Un test verifica che nessun altro modulo apra entrambe le connessioni, preservando il firewall fra dati clinici e identita'. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/longevity/anagrafica.ts | 47 ++++++++++++++++++++++++ tests/longevity/anagrafica.test.ts | 59 ++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 src/lib/longevity/anagrafica.ts create mode 100644 tests/longevity/anagrafica.test.ts diff --git a/src/lib/longevity/anagrafica.ts b/src/lib/longevity/anagrafica.ts new file mode 100644 index 0000000..4e26c63 --- /dev/null +++ b/src/lib/longevity/anagrafica.ts @@ -0,0 +1,47 @@ +// +// QUESTO è l'unico modulo autorizzato ad aprire insieme identity e longevity. +// La separazione dei due database è la garanzia che chi legge i dati clinici non +// risalga alla persona (richiesta del cliente, 21/08): un secondo punto di giunzione +// la annullerebbe in silenzio. Un test in tests/longevity/anagrafica.test.ts lo verifica. +import type Database from 'better-sqlite3'; + +export function creaCliente( + identity: Database.Database, + longevity: Database.Database, + dati: { + nome: string; cognome: string; sesso: 'M' | 'F'; + data_nascita?: string; email?: string; telefono?: string; user_id?: number; + } +): string { + const ultimo = identity.prepare( + `SELECT client_code FROM clienti ORDER BY client_code DESC LIMIT 1` + ).get() as { client_code: string } | undefined; + const n = ultimo ? Number(ultimo.client_code.slice(4)) + 1 : 1; + const code = `ISL-${String(n).padStart(4, '0')}`; + + identity.prepare( + `INSERT INTO clienti (client_code, user_id, nome, cognome, data_nascita, email, telefono) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run(code, dati.user_id ?? null, dati.nome, dati.cognome, + dati.data_nascita ?? null, dati.email ?? null, dati.telefono ?? null); + + longevity.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, ?)`).run(code, dati.sesso); + + return code; +} + +export function codicePerUtente(identity: Database.Database, userId: number): string | null { + const r = identity.prepare(`SELECT client_code FROM clienti WHERE user_id = ?`).get(userId) as + { client_code: string } | undefined; + return r?.client_code ?? null; +} + +/** L'eta' al momento della misura: è ciò che serve al motore, e non è un quasi-identificatore. */ +export function etaAllaData(dataNascita: string, alla: string): number { + const n = new Date(dataNascita); + const d = new Date(alla); + let eta = d.getFullYear() - n.getFullYear(); + const m = d.getMonth() - n.getMonth(); + if (m < 0 || (m === 0 && d.getDate() < n.getDate())) eta--; + return eta; +} diff --git a/tests/longevity/anagrafica.test.ts b/tests/longevity/anagrafica.test.ts new file mode 100644 index 0000000..1114c7c --- /dev/null +++ b/tests/longevity/anagrafica.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { createLongevityDb, createIdentityDb } from '../../src/lib/longevity/db'; +import { creaCliente, codicePerUtente, etaAllaData } from '../../src/lib/longevity/anagrafica'; + +describe('anagrafica pseudonimizzata', () => { + it('il nome sta in identity, il sesso in longevity, e i due non si mescolano', () => { + const id = createIdentityDb(':memory:'); + const lg = createLongevityDb(':memory:'); + const code = creaCliente(id, lg, { nome: 'Mario', cognome: 'Rossi', sesso: 'M', data_nascita: '1988-03-04' }); + + const inIdentity = id.prepare(`SELECT nome, cognome FROM clienti WHERE client_code = ?`).get(code) as + { nome: string; cognome: string }; + expect(inIdentity.nome).toBe('Mario'); + + const inLongevity = lg.prepare(`SELECT sesso FROM soggetti WHERE client_code = ?`).get(code) as { sesso: string }; + expect(inLongevity.sesso).toBe('M'); + + // in longevity non deve esistere nessuna colonna che contenga il nome + const dump = JSON.stringify(lg.prepare(`SELECT * FROM soggetti`).all()); + expect(dump).not.toContain('Mario'); + expect(dump).not.toContain('Rossi'); + expect(dump).not.toContain('1988-03-04'); + }); + + it('assegna codici progressivi e non riusa i vecchi', () => { + const id = createIdentityDb(':memory:'); + const lg = createLongevityDb(':memory:'); + const a = creaCliente(id, lg, { nome: 'A', cognome: 'A', sesso: 'F' }); + const b = creaCliente(id, lg, { nome: 'B', cognome: 'B', sesso: 'M' }); + expect(a).toMatch(/^ISL-\d{4}$/); + expect(b).not.toBe(a); + }); + + it('ritrova il codice a partire dall utente del sito', () => { + const id = createIdentityDb(':memory:'); + const lg = createLongevityDb(':memory:'); + const code = creaCliente(id, lg, { nome: 'C', cognome: 'C', sesso: 'F', user_id: 42 }); + expect(codicePerUtente(id, 42)).toBe(code); + expect(codicePerUtente(id, 99)).toBeNull(); + }); + + it('calcola l eta alla data della sessione', () => { + expect(etaAllaData('1988-03-04', '2026-08-21')).toBe(38); + expect(etaAllaData('1988-12-31', '2026-08-21')).toBe(37); // compleanno non ancora passato + }); + + it('nessun altro modulo apre entrambe le connessioni', () => { + const dir = join(process.cwd(), 'src/lib/longevity'); + const colpevoli: string[] = []; + for (const f of readdirSync(dir)) { + if (!f.endsWith('.ts') || f === 'anagrafica.ts' || f === 'db.ts') continue; + const src = readFileSync(join(dir, f), 'utf8'); + if (src.includes('createIdentityDb') && src.includes('createLongevityDb')) colpevoli.push(f); + } + expect(colpevoli).toEqual([]); + }); +}); From b7ceece4edc3900702ecac2e8ea1fd64b78363b4 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 21 Aug 2026 19:38:56 +0200 Subject: [PATCH 11/62] test(anagrafica): allarga la scansione a tutto src/ per rilevare giunzioni non autorizzate Rafforza il test che verifica l'isolamento tra identity e longevity. Invece di scandire solo src/lib/longevity/, ora scandisce ricorsivamente tutto src/ inclusi .astro e .ts, saltando node_modules e dist. Preserva le due sole esclusioni autorizzate: anagrafica.ts (il modulo autorizzato) e db.ts (dove le factory sono definite). In questo modo il test rileva violazioni anche da route API e pagine Astro, non solo da moduli della cartella longevity. Co-Authored-By: Claude Opus 5 (1M context) --- tests/longevity/anagrafica.test.ts | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/tests/longevity/anagrafica.test.ts b/tests/longevity/anagrafica.test.ts index 1114c7c..83ac0ce 100644 --- a/tests/longevity/anagrafica.test.ts +++ b/tests/longevity/anagrafica.test.ts @@ -46,13 +46,27 @@ describe('anagrafica pseudonimizzata', () => { expect(etaAllaData('1988-12-31', '2026-08-21')).toBe(37); // compleanno non ancora passato }); - it('nessun altro modulo apre entrambe le connessioni', () => { - const dir = join(process.cwd(), 'src/lib/longevity'); + it('nessun file in src/ (escluso anagrafica.ts e db.ts) apre entrambe le connessioni', () => { + const srcDir = join(process.cwd(), 'src'); const colpevoli: string[] = []; - for (const f of readdirSync(dir)) { - if (!f.endsWith('.ts') || f === 'anagrafica.ts' || f === 'db.ts') continue; - const src = readFileSync(join(dir, f), 'utf8'); - if (src.includes('createIdentityDb') && src.includes('createLongevityDb')) colpevoli.push(f); + + const files = readdirSync(srcDir, { recursive: true, withFileTypes: false }) as string[]; + for (const f of files) { + const path = join(srcDir, f); + + // Salta node_modules e dist + if (f.includes('node_modules') || f.includes('dist')) continue; + + // Accetta solo .ts e .astro + if (!f.endsWith('.ts') && !f.endsWith('.astro')) continue; + + // Esclude i moduli autorizzati + if (f === 'lib/longevity/anagrafica.ts' || f === 'lib/longevity/db.ts') continue; + + const src = readFileSync(path, 'utf8'); + if (src.includes('createIdentityDb') && src.includes('createLongevityDb')) { + colpevoli.push(f); + } } expect(colpevoli).toEqual([]); }); From 502ccb1585710ea9a4ca530db8d4ea747167b6bc Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 21 Aug 2026 19:41:45 +0200 Subject: [PATCH 12/62] longevity: ruoli cliente e trainer con le rispettive rotte --- src/lib/auth.ts | 9 ++++++++- tests/longevity/ruoli.test.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 tests/longevity/ruoli.test.ts diff --git a/src/lib/auth.ts b/src/lib/auth.ts index c9db9d7..581e4e6 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -5,7 +5,7 @@ import { randomBytes } from 'node:crypto'; export const SESSION_COOKIE = 'session'; const SESSION_DAYS = 7; -export type Role = 'admin' | 'superuser' | 'user' | 'piattaforme'; +export type Role = 'admin' | 'superuser' | 'user' | 'piattaforme' | 'cliente' | 'trainer'; export function hashPassword(plain: string): string { return bcrypt.hashSync(plain, 12); @@ -62,6 +62,11 @@ const RULES: [RegExp, Role[]][] = [ // sotto /piattaforme. Stesso ruolo per entrambe. [/^\/campus(\/|$)/, ['admin', 'piattaforme']], [/^\/piattaforme(\/|$)/, ['admin', 'piattaforme']], + // Longevity: il cliente vede solo il proprio spazio, il gestionale è del trainer. + [/^\/longevity\/gestionale(\/|$)/, ['admin', 'trainer']], + [/^\/api\/longevity\/gestionale(\/|$)/, ['admin', 'trainer']], + [/^\/longevity(\/|$)/, ['admin', 'trainer', 'cliente']], + [/^\/api\/longevity(\/|$)/, ['admin', 'trainer', 'cliente']], ]; export function canAccessAdminPath(role: string, pathname: string): boolean { @@ -71,6 +76,8 @@ export function canAccessAdminPath(role: string, pathname: string): boolean { } // piattaforme vede solo le sue sezioni (coperte da RULES) e il logout if (role === 'piattaforme') return pathname === '/admin/logout'; + // cliente e trainer non sono utenti del sito: fuori dalle rotte elencate non passano. + if (role === 'cliente' || role === 'trainer') return pathname === '/admin/logout'; return true; // blog, upload, logout, showtags: tutti i loggati } diff --git a/tests/longevity/ruoli.test.ts b/tests/longevity/ruoli.test.ts new file mode 100644 index 0000000..ab7d080 --- /dev/null +++ b/tests/longevity/ruoli.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { canAccessAdminPath } from '../../src/lib/auth'; + +describe('accesso alle rotte longevity', () => { + it('il cliente entra nel proprio spazio', () => { + expect(canAccessAdminPath('cliente', '/longevity/io')).toBe(true); + }); + + it('il cliente NON entra nel gestionale', () => { + expect(canAccessAdminPath('cliente', '/longevity/gestionale')).toBe(false); + }); + + it('il trainer entra in entrambi', () => { + expect(canAccessAdminPath('trainer', '/longevity/io')).toBe(true); + expect(canAccessAdminPath('trainer', '/longevity/gestionale')).toBe(true); + }); + + it('il cliente non entra nelle altre piattaforme ne nel blog', () => { + expect(canAccessAdminPath('cliente', '/campus')).toBe(false); + expect(canAccessAdminPath('cliente', '/admin/content')).toBe(false); + expect(canAccessAdminPath('cliente', '/admin/posts')).toBe(false); + }); + + it('il ruolo piattaforme non eredita longevity', () => { + expect(canAccessAdminPath('piattaforme', '/longevity/gestionale')).toBe(false); + }); + + it('admin passa sempre', () => { + expect(canAccessAdminPath('admin', '/longevity/gestionale')).toBe(true); + }); +}); From 473c0d8c365944507f3a09fe502b06b924313910 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 21 Aug 2026 19:44:08 +0200 Subject: [PATCH 13/62] fix: isProtectedPath nel middleware, landingFor per cliente e trainer --- src/lib/auth.ts | 13 +++++++++++ src/middleware.ts | 9 ++------ tests/longevity/ruoli.test.ts | 41 ++++++++++++++++++++++++++++++++++- 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 581e4e6..8131b0d 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -124,5 +124,18 @@ export function isRole(v: unknown): v is Role { export function landingFor(role: string): string { if (role === 'superuser') return '/admin/content'; if (role === 'piattaforme') return '/piattaforme'; + if (role === 'cliente') return '/longevity/io'; + if (role === 'trainer') return '/longevity/gestionale'; return '/admin'; } + +export function isProtectedPath(pathname: string): boolean { + return ( + (pathname.startsWith('/admin') && pathname !== '/admin/login') || + pathname.startsWith('/api/admin') || + pathname.startsWith('/campus') || + pathname.startsWith('/piattaforme') || + pathname.startsWith('/longevity') || + pathname.startsWith('/api/longevity') + ); +} diff --git a/src/middleware.ts b/src/middleware.ts index a1517ee..58dc1c5 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,15 +1,10 @@ import { defineMiddleware } from 'astro:middleware'; import { getDb } from './lib/db'; -import { getSessionUser, SESSION_COOKIE, canAccessAdminPath, landingFor } from './lib/auth'; +import { getSessionUser, SESSION_COOKIE, canAccessAdminPath, landingFor, isProtectedPath } from './lib/auth'; export const onRequest = defineMiddleware((context, next) => { const { pathname } = context.url; - const isProtected = - (pathname.startsWith('/admin') && pathname !== '/admin/login') || - pathname.startsWith('/api/admin') || - pathname.startsWith('/campus') || - pathname.startsWith('/piattaforme'); - if (!isProtected) return next(); + if (!isProtectedPath(pathname)) return next(); const token = context.cookies.get(SESSION_COOKIE)?.value; const user = token ? getSessionUser(getDb(), token) : null; diff --git a/tests/longevity/ruoli.test.ts b/tests/longevity/ruoli.test.ts index ab7d080..3eb32ba 100644 --- a/tests/longevity/ruoli.test.ts +++ b/tests/longevity/ruoli.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { canAccessAdminPath } from '../../src/lib/auth'; +import { canAccessAdminPath, isProtectedPath, landingFor } from '../../src/lib/auth'; describe('accesso alle rotte longevity', () => { it('il cliente entra nel proprio spazio', () => { @@ -29,3 +29,42 @@ describe('accesso alle rotte longevity', () => { expect(canAccessAdminPath('admin', '/longevity/gestionale')).toBe(true); }); }); + +describe('isProtectedPath — quali rotte sono protette dal middleware', () => { + it('longevity è protetto', () => { + expect(isProtectedPath('/longevity/io')).toBe(true); + expect(isProtectedPath('/longevity/gestionale')).toBe(true); + expect(isProtectedPath('/api/longevity/qualcosa')).toBe(true); + }); + + it('i percorsi già esistenti restano protetti', () => { + expect(isProtectedPath('/admin')).toBe(true); + expect(isProtectedPath('/admin/content')).toBe(true); + expect(isProtectedPath('/api/admin/content')).toBe(true); + expect(isProtectedPath('/campus')).toBe(true); + expect(isProtectedPath('/piattaforme')).toBe(true); + }); + + it('il login e le pagine pubbliche non sono protetti', () => { + expect(isProtectedPath('/admin/login')).toBe(false); + expect(isProtectedPath('/blog')).toBe(false); + expect(isProtectedPath('/')).toBe(false); + }); +}); + +describe('landingFor — indirizzo di atterraggio per ruolo', () => { + it('cliente atterra su longevity/io', () => { + expect(landingFor('cliente')).toBe('/longevity/io'); + }); + + it('trainer atterra su longevity/gestionale', () => { + expect(landingFor('trainer')).toBe('/longevity/gestionale'); + }); + + it('i ruoli già esistenti restano uguali', () => { + expect(landingFor('superuser')).toBe('/admin/content'); + expect(landingFor('piattaforme')).toBe('/piattaforme'); + expect(landingFor('admin')).toBe('/admin'); + expect(landingFor('user')).toBe('/admin'); + }); +}); From d3abbf9bf7d9eee9171474fc2ffd5dac2f4a3d17 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 21 Aug 2026 19:53:12 +0200 Subject: [PATCH 14/62] task-5 fix: isRole, login redirect, create-user script per cliente e trainer --- scripts/create-user.mjs | 4 ++-- src/lib/auth.ts | 2 +- src/middleware.ts | 2 +- tests/longevity/ruoli.test.ts | 27 ++++++++++++++++++++++++++- 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/scripts/create-user.mjs b/scripts/create-user.mjs index 0dca403..3621a1a 100644 --- a/scripts/create-user.mjs +++ b/scripts/create-user.mjs @@ -8,8 +8,8 @@ if (!username || !password) { console.error('Uso: npm run create-user -- [role]'); process.exit(1); } -if (!['admin', 'superuser', 'user', 'campus'].includes(role)) { - console.error('Ruolo non valido. Ammessi: admin, superuser, user, campus'); +if (!['admin', 'superuser', 'user', 'piattaforme', 'cliente', 'trainer'].includes(role)) { + console.error('Ruolo non valido. Ammessi: admin, superuser, user, piattaforme, cliente, trainer'); process.exit(1); } const path = process.env.DB_PATH ?? 'data/insanitylab.db'; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 8131b0d..e98a2f0 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -118,7 +118,7 @@ export function randomPassword(): string { } export function isRole(v: unknown): v is Role { - return v === 'admin' || v === 'superuser' || v === 'user' || v === 'piattaforme'; + return v === 'admin' || v === 'superuser' || v === 'user' || v === 'piattaforme' || v === 'cliente' || v === 'trainer'; } export function landingFor(role: string): string { diff --git a/src/middleware.ts b/src/middleware.ts index 58dc1c5..c638615 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -16,7 +16,7 @@ export const onRequest = defineMiddleware((context, next) => { } // Le piattaforme riservate si raggiungono dal sito, quindi passano dal login // pubblico e ci tornano dopo l'accesso; il pannello ha il proprio. - if (pathname.startsWith('/campus') || pathname.startsWith('/piattaforme')) + if (pathname.startsWith('/campus') || pathname.startsWith('/piattaforme') || pathname.startsWith('/longevity')) return context.redirect(`/login?next=${encodeURIComponent(pathname)}`); return context.redirect('/admin/login'); } diff --git a/tests/longevity/ruoli.test.ts b/tests/longevity/ruoli.test.ts index 3eb32ba..fd5c3be 100644 --- a/tests/longevity/ruoli.test.ts +++ b/tests/longevity/ruoli.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { canAccessAdminPath, isProtectedPath, landingFor } from '../../src/lib/auth'; +import { canAccessAdminPath, isProtectedPath, landingFor, isRole } from '../../src/lib/auth'; describe('accesso alle rotte longevity', () => { it('il cliente entra nel proprio spazio', () => { @@ -21,6 +21,12 @@ describe('accesso alle rotte longevity', () => { expect(canAccessAdminPath('cliente', '/admin/posts')).toBe(false); }); + it('il trainer non entra nelle altre piattaforme ne nel blog', () => { + expect(canAccessAdminPath('trainer', '/campus')).toBe(false); + expect(canAccessAdminPath('trainer', '/admin/content')).toBe(false); + expect(canAccessAdminPath('trainer', '/admin/posts')).toBe(false); + }); + it('il ruolo piattaforme non eredita longevity', () => { expect(canAccessAdminPath('piattaforme', '/longevity/gestionale')).toBe(false); }); @@ -68,3 +74,22 @@ describe('landingFor — indirizzo di atterraggio per ruolo', () => { expect(landingFor('user')).toBe('/admin'); }); }); + +describe('isRole — validazione dei ruoli', () => { + it('cliente e trainer sono ruoli validi', () => { + expect(isRole('cliente')).toBe(true); + expect(isRole('trainer')).toBe(true); + }); + + it('i ruoli già esistenti restano validi', () => { + expect(isRole('admin')).toBe(true); + expect(isRole('superuser')).toBe(true); + expect(isRole('user')).toBe(true); + expect(isRole('piattaforme')).toBe(true); + }); + + it('campus e stringhe inventate non sono ruoli', () => { + expect(isRole('campus')).toBe(false); + expect(isRole('qualsiasi-cosa')).toBe(false); + }); +}); From f21b64fcc57b89faebe6c310b34802659d4f79cd Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 21 Aug 2026 19:57:35 +0200 Subject: [PATCH 15/62] longevity: salvataggio compilazione questionario, versionata --- src/lib/longevity/questionario.ts | 55 +++++++++++++++++++++++ tests/longevity/questionario.test.ts | 67 ++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 src/lib/longevity/questionario.ts create mode 100644 tests/longevity/questionario.test.ts diff --git a/src/lib/longevity/questionario.ts b/src/lib/longevity/questionario.ts new file mode 100644 index 0000000..5f1032a --- /dev/null +++ b/src/lib/longevity/questionario.ts @@ -0,0 +1,55 @@ +import type Database from 'better-sqlite3'; +import { apriSessione, registraMisure } from './misure'; +import { esisteTest } from './registro'; + +/** Versione del set di domande. Si incrementa quando cambia una domanda. */ +export const QUEST_VERSION = 'v1.0'; + +/** + * Campi testuali che si salvano ma non entrano MAI in una formula + * (indicazione esplicita del cliente, punto 7 della specifica). + */ +export const CAMPI_LIBERI = ['farmaci', 'problematiche_attuali', 'problematiche_pregresse', 'obiettivi']; + +export function salvaCompilazione( + db: Database.Database, + input: { + client_code: string; data: string; eta?: number; + risposte: Record; + liberi?: Record; + } +): number { + // Validare PRIMA di aprire la sessione: altrimenti un id sbagliato lascia + // in giro una sessione vuota. + for (const id of Object.keys(input.risposte)) { + if (!esisteTest(db, id)) throw new Error(`test_id non nel registro: ${id}`); + } + + const sessioneId = apriSessione(db, { + client_code: input.client_code, + data: input.data, + tipo: 'questionario', + eta_alla_data: input.eta, + quest_version: QUEST_VERSION, + }); + + registraMisure( + db, sessioneId, 'questionario', + Object.entries(input.risposte).map(([test_id, valore_num]) => ({ test_id, valore_num })) + ); + + if (input.liberi) { + const ins = db.prepare( + `INSERT INTO profilo_note (client_code, sessione_id, campo_id, testo) VALUES (?, ?, ?, ?)` + ); + const tx = db.transaction(() => { + for (const [campo, testo] of Object.entries(input.liberi!)) { + if (testo.trim() === '') continue; + ins.run(input.client_code, sessioneId, campo, testo); + } + }); + tx(); + } + + return sessioneId; +} diff --git a/tests/longevity/questionario.test.ts b/tests/longevity/questionario.test.ts new file mode 100644 index 0000000..dcf65ad --- /dev/null +++ b/tests/longevity/questionario.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; +import { createLongevityDb } from '../../src/lib/longevity/db'; +import { seedRegistro } from '../../src/lib/longevity/registro'; +import { salvaCompilazione, QUEST_VERSION } from '../../src/lib/longevity/questionario'; + +function dbPronto() { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run(); + return db; +} + +describe('compilazione del questionario', () => { + it('salva le risposte punteggiate come misure', () => { + const db = dbPronto(); + const s = salvaCompilazione(db, { + client_code: 'ISL-0001', data: '2026-08-21', + risposte: { q_ore_sonno: 7.5, q_riposato: 8, q_alcol_life: 3 }, + }); + const n = db.prepare(`SELECT COUNT(*) n FROM misure WHERE sessione_id = ? AND fonte = 'questionario'`) + .get(s) as { n: number }; + expect(n.n).toBe(3); + }); + + it('marchia la compilazione con la versione del questionario', () => { + const db = dbPronto(); + const s = salvaCompilazione(db, { client_code: 'ISL-0001', data: '2026-08-21', risposte: { q_riposato: 6 } }); + const row = db.prepare(`SELECT tipo, quest_version FROM sessioni WHERE id = ?`).get(s) as + { tipo: string; quest_version: string }; + expect(row.tipo).toBe('questionario'); + expect(row.quest_version).toBe(QUEST_VERSION); + }); + + it('i campi liberi vanno in profilo_note, non fra le misure', () => { + const db = dbPronto(); + const s = salvaCompilazione(db, { + client_code: 'ISL-0001', data: '2026-08-21', + risposte: { q_riposato: 6 }, + liberi: { farmaci: 'nessuno', obiettivi: 'dormire meglio' }, + }); + const note = db.prepare(`SELECT campo_id, testo FROM profilo_note WHERE sessione_id = ? ORDER BY campo_id`) + .all(s) as { campo_id: string; testo: string }[]; + expect(note.map((n) => n.campo_id)).toEqual(['farmaci', 'obiettivi']); + const misure = db.prepare(`SELECT test_id FROM misure WHERE sessione_id = ?`).all(s) as { test_id: string }[]; + expect(misure.map((m) => m.test_id)).toEqual(['q_riposato']); + }); + + it('ogni compilazione e un record nuovo, non un aggiornamento', () => { + const db = dbPronto(); + salvaCompilazione(db, { client_code: 'ISL-0001', data: '2026-08-21', risposte: { q_riposato: 6 } }); + salvaCompilazione(db, { client_code: 'ISL-0001', data: '2026-09-21', risposte: { q_riposato: 9 } }); + const n = db.prepare(`SELECT COUNT(*) n FROM sessioni WHERE tipo = 'questionario'`).get() as { n: number }; + expect(n.n).toBe(2); + }); + + it('una risposta con id sconosciuto fa fallire la compilazione intera', () => { + const db = dbPronto(); + expect(() => + salvaCompilazione(db, { + client_code: 'ISL-0001', data: '2026-08-21', + risposte: { q_riposato: 6, q_domanda_nuova: 3 }, + }) + ).toThrow(/q_domanda_nuova/); + const n = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number }; + expect(n.n).toBe(0); + }); +}); From 91e6487da37493c2e79893c4960c1d815f6c5559 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 21 Aug 2026 20:02:23 +0200 Subject: [PATCH 16/62] =?UTF-8?q?longevity:=20atomicit=C3=A0=20compilazion?= =?UTF-8?q?e=20questionario=20con=20transazione=20unica?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/longevity/questionario.ts | 49 +++++++++++++++------------- tests/longevity/questionario.test.ts | 20 ++++++++++++ 2 files changed, 46 insertions(+), 23 deletions(-) diff --git a/src/lib/longevity/questionario.ts b/src/lib/longevity/questionario.ts index 5f1032a..3bbfb06 100644 --- a/src/lib/longevity/questionario.ts +++ b/src/lib/longevity/questionario.ts @@ -19,37 +19,40 @@ export function salvaCompilazione( liberi?: Record; } ): number { - // Validare PRIMA di aprire la sessione: altrimenti un id sbagliato lascia - // in giro una sessione vuota. + // Validare PRIMA della transazione: se un test_id è sconosciuto, non apriamo + // nemmeno una sessione. La validazione non è nel corpo della transazione. for (const id of Object.keys(input.risposte)) { if (!esisteTest(db, id)) throw new Error(`test_id non nel registro: ${id}`); } - const sessioneId = apriSessione(db, { - client_code: input.client_code, - data: input.data, - tipo: 'questionario', - eta_alla_data: input.eta, - quest_version: QUEST_VERSION, - }); + // Avvolgi le tre scritture (apertura sessione, registra misure, inserisci note) + // in un'unica transazione: tutto o niente, senza tracce parziali su dati sanitari. + const tx = db.transaction(() => { + const sessioneId = apriSessione(db, { + client_code: input.client_code, + data: input.data, + tipo: 'questionario', + eta_alla_data: input.eta, + quest_version: QUEST_VERSION, + }); - registraMisure( - db, sessioneId, 'questionario', - Object.entries(input.risposte).map(([test_id, valore_num]) => ({ test_id, valore_num })) - ); - - if (input.liberi) { - const ins = db.prepare( - `INSERT INTO profilo_note (client_code, sessione_id, campo_id, testo) VALUES (?, ?, ?, ?)` + registraMisure( + db, sessioneId, 'questionario', + Object.entries(input.risposte).map(([test_id, valore_num]) => ({ test_id, valore_num })) ); - const tx = db.transaction(() => { - for (const [campo, testo] of Object.entries(input.liberi!)) { + + if (input.liberi) { + const ins = db.prepare( + `INSERT INTO profilo_note (client_code, sessione_id, campo_id, testo) VALUES (?, ?, ?, ?)` + ); + for (const [campo, testo] of Object.entries(input.liberi)) { if (testo.trim() === '') continue; ins.run(input.client_code, sessioneId, campo, testo); } - }); - tx(); - } + } - return sessioneId; + return sessioneId; + }); + + return tx(); } diff --git a/tests/longevity/questionario.test.ts b/tests/longevity/questionario.test.ts index dcf65ad..a7a590c 100644 --- a/tests/longevity/questionario.test.ts +++ b/tests/longevity/questionario.test.ts @@ -64,4 +64,24 @@ describe('compilazione del questionario', () => { const n = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number }; expect(n.n).toBe(0); }); + + it('fallimento a metà non lascia traccia: sessione, misure e note sono atomiche', () => { + const db = dbPronto(); + // Passa null nel campo libero per provocare un errore NOT NULL nella tabella profilo_note. + // Questo forza il fallimento DOPO aver aperto la sessione e registrato le misure. + expect(() => + salvaCompilazione(db, { + client_code: 'ISL-0001', data: '2026-08-21', + risposte: { q_riposato: 6 }, + liberi: { farmaci: null as any }, // Tipo violato: NOT NULL + }) + ).toThrow(); + // Verifica che la transazione sia stata rollback: niente sessione, niente misure, niente note. + const sessioni = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number }; + const misure = db.prepare(`SELECT COUNT(*) n FROM misure`).get() as { n: number }; + const note = db.prepare(`SELECT COUNT(*) n FROM profilo_note`).get() as { n: number }; + expect(sessioni.n).toBe(0); + expect(misure.n).toBe(0); + expect(note.n).toBe(0); + }); }); From 2024c329659107677739ed956782537d16c2fb98 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 21 Aug 2026 20:05:19 +0200 Subject: [PATCH 17/62] longevity: export pseudonimizzato per costruzione --- src/lib/longevity/export.ts | 36 +++++++++++++++++++++++++ tests/longevity/export.test.ts | 49 ++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 src/lib/longevity/export.ts create mode 100644 tests/longevity/export.test.ts diff --git a/src/lib/longevity/export.ts b/src/lib/longevity/export.ts new file mode 100644 index 0000000..34b0919 --- /dev/null +++ b/src/lib/longevity/export.ts @@ -0,0 +1,36 @@ +// +// L'export per le statistiche legge SOLO longevity.db, che per costruzione non contiene +// nomi: non serve nessuna funzione di anonimizzazione, e non c'è niente da ricordarsi. +// I campi liberi (farmaci, patologie) restano fuori: sono per il coach, non per l'analisi. +import type Database from 'better-sqlite3'; + +const INTESTAZIONI = [ + 'client_code', 'sesso', 'eta', 'data', 'tipo_sessione', + 'quest_version', 'test_id', 'valore', 'unita', 'fonte', 'fuori_range', +]; + +export function esportaMisure(db: Database.Database): { + intestazioni: string[]; + righe: (string | number | null)[][]; +} { + const righe = db.prepare( + `SELECT sg.client_code, sg.sesso, s.eta_alla_data, s.data, s.tipo, s.quest_version, + m.test_id, COALESCE(m.valore_num, m.valore_txt) AS valore, m.unita, m.fonte, m.fuori_range + FROM misure m + JOIN sessioni s ON s.id = m.sessione_id + JOIN soggetti sg ON sg.client_code = m.client_code + ORDER BY s.data, m.id` + ).raw().all() as (string | number | null)[][]; + + return { intestazioni: INTESTAZIONI, righe }; +} + +export function esportaCsv(db: Database.Database): string { + const { intestazioni, righe } = esportaMisure(db); + const cella = (v: string | number | null) => { + if (v === null) return ''; + const s = String(v); + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; + }; + return [intestazioni.join(','), ...righe.map((r) => r.map(cella).join(','))].join('\n') + '\n'; +} diff --git a/tests/longevity/export.test.ts b/tests/longevity/export.test.ts new file mode 100644 index 0000000..577c76d --- /dev/null +++ b/tests/longevity/export.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { createLongevityDb } from '../../src/lib/longevity/db'; +import { seedRegistro } from '../../src/lib/longevity/registro'; +import { salvaCompilazione } from '../../src/lib/longevity/questionario'; +import { esportaMisure, esportaCsv } from '../../src/lib/longevity/export'; + +function dbConDati() { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run(); + salvaCompilazione(db, { + client_code: 'ISL-0001', data: '2026-08-21', eta: 35, + risposte: { q_ore_sonno: 7, q_riposato: 8 }, + liberi: { farmaci: 'nessuno' }, + }); + return db; +} + +describe('export per le statistiche', () => { + it('esporta una riga per misura con codice, non con nome', () => { + const db = dbConDati(); + const out = esportaMisure(db); + expect(out.intestazioni).toEqual( + ['client_code', 'sesso', 'eta', 'data', 'tipo_sessione', 'quest_version', 'test_id', 'valore', 'unita', 'fonte', 'fuori_range'] + ); + expect(out.righe.length).toBe(2); + expect(out.righe[0][0]).toBe('ISL-0001'); + }); + + it('l export non contiene campi identificativi', () => { + const db = dbConDati(); + const testo = JSON.stringify(esportaMisure(db)); + for (const vietato of ['nome', 'cognome', 'email', 'telefono', 'data_nascita']) { + expect(testo).not.toContain(vietato); + } + }); + + it('i campi liberi restano fuori dall export statistico', () => { + const db = dbConDati(); + expect(JSON.stringify(esportaMisure(db))).not.toContain('nessuno'); + }); + + it('produce un CSV con intestazione e una riga per misura', () => { + const db = dbConDati(); + const righe = esportaCsv(db).trim().split('\n'); + expect(righe.length).toBe(3); // intestazione + 2 misure + expect(righe[0]).toBe('client_code,sesso,eta,data,tipo_sessione,quest_version,test_id,valore,unita,fonte,fuori_range'); + }); +}); From e6c2ac91484a04b91dec94efbfb0f43e9dc387a2 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 21 Aug 2026 20:08:12 +0200 Subject: [PATCH 18/62] =?UTF-8?q?longevity:=20test=20atomicit=C3=A0=20corr?= =?UTF-8?q?etto=20-=20verifica=20rollback=20anche=20dei=20campi=20liberi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/longevity/questionario.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/longevity/questionario.test.ts b/tests/longevity/questionario.test.ts index a7a590c..81024e8 100644 --- a/tests/longevity/questionario.test.ts +++ b/tests/longevity/questionario.test.ts @@ -67,16 +67,19 @@ describe('compilazione del questionario', () => { it('fallimento a metà non lascia traccia: sessione, misure e note sono atomiche', () => { const db = dbPronto(); - // Passa null nel campo libero per provocare un errore NOT NULL nella tabella profilo_note. - // Questo forza il fallimento DOPO aver aperto la sessione e registrato le misure. + // Passa due campi liberi: il primo valido (farmaci), il secondo con null (obiettivi). + // Il primo insert su profilo_note avviene (per farmaci: 'nessuno'), poi fallisce su + // obiettivi quando tenta di eseguire testo.trim() su un null, lanciando un TypeError. + // Il rollback della transazione deve annullare anche il primo insert già avvenuto. expect(() => salvaCompilazione(db, { client_code: 'ISL-0001', data: '2026-08-21', risposte: { q_riposato: 6 }, - liberi: { farmaci: null as any }, // Tipo violato: NOT NULL + liberi: { farmaci: 'nessuno', obiettivi: null as any }, // null causa TypeError su .trim() }) ).toThrow(); // Verifica che la transazione sia stata rollback: niente sessione, niente misure, niente note. + // Se il rollback non funzionasse, almeno il record di farmaci resterebbe in profilo_note. const sessioni = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number }; const misure = db.prepare(`SELECT COUNT(*) n FROM misure`).get() as { n: number }; const note = db.prepare(`SELECT COUNT(*) n FROM profilo_note`).get() as { n: number }; From 763e5611e6556b132d70802eac7282ce26458122 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 21 Aug 2026 20:30:23 +0200 Subject: [PATCH 19/62] longevity: revisione finale - codice cliente non riusabile, ruoli allineati, middleware verificato - creaCliente: il codice si deduce dal massimo fra identity E longevity (non piu' solo identity), cosi' un client_code cancellato dall'anagrafica non torna mai disponibile e non si attribuiscono le misure di un vecchio cliente a uno nuovo. Compensazione se la scrittura su longevity fallisce dopo quella su identity. - ROLES unica fonte in auth.ts (Role e isRole derivati); users.astro e users/new.astro usano quella lista invece di array scritti a mano che dimenticavano cliente/trainer. - Commento falso su cosa protegge il middleware, riscritto: rimanda a isProtectedPath. - vitest.config.ts: alias per astro:middleware (stesso bersaglio della pipeline vite di Astro), cosi' src/middleware.ts e' finalmente importabile e testabile in isolamento - prima nessun test lo caricava davvero. - export.test.ts: asserzione posizionale per colonna, non piu' solo intestazioni scritte a mano; intercetta un riordino della SELECT che disallinea etichette e valori. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/create-user.mjs | 2 + src/lib/auth.ts | 15 ++++-- src/lib/longevity/anagrafica.ts | 38 +++++++++++--- src/pages/admin/users.astro | 4 +- src/pages/admin/users/new.astro | 17 ++++-- tests/longevity/anagrafica.test.ts | 42 +++++++++++++++ tests/longevity/export.test.ts | 36 +++++++++++++ tests/longevity/middleware.test.ts | 83 ++++++++++++++++++++++++++++++ tests/longevity/ruoli.test.ts | 30 ++++++++++- vitest.config.ts | 10 ++++ 10 files changed, 260 insertions(+), 17 deletions(-) create mode 100644 tests/longevity/middleware.test.ts diff --git a/scripts/create-user.mjs b/scripts/create-user.mjs index 3621a1a..7721995 100644 --- a/scripts/create-user.mjs +++ b/scripts/create-user.mjs @@ -8,6 +8,8 @@ if (!username || !password) { console.error('Uso: npm run create-user -- [role]'); process.exit(1); } +// Lista duplicata di proposito: questo file è JavaScript e non può importare da +// src/lib/auth.ts. La fonte vera è ROLES in src/lib/auth.ts - se cambia là, va allineata qui. if (!['admin', 'superuser', 'user', 'piattaforme', 'cliente', 'trainer'].includes(role)) { console.error('Ruolo non valido. Ammessi: admin, superuser, user, piattaforme, cliente, trainer'); process.exit(1); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index e98a2f0..8d37399 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -5,7 +5,13 @@ import { randomBytes } from 'node:crypto'; export const SESSION_COOKIE = 'session'; const SESSION_DAYS = 7; -export type Role = 'admin' | 'superuser' | 'user' | 'piattaforme' | 'cliente' | 'trainer'; +// Unica fonte dei ruoli validi: da qui si derivano il tipo Role e la validazione isRole, +// così aggiungere un ruolo in futuro è una modifica sola. Prima erano quattro punti +// indipendenti - questo tipo, isRole, la tendina di admin/users.astro e create-user.mjs - +// ed è già andato fuori sincrono due volte in questo stesso ramo (create-user.mjs resta +// duplicato: è JavaScript, non può importare da qui, vedi il commento lì). +export const ROLES = ['user', 'superuser', 'piattaforme', 'admin', 'cliente', 'trainer'] as const; +export type Role = typeof ROLES[number]; export function hashPassword(plain: string): string { return bcrypt.hashSync(plain, 12); @@ -51,8 +57,9 @@ export function logout(db: Database.Database, token: string): void { // Autorizzazione per prefisso di rotta. L'admin passa sempre; per gli altri, la prima regola // che matcha decide; se nessuna regola matcha la rotta è "blog/upload/logout" → consentita a -// qualsiasi loggato (il middleware protegge solo /admin e /api/admin, quindi qui arrivano solo -// utenti già autenticati). +// qualsiasi loggato. L'elenco vero di cosa il middleware protegge (non solo /admin e +// /api/admin: anche /campus, /piattaforme e /longevity) vive in isProtectedPath, più sotto: +// qui arrivano solo utenti già autenticati su una di quelle rotte. const RULES: [RegExp, Role[]][] = [ [/^\/admin\/users(\/|$)/, ['admin']], [/^\/api\/admin\/users(\/|$)/, ['admin']], @@ -118,7 +125,7 @@ export function randomPassword(): string { } export function isRole(v: unknown): v is Role { - return v === 'admin' || v === 'superuser' || v === 'user' || v === 'piattaforme' || v === 'cliente' || v === 'trainer'; + return (ROLES as readonly unknown[]).includes(v); } export function landingFor(role: string): string { diff --git a/src/lib/longevity/anagrafica.ts b/src/lib/longevity/anagrafica.ts index 4e26c63..5d60a0e 100644 --- a/src/lib/longevity/anagrafica.ts +++ b/src/lib/longevity/anagrafica.ts @@ -5,6 +5,28 @@ // la annullerebbe in silenzio. Un test in tests/longevity/anagrafica.test.ts lo verifica. import type Database from 'better-sqlite3'; +/** + * Il prossimo codice libero: il massimo fra quelli già usati in identity.clienti E in + * longevity.soggetti. Dedurlo da una sola metà è il bug che permette di riassegnare il + * codice di un cliente cancellato dall'anagrafica (le sue misure restano in longevity) + * a una persona nuova: qui non torna mai disponibile. + */ +function prossimoCodice(identity: Database.Database, longevity: Database.Database): string { + const numero = (code: string) => Number(code.slice(4)); + const ultimoIdentity = identity.prepare( + `SELECT client_code FROM clienti ORDER BY client_code DESC LIMIT 1` + ).get() as { client_code: string } | undefined; + const ultimoLongevity = longevity.prepare( + `SELECT client_code FROM soggetti ORDER BY client_code DESC LIMIT 1` + ).get() as { client_code: string } | undefined; + + const max = Math.max( + ultimoIdentity ? numero(ultimoIdentity.client_code) : 0, + ultimoLongevity ? numero(ultimoLongevity.client_code) : 0, + ); + return `ISL-${String(max + 1).padStart(4, '0')}`; +} + export function creaCliente( identity: Database.Database, longevity: Database.Database, @@ -13,11 +35,7 @@ export function creaCliente( data_nascita?: string; email?: string; telefono?: string; user_id?: number; } ): string { - const ultimo = identity.prepare( - `SELECT client_code FROM clienti ORDER BY client_code DESC LIMIT 1` - ).get() as { client_code: string } | undefined; - const n = ultimo ? Number(ultimo.client_code.slice(4)) + 1 : 1; - const code = `ISL-${String(n).padStart(4, '0')}`; + const code = prossimoCodice(identity, longevity); identity.prepare( `INSERT INTO clienti (client_code, user_id, nome, cognome, data_nascita, email, telefono) @@ -25,7 +43,15 @@ export function creaCliente( ).run(code, dati.user_id ?? null, dati.nome, dati.cognome, dati.data_nascita ?? null, dati.email ?? null, dati.telefono ?? null); - longevity.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, ?)`).run(code, dati.sesso); + try { + longevity.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, ?)`).run(code, dati.sesso); + } catch (err) { + // Compensazione: due database distinti, quindi nessuna transazione unica possibile. + // Se longevity rifiuta il cliente non deve restarne uno orfano in identity, senza + // corrispettivo nell'altro database. + identity.prepare(`DELETE FROM clienti WHERE client_code = ?`).run(code); + throw err; + } return code; } diff --git a/src/pages/admin/users.astro b/src/pages/admin/users.astro index bd4426c..0e6d4f5 100644 --- a/src/pages/admin/users.astro +++ b/src/pages/admin/users.astro @@ -1,7 +1,7 @@ --- import Admin from '../../layouts/Admin.astro'; import { getDb } from '../../lib/db'; -import { listUsers } from '../../lib/auth'; +import { listUsers, ROLES } from '../../lib/auth'; export const prerender = false; const users = listUsers(getDb()); const me = Astro.locals.user!; @@ -45,7 +45,7 @@ const me = Astro.locals.user!; {u.username}{u.id === me.id && ' (tu)'} diff --git a/src/pages/admin/users/new.astro b/src/pages/admin/users/new.astro index 92d32bb..d495d73 100644 --- a/src/pages/admin/users/new.astro +++ b/src/pages/admin/users/new.astro @@ -2,7 +2,19 @@ // Creazione di un utente, su pagina propria come per gli articoli: l'elenco resta pulito e // il modulo ha spazio per le sue spiegazioni. import Admin from '../../../layouts/Admin.astro'; +import { ROLES } from '../../../lib/auth'; export const prerender = false; + +// Descrizioni solo per la UI: se un ruolo nuovo non è qui, compare comunque nella tendina +// (fonte di validità unica: ROLES), semplicemente senza spiegazione accanto. +const DESCRIZIONI: Partial> = { + user: 'scrive i propri articoli', + superuser: 'articoli e contenuti del sito', + piattaforme: 'solo Campus e Stress Index', + admin: 'accesso completo', + cliente: 'solo il proprio fascicolo Longevity', + trainer: 'gestionale Longevity', +}; ---

Nuovo utente

@@ -17,10 +29,7 @@ export const prerender = false;
diff --git a/tests/longevity/anagrafica.test.ts b/tests/longevity/anagrafica.test.ts index 83ac0ce..bf232be 100644 --- a/tests/longevity/anagrafica.test.ts +++ b/tests/longevity/anagrafica.test.ts @@ -46,6 +46,48 @@ describe('anagrafica pseudonimizzata', () => { expect(etaAllaData('1988-12-31', '2026-08-21')).toBe(37); // compleanno non ancora passato }); + it('un codice cancellato dall anagrafica non torna mai disponibile: le misure del vecchio non si attribuiscono al nuovo', () => { + const id = createIdentityDb(':memory:'); + const lg = createLongevityDb(':memory:'); + const a = creaCliente(id, lg, { nome: 'A', cognome: 'A', sesso: 'F' }); + const b = creaCliente(id, lg, { nome: 'B', cognome: 'B', sesso: 'M' }); + + // Il secondo cliente viene cancellato dall'anagrafica (disdetta, richiesta di + // cancellazione, errore): le sue misure restano in longevity, pseudonimizzate. + id.prepare(`DELETE FROM clienti WHERE client_code = ?`).run(b); + lg.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES (?, '2026-08-01', 'checkup')`).run(b); + + const c = creaCliente(id, lg, { nome: 'C', cognome: 'C', sesso: 'F' }); + + // Il terzo cliente riceve un codice nuovo, mai il codice riciclato del secondo. + expect(c).not.toBe(b); + expect(c).not.toBe(a); + + // Le sessioni del vecchio cliente (b) restano leggibili sotto il suo codice... + const sessioniDiB = lg.prepare(`SELECT * FROM sessioni WHERE client_code = ?`).all(b); + expect(sessioniDiB.length).toBe(1); + + // ...e non risultano in nessun modo attribuite al nuovo cliente (c). + const sessioniDiC = lg.prepare(`SELECT * FROM sessioni WHERE client_code = ?`).all(c); + expect(sessioniDiC.length).toBe(0); + }); + + it('se la scrittura su longevity fallisce, quella gia fatta su identity viene annullata', () => { + const id = createIdentityDb(':memory:'); + const lg = createLongevityDb(':memory:'); + const primaDelTentativo = (id.prepare(`SELECT COUNT(*) AS n FROM clienti`).get() as { n: number }).n; + + // 'Z' non è un sesso valido (CHECK IN ('M','F') su longevity.soggetti): la scrittura + // su longevity fallisce per costruzione, dopo che quella su identity è già avvenuta. + expect(() => + creaCliente(id, lg, { nome: 'Errato', cognome: 'Errato', sesso: 'Z' as unknown as 'M' | 'F' }) + ).toThrow(); + + const dopoIlTentativo = (id.prepare(`SELECT COUNT(*) AS n FROM clienti`).get() as { n: number }).n; + expect(dopoIlTentativo).toBe(primaDelTentativo); // nessun cliente orfano rimasto in identity + expect(id.prepare(`SELECT * FROM clienti WHERE nome = 'Errato'`).get()).toBeUndefined(); + }); + it('nessun file in src/ (escluso anagrafica.ts e db.ts) apre entrambe le connessioni', () => { const srcDir = join(process.cwd(), 'src'); const colpevoli: string[] = []; diff --git a/tests/longevity/export.test.ts b/tests/longevity/export.test.ts index 577c76d..98244e5 100644 --- a/tests/longevity/export.test.ts +++ b/tests/longevity/export.test.ts @@ -46,4 +46,40 @@ describe('export per le statistiche', () => { expect(righe.length).toBe(3); // intestazione + 2 misure expect(righe[0]).toBe('client_code,sesso,eta,data,tipo_sessione,quest_version,test_id,valore,unita,fonte,fuori_range'); }); + + it('ogni valore sta sotto l intestazione giusta, colonna per colonna (asserzione posizionale)', () => { + // Non un indice scritto a mano: l'indice si cerca per NOME nell'intestazione prodotta. + // Se un domani la SELECT in export.ts viene riordinata senza riallineare INTESTAZIONI, + // il valore letto a quell'indice smette di corrispondere e l'asserzione fallisce - + // invece di restare verde con l'età etichettata come sesso. + const db = dbConDati(); + const { intestazioni, righe } = esportaMisure(db); + const idx = (col: string) => { + const i = intestazioni.indexOf(col); + if (i < 0) throw new Error(`intestazione mancante: ${col}`); + return i; + }; + + const rigaSonno = righe.find((r) => r[idx('test_id')] === 'q_ore_sonno')!; + expect(rigaSonno).toBeDefined(); + expect(rigaSonno[idx('client_code')]).toBe('ISL-0001'); + expect(rigaSonno[idx('sesso')]).toBe('F'); + expect(rigaSonno[idx('eta')]).toBe(35); + expect(rigaSonno[idx('data')]).toBe('2026-08-21'); + expect(rigaSonno[idx('tipo_sessione')]).toBe('questionario'); + expect(rigaSonno[idx('quest_version')]).toBe('v1.0'); + expect(rigaSonno[idx('valore')]).toBe(7); + // salvaCompilazione non passa 'unita' per le risposte del questionario: resta null. + expect(rigaSonno[idx('unita')]).toBeNull(); + expect(rigaSonno[idx('fonte')]).toBe('questionario'); + expect(rigaSonno[idx('fuori_range')]).toBe(0); + + const rigaRiposato = righe.find((r) => r[idx('test_id')] === 'q_riposato')!; + expect(rigaRiposato).toBeDefined(); + expect(rigaRiposato[idx('client_code')]).toBe('ISL-0001'); + expect(rigaRiposato[idx('sesso')]).toBe('F'); + expect(rigaRiposato[idx('eta')]).toBe(35); + expect(rigaRiposato[idx('valore')]).toBe(8); + expect(rigaRiposato[idx('unita')]).toBeNull(); + }); }); diff --git a/tests/longevity/middleware.test.ts b/tests/longevity/middleware.test.ts new file mode 100644 index 0000000..1430b9c --- /dev/null +++ b/tests/longevity/middleware.test.ts @@ -0,0 +1,83 @@ +// Il difetto più serio di questo ramo era "una funzione di autorizzazione corretta che +// nessuno chiama": isProtectedPath e canAccessAdminPath erano giuste, ma finché niente le +// collegava dentro src/middleware.ts le pagine sanitarie restavano pubbliche. Nessun altro +// test in tutto il repo importa src/middleware.ts: qui lo si carica davvero (vedi l'alias +// 'astro:middleware' in vitest.config.ts, necessario perché quel modulo è virtuale e +// risolto solo dentro la pipeline vite di Astro) e si esercita la funzione vera, non una +// sua reimplementazione. +import { describe, it, expect, beforeAll } from 'vitest'; + +// getDb() (usato da src/middleware.ts) apre 'data/insanitylab.db' su disco per default: +// DB_PATH va puntato a :memory: PRIMA che qualcosa lo chiami (il singleton è pigro). +process.env.DB_PATH = ':memory:'; + +type FakeContext = { + url: URL; + cookies: { get: (name: string) => { value: string } | undefined }; + redirect: (path: string, status?: number) => Response; + locals: Record; +}; + +function contesto(pathname: string, cookieValue?: string): FakeContext { + return { + url: new URL(`http://localhost${pathname}`), + cookies: { get: () => (cookieValue !== undefined ? { value: cookieValue } : undefined) }, + redirect: (path, status) => new Response(null, { status: status ?? 302, headers: { Location: path } }), + locals: {}, + }; +} + +function next() { + let chiamata = false; + const fn = async () => { chiamata = true; return new Response('PAGINA-SERVITA'); }; + return { fn, fuChiamata: () => chiamata }; +} + +describe('src/middleware.ts — protezione reale delle rotte longevity', () => { + let onRequest: (context: FakeContext, next: () => Promise) => Response | Promise; + + beforeAll(async () => { + const mod = await import('../../src/middleware.ts'); + onRequest = mod.onRequest as typeof onRequest; + }); + + it('una rotta longevity senza sessione (nessun cookie) redirige al login e non serve la pagina', async () => { + const ctx = contesto('/longevity/io'); + const n = next(); + const res = await onRequest(ctx, n.fn); + + expect(n.fuChiamata()).toBe(false); + expect(res).toBeInstanceOf(Response); + expect([301, 302, 303, 307, 308]).toContain((res as Response).status); + expect((res as Response).headers.get('Location')).toMatch(/^\/login/); + }); + + it('una rotta longevity con un token di sessione inesistente redirige comunque al login', async () => { + const ctx = contesto('/longevity/gestionale', 'token-che-non-esiste-in-nessuna-sessione'); + const n = next(); + const res = await onRequest(ctx, n.fn); + + expect(n.fuChiamata()).toBe(false); + expect((res as Response).headers.get('Location')).toMatch(/^\/login/); + }); + + it('una API longevity senza sessione risponde 401, non serve la richiesta', async () => { + const ctx = contesto('/api/longevity/qualcosa'); + const n = next(); + const res = await onRequest(ctx, n.fn); + + expect(n.fuChiamata()).toBe(false); + expect((res as Response).status).toBe(401); + const body = await (res as Response).json(); + expect(body.error).toBeTruthy(); + }); + + it('una rotta pubblica non passa da nessun controllo: next() viene chiamata', async () => { + const ctx = contesto('/blog'); + const n = next(); + const res = await onRequest(ctx, n.fn); + + expect(n.fuChiamata()).toBe(true); + expect(await (res as Response).text()).toBe('PAGINA-SERVITA'); + }); +}); diff --git a/tests/longevity/ruoli.test.ts b/tests/longevity/ruoli.test.ts index fd5c3be..5b057f9 100644 --- a/tests/longevity/ruoli.test.ts +++ b/tests/longevity/ruoli.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { canAccessAdminPath, isProtectedPath, landingFor, isRole } from '../../src/lib/auth'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { canAccessAdminPath, isProtectedPath, landingFor, isRole, ROLES } from '../../src/lib/auth'; describe('accesso alle rotte longevity', () => { it('il cliente entra nel proprio spazio', () => { @@ -93,3 +95,29 @@ describe('isRole — validazione dei ruoli', () => { expect(isRole('qualsiasi-cosa')).toBe(false); }); }); + +describe('ROLES — fonte unica dei ruoli, usata dal pannello utenti', () => { + it('contiene tutti e sei i ruoli, cliente e trainer inclusi', () => { + expect(ROLES).toContain('cliente'); + expect(ROLES).toContain('trainer'); + expect(ROLES).toContain('admin'); + expect(ROLES).toContain('superuser'); + expect(ROLES).toContain('user'); + expect(ROLES).toContain('piattaforme'); + expect(ROLES.length).toBe(6); + }); + + it('isRole accetta esattamente i ruoli di ROLES, nessuno in piu o in meno', () => { + for (const r of ROLES) expect(isRole(r)).toBe(true); + expect(isRole('non-un-ruolo')).toBe(false); + }); +}); + +describe('pannello utenti — la tendina dei ruoli non e piu scritta a mano', () => { + it('users.astro pesca i ruoli da ROLES, non da una lista propria che ne dimentica due', () => { + const src = readFileSync(join(process.cwd(), 'src/pages/admin/users.astro'), 'utf8'); + expect(src).toContain('ROLES'); + // La vecchia lista a quattro (dimenticava cliente e trainer) non deve piu comparire. + expect(src).not.toContain("['user', 'superuser', 'piattaforme', 'admin']"); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index aefccb5..67ab3e7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,4 +2,14 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { include: ['tests/**/*.test.ts'] }, + resolve: { + alias: [ + // src/middleware.ts importa da 'astro:middleware', un modulo virtuale che Astro + // risolve solo dentro la propria pipeline vite (dev/build), non sotto vitest puro. + // Stesso bersaglio che Astro stesso usa (node_modules/astro/dist/core/create-vite.js): + // defineMiddleware è identità (fn => fn), sequence è reale. Senza questo alias il + // middleware non è caricabile in isolamento e nessun test può importarlo davvero. + { find: 'astro:middleware', replacement: 'astro/virtual-modules/middleware.js' }, + ], + }, }); From 52ff28b69d5645f71fc1022ea271f569a83b8f48 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 21 Aug 2026 20:34:49 +0200 Subject: [PATCH 20/62] spec: i sei punti emersi costruendo lo strato dati, piu' la curva x10_inv che mancava --- docs/specs/2026-08-21-longevity-design.md | 51 ++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/docs/specs/2026-08-21-longevity-design.md b/docs/specs/2026-08-21-longevity-design.md index eb4739c..db0d622 100644 --- a/docs/specs/2026-08-21-longevity-design.md +++ b/docs/specs/2026-08-21-longevity-design.md @@ -145,7 +145,7 @@ CREATE TABLE registro_test ( etichetta TEXT NOT NULL, unita TEXT, tipo_valore TEXT NOT NULL CHECK (tipo_valore IN ('num','txt')), - curva TEXT, -- 'bell','lin_dec','inc_plateau','x10','decstep','incstep' + curva TEXT, -- 'bell','lin_dec','inc_plateau','x10','x10_inv','decstep','incstep' params TEXT, -- JSON dei parametri della curva asse TEXT, -- uno dei 7 assi; NULL = non entra nello score sotto_dominio TEXT, -- a QUALE sotto-dominio contribuisce; il peso sta in `pesi` @@ -384,6 +384,55 @@ capita. confermata. L'HRV entra nel motore come passthrough, quindi senza quel canale va inserito a mano. +## 12. Cosa l'implementazione ha scoperto — da chiudere prima del motore + +Lo strato dati è stato costruito il 21/08 (ramo `feat/longevity`, 19 commit, 241 test). +Costruirlo ha fatto emergere sei punti che questo documento non aveva previsto o su cui +si contraddiceva. **Nessuno è bloccante per lo strato dati; tutti lo diventano per il +motore di calcolo.** + +1. **La conferma delle misure fuori range non ha dove stare.** La §9 dice che una misura + marcata «non entra nello score finché qualcuno non la conferma», ma lo schema della §5 + non prevede nessuna colonna che registri quella conferma. Le due sezioni si + contraddicono: va aggiunta la colonna o riscritta la regola. +2. **`registraMisure` valida l'esistenza del test, non il valore.** Oggi accetta una + misura senza nessun valore, un testo dove il registro dichiara un numero, e un `NaN` + (che viene scritto come nullo e **non** marcato fuori range, perché ogni confronto con + NaN è falso). Una riga vuota conta come «test presente» ai fini della copertura del 40%. +3. **`seedRegistro` rilanciato disfa il registro.** Usa `INSERT OR REPLACE` e riazzera + `attivo_a` e le note: disattivare un test e poi rilanciare il seed lo riattiva. È + esattamente l'opposto della promessa della §5, dove disattivare un test è valorizzare + `attivo_a`. +4. **`MODEL_VERSION` non esiste.** `QUEST_VERSION` sta in `questionario.ts`; la gemella + che versiona curve e pesi non è dichiarata da nessuna parte, e `seedPesi` la riceve + come parametro che solo i test valorizzano. Finché non esiste, il congelamento del §6 + ha metà del suo significato. +5. **La soglia del 40% è una costante di codice**, non un parametro nel registro come + chiede la §11.3 — e nessuno la consuma ancora. +6. **Registro e pesi sono disallineati per costruzione.** Il registro contiene oggi i 20 + campi del questionario; i pesi citano handgrip, vo2max, plank, flamingo — sotto-domini + che nessun test ancora alimenta. Con i dati attuali sei assi su sette resterebbero + permanentemente «insufficiente». È atteso, perché i test fisici arrivano col piano degli + import, ma va saputo: il primo che lancia il motore lo legge come un difetto. + +⚠️ **Una trappola già armata per il piano delle API.** Nelle regole di accesso, tutto ciò +che sta sotto `/api/longevity/` e non sotto `/api/longevity/gestionale/` è raggiungibile da +**ogni** cliente. Oggi è innocuo perché non esiste nessun endpoint, ma significa che la +scelta dei nomi delle rotte è una questione di sicurezza, non di stile: un domani +`/api/longevity/clienti` sarebbe l'elenco di tutti, aperto a tutti. + +⚠️ **Due limiti noti dell'anagrafica**, accettati con cognizione: se fallisse anche la +cancellazione compensativa su identity, l'errore diagnostico verrebbe mascherato e +resterebbe un cliente senza pendant clinico; e i codici sono ordinati come stringhe, +quindi oltre `ISL-9999` l'ordinamento sbaglia e un codice potrebbe essere riusato. + +📌 **Da dire a Nicola, non ancora detto:** il plank pesa su due assi — `core` in *Forza & +Struttura* (0.15) e `plank` in *Stabilità & Mobilità* (0.20). È lo stesso doppio conteggio +che lui dichiara per l'handgrip nella Fitness Age, ma questo non è dichiarato da nessuna +parte. + +--- + ## 12. Decisioni prese, e da chi | Decisione | Chi | Quando | From 59b453c67ed76d80970b607a3d7b99033fcc18d1 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 22 Aug 2026 08:02:20 +0200 Subject: [PATCH 21/62] longevity: annotate le conferme di Nicola del 22/08 (plank su due assi, soglia unica al 40%) --- src/lib/longevity/db.ts | 6 +++++- src/lib/longevity/registro.ts | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/lib/longevity/db.ts b/src/lib/longevity/db.ts index 639e697..21a0166 100644 --- a/src/lib/longevity/db.ts +++ b/src/lib/longevity/db.ts @@ -2,7 +2,11 @@ import Database from 'better-sqlite3'; import { mkdirSync } from 'node:fs'; import { dirname } from 'node:path'; -/** Sotto questa copertura di peso un elemento calcolato e' 'insufficiente' e non ha valore. */ +/** + * Sotto questa copertura di peso un elemento calcolato e' 'insufficiente' e non ha valore. + * Vale a ogni livello E dentro il questionario: soglia unica, confermata da Nicola il + * 22/08 — due criteri diversi sarebbero due cose da spiegare e da ricordare. + */ export const COPERTURA_MINIMA = 0.4; const SCHEMA_LONGEVITY = ` diff --git a/src/lib/longevity/registro.ts b/src/lib/longevity/registro.ts index d934a5f..30cd1f1 100644 --- a/src/lib/longevity/registro.ts +++ b/src/lib/longevity/registro.ts @@ -116,6 +116,10 @@ export function seedRegistro(db: Database.Database): void { // Pesi presi dal motore del cliente (AXIS_SUBDOMAIN_WEIGHTS, MACRO_SCORE_WEIGHTS, // FITNESS_AGE_WEIGHTS). I macro con peso 0 non si inseriscono: assenza e zero sono // la stessa cosa per la rinormalizzazione, e una riga a zero confonde chi legge. +// ⚠️ Il plank compare volutamente in DUE assi: 'core' in Forza & Struttura (0.15) e +// 'plank' in Stabilità & Mobilità (0.20). Confermato da Nicola il 22/08 e motivato: +// il plank non è forza pura, richiede controllo neuromuscolare del tronco, quindi +// contribuisce davvero a entrambi i domini che misura. Non è un refuso da correggere. const PESI_ASSE: Record> = { '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 }, From bc9d31e19391d954393624300d60dc98ae2b50b4 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 22 Aug 2026 08:27:51 +0200 Subject: [PATCH 22/62] docs: piano del motore di calcolo, verificato contro l'oracolo del cliente --- docs/plans/2026-08-22-longevity-motore.md | 860 ++++++++++++++++++ tests/longevity/riferimento/README.md | 22 + .../riferimento/isl_scoring_engine.py | 537 +++++++++++ 3 files changed, 1419 insertions(+) create mode 100644 docs/plans/2026-08-22-longevity-motore.md create mode 100644 tests/longevity/riferimento/README.md create mode 100644 tests/longevity/riferimento/isl_scoring_engine.py diff --git a/docs/plans/2026-08-22-longevity-motore.md b/docs/plans/2026-08-22-longevity-motore.md new file mode 100644 index 0000000..ef39038 --- /dev/null +++ b/docs/plans/2026-08-22-longevity-motore.md @@ -0,0 +1,860 @@ +# Longevity — motore di calcolo: piano di implementazione + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** portare in TypeScript il motore di punteggio del cliente, con un verificatore numerico che dimostri che i due producono gli stessi numeri. + +**Architecture:** le curve di normalizzazione e la cascata a quattro livelli diventano funzioni pure in `src/lib/longevity/motore/`. Curve e pesi si leggono dal registro nel database, non da costanti nel codice. Il tipo di ritorno rende impossibile leggere il valore di un punteggio dichiarato insufficiente. Un oracolo Python versionato genera i casi di riferimento contro cui il porting si confronta. + +**Tech Stack:** TypeScript, vitest, `python3` (solo per generare il riferimento). Nessuna dipendenza npm nuova. + +**Spec:** `docs/specs/2026-08-21-longevity-design.md` + +**Piano precedente, già eseguito:** `docs/plans/2026-08-21-longevity-strato-dati.md` — lo strato dati è costruito (ramo `feat/longevity`, 20 commit). + +## Global Constraints + +- Branch **`feat/longevity`**. `main` non si tocca, non si deploya, non si pusha senza che Adriano lo chieda. +- Nessuna dipendenza npm nuova. +- Test in `tests/longevity/`, eseguiti con `npm test`, che usano `':memory:'`. +- Codice e commenti in italiano. +- Soglia di copertura: **0.40**, la costante `COPERTURA_MINIMA` già esportata da `src/lib/longevity/db.ts`. Mai un numero scritto a mano nei rami. +- `MODEL_VERSION` iniziale: **`v1.0`**. +- **Nessun dato reale di persone** nelle fixture: i casi di prova sono sintetici. +- ⚠️ **La suite parte con un rosso che non è nostro:** `tests/modifiche-agosto.test.ts` fallisce da prima (test del sito disallineato su `main`, fuori perimetro). L'atteso è **1 fallito pre-esistente**, il resto verde. Non ripararlo. + +## Il principio che regge tutto il piano + +Il porting delle curve è la parte più pericolosa dell'intera piattaforma, e il pericolo non è il codice che non compila: è **un coefficiente trascritto male**. Produce un punteggio sanitario sbagliato che sembra plausibile, che nessun test funzionale intercetta, e che diventa il valore "atteso" di tutto ciò che gli sta sopra. + +Quindi la fedeltà **non è garantita dalla trascrizione**, ma dal confronto numerico con l'originale. L'oracolo (`tests/longevity/riferimento/isl_scoring_engine.py`, scritto dal cliente) genera i valori attesi su una griglia fitta di ingressi; il test TypeScript li rilegge e confronta. Un implementatore che sbagli una cifra se ne accorge subito, non fra sei mesi. + +⚠️ **Corollario:** nessun task di questo piano è "finito" perché i suoi test passano. È finito quando il **confronto con l'oracolo** passa sulle funzioni che tocca. + +## Struttura dei file + +| File | Responsabilità | +|---|---| +| `tests/longevity/riferimento/isl_scoring_engine.py` | l'oracolo (già presente, del cliente, non si modifica) | +| `tests/longevity/riferimento/genera-riferimento.py` | esegue l'oracolo su una griglia e scrive `riferimento.json` | +| `src/lib/longevity/motore/curve.ts` | le cinque curve del questionario + le utility | +| `src/lib/longevity/motore/test-fisici.ts` | le curve dei test misurati in sala e dagli strumenti | +| `src/lib/longevity/motore/cascata.ts` | aggregazione, assi, macro-score, Fitness Age | +| `src/lib/longevity/motore/index.ts` | la funzione che calcola una sessione leggendo dal registro | + +--- + +### Task 1: L'oracolo genera il riferimento + +Prima di portare una sola curva, serve il metro di paragone. + +**Files:** +- Create: `tests/longevity/riferimento/genera-riferimento.py` +- Create (generato): `tests/longevity/riferimento/riferimento.json` +- Test: nessuno — è uno strumento; lo verificano i task seguenti + +**Interfaces:** +- Consumes: `tests/longevity/riferimento/isl_scoring_engine.py` +- Produces: `riferimento.json`, con questa forma esatta: + +```json +{ + "generato_da": "isl_scoring_engine.py", + "casi": [ + { "fn": "score_plank", "args": [110, "M"], "atteso": 62.5 }, + { "fn": "score_bell_curve", "args": [7.5, 4, 7, 9, 12], "atteso": 100 } + ] +} +``` + +- [ ] **Step 1: Scrivi il generatore** + +```python +#!/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) + +# --- 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) +prova("vo2max_from_2km_walk", 15.5, 130, 40, 24) + +# --- 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) + 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}") +``` + +- [ ] **Step 2: Eseguilo e guarda l'esito** + +Run: `python3 tests/longevity/riferimento/genera-riferimento.py` +Expected: stampa il numero di casi (nell'ordine delle migliaia) e scrive il file. + +⚠️ Se una chiamata solleva un'eccezione, **non aggirarla**: significa che l'oracolo ha un dominio più stretto di quanto la griglia assume. Restringi la griglia per quella funzione e **annota nel rapporto quale dominio hai dovuto escludere** — è un'informazione che serve a chi userà il motore. + +- [ ] **Step 3: Verifica che il file sia sensato** + +Run: `python3 -c "import json;d=json.load(open('tests/longevity/riferimento/riferimento.json'));print(len(d['casi']),'casi');print(sorted({c['fn'] for c in d['casi']}))"` +Expected: l'elenco delle funzioni coperte, senza buchi rispetto alla griglia sopra. + +- [ ] **Step 4: Commit** + +```bash +git add tests/longevity/riferimento/ +git commit -m "longevity: l'oracolo del motore e la griglia di riferimento" +``` + +--- + +### Task 2: Le cinque curve del questionario + +**Files:** +- Create: `src/lib/longevity/motore/curve.ts` +- Test: `tests/longevity/motore-curve.test.ts` + +**Interfaces:** +- Consumes: `riferimento.json` (Task 1) +- Produces: + - `clamp(x: number, lo?: number, hi?: number): number` + - `lerp(x: number, x0: number, x1: number, y0: number, y1: number): number` + - `curvaCampana(v: number, low: number, peakLow: number, peakHigh: number, high: number): number` + - `curvaDecrescente(v: number, best: number, worst: number): number` + - `curvaCrescenteConPlateau(v: number, worst: number, plateauStart: number): number` + - `curvaDirettaX10(v: number): number` + - `curvaDirettaX10Invertita(v: number): number` + - `curvaGradini(v: number, steps: [number, number][], zeroVal: number | undefined, decrescente: boolean): number` + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/longevity/motore-curve.test.ts +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + clamp, lerp, curvaCampana, curvaDecrescente, curvaCrescenteConPlateau, + curvaDirettaX10, curvaDirettaX10Invertita, curvaGradini, +} from '../../src/lib/longevity/motore/curve'; + +const RIF = JSON.parse( + readFileSync(join(process.cwd(), 'tests/longevity/riferimento/riferimento.json'), 'utf8') +) as { casi: { fn: string; args: unknown[]; atteso: number }[] }; + +const casiDi = (fn: string) => RIF.casi.filter((c) => c.fn === fn); + +describe('curve del questionario, confrontate con l oracolo', () => { + it('la campana combacia su tutto il dominio', () => { + const casi = casiDi('score_bell_curve'); + expect(casi.length).toBeGreaterThan(20); + for (const c of casi) { + const [v, low, pl, ph, high] = c.args as number[]; + expect(curvaCampana(v, low, pl, ph, high)).toBeCloseTo(c.atteso, 1); + } + }); + + it('la decrescente combacia, inclusa la soglia 7-14 dell alcol', () => { + const casi = casiDi('score_decreasing'); + expect(casi.length).toBeGreaterThan(40); + for (const c of casi) { + const [v, best, worst] = c.args as number[]; + expect(curvaDecrescente(v, best, worst)).toBeCloseTo(c.atteso, 1); + } + }); + + it('la crescente con plateau combacia', () => { + for (const c of casiDi('score_increasing_plateau')) { + const [v, worst, plateau] = c.args as number[]; + expect(curvaCrescenteConPlateau(v, worst, plateau)).toBeCloseTo(c.atteso, 1); + } + }); + + it('la diretta per dieci combacia', () => { + for (const c of casiDi('score_direct_x10')) { + expect(curvaDirettaX10((c.args as number[])[0])).toBeCloseTo(c.atteso, 1); + } + }); + + // Questa curva NON esiste nell'oracolo: è la quinta, che al motore del cliente manca. + // Serve a q_calo_pomeridiano e nel prototipo HTML è `scale10_inv`: (10 - v) * 10. + it('la diretta invertita e il complemento della diretta', () => { + for (const v of [0, 2.5, 5, 7.5, 10]) { + expect(curvaDirettaX10Invertita(v)).toBeCloseTo(curvaDirettaX10(10 - v), 6); + } + expect(curvaDirettaX10Invertita(0)).toBe(100); + expect(curvaDirettaX10Invertita(10)).toBe(0); + }); + + it('i gradini decrescenti riproducono il prototipo: q_sigarette', () => { + const steps: [number, number][] = [[0, 100], [5, 60], [10, 40], [20, 20], [999, 0]]; + expect(curvaGradini(0, steps, undefined, true)).toBe(100); + expect(curvaGradini(5, steps, undefined, true)).toBe(60); + expect(curvaGradini(10, steps, undefined, true)).toBe(40); + expect(curvaGradini(20, steps, undefined, true)).toBe(20); + expect(curvaGradini(40, steps, undefined, true)).toBeLessThan(20); + }); + + it('zeroVal ha la precedenza sui gradini: q_schermi a zero vale 100', () => { + const steps: [number, number][] = [[15, 85], [30, 70], [60, 40], [999, 10]]; + expect(curvaGradini(0, steps, 100, true)).toBe(100); + expect(curvaGradini(15, steps, 100, true)).toBe(85); + }); + + it('clamp e lerp si comportano come nell oracolo', () => { + expect(clamp(150)).toBe(100); + expect(clamp(-5)).toBe(0); + expect(lerp(5, 0, 10, 0, 100)).toBe(50); + expect(lerp(-1, 0, 10, 0, 100)).toBe(0); // t viene limitato a [0,1] + expect(lerp(11, 0, 10, 0, 100)).toBe(100); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- tests/longevity/motore-curve.test.ts` +Expected: FAIL — modulo `motore/curve` non trovato + +- [ ] **Step 3: Write minimal implementation** + +Porta le funzioni dall'oracolo `tests/longevity/riferimento/isl_scoring_engine.py`, sezione «Questionario: curve generiche» più `clamp` e `_lerp` in cima al file. Nomi italiani come da interfacce sopra. + +Due avvertenze che l'oracolo non dichiara e che i test qui sopra pretendono: + +- **`_lerp` limita `t` all'intervallo [0,1]**, quindi le curve non escono mai oltre gli estremi: è il motivo per cui `curvaDecrescente(0, 7, 14)` vale 100 e non di più. È esattamente ciò che rende voluta la soglia dell'alcol. +- **`curvaGradini` non è nell'oracolo**: è la logica `decstep`/`incstep` del prototipo HTML del cliente. Se il valore è zero ed esiste `zeroVal`, vince quello; altrimenti si interpola fra i gradini, partendo da `(0, 100)` per le decrescenti e `(0, 20)` per le crescenti. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- tests/longevity/motore-curve.test.ts` +Expected: PASS, 8 test + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/longevity/motore/curve.ts tests/longevity/motore-curve.test.ts +git commit -m "longevity: le cinque curve del questionario, verificate contro l oracolo" +``` + +--- + +### Task 3: Le curve dei test fisici + +**Files:** +- Create: `src/lib/longevity/motore/test-fisici.ts` +- Test: `tests/longevity/motore-fisici.test.ts` + +**Interfaces:** +- Consumes: `clamp`, `lerp` da `motore/curve` (Task 2); `riferimento.json` (Task 1) +- Produces, con questi nomi esatti (l'ordine dei parametri è quello dell'oracolo): + - `scoreGrassoPercento(fatPct: number, sesso: Sesso): number` + - `scoreMuscoloPercento(musclePct: number, sesso: Sesso): number` + - `scoreWhr(whr: number, sesso: Sesso): number` + - `scoreVo2max(vo2max: number, eta: number, sesso: Sesso): number` + - `scoreSpo2(spo2: number): number` + - `scorePressione(sistolica: number, diastolica: number): number` + - `scoreRecuperoCardiaco(caloBpm1min: number): number` + - `scoreHandgrip(kg: number, eta: number, sesso: Sesso): number` + - `scorePushup(reps: number, eta: number, sesso: Sesso): number` + - `scoreSollevamentoSuPeso(caricoKg, pesoKg, rip, tiersM, tiersF, sesso): number` + - `scoreTrazioneIsometrica(sec: number, sesso: Sesso): number` + - `scoreSitToStand(reps: number, eta: number, sesso: Sesso, bmi?: number): number` + - `scorePlank(sec: number, sesso: Sesso): number` + - `scoreBackScratch(cm: number, eta: number, sesso: Sesso): number` + - `scoreMobilitaSpalla(outreachDeg: number, bucklingDeg: number): number` + - `scoreFlamingo(cadute: number): number` + - `scoreSitAndReach(cm: number, sesso: Sesso): number` + - `vo2maxDaStepTest`, `vo2maxDa2kmWalk`, `vo2maxDaMutt`, `vo2maxDaMilfit` + - `type Sesso = 'M' | 'F'` + - le sei tabelle: `TIERS_BENCH_M`, `TIERS_BENCH_F`, `TIERS_SQUAT_M`, `TIERS_SQUAT_F`, `TIERS_ROW_M`, `TIERS_ROW_F` + +⚠️ **Due funzioni dell'oracolo NON vanno portate**, e non è una dimenticanza: +- `score_agility_ms` — l'agilità è stata **rimossa dallo score** dal cliente il 13/08: 548 ms reali contro i 250 attesi davano 20/100, falsi, per una scala non comparabile fra protocolli. Nel motore la funzione è rimasta ma nessun peso la richiama. +- `score_generic_range_local` — definita in fondo al file e mai chiamata. + +Portarle significherebbe riportare in vita una misura che il cliente ha escluso. + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/longevity/motore-fisici.test.ts +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(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- tests/longevity/motore-fisici.test.ts` +Expected: FAIL — modulo `motore/test-fisici` non trovato + +- [ ] **Step 3: Write minimal implementation** + +Porta le funzioni dall'oracolo, una per una, mantenendo **identici** i coefficienti, le tabelle di ancoraggio e l'ordine dei confronti. Le trovi nelle sezioni «Composizione Corporea», «Cardio-Respiratorio», «Recupero & Sistema Nervoso», «Forza & Struttura» e «Stabilità & Mobilità». + +⚠️ **Non "sistemare" nulla mentre porti**, nemmeno ciò che sembra un difetto: se una funzione ha un ramo irraggiungibile o un valore di ripiego che pare arbitrario, va riprodotto tale e quale. L'oracolo è la definizione, non una proposta — e il confronto numerico ti dirà subito se hai cambiato qualcosa. + +⚠️ **Attenzione a `score_handgrip`**: usa un ciclo con `break` e un ramo `else` del `for` (costrutto che in TypeScript non esiste). Riproduci il comportamento: se l'età non cade in nessun intervallo fra le ancore, vale il valore dell'ultima. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- tests/longevity/motore-fisici.test.ts` +Expected: PASS, 22 test + +Se un test elenca divergenze, il messaggio ti dà ingressi, atteso e ottenuto: **correggi la tua funzione**, non il riferimento. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/longevity/motore/test-fisici.ts tests/longevity/motore-fisici.test.ts +git commit -m "longevity: le curve dei test fisici, verificate contro l oracolo" +``` + +--- + +### Task 4: La cascata, e il tipo che impedisce di mostrare un numero che non c'è + +**Files:** +- Create: `src/lib/longevity/motore/cascata.ts` +- Test: `tests/longevity/motore-cascata.test.ts` + +**Interfaces:** +- Consumes: `COPERTURA_MINIMA` da `src/lib/longevity/db.ts` +- Produces: + - `type Punteggio = { stato: 'ok'; valore: number; copertura: number } | { stato: 'insufficiente'; copertura: number }` + - `type VocePesata = { punteggio: number | null; peso: number }` + - `aggrega(voci: VocePesata[]): Punteggio` + - `calcolaAsse(pesi: Record, punteggi: Record): Punteggio` + - `calcolaMacro(pesiMacro: Record, assi: Record): Punteggio` + - `calcolaFitnessAge(etaAnagrafica: number, pesi: Record, voci: Record): { fitnessAge: number | null; composito: Punteggio }` + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/longevity/motore-cascata.test.ts +import { describe, it, expect } from 'vitest'; +import { COPERTURA_MINIMA } from '../../src/lib/longevity/db'; +import { aggrega, calcolaAsse, calcolaMacro, calcolaFitnessAge } from '../../src/lib/longevity/motore/cascata'; + +describe('aggregazione e rinormalizzazione', () => { + it('con tutti i dati fa la media pesata', () => { + const r = aggrega([{ punteggio: 80, peso: 0.5 }, { punteggio: 60, peso: 0.5 }]); + expect(r.stato).toBe('ok'); + if (r.stato === 'ok') { expect(r.valore).toBeCloseTo(70, 1); expect(r.copertura).toBe(1); } + }); + + it('un dato mancante ridistribuisce il suo peso, non vale zero', () => { + const r = aggrega([{ punteggio: 80, peso: 0.5 }, { punteggio: null, peso: 0.25 }, { punteggio: 60, peso: 0.25 }]); + expect(r.stato).toBe('ok'); + // 80*0.5 + 60*0.25 = 55, su peso disponibile 0.75 -> 73.3, non 55 + if (r.stato === 'ok') { expect(r.valore).toBeCloseTo(73.3, 1); expect(r.copertura).toBeCloseTo(0.75, 2); } + }); + + it('sotto la soglia di copertura NON esiste un valore da leggere', () => { + const r = aggrega([{ punteggio: 90, peso: 0.2 }, { punteggio: null, peso: 0.8 }]); + expect(r.stato).toBe('insufficiente'); + expect(r.copertura).toBeCloseTo(0.2, 2); + // il punto dell'intero tipo: chi consuma non ha il campo da cui prendere il numero + expect((r as { valore?: number }).valore).toBeUndefined(); + }); + + it('la soglia e quella dichiarata una volta sola, non un numero sparso', () => { + const pocoSotto = aggrega([{ punteggio: 90, peso: COPERTURA_MINIMA - 0.01 }, { punteggio: null, peso: 1 - COPERTURA_MINIMA + 0.01 }]); + const esatto = aggrega([{ punteggio: 90, peso: COPERTURA_MINIMA }, { punteggio: null, peso: 1 - COPERTURA_MINIMA }]); + expect(pocoSotto.stato).toBe('insufficiente'); + expect(esatto.stato).toBe('ok'); // la soglia e inclusiva, come nell'oracolo + }); + + it('senza nessun dato e insufficiente con copertura zero', () => { + const r = aggrega([{ punteggio: null, peso: 1 }]); + expect(r.stato).toBe('insufficiente'); + expect(r.copertura).toBe(0); + }); +}); + +describe('assi, macro e Fitness Age', () => { + const PESI_FORZA = { handgrip: 0.25, spinta: 0.2, trazione: 0.2, arti_inferiori: 0.2, core: 0.15 }; + + it('un asse si calcola sui suoi sotto-domini', () => { + const r = calcolaAsse(PESI_FORZA, { handgrip: 70, spinta: 60, trazione: 65, arti_inferiori: 80, core: 50 }); + expect(r.stato).toBe('ok'); + if (r.stato === 'ok') expect(r.valore).toBeCloseTo(66.25, 1); + }); + + it('un asse insufficiente NON entra nel macro-score, invece di entrarci come zero', () => { + const assi = { + A: { stato: 'ok', valore: 80, copertura: 1 } as const, + B: { stato: 'insufficiente', copertura: 0.1 } as const, + }; + const r = calcolaMacro({ A: 0.5, B: 0.5 }, assi); + expect(r.stato).toBe('ok'); + // se B entrasse come zero il risultato sarebbe 40: la rinormalizzazione lo esclude + if (r.stato === 'ok') { expect(r.valore).toBeCloseTo(80, 1); expect(r.copertura).toBeCloseTo(0.5, 2); } + }); + + it('la Fitness Age scende sotto l eta quando il composito supera 50', () => { + const pesi = { cardio: 0.3, handgrip_isolato: 0.2, hrv_isolato: 0.2, forza_resto: 0.15, composizione: 0.1, stabilita: 0.05 }; + const r = calcolaFitnessAge(40, pesi, { cardio: 75, handgrip_isolato: 75, hrv_isolato: 75, forza_resto: 75, composizione: 75, stabilita: 75 }); + // 40 - (75 - 50) * 0.4 = 30 + expect(r.fitnessAge).toBeCloseTo(30, 1); + }); + + it('senza dati sufficienti la Fitness Age non esiste', () => { + const pesi = { cardio: 0.3, handgrip_isolato: 0.2, hrv_isolato: 0.2, forza_resto: 0.15, composizione: 0.1, stabilita: 0.05 }; + const r = calcolaFitnessAge(40, pesi, { cardio: 75, handgrip_isolato: null, hrv_isolato: null, forza_resto: null, composizione: null, stabilita: null }); + expect(r.composito.stato).toBe('insufficiente'); + expect(r.fitnessAge).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- tests/longevity/motore-cascata.test.ts` +Expected: FAIL — modulo `motore/cascata` non trovato + +- [ ] **Step 3: Write minimal implementation** + +Porta `aggregate`, `compute_axis`, `compute_macro_scores` e `compute_fitness_age` dall'oracolo, **con una differenza deliberata**: + +⚠️ Nell'oracolo `aggregate()` restituisce il punteggio pieno **anche quando lo dichiara insufficiente** — la docstring dice il contrario di ciò che il codice fa. È una trappola: chi consuma deve ricordarsi di guardare lo stato, e prima o poi qualcuno non lo fa. Qui il tipo `Punteggio` la chiude: nel ramo `insufficiente` **il campo `valore` non esiste**, quindi la regola di prodotto — *«tratteggiato, mai un numero pieno fasullo»* — diventa impossibile da violare per distrazione. + +Il resto va riprodotto fedelmente: la soglia è **inclusiva** (`copertura < MINIMA` è insufficiente, quindi esattamente 0.40 è ok), i pesi si rinormalizzano su quelli disponibili, la Fitness Age è `eta - (composito - 50) * 0.4`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- tests/longevity/motore-cascata.test.ts` +Expected: PASS, 10 test + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/longevity/motore/cascata.ts tests/longevity/motore-cascata.test.ts +git commit -m "longevity: la cascata a quattro livelli, con il tipo che chiude la trappola dell insufficiente" +``` + +--- + +### Task 5: Il motore legge dal registro, non da costanti + +È il task che collega il motore allo strato dati: curve, parametri e pesi vengono dal database, così aggiungere o togliere un test resta una modifica ai dati. + +**Files:** +- Create: `src/lib/longevity/motore/index.ts` +- Modify: `src/lib/longevity/registro.ts` — aggiungere `MODEL_VERSION` e `pesiDi` +- Test: `tests/longevity/motore.test.ts` + +**Interfaces:** +- Consumes: tutto quanto sopra, più `createLongevityDb`, `seedRegistro`, `seedPesi`, `apriSessione`, `registraMisure` +- Produces: + - `MODEL_VERSION: string` (`'v1.0'`, esportata da `registro.ts`) + - `pesiDi(db, modelVersion, livello, contenitore): Record` in `registro.ts` + - `applicaCurva(voce: VoceRegistro, valore: number): number | null` — normalizza un valore grezzo secondo la curva dichiarata nel registro + - `calcolaSessione(db, sessioneId): { assi, macro, fitnessAge, modelVersion, questVersion }` + - `salvaScore(db, sessioneId, risultato): void` — congela i punteggi nella tabella `score` + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/longevity/motore.test.ts +import { describe, it, expect } from 'vitest'; +import { createLongevityDb } from '../../src/lib/longevity/db'; +import { seedRegistro, seedPesi, MODEL_VERSION, pesiDi } from '../../src/lib/longevity/registro'; +import { salvaCompilazione } from '../../src/lib/longevity/questionario'; +import { applicaCurva, calcolaSessione, salvaScore } from '../../src/lib/longevity/motore'; + +function dbPronto() { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + seedPesi(db, MODEL_VERSION); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run(); + return db; +} + +const voce = (db: ReturnType, id: string) => + db.prepare(`SELECT * FROM registro_test WHERE test_id = ?`).get(id) as Record; + +describe('il motore legge le curve dal registro', () => { + it('applica la curva dichiarata per il test, non una scritta nel codice', () => { + const db = dbPronto(); + const v = { ...voce(db, 'q_ore_sonno'), params: JSON.parse(voce(db, 'q_ore_sonno').params as string) }; + expect(applicaCurva(v as never, 8)).toBe(100); // dentro il picco 7-9 + expect(applicaCurva(v as never, 4)).toBeLessThan(20); + }); + + it('rispetta la soglia dell alcol come e scritta nel registro', () => { + const db = dbPronto(); + const v = { ...voce(db, 'q_alcol_life'), params: JSON.parse(voce(db, 'q_alcol_life').params as string) }; + expect(applicaCurva(v as never, 0)).toBe(100); + expect(applicaCurva(v as never, 7)).toBe(100); + expect(applicaCurva(v as never, 14)).toBe(0); + }); + + it('cambiare i parametri nel registro cambia il punteggio, senza toccare il codice', () => { + const db = dbPronto(); + db.prepare(`UPDATE registro_test SET params = ? WHERE test_id = 'q_alcol_life'`) + .run(JSON.stringify({ best: 0, worst: 7 })); + const v = { ...voce(db, 'q_alcol_life'), params: JSON.parse(voce(db, 'q_alcol_life').params as string) }; + expect(applicaCurva(v as never, 7)).toBe(0); // con i parametri nuovi, 7 non vale piu 100 + }); + + it('un test disattivato non entra nel calcolo', () => { + const db = dbPronto(); + salvaCompilazione(db, { + client_code: 'ISL-0001', data: '2026-08-22', eta: 35, + risposte: { q_ore_sonno: 8, q_riposato: 8, q_min_addorm: 10, q_risvegli: 0, q_caffeina: 0, q_sonnolenza_diurna: 0 }, + }); + const prima = calcolaSessione(db, 1); + db.prepare(`UPDATE registro_test SET attivo_a = '2026-01-01' WHERE test_id = 'q_ore_sonno'`).run(); + const dopo = calcolaSessione(db, 1); + expect(JSON.stringify(prima)).not.toBe(JSON.stringify(dopo)); + }); +}); + +describe('calcolo e congelamento di una sessione', () => { + it('produce i sette assi, e quelli senza dati sono insufficienti', () => { + const db = dbPronto(); + salvaCompilazione(db, { + client_code: 'ISL-0001', data: '2026-08-22', eta: 35, + risposte: { q_ore_sonno: 8, q_riposato: 8, q_min_addorm: 10, q_risvegli: 0, q_caffeina: 0, q_sonnolenza_diurna: 1 }, + }); + const r = calcolaSessione(db, 1); + expect(Object.keys(r.assi).length).toBe(7); + // il questionario copre il sonno, che pesa 0.20 dentro Recupero: sotto il 40% + expect(r.assi['Recupero & Sistema Nervoso'].stato).toBe('insufficiente'); + // Stile di Vita e coperto al 100% dal solo questionario, ma qui non abbiamo risposto + expect(r.assi['Forza & Struttura'].stato).toBe('insufficiente'); + }); + + it('congela i punteggi con le DUE versioni', () => { + const db = dbPronto(); + salvaCompilazione(db, { + client_code: 'ISL-0001', data: '2026-08-22', eta: 35, + risposte: { q_riposato: 7 }, + }); + salvaScore(db, 1, calcolaSessione(db, 1)); + const righe = db.prepare(`SELECT tipo, elemento, valore, stato, quest_version, model_version FROM score`).all() as Record[]; + expect(righe.length).toBeGreaterThan(0); + for (const r of righe) { + expect(r.model_version).toBe(MODEL_VERSION); + expect(r.quest_version).toBe('v1.0'); + if (r.stato === 'insufficiente') expect(r.valore).toBeNull(); + } + }); + + it('i pesi arrivano dal registro e sono quelli della versione richiesta', () => { + const db = dbPronto(); + const pesi = pesiDi(db, MODEL_VERSION, 'asse', 'Forza & Struttura'); + expect(pesi.handgrip).toBe(0.25); + expect(Object.values(pesi).reduce((a, b) => a + b, 0)).toBeCloseTo(1, 6); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- tests/longevity/motore.test.ts` +Expected: FAIL — modulo `motore` non trovato + +- [ ] **Step 3: Write minimal implementation** + +In `registro.ts` aggiungi: + +```ts +/** Versione del modello di calcolo: curve e pesi. Distinta da QUEST_VERSION, che versiona le domande. */ +export const MODEL_VERSION = 'v1.0'; + +export function pesiDi( + db: Database.Database, modelVersion: string, + livello: 'asse' | 'macro' | 'fitness_age', contenitore: string +): Record { + const righe = db.prepare( + `SELECT elemento, peso FROM pesi WHERE model_version = ? AND livello = ? AND contenitore = ?` + ).all(modelVersion, livello, contenitore) as { elemento: string; peso: number }[]; + return Object.fromEntries(righe.map((r) => [r.elemento, r.peso])); +} +``` + +In `motore/index.ts`: `applicaCurva` smista sulla curva dichiarata nel registro e passa i parametri letti da lì; `calcolaSessione` legge le misure attive della sessione, le normalizza, raggruppa per sotto-dominio (facendo la **media** dei test dello stesso sotto-dominio), calcola i sette assi, i tre macro e la Fitness Age; `salvaScore` scrive in `score` una riga per asse, macro e Fitness Age, con `valore` a `null` quando lo stato è insufficiente. + +⚠️ **La copertura si calcola sui pesi, non sul numero di test.** Un sotto-dominio senza nessuna misura è `null` e il suo peso si ridistribuisce; è la regola dell'oracolo e vale a ogni livello. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test` +Expected: PASS su tutta la suite nuova; atteso il solo rosso pre-esistente. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/longevity/motore/index.ts src/lib/longevity/registro.ts tests/longevity/motore.test.ts +git commit -m "longevity: il motore legge curve e pesi dal registro, e congela i punteggi" +``` + +--- + +### Task 6: La prova sul caso reale documentato + +L'ultimo controllo non è sulle funzioni: è sul fatto che l'insieme produca i numeri che il cliente ha già visto. + +**Files:** +- Test: `tests/longevity/motore-caso-reale.test.ts` + +**Interfaces:** +- Consumes: tutto il motore + +⚠️ **I dati di Donata e Nicola non entrano nelle fixture** — sono dati sanitari di due persone identificabili, e le fixture stanno in git. Il caso qui sotto è **sintetico**, costruito per esercitare le stesse condizioni descritte nella documentazione: un profilo con composizione corporea completa e tutto il resto mancante. + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/longevity/motore-caso-reale.test.ts +import { describe, it, expect } from 'vitest'; +import { createLongevityDb } from '../../src/lib/longevity/db'; +import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro'; +import { apriSessione, registraMisure } from '../../src/lib/longevity/misure'; +import { calcolaSessione } from '../../src/lib/longevity/motore'; + +/** + * Riproduce la situazione descritta nella documentazione del cliente: una persona di cui + * si conosce solo la composizione corporea, misurata dalla Wellness Tower. Un asse pieno, + * tutti gli altri scoperti. È il caso in cui un motore ingenuo mostrerebbe sei zeri. + * Dati sintetici: nessuna persona reale. + */ +describe('un profilo con una sola area misurata', () => { + it('mostra l asse coperto e dichiara insufficienti gli altri sei', () => { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + seedPesi(db, MODEL_VERSION); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','M')`).run(); + + // i tre test di composizione esistono nel registro solo dopo il piano degli import: + // finche non ci sono, questo test dimostra il comportamento con zero misure fisiche + const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-22', tipo: 'checkup', eta_alla_data: 38 }); + registraMisure(db, s, 'questionario', []); + + const r = calcolaSessione(db, s); + const insufficienti = Object.values(r.assi).filter((a) => a.stato === 'insufficiente').length; + expect(insufficienti).toBe(7); + expect(r.fitnessAge).toBeNull(); + }); + + it('nessun asse insufficiente porta con se un valore da mostrare per sbaglio', () => { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + seedPesi(db, MODEL_VERSION); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0002','F')`).run(); + const s = apriSessione(db, { client_code: 'ISL-0002', data: '2026-08-22', tipo: 'checkup', eta_alla_data: 35 }); + registraMisure(db, s, 'questionario', []); + + const r = calcolaSessione(db, s); + for (const [nome, asse] of Object.entries(r.assi)) { + if (asse.stato === 'insufficiente') { + expect((asse as { valore?: number }).valore, `${nome} espone un valore`).toBeUndefined(); + } + } + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- tests/longevity/motore-caso-reale.test.ts` +Expected: FAIL se qualcosa nella catena non regge il caso senza misure. + +- [ ] **Step 3: Correggi ciò che il caso reale smonta** + +Non c'è codice nuovo da scrivere: se questo test fallisce, il difetto è in un task precedente. Correggilo lì e **annota nel rapporto quale task ha dovuto essere corretto** — è l'informazione che dice se il porting regge davvero o solo sui casi comodi. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test` +Expected: PASS, con il solo rosso pre-esistente. + +- [ ] **Step 5: Commit** + +```bash +git add tests/longevity/motore-caso-reale.test.ts +git commit -m "longevity: la prova sul profilo con una sola area misurata" +``` + +--- + +## Cosa esiste alla fine di questo piano + +Il motore completo: le curve verificate una per una contro l'oracolo del cliente su migliaia di casi, la cascata a quattro livelli, la lettura di curve e pesi dal registro, il congelamento dei punteggi con le due versioni distinte. + +**Non esiste ancora nessuna pagina.** È il piano successivo, e da lì in poi il radar ha numeri veri da mostrare. + +## Nota per il piano dell'interfaccia + +I sette assi che il motore restituisce sono `Punteggio`, cioè o `{stato:'ok', valore}` o `{stato:'insufficiente'}` senza valore. La dashboard **non può** stampare un numero dove non c'è: è il tipo a impedirlo, ed è il motivo per cui il tratteggio del radar non dipende dalla disciplina di chi scrive la vista. + +⚠️ Con i dati di oggi — solo questionario, nessun test fisico — **sei assi su sette risultano insufficienti**. È corretto e atteso: i test fisici entrano col piano degli import. Chi guarderà la prima dashboard non deve scambiarlo per un difetto. diff --git a/tests/longevity/riferimento/README.md b/tests/longevity/riferimento/README.md new file mode 100644 index 0000000..c1d2f43 --- /dev/null +++ b/tests/longevity/riferimento/README.md @@ -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. diff --git a/tests/longevity/riferimento/isl_scoring_engine.py b/tests/longevity/riferimento/isl_scoring_engine.py new file mode 100644 index 0000000..cada6f5 --- /dev/null +++ b/tests/longevity/riferimento/isl_scoring_engine.py @@ -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() From 7cb49c6a495e72075b8509c4d9925bfeabac87eb Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 22 Aug 2026 08:29:53 +0200 Subject: [PATCH 23/62] longevity: l'oracolo del motore e la griglia di riferimento --- .../riferimento/genera-riferimento.py | 113 + tests/longevity/riferimento/riferimento.json | 38459 ++++++++++++++++ 2 files changed, 38572 insertions(+) create mode 100644 tests/longevity/riferimento/genera-riferimento.py create mode 100644 tests/longevity/riferimento/riferimento.json diff --git a/tests/longevity/riferimento/genera-riferimento.py b/tests/longevity/riferimento/genera-riferimento.py new file mode 100644 index 0000000..9df854d --- /dev/null +++ b/tests/longevity/riferimento/genera-riferimento.py @@ -0,0 +1,113 @@ +#!/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) + +# --- 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) +prova("vo2max_from_2km_walk", 15.5, 130, 40, 24) + +# --- 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) + 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}") diff --git a/tests/longevity/riferimento/riferimento.json b/tests/longevity/riferimento/riferimento.json new file mode 100644 index 0000000..dcad11a --- /dev/null +++ b/tests/longevity/riferimento/riferimento.json @@ -0,0 +1,38459 @@ +{ + "generato_da": "isl_scoring_engine.py", + "casi": [ + { + "fn": "score_bell_curve", + "args": [ + 0, + 4, + 7, + 9, + 12 + ], + "atteso": 10 + }, + { + "fn": "score_decreasing", + "args": [ + 0, + 0, + 7 + ], + "atteso": 100 + }, + { + "fn": "score_decreasing", + "args": [ + 0, + 7, + 14 + ], + "atteso": 100 + }, + { + "fn": "score_bell_curve", + "args": [ + 0.5, + 4, + 7, + 9, + 12 + ], + "atteso": 10 + }, + { + "fn": "score_decreasing", + "args": [ + 0.5, + 0, + 7 + ], + "atteso": 92.9 + }, + { + "fn": "score_decreasing", + "args": [ + 0.5, + 7, + 14 + ], + "atteso": 100 + }, + { + "fn": "score_bell_curve", + "args": [ + 1.0, + 4, + 7, + 9, + 12 + ], + "atteso": 10 + }, + { + "fn": "score_decreasing", + "args": [ + 1.0, + 0, + 7 + ], + "atteso": 85.7 + }, + { + "fn": "score_decreasing", + "args": [ + 1.0, + 7, + 14 + ], + "atteso": 100 + }, + { + "fn": "score_bell_curve", + "args": [ + 1.5, + 4, + 7, + 9, + 12 + ], + "atteso": 10 + }, + { + "fn": "score_decreasing", + "args": [ + 1.5, + 0, + 7 + ], + "atteso": 78.6 + }, + { + "fn": "score_decreasing", + "args": [ + 1.5, + 7, + 14 + ], + "atteso": 100 + }, + { + "fn": "score_bell_curve", + "args": [ + 2.0, + 4, + 7, + 9, + 12 + ], + "atteso": 10 + }, + { + "fn": "score_decreasing", + "args": [ + 2.0, + 0, + 7 + ], + "atteso": 71.4 + }, + { + "fn": "score_decreasing", + "args": [ + 2.0, + 7, + 14 + ], + "atteso": 100 + }, + { + "fn": "score_bell_curve", + "args": [ + 2.5, + 4, + 7, + 9, + 12 + ], + "atteso": 10 + }, + { + "fn": "score_decreasing", + "args": [ + 2.5, + 0, + 7 + ], + "atteso": 64.3 + }, + { + "fn": "score_decreasing", + "args": [ + 2.5, + 7, + 14 + ], + "atteso": 100 + }, + { + "fn": "score_bell_curve", + "args": [ + 3.0, + 4, + 7, + 9, + 12 + ], + "atteso": 10 + }, + { + "fn": "score_decreasing", + "args": [ + 3.0, + 0, + 7 + ], + "atteso": 57.1 + }, + { + "fn": "score_decreasing", + "args": [ + 3.0, + 7, + 14 + ], + "atteso": 100 + }, + { + "fn": "score_bell_curve", + "args": [ + 3.5, + 4, + 7, + 9, + 12 + ], + "atteso": 10 + }, + { + "fn": "score_decreasing", + "args": [ + 3.5, + 0, + 7 + ], + "atteso": 50.0 + }, + { + "fn": "score_decreasing", + "args": [ + 3.5, + 7, + 14 + ], + "atteso": 100 + }, + { + "fn": "score_bell_curve", + "args": [ + 4.0, + 4, + 7, + 9, + 12 + ], + "atteso": 10 + }, + { + "fn": "score_decreasing", + "args": [ + 4.0, + 0, + 7 + ], + "atteso": 42.9 + }, + { + "fn": "score_decreasing", + "args": [ + 4.0, + 7, + 14 + ], + "atteso": 100 + }, + { + "fn": "score_bell_curve", + "args": [ + 4.5, + 4, + 7, + 9, + 12 + ], + "atteso": 25.0 + }, + { + "fn": "score_decreasing", + "args": [ + 4.5, + 0, + 7 + ], + "atteso": 35.7 + }, + { + "fn": "score_decreasing", + "args": [ + 4.5, + 7, + 14 + ], + "atteso": 100 + }, + { + "fn": "score_bell_curve", + "args": [ + 5.0, + 4, + 7, + 9, + 12 + ], + "atteso": 40.0 + }, + { + "fn": "score_decreasing", + "args": [ + 5.0, + 0, + 7 + ], + "atteso": 28.6 + }, + { + "fn": "score_decreasing", + "args": [ + 5.0, + 7, + 14 + ], + "atteso": 100 + }, + { + "fn": "score_bell_curve", + "args": [ + 5.5, + 4, + 7, + 9, + 12 + ], + "atteso": 55.0 + }, + { + "fn": "score_decreasing", + "args": [ + 5.5, + 0, + 7 + ], + "atteso": 21.4 + }, + { + "fn": "score_decreasing", + "args": [ + 5.5, + 7, + 14 + ], + "atteso": 100 + }, + { + "fn": "score_bell_curve", + "args": [ + 6.0, + 4, + 7, + 9, + 12 + ], + "atteso": 70.0 + }, + { + "fn": "score_decreasing", + "args": [ + 6.0, + 0, + 7 + ], + "atteso": 14.3 + }, + { + "fn": "score_decreasing", + "args": [ + 6.0, + 7, + 14 + ], + "atteso": 100 + }, + { + "fn": "score_bell_curve", + "args": [ + 6.5, + 4, + 7, + 9, + 12 + ], + "atteso": 85.0 + }, + { + "fn": "score_decreasing", + "args": [ + 6.5, + 0, + 7 + ], + "atteso": 7.1 + }, + { + "fn": "score_decreasing", + "args": [ + 6.5, + 7, + 14 + ], + "atteso": 100 + }, + { + "fn": "score_bell_curve", + "args": [ + 7.0, + 4, + 7, + 9, + 12 + ], + "atteso": 100 + }, + { + "fn": "score_decreasing", + "args": [ + 7.0, + 0, + 7 + ], + "atteso": 0 + }, + { + "fn": "score_decreasing", + "args": [ + 7.0, + 7, + 14 + ], + "atteso": 100 + }, + { + "fn": "score_bell_curve", + "args": [ + 7.5, + 4, + 7, + 9, + 12 + ], + "atteso": 100 + }, + { + "fn": "score_decreasing", + "args": [ + 7.5, + 0, + 7 + ], + "atteso": 0 + }, + { + "fn": "score_decreasing", + "args": [ + 7.5, + 7, + 14 + ], + "atteso": 92.9 + }, + { + "fn": "score_bell_curve", + "args": [ + 8.0, + 4, + 7, + 9, + 12 + ], + "atteso": 100 + }, + { + "fn": "score_decreasing", + "args": [ + 8.0, + 0, + 7 + ], + "atteso": 0 + }, + { + "fn": "score_decreasing", + "args": [ + 8.0, + 7, + 14 + ], + "atteso": 85.7 + }, + { + "fn": "score_bell_curve", + "args": [ + 8.5, + 4, + 7, + 9, + 12 + ], + "atteso": 100 + }, + { + "fn": "score_decreasing", + "args": [ + 8.5, + 0, + 7 + ], + "atteso": 0 + }, + { + "fn": "score_decreasing", + "args": [ + 8.5, + 7, + 14 + ], + "atteso": 78.6 + }, + { + "fn": "score_bell_curve", + "args": [ + 9.0, + 4, + 7, + 9, + 12 + ], + "atteso": 100 + }, + { + "fn": "score_decreasing", + "args": [ + 9.0, + 0, + 7 + ], + "atteso": 0 + }, + { + "fn": "score_decreasing", + "args": [ + 9.0, + 7, + 14 + ], + "atteso": 71.4 + }, + { + "fn": "score_bell_curve", + "args": [ + 9.5, + 4, + 7, + 9, + 12 + ], + "atteso": 85.0 + }, + { + "fn": "score_decreasing", + "args": [ + 9.5, + 0, + 7 + ], + "atteso": 0 + }, + { + "fn": "score_decreasing", + "args": [ + 9.5, + 7, + 14 + ], + "atteso": 64.3 + }, + { + "fn": "score_bell_curve", + "args": [ + 10.0, + 4, + 7, + 9, + 12 + ], + "atteso": 70.0 + }, + { + "fn": "score_decreasing", + "args": [ + 10.0, + 0, + 7 + ], + "atteso": 0 + }, + { + "fn": "score_decreasing", + "args": [ + 10.0, + 7, + 14 + ], + "atteso": 57.1 + }, + { + "fn": "score_bell_curve", + "args": [ + 10.5, + 4, + 7, + 9, + 12 + ], + "atteso": 55.0 + }, + { + "fn": "score_decreasing", + "args": [ + 10.5, + 0, + 7 + ], + "atteso": 0 + }, + { + "fn": "score_decreasing", + "args": [ + 10.5, + 7, + 14 + ], + "atteso": 50.0 + }, + { + "fn": "score_bell_curve", + "args": [ + 11.0, + 4, + 7, + 9, + 12 + ], + "atteso": 40.0 + }, + { + "fn": "score_decreasing", + "args": [ + 11.0, + 0, + 7 + ], + "atteso": 0 + }, + { + "fn": "score_decreasing", + "args": [ + 11.0, + 7, + 14 + ], + "atteso": 42.9 + }, + { + "fn": "score_bell_curve", + "args": [ + 11.5, + 4, + 7, + 9, + 12 + ], + "atteso": 25.0 + }, + { + "fn": "score_decreasing", + "args": [ + 11.5, + 0, + 7 + ], + "atteso": 0 + }, + { + "fn": "score_decreasing", + "args": [ + 11.5, + 7, + 14 + ], + "atteso": 35.7 + }, + { + "fn": "score_bell_curve", + "args": [ + 12.0, + 4, + 7, + 9, + 12 + ], + "atteso": 10 + }, + { + "fn": "score_decreasing", + "args": [ + 12.0, + 0, + 7 + ], + "atteso": 0 + }, + { + "fn": "score_decreasing", + "args": [ + 12.0, + 7, + 14 + ], + "atteso": 28.6 + }, + { + "fn": "score_bell_curve", + "args": [ + 12.5, + 4, + 7, + 9, + 12 + ], + "atteso": 10 + }, + { + "fn": "score_decreasing", + "args": [ + 12.5, + 0, + 7 + ], + "atteso": 0 + }, + { + "fn": "score_decreasing", + "args": [ + 12.5, + 7, + 14 + ], + "atteso": 21.4 + }, + { + "fn": "score_bell_curve", + "args": [ + 13.0, + 4, + 7, + 9, + 12 + ], + "atteso": 10 + }, + { + "fn": "score_decreasing", + "args": [ + 13.0, + 0, + 7 + ], + "atteso": 0 + }, + { + "fn": "score_decreasing", + "args": [ + 13.0, + 7, + 14 + ], + "atteso": 14.3 + }, + { + "fn": "score_bell_curve", + "args": [ + 13.5, + 4, + 7, + 9, + 12 + ], + "atteso": 10 + }, + { + "fn": "score_decreasing", + "args": [ + 13.5, + 0, + 7 + ], + "atteso": 0 + }, + { + "fn": "score_decreasing", + "args": [ + 13.5, + 7, + 14 + ], + "atteso": 7.1 + }, + { + "fn": "score_bell_curve", + "args": [ + 14.0, + 4, + 7, + 9, + 12 + ], + "atteso": 10 + }, + { + "fn": "score_decreasing", + "args": [ + 14.0, + 0, + 7 + ], + "atteso": 0 + }, + { + "fn": "score_decreasing", + "args": [ + 14.0, + 7, + 14 + ], + "atteso": 0 + }, + { + "fn": "score_direct_x10", + "args": [ + 0 + ], + "atteso": 0 + }, + { + "fn": "score_direct_x10", + "args": [ + 0.5 + ], + "atteso": 5.0 + }, + { + "fn": "score_direct_x10", + "args": [ + 1.0 + ], + "atteso": 10.0 + }, + { + "fn": "score_direct_x10", + "args": [ + 1.5 + ], + "atteso": 15.0 + }, + { + "fn": "score_direct_x10", + "args": [ + 2.0 + ], + "atteso": 20.0 + }, + { + "fn": "score_direct_x10", + "args": [ + 2.5 + ], + "atteso": 25.0 + }, + { + "fn": "score_direct_x10", + "args": [ + 3.0 + ], + "atteso": 30.0 + }, + { + "fn": "score_direct_x10", + "args": [ + 3.5 + ], + "atteso": 35.0 + }, + { + "fn": "score_direct_x10", + "args": [ + 4.0 + ], + "atteso": 40.0 + }, + { + "fn": "score_direct_x10", + "args": [ + 4.5 + ], + "atteso": 45.0 + }, + { + "fn": "score_direct_x10", + "args": [ + 5.0 + ], + "atteso": 50.0 + }, + { + "fn": "score_direct_x10", + "args": [ + 5.5 + ], + "atteso": 55.0 + }, + { + "fn": "score_direct_x10", + "args": [ + 6.0 + ], + "atteso": 60.0 + }, + { + "fn": "score_direct_x10", + "args": [ + 6.5 + ], + "atteso": 65.0 + }, + { + "fn": "score_direct_x10", + "args": [ + 7.0 + ], + "atteso": 70.0 + }, + { + "fn": "score_direct_x10", + "args": [ + 7.5 + ], + "atteso": 75.0 + }, + { + "fn": "score_direct_x10", + "args": [ + 8.0 + ], + "atteso": 80.0 + }, + { + "fn": "score_direct_x10", + "args": [ + 8.5 + ], + "atteso": 85.0 + }, + { + "fn": "score_direct_x10", + "args": [ + 9.0 + ], + "atteso": 90.0 + }, + { + "fn": "score_direct_x10", + "args": [ + 9.5 + ], + "atteso": 95.0 + }, + { + "fn": "score_direct_x10", + "args": [ + 10.0 + ], + "atteso": 100 + }, + { + "fn": "score_increasing_plateau", + "args": [ + 0, + 0, + 4 + ], + "atteso": 20 + }, + { + "fn": "score_increasing_plateau", + "args": [ + 0.5, + 0, + 4 + ], + "atteso": 30.0 + }, + { + "fn": "score_increasing_plateau", + "args": [ + 1.0, + 0, + 4 + ], + "atteso": 40.0 + }, + { + "fn": "score_increasing_plateau", + "args": [ + 1.5, + 0, + 4 + ], + "atteso": 50.0 + }, + { + "fn": "score_increasing_plateau", + "args": [ + 2.0, + 0, + 4 + ], + "atteso": 60.0 + }, + { + "fn": "score_increasing_plateau", + "args": [ + 2.5, + 0, + 4 + ], + "atteso": 70.0 + }, + { + "fn": "score_increasing_plateau", + "args": [ + 3.0, + 0, + 4 + ], + "atteso": 80.0 + }, + { + "fn": "score_increasing_plateau", + "args": [ + 3.5, + 0, + 4 + ], + "atteso": 90.0 + }, + { + "fn": "score_increasing_plateau", + "args": [ + 4.0, + 0, + 4 + ], + "atteso": 100 + }, + { + "fn": "score_increasing_plateau", + "args": [ + 4.5, + 0, + 4 + ], + "atteso": 100 + }, + { + "fn": "score_increasing_plateau", + "args": [ + 5.0, + 0, + 4 + ], + "atteso": 100 + }, + { + "fn": "score_increasing_plateau", + "args": [ + 5.5, + 0, + 4 + ], + "atteso": 100 + }, + { + "fn": "score_increasing_plateau", + "args": [ + 6.0, + 0, + 4 + ], + "atteso": 100 + }, + { + "fn": "score_increasing_plateau", + "args": [ + 6.5, + 0, + 4 + ], + "atteso": 100 + }, + { + "fn": "score_increasing_plateau", + "args": [ + 7.0, + 0, + 4 + ], + "atteso": 100 + }, + { + "fn": "score_fat_percent", + "args": [ + 0, + "M" + ], + "atteso": 40 + }, + { + "fn": "score_fat_percent", + "args": [ + 0.5, + "M" + ], + "atteso": 40 + }, + { + "fn": "score_fat_percent", + "args": [ + 1.0, + "M" + ], + "atteso": 40 + }, + { + "fn": "score_fat_percent", + "args": [ + 1.5, + "M" + ], + "atteso": 40 + }, + { + "fn": "score_fat_percent", + "args": [ + 2.0, + "M" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 2.5, + "M" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 3.0, + "M" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 3.5, + "M" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 4.0, + "M" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 4.5, + "M" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 5.0, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_fat_percent", + "args": [ + 5.5, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_fat_percent", + "args": [ + 6.0, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_fat_percent", + "args": [ + 6.5, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_fat_percent", + "args": [ + 7.0, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_fat_percent", + "args": [ + 7.5, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_fat_percent", + "args": [ + 8.0, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_fat_percent", + "args": [ + 8.5, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_fat_percent", + "args": [ + 9.0, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_fat_percent", + "args": [ + 9.5, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_fat_percent", + "args": [ + 10.0, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_fat_percent", + "args": [ + 10.5, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_fat_percent", + "args": [ + 11.0, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_fat_percent", + "args": [ + 11.5, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_fat_percent", + "args": [ + 12.0, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_fat_percent", + "args": [ + 12.5, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_fat_percent", + "args": [ + 13.0, + "M" + ], + "atteso": 90 + }, + { + "fn": "score_fat_percent", + "args": [ + 13.5, + "M" + ], + "atteso": 90 + }, + { + "fn": "score_fat_percent", + "args": [ + 14.0, + "M" + ], + "atteso": 90 + }, + { + "fn": "score_fat_percent", + "args": [ + 14.5, + "M" + ], + "atteso": 90 + }, + { + "fn": "score_fat_percent", + "args": [ + 15.0, + "M" + ], + "atteso": 90 + }, + { + "fn": "score_fat_percent", + "args": [ + 15.5, + "M" + ], + "atteso": 90 + }, + { + "fn": "score_fat_percent", + "args": [ + 16.0, + "M" + ], + "atteso": 90 + }, + { + "fn": "score_fat_percent", + "args": [ + 16.5, + "M" + ], + "atteso": 90 + }, + { + "fn": "score_fat_percent", + "args": [ + 17.0, + "M" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 17.5, + "M" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 18.0, + "M" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 18.5, + "M" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 19.0, + "M" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 19.5, + "M" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 20.0, + "M" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 20.5, + "M" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 21.0, + "M" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 21.5, + "M" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 22.0, + "M" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 22.5, + "M" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 23.0, + "M" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 23.5, + "M" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 24.0, + "M" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 24.5, + "M" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 25.0, + "M" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 25.5, + "M" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 26.0, + "M" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 26.5, + "M" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 27.0, + "M" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 27.5, + "M" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 28.0, + "M" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 28.5, + "M" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 29.0, + "M" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 29.5, + "M" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 30.0, + "M" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 30.5, + "M" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 31.0, + "M" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 31.5, + "M" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 32.0, + "M" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 32.5, + "M" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 33.0, + "M" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 33.5, + "M" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 34.0, + "M" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 34.5, + "M" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 35.0, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_fat_percent", + "args": [ + 35.5, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_fat_percent", + "args": [ + 36.0, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_fat_percent", + "args": [ + 36.5, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_fat_percent", + "args": [ + 37.0, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_fat_percent", + "args": [ + 37.5, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_fat_percent", + "args": [ + 38.0, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_fat_percent", + "args": [ + 38.5, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_fat_percent", + "args": [ + 39.0, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_fat_percent", + "args": [ + 39.5, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_fat_percent", + "args": [ + 40.0, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_fat_percent", + "args": [ + 40.5, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_fat_percent", + "args": [ + 41.0, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_fat_percent", + "args": [ + 41.5, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_fat_percent", + "args": [ + 42.0, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_fat_percent", + "args": [ + 42.5, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_fat_percent", + "args": [ + 43.0, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_fat_percent", + "args": [ + 43.5, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_fat_percent", + "args": [ + 44.0, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_fat_percent", + "args": [ + 44.5, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_fat_percent", + "args": [ + 45.0, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_muscle_percent", + "args": [ + 20, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 20.5, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 21.0, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 21.5, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 22.0, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 22.5, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 23.0, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 23.5, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 24.0, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 24.5, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 25.0, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 25.5, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 26.0, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 26.5, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 27.0, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 27.5, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 28.0, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 28.5, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 29.0, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 29.5, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 30.0, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 30.5, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 31.0, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 31.5, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 32.0, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 32.5, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 33.0, + "M" + ], + "atteso": 20.4 + }, + { + "fn": "score_muscle_percent", + "args": [ + 33.5, + "M" + ], + "atteso": 22.5 + }, + { + "fn": "score_muscle_percent", + "args": [ + 34.0, + "M" + ], + "atteso": 24.5 + }, + { + "fn": "score_muscle_percent", + "args": [ + 34.5, + "M" + ], + "atteso": 26.6 + }, + { + "fn": "score_muscle_percent", + "args": [ + 35.0, + "M" + ], + "atteso": 28.6 + }, + { + "fn": "score_muscle_percent", + "args": [ + 35.5, + "M" + ], + "atteso": 30.7 + }, + { + "fn": "score_muscle_percent", + "args": [ + 36.0, + "M" + ], + "atteso": 32.7 + }, + { + "fn": "score_muscle_percent", + "args": [ + 36.5, + "M" + ], + "atteso": 34.8 + }, + { + "fn": "score_muscle_percent", + "args": [ + 37.0, + "M" + ], + "atteso": 36.8 + }, + { + "fn": "score_muscle_percent", + "args": [ + 37.5, + "M" + ], + "atteso": 38.9 + }, + { + "fn": "score_muscle_percent", + "args": [ + 38.0, + "M" + ], + "atteso": 40.9 + }, + { + "fn": "score_muscle_percent", + "args": [ + 38.5, + "M" + ], + "atteso": 43.0 + }, + { + "fn": "score_muscle_percent", + "args": [ + 39.0, + "M" + ], + "atteso": 45.0 + }, + { + "fn": "score_muscle_percent", + "args": [ + 39.5, + "M" + ], + "atteso": 47.1 + }, + { + "fn": "score_muscle_percent", + "args": [ + 40.0, + "M" + ], + "atteso": 49.1 + }, + { + "fn": "score_muscle_percent", + "args": [ + 40.5, + "M" + ], + "atteso": 51.2 + }, + { + "fn": "score_muscle_percent", + "args": [ + 41.0, + "M" + ], + "atteso": 53.2 + }, + { + "fn": "score_muscle_percent", + "args": [ + 41.5, + "M" + ], + "atteso": 55.3 + }, + { + "fn": "score_muscle_percent", + "args": [ + 42.0, + "M" + ], + "atteso": 57.3 + }, + { + "fn": "score_muscle_percent", + "args": [ + 42.5, + "M" + ], + "atteso": 59.4 + }, + { + "fn": "score_muscle_percent", + "args": [ + 43.0, + "M" + ], + "atteso": 61.4 + }, + { + "fn": "score_muscle_percent", + "args": [ + 43.5, + "M" + ], + "atteso": 63.5 + }, + { + "fn": "score_muscle_percent", + "args": [ + 44.0, + "M" + ], + "atteso": 65.5 + }, + { + "fn": "score_muscle_percent", + "args": [ + 44.5, + "M" + ], + "atteso": 67.6 + }, + { + "fn": "score_muscle_percent", + "args": [ + 45.0, + "M" + ], + "atteso": 69.6 + }, + { + "fn": "score_muscle_percent", + "args": [ + 45.5, + "M" + ], + "atteso": 71.7 + }, + { + "fn": "score_muscle_percent", + "args": [ + 46.0, + "M" + ], + "atteso": 73.7 + }, + { + "fn": "score_muscle_percent", + "args": [ + 46.5, + "M" + ], + "atteso": 75.8 + }, + { + "fn": "score_muscle_percent", + "args": [ + 47.0, + "M" + ], + "atteso": 77.8 + }, + { + "fn": "score_muscle_percent", + "args": [ + 47.5, + "M" + ], + "atteso": 79.9 + }, + { + "fn": "score_muscle_percent", + "args": [ + 48.0, + "M" + ], + "atteso": 81.9 + }, + { + "fn": "score_muscle_percent", + "args": [ + 48.5, + "M" + ], + "atteso": 84.0 + }, + { + "fn": "score_muscle_percent", + "args": [ + 49.0, + "M" + ], + "atteso": 86.1 + }, + { + "fn": "score_muscle_percent", + "args": [ + 49.5, + "M" + ], + "atteso": 88.1 + }, + { + "fn": "score_muscle_percent", + "args": [ + 50.0, + "M" + ], + "atteso": 90.2 + }, + { + "fn": "score_muscle_percent", + "args": [ + 50.5, + "M" + ], + "atteso": 92.2 + }, + { + "fn": "score_muscle_percent", + "args": [ + 51.0, + "M" + ], + "atteso": 94.3 + }, + { + "fn": "score_muscle_percent", + "args": [ + 51.5, + "M" + ], + "atteso": 96.3 + }, + { + "fn": "score_muscle_percent", + "args": [ + 52.0, + "M" + ], + "atteso": 98.4 + }, + { + "fn": "score_muscle_percent", + "args": [ + 52.5, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 53.0, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 53.5, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 54.0, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 54.5, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 55.0, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 55.5, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 56.0, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 56.5, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 57.0, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 57.5, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 58.0, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 58.5, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 59.0, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 59.5, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 60.0, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.5, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.51, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.52, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.53, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.54, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.56, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.57, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.58, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.59, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.6, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.61, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.62, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.63, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.64, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.66, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.67, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.68, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.69, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.7, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.71, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.72, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.73, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.74, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.76, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.77, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.78, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.79, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.8, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.81, + "M" + ], + "atteso": 96.0 + }, + { + "fn": "score_whr", + "args": [ + 0.82, + "M" + ], + "atteso": 92.0 + }, + { + "fn": "score_whr", + "args": [ + 0.83, + "M" + ], + "atteso": 88.0 + }, + { + "fn": "score_whr", + "args": [ + 0.84, + "M" + ], + "atteso": 84.0 + }, + { + "fn": "score_whr", + "args": [ + 0.85, + "M" + ], + "atteso": 80.0 + }, + { + "fn": "score_whr", + "args": [ + 0.86, + "M" + ], + "atteso": 76.0 + }, + { + "fn": "score_whr", + "args": [ + 0.87, + "M" + ], + "atteso": 72.0 + }, + { + "fn": "score_whr", + "args": [ + 0.88, + "M" + ], + "atteso": 68.0 + }, + { + "fn": "score_whr", + "args": [ + 0.89, + "M" + ], + "atteso": 64.0 + }, + { + "fn": "score_whr", + "args": [ + 0.9, + "M" + ], + "atteso": 60 + }, + { + "fn": "score_whr", + "args": [ + 0.91, + "M" + ], + "atteso": 57.0 + }, + { + "fn": "score_whr", + "args": [ + 0.92, + "M" + ], + "atteso": 54.0 + }, + { + "fn": "score_whr", + "args": [ + 0.93, + "M" + ], + "atteso": 51.0 + }, + { + "fn": "score_whr", + "args": [ + 0.94, + "M" + ], + "atteso": 48.0 + }, + { + "fn": "score_whr", + "args": [ + 0.95, + "M" + ], + "atteso": 45.0 + }, + { + "fn": "score_whr", + "args": [ + 0.96, + "M" + ], + "atteso": 42.0 + }, + { + "fn": "score_whr", + "args": [ + 0.97, + "M" + ], + "atteso": 39.0 + }, + { + "fn": "score_whr", + "args": [ + 0.98, + "M" + ], + "atteso": 36.0 + }, + { + "fn": "score_whr", + "args": [ + 0.99, + "M" + ], + "atteso": 33.0 + }, + { + "fn": "score_whr", + "args": [ + 1.0, + "M" + ], + "atteso": 30.0 + }, + { + "fn": "score_whr", + "args": [ + 1.01, + "M" + ], + "atteso": 27.0 + }, + { + "fn": "score_whr", + "args": [ + 1.02, + "M" + ], + "atteso": 24.0 + }, + { + "fn": "score_whr", + "args": [ + 1.03, + "M" + ], + "atteso": 21.0 + }, + { + "fn": "score_whr", + "args": [ + 1.04, + "M" + ], + "atteso": 18.0 + }, + { + "fn": "score_whr", + "args": [ + 1.05, + "M" + ], + "atteso": 15.0 + }, + { + "fn": "score_whr", + "args": [ + 1.06, + "M" + ], + "atteso": 12.0 + }, + { + "fn": "score_whr", + "args": [ + 1.07, + "M" + ], + "atteso": 9.0 + }, + { + "fn": "score_whr", + "args": [ + 1.08, + "M" + ], + "atteso": 6.0 + }, + { + "fn": "score_whr", + "args": [ + 1.09, + "M" + ], + "atteso": 3.0 + }, + { + "fn": "score_whr", + "args": [ + 1.1, + "M" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.11, + "M" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.12, + "M" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.13, + "M" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.14, + "M" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.15, + "M" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.16, + "M" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.17, + "M" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.18, + "M" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.19, + "M" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.2, + "M" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.21, + "M" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.22, + "M" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.23, + "M" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.24, + "M" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.25, + "M" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.26, + "M" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.27, + "M" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.28, + "M" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.29, + "M" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.3, + "M" + ], + "atteso": 0 + }, + { + "fn": "score_fat_percent", + "args": [ + 0, + "F" + ], + "atteso": 40 + }, + { + "fn": "score_fat_percent", + "args": [ + 0.5, + "F" + ], + "atteso": 40 + }, + { + "fn": "score_fat_percent", + "args": [ + 1.0, + "F" + ], + "atteso": 40 + }, + { + "fn": "score_fat_percent", + "args": [ + 1.5, + "F" + ], + "atteso": 40 + }, + { + "fn": "score_fat_percent", + "args": [ + 2.0, + "F" + ], + "atteso": 40 + }, + { + "fn": "score_fat_percent", + "args": [ + 2.5, + "F" + ], + "atteso": 40 + }, + { + "fn": "score_fat_percent", + "args": [ + 3.0, + "F" + ], + "atteso": 40 + }, + { + "fn": "score_fat_percent", + "args": [ + 3.5, + "F" + ], + "atteso": 40 + }, + { + "fn": "score_fat_percent", + "args": [ + 4.0, + "F" + ], + "atteso": 40 + }, + { + "fn": "score_fat_percent", + "args": [ + 4.5, + "F" + ], + "atteso": 40 + }, + { + "fn": "score_fat_percent", + "args": [ + 5.0, + "F" + ], + "atteso": 40 + }, + { + "fn": "score_fat_percent", + "args": [ + 5.5, + "F" + ], + "atteso": 40 + }, + { + "fn": "score_fat_percent", + "args": [ + 6.0, + "F" + ], + "atteso": 40 + }, + { + "fn": "score_fat_percent", + "args": [ + 6.5, + "F" + ], + "atteso": 40 + }, + { + "fn": "score_fat_percent", + "args": [ + 7.0, + "F" + ], + "atteso": 40 + }, + { + "fn": "score_fat_percent", + "args": [ + 7.5, + "F" + ], + "atteso": 40 + }, + { + "fn": "score_fat_percent", + "args": [ + 8.0, + "F" + ], + "atteso": 40 + }, + { + "fn": "score_fat_percent", + "args": [ + 8.5, + "F" + ], + "atteso": 40 + }, + { + "fn": "score_fat_percent", + "args": [ + 9.0, + "F" + ], + "atteso": 40 + }, + { + "fn": "score_fat_percent", + "args": [ + 9.5, + "F" + ], + "atteso": 40 + }, + { + "fn": "score_fat_percent", + "args": [ + 10.0, + "F" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 10.5, + "F" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 11.0, + "F" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 11.5, + "F" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 12.0, + "F" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 12.5, + "F" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 13.0, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_fat_percent", + "args": [ + 13.5, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_fat_percent", + "args": [ + 14.0, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_fat_percent", + "args": [ + 14.5, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_fat_percent", + "args": [ + 15.0, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_fat_percent", + "args": [ + 15.5, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_fat_percent", + "args": [ + 16.0, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_fat_percent", + "args": [ + 16.5, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_fat_percent", + "args": [ + 17.0, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_fat_percent", + "args": [ + 17.5, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_fat_percent", + "args": [ + 18.0, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_fat_percent", + "args": [ + 18.5, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_fat_percent", + "args": [ + 19.0, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_fat_percent", + "args": [ + 19.5, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_fat_percent", + "args": [ + 20.0, + "F" + ], + "atteso": 90 + }, + { + "fn": "score_fat_percent", + "args": [ + 20.5, + "F" + ], + "atteso": 90 + }, + { + "fn": "score_fat_percent", + "args": [ + 21.0, + "F" + ], + "atteso": 90 + }, + { + "fn": "score_fat_percent", + "args": [ + 21.5, + "F" + ], + "atteso": 90 + }, + { + "fn": "score_fat_percent", + "args": [ + 22.0, + "F" + ], + "atteso": 90 + }, + { + "fn": "score_fat_percent", + "args": [ + 22.5, + "F" + ], + "atteso": 90 + }, + { + "fn": "score_fat_percent", + "args": [ + 23.0, + "F" + ], + "atteso": 90 + }, + { + "fn": "score_fat_percent", + "args": [ + 23.5, + "F" + ], + "atteso": 90 + }, + { + "fn": "score_fat_percent", + "args": [ + 24.0, + "F" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 24.5, + "F" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 25.0, + "F" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 25.5, + "F" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 26.0, + "F" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 26.5, + "F" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 27.0, + "F" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 27.5, + "F" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 28.0, + "F" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 28.5, + "F" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 29.0, + "F" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 29.5, + "F" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 30.0, + "F" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 30.5, + "F" + ], + "atteso": 65 + }, + { + "fn": "score_fat_percent", + "args": [ + 31.0, + "F" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 31.5, + "F" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 32.0, + "F" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 32.5, + "F" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 33.0, + "F" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 33.5, + "F" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 34.0, + "F" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 34.5, + "F" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 35.0, + "F" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 35.5, + "F" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 36.0, + "F" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 36.5, + "F" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 37.0, + "F" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 37.5, + "F" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 38.0, + "F" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 38.5, + "F" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 39.0, + "F" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 39.5, + "F" + ], + "atteso": 30 + }, + { + "fn": "score_fat_percent", + "args": [ + 40.0, + "F" + ], + "atteso": 10 + }, + { + "fn": "score_fat_percent", + "args": [ + 40.5, + "F" + ], + "atteso": 10 + }, + { + "fn": "score_fat_percent", + "args": [ + 41.0, + "F" + ], + "atteso": 10 + }, + { + "fn": "score_fat_percent", + "args": [ + 41.5, + "F" + ], + "atteso": 10 + }, + { + "fn": "score_fat_percent", + "args": [ + 42.0, + "F" + ], + "atteso": 10 + }, + { + "fn": "score_fat_percent", + "args": [ + 42.5, + "F" + ], + "atteso": 10 + }, + { + "fn": "score_fat_percent", + "args": [ + 43.0, + "F" + ], + "atteso": 10 + }, + { + "fn": "score_fat_percent", + "args": [ + 43.5, + "F" + ], + "atteso": 10 + }, + { + "fn": "score_fat_percent", + "args": [ + 44.0, + "F" + ], + "atteso": 10 + }, + { + "fn": "score_fat_percent", + "args": [ + 44.5, + "F" + ], + "atteso": 10 + }, + { + "fn": "score_fat_percent", + "args": [ + 45.0, + "F" + ], + "atteso": 10 + }, + { + "fn": "score_muscle_percent", + "args": [ + 20, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 20.5, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 21.0, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 21.5, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 22.0, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 22.5, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 23.0, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 23.5, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 24.0, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 24.5, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 25.0, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 25.5, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 26.0, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 26.5, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 27.0, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 27.5, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_muscle_percent", + "args": [ + 28.0, + "F" + ], + "atteso": 20.9 + }, + { + "fn": "score_muscle_percent", + "args": [ + 28.5, + "F" + ], + "atteso": 23.0 + }, + { + "fn": "score_muscle_percent", + "args": [ + 29.0, + "F" + ], + "atteso": 25.2 + }, + { + "fn": "score_muscle_percent", + "args": [ + 29.5, + "F" + ], + "atteso": 27.4 + }, + { + "fn": "score_muscle_percent", + "args": [ + 30.0, + "F" + ], + "atteso": 29.6 + }, + { + "fn": "score_muscle_percent", + "args": [ + 30.5, + "F" + ], + "atteso": 31.7 + }, + { + "fn": "score_muscle_percent", + "args": [ + 31.0, + "F" + ], + "atteso": 33.9 + }, + { + "fn": "score_muscle_percent", + "args": [ + 31.5, + "F" + ], + "atteso": 36.1 + }, + { + "fn": "score_muscle_percent", + "args": [ + 32.0, + "F" + ], + "atteso": 38.3 + }, + { + "fn": "score_muscle_percent", + "args": [ + 32.5, + "F" + ], + "atteso": 40.4 + }, + { + "fn": "score_muscle_percent", + "args": [ + 33.0, + "F" + ], + "atteso": 42.6 + }, + { + "fn": "score_muscle_percent", + "args": [ + 33.5, + "F" + ], + "atteso": 44.8 + }, + { + "fn": "score_muscle_percent", + "args": [ + 34.0, + "F" + ], + "atteso": 47.0 + }, + { + "fn": "score_muscle_percent", + "args": [ + 34.5, + "F" + ], + "atteso": 49.1 + }, + { + "fn": "score_muscle_percent", + "args": [ + 35.0, + "F" + ], + "atteso": 51.3 + }, + { + "fn": "score_muscle_percent", + "args": [ + 35.5, + "F" + ], + "atteso": 53.5 + }, + { + "fn": "score_muscle_percent", + "args": [ + 36.0, + "F" + ], + "atteso": 55.7 + }, + { + "fn": "score_muscle_percent", + "args": [ + 36.5, + "F" + ], + "atteso": 57.8 + }, + { + "fn": "score_muscle_percent", + "args": [ + 37.0, + "F" + ], + "atteso": 60.0 + }, + { + "fn": "score_muscle_percent", + "args": [ + 37.5, + "F" + ], + "atteso": 62.2 + }, + { + "fn": "score_muscle_percent", + "args": [ + 38.0, + "F" + ], + "atteso": 64.3 + }, + { + "fn": "score_muscle_percent", + "args": [ + 38.5, + "F" + ], + "atteso": 66.5 + }, + { + "fn": "score_muscle_percent", + "args": [ + 39.0, + "F" + ], + "atteso": 68.7 + }, + { + "fn": "score_muscle_percent", + "args": [ + 39.5, + "F" + ], + "atteso": 70.9 + }, + { + "fn": "score_muscle_percent", + "args": [ + 40.0, + "F" + ], + "atteso": 73.0 + }, + { + "fn": "score_muscle_percent", + "args": [ + 40.5, + "F" + ], + "atteso": 75.2 + }, + { + "fn": "score_muscle_percent", + "args": [ + 41.0, + "F" + ], + "atteso": 77.4 + }, + { + "fn": "score_muscle_percent", + "args": [ + 41.5, + "F" + ], + "atteso": 79.6 + }, + { + "fn": "score_muscle_percent", + "args": [ + 42.0, + "F" + ], + "atteso": 81.7 + }, + { + "fn": "score_muscle_percent", + "args": [ + 42.5, + "F" + ], + "atteso": 83.9 + }, + { + "fn": "score_muscle_percent", + "args": [ + 43.0, + "F" + ], + "atteso": 86.1 + }, + { + "fn": "score_muscle_percent", + "args": [ + 43.5, + "F" + ], + "atteso": 88.3 + }, + { + "fn": "score_muscle_percent", + "args": [ + 44.0, + "F" + ], + "atteso": 90.4 + }, + { + "fn": "score_muscle_percent", + "args": [ + 44.5, + "F" + ], + "atteso": 92.6 + }, + { + "fn": "score_muscle_percent", + "args": [ + 45.0, + "F" + ], + "atteso": 94.8 + }, + { + "fn": "score_muscle_percent", + "args": [ + 45.5, + "F" + ], + "atteso": 97.0 + }, + { + "fn": "score_muscle_percent", + "args": [ + 46.0, + "F" + ], + "atteso": 99.1 + }, + { + "fn": "score_muscle_percent", + "args": [ + 46.5, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 47.0, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 47.5, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 48.0, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 48.5, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 49.0, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 49.5, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 50.0, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 50.5, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 51.0, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 51.5, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 52.0, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 52.5, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 53.0, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 53.5, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 54.0, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 54.5, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 55.0, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 55.5, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 56.0, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 56.5, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 57.0, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 57.5, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 58.0, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 58.5, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 59.0, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 59.5, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_muscle_percent", + "args": [ + 60.0, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.5, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.51, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.52, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.53, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.54, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.56, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.57, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.58, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.59, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.6, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.61, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.62, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.63, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.64, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.66, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.67, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.68, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.69, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.7, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.71, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.72, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.73, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.74, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_whr", + "args": [ + 0.76, + "F" + ], + "atteso": 96.0 + }, + { + "fn": "score_whr", + "args": [ + 0.77, + "F" + ], + "atteso": 92.0 + }, + { + "fn": "score_whr", + "args": [ + 0.78, + "F" + ], + "atteso": 88.0 + }, + { + "fn": "score_whr", + "args": [ + 0.79, + "F" + ], + "atteso": 84.0 + }, + { + "fn": "score_whr", + "args": [ + 0.8, + "F" + ], + "atteso": 80.0 + }, + { + "fn": "score_whr", + "args": [ + 0.81, + "F" + ], + "atteso": 76.0 + }, + { + "fn": "score_whr", + "args": [ + 0.82, + "F" + ], + "atteso": 72.0 + }, + { + "fn": "score_whr", + "args": [ + 0.83, + "F" + ], + "atteso": 68.0 + }, + { + "fn": "score_whr", + "args": [ + 0.84, + "F" + ], + "atteso": 64.0 + }, + { + "fn": "score_whr", + "args": [ + 0.85, + "F" + ], + "atteso": 60 + }, + { + "fn": "score_whr", + "args": [ + 0.86, + "F" + ], + "atteso": 57.0 + }, + { + "fn": "score_whr", + "args": [ + 0.87, + "F" + ], + "atteso": 54.0 + }, + { + "fn": "score_whr", + "args": [ + 0.88, + "F" + ], + "atteso": 51.0 + }, + { + "fn": "score_whr", + "args": [ + 0.89, + "F" + ], + "atteso": 48.0 + }, + { + "fn": "score_whr", + "args": [ + 0.9, + "F" + ], + "atteso": 45.0 + }, + { + "fn": "score_whr", + "args": [ + 0.91, + "F" + ], + "atteso": 42.0 + }, + { + "fn": "score_whr", + "args": [ + 0.92, + "F" + ], + "atteso": 39.0 + }, + { + "fn": "score_whr", + "args": [ + 0.93, + "F" + ], + "atteso": 36.0 + }, + { + "fn": "score_whr", + "args": [ + 0.94, + "F" + ], + "atteso": 33.0 + }, + { + "fn": "score_whr", + "args": [ + 0.95, + "F" + ], + "atteso": 30.0 + }, + { + "fn": "score_whr", + "args": [ + 0.96, + "F" + ], + "atteso": 27.0 + }, + { + "fn": "score_whr", + "args": [ + 0.97, + "F" + ], + "atteso": 24.0 + }, + { + "fn": "score_whr", + "args": [ + 0.98, + "F" + ], + "atteso": 21.0 + }, + { + "fn": "score_whr", + "args": [ + 0.99, + "F" + ], + "atteso": 18.0 + }, + { + "fn": "score_whr", + "args": [ + 1.0, + "F" + ], + "atteso": 15.0 + }, + { + "fn": "score_whr", + "args": [ + 1.01, + "F" + ], + "atteso": 12.0 + }, + { + "fn": "score_whr", + "args": [ + 1.02, + "F" + ], + "atteso": 9.0 + }, + { + "fn": "score_whr", + "args": [ + 1.03, + "F" + ], + "atteso": 6.0 + }, + { + "fn": "score_whr", + "args": [ + 1.04, + "F" + ], + "atteso": 3.0 + }, + { + "fn": "score_whr", + "args": [ + 1.05, + "F" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.06, + "F" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.07, + "F" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.08, + "F" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.09, + "F" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.1, + "F" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.11, + "F" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.12, + "F" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.13, + "F" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.14, + "F" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.15, + "F" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.16, + "F" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.17, + "F" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.18, + "F" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.19, + "F" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.2, + "F" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.21, + "F" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.22, + "F" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.23, + "F" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.24, + "F" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.25, + "F" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.26, + "F" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.27, + "F" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.28, + "F" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.29, + "F" + ], + "atteso": 0 + }, + { + "fn": "score_whr", + "args": [ + 1.3, + "F" + ], + "atteso": 0 + }, + { + "fn": "score_spo2", + "args": [ + 85 + ], + "atteso": 15 + }, + { + "fn": "score_spo2", + "args": [ + 86 + ], + "atteso": 15 + }, + { + "fn": "score_spo2", + "args": [ + 87 + ], + "atteso": 15 + }, + { + "fn": "score_spo2", + "args": [ + 88 + ], + "atteso": 15 + }, + { + "fn": "score_spo2", + "args": [ + 89 + ], + "atteso": 15 + }, + { + "fn": "score_spo2", + "args": [ + 90 + ], + "atteso": 50 + }, + { + "fn": "score_spo2", + "args": [ + 91 + ], + "atteso": 50 + }, + { + "fn": "score_spo2", + "args": [ + 92 + ], + "atteso": 50 + }, + { + "fn": "score_spo2", + "args": [ + 93 + ], + "atteso": 50 + }, + { + "fn": "score_spo2", + "args": [ + 94 + ], + "atteso": 50 + }, + { + "fn": "score_spo2", + "args": [ + 95 + ], + "atteso": 85 + }, + { + "fn": "score_spo2", + "args": [ + 96 + ], + "atteso": 85 + }, + { + "fn": "score_spo2", + "args": [ + 97 + ], + "atteso": 100 + }, + { + "fn": "score_spo2", + "args": [ + 98 + ], + "atteso": 100 + }, + { + "fn": "score_spo2", + "args": [ + 99 + ], + "atteso": 100 + }, + { + "fn": "score_spo2", + "args": [ + 100 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 90, + 50 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 90, + 55 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 90, + 60 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 90, + 65 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 90, + 70 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 90, + 75 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 90, + 80 + ], + "atteso": 90 + }, + { + "fn": "score_blood_pressure", + "args": [ + 90, + 85 + ], + "atteso": 70 + }, + { + "fn": "score_blood_pressure", + "args": [ + 90, + 90 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 90, + 95 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 90, + 100 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 90, + 105 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 90, + 110 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 90, + 115 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 90, + 120 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 95, + 50 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 95, + 55 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 95, + 60 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 95, + 65 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 95, + 70 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 95, + 75 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 95, + 80 + ], + "atteso": 90 + }, + { + "fn": "score_blood_pressure", + "args": [ + 95, + 85 + ], + "atteso": 70 + }, + { + "fn": "score_blood_pressure", + "args": [ + 95, + 90 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 95, + 95 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 95, + 100 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 95, + 105 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 95, + 110 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 95, + 115 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 95, + 120 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 100, + 50 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 100, + 55 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 100, + 60 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 100, + 65 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 100, + 70 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 100, + 75 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 100, + 80 + ], + "atteso": 90 + }, + { + "fn": "score_blood_pressure", + "args": [ + 100, + 85 + ], + "atteso": 70 + }, + { + "fn": "score_blood_pressure", + "args": [ + 100, + 90 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 100, + 95 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 100, + 100 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 100, + 105 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 100, + 110 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 100, + 115 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 100, + 120 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 105, + 50 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 105, + 55 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 105, + 60 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 105, + 65 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 105, + 70 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 105, + 75 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 105, + 80 + ], + "atteso": 90 + }, + { + "fn": "score_blood_pressure", + "args": [ + 105, + 85 + ], + "atteso": 70 + }, + { + "fn": "score_blood_pressure", + "args": [ + 105, + 90 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 105, + 95 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 105, + 100 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 105, + 105 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 105, + 110 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 105, + 115 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 105, + 120 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 110, + 50 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 110, + 55 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 110, + 60 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 110, + 65 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 110, + 70 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 110, + 75 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 110, + 80 + ], + "atteso": 90 + }, + { + "fn": "score_blood_pressure", + "args": [ + 110, + 85 + ], + "atteso": 70 + }, + { + "fn": "score_blood_pressure", + "args": [ + 110, + 90 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 110, + 95 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 110, + 100 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 110, + 105 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 110, + 110 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 110, + 115 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 110, + 120 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 115, + 50 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 115, + 55 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 115, + 60 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 115, + 65 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 115, + 70 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 115, + 75 + ], + "atteso": 100 + }, + { + "fn": "score_blood_pressure", + "args": [ + 115, + 80 + ], + "atteso": 90 + }, + { + "fn": "score_blood_pressure", + "args": [ + 115, + 85 + ], + "atteso": 70 + }, + { + "fn": "score_blood_pressure", + "args": [ + 115, + 90 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 115, + 95 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 115, + 100 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 115, + 105 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 115, + 110 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 115, + 115 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 115, + 120 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 120, + 50 + ], + "atteso": 90 + }, + { + "fn": "score_blood_pressure", + "args": [ + 120, + 55 + ], + "atteso": 90 + }, + { + "fn": "score_blood_pressure", + "args": [ + 120, + 60 + ], + "atteso": 90 + }, + { + "fn": "score_blood_pressure", + "args": [ + 120, + 65 + ], + "atteso": 90 + }, + { + "fn": "score_blood_pressure", + "args": [ + 120, + 70 + ], + "atteso": 90 + }, + { + "fn": "score_blood_pressure", + "args": [ + 120, + 75 + ], + "atteso": 90 + }, + { + "fn": "score_blood_pressure", + "args": [ + 120, + 80 + ], + "atteso": 90 + }, + { + "fn": "score_blood_pressure", + "args": [ + 120, + 85 + ], + "atteso": 70 + }, + { + "fn": "score_blood_pressure", + "args": [ + 120, + 90 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 120, + 95 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 120, + 100 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 120, + 105 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 120, + 110 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 120, + 115 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 120, + 120 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 125, + 50 + ], + "atteso": 90 + }, + { + "fn": "score_blood_pressure", + "args": [ + 125, + 55 + ], + "atteso": 90 + }, + { + "fn": "score_blood_pressure", + "args": [ + 125, + 60 + ], + "atteso": 90 + }, + { + "fn": "score_blood_pressure", + "args": [ + 125, + 65 + ], + "atteso": 90 + }, + { + "fn": "score_blood_pressure", + "args": [ + 125, + 70 + ], + "atteso": 90 + }, + { + "fn": "score_blood_pressure", + "args": [ + 125, + 75 + ], + "atteso": 90 + }, + { + "fn": "score_blood_pressure", + "args": [ + 125, + 80 + ], + "atteso": 90 + }, + { + "fn": "score_blood_pressure", + "args": [ + 125, + 85 + ], + "atteso": 70 + }, + { + "fn": "score_blood_pressure", + "args": [ + 125, + 90 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 125, + 95 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 125, + 100 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 125, + 105 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 125, + 110 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 125, + 115 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 125, + 120 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 130, + 50 + ], + "atteso": 70 + }, + { + "fn": "score_blood_pressure", + "args": [ + 130, + 55 + ], + "atteso": 70 + }, + { + "fn": "score_blood_pressure", + "args": [ + 130, + 60 + ], + "atteso": 70 + }, + { + "fn": "score_blood_pressure", + "args": [ + 130, + 65 + ], + "atteso": 70 + }, + { + "fn": "score_blood_pressure", + "args": [ + 130, + 70 + ], + "atteso": 70 + }, + { + "fn": "score_blood_pressure", + "args": [ + 130, + 75 + ], + "atteso": 70 + }, + { + "fn": "score_blood_pressure", + "args": [ + 130, + 80 + ], + "atteso": 70 + }, + { + "fn": "score_blood_pressure", + "args": [ + 130, + 85 + ], + "atteso": 70 + }, + { + "fn": "score_blood_pressure", + "args": [ + 130, + 90 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 130, + 95 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 130, + 100 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 130, + 105 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 130, + 110 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 130, + 115 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 130, + 120 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 135, + 50 + ], + "atteso": 70 + }, + { + "fn": "score_blood_pressure", + "args": [ + 135, + 55 + ], + "atteso": 70 + }, + { + "fn": "score_blood_pressure", + "args": [ + 135, + 60 + ], + "atteso": 70 + }, + { + "fn": "score_blood_pressure", + "args": [ + 135, + 65 + ], + "atteso": 70 + }, + { + "fn": "score_blood_pressure", + "args": [ + 135, + 70 + ], + "atteso": 70 + }, + { + "fn": "score_blood_pressure", + "args": [ + 135, + 75 + ], + "atteso": 70 + }, + { + "fn": "score_blood_pressure", + "args": [ + 135, + 80 + ], + "atteso": 70 + }, + { + "fn": "score_blood_pressure", + "args": [ + 135, + 85 + ], + "atteso": 70 + }, + { + "fn": "score_blood_pressure", + "args": [ + 135, + 90 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 135, + 95 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 135, + 100 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 135, + 105 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 135, + 110 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 135, + 115 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 135, + 120 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 140, + 50 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 140, + 55 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 140, + 60 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 140, + 65 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 140, + 70 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 140, + 75 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 140, + 80 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 140, + 85 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 140, + 90 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 140, + 95 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 140, + 100 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 140, + 105 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 140, + 110 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 140, + 115 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 140, + 120 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 145, + 50 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 145, + 55 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 145, + 60 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 145, + 65 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 145, + 70 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 145, + 75 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 145, + 80 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 145, + 85 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 145, + 90 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 145, + 95 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 145, + 100 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 145, + 105 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 145, + 110 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 145, + 115 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 145, + 120 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 150, + 50 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 150, + 55 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 150, + 60 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 150, + 65 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 150, + 70 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 150, + 75 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 150, + 80 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 150, + 85 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 150, + 90 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 150, + 95 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 150, + 100 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 150, + 105 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 150, + 110 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 150, + 115 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 150, + 120 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 155, + 50 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 155, + 55 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 155, + 60 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 155, + 65 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 155, + 70 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 155, + 75 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 155, + 80 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 155, + 85 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 155, + 90 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 155, + 95 + ], + "atteso": 45 + }, + { + "fn": "score_blood_pressure", + "args": [ + 155, + 100 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 155, + 105 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 155, + 110 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 155, + 115 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 155, + 120 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 160, + 50 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 160, + 55 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 160, + 60 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 160, + 65 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 160, + 70 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 160, + 75 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 160, + 80 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 160, + 85 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 160, + 90 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 160, + 95 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 160, + 100 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 160, + 105 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 160, + 110 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 160, + 115 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 160, + 120 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 165, + 50 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 165, + 55 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 165, + 60 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 165, + 65 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 165, + 70 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 165, + 75 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 165, + 80 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 165, + 85 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 165, + 90 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 165, + 95 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 165, + 100 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 165, + 105 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 165, + 110 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 165, + 115 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 165, + 120 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 170, + 50 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 170, + 55 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 170, + 60 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 170, + 65 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 170, + 70 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 170, + 75 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 170, + 80 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 170, + 85 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 170, + 90 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 170, + 95 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 170, + 100 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 170, + 105 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 170, + 110 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 170, + 115 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 170, + 120 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 175, + 50 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 175, + 55 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 175, + 60 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 175, + 65 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 175, + 70 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 175, + 75 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 175, + 80 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 175, + 85 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 175, + 90 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 175, + 95 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 175, + 100 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 175, + 105 + ], + "atteso": 25 + }, + { + "fn": "score_blood_pressure", + "args": [ + 175, + 110 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 175, + 115 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 175, + 120 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 180, + 50 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 180, + 55 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 180, + 60 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 180, + 65 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 180, + 70 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 180, + 75 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 180, + 80 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 180, + 85 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 180, + 90 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 180, + 95 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 180, + 100 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 180, + 105 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 180, + 110 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 180, + 115 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 180, + 120 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 185, + 50 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 185, + 55 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 185, + 60 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 185, + 65 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 185, + 70 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 185, + 75 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 185, + 80 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 185, + 85 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 185, + 90 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 185, + 95 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 185, + 100 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 185, + 105 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 185, + 110 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 185, + 115 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 185, + 120 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 190, + 50 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 190, + 55 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 190, + 60 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 190, + 65 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 190, + 70 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 190, + 75 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 190, + 80 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 190, + 85 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 190, + 90 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 190, + 95 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 190, + 100 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 190, + 105 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 190, + 110 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 190, + 115 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 190, + 120 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 195, + 50 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 195, + 55 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 195, + 60 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 195, + 65 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 195, + 70 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 195, + 75 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 195, + 80 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 195, + 85 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 195, + 90 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 195, + 95 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 195, + 100 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 195, + 105 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 195, + 110 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 195, + 115 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 195, + 120 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 200, + 50 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 200, + 55 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 200, + 60 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 200, + 65 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 200, + 70 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 200, + 75 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 200, + 80 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 200, + 85 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 200, + 90 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 200, + 95 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 200, + 100 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 200, + 105 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 200, + 110 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 200, + 115 + ], + "atteso": 5 + }, + { + "fn": "score_blood_pressure", + "args": [ + 200, + 120 + ], + "atteso": 5 + }, + { + "fn": "score_hrr", + "args": [ + 0 + ], + "atteso": 20 + }, + { + "fn": "score_hrr", + "args": [ + 1 + ], + "atteso": 24.2 + }, + { + "fn": "score_hrr", + "args": [ + 2 + ], + "atteso": 28.3 + }, + { + "fn": "score_hrr", + "args": [ + 3 + ], + "atteso": 32.5 + }, + { + "fn": "score_hrr", + "args": [ + 4 + ], + "atteso": 36.7 + }, + { + "fn": "score_hrr", + "args": [ + 5 + ], + "atteso": 40.8 + }, + { + "fn": "score_hrr", + "args": [ + 6 + ], + "atteso": 45.0 + }, + { + "fn": "score_hrr", + "args": [ + 7 + ], + "atteso": 49.2 + }, + { + "fn": "score_hrr", + "args": [ + 8 + ], + "atteso": 53.3 + }, + { + "fn": "score_hrr", + "args": [ + 9 + ], + "atteso": 57.5 + }, + { + "fn": "score_hrr", + "args": [ + 10 + ], + "atteso": 61.7 + }, + { + "fn": "score_hrr", + "args": [ + 11 + ], + "atteso": 65.8 + }, + { + "fn": "score_hrr", + "args": [ + 12 + ], + "atteso": 70 + }, + { + "fn": "score_hrr", + "args": [ + 13 + ], + "atteso": 71.7 + }, + { + "fn": "score_hrr", + "args": [ + 14 + ], + "atteso": 73.3 + }, + { + "fn": "score_hrr", + "args": [ + 15 + ], + "atteso": 75.0 + }, + { + "fn": "score_hrr", + "args": [ + 16 + ], + "atteso": 76.7 + }, + { + "fn": "score_hrr", + "args": [ + 17 + ], + "atteso": 78.3 + }, + { + "fn": "score_hrr", + "args": [ + 18 + ], + "atteso": 80.0 + }, + { + "fn": "score_hrr", + "args": [ + 19 + ], + "atteso": 81.7 + }, + { + "fn": "score_hrr", + "args": [ + 20 + ], + "atteso": 83.3 + }, + { + "fn": "score_hrr", + "args": [ + 21 + ], + "atteso": 85.0 + }, + { + "fn": "score_hrr", + "args": [ + 22 + ], + "atteso": 86.7 + }, + { + "fn": "score_hrr", + "args": [ + 23 + ], + "atteso": 88.3 + }, + { + "fn": "score_hrr", + "args": [ + 24 + ], + "atteso": 90.0 + }, + { + "fn": "score_hrr", + "args": [ + 25 + ], + "atteso": 91.7 + }, + { + "fn": "score_hrr", + "args": [ + 26 + ], + "atteso": 93.3 + }, + { + "fn": "score_hrr", + "args": [ + 27 + ], + "atteso": 95.0 + }, + { + "fn": "score_hrr", + "args": [ + 28 + ], + "atteso": 96.7 + }, + { + "fn": "score_hrr", + "args": [ + 29 + ], + "atteso": 98.3 + }, + { + "fn": "score_hrr", + "args": [ + 30 + ], + "atteso": 100 + }, + { + "fn": "score_hrr", + "args": [ + 31 + ], + "atteso": 100 + }, + { + "fn": "score_hrr", + "args": [ + 32 + ], + "atteso": 100 + }, + { + "fn": "score_hrr", + "args": [ + 33 + ], + "atteso": 100 + }, + { + "fn": "score_hrr", + "args": [ + 34 + ], + "atteso": 100 + }, + { + "fn": "score_hrr", + "args": [ + 35 + ], + "atteso": 100 + }, + { + "fn": "score_hrr", + "args": [ + 36 + ], + "atteso": 100 + }, + { + "fn": "score_hrr", + "args": [ + 37 + ], + "atteso": 100 + }, + { + "fn": "score_hrr", + "args": [ + 38 + ], + "atteso": 100 + }, + { + "fn": "score_hrr", + "args": [ + 39 + ], + "atteso": 100 + }, + { + "fn": "score_hrr", + "args": [ + 40 + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 15, + 20, + "M" + ], + "atteso": 25.1 + }, + { + "fn": "score_vo2max", + "args": [ + 17.5, + 20, + "M" + ], + "atteso": 27.6 + }, + { + "fn": "score_vo2max", + "args": [ + 20.0, + 20, + "M" + ], + "atteso": 30.1 + }, + { + "fn": "score_vo2max", + "args": [ + 22.5, + 20, + "M" + ], + "atteso": 32.6 + }, + { + "fn": "score_vo2max", + "args": [ + 25.0, + 20, + "M" + ], + "atteso": 35.1 + }, + { + "fn": "score_vo2max", + "args": [ + 27.5, + 20, + "M" + ], + "atteso": 37.6 + }, + { + "fn": "score_vo2max", + "args": [ + 30.0, + 20, + "M" + ], + "atteso": 40.2 + }, + { + "fn": "score_vo2max", + "args": [ + 32.5, + 20, + "M" + ], + "atteso": 44.8 + }, + { + "fn": "score_vo2max", + "args": [ + 35.0, + 20, + "M" + ], + "atteso": 49.5 + }, + { + "fn": "score_vo2max", + "args": [ + 37.5, + 20, + "M" + ], + "atteso": 54.2 + }, + { + "fn": "score_vo2max", + "args": [ + 40.0, + 20, + "M" + ], + "atteso": 58.8 + }, + { + "fn": "score_vo2max", + "args": [ + 42.5, + 20, + "M" + ], + "atteso": 63.5 + }, + { + "fn": "score_vo2max", + "args": [ + 45.0, + 20, + "M" + ], + "atteso": 68.1 + }, + { + "fn": "score_vo2max", + "args": [ + 47.5, + 20, + "M" + ], + "atteso": 72.8 + }, + { + "fn": "score_vo2max", + "args": [ + 50.0, + 20, + "M" + ], + "atteso": 77.5 + }, + { + "fn": "score_vo2max", + "args": [ + 52.5, + 20, + "M" + ], + "atteso": 82.1 + }, + { + "fn": "score_vo2max", + "args": [ + 55.0, + 20, + "M" + ], + "atteso": 86.8 + }, + { + "fn": "score_vo2max", + "args": [ + 57.5, + 20, + "M" + ], + "atteso": 91.4 + }, + { + "fn": "score_vo2max", + "args": [ + 60.0, + 20, + "M" + ], + "atteso": 96.1 + }, + { + "fn": "score_vo2max", + "args": [ + 62.5, + 20, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 65.0, + 20, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 67.5, + 20, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 70.0, + 20, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 15, + 25, + "M" + ], + "atteso": 25.1 + }, + { + "fn": "score_vo2max", + "args": [ + 17.5, + 25, + "M" + ], + "atteso": 27.6 + }, + { + "fn": "score_vo2max", + "args": [ + 20.0, + 25, + "M" + ], + "atteso": 30.1 + }, + { + "fn": "score_vo2max", + "args": [ + 22.5, + 25, + "M" + ], + "atteso": 32.6 + }, + { + "fn": "score_vo2max", + "args": [ + 25.0, + 25, + "M" + ], + "atteso": 35.1 + }, + { + "fn": "score_vo2max", + "args": [ + 27.5, + 25, + "M" + ], + "atteso": 37.6 + }, + { + "fn": "score_vo2max", + "args": [ + 30.0, + 25, + "M" + ], + "atteso": 40.2 + }, + { + "fn": "score_vo2max", + "args": [ + 32.5, + 25, + "M" + ], + "atteso": 44.8 + }, + { + "fn": "score_vo2max", + "args": [ + 35.0, + 25, + "M" + ], + "atteso": 49.5 + }, + { + "fn": "score_vo2max", + "args": [ + 37.5, + 25, + "M" + ], + "atteso": 54.2 + }, + { + "fn": "score_vo2max", + "args": [ + 40.0, + 25, + "M" + ], + "atteso": 58.8 + }, + { + "fn": "score_vo2max", + "args": [ + 42.5, + 25, + "M" + ], + "atteso": 63.5 + }, + { + "fn": "score_vo2max", + "args": [ + 45.0, + 25, + "M" + ], + "atteso": 68.1 + }, + { + "fn": "score_vo2max", + "args": [ + 47.5, + 25, + "M" + ], + "atteso": 72.8 + }, + { + "fn": "score_vo2max", + "args": [ + 50.0, + 25, + "M" + ], + "atteso": 77.5 + }, + { + "fn": "score_vo2max", + "args": [ + 52.5, + 25, + "M" + ], + "atteso": 82.1 + }, + { + "fn": "score_vo2max", + "args": [ + 55.0, + 25, + "M" + ], + "atteso": 86.8 + }, + { + "fn": "score_vo2max", + "args": [ + 57.5, + 25, + "M" + ], + "atteso": 91.4 + }, + { + "fn": "score_vo2max", + "args": [ + 60.0, + 25, + "M" + ], + "atteso": 96.1 + }, + { + "fn": "score_vo2max", + "args": [ + 62.5, + 25, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 65.0, + 25, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 67.5, + 25, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 70.0, + 25, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 15, + 30, + "M" + ], + "atteso": 26.1 + }, + { + "fn": "score_vo2max", + "args": [ + 17.5, + 30, + "M" + ], + "atteso": 28.8 + }, + { + "fn": "score_vo2max", + "args": [ + 20.0, + 30, + "M" + ], + "atteso": 31.5 + }, + { + "fn": "score_vo2max", + "args": [ + 22.5, + 30, + "M" + ], + "atteso": 34.2 + }, + { + "fn": "score_vo2max", + "args": [ + 25.0, + 30, + "M" + ], + "atteso": 36.8 + }, + { + "fn": "score_vo2max", + "args": [ + 27.5, + 30, + "M" + ], + "atteso": 39.5 + }, + { + "fn": "score_vo2max", + "args": [ + 30.0, + 30, + "M" + ], + "atteso": 44.1 + }, + { + "fn": "score_vo2max", + "args": [ + 32.5, + 30, + "M" + ], + "atteso": 49.1 + }, + { + "fn": "score_vo2max", + "args": [ + 35.0, + 30, + "M" + ], + "atteso": 54.1 + }, + { + "fn": "score_vo2max", + "args": [ + 37.5, + 30, + "M" + ], + "atteso": 59.0 + }, + { + "fn": "score_vo2max", + "args": [ + 40.0, + 30, + "M" + ], + "atteso": 64.0 + }, + { + "fn": "score_vo2max", + "args": [ + 42.5, + 30, + "M" + ], + "atteso": 69.0 + }, + { + "fn": "score_vo2max", + "args": [ + 45.0, + 30, + "M" + ], + "atteso": 74.0 + }, + { + "fn": "score_vo2max", + "args": [ + 47.5, + 30, + "M" + ], + "atteso": 79.0 + }, + { + "fn": "score_vo2max", + "args": [ + 50.0, + 30, + "M" + ], + "atteso": 84.0 + }, + { + "fn": "score_vo2max", + "args": [ + 52.5, + 30, + "M" + ], + "atteso": 88.9 + }, + { + "fn": "score_vo2max", + "args": [ + 55.0, + 30, + "M" + ], + "atteso": 93.9 + }, + { + "fn": "score_vo2max", + "args": [ + 57.5, + 30, + "M" + ], + "atteso": 98.9 + }, + { + "fn": "score_vo2max", + "args": [ + 60.0, + 30, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 62.5, + 30, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 65.0, + 30, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 67.5, + 30, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 70.0, + 30, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 15, + 35, + "M" + ], + "atteso": 26.1 + }, + { + "fn": "score_vo2max", + "args": [ + 17.5, + 35, + "M" + ], + "atteso": 28.8 + }, + { + "fn": "score_vo2max", + "args": [ + 20.0, + 35, + "M" + ], + "atteso": 31.5 + }, + { + "fn": "score_vo2max", + "args": [ + 22.5, + 35, + "M" + ], + "atteso": 34.2 + }, + { + "fn": "score_vo2max", + "args": [ + 25.0, + 35, + "M" + ], + "atteso": 36.8 + }, + { + "fn": "score_vo2max", + "args": [ + 27.5, + 35, + "M" + ], + "atteso": 39.5 + }, + { + "fn": "score_vo2max", + "args": [ + 30.0, + 35, + "M" + ], + "atteso": 44.1 + }, + { + "fn": "score_vo2max", + "args": [ + 32.5, + 35, + "M" + ], + "atteso": 49.1 + }, + { + "fn": "score_vo2max", + "args": [ + 35.0, + 35, + "M" + ], + "atteso": 54.1 + }, + { + "fn": "score_vo2max", + "args": [ + 37.5, + 35, + "M" + ], + "atteso": 59.0 + }, + { + "fn": "score_vo2max", + "args": [ + 40.0, + 35, + "M" + ], + "atteso": 64.0 + }, + { + "fn": "score_vo2max", + "args": [ + 42.5, + 35, + "M" + ], + "atteso": 69.0 + }, + { + "fn": "score_vo2max", + "args": [ + 45.0, + 35, + "M" + ], + "atteso": 74.0 + }, + { + "fn": "score_vo2max", + "args": [ + 47.5, + 35, + "M" + ], + "atteso": 79.0 + }, + { + "fn": "score_vo2max", + "args": [ + 50.0, + 35, + "M" + ], + "atteso": 84.0 + }, + { + "fn": "score_vo2max", + "args": [ + 52.5, + 35, + "M" + ], + "atteso": 88.9 + }, + { + "fn": "score_vo2max", + "args": [ + 55.0, + 35, + "M" + ], + "atteso": 93.9 + }, + { + "fn": "score_vo2max", + "args": [ + 57.5, + 35, + "M" + ], + "atteso": 98.9 + }, + { + "fn": "score_vo2max", + "args": [ + 60.0, + 35, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 62.5, + 35, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 65.0, + 35, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 67.5, + 35, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 70.0, + 35, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 15, + 40, + "M" + ], + "atteso": 26.5 + }, + { + "fn": "score_vo2max", + "args": [ + 17.5, + 40, + "M" + ], + "atteso": 29.2 + }, + { + "fn": "score_vo2max", + "args": [ + 20.0, + 40, + "M" + ], + "atteso": 32.0 + }, + { + "fn": "score_vo2max", + "args": [ + 22.5, + 40, + "M" + ], + "atteso": 34.7 + }, + { + "fn": "score_vo2max", + "args": [ + 25.0, + 40, + "M" + ], + "atteso": 37.5 + }, + { + "fn": "score_vo2max", + "args": [ + 27.5, + 40, + "M" + ], + "atteso": 40.4 + }, + { + "fn": "score_vo2max", + "args": [ + 30.0, + 40, + "M" + ], + "atteso": 45.5 + }, + { + "fn": "score_vo2max", + "args": [ + 32.5, + 40, + "M" + ], + "atteso": 50.6 + }, + { + "fn": "score_vo2max", + "args": [ + 35.0, + 40, + "M" + ], + "atteso": 55.7 + }, + { + "fn": "score_vo2max", + "args": [ + 37.5, + 40, + "M" + ], + "atteso": 60.8 + }, + { + "fn": "score_vo2max", + "args": [ + 40.0, + 40, + "M" + ], + "atteso": 65.9 + }, + { + "fn": "score_vo2max", + "args": [ + 42.5, + 40, + "M" + ], + "atteso": 71.0 + }, + { + "fn": "score_vo2max", + "args": [ + 45.0, + 40, + "M" + ], + "atteso": 76.1 + }, + { + "fn": "score_vo2max", + "args": [ + 47.5, + 40, + "M" + ], + "atteso": 81.2 + }, + { + "fn": "score_vo2max", + "args": [ + 50.0, + 40, + "M" + ], + "atteso": 86.3 + }, + { + "fn": "score_vo2max", + "args": [ + 52.5, + 40, + "M" + ], + "atteso": 91.4 + }, + { + "fn": "score_vo2max", + "args": [ + 55.0, + 40, + "M" + ], + "atteso": 96.5 + }, + { + "fn": "score_vo2max", + "args": [ + 57.5, + 40, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 60.0, + 40, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 62.5, + 40, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 65.0, + 40, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 67.5, + 40, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 70.0, + 40, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 15, + 45, + "M" + ], + "atteso": 26.5 + }, + { + "fn": "score_vo2max", + "args": [ + 17.5, + 45, + "M" + ], + "atteso": 29.2 + }, + { + "fn": "score_vo2max", + "args": [ + 20.0, + 45, + "M" + ], + "atteso": 32.0 + }, + { + "fn": "score_vo2max", + "args": [ + 22.5, + 45, + "M" + ], + "atteso": 34.7 + }, + { + "fn": "score_vo2max", + "args": [ + 25.0, + 45, + "M" + ], + "atteso": 37.5 + }, + { + "fn": "score_vo2max", + "args": [ + 27.5, + 45, + "M" + ], + "atteso": 40.4 + }, + { + "fn": "score_vo2max", + "args": [ + 30.0, + 45, + "M" + ], + "atteso": 45.5 + }, + { + "fn": "score_vo2max", + "args": [ + 32.5, + 45, + "M" + ], + "atteso": 50.6 + }, + { + "fn": "score_vo2max", + "args": [ + 35.0, + 45, + "M" + ], + "atteso": 55.7 + }, + { + "fn": "score_vo2max", + "args": [ + 37.5, + 45, + "M" + ], + "atteso": 60.8 + }, + { + "fn": "score_vo2max", + "args": [ + 40.0, + 45, + "M" + ], + "atteso": 65.9 + }, + { + "fn": "score_vo2max", + "args": [ + 42.5, + 45, + "M" + ], + "atteso": 71.0 + }, + { + "fn": "score_vo2max", + "args": [ + 45.0, + 45, + "M" + ], + "atteso": 76.1 + }, + { + "fn": "score_vo2max", + "args": [ + 47.5, + 45, + "M" + ], + "atteso": 81.2 + }, + { + "fn": "score_vo2max", + "args": [ + 50.0, + 45, + "M" + ], + "atteso": 86.3 + }, + { + "fn": "score_vo2max", + "args": [ + 52.5, + 45, + "M" + ], + "atteso": 91.4 + }, + { + "fn": "score_vo2max", + "args": [ + 55.0, + 45, + "M" + ], + "atteso": 96.5 + }, + { + "fn": "score_vo2max", + "args": [ + 57.5, + 45, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 60.0, + 45, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 62.5, + 45, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 65.0, + 45, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 67.5, + 45, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 70.0, + 45, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 15, + 50, + "M" + ], + "atteso": 28.2 + }, + { + "fn": "score_vo2max", + "args": [ + 17.5, + 50, + "M" + ], + "atteso": 31.3 + }, + { + "fn": "score_vo2max", + "args": [ + 20.0, + 50, + "M" + ], + "atteso": 34.3 + }, + { + "fn": "score_vo2max", + "args": [ + 22.5, + 50, + "M" + ], + "atteso": 37.3 + }, + { + "fn": "score_vo2max", + "args": [ + 25.0, + 50, + "M" + ], + "atteso": 40.7 + }, + { + "fn": "score_vo2max", + "args": [ + 27.5, + 50, + "M" + ], + "atteso": 46.3 + }, + { + "fn": "score_vo2max", + "args": [ + 30.0, + 50, + "M" + ], + "atteso": 52.0 + }, + { + "fn": "score_vo2max", + "args": [ + 32.5, + 50, + "M" + ], + "atteso": 57.6 + }, + { + "fn": "score_vo2max", + "args": [ + 35.0, + 50, + "M" + ], + "atteso": 63.2 + }, + { + "fn": "score_vo2max", + "args": [ + 37.5, + 50, + "M" + ], + "atteso": 68.9 + }, + { + "fn": "score_vo2max", + "args": [ + 40.0, + 50, + "M" + ], + "atteso": 74.5 + }, + { + "fn": "score_vo2max", + "args": [ + 42.5, + 50, + "M" + ], + "atteso": 80.2 + }, + { + "fn": "score_vo2max", + "args": [ + 45.0, + 50, + "M" + ], + "atteso": 85.8 + }, + { + "fn": "score_vo2max", + "args": [ + 47.5, + 50, + "M" + ], + "atteso": 91.4 + }, + { + "fn": "score_vo2max", + "args": [ + 50.0, + 50, + "M" + ], + "atteso": 97.1 + }, + { + "fn": "score_vo2max", + "args": [ + 52.5, + 50, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 55.0, + 50, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 57.5, + 50, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 60.0, + 50, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 62.5, + 50, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 65.0, + 50, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 67.5, + 50, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 70.0, + 50, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 15, + 55, + "M" + ], + "atteso": 28.2 + }, + { + "fn": "score_vo2max", + "args": [ + 17.5, + 55, + "M" + ], + "atteso": 31.3 + }, + { + "fn": "score_vo2max", + "args": [ + 20.0, + 55, + "M" + ], + "atteso": 34.3 + }, + { + "fn": "score_vo2max", + "args": [ + 22.5, + 55, + "M" + ], + "atteso": 37.3 + }, + { + "fn": "score_vo2max", + "args": [ + 25.0, + 55, + "M" + ], + "atteso": 40.7 + }, + { + "fn": "score_vo2max", + "args": [ + 27.5, + 55, + "M" + ], + "atteso": 46.3 + }, + { + "fn": "score_vo2max", + "args": [ + 30.0, + 55, + "M" + ], + "atteso": 52.0 + }, + { + "fn": "score_vo2max", + "args": [ + 32.5, + 55, + "M" + ], + "atteso": 57.6 + }, + { + "fn": "score_vo2max", + "args": [ + 35.0, + 55, + "M" + ], + "atteso": 63.2 + }, + { + "fn": "score_vo2max", + "args": [ + 37.5, + 55, + "M" + ], + "atteso": 68.9 + }, + { + "fn": "score_vo2max", + "args": [ + 40.0, + 55, + "M" + ], + "atteso": 74.5 + }, + { + "fn": "score_vo2max", + "args": [ + 42.5, + 55, + "M" + ], + "atteso": 80.2 + }, + { + "fn": "score_vo2max", + "args": [ + 45.0, + 55, + "M" + ], + "atteso": 85.8 + }, + { + "fn": "score_vo2max", + "args": [ + 47.5, + 55, + "M" + ], + "atteso": 91.4 + }, + { + "fn": "score_vo2max", + "args": [ + 50.0, + 55, + "M" + ], + "atteso": 97.1 + }, + { + "fn": "score_vo2max", + "args": [ + 52.5, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 55.0, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 57.5, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 60.0, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 62.5, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 65.0, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 67.5, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 70.0, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 15, + 60, + "M" + ], + "atteso": 29.8 + }, + { + "fn": "score_vo2max", + "args": [ + 17.5, + 60, + "M" + ], + "atteso": 33.1 + }, + { + "fn": "score_vo2max", + "args": [ + 20.0, + 60, + "M" + ], + "atteso": 36.4 + }, + { + "fn": "score_vo2max", + "args": [ + 22.5, + 60, + "M" + ], + "atteso": 39.7 + }, + { + "fn": "score_vo2max", + "args": [ + 25.0, + 60, + "M" + ], + "atteso": 45.5 + }, + { + "fn": "score_vo2max", + "args": [ + 27.5, + 60, + "M" + ], + "atteso": 51.6 + }, + { + "fn": "score_vo2max", + "args": [ + 30.0, + 60, + "M" + ], + "atteso": 57.8 + }, + { + "fn": "score_vo2max", + "args": [ + 32.5, + 60, + "M" + ], + "atteso": 63.9 + }, + { + "fn": "score_vo2max", + "args": [ + 35.0, + 60, + "M" + ], + "atteso": 70 + }, + { + "fn": "score_vo2max", + "args": [ + 37.5, + 60, + "M" + ], + "atteso": 76.1 + }, + { + "fn": "score_vo2max", + "args": [ + 40.0, + 60, + "M" + ], + "atteso": 82.2 + }, + { + "fn": "score_vo2max", + "args": [ + 42.5, + 60, + "M" + ], + "atteso": 88.4 + }, + { + "fn": "score_vo2max", + "args": [ + 45.0, + 60, + "M" + ], + "atteso": 94.5 + }, + { + "fn": "score_vo2max", + "args": [ + 47.5, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 50.0, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 52.5, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 55.0, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 57.5, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 60.0, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 62.5, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 65.0, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 67.5, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 70.0, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 15, + 65, + "M" + ], + "atteso": 29.8 + }, + { + "fn": "score_vo2max", + "args": [ + 17.5, + 65, + "M" + ], + "atteso": 33.1 + }, + { + "fn": "score_vo2max", + "args": [ + 20.0, + 65, + "M" + ], + "atteso": 36.4 + }, + { + "fn": "score_vo2max", + "args": [ + 22.5, + 65, + "M" + ], + "atteso": 39.7 + }, + { + "fn": "score_vo2max", + "args": [ + 25.0, + 65, + "M" + ], + "atteso": 45.5 + }, + { + "fn": "score_vo2max", + "args": [ + 27.5, + 65, + "M" + ], + "atteso": 51.6 + }, + { + "fn": "score_vo2max", + "args": [ + 30.0, + 65, + "M" + ], + "atteso": 57.8 + }, + { + "fn": "score_vo2max", + "args": [ + 32.5, + 65, + "M" + ], + "atteso": 63.9 + }, + { + "fn": "score_vo2max", + "args": [ + 35.0, + 65, + "M" + ], + "atteso": 70 + }, + { + "fn": "score_vo2max", + "args": [ + 37.5, + 65, + "M" + ], + "atteso": 76.1 + }, + { + "fn": "score_vo2max", + "args": [ + 40.0, + 65, + "M" + ], + "atteso": 82.2 + }, + { + "fn": "score_vo2max", + "args": [ + 42.5, + 65, + "M" + ], + "atteso": 88.4 + }, + { + "fn": "score_vo2max", + "args": [ + 45.0, + 65, + "M" + ], + "atteso": 94.5 + }, + { + "fn": "score_vo2max", + "args": [ + 47.5, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 50.0, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 52.5, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 55.0, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 57.5, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 60.0, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 62.5, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 65.0, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 67.5, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 70.0, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 15, + 70, + "M" + ], + "atteso": 33.1 + }, + { + "fn": "score_vo2max", + "args": [ + 17.5, + 70, + "M" + ], + "atteso": 36.9 + }, + { + "fn": "score_vo2max", + "args": [ + 20.0, + 70, + "M" + ], + "atteso": 41.4 + }, + { + "fn": "score_vo2max", + "args": [ + 22.5, + 70, + "M" + ], + "atteso": 48.6 + }, + { + "fn": "score_vo2max", + "args": [ + 25.0, + 70, + "M" + ], + "atteso": 55.7 + }, + { + "fn": "score_vo2max", + "args": [ + 27.5, + 70, + "M" + ], + "atteso": 62.9 + }, + { + "fn": "score_vo2max", + "args": [ + 30.0, + 70, + "M" + ], + "atteso": 70 + }, + { + "fn": "score_vo2max", + "args": [ + 32.5, + 70, + "M" + ], + "atteso": 77.1 + }, + { + "fn": "score_vo2max", + "args": [ + 35.0, + 70, + "M" + ], + "atteso": 84.3 + }, + { + "fn": "score_vo2max", + "args": [ + 37.5, + 70, + "M" + ], + "atteso": 91.4 + }, + { + "fn": "score_vo2max", + "args": [ + 40.0, + 70, + "M" + ], + "atteso": 98.6 + }, + { + "fn": "score_vo2max", + "args": [ + 42.5, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 45.0, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 47.5, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 50.0, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 52.5, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 55.0, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 57.5, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 60.0, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 62.5, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 65.0, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 67.5, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 70.0, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 15, + 75, + "M" + ], + "atteso": 33.1 + }, + { + "fn": "score_vo2max", + "args": [ + 17.5, + 75, + "M" + ], + "atteso": 36.9 + }, + { + "fn": "score_vo2max", + "args": [ + 20.0, + 75, + "M" + ], + "atteso": 41.4 + }, + { + "fn": "score_vo2max", + "args": [ + 22.5, + 75, + "M" + ], + "atteso": 48.6 + }, + { + "fn": "score_vo2max", + "args": [ + 25.0, + 75, + "M" + ], + "atteso": 55.7 + }, + { + "fn": "score_vo2max", + "args": [ + 27.5, + 75, + "M" + ], + "atteso": 62.9 + }, + { + "fn": "score_vo2max", + "args": [ + 30.0, + 75, + "M" + ], + "atteso": 70 + }, + { + "fn": "score_vo2max", + "args": [ + 32.5, + 75, + "M" + ], + "atteso": 77.1 + }, + { + "fn": "score_vo2max", + "args": [ + 35.0, + 75, + "M" + ], + "atteso": 84.3 + }, + { + "fn": "score_vo2max", + "args": [ + 37.5, + 75, + "M" + ], + "atteso": 91.4 + }, + { + "fn": "score_vo2max", + "args": [ + 40.0, + 75, + "M" + ], + "atteso": 98.6 + }, + { + "fn": "score_vo2max", + "args": [ + 42.5, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 45.0, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 47.5, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 50.0, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 52.5, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 55.0, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 57.5, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 60.0, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 62.5, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 65.0, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 67.5, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 70.0, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 15, + 20, + "F" + ], + "atteso": 28.2 + }, + { + "fn": "score_vo2max", + "args": [ + 17.5, + 20, + "F" + ], + "atteso": 31.3 + }, + { + "fn": "score_vo2max", + "args": [ + 20.0, + 20, + "F" + ], + "atteso": 34.3 + }, + { + "fn": "score_vo2max", + "args": [ + 22.5, + 20, + "F" + ], + "atteso": 37.3 + }, + { + "fn": "score_vo2max", + "args": [ + 25.0, + 20, + "F" + ], + "atteso": 40.7 + }, + { + "fn": "score_vo2max", + "args": [ + 27.5, + 20, + "F" + ], + "atteso": 46.3 + }, + { + "fn": "score_vo2max", + "args": [ + 30.0, + 20, + "F" + ], + "atteso": 52.0 + }, + { + "fn": "score_vo2max", + "args": [ + 32.5, + 20, + "F" + ], + "atteso": 57.6 + }, + { + "fn": "score_vo2max", + "args": [ + 35.0, + 20, + "F" + ], + "atteso": 63.2 + }, + { + "fn": "score_vo2max", + "args": [ + 37.5, + 20, + "F" + ], + "atteso": 68.9 + }, + { + "fn": "score_vo2max", + "args": [ + 40.0, + 20, + "F" + ], + "atteso": 74.5 + }, + { + "fn": "score_vo2max", + "args": [ + 42.5, + 20, + "F" + ], + "atteso": 80.2 + }, + { + "fn": "score_vo2max", + "args": [ + 45.0, + 20, + "F" + ], + "atteso": 85.8 + }, + { + "fn": "score_vo2max", + "args": [ + 47.5, + 20, + "F" + ], + "atteso": 91.4 + }, + { + "fn": "score_vo2max", + "args": [ + 50.0, + 20, + "F" + ], + "atteso": 97.1 + }, + { + "fn": "score_vo2max", + "args": [ + 52.5, + 20, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 55.0, + 20, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 57.5, + 20, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 60.0, + 20, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 62.5, + 20, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 65.0, + 20, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 67.5, + 20, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 70.0, + 20, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 15, + 25, + "F" + ], + "atteso": 28.2 + }, + { + "fn": "score_vo2max", + "args": [ + 17.5, + 25, + "F" + ], + "atteso": 31.3 + }, + { + "fn": "score_vo2max", + "args": [ + 20.0, + 25, + "F" + ], + "atteso": 34.3 + }, + { + "fn": "score_vo2max", + "args": [ + 22.5, + 25, + "F" + ], + "atteso": 37.3 + }, + { + "fn": "score_vo2max", + "args": [ + 25.0, + 25, + "F" + ], + "atteso": 40.7 + }, + { + "fn": "score_vo2max", + "args": [ + 27.5, + 25, + "F" + ], + "atteso": 46.3 + }, + { + "fn": "score_vo2max", + "args": [ + 30.0, + 25, + "F" + ], + "atteso": 52.0 + }, + { + "fn": "score_vo2max", + "args": [ + 32.5, + 25, + "F" + ], + "atteso": 57.6 + }, + { + "fn": "score_vo2max", + "args": [ + 35.0, + 25, + "F" + ], + "atteso": 63.2 + }, + { + "fn": "score_vo2max", + "args": [ + 37.5, + 25, + "F" + ], + "atteso": 68.9 + }, + { + "fn": "score_vo2max", + "args": [ + 40.0, + 25, + "F" + ], + "atteso": 74.5 + }, + { + "fn": "score_vo2max", + "args": [ + 42.5, + 25, + "F" + ], + "atteso": 80.2 + }, + { + "fn": "score_vo2max", + "args": [ + 45.0, + 25, + "F" + ], + "atteso": 85.8 + }, + { + "fn": "score_vo2max", + "args": [ + 47.5, + 25, + "F" + ], + "atteso": 91.4 + }, + { + "fn": "score_vo2max", + "args": [ + 50.0, + 25, + "F" + ], + "atteso": 97.1 + }, + { + "fn": "score_vo2max", + "args": [ + 52.5, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 55.0, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 57.5, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 60.0, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 62.5, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 65.0, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 67.5, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 70.0, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 15, + 30, + "F" + ], + "atteso": 28.7 + }, + { + "fn": "score_vo2max", + "args": [ + 17.5, + 30, + "F" + ], + "atteso": 31.8 + }, + { + "fn": "score_vo2max", + "args": [ + 20.0, + 30, + "F" + ], + "atteso": 34.9 + }, + { + "fn": "score_vo2max", + "args": [ + 22.5, + 30, + "F" + ], + "atteso": 38.1 + }, + { + "fn": "score_vo2max", + "args": [ + 25.0, + 30, + "F" + ], + "atteso": 42.2 + }, + { + "fn": "score_vo2max", + "args": [ + 27.5, + 30, + "F" + ], + "atteso": 48.0 + }, + { + "fn": "score_vo2max", + "args": [ + 30.0, + 30, + "F" + ], + "atteso": 53.8 + }, + { + "fn": "score_vo2max", + "args": [ + 32.5, + 30, + "F" + ], + "atteso": 59.6 + }, + { + "fn": "score_vo2max", + "args": [ + 35.0, + 30, + "F" + ], + "atteso": 65.4 + }, + { + "fn": "score_vo2max", + "args": [ + 37.5, + 30, + "F" + ], + "atteso": 71.2 + }, + { + "fn": "score_vo2max", + "args": [ + 40.0, + 30, + "F" + ], + "atteso": 76.9 + }, + { + "fn": "score_vo2max", + "args": [ + 42.5, + 30, + "F" + ], + "atteso": 82.7 + }, + { + "fn": "score_vo2max", + "args": [ + 45.0, + 30, + "F" + ], + "atteso": 88.5 + }, + { + "fn": "score_vo2max", + "args": [ + 47.5, + 30, + "F" + ], + "atteso": 94.3 + }, + { + "fn": "score_vo2max", + "args": [ + 50.0, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 52.5, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 55.0, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 57.5, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 60.0, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 62.5, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 65.0, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 67.5, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 70.0, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 15, + 35, + "F" + ], + "atteso": 28.7 + }, + { + "fn": "score_vo2max", + "args": [ + 17.5, + 35, + "F" + ], + "atteso": 31.8 + }, + { + "fn": "score_vo2max", + "args": [ + 20.0, + 35, + "F" + ], + "atteso": 34.9 + }, + { + "fn": "score_vo2max", + "args": [ + 22.5, + 35, + "F" + ], + "atteso": 38.1 + }, + { + "fn": "score_vo2max", + "args": [ + 25.0, + 35, + "F" + ], + "atteso": 42.2 + }, + { + "fn": "score_vo2max", + "args": [ + 27.5, + 35, + "F" + ], + "atteso": 48.0 + }, + { + "fn": "score_vo2max", + "args": [ + 30.0, + 35, + "F" + ], + "atteso": 53.8 + }, + { + "fn": "score_vo2max", + "args": [ + 32.5, + 35, + "F" + ], + "atteso": 59.6 + }, + { + "fn": "score_vo2max", + "args": [ + 35.0, + 35, + "F" + ], + "atteso": 65.4 + }, + { + "fn": "score_vo2max", + "args": [ + 37.5, + 35, + "F" + ], + "atteso": 71.2 + }, + { + "fn": "score_vo2max", + "args": [ + 40.0, + 35, + "F" + ], + "atteso": 76.9 + }, + { + "fn": "score_vo2max", + "args": [ + 42.5, + 35, + "F" + ], + "atteso": 82.7 + }, + { + "fn": "score_vo2max", + "args": [ + 45.0, + 35, + "F" + ], + "atteso": 88.5 + }, + { + "fn": "score_vo2max", + "args": [ + 47.5, + 35, + "F" + ], + "atteso": 94.3 + }, + { + "fn": "score_vo2max", + "args": [ + 50.0, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 52.5, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 55.0, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 57.5, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 60.0, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 62.5, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 65.0, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 67.5, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 70.0, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 15, + 40, + "F" + ], + "atteso": 30.4 + }, + { + "fn": "score_vo2max", + "args": [ + 17.5, + 40, + "F" + ], + "atteso": 33.8 + }, + { + "fn": "score_vo2max", + "args": [ + 20.0, + 40, + "F" + ], + "atteso": 37.1 + }, + { + "fn": "score_vo2max", + "args": [ + 22.5, + 40, + "F" + ], + "atteso": 41.0 + }, + { + "fn": "score_vo2max", + "args": [ + 25.0, + 40, + "F" + ], + "atteso": 47.3 + }, + { + "fn": "score_vo2max", + "args": [ + 27.5, + 40, + "F" + ], + "atteso": 53.6 + }, + { + "fn": "score_vo2max", + "args": [ + 30.0, + 40, + "F" + ], + "atteso": 59.9 + }, + { + "fn": "score_vo2max", + "args": [ + 32.5, + 40, + "F" + ], + "atteso": 66.2 + }, + { + "fn": "score_vo2max", + "args": [ + 35.0, + 40, + "F" + ], + "atteso": 72.5 + }, + { + "fn": "score_vo2max", + "args": [ + 37.5, + 40, + "F" + ], + "atteso": 78.8 + }, + { + "fn": "score_vo2max", + "args": [ + 40.0, + 40, + "F" + ], + "atteso": 85.1 + }, + { + "fn": "score_vo2max", + "args": [ + 42.5, + 40, + "F" + ], + "atteso": 91.4 + }, + { + "fn": "score_vo2max", + "args": [ + 45.0, + 40, + "F" + ], + "atteso": 97.7 + }, + { + "fn": "score_vo2max", + "args": [ + 47.5, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 50.0, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 52.5, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 55.0, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 57.5, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 60.0, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 62.5, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 65.0, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 67.5, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 70.0, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 15, + 45, + "F" + ], + "atteso": 30.4 + }, + { + "fn": "score_vo2max", + "args": [ + 17.5, + 45, + "F" + ], + "atteso": 33.8 + }, + { + "fn": "score_vo2max", + "args": [ + 20.0, + 45, + "F" + ], + "atteso": 37.1 + }, + { + "fn": "score_vo2max", + "args": [ + 22.5, + 45, + "F" + ], + "atteso": 41.0 + }, + { + "fn": "score_vo2max", + "args": [ + 25.0, + 45, + "F" + ], + "atteso": 47.3 + }, + { + "fn": "score_vo2max", + "args": [ + 27.5, + 45, + "F" + ], + "atteso": 53.6 + }, + { + "fn": "score_vo2max", + "args": [ + 30.0, + 45, + "F" + ], + "atteso": 59.9 + }, + { + "fn": "score_vo2max", + "args": [ + 32.5, + 45, + "F" + ], + "atteso": 66.2 + }, + { + "fn": "score_vo2max", + "args": [ + 35.0, + 45, + "F" + ], + "atteso": 72.5 + }, + { + "fn": "score_vo2max", + "args": [ + 37.5, + 45, + "F" + ], + "atteso": 78.8 + }, + { + "fn": "score_vo2max", + "args": [ + 40.0, + 45, + "F" + ], + "atteso": 85.1 + }, + { + "fn": "score_vo2max", + "args": [ + 42.5, + 45, + "F" + ], + "atteso": 91.4 + }, + { + "fn": "score_vo2max", + "args": [ + 45.0, + 45, + "F" + ], + "atteso": 97.7 + }, + { + "fn": "score_vo2max", + "args": [ + 47.5, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 50.0, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 52.5, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 55.0, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 57.5, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 60.0, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 62.5, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 65.0, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 67.5, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 70.0, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 15, + 50, + "F" + ], + "atteso": 33.1 + }, + { + "fn": "score_vo2max", + "args": [ + 17.5, + 50, + "F" + ], + "atteso": 36.9 + }, + { + "fn": "score_vo2max", + "args": [ + 20.0, + 50, + "F" + ], + "atteso": 41.4 + }, + { + "fn": "score_vo2max", + "args": [ + 22.5, + 50, + "F" + ], + "atteso": 48.6 + }, + { + "fn": "score_vo2max", + "args": [ + 25.0, + 50, + "F" + ], + "atteso": 55.7 + }, + { + "fn": "score_vo2max", + "args": [ + 27.5, + 50, + "F" + ], + "atteso": 62.9 + }, + { + "fn": "score_vo2max", + "args": [ + 30.0, + 50, + "F" + ], + "atteso": 70 + }, + { + "fn": "score_vo2max", + "args": [ + 32.5, + 50, + "F" + ], + "atteso": 77.1 + }, + { + "fn": "score_vo2max", + "args": [ + 35.0, + 50, + "F" + ], + "atteso": 84.3 + }, + { + "fn": "score_vo2max", + "args": [ + 37.5, + 50, + "F" + ], + "atteso": 91.4 + }, + { + "fn": "score_vo2max", + "args": [ + 40.0, + 50, + "F" + ], + "atteso": 98.6 + }, + { + "fn": "score_vo2max", + "args": [ + 42.5, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 45.0, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 47.5, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 50.0, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 52.5, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 55.0, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 57.5, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 60.0, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 62.5, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 65.0, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 67.5, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 70.0, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 15, + 55, + "F" + ], + "atteso": 33.1 + }, + { + "fn": "score_vo2max", + "args": [ + 17.5, + 55, + "F" + ], + "atteso": 36.9 + }, + { + "fn": "score_vo2max", + "args": [ + 20.0, + 55, + "F" + ], + "atteso": 41.4 + }, + { + "fn": "score_vo2max", + "args": [ + 22.5, + 55, + "F" + ], + "atteso": 48.6 + }, + { + "fn": "score_vo2max", + "args": [ + 25.0, + 55, + "F" + ], + "atteso": 55.7 + }, + { + "fn": "score_vo2max", + "args": [ + 27.5, + 55, + "F" + ], + "atteso": 62.9 + }, + { + "fn": "score_vo2max", + "args": [ + 30.0, + 55, + "F" + ], + "atteso": 70 + }, + { + "fn": "score_vo2max", + "args": [ + 32.5, + 55, + "F" + ], + "atteso": 77.1 + }, + { + "fn": "score_vo2max", + "args": [ + 35.0, + 55, + "F" + ], + "atteso": 84.3 + }, + { + "fn": "score_vo2max", + "args": [ + 37.5, + 55, + "F" + ], + "atteso": 91.4 + }, + { + "fn": "score_vo2max", + "args": [ + 40.0, + 55, + "F" + ], + "atteso": 98.6 + }, + { + "fn": "score_vo2max", + "args": [ + 42.5, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 45.0, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 47.5, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 50.0, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 52.5, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 55.0, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 57.5, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 60.0, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 62.5, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 65.0, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 67.5, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 70.0, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 15, + 60, + "F" + ], + "atteso": 35.6 + }, + { + "fn": "score_vo2max", + "args": [ + 17.5, + 60, + "F" + ], + "atteso": 39.9 + }, + { + "fn": "score_vo2max", + "args": [ + 20.0, + 60, + "F" + ], + "atteso": 47.8 + }, + { + "fn": "score_vo2max", + "args": [ + 22.5, + 60, + "F" + ], + "atteso": 55.7 + }, + { + "fn": "score_vo2max", + "args": [ + 25.0, + 60, + "F" + ], + "atteso": 63.7 + }, + { + "fn": "score_vo2max", + "args": [ + 27.5, + 60, + "F" + ], + "atteso": 71.6 + }, + { + "fn": "score_vo2max", + "args": [ + 30.0, + 60, + "F" + ], + "atteso": 79.5 + }, + { + "fn": "score_vo2max", + "args": [ + 32.5, + 60, + "F" + ], + "atteso": 87.5 + }, + { + "fn": "score_vo2max", + "args": [ + 35.0, + 60, + "F" + ], + "atteso": 95.4 + }, + { + "fn": "score_vo2max", + "args": [ + 37.5, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 40.0, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 42.5, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 45.0, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 47.5, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 50.0, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 52.5, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 55.0, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 57.5, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 60.0, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 62.5, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 65.0, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 67.5, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 70.0, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 15, + 65, + "F" + ], + "atteso": 35.6 + }, + { + "fn": "score_vo2max", + "args": [ + 17.5, + 65, + "F" + ], + "atteso": 39.9 + }, + { + "fn": "score_vo2max", + "args": [ + 20.0, + 65, + "F" + ], + "atteso": 47.8 + }, + { + "fn": "score_vo2max", + "args": [ + 22.5, + 65, + "F" + ], + "atteso": 55.7 + }, + { + "fn": "score_vo2max", + "args": [ + 25.0, + 65, + "F" + ], + "atteso": 63.7 + }, + { + "fn": "score_vo2max", + "args": [ + 27.5, + 65, + "F" + ], + "atteso": 71.6 + }, + { + "fn": "score_vo2max", + "args": [ + 30.0, + 65, + "F" + ], + "atteso": 79.5 + }, + { + "fn": "score_vo2max", + "args": [ + 32.5, + 65, + "F" + ], + "atteso": 87.5 + }, + { + "fn": "score_vo2max", + "args": [ + 35.0, + 65, + "F" + ], + "atteso": 95.4 + }, + { + "fn": "score_vo2max", + "args": [ + 37.5, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 40.0, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 42.5, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 45.0, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 47.5, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 50.0, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 52.5, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 55.0, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 57.5, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 60.0, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 62.5, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 65.0, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 67.5, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 70.0, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 15, + 70, + "F" + ], + "atteso": 40.2 + }, + { + "fn": "score_vo2max", + "args": [ + 17.5, + 70, + "F" + ], + "atteso": 49.5 + }, + { + "fn": "score_vo2max", + "args": [ + 20.0, + 70, + "F" + ], + "atteso": 58.8 + }, + { + "fn": "score_vo2max", + "args": [ + 22.5, + 70, + "F" + ], + "atteso": 68.1 + }, + { + "fn": "score_vo2max", + "args": [ + 25.0, + 70, + "F" + ], + "atteso": 77.5 + }, + { + "fn": "score_vo2max", + "args": [ + 27.5, + 70, + "F" + ], + "atteso": 86.8 + }, + { + "fn": "score_vo2max", + "args": [ + 30.0, + 70, + "F" + ], + "atteso": 96.1 + }, + { + "fn": "score_vo2max", + "args": [ + 32.5, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 35.0, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 37.5, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 40.0, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 42.5, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 45.0, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 47.5, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 50.0, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 52.5, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 55.0, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 57.5, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 60.0, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 62.5, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 65.0, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 67.5, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 70.0, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 15, + 75, + "F" + ], + "atteso": 40.2 + }, + { + "fn": "score_vo2max", + "args": [ + 17.5, + 75, + "F" + ], + "atteso": 49.5 + }, + { + "fn": "score_vo2max", + "args": [ + 20.0, + 75, + "F" + ], + "atteso": 58.8 + }, + { + "fn": "score_vo2max", + "args": [ + 22.5, + 75, + "F" + ], + "atteso": 68.1 + }, + { + "fn": "score_vo2max", + "args": [ + 25.0, + 75, + "F" + ], + "atteso": 77.5 + }, + { + "fn": "score_vo2max", + "args": [ + 27.5, + 75, + "F" + ], + "atteso": 86.8 + }, + { + "fn": "score_vo2max", + "args": [ + 30.0, + 75, + "F" + ], + "atteso": 96.1 + }, + { + "fn": "score_vo2max", + "args": [ + 32.5, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 35.0, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 37.5, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 40.0, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 42.5, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 45.0, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 47.5, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 50.0, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 52.5, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 55.0, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 57.5, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 60.0, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 62.5, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 65.0, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 67.5, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_vo2max", + "args": [ + 70.0, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 120, + "M" + ], + "atteso": 60.93 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 120, + "F" + ], + "atteso": 43.646 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 125, + "M" + ], + "atteso": 58.83 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 125, + "F" + ], + "atteso": 42.7225 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 130, + "M" + ], + "atteso": 56.73 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 130, + "F" + ], + "atteso": 41.79900000000001 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 135, + "M" + ], + "atteso": 54.63 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 135, + "F" + ], + "atteso": 40.8755 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 140, + "M" + ], + "atteso": 52.53 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 140, + "F" + ], + "atteso": 39.952 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 145, + "M" + ], + "atteso": 50.43 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 145, + "F" + ], + "atteso": 39.0285 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 150, + "M" + ], + "atteso": 48.33 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 150, + "F" + ], + "atteso": 38.105000000000004 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 155, + "M" + ], + "atteso": 46.230000000000004 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 155, + "F" + ], + "atteso": 37.1815 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 160, + "M" + ], + "atteso": 44.129999999999995 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 160, + "F" + ], + "atteso": 36.258 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 165, + "M" + ], + "atteso": 42.03 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 165, + "F" + ], + "atteso": 35.334500000000006 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 170, + "M" + ], + "atteso": 39.93000000000001 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 170, + "F" + ], + "atteso": 34.411 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 175, + "M" + ], + "atteso": 37.83 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 175, + "F" + ], + "atteso": 33.487500000000004 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 180, + "M" + ], + "atteso": 35.730000000000004 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 180, + "F" + ], + "atteso": 32.564 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 185, + "M" + ], + "atteso": 33.629999999999995 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 185, + "F" + ], + "atteso": 31.640500000000003 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 190, + "M" + ], + "atteso": 31.53 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 190, + "F" + ], + "atteso": 30.717 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 195, + "M" + ], + "atteso": 29.430000000000007 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 195, + "F" + ], + "atteso": 29.7935 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 200, + "M" + ], + "atteso": 27.33 + }, + { + "fn": "vo2max_from_step_test", + "args": [ + 200, + "F" + ], + "atteso": 28.870000000000005 + }, + { + "fn": "vo2max_from_mutt", + "args": [ + 100 + ], + "atteso": 23.5 + }, + { + "fn": "vo2max_from_mutt", + "args": [ + 110 + ], + "atteso": 25.5 + }, + { + "fn": "vo2max_from_mutt", + "args": [ + 120 + ], + "atteso": 27.5 + }, + { + "fn": "vo2max_from_mutt", + "args": [ + 130 + ], + "atteso": 29.5 + }, + { + "fn": "vo2max_from_mutt", + "args": [ + 140 + ], + "atteso": 31.5 + }, + { + "fn": "vo2max_from_mutt", + "args": [ + 150 + ], + "atteso": 33.5 + }, + { + "fn": "vo2max_from_mutt", + "args": [ + 160 + ], + "atteso": 35.5 + }, + { + "fn": "vo2max_from_mutt", + "args": [ + 170 + ], + "atteso": 37.5 + }, + { + "fn": "vo2max_from_mutt", + "args": [ + 180 + ], + "atteso": 39.5 + }, + { + "fn": "vo2max_from_mutt", + "args": [ + 190 + ], + "atteso": 41.5 + }, + { + "fn": "vo2max_from_mutt", + "args": [ + 200 + ], + "atteso": 43.5 + }, + { + "fn": "vo2max_from_mutt", + "args": [ + 210 + ], + "atteso": 45.5 + }, + { + "fn": "vo2max_from_mutt", + "args": [ + 220 + ], + "atteso": 47.5 + }, + { + "fn": "vo2max_from_mutt", + "args": [ + 230 + ], + "atteso": 49.5 + }, + { + "fn": "vo2max_from_mutt", + "args": [ + 240 + ], + "atteso": 51.5 + }, + { + "fn": "vo2max_from_mutt", + "args": [ + 250 + ], + "atteso": 53.5 + }, + { + "fn": "vo2max_from_mutt", + "args": [ + 260 + ], + "atteso": 55.5 + }, + { + "fn": "vo2max_from_mutt", + "args": [ + 270 + ], + "atteso": 57.5 + }, + { + "fn": "vo2max_from_mutt", + "args": [ + 280 + ], + "atteso": 59.5 + }, + { + "fn": "vo2max_from_mutt", + "args": [ + 290 + ], + "atteso": 61.5 + }, + { + "fn": "vo2max_from_mutt", + "args": [ + 300 + ], + "atteso": 63.5 + }, + { + "fn": "vo2max_from_milfit", + "args": [ + 50, + 75 + ], + "atteso": 14.2 + }, + { + "fn": "vo2max_from_milfit", + "args": [ + 60, + 75 + ], + "atteso": 15.64 + }, + { + "fn": "vo2max_from_milfit", + "args": [ + 70, + 75 + ], + "atteso": 17.08 + }, + { + "fn": "vo2max_from_milfit", + "args": [ + 80, + 75 + ], + "atteso": 18.52 + }, + { + "fn": "vo2max_from_milfit", + "args": [ + 90, + 75 + ], + "atteso": 19.96 + }, + { + "fn": "vo2max_from_milfit", + "args": [ + 100, + 75 + ], + "atteso": 21.4 + }, + { + "fn": "vo2max_from_milfit", + "args": [ + 110, + 75 + ], + "atteso": 22.84 + }, + { + "fn": "vo2max_from_milfit", + "args": [ + 120, + 75 + ], + "atteso": 24.28 + }, + { + "fn": "vo2max_from_milfit", + "args": [ + 130, + 75 + ], + "atteso": 25.72 + }, + { + "fn": "vo2max_from_milfit", + "args": [ + 140, + 75 + ], + "atteso": 27.16 + }, + { + "fn": "vo2max_from_milfit", + "args": [ + 150, + 75 + ], + "atteso": 28.6 + }, + { + "fn": "vo2max_from_milfit", + "args": [ + 160, + 75 + ], + "atteso": 30.04 + }, + { + "fn": "vo2max_from_milfit", + "args": [ + 170, + 75 + ], + "atteso": 31.480000000000004 + }, + { + "fn": "vo2max_from_milfit", + "args": [ + 180, + 75 + ], + "atteso": 32.92 + }, + { + "fn": "vo2max_from_milfit", + "args": [ + 190, + 75 + ], + "atteso": 34.36 + }, + { + "fn": "vo2max_from_milfit", + "args": [ + 200, + 75 + ], + "atteso": 35.8 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 15.5, + 130, + 40, + 24 + ], + "atteso": 40.75000000000001 + }, + { + "fn": "score_handgrip", + "args": [ + 10, + 25, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 12.5, + 25, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 15.0, + 25, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 17.5, + 25, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 20.0, + 25, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 22.5, + 25, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 25.0, + 25, + "M" + ], + "atteso": 20.2 + }, + { + "fn": "score_handgrip", + "args": [ + 27.5, + 25, + "M" + ], + "atteso": 24.3 + }, + { + "fn": "score_handgrip", + "args": [ + 30.0, + 25, + "M" + ], + "atteso": 28.3 + }, + { + "fn": "score_handgrip", + "args": [ + 32.5, + 25, + "M" + ], + "atteso": 32.3 + }, + { + "fn": "score_handgrip", + "args": [ + 35.0, + 25, + "M" + ], + "atteso": 36.3 + }, + { + "fn": "score_handgrip", + "args": [ + 37.5, + 25, + "M" + ], + "atteso": 40.4 + }, + { + "fn": "score_handgrip", + "args": [ + 40.0, + 25, + "M" + ], + "atteso": 44.4 + }, + { + "fn": "score_handgrip", + "args": [ + 42.5, + 25, + "M" + ], + "atteso": 48.4 + }, + { + "fn": "score_handgrip", + "args": [ + 45.0, + 25, + "M" + ], + "atteso": 52.4 + }, + { + "fn": "score_handgrip", + "args": [ + 47.5, + 25, + "M" + ], + "atteso": 56.5 + }, + { + "fn": "score_handgrip", + "args": [ + 50.0, + 25, + "M" + ], + "atteso": 60.5 + }, + { + "fn": "score_handgrip", + "args": [ + 52.5, + 25, + "M" + ], + "atteso": 64.5 + }, + { + "fn": "score_handgrip", + "args": [ + 55.0, + 25, + "M" + ], + "atteso": 68.5 + }, + { + "fn": "score_handgrip", + "args": [ + 57.5, + 25, + "M" + ], + "atteso": 72.6 + }, + { + "fn": "score_handgrip", + "args": [ + 60.0, + 25, + "M" + ], + "atteso": 76.6 + }, + { + "fn": "score_handgrip", + "args": [ + 62.5, + 25, + "M" + ], + "atteso": 80.6 + }, + { + "fn": "score_handgrip", + "args": [ + 65.0, + 25, + "M" + ], + "atteso": 84.6 + }, + { + "fn": "score_handgrip", + "args": [ + 67.5, + 25, + "M" + ], + "atteso": 88.7 + }, + { + "fn": "score_handgrip", + "args": [ + 70.0, + 25, + "M" + ], + "atteso": 92.7 + }, + { + "fn": "score_pushup", + "args": [ + 0, + 25, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_pushup", + "args": [ + 2, + 25, + "M" + ], + "atteso": 15.5 + }, + { + "fn": "score_pushup", + "args": [ + 4, + 25, + "M" + ], + "atteso": 20.9 + }, + { + "fn": "score_pushup", + "args": [ + 6, + 25, + "M" + ], + "atteso": 26.4 + }, + { + "fn": "score_pushup", + "args": [ + 8, + 25, + "M" + ], + "atteso": 31.8 + }, + { + "fn": "score_pushup", + "args": [ + 10, + 25, + "M" + ], + "atteso": 37.3 + }, + { + "fn": "score_pushup", + "args": [ + 12, + 25, + "M" + ], + "atteso": 42.7 + }, + { + "fn": "score_pushup", + "args": [ + 14, + 25, + "M" + ], + "atteso": 48.2 + }, + { + "fn": "score_pushup", + "args": [ + 16, + 25, + "M" + ], + "atteso": 53.6 + }, + { + "fn": "score_pushup", + "args": [ + 18, + 25, + "M" + ], + "atteso": 59.1 + }, + { + "fn": "score_pushup", + "args": [ + 20, + 25, + "M" + ], + "atteso": 64.5 + }, + { + "fn": "score_pushup", + "args": [ + 22, + 25, + "M" + ], + "atteso": 70 + }, + { + "fn": "score_pushup", + "args": [ + 24, + 25, + "M" + ], + "atteso": 74.5 + }, + { + "fn": "score_pushup", + "args": [ + 26, + 25, + "M" + ], + "atteso": 79.1 + }, + { + "fn": "score_pushup", + "args": [ + 28, + 25, + "M" + ], + "atteso": 83.6 + }, + { + "fn": "score_pushup", + "args": [ + 30, + 25, + "M" + ], + "atteso": 88.2 + }, + { + "fn": "score_pushup", + "args": [ + 32, + 25, + "M" + ], + "atteso": 92.7 + }, + { + "fn": "score_pushup", + "args": [ + 34, + 25, + "M" + ], + "atteso": 97.3 + }, + { + "fn": "score_pushup", + "args": [ + 36, + 25, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 38, + 25, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 40, + 25, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 42, + 25, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 44, + 25, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 46, + 25, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 48, + 25, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 50, + 25, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 52, + 25, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 54, + 25, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 56, + 25, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 58, + 25, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 60, + 25, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 10, + 30, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 12.5, + 30, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 15.0, + 30, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 17.5, + 30, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 20.0, + 30, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 22.5, + 30, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 25.0, + 30, + "M" + ], + "atteso": 21.3 + }, + { + "fn": "score_handgrip", + "args": [ + 27.5, + 30, + "M" + ], + "atteso": 25.4 + }, + { + "fn": "score_handgrip", + "args": [ + 30.0, + 30, + "M" + ], + "atteso": 29.5 + }, + { + "fn": "score_handgrip", + "args": [ + 32.5, + 30, + "M" + ], + "atteso": 33.6 + }, + { + "fn": "score_handgrip", + "args": [ + 35.0, + 30, + "M" + ], + "atteso": 37.8 + }, + { + "fn": "score_handgrip", + "args": [ + 37.5, + 30, + "M" + ], + "atteso": 41.9 + }, + { + "fn": "score_handgrip", + "args": [ + 40.0, + 30, + "M" + ], + "atteso": 46.0 + }, + { + "fn": "score_handgrip", + "args": [ + 42.5, + 30, + "M" + ], + "atteso": 50.2 + }, + { + "fn": "score_handgrip", + "args": [ + 45.0, + 30, + "M" + ], + "atteso": 54.3 + }, + { + "fn": "score_handgrip", + "args": [ + 47.5, + 30, + "M" + ], + "atteso": 58.4 + }, + { + "fn": "score_handgrip", + "args": [ + 50.0, + 30, + "M" + ], + "atteso": 62.5 + }, + { + "fn": "score_handgrip", + "args": [ + 52.5, + 30, + "M" + ], + "atteso": 66.7 + }, + { + "fn": "score_handgrip", + "args": [ + 55.0, + 30, + "M" + ], + "atteso": 70.8 + }, + { + "fn": "score_handgrip", + "args": [ + 57.5, + 30, + "M" + ], + "atteso": 74.9 + }, + { + "fn": "score_handgrip", + "args": [ + 60.0, + 30, + "M" + ], + "atteso": 79.0 + }, + { + "fn": "score_handgrip", + "args": [ + 62.5, + 30, + "M" + ], + "atteso": 83.2 + }, + { + "fn": "score_handgrip", + "args": [ + 65.0, + 30, + "M" + ], + "atteso": 87.3 + }, + { + "fn": "score_handgrip", + "args": [ + 67.5, + 30, + "M" + ], + "atteso": 91.4 + }, + { + "fn": "score_handgrip", + "args": [ + 70.0, + 30, + "M" + ], + "atteso": 95.5 + }, + { + "fn": "score_pushup", + "args": [ + 0, + 30, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_pushup", + "args": [ + 2, + 30, + "M" + ], + "atteso": 15.5 + }, + { + "fn": "score_pushup", + "args": [ + 4, + 30, + "M" + ], + "atteso": 20.9 + }, + { + "fn": "score_pushup", + "args": [ + 6, + 30, + "M" + ], + "atteso": 26.4 + }, + { + "fn": "score_pushup", + "args": [ + 8, + 30, + "M" + ], + "atteso": 31.8 + }, + { + "fn": "score_pushup", + "args": [ + 10, + 30, + "M" + ], + "atteso": 37.3 + }, + { + "fn": "score_pushup", + "args": [ + 12, + 30, + "M" + ], + "atteso": 42.7 + }, + { + "fn": "score_pushup", + "args": [ + 14, + 30, + "M" + ], + "atteso": 48.2 + }, + { + "fn": "score_pushup", + "args": [ + 16, + 30, + "M" + ], + "atteso": 53.6 + }, + { + "fn": "score_pushup", + "args": [ + 18, + 30, + "M" + ], + "atteso": 59.1 + }, + { + "fn": "score_pushup", + "args": [ + 20, + 30, + "M" + ], + "atteso": 64.5 + }, + { + "fn": "score_pushup", + "args": [ + 22, + 30, + "M" + ], + "atteso": 70 + }, + { + "fn": "score_pushup", + "args": [ + 24, + 30, + "M" + ], + "atteso": 74.5 + }, + { + "fn": "score_pushup", + "args": [ + 26, + 30, + "M" + ], + "atteso": 79.1 + }, + { + "fn": "score_pushup", + "args": [ + 28, + 30, + "M" + ], + "atteso": 83.6 + }, + { + "fn": "score_pushup", + "args": [ + 30, + 30, + "M" + ], + "atteso": 88.2 + }, + { + "fn": "score_pushup", + "args": [ + 32, + 30, + "M" + ], + "atteso": 92.7 + }, + { + "fn": "score_pushup", + "args": [ + 34, + 30, + "M" + ], + "atteso": 97.3 + }, + { + "fn": "score_pushup", + "args": [ + 36, + 30, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 38, + 30, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 40, + 30, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 42, + 30, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 44, + 30, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 46, + 30, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 48, + 30, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 50, + 30, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 52, + 30, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 54, + 30, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 56, + 30, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 58, + 30, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 60, + 30, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 10, + 35, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 12.5, + 35, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 15.0, + 35, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 17.5, + 35, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 20.0, + 35, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 22.5, + 35, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 25.0, + 35, + "M" + ], + "atteso": 22.3 + }, + { + "fn": "score_handgrip", + "args": [ + 27.5, + 35, + "M" + ], + "atteso": 26.6 + }, + { + "fn": "score_handgrip", + "args": [ + 30.0, + 35, + "M" + ], + "atteso": 30.8 + }, + { + "fn": "score_handgrip", + "args": [ + 32.5, + 35, + "M" + ], + "atteso": 35.0 + }, + { + "fn": "score_handgrip", + "args": [ + 35.0, + 35, + "M" + ], + "atteso": 39.3 + }, + { + "fn": "score_handgrip", + "args": [ + 37.5, + 35, + "M" + ], + "atteso": 43.5 + }, + { + "fn": "score_handgrip", + "args": [ + 40.0, + 35, + "M" + ], + "atteso": 47.7 + }, + { + "fn": "score_handgrip", + "args": [ + 42.5, + 35, + "M" + ], + "atteso": 52.0 + }, + { + "fn": "score_handgrip", + "args": [ + 45.0, + 35, + "M" + ], + "atteso": 56.2 + }, + { + "fn": "score_handgrip", + "args": [ + 47.5, + 35, + "M" + ], + "atteso": 60.5 + }, + { + "fn": "score_handgrip", + "args": [ + 50.0, + 35, + "M" + ], + "atteso": 64.7 + }, + { + "fn": "score_handgrip", + "args": [ + 52.5, + 35, + "M" + ], + "atteso": 68.9 + }, + { + "fn": "score_handgrip", + "args": [ + 55.0, + 35, + "M" + ], + "atteso": 73.2 + }, + { + "fn": "score_handgrip", + "args": [ + 57.5, + 35, + "M" + ], + "atteso": 77.4 + }, + { + "fn": "score_handgrip", + "args": [ + 60.0, + 35, + "M" + ], + "atteso": 81.6 + }, + { + "fn": "score_handgrip", + "args": [ + 62.5, + 35, + "M" + ], + "atteso": 85.9 + }, + { + "fn": "score_handgrip", + "args": [ + 65.0, + 35, + "M" + ], + "atteso": 90.1 + }, + { + "fn": "score_handgrip", + "args": [ + 67.5, + 35, + "M" + ], + "atteso": 94.3 + }, + { + "fn": "score_handgrip", + "args": [ + 70.0, + 35, + "M" + ], + "atteso": 98.6 + }, + { + "fn": "score_pushup", + "args": [ + 0, + 35, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_pushup", + "args": [ + 2, + 35, + "M" + ], + "atteso": 15.5 + }, + { + "fn": "score_pushup", + "args": [ + 4, + 35, + "M" + ], + "atteso": 20.9 + }, + { + "fn": "score_pushup", + "args": [ + 6, + 35, + "M" + ], + "atteso": 26.4 + }, + { + "fn": "score_pushup", + "args": [ + 8, + 35, + "M" + ], + "atteso": 31.8 + }, + { + "fn": "score_pushup", + "args": [ + 10, + 35, + "M" + ], + "atteso": 37.3 + }, + { + "fn": "score_pushup", + "args": [ + 12, + 35, + "M" + ], + "atteso": 42.7 + }, + { + "fn": "score_pushup", + "args": [ + 14, + 35, + "M" + ], + "atteso": 48.2 + }, + { + "fn": "score_pushup", + "args": [ + 16, + 35, + "M" + ], + "atteso": 53.6 + }, + { + "fn": "score_pushup", + "args": [ + 18, + 35, + "M" + ], + "atteso": 59.1 + }, + { + "fn": "score_pushup", + "args": [ + 20, + 35, + "M" + ], + "atteso": 64.5 + }, + { + "fn": "score_pushup", + "args": [ + 22, + 35, + "M" + ], + "atteso": 70 + }, + { + "fn": "score_pushup", + "args": [ + 24, + 35, + "M" + ], + "atteso": 74.5 + }, + { + "fn": "score_pushup", + "args": [ + 26, + 35, + "M" + ], + "atteso": 79.1 + }, + { + "fn": "score_pushup", + "args": [ + 28, + 35, + "M" + ], + "atteso": 83.6 + }, + { + "fn": "score_pushup", + "args": [ + 30, + 35, + "M" + ], + "atteso": 88.2 + }, + { + "fn": "score_pushup", + "args": [ + 32, + 35, + "M" + ], + "atteso": 92.7 + }, + { + "fn": "score_pushup", + "args": [ + 34, + 35, + "M" + ], + "atteso": 97.3 + }, + { + "fn": "score_pushup", + "args": [ + 36, + 35, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 38, + 35, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 40, + 35, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 42, + 35, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 44, + 35, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 46, + 35, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 48, + 35, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 50, + 35, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 52, + 35, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 54, + 35, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 56, + 35, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 58, + 35, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 60, + 35, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 10, + 40, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 12.5, + 40, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 15.0, + 40, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 17.5, + 40, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 20.0, + 40, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 22.5, + 40, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 25.0, + 40, + "M" + ], + "atteso": 23.5 + }, + { + "fn": "score_handgrip", + "args": [ + 27.5, + 40, + "M" + ], + "atteso": 27.8 + }, + { + "fn": "score_handgrip", + "args": [ + 30.0, + 40, + "M" + ], + "atteso": 32.2 + }, + { + "fn": "score_handgrip", + "args": [ + 32.5, + 40, + "M" + ], + "atteso": 36.5 + }, + { + "fn": "score_handgrip", + "args": [ + 35.0, + 40, + "M" + ], + "atteso": 40.9 + }, + { + "fn": "score_handgrip", + "args": [ + 37.5, + 40, + "M" + ], + "atteso": 45.2 + }, + { + "fn": "score_handgrip", + "args": [ + 40.0, + 40, + "M" + ], + "atteso": 49.6 + }, + { + "fn": "score_handgrip", + "args": [ + 42.5, + 40, + "M" + ], + "atteso": 53.9 + }, + { + "fn": "score_handgrip", + "args": [ + 45.0, + 40, + "M" + ], + "atteso": 58.3 + }, + { + "fn": "score_handgrip", + "args": [ + 47.5, + 40, + "M" + ], + "atteso": 62.6 + }, + { + "fn": "score_handgrip", + "args": [ + 50.0, + 40, + "M" + ], + "atteso": 67.0 + }, + { + "fn": "score_handgrip", + "args": [ + 52.5, + 40, + "M" + ], + "atteso": 71.3 + }, + { + "fn": "score_handgrip", + "args": [ + 55.0, + 40, + "M" + ], + "atteso": 75.7 + }, + { + "fn": "score_handgrip", + "args": [ + 57.5, + 40, + "M" + ], + "atteso": 80.0 + }, + { + "fn": "score_handgrip", + "args": [ + 60.0, + 40, + "M" + ], + "atteso": 84.3 + }, + { + "fn": "score_handgrip", + "args": [ + 62.5, + 40, + "M" + ], + "atteso": 88.7 + }, + { + "fn": "score_handgrip", + "args": [ + 65.0, + 40, + "M" + ], + "atteso": 93.0 + }, + { + "fn": "score_handgrip", + "args": [ + 67.5, + 40, + "M" + ], + "atteso": 97.4 + }, + { + "fn": "score_handgrip", + "args": [ + 70.0, + 40, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 0, + 40, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_pushup", + "args": [ + 2, + 40, + "M" + ], + "atteso": 15.5 + }, + { + "fn": "score_pushup", + "args": [ + 4, + 40, + "M" + ], + "atteso": 20.9 + }, + { + "fn": "score_pushup", + "args": [ + 6, + 40, + "M" + ], + "atteso": 26.4 + }, + { + "fn": "score_pushup", + "args": [ + 8, + 40, + "M" + ], + "atteso": 31.8 + }, + { + "fn": "score_pushup", + "args": [ + 10, + 40, + "M" + ], + "atteso": 37.3 + }, + { + "fn": "score_pushup", + "args": [ + 12, + 40, + "M" + ], + "atteso": 42.7 + }, + { + "fn": "score_pushup", + "args": [ + 14, + 40, + "M" + ], + "atteso": 48.2 + }, + { + "fn": "score_pushup", + "args": [ + 16, + 40, + "M" + ], + "atteso": 53.6 + }, + { + "fn": "score_pushup", + "args": [ + 18, + 40, + "M" + ], + "atteso": 59.1 + }, + { + "fn": "score_pushup", + "args": [ + 20, + 40, + "M" + ], + "atteso": 64.5 + }, + { + "fn": "score_pushup", + "args": [ + 22, + 40, + "M" + ], + "atteso": 70 + }, + { + "fn": "score_pushup", + "args": [ + 24, + 40, + "M" + ], + "atteso": 74.5 + }, + { + "fn": "score_pushup", + "args": [ + 26, + 40, + "M" + ], + "atteso": 79.1 + }, + { + "fn": "score_pushup", + "args": [ + 28, + 40, + "M" + ], + "atteso": 83.6 + }, + { + "fn": "score_pushup", + "args": [ + 30, + 40, + "M" + ], + "atteso": 88.2 + }, + { + "fn": "score_pushup", + "args": [ + 32, + 40, + "M" + ], + "atteso": 92.7 + }, + { + "fn": "score_pushup", + "args": [ + 34, + 40, + "M" + ], + "atteso": 97.3 + }, + { + "fn": "score_pushup", + "args": [ + 36, + 40, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 38, + 40, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 40, + 40, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 42, + 40, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 44, + 40, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 46, + 40, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 48, + 40, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 50, + 40, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 52, + 40, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 54, + 40, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 56, + 40, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 58, + 40, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 60, + 40, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 10, + 45, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 12.5, + 45, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 15.0, + 45, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 17.5, + 45, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 20.0, + 45, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 22.5, + 45, + "M" + ], + "atteso": 20.9 + }, + { + "fn": "score_handgrip", + "args": [ + 25.0, + 45, + "M" + ], + "atteso": 25.5 + }, + { + "fn": "score_handgrip", + "args": [ + 27.5, + 45, + "M" + ], + "atteso": 30.0 + }, + { + "fn": "score_handgrip", + "args": [ + 30.0, + 45, + "M" + ], + "atteso": 34.5 + }, + { + "fn": "score_handgrip", + "args": [ + 32.5, + 45, + "M" + ], + "atteso": 39.1 + }, + { + "fn": "score_handgrip", + "args": [ + 35.0, + 45, + "M" + ], + "atteso": 43.6 + }, + { + "fn": "score_handgrip", + "args": [ + 37.5, + 45, + "M" + ], + "atteso": 48.2 + }, + { + "fn": "score_handgrip", + "args": [ + 40.0, + 45, + "M" + ], + "atteso": 52.7 + }, + { + "fn": "score_handgrip", + "args": [ + 42.5, + 45, + "M" + ], + "atteso": 57.3 + }, + { + "fn": "score_handgrip", + "args": [ + 45.0, + 45, + "M" + ], + "atteso": 61.8 + }, + { + "fn": "score_handgrip", + "args": [ + 47.5, + 45, + "M" + ], + "atteso": 66.4 + }, + { + "fn": "score_handgrip", + "args": [ + 50.0, + 45, + "M" + ], + "atteso": 70.9 + }, + { + "fn": "score_handgrip", + "args": [ + 52.5, + 45, + "M" + ], + "atteso": 75.5 + }, + { + "fn": "score_handgrip", + "args": [ + 55.0, + 45, + "M" + ], + "atteso": 80.0 + }, + { + "fn": "score_handgrip", + "args": [ + 57.5, + 45, + "M" + ], + "atteso": 84.5 + }, + { + "fn": "score_handgrip", + "args": [ + 60.0, + 45, + "M" + ], + "atteso": 89.1 + }, + { + "fn": "score_handgrip", + "args": [ + 62.5, + 45, + "M" + ], + "atteso": 93.6 + }, + { + "fn": "score_handgrip", + "args": [ + 65.0, + 45, + "M" + ], + "atteso": 98.2 + }, + { + "fn": "score_handgrip", + "args": [ + 67.5, + 45, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 70.0, + 45, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 0, + 45, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_pushup", + "args": [ + 2, + 45, + "M" + ], + "atteso": 16.2 + }, + { + "fn": "score_pushup", + "args": [ + 4, + 45, + "M" + ], + "atteso": 22.3 + }, + { + "fn": "score_pushup", + "args": [ + 6, + 45, + "M" + ], + "atteso": 28.5 + }, + { + "fn": "score_pushup", + "args": [ + 8, + 45, + "M" + ], + "atteso": 34.6 + }, + { + "fn": "score_pushup", + "args": [ + 10, + 45, + "M" + ], + "atteso": 40.8 + }, + { + "fn": "score_pushup", + "args": [ + 12, + 45, + "M" + ], + "atteso": 46.9 + }, + { + "fn": "score_pushup", + "args": [ + 14, + 45, + "M" + ], + "atteso": 53.1 + }, + { + "fn": "score_pushup", + "args": [ + 16, + 45, + "M" + ], + "atteso": 59.2 + }, + { + "fn": "score_pushup", + "args": [ + 18, + 45, + "M" + ], + "atteso": 65.4 + }, + { + "fn": "score_pushup", + "args": [ + 20, + 45, + "M" + ], + "atteso": 71.3 + }, + { + "fn": "score_pushup", + "args": [ + 22, + 45, + "M" + ], + "atteso": 76.4 + }, + { + "fn": "score_pushup", + "args": [ + 24, + 45, + "M" + ], + "atteso": 81.5 + }, + { + "fn": "score_pushup", + "args": [ + 26, + 45, + "M" + ], + "atteso": 86.7 + }, + { + "fn": "score_pushup", + "args": [ + 28, + 45, + "M" + ], + "atteso": 91.8 + }, + { + "fn": "score_pushup", + "args": [ + 30, + 45, + "M" + ], + "atteso": 96.9 + }, + { + "fn": "score_pushup", + "args": [ + 32, + 45, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 34, + 45, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 36, + 45, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 38, + 45, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 40, + 45, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 42, + 45, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 44, + 45, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 46, + 45, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 48, + 45, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 50, + 45, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 52, + 45, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 54, + 45, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 56, + 45, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 58, + 45, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 60, + 45, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 10, + 50, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 12.5, + 50, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 15.0, + 50, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 17.5, + 50, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 20.0, + 50, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 22.5, + 50, + "M" + ], + "atteso": 22.9 + }, + { + "fn": "score_handgrip", + "args": [ + 25.0, + 50, + "M" + ], + "atteso": 27.6 + }, + { + "fn": "score_handgrip", + "args": [ + 27.5, + 50, + "M" + ], + "atteso": 32.4 + }, + { + "fn": "score_handgrip", + "args": [ + 30.0, + 50, + "M" + ], + "atteso": 37.1 + }, + { + "fn": "score_handgrip", + "args": [ + 32.5, + 50, + "M" + ], + "atteso": 41.9 + }, + { + "fn": "score_handgrip", + "args": [ + 35.0, + 50, + "M" + ], + "atteso": 46.7 + }, + { + "fn": "score_handgrip", + "args": [ + 37.5, + 50, + "M" + ], + "atteso": 51.4 + }, + { + "fn": "score_handgrip", + "args": [ + 40.0, + 50, + "M" + ], + "atteso": 56.2 + }, + { + "fn": "score_handgrip", + "args": [ + 42.5, + 50, + "M" + ], + "atteso": 61.0 + }, + { + "fn": "score_handgrip", + "args": [ + 45.0, + 50, + "M" + ], + "atteso": 65.7 + }, + { + "fn": "score_handgrip", + "args": [ + 47.5, + 50, + "M" + ], + "atteso": 70.5 + }, + { + "fn": "score_handgrip", + "args": [ + 50.0, + 50, + "M" + ], + "atteso": 75.2 + }, + { + "fn": "score_handgrip", + "args": [ + 52.5, + 50, + "M" + ], + "atteso": 80.0 + }, + { + "fn": "score_handgrip", + "args": [ + 55.0, + 50, + "M" + ], + "atteso": 84.8 + }, + { + "fn": "score_handgrip", + "args": [ + 57.5, + 50, + "M" + ], + "atteso": 89.5 + }, + { + "fn": "score_handgrip", + "args": [ + 60.0, + 50, + "M" + ], + "atteso": 94.3 + }, + { + "fn": "score_handgrip", + "args": [ + 62.5, + 50, + "M" + ], + "atteso": 99.0 + }, + { + "fn": "score_handgrip", + "args": [ + 65.0, + 50, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 67.5, + 50, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 70.0, + 50, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 0, + 50, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_pushup", + "args": [ + 2, + 50, + "M" + ], + "atteso": 16.2 + }, + { + "fn": "score_pushup", + "args": [ + 4, + 50, + "M" + ], + "atteso": 22.3 + }, + { + "fn": "score_pushup", + "args": [ + 6, + 50, + "M" + ], + "atteso": 28.5 + }, + { + "fn": "score_pushup", + "args": [ + 8, + 50, + "M" + ], + "atteso": 34.6 + }, + { + "fn": "score_pushup", + "args": [ + 10, + 50, + "M" + ], + "atteso": 40.8 + }, + { + "fn": "score_pushup", + "args": [ + 12, + 50, + "M" + ], + "atteso": 46.9 + }, + { + "fn": "score_pushup", + "args": [ + 14, + 50, + "M" + ], + "atteso": 53.1 + }, + { + "fn": "score_pushup", + "args": [ + 16, + 50, + "M" + ], + "atteso": 59.2 + }, + { + "fn": "score_pushup", + "args": [ + 18, + 50, + "M" + ], + "atteso": 65.4 + }, + { + "fn": "score_pushup", + "args": [ + 20, + 50, + "M" + ], + "atteso": 71.3 + }, + { + "fn": "score_pushup", + "args": [ + 22, + 50, + "M" + ], + "atteso": 76.4 + }, + { + "fn": "score_pushup", + "args": [ + 24, + 50, + "M" + ], + "atteso": 81.5 + }, + { + "fn": "score_pushup", + "args": [ + 26, + 50, + "M" + ], + "atteso": 86.7 + }, + { + "fn": "score_pushup", + "args": [ + 28, + 50, + "M" + ], + "atteso": 91.8 + }, + { + "fn": "score_pushup", + "args": [ + 30, + 50, + "M" + ], + "atteso": 96.9 + }, + { + "fn": "score_pushup", + "args": [ + 32, + 50, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 34, + 50, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 36, + 50, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 38, + 50, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 40, + 50, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 42, + 50, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 44, + 50, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 46, + 50, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 48, + 50, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 50, + 50, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 52, + 50, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 54, + 50, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 56, + 50, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 58, + 50, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 60, + 50, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 10, + 55, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 12.5, + 55, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 15.0, + 55, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 17.5, + 55, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 20.0, + 55, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 22.5, + 55, + "M" + ], + "atteso": 25.0 + }, + { + "fn": "score_handgrip", + "args": [ + 25.0, + 55, + "M" + ], + "atteso": 30.0 + }, + { + "fn": "score_handgrip", + "args": [ + 27.5, + 55, + "M" + ], + "atteso": 35.0 + }, + { + "fn": "score_handgrip", + "args": [ + 30.0, + 55, + "M" + ], + "atteso": 40.0 + }, + { + "fn": "score_handgrip", + "args": [ + 32.5, + 55, + "M" + ], + "atteso": 45.0 + }, + { + "fn": "score_handgrip", + "args": [ + 35.0, + 55, + "M" + ], + "atteso": 50.0 + }, + { + "fn": "score_handgrip", + "args": [ + 37.5, + 55, + "M" + ], + "atteso": 55.0 + }, + { + "fn": "score_handgrip", + "args": [ + 40.0, + 55, + "M" + ], + "atteso": 60.0 + }, + { + "fn": "score_handgrip", + "args": [ + 42.5, + 55, + "M" + ], + "atteso": 65.0 + }, + { + "fn": "score_handgrip", + "args": [ + 45.0, + 55, + "M" + ], + "atteso": 70.0 + }, + { + "fn": "score_handgrip", + "args": [ + 47.5, + 55, + "M" + ], + "atteso": 75.0 + }, + { + "fn": "score_handgrip", + "args": [ + 50.0, + 55, + "M" + ], + "atteso": 80.0 + }, + { + "fn": "score_handgrip", + "args": [ + 52.5, + 55, + "M" + ], + "atteso": 85.0 + }, + { + "fn": "score_handgrip", + "args": [ + 55.0, + 55, + "M" + ], + "atteso": 90.0 + }, + { + "fn": "score_handgrip", + "args": [ + 57.5, + 55, + "M" + ], + "atteso": 95.0 + }, + { + "fn": "score_handgrip", + "args": [ + 60.0, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 62.5, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 65.0, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 67.5, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 70.0, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 0, + 55, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_pushup", + "args": [ + 2, + 55, + "M" + ], + "atteso": 17.1 + }, + { + "fn": "score_pushup", + "args": [ + 4, + 55, + "M" + ], + "atteso": 24.1 + }, + { + "fn": "score_pushup", + "args": [ + 6, + 55, + "M" + ], + "atteso": 31.2 + }, + { + "fn": "score_pushup", + "args": [ + 8, + 55, + "M" + ], + "atteso": 38.2 + }, + { + "fn": "score_pushup", + "args": [ + 10, + 55, + "M" + ], + "atteso": 45.3 + }, + { + "fn": "score_pushup", + "args": [ + 12, + 55, + "M" + ], + "atteso": 52.4 + }, + { + "fn": "score_pushup", + "args": [ + 14, + 55, + "M" + ], + "atteso": 59.4 + }, + { + "fn": "score_pushup", + "args": [ + 16, + 55, + "M" + ], + "atteso": 66.5 + }, + { + "fn": "score_pushup", + "args": [ + 18, + 55, + "M" + ], + "atteso": 72.9 + }, + { + "fn": "score_pushup", + "args": [ + 20, + 55, + "M" + ], + "atteso": 78.8 + }, + { + "fn": "score_pushup", + "args": [ + 22, + 55, + "M" + ], + "atteso": 84.7 + }, + { + "fn": "score_pushup", + "args": [ + 24, + 55, + "M" + ], + "atteso": 90.6 + }, + { + "fn": "score_pushup", + "args": [ + 26, + 55, + "M" + ], + "atteso": 96.5 + }, + { + "fn": "score_pushup", + "args": [ + 28, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 30, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 32, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 34, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 36, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 38, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 40, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 42, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 44, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 46, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 48, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 50, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 52, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 54, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 56, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 58, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 60, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 10, + 60, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 12.5, + 60, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 15.0, + 60, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 17.5, + 60, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 20.0, + 60, + "M" + ], + "atteso": 22.1 + }, + { + "fn": "score_handgrip", + "args": [ + 22.5, + 60, + "M" + ], + "atteso": 27.4 + }, + { + "fn": "score_handgrip", + "args": [ + 25.0, + 60, + "M" + ], + "atteso": 32.6 + }, + { + "fn": "score_handgrip", + "args": [ + 27.5, + 60, + "M" + ], + "atteso": 37.9 + }, + { + "fn": "score_handgrip", + "args": [ + 30.0, + 60, + "M" + ], + "atteso": 43.2 + }, + { + "fn": "score_handgrip", + "args": [ + 32.5, + 60, + "M" + ], + "atteso": 48.4 + }, + { + "fn": "score_handgrip", + "args": [ + 35.0, + 60, + "M" + ], + "atteso": 53.7 + }, + { + "fn": "score_handgrip", + "args": [ + 37.5, + 60, + "M" + ], + "atteso": 58.9 + }, + { + "fn": "score_handgrip", + "args": [ + 40.0, + 60, + "M" + ], + "atteso": 64.2 + }, + { + "fn": "score_handgrip", + "args": [ + 42.5, + 60, + "M" + ], + "atteso": 69.5 + }, + { + "fn": "score_handgrip", + "args": [ + 45.0, + 60, + "M" + ], + "atteso": 74.7 + }, + { + "fn": "score_handgrip", + "args": [ + 47.5, + 60, + "M" + ], + "atteso": 80.0 + }, + { + "fn": "score_handgrip", + "args": [ + 50.0, + 60, + "M" + ], + "atteso": 85.3 + }, + { + "fn": "score_handgrip", + "args": [ + 52.5, + 60, + "M" + ], + "atteso": 90.5 + }, + { + "fn": "score_handgrip", + "args": [ + 55.0, + 60, + "M" + ], + "atteso": 95.8 + }, + { + "fn": "score_handgrip", + "args": [ + 57.5, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 60.0, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 62.5, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 65.0, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 67.5, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 70.0, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 0, + 60, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_pushup", + "args": [ + 2, + 60, + "M" + ], + "atteso": 17.1 + }, + { + "fn": "score_pushup", + "args": [ + 4, + 60, + "M" + ], + "atteso": 24.1 + }, + { + "fn": "score_pushup", + "args": [ + 6, + 60, + "M" + ], + "atteso": 31.2 + }, + { + "fn": "score_pushup", + "args": [ + 8, + 60, + "M" + ], + "atteso": 38.2 + }, + { + "fn": "score_pushup", + "args": [ + 10, + 60, + "M" + ], + "atteso": 45.3 + }, + { + "fn": "score_pushup", + "args": [ + 12, + 60, + "M" + ], + "atteso": 52.4 + }, + { + "fn": "score_pushup", + "args": [ + 14, + 60, + "M" + ], + "atteso": 59.4 + }, + { + "fn": "score_pushup", + "args": [ + 16, + 60, + "M" + ], + "atteso": 66.5 + }, + { + "fn": "score_pushup", + "args": [ + 18, + 60, + "M" + ], + "atteso": 72.9 + }, + { + "fn": "score_pushup", + "args": [ + 20, + 60, + "M" + ], + "atteso": 78.8 + }, + { + "fn": "score_pushup", + "args": [ + 22, + 60, + "M" + ], + "atteso": 84.7 + }, + { + "fn": "score_pushup", + "args": [ + 24, + 60, + "M" + ], + "atteso": 90.6 + }, + { + "fn": "score_pushup", + "args": [ + 26, + 60, + "M" + ], + "atteso": 96.5 + }, + { + "fn": "score_pushup", + "args": [ + 28, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 30, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 32, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 34, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 36, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 38, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 40, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 42, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 44, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 46, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 48, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 50, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 52, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 54, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 56, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 58, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 60, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 10, + 65, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 12.5, + 65, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 15.0, + 65, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 17.5, + 65, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 20.0, + 65, + "M" + ], + "atteso": 25.3 + }, + { + "fn": "score_handgrip", + "args": [ + 22.5, + 65, + "M" + ], + "atteso": 30.9 + }, + { + "fn": "score_handgrip", + "args": [ + 25.0, + 65, + "M" + ], + "atteso": 36.6 + }, + { + "fn": "score_handgrip", + "args": [ + 27.5, + 65, + "M" + ], + "atteso": 42.3 + }, + { + "fn": "score_handgrip", + "args": [ + 30.0, + 65, + "M" + ], + "atteso": 47.9 + }, + { + "fn": "score_handgrip", + "args": [ + 32.5, + 65, + "M" + ], + "atteso": 53.6 + }, + { + "fn": "score_handgrip", + "args": [ + 35.0, + 65, + "M" + ], + "atteso": 59.2 + }, + { + "fn": "score_handgrip", + "args": [ + 37.5, + 65, + "M" + ], + "atteso": 64.9 + }, + { + "fn": "score_handgrip", + "args": [ + 40.0, + 65, + "M" + ], + "atteso": 70.6 + }, + { + "fn": "score_handgrip", + "args": [ + 42.5, + 65, + "M" + ], + "atteso": 76.2 + }, + { + "fn": "score_handgrip", + "args": [ + 45.0, + 65, + "M" + ], + "atteso": 81.9 + }, + { + "fn": "score_handgrip", + "args": [ + 47.5, + 65, + "M" + ], + "atteso": 87.5 + }, + { + "fn": "score_handgrip", + "args": [ + 50.0, + 65, + "M" + ], + "atteso": 93.2 + }, + { + "fn": "score_handgrip", + "args": [ + 52.5, + 65, + "M" + ], + "atteso": 98.9 + }, + { + "fn": "score_handgrip", + "args": [ + 55.0, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 57.5, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 60.0, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 62.5, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 65.0, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 67.5, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 70.0, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 0, + 65, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_pushup", + "args": [ + 2, + 65, + "M" + ], + "atteso": 18.3 + }, + { + "fn": "score_pushup", + "args": [ + 4, + 65, + "M" + ], + "atteso": 26.6 + }, + { + "fn": "score_pushup", + "args": [ + 6, + 65, + "M" + ], + "atteso": 34.8 + }, + { + "fn": "score_pushup", + "args": [ + 8, + 65, + "M" + ], + "atteso": 43.1 + }, + { + "fn": "score_pushup", + "args": [ + 10, + 65, + "M" + ], + "atteso": 51.4 + }, + { + "fn": "score_pushup", + "args": [ + 12, + 65, + "M" + ], + "atteso": 59.7 + }, + { + "fn": "score_pushup", + "args": [ + 14, + 65, + "M" + ], + "atteso": 67.9 + }, + { + "fn": "score_pushup", + "args": [ + 16, + 65, + "M" + ], + "atteso": 75.2 + }, + { + "fn": "score_pushup", + "args": [ + 18, + 65, + "M" + ], + "atteso": 82.1 + }, + { + "fn": "score_pushup", + "args": [ + 20, + 65, + "M" + ], + "atteso": 89.0 + }, + { + "fn": "score_pushup", + "args": [ + 22, + 65, + "M" + ], + "atteso": 95.9 + }, + { + "fn": "score_pushup", + "args": [ + 24, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 26, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 28, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 30, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 32, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 34, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 36, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 38, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 40, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 42, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 44, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 46, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 48, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 50, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 52, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 54, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 56, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 58, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 60, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 10, + 70, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 12.5, + 70, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 15.0, + 70, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 17.5, + 70, + "M" + ], + "atteso": 22.9 + }, + { + "fn": "score_handgrip", + "args": [ + 20.0, + 70, + "M" + ], + "atteso": 29.0 + }, + { + "fn": "score_handgrip", + "args": [ + 22.5, + 70, + "M" + ], + "atteso": 35.1 + }, + { + "fn": "score_handgrip", + "args": [ + 25.0, + 70, + "M" + ], + "atteso": 41.2 + }, + { + "fn": "score_handgrip", + "args": [ + 27.5, + 70, + "M" + ], + "atteso": 47.3 + }, + { + "fn": "score_handgrip", + "args": [ + 30.0, + 70, + "M" + ], + "atteso": 53.5 + }, + { + "fn": "score_handgrip", + "args": [ + 32.5, + 70, + "M" + ], + "atteso": 59.6 + }, + { + "fn": "score_handgrip", + "args": [ + 35.0, + 70, + "M" + ], + "atteso": 65.7 + }, + { + "fn": "score_handgrip", + "args": [ + 37.5, + 70, + "M" + ], + "atteso": 71.8 + }, + { + "fn": "score_handgrip", + "args": [ + 40.0, + 70, + "M" + ], + "atteso": 78.0 + }, + { + "fn": "score_handgrip", + "args": [ + 42.5, + 70, + "M" + ], + "atteso": 84.1 + }, + { + "fn": "score_handgrip", + "args": [ + 45.0, + 70, + "M" + ], + "atteso": 90.2 + }, + { + "fn": "score_handgrip", + "args": [ + 47.5, + 70, + "M" + ], + "atteso": 96.3 + }, + { + "fn": "score_handgrip", + "args": [ + 50.0, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 52.5, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 55.0, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 57.5, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 60.0, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 62.5, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 65.0, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 67.5, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 70.0, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 0, + 70, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_pushup", + "args": [ + 2, + 70, + "M" + ], + "atteso": 18.3 + }, + { + "fn": "score_pushup", + "args": [ + 4, + 70, + "M" + ], + "atteso": 26.6 + }, + { + "fn": "score_pushup", + "args": [ + 6, + 70, + "M" + ], + "atteso": 34.8 + }, + { + "fn": "score_pushup", + "args": [ + 8, + 70, + "M" + ], + "atteso": 43.1 + }, + { + "fn": "score_pushup", + "args": [ + 10, + 70, + "M" + ], + "atteso": 51.4 + }, + { + "fn": "score_pushup", + "args": [ + 12, + 70, + "M" + ], + "atteso": 59.7 + }, + { + "fn": "score_pushup", + "args": [ + 14, + 70, + "M" + ], + "atteso": 67.9 + }, + { + "fn": "score_pushup", + "args": [ + 16, + 70, + "M" + ], + "atteso": 75.2 + }, + { + "fn": "score_pushup", + "args": [ + 18, + 70, + "M" + ], + "atteso": 82.1 + }, + { + "fn": "score_pushup", + "args": [ + 20, + 70, + "M" + ], + "atteso": 89.0 + }, + { + "fn": "score_pushup", + "args": [ + 22, + 70, + "M" + ], + "atteso": 95.9 + }, + { + "fn": "score_pushup", + "args": [ + 24, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 26, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 28, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 30, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 32, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 34, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 36, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 38, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 40, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 42, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 44, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 46, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 48, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 50, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 52, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 54, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 56, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 58, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 60, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 10, + 75, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 12.5, + 75, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 15.0, + 75, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 17.5, + 75, + "M" + ], + "atteso": 26.7 + }, + { + "fn": "score_handgrip", + "args": [ + 20.0, + 75, + "M" + ], + "atteso": 33.3 + }, + { + "fn": "score_handgrip", + "args": [ + 22.5, + 75, + "M" + ], + "atteso": 40.0 + }, + { + "fn": "score_handgrip", + "args": [ + 25.0, + 75, + "M" + ], + "atteso": 46.7 + }, + { + "fn": "score_handgrip", + "args": [ + 27.5, + 75, + "M" + ], + "atteso": 53.3 + }, + { + "fn": "score_handgrip", + "args": [ + 30.0, + 75, + "M" + ], + "atteso": 60.0 + }, + { + "fn": "score_handgrip", + "args": [ + 32.5, + 75, + "M" + ], + "atteso": 66.7 + }, + { + "fn": "score_handgrip", + "args": [ + 35.0, + 75, + "M" + ], + "atteso": 73.3 + }, + { + "fn": "score_handgrip", + "args": [ + 37.5, + 75, + "M" + ], + "atteso": 80.0 + }, + { + "fn": "score_handgrip", + "args": [ + 40.0, + 75, + "M" + ], + "atteso": 86.7 + }, + { + "fn": "score_handgrip", + "args": [ + 42.5, + 75, + "M" + ], + "atteso": 93.3 + }, + { + "fn": "score_handgrip", + "args": [ + 45.0, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 47.5, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 50.0, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 52.5, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 55.0, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 57.5, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 60.0, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 62.5, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 65.0, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 67.5, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 70.0, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 0, + 75, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_pushup", + "args": [ + 2, + 75, + "M" + ], + "atteso": 20.0 + }, + { + "fn": "score_pushup", + "args": [ + 4, + 75, + "M" + ], + "atteso": 30.0 + }, + { + "fn": "score_pushup", + "args": [ + 6, + 75, + "M" + ], + "atteso": 40 + }, + { + "fn": "score_pushup", + "args": [ + 8, + 75, + "M" + ], + "atteso": 50.0 + }, + { + "fn": "score_pushup", + "args": [ + 10, + 75, + "M" + ], + "atteso": 60.0 + }, + { + "fn": "score_pushup", + "args": [ + 12, + 75, + "M" + ], + "atteso": 70 + }, + { + "fn": "score_pushup", + "args": [ + 14, + 75, + "M" + ], + "atteso": 78.3 + }, + { + "fn": "score_pushup", + "args": [ + 16, + 75, + "M" + ], + "atteso": 86.7 + }, + { + "fn": "score_pushup", + "args": [ + 18, + 75, + "M" + ], + "atteso": 95.0 + }, + { + "fn": "score_pushup", + "args": [ + 20, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 22, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 24, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 26, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 28, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 30, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 32, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 34, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 36, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 38, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 40, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 42, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 44, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 46, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 48, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 50, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 52, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 54, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 56, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 58, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 60, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_plank", + "args": [ + 0, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_plank", + "args": [ + 5, + "M" + ], + "atteso": 10.6 + }, + { + "fn": "score_plank", + "args": [ + 10, + "M" + ], + "atteso": 11.3 + }, + { + "fn": "score_plank", + "args": [ + 15, + "M" + ], + "atteso": 11.9 + }, + { + "fn": "score_plank", + "args": [ + 20, + "M" + ], + "atteso": 12.5 + }, + { + "fn": "score_plank", + "args": [ + 25, + "M" + ], + "atteso": 13.2 + }, + { + "fn": "score_plank", + "args": [ + 30, + "M" + ], + "atteso": 13.8 + }, + { + "fn": "score_plank", + "args": [ + 35, + "M" + ], + "atteso": 14.4 + }, + { + "fn": "score_plank", + "args": [ + 40, + "M" + ], + "atteso": 15.1 + }, + { + "fn": "score_plank", + "args": [ + 45, + "M" + ], + "atteso": 15.7 + }, + { + "fn": "score_plank", + "args": [ + 50, + "M" + ], + "atteso": 16.3 + }, + { + "fn": "score_plank", + "args": [ + 55, + "M" + ], + "atteso": 17.0 + }, + { + "fn": "score_plank", + "args": [ + 60, + "M" + ], + "atteso": 17.6 + }, + { + "fn": "score_plank", + "args": [ + 65, + "M" + ], + "atteso": 18.2 + }, + { + "fn": "score_plank", + "args": [ + 70, + "M" + ], + "atteso": 18.9 + }, + { + "fn": "score_plank", + "args": [ + 75, + "M" + ], + "atteso": 19.5 + }, + { + "fn": "score_plank", + "args": [ + 80, + "M" + ], + "atteso": 21.1 + }, + { + "fn": "score_plank", + "args": [ + 85, + "M" + ], + "atteso": 26.7 + }, + { + "fn": "score_plank", + "args": [ + 90, + "M" + ], + "atteso": 32.2 + }, + { + "fn": "score_plank", + "args": [ + 95, + "M" + ], + "atteso": 37.8 + }, + { + "fn": "score_plank", + "args": [ + 100, + "M" + ], + "atteso": 41.8 + }, + { + "fn": "score_plank", + "args": [ + 105, + "M" + ], + "atteso": 44.8 + }, + { + "fn": "score_plank", + "args": [ + 110, + "M" + ], + "atteso": 47.8 + }, + { + "fn": "score_plank", + "args": [ + 115, + "M" + ], + "atteso": 50.8 + }, + { + "fn": "score_plank", + "args": [ + 120, + "M" + ], + "atteso": 53.8 + }, + { + "fn": "score_plank", + "args": [ + 125, + "M" + ], + "atteso": 56.7 + }, + { + "fn": "score_plank", + "args": [ + 130, + "M" + ], + "atteso": 59.6 + }, + { + "fn": "score_plank", + "args": [ + 135, + "M" + ], + "atteso": 62.4 + }, + { + "fn": "score_plank", + "args": [ + 140, + "M" + ], + "atteso": 65.3 + }, + { + "fn": "score_plank", + "args": [ + 145, + "M" + ], + "atteso": 68.1 + }, + { + "fn": "score_plank", + "args": [ + 150, + "M" + ], + "atteso": 71.0 + }, + { + "fn": "score_plank", + "args": [ + 155, + "M" + ], + "atteso": 73.9 + }, + { + "fn": "score_plank", + "args": [ + 160, + "M" + ], + "atteso": 76.0 + }, + { + "fn": "score_plank", + "args": [ + 165, + "M" + ], + "atteso": 77.7 + }, + { + "fn": "score_plank", + "args": [ + 170, + "M" + ], + "atteso": 79.4 + }, + { + "fn": "score_plank", + "args": [ + 175, + "M" + ], + "atteso": 81.1 + }, + { + "fn": "score_plank", + "args": [ + 180, + "M" + ], + "atteso": 82.8 + }, + { + "fn": "score_plank", + "args": [ + 185, + "M" + ], + "atteso": 84.5 + }, + { + "fn": "score_plank", + "args": [ + 190, + "M" + ], + "atteso": 86.2 + }, + { + "fn": "score_plank", + "args": [ + 195, + "M" + ], + "atteso": 88.0 + }, + { + "fn": "score_plank", + "args": [ + 200, + "M" + ], + "atteso": 89.7 + }, + { + "fn": "score_plank", + "args": [ + 205, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_plank", + "args": [ + 210, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_plank", + "args": [ + 215, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_plank", + "args": [ + 220, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_plank", + "args": [ + 225, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_plank", + "args": [ + 230, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_plank", + "args": [ + 235, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_plank", + "args": [ + 240, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_plank", + "args": [ + 245, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_plank", + "args": [ + 250, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 0, + "M" + ], + "atteso": 10 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 5, + "M" + ], + "atteso": 18.3 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 10, + "M" + ], + "atteso": 26.7 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 15, + "M" + ], + "atteso": 35.0 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 20, + "M" + ], + "atteso": 42.2 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 25, + "M" + ], + "atteso": 47.8 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 30, + "M" + ], + "atteso": 53.3 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 35, + "M" + ], + "atteso": 58.9 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 40, + "M" + ], + "atteso": 64.4 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 45, + "M" + ], + "atteso": 70 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 50, + "M" + ], + "atteso": 76.0 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 55, + "M" + ], + "atteso": 82.0 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 60, + "M" + ], + "atteso": 88.0 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 65, + "M" + ], + "atteso": 94.0 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 80, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 85, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 90, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 95, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 100, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 105, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 110, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 115, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 120, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 0, + 40, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 2, + 40, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 4, + 40, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 6, + 40, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 8, + 40, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 10, + 40, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 12, + 40, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 14, + 40, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 16, + 40, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 18, + 40, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 20, + 40, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 22, + 40, + "M" + ], + "atteso": 25.0 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 24, + 40, + "M" + ], + "atteso": 30.0 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 26, + 40, + "M" + ], + "atteso": 35.0 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 28, + 40, + "M" + ], + "atteso": 40.0 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 30, + 40, + "M" + ], + "atteso": 45.0 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 32, + 40, + "M" + ], + "atteso": 50.0 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 34, + 40, + "M" + ], + "atteso": 55.0 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 36, + 40, + "M" + ], + "atteso": 60.0 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 38, + 40, + "M" + ], + "atteso": 65.0 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 40, + 40, + "M" + ], + "atteso": 70.0 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 42, + 40, + "M" + ], + "atteso": 75.0 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 44, + 40, + "M" + ], + "atteso": 80.0 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 46, + 40, + "M" + ], + "atteso": 85.0 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 48, + 40, + "M" + ], + "atteso": 90.0 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 50, + 40, + "M" + ], + "atteso": 95.0 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 52, + 40, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 54, + 40, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 56, + 40, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 58, + 40, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 60, + 40, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -30, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -29, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -28, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -27, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -26, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -25, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -24, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -23, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -22, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -21, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -20, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -19, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -18, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -17, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -16, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -15, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -14, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -13, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -12, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -11, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -10, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -9, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -8, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -7, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -6, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -5, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -4, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -3, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -2, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -1, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 0, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 1, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 2, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 3, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 4, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 5, + "M" + ], + "atteso": 22.7 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 6, + "M" + ], + "atteso": 25.3 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 7, + "M" + ], + "atteso": 28.0 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 8, + "M" + ], + "atteso": 30.7 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 9, + "M" + ], + "atteso": 33.3 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 10, + "M" + ], + "atteso": 36.0 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 11, + "M" + ], + "atteso": 38.7 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 12, + "M" + ], + "atteso": 41.3 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 13, + "M" + ], + "atteso": 44.0 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 14, + "M" + ], + "atteso": 46.7 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 15, + "M" + ], + "atteso": 49.3 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 16, + "M" + ], + "atteso": 52.0 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 17, + "M" + ], + "atteso": 54.7 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 18, + "M" + ], + "atteso": 57.3 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 19, + "M" + ], + "atteso": 60.0 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 20, + "M" + ], + "atteso": 62.7 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 21, + "M" + ], + "atteso": 65.3 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 22, + "M" + ], + "atteso": 68.0 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 23, + "M" + ], + "atteso": 70.7 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 24, + "M" + ], + "atteso": 73.3 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 25, + "M" + ], + "atteso": 76.0 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 26, + "M" + ], + "atteso": 78.7 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 27, + "M" + ], + "atteso": 81.3 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 28, + "M" + ], + "atteso": 84.0 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 29, + "M" + ], + "atteso": 86.7 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 30, + "M" + ], + "atteso": 89.3 + }, + { + "fn": "score_back_scratch", + "args": [ + -30, + 25, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -28, + 25, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -26, + 25, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -24, + 25, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -22, + 25, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -20, + 25, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -18, + 25, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -16, + 25, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -14, + 25, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -12, + 25, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -10, + 25, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -8, + 25, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -6, + 25, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -4, + 25, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -2, + 25, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + 0, + 25, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + 2, + 25, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + 4, + 25, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + 6, + 25, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + 8, + 25, + "M" + ], + "atteso": 25.3 + }, + { + "fn": "score_back_scratch", + "args": [ + 10, + 25, + "M" + ], + "atteso": 30.7 + }, + { + "fn": "score_back_scratch", + "args": [ + 12, + 25, + "M" + ], + "atteso": 36.0 + }, + { + "fn": "score_back_scratch", + "args": [ + 14, + 25, + "M" + ], + "atteso": 41.3 + }, + { + "fn": "score_back_scratch", + "args": [ + 16, + 25, + "M" + ], + "atteso": 46.7 + }, + { + "fn": "score_back_scratch", + "args": [ + 18, + 25, + "M" + ], + "atteso": 52.0 + }, + { + "fn": "score_back_scratch", + "args": [ + 20, + 25, + "M" + ], + "atteso": 57.3 + }, + { + "fn": "score_back_scratch", + "args": [ + -30, + 30, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -28, + 30, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -26, + 30, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -24, + 30, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -22, + 30, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -20, + 30, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -18, + 30, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -16, + 30, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -14, + 30, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -12, + 30, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -10, + 30, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -8, + 30, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -6, + 30, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -4, + 30, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -2, + 30, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + 0, + 30, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + 2, + 30, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + 4, + 30, + "M" + ], + "atteso": 25.3 + }, + { + "fn": "score_back_scratch", + "args": [ + 6, + 30, + "M" + ], + "atteso": 30.7 + }, + { + "fn": "score_back_scratch", + "args": [ + 8, + 30, + "M" + ], + "atteso": 36.0 + }, + { + "fn": "score_back_scratch", + "args": [ + 10, + 30, + "M" + ], + "atteso": 41.3 + }, + { + "fn": "score_back_scratch", + "args": [ + 12, + 30, + "M" + ], + "atteso": 46.7 + }, + { + "fn": "score_back_scratch", + "args": [ + 14, + 30, + "M" + ], + "atteso": 52.0 + }, + { + "fn": "score_back_scratch", + "args": [ + 16, + 30, + "M" + ], + "atteso": 57.3 + }, + { + "fn": "score_back_scratch", + "args": [ + 18, + 30, + "M" + ], + "atteso": 62.7 + }, + { + "fn": "score_back_scratch", + "args": [ + 20, + 30, + "M" + ], + "atteso": 68.0 + }, + { + "fn": "score_back_scratch", + "args": [ + -30, + 35, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -28, + 35, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -26, + 35, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -24, + 35, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -22, + 35, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -20, + 35, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -18, + 35, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -16, + 35, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -14, + 35, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -12, + 35, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -10, + 35, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -8, + 35, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -6, + 35, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -4, + 35, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -2, + 35, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + 0, + 35, + "M" + ], + "atteso": 25.3 + }, + { + "fn": "score_back_scratch", + "args": [ + 2, + 35, + "M" + ], + "atteso": 30.7 + }, + { + "fn": "score_back_scratch", + "args": [ + 4, + 35, + "M" + ], + "atteso": 36.0 + }, + { + "fn": "score_back_scratch", + "args": [ + 6, + 35, + "M" + ], + "atteso": 41.3 + }, + { + "fn": "score_back_scratch", + "args": [ + 8, + 35, + "M" + ], + "atteso": 46.7 + }, + { + "fn": "score_back_scratch", + "args": [ + 10, + 35, + "M" + ], + "atteso": 52.0 + }, + { + "fn": "score_back_scratch", + "args": [ + 12, + 35, + "M" + ], + "atteso": 57.3 + }, + { + "fn": "score_back_scratch", + "args": [ + 14, + 35, + "M" + ], + "atteso": 62.7 + }, + { + "fn": "score_back_scratch", + "args": [ + 16, + 35, + "M" + ], + "atteso": 68.0 + }, + { + "fn": "score_back_scratch", + "args": [ + 18, + 35, + "M" + ], + "atteso": 73.3 + }, + { + "fn": "score_back_scratch", + "args": [ + 20, + 35, + "M" + ], + "atteso": 78.7 + }, + { + "fn": "score_back_scratch", + "args": [ + -30, + 40, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -28, + 40, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -26, + 40, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -24, + 40, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -22, + 40, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -20, + 40, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -18, + 40, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -16, + 40, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -14, + 40, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -12, + 40, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -10, + 40, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -8, + 40, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -6, + 40, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -4, + 40, + "M" + ], + "atteso": 25.3 + }, + { + "fn": "score_back_scratch", + "args": [ + -2, + 40, + "M" + ], + "atteso": 30.7 + }, + { + "fn": "score_back_scratch", + "args": [ + 0, + 40, + "M" + ], + "atteso": 36.0 + }, + { + "fn": "score_back_scratch", + "args": [ + 2, + 40, + "M" + ], + "atteso": 41.3 + }, + { + "fn": "score_back_scratch", + "args": [ + 4, + 40, + "M" + ], + "atteso": 46.7 + }, + { + "fn": "score_back_scratch", + "args": [ + 6, + 40, + "M" + ], + "atteso": 52.0 + }, + { + "fn": "score_back_scratch", + "args": [ + 8, + 40, + "M" + ], + "atteso": 57.3 + }, + { + "fn": "score_back_scratch", + "args": [ + 10, + 40, + "M" + ], + "atteso": 62.7 + }, + { + "fn": "score_back_scratch", + "args": [ + 12, + 40, + "M" + ], + "atteso": 68.0 + }, + { + "fn": "score_back_scratch", + "args": [ + 14, + 40, + "M" + ], + "atteso": 73.3 + }, + { + "fn": "score_back_scratch", + "args": [ + 16, + 40, + "M" + ], + "atteso": 78.7 + }, + { + "fn": "score_back_scratch", + "args": [ + 18, + 40, + "M" + ], + "atteso": 84.0 + }, + { + "fn": "score_back_scratch", + "args": [ + 20, + 40, + "M" + ], + "atteso": 89.3 + }, + { + "fn": "score_back_scratch", + "args": [ + -30, + 45, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -28, + 45, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -26, + 45, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -24, + 45, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -22, + 45, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -20, + 45, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -18, + 45, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -16, + 45, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -14, + 45, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -12, + 45, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -10, + 45, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -8, + 45, + "M" + ], + "atteso": 25.3 + }, + { + "fn": "score_back_scratch", + "args": [ + -6, + 45, + "M" + ], + "atteso": 30.7 + }, + { + "fn": "score_back_scratch", + "args": [ + -4, + 45, + "M" + ], + "atteso": 36.0 + }, + { + "fn": "score_back_scratch", + "args": [ + -2, + 45, + "M" + ], + "atteso": 41.3 + }, + { + "fn": "score_back_scratch", + "args": [ + 0, + 45, + "M" + ], + "atteso": 46.7 + }, + { + "fn": "score_back_scratch", + "args": [ + 2, + 45, + "M" + ], + "atteso": 52.0 + }, + { + "fn": "score_back_scratch", + "args": [ + 4, + 45, + "M" + ], + "atteso": 57.3 + }, + { + "fn": "score_back_scratch", + "args": [ + 6, + 45, + "M" + ], + "atteso": 62.7 + }, + { + "fn": "score_back_scratch", + "args": [ + 8, + 45, + "M" + ], + "atteso": 68.0 + }, + { + "fn": "score_back_scratch", + "args": [ + 10, + 45, + "M" + ], + "atteso": 73.3 + }, + { + "fn": "score_back_scratch", + "args": [ + 12, + 45, + "M" + ], + "atteso": 78.7 + }, + { + "fn": "score_back_scratch", + "args": [ + 14, + 45, + "M" + ], + "atteso": 84.0 + }, + { + "fn": "score_back_scratch", + "args": [ + 16, + 45, + "M" + ], + "atteso": 89.3 + }, + { + "fn": "score_back_scratch", + "args": [ + 18, + 45, + "M" + ], + "atteso": 94.7 + }, + { + "fn": "score_back_scratch", + "args": [ + 20, + 45, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + -30, + 50, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -28, + 50, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -26, + 50, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -24, + 50, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -22, + 50, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -20, + 50, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -18, + 50, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -16, + 50, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -14, + 50, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -12, + 50, + "M" + ], + "atteso": 25.3 + }, + { + "fn": "score_back_scratch", + "args": [ + -10, + 50, + "M" + ], + "atteso": 30.7 + }, + { + "fn": "score_back_scratch", + "args": [ + -8, + 50, + "M" + ], + "atteso": 36.0 + }, + { + "fn": "score_back_scratch", + "args": [ + -6, + 50, + "M" + ], + "atteso": 41.3 + }, + { + "fn": "score_back_scratch", + "args": [ + -4, + 50, + "M" + ], + "atteso": 46.7 + }, + { + "fn": "score_back_scratch", + "args": [ + -2, + 50, + "M" + ], + "atteso": 52.0 + }, + { + "fn": "score_back_scratch", + "args": [ + 0, + 50, + "M" + ], + "atteso": 57.3 + }, + { + "fn": "score_back_scratch", + "args": [ + 2, + 50, + "M" + ], + "atteso": 62.7 + }, + { + "fn": "score_back_scratch", + "args": [ + 4, + 50, + "M" + ], + "atteso": 68.0 + }, + { + "fn": "score_back_scratch", + "args": [ + 6, + 50, + "M" + ], + "atteso": 73.3 + }, + { + "fn": "score_back_scratch", + "args": [ + 8, + 50, + "M" + ], + "atteso": 78.7 + }, + { + "fn": "score_back_scratch", + "args": [ + 10, + 50, + "M" + ], + "atteso": 84.0 + }, + { + "fn": "score_back_scratch", + "args": [ + 12, + 50, + "M" + ], + "atteso": 89.3 + }, + { + "fn": "score_back_scratch", + "args": [ + 14, + 50, + "M" + ], + "atteso": 94.7 + }, + { + "fn": "score_back_scratch", + "args": [ + 16, + 50, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 18, + 50, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 20, + 50, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + -30, + 55, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -28, + 55, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -26, + 55, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -24, + 55, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -22, + 55, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -20, + 55, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -18, + 55, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -16, + 55, + "M" + ], + "atteso": 25.3 + }, + { + "fn": "score_back_scratch", + "args": [ + -14, + 55, + "M" + ], + "atteso": 30.7 + }, + { + "fn": "score_back_scratch", + "args": [ + -12, + 55, + "M" + ], + "atteso": 36.0 + }, + { + "fn": "score_back_scratch", + "args": [ + -10, + 55, + "M" + ], + "atteso": 41.3 + }, + { + "fn": "score_back_scratch", + "args": [ + -8, + 55, + "M" + ], + "atteso": 46.7 + }, + { + "fn": "score_back_scratch", + "args": [ + -6, + 55, + "M" + ], + "atteso": 52.0 + }, + { + "fn": "score_back_scratch", + "args": [ + -4, + 55, + "M" + ], + "atteso": 57.3 + }, + { + "fn": "score_back_scratch", + "args": [ + -2, + 55, + "M" + ], + "atteso": 62.7 + }, + { + "fn": "score_back_scratch", + "args": [ + 0, + 55, + "M" + ], + "atteso": 68.0 + }, + { + "fn": "score_back_scratch", + "args": [ + 2, + 55, + "M" + ], + "atteso": 73.3 + }, + { + "fn": "score_back_scratch", + "args": [ + 4, + 55, + "M" + ], + "atteso": 78.7 + }, + { + "fn": "score_back_scratch", + "args": [ + 6, + 55, + "M" + ], + "atteso": 84.0 + }, + { + "fn": "score_back_scratch", + "args": [ + 8, + 55, + "M" + ], + "atteso": 89.3 + }, + { + "fn": "score_back_scratch", + "args": [ + 10, + 55, + "M" + ], + "atteso": 94.7 + }, + { + "fn": "score_back_scratch", + "args": [ + 12, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 14, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 16, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 18, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 20, + 55, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + -30, + 60, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -28, + 60, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -26, + 60, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -24, + 60, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -22, + 60, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -20, + 60, + "M" + ], + "atteso": 25.3 + }, + { + "fn": "score_back_scratch", + "args": [ + -18, + 60, + "M" + ], + "atteso": 30.7 + }, + { + "fn": "score_back_scratch", + "args": [ + -16, + 60, + "M" + ], + "atteso": 36.0 + }, + { + "fn": "score_back_scratch", + "args": [ + -14, + 60, + "M" + ], + "atteso": 41.3 + }, + { + "fn": "score_back_scratch", + "args": [ + -12, + 60, + "M" + ], + "atteso": 46.7 + }, + { + "fn": "score_back_scratch", + "args": [ + -10, + 60, + "M" + ], + "atteso": 52.0 + }, + { + "fn": "score_back_scratch", + "args": [ + -8, + 60, + "M" + ], + "atteso": 57.3 + }, + { + "fn": "score_back_scratch", + "args": [ + -6, + 60, + "M" + ], + "atteso": 62.7 + }, + { + "fn": "score_back_scratch", + "args": [ + -4, + 60, + "M" + ], + "atteso": 68.0 + }, + { + "fn": "score_back_scratch", + "args": [ + -2, + 60, + "M" + ], + "atteso": 73.3 + }, + { + "fn": "score_back_scratch", + "args": [ + 0, + 60, + "M" + ], + "atteso": 78.7 + }, + { + "fn": "score_back_scratch", + "args": [ + 2, + 60, + "M" + ], + "atteso": 84.0 + }, + { + "fn": "score_back_scratch", + "args": [ + 4, + 60, + "M" + ], + "atteso": 89.3 + }, + { + "fn": "score_back_scratch", + "args": [ + 6, + 60, + "M" + ], + "atteso": 94.7 + }, + { + "fn": "score_back_scratch", + "args": [ + 8, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 10, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 12, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 14, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 16, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 18, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 20, + 60, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + -30, + 65, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -28, + 65, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -26, + 65, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -24, + 65, + "M" + ], + "atteso": 25.3 + }, + { + "fn": "score_back_scratch", + "args": [ + -22, + 65, + "M" + ], + "atteso": 30.7 + }, + { + "fn": "score_back_scratch", + "args": [ + -20, + 65, + "M" + ], + "atteso": 36.0 + }, + { + "fn": "score_back_scratch", + "args": [ + -18, + 65, + "M" + ], + "atteso": 41.3 + }, + { + "fn": "score_back_scratch", + "args": [ + -16, + 65, + "M" + ], + "atteso": 46.7 + }, + { + "fn": "score_back_scratch", + "args": [ + -14, + 65, + "M" + ], + "atteso": 52.0 + }, + { + "fn": "score_back_scratch", + "args": [ + -12, + 65, + "M" + ], + "atteso": 57.3 + }, + { + "fn": "score_back_scratch", + "args": [ + -10, + 65, + "M" + ], + "atteso": 62.7 + }, + { + "fn": "score_back_scratch", + "args": [ + -8, + 65, + "M" + ], + "atteso": 68.0 + }, + { + "fn": "score_back_scratch", + "args": [ + -6, + 65, + "M" + ], + "atteso": 73.3 + }, + { + "fn": "score_back_scratch", + "args": [ + -4, + 65, + "M" + ], + "atteso": 78.7 + }, + { + "fn": "score_back_scratch", + "args": [ + -2, + 65, + "M" + ], + "atteso": 84.0 + }, + { + "fn": "score_back_scratch", + "args": [ + 0, + 65, + "M" + ], + "atteso": 89.3 + }, + { + "fn": "score_back_scratch", + "args": [ + 2, + 65, + "M" + ], + "atteso": 94.7 + }, + { + "fn": "score_back_scratch", + "args": [ + 4, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 6, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 8, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 10, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 12, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 14, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 16, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 18, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 20, + 65, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + -30, + 70, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -28, + 70, + "M" + ], + "atteso": 25.3 + }, + { + "fn": "score_back_scratch", + "args": [ + -26, + 70, + "M" + ], + "atteso": 30.7 + }, + { + "fn": "score_back_scratch", + "args": [ + -24, + 70, + "M" + ], + "atteso": 36.0 + }, + { + "fn": "score_back_scratch", + "args": [ + -22, + 70, + "M" + ], + "atteso": 41.3 + }, + { + "fn": "score_back_scratch", + "args": [ + -20, + 70, + "M" + ], + "atteso": 46.7 + }, + { + "fn": "score_back_scratch", + "args": [ + -18, + 70, + "M" + ], + "atteso": 52.0 + }, + { + "fn": "score_back_scratch", + "args": [ + -16, + 70, + "M" + ], + "atteso": 57.3 + }, + { + "fn": "score_back_scratch", + "args": [ + -14, + 70, + "M" + ], + "atteso": 62.7 + }, + { + "fn": "score_back_scratch", + "args": [ + -12, + 70, + "M" + ], + "atteso": 68.0 + }, + { + "fn": "score_back_scratch", + "args": [ + -10, + 70, + "M" + ], + "atteso": 73.3 + }, + { + "fn": "score_back_scratch", + "args": [ + -8, + 70, + "M" + ], + "atteso": 78.7 + }, + { + "fn": "score_back_scratch", + "args": [ + -6, + 70, + "M" + ], + "atteso": 84.0 + }, + { + "fn": "score_back_scratch", + "args": [ + -4, + 70, + "M" + ], + "atteso": 89.3 + }, + { + "fn": "score_back_scratch", + "args": [ + -2, + 70, + "M" + ], + "atteso": 94.7 + }, + { + "fn": "score_back_scratch", + "args": [ + 0, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 2, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 4, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 6, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 8, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 10, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 12, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 14, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 16, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 18, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 20, + 70, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + -30, + 75, + "M" + ], + "atteso": 30.7 + }, + { + "fn": "score_back_scratch", + "args": [ + -28, + 75, + "M" + ], + "atteso": 36.0 + }, + { + "fn": "score_back_scratch", + "args": [ + -26, + 75, + "M" + ], + "atteso": 41.3 + }, + { + "fn": "score_back_scratch", + "args": [ + -24, + 75, + "M" + ], + "atteso": 46.7 + }, + { + "fn": "score_back_scratch", + "args": [ + -22, + 75, + "M" + ], + "atteso": 52.0 + }, + { + "fn": "score_back_scratch", + "args": [ + -20, + 75, + "M" + ], + "atteso": 57.3 + }, + { + "fn": "score_back_scratch", + "args": [ + -18, + 75, + "M" + ], + "atteso": 62.7 + }, + { + "fn": "score_back_scratch", + "args": [ + -16, + 75, + "M" + ], + "atteso": 68.0 + }, + { + "fn": "score_back_scratch", + "args": [ + -14, + 75, + "M" + ], + "atteso": 73.3 + }, + { + "fn": "score_back_scratch", + "args": [ + -12, + 75, + "M" + ], + "atteso": 78.7 + }, + { + "fn": "score_back_scratch", + "args": [ + -10, + 75, + "M" + ], + "atteso": 84.0 + }, + { + "fn": "score_back_scratch", + "args": [ + -8, + 75, + "M" + ], + "atteso": 89.3 + }, + { + "fn": "score_back_scratch", + "args": [ + -6, + 75, + "M" + ], + "atteso": 94.7 + }, + { + "fn": "score_back_scratch", + "args": [ + -4, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + -2, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 0, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 2, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 4, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 6, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 8, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 10, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 12, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 14, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 16, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 18, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 20, + 75, + "M" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 10, + 25, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 12.5, + 25, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 15.0, + 25, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 17.5, + 25, + "F" + ], + "atteso": 26.7 + }, + { + "fn": "score_handgrip", + "args": [ + 20.0, + 25, + "F" + ], + "atteso": 33.3 + }, + { + "fn": "score_handgrip", + "args": [ + 22.5, + 25, + "F" + ], + "atteso": 40.0 + }, + { + "fn": "score_handgrip", + "args": [ + 25.0, + 25, + "F" + ], + "atteso": 46.7 + }, + { + "fn": "score_handgrip", + "args": [ + 27.5, + 25, + "F" + ], + "atteso": 53.3 + }, + { + "fn": "score_handgrip", + "args": [ + 30.0, + 25, + "F" + ], + "atteso": 60.0 + }, + { + "fn": "score_handgrip", + "args": [ + 32.5, + 25, + "F" + ], + "atteso": 66.7 + }, + { + "fn": "score_handgrip", + "args": [ + 35.0, + 25, + "F" + ], + "atteso": 73.3 + }, + { + "fn": "score_handgrip", + "args": [ + 37.5, + 25, + "F" + ], + "atteso": 80.0 + }, + { + "fn": "score_handgrip", + "args": [ + 40.0, + 25, + "F" + ], + "atteso": 86.7 + }, + { + "fn": "score_handgrip", + "args": [ + 42.5, + 25, + "F" + ], + "atteso": 93.3 + }, + { + "fn": "score_handgrip", + "args": [ + 45.0, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 47.5, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 50.0, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 52.5, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 55.0, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 57.5, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 60.0, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 62.5, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 65.0, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 67.5, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 70.0, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 0, + 25, + "F" + ], + "atteso": 10 + }, + { + "fn": "score_pushup", + "args": [ + 2, + 25, + "F" + ], + "atteso": 18.0 + }, + { + "fn": "score_pushup", + "args": [ + 4, + 25, + "F" + ], + "atteso": 26.0 + }, + { + "fn": "score_pushup", + "args": [ + 6, + 25, + "F" + ], + "atteso": 34.0 + }, + { + "fn": "score_pushup", + "args": [ + 8, + 25, + "F" + ], + "atteso": 42.0 + }, + { + "fn": "score_pushup", + "args": [ + 10, + 25, + "F" + ], + "atteso": 50.0 + }, + { + "fn": "score_pushup", + "args": [ + 12, + 25, + "F" + ], + "atteso": 58.0 + }, + { + "fn": "score_pushup", + "args": [ + 14, + 25, + "F" + ], + "atteso": 66.0 + }, + { + "fn": "score_pushup", + "args": [ + 16, + 25, + "F" + ], + "atteso": 73.3 + }, + { + "fn": "score_pushup", + "args": [ + 18, + 25, + "F" + ], + "atteso": 80.0 + }, + { + "fn": "score_pushup", + "args": [ + 20, + 25, + "F" + ], + "atteso": 86.7 + }, + { + "fn": "score_pushup", + "args": [ + 22, + 25, + "F" + ], + "atteso": 93.3 + }, + { + "fn": "score_pushup", + "args": [ + 24, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 26, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 28, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 30, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 32, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 34, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 36, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 38, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 40, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 42, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 44, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 46, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 48, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 50, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 52, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 54, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 56, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 58, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 60, + 25, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 10, + 30, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 12.5, + 30, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 15.0, + 30, + "F" + ], + "atteso": 20.9 + }, + { + "fn": "score_handgrip", + "args": [ + 17.5, + 30, + "F" + ], + "atteso": 27.7 + }, + { + "fn": "score_handgrip", + "args": [ + 20.0, + 30, + "F" + ], + "atteso": 34.5 + }, + { + "fn": "score_handgrip", + "args": [ + 22.5, + 30, + "F" + ], + "atteso": 41.4 + }, + { + "fn": "score_handgrip", + "args": [ + 25.0, + 30, + "F" + ], + "atteso": 48.2 + }, + { + "fn": "score_handgrip", + "args": [ + 27.5, + 30, + "F" + ], + "atteso": 55.0 + }, + { + "fn": "score_handgrip", + "args": [ + 30.0, + 30, + "F" + ], + "atteso": 61.8 + }, + { + "fn": "score_handgrip", + "args": [ + 32.5, + 30, + "F" + ], + "atteso": 68.6 + }, + { + "fn": "score_handgrip", + "args": [ + 35.0, + 30, + "F" + ], + "atteso": 75.5 + }, + { + "fn": "score_handgrip", + "args": [ + 37.5, + 30, + "F" + ], + "atteso": 82.3 + }, + { + "fn": "score_handgrip", + "args": [ + 40.0, + 30, + "F" + ], + "atteso": 89.1 + }, + { + "fn": "score_handgrip", + "args": [ + 42.5, + 30, + "F" + ], + "atteso": 95.9 + }, + { + "fn": "score_handgrip", + "args": [ + 45.0, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 47.5, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 50.0, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 52.5, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 55.0, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 57.5, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 60.0, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 62.5, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 65.0, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 67.5, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 70.0, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 0, + 30, + "F" + ], + "atteso": 10 + }, + { + "fn": "score_pushup", + "args": [ + 2, + 30, + "F" + ], + "atteso": 18.0 + }, + { + "fn": "score_pushup", + "args": [ + 4, + 30, + "F" + ], + "atteso": 26.0 + }, + { + "fn": "score_pushup", + "args": [ + 6, + 30, + "F" + ], + "atteso": 34.0 + }, + { + "fn": "score_pushup", + "args": [ + 8, + 30, + "F" + ], + "atteso": 42.0 + }, + { + "fn": "score_pushup", + "args": [ + 10, + 30, + "F" + ], + "atteso": 50.0 + }, + { + "fn": "score_pushup", + "args": [ + 12, + 30, + "F" + ], + "atteso": 58.0 + }, + { + "fn": "score_pushup", + "args": [ + 14, + 30, + "F" + ], + "atteso": 66.0 + }, + { + "fn": "score_pushup", + "args": [ + 16, + 30, + "F" + ], + "atteso": 73.3 + }, + { + "fn": "score_pushup", + "args": [ + 18, + 30, + "F" + ], + "atteso": 80.0 + }, + { + "fn": "score_pushup", + "args": [ + 20, + 30, + "F" + ], + "atteso": 86.7 + }, + { + "fn": "score_pushup", + "args": [ + 22, + 30, + "F" + ], + "atteso": 93.3 + }, + { + "fn": "score_pushup", + "args": [ + 24, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 26, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 28, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 30, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 32, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 34, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 36, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 38, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 40, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 42, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 44, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 46, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 48, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 50, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 52, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 54, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 56, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 58, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 60, + 30, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 10, + 35, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 12.5, + 35, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 15.0, + 35, + "F" + ], + "atteso": 21.9 + }, + { + "fn": "score_handgrip", + "args": [ + 17.5, + 35, + "F" + ], + "atteso": 28.8 + }, + { + "fn": "score_handgrip", + "args": [ + 20.0, + 35, + "F" + ], + "atteso": 35.8 + }, + { + "fn": "score_handgrip", + "args": [ + 22.5, + 35, + "F" + ], + "atteso": 42.8 + }, + { + "fn": "score_handgrip", + "args": [ + 25.0, + 35, + "F" + ], + "atteso": 49.8 + }, + { + "fn": "score_handgrip", + "args": [ + 27.5, + 35, + "F" + ], + "atteso": 56.7 + }, + { + "fn": "score_handgrip", + "args": [ + 30.0, + 35, + "F" + ], + "atteso": 63.7 + }, + { + "fn": "score_handgrip", + "args": [ + 32.5, + 35, + "F" + ], + "atteso": 70.7 + }, + { + "fn": "score_handgrip", + "args": [ + 35.0, + 35, + "F" + ], + "atteso": 77.7 + }, + { + "fn": "score_handgrip", + "args": [ + 37.5, + 35, + "F" + ], + "atteso": 84.7 + }, + { + "fn": "score_handgrip", + "args": [ + 40.0, + 35, + "F" + ], + "atteso": 91.6 + }, + { + "fn": "score_handgrip", + "args": [ + 42.5, + 35, + "F" + ], + "atteso": 98.6 + }, + { + "fn": "score_handgrip", + "args": [ + 45.0, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 47.5, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 50.0, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 52.5, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 55.0, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 57.5, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 60.0, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 62.5, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 65.0, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 67.5, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 70.0, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 0, + 35, + "F" + ], + "atteso": 10 + }, + { + "fn": "score_pushup", + "args": [ + 2, + 35, + "F" + ], + "atteso": 18.0 + }, + { + "fn": "score_pushup", + "args": [ + 4, + 35, + "F" + ], + "atteso": 26.0 + }, + { + "fn": "score_pushup", + "args": [ + 6, + 35, + "F" + ], + "atteso": 34.0 + }, + { + "fn": "score_pushup", + "args": [ + 8, + 35, + "F" + ], + "atteso": 42.0 + }, + { + "fn": "score_pushup", + "args": [ + 10, + 35, + "F" + ], + "atteso": 50.0 + }, + { + "fn": "score_pushup", + "args": [ + 12, + 35, + "F" + ], + "atteso": 58.0 + }, + { + "fn": "score_pushup", + "args": [ + 14, + 35, + "F" + ], + "atteso": 66.0 + }, + { + "fn": "score_pushup", + "args": [ + 16, + 35, + "F" + ], + "atteso": 73.3 + }, + { + "fn": "score_pushup", + "args": [ + 18, + 35, + "F" + ], + "atteso": 80.0 + }, + { + "fn": "score_pushup", + "args": [ + 20, + 35, + "F" + ], + "atteso": 86.7 + }, + { + "fn": "score_pushup", + "args": [ + 22, + 35, + "F" + ], + "atteso": 93.3 + }, + { + "fn": "score_pushup", + "args": [ + 24, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 26, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 28, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 30, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 32, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 34, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 36, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 38, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 40, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 42, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 44, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 46, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 48, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 50, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 52, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 54, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 56, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 58, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 60, + 35, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 10, + 40, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 12.5, + 40, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 15.0, + 40, + "F" + ], + "atteso": 22.9 + }, + { + "fn": "score_handgrip", + "args": [ + 17.5, + 40, + "F" + ], + "atteso": 30.0 + }, + { + "fn": "score_handgrip", + "args": [ + 20.0, + 40, + "F" + ], + "atteso": 37.1 + }, + { + "fn": "score_handgrip", + "args": [ + 22.5, + 40, + "F" + ], + "atteso": 44.3 + }, + { + "fn": "score_handgrip", + "args": [ + 25.0, + 40, + "F" + ], + "atteso": 51.4 + }, + { + "fn": "score_handgrip", + "args": [ + 27.5, + 40, + "F" + ], + "atteso": 58.6 + }, + { + "fn": "score_handgrip", + "args": [ + 30.0, + 40, + "F" + ], + "atteso": 65.7 + }, + { + "fn": "score_handgrip", + "args": [ + 32.5, + 40, + "F" + ], + "atteso": 72.9 + }, + { + "fn": "score_handgrip", + "args": [ + 35.0, + 40, + "F" + ], + "atteso": 80.0 + }, + { + "fn": "score_handgrip", + "args": [ + 37.5, + 40, + "F" + ], + "atteso": 87.1 + }, + { + "fn": "score_handgrip", + "args": [ + 40.0, + 40, + "F" + ], + "atteso": 94.3 + }, + { + "fn": "score_handgrip", + "args": [ + 42.5, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 45.0, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 47.5, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 50.0, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 52.5, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 55.0, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 57.5, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 60.0, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 62.5, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 65.0, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 67.5, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 70.0, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 0, + 40, + "F" + ], + "atteso": 10 + }, + { + "fn": "score_pushup", + "args": [ + 2, + 40, + "F" + ], + "atteso": 18.0 + }, + { + "fn": "score_pushup", + "args": [ + 4, + 40, + "F" + ], + "atteso": 26.0 + }, + { + "fn": "score_pushup", + "args": [ + 6, + 40, + "F" + ], + "atteso": 34.0 + }, + { + "fn": "score_pushup", + "args": [ + 8, + 40, + "F" + ], + "atteso": 42.0 + }, + { + "fn": "score_pushup", + "args": [ + 10, + 40, + "F" + ], + "atteso": 50.0 + }, + { + "fn": "score_pushup", + "args": [ + 12, + 40, + "F" + ], + "atteso": 58.0 + }, + { + "fn": "score_pushup", + "args": [ + 14, + 40, + "F" + ], + "atteso": 66.0 + }, + { + "fn": "score_pushup", + "args": [ + 16, + 40, + "F" + ], + "atteso": 73.3 + }, + { + "fn": "score_pushup", + "args": [ + 18, + 40, + "F" + ], + "atteso": 80.0 + }, + { + "fn": "score_pushup", + "args": [ + 20, + 40, + "F" + ], + "atteso": 86.7 + }, + { + "fn": "score_pushup", + "args": [ + 22, + 40, + "F" + ], + "atteso": 93.3 + }, + { + "fn": "score_pushup", + "args": [ + 24, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 26, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 28, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 30, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 32, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 34, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 36, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 38, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 40, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 42, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 44, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 46, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 48, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 50, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 52, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 54, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 56, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 58, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 60, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 10, + 45, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 12.5, + 45, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 15.0, + 45, + "F" + ], + "atteso": 24.4 + }, + { + "fn": "score_handgrip", + "args": [ + 17.5, + 45, + "F" + ], + "atteso": 31.9 + }, + { + "fn": "score_handgrip", + "args": [ + 20.0, + 45, + "F" + ], + "atteso": 39.3 + }, + { + "fn": "score_handgrip", + "args": [ + 22.5, + 45, + "F" + ], + "atteso": 46.7 + }, + { + "fn": "score_handgrip", + "args": [ + 25.0, + 45, + "F" + ], + "atteso": 54.1 + }, + { + "fn": "score_handgrip", + "args": [ + 27.5, + 45, + "F" + ], + "atteso": 61.5 + }, + { + "fn": "score_handgrip", + "args": [ + 30.0, + 45, + "F" + ], + "atteso": 68.9 + }, + { + "fn": "score_handgrip", + "args": [ + 32.5, + 45, + "F" + ], + "atteso": 76.3 + }, + { + "fn": "score_handgrip", + "args": [ + 35.0, + 45, + "F" + ], + "atteso": 83.7 + }, + { + "fn": "score_handgrip", + "args": [ + 37.5, + 45, + "F" + ], + "atteso": 91.1 + }, + { + "fn": "score_handgrip", + "args": [ + 40.0, + 45, + "F" + ], + "atteso": 98.5 + }, + { + "fn": "score_handgrip", + "args": [ + 42.5, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 45.0, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 47.5, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 50.0, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 52.5, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 55.0, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 57.5, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 60.0, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 62.5, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 65.0, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 67.5, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 70.0, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 0, + 45, + "F" + ], + "atteso": 10 + }, + { + "fn": "score_pushup", + "args": [ + 2, + 45, + "F" + ], + "atteso": 19.6 + }, + { + "fn": "score_pushup", + "args": [ + 4, + 45, + "F" + ], + "atteso": 29.2 + }, + { + "fn": "score_pushup", + "args": [ + 6, + 45, + "F" + ], + "atteso": 38.8 + }, + { + "fn": "score_pushup", + "args": [ + 8, + 45, + "F" + ], + "atteso": 48.4 + }, + { + "fn": "score_pushup", + "args": [ + 10, + 45, + "F" + ], + "atteso": 58.0 + }, + { + "fn": "score_pushup", + "args": [ + 12, + 45, + "F" + ], + "atteso": 67.6 + }, + { + "fn": "score_pushup", + "args": [ + 14, + 45, + "F" + ], + "atteso": 76.0 + }, + { + "fn": "score_pushup", + "args": [ + 16, + 45, + "F" + ], + "atteso": 84.0 + }, + { + "fn": "score_pushup", + "args": [ + 18, + 45, + "F" + ], + "atteso": 92.0 + }, + { + "fn": "score_pushup", + "args": [ + 20, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 22, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 24, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 26, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 28, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 30, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 32, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 34, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 36, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 38, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 40, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 42, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 44, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 46, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 48, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 50, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 52, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 54, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 56, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 58, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 60, + 45, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 10, + 50, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 12.5, + 50, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 15.0, + 50, + "F" + ], + "atteso": 26.2 + }, + { + "fn": "score_handgrip", + "args": [ + 17.5, + 50, + "F" + ], + "atteso": 33.8 + }, + { + "fn": "score_handgrip", + "args": [ + 20.0, + 50, + "F" + ], + "atteso": 41.5 + }, + { + "fn": "score_handgrip", + "args": [ + 22.5, + 50, + "F" + ], + "atteso": 49.2 + }, + { + "fn": "score_handgrip", + "args": [ + 25.0, + 50, + "F" + ], + "atteso": 56.9 + }, + { + "fn": "score_handgrip", + "args": [ + 27.5, + 50, + "F" + ], + "atteso": 64.6 + }, + { + "fn": "score_handgrip", + "args": [ + 30.0, + 50, + "F" + ], + "atteso": 72.3 + }, + { + "fn": "score_handgrip", + "args": [ + 32.5, + 50, + "F" + ], + "atteso": 80.0 + }, + { + "fn": "score_handgrip", + "args": [ + 35.0, + 50, + "F" + ], + "atteso": 87.7 + }, + { + "fn": "score_handgrip", + "args": [ + 37.5, + 50, + "F" + ], + "atteso": 95.4 + }, + { + "fn": "score_handgrip", + "args": [ + 40.0, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 42.5, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 45.0, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 47.5, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 50.0, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 52.5, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 55.0, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 57.5, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 60.0, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 62.5, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 65.0, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 67.5, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 70.0, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 0, + 50, + "F" + ], + "atteso": 10 + }, + { + "fn": "score_pushup", + "args": [ + 2, + 50, + "F" + ], + "atteso": 19.6 + }, + { + "fn": "score_pushup", + "args": [ + 4, + 50, + "F" + ], + "atteso": 29.2 + }, + { + "fn": "score_pushup", + "args": [ + 6, + 50, + "F" + ], + "atteso": 38.8 + }, + { + "fn": "score_pushup", + "args": [ + 8, + 50, + "F" + ], + "atteso": 48.4 + }, + { + "fn": "score_pushup", + "args": [ + 10, + 50, + "F" + ], + "atteso": 58.0 + }, + { + "fn": "score_pushup", + "args": [ + 12, + 50, + "F" + ], + "atteso": 67.6 + }, + { + "fn": "score_pushup", + "args": [ + 14, + 50, + "F" + ], + "atteso": 76.0 + }, + { + "fn": "score_pushup", + "args": [ + 16, + 50, + "F" + ], + "atteso": 84.0 + }, + { + "fn": "score_pushup", + "args": [ + 18, + 50, + "F" + ], + "atteso": 92.0 + }, + { + "fn": "score_pushup", + "args": [ + 20, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 22, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 24, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 26, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 28, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 30, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 32, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 34, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 36, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 38, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 40, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 42, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 44, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 46, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 48, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 50, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 52, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 54, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 56, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 58, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 60, + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 10, + 55, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 12.5, + 55, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 15.0, + 55, + "F" + ], + "atteso": 28.0 + }, + { + "fn": "score_handgrip", + "args": [ + 17.5, + 55, + "F" + ], + "atteso": 36.0 + }, + { + "fn": "score_handgrip", + "args": [ + 20.0, + 55, + "F" + ], + "atteso": 44.0 + }, + { + "fn": "score_handgrip", + "args": [ + 22.5, + 55, + "F" + ], + "atteso": 52.0 + }, + { + "fn": "score_handgrip", + "args": [ + 25.0, + 55, + "F" + ], + "atteso": 60.0 + }, + { + "fn": "score_handgrip", + "args": [ + 27.5, + 55, + "F" + ], + "atteso": 68.0 + }, + { + "fn": "score_handgrip", + "args": [ + 30.0, + 55, + "F" + ], + "atteso": 76.0 + }, + { + "fn": "score_handgrip", + "args": [ + 32.5, + 55, + "F" + ], + "atteso": 84.0 + }, + { + "fn": "score_handgrip", + "args": [ + 35.0, + 55, + "F" + ], + "atteso": 92.0 + }, + { + "fn": "score_handgrip", + "args": [ + 37.5, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 40.0, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 42.5, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 45.0, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 47.5, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 50.0, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 52.5, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 55.0, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 57.5, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 60.0, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 62.5, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 65.0, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 67.5, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 70.0, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 0, + 55, + "F" + ], + "atteso": 10 + }, + { + "fn": "score_pushup", + "args": [ + 2, + 55, + "F" + ], + "atteso": 22.0 + }, + { + "fn": "score_pushup", + "args": [ + 4, + 55, + "F" + ], + "atteso": 34.0 + }, + { + "fn": "score_pushup", + "args": [ + 6, + 55, + "F" + ], + "atteso": 46.0 + }, + { + "fn": "score_pushup", + "args": [ + 8, + 55, + "F" + ], + "atteso": 58.0 + }, + { + "fn": "score_pushup", + "args": [ + 10, + 55, + "F" + ], + "atteso": 70 + }, + { + "fn": "score_pushup", + "args": [ + 12, + 55, + "F" + ], + "atteso": 80.0 + }, + { + "fn": "score_pushup", + "args": [ + 14, + 55, + "F" + ], + "atteso": 90.0 + }, + { + "fn": "score_pushup", + "args": [ + 16, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 18, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 20, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 22, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 24, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 26, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 28, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 30, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 32, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 34, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 36, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 38, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 40, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 42, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 44, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 46, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 48, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 50, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 52, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 54, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 56, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 58, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 60, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 10, + 60, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 12.5, + 60, + "F" + ], + "atteso": 21.7 + }, + { + "fn": "score_handgrip", + "args": [ + 15.0, + 60, + "F" + ], + "atteso": 30.0 + }, + { + "fn": "score_handgrip", + "args": [ + 17.5, + 60, + "F" + ], + "atteso": 38.3 + }, + { + "fn": "score_handgrip", + "args": [ + 20.0, + 60, + "F" + ], + "atteso": 46.7 + }, + { + "fn": "score_handgrip", + "args": [ + 22.5, + 60, + "F" + ], + "atteso": 55.0 + }, + { + "fn": "score_handgrip", + "args": [ + 25.0, + 60, + "F" + ], + "atteso": 63.3 + }, + { + "fn": "score_handgrip", + "args": [ + 27.5, + 60, + "F" + ], + "atteso": 71.7 + }, + { + "fn": "score_handgrip", + "args": [ + 30.0, + 60, + "F" + ], + "atteso": 80.0 + }, + { + "fn": "score_handgrip", + "args": [ + 32.5, + 60, + "F" + ], + "atteso": 88.3 + }, + { + "fn": "score_handgrip", + "args": [ + 35.0, + 60, + "F" + ], + "atteso": 96.7 + }, + { + "fn": "score_handgrip", + "args": [ + 37.5, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 40.0, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 42.5, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 45.0, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 47.5, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 50.0, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 52.5, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 55.0, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 57.5, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 60.0, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 62.5, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 65.0, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 67.5, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 70.0, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 0, + 60, + "F" + ], + "atteso": 10 + }, + { + "fn": "score_pushup", + "args": [ + 2, + 60, + "F" + ], + "atteso": 22.0 + }, + { + "fn": "score_pushup", + "args": [ + 4, + 60, + "F" + ], + "atteso": 34.0 + }, + { + "fn": "score_pushup", + "args": [ + 6, + 60, + "F" + ], + "atteso": 46.0 + }, + { + "fn": "score_pushup", + "args": [ + 8, + 60, + "F" + ], + "atteso": 58.0 + }, + { + "fn": "score_pushup", + "args": [ + 10, + 60, + "F" + ], + "atteso": 70 + }, + { + "fn": "score_pushup", + "args": [ + 12, + 60, + "F" + ], + "atteso": 80.0 + }, + { + "fn": "score_pushup", + "args": [ + 14, + 60, + "F" + ], + "atteso": 90.0 + }, + { + "fn": "score_pushup", + "args": [ + 16, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 18, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 20, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 22, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 24, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 26, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 28, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 30, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 32, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 34, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 36, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 38, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 40, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 42, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 44, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 46, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 48, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 50, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 52, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 54, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 56, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 58, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 60, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 10, + 65, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 12.5, + 65, + "F" + ], + "atteso": 25.0 + }, + { + "fn": "score_handgrip", + "args": [ + 15.0, + 65, + "F" + ], + "atteso": 34.0 + }, + { + "fn": "score_handgrip", + "args": [ + 17.5, + 65, + "F" + ], + "atteso": 43.0 + }, + { + "fn": "score_handgrip", + "args": [ + 20.0, + 65, + "F" + ], + "atteso": 52.0 + }, + { + "fn": "score_handgrip", + "args": [ + 22.5, + 65, + "F" + ], + "atteso": 61.0 + }, + { + "fn": "score_handgrip", + "args": [ + 25.0, + 65, + "F" + ], + "atteso": 70.0 + }, + { + "fn": "score_handgrip", + "args": [ + 27.5, + 65, + "F" + ], + "atteso": 79.0 + }, + { + "fn": "score_handgrip", + "args": [ + 30.0, + 65, + "F" + ], + "atteso": 87.9 + }, + { + "fn": "score_handgrip", + "args": [ + 32.5, + 65, + "F" + ], + "atteso": 96.9 + }, + { + "fn": "score_handgrip", + "args": [ + 35.0, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 37.5, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 40.0, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 42.5, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 45.0, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 47.5, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 50.0, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 52.5, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 55.0, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 57.5, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 60.0, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 62.5, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 65.0, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 67.5, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 70.0, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 0, + 65, + "F" + ], + "atteso": 10 + }, + { + "fn": "score_pushup", + "args": [ + 2, + 65, + "F" + ], + "atteso": 26.0 + }, + { + "fn": "score_pushup", + "args": [ + 4, + 65, + "F" + ], + "atteso": 42.0 + }, + { + "fn": "score_pushup", + "args": [ + 6, + 65, + "F" + ], + "atteso": 58.0 + }, + { + "fn": "score_pushup", + "args": [ + 8, + 65, + "F" + ], + "atteso": 73.3 + }, + { + "fn": "score_pushup", + "args": [ + 10, + 65, + "F" + ], + "atteso": 86.7 + }, + { + "fn": "score_pushup", + "args": [ + 12, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 14, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 16, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 18, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 20, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 22, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 24, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 26, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 28, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 30, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 32, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 34, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 36, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 38, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 40, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 42, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 44, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 46, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 48, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 50, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 52, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 54, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 56, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 58, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 60, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 10, + 70, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_handgrip", + "args": [ + 12.5, + 70, + "F" + ], + "atteso": 28.9 + }, + { + "fn": "score_handgrip", + "args": [ + 15.0, + 70, + "F" + ], + "atteso": 38.6 + }, + { + "fn": "score_handgrip", + "args": [ + 17.5, + 70, + "F" + ], + "atteso": 48.4 + }, + { + "fn": "score_handgrip", + "args": [ + 20.0, + 70, + "F" + ], + "atteso": 58.2 + }, + { + "fn": "score_handgrip", + "args": [ + 22.5, + 70, + "F" + ], + "atteso": 67.9 + }, + { + "fn": "score_handgrip", + "args": [ + 25.0, + 70, + "F" + ], + "atteso": 77.7 + }, + { + "fn": "score_handgrip", + "args": [ + 27.5, + 70, + "F" + ], + "atteso": 87.5 + }, + { + "fn": "score_handgrip", + "args": [ + 30.0, + 70, + "F" + ], + "atteso": 97.3 + }, + { + "fn": "score_handgrip", + "args": [ + 32.5, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 35.0, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 37.5, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 40.0, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 42.5, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 45.0, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 47.5, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 50.0, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 52.5, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 55.0, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 57.5, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 60.0, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 62.5, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 65.0, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 67.5, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 70.0, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 0, + 70, + "F" + ], + "atteso": 10 + }, + { + "fn": "score_pushup", + "args": [ + 2, + 70, + "F" + ], + "atteso": 26.0 + }, + { + "fn": "score_pushup", + "args": [ + 4, + 70, + "F" + ], + "atteso": 42.0 + }, + { + "fn": "score_pushup", + "args": [ + 6, + 70, + "F" + ], + "atteso": 58.0 + }, + { + "fn": "score_pushup", + "args": [ + 8, + 70, + "F" + ], + "atteso": 73.3 + }, + { + "fn": "score_pushup", + "args": [ + 10, + 70, + "F" + ], + "atteso": 86.7 + }, + { + "fn": "score_pushup", + "args": [ + 12, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 14, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 16, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 18, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 20, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 22, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 24, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 26, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 28, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 30, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 32, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 34, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 36, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 38, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 40, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 42, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 44, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 46, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 48, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 50, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 52, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 54, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 56, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 58, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 60, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 10, + 75, + "F" + ], + "atteso": 22.8 + }, + { + "fn": "score_handgrip", + "args": [ + 12.5, + 75, + "F" + ], + "atteso": 33.5 + }, + { + "fn": "score_handgrip", + "args": [ + 15.0, + 75, + "F" + ], + "atteso": 44.2 + }, + { + "fn": "score_handgrip", + "args": [ + 17.5, + 75, + "F" + ], + "atteso": 54.9 + }, + { + "fn": "score_handgrip", + "args": [ + 20.0, + 75, + "F" + ], + "atteso": 65.6 + }, + { + "fn": "score_handgrip", + "args": [ + 22.5, + 75, + "F" + ], + "atteso": 76.3 + }, + { + "fn": "score_handgrip", + "args": [ + 25.0, + 75, + "F" + ], + "atteso": 87.0 + }, + { + "fn": "score_handgrip", + "args": [ + 27.5, + 75, + "F" + ], + "atteso": 97.6 + }, + { + "fn": "score_handgrip", + "args": [ + 30.0, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 32.5, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 35.0, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 37.5, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 40.0, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 42.5, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 45.0, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 47.5, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 50.0, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 52.5, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 55.0, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 57.5, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 60.0, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 62.5, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 65.0, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 67.5, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_handgrip", + "args": [ + 70.0, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 0, + 75, + "F" + ], + "atteso": 10 + }, + { + "fn": "score_pushup", + "args": [ + 2, + 75, + "F" + ], + "atteso": 34.0 + }, + { + "fn": "score_pushup", + "args": [ + 4, + 75, + "F" + ], + "atteso": 58.0 + }, + { + "fn": "score_pushup", + "args": [ + 6, + 75, + "F" + ], + "atteso": 80.0 + }, + { + "fn": "score_pushup", + "args": [ + 8, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 10, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 12, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 14, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 16, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 18, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 20, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 22, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 24, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 26, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 28, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 30, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 32, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 34, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 36, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 38, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 40, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 42, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 44, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 46, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 48, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 50, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 52, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 54, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 56, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 58, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_pushup", + "args": [ + 60, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_plank", + "args": [ + 0, + "F" + ], + "atteso": 10 + }, + { + "fn": "score_plank", + "args": [ + 5, + "F" + ], + "atteso": 11.4 + }, + { + "fn": "score_plank", + "args": [ + 10, + "F" + ], + "atteso": 12.9 + }, + { + "fn": "score_plank", + "args": [ + 15, + "F" + ], + "atteso": 14.3 + }, + { + "fn": "score_plank", + "args": [ + 20, + "F" + ], + "atteso": 15.7 + }, + { + "fn": "score_plank", + "args": [ + 25, + "F" + ], + "atteso": 17.1 + }, + { + "fn": "score_plank", + "args": [ + 30, + "F" + ], + "atteso": 18.6 + }, + { + "fn": "score_plank", + "args": [ + 35, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_plank", + "args": [ + 40, + "F" + ], + "atteso": 23.6 + }, + { + "fn": "score_plank", + "args": [ + 45, + "F" + ], + "atteso": 27.1 + }, + { + "fn": "score_plank", + "args": [ + 50, + "F" + ], + "atteso": 30.7 + }, + { + "fn": "score_plank", + "args": [ + 55, + "F" + ], + "atteso": 34.3 + }, + { + "fn": "score_plank", + "args": [ + 60, + "F" + ], + "atteso": 37.9 + }, + { + "fn": "score_plank", + "args": [ + 65, + "F" + ], + "atteso": 41.4 + }, + { + "fn": "score_plank", + "args": [ + 70, + "F" + ], + "atteso": 45.0 + }, + { + "fn": "score_plank", + "args": [ + 75, + "F" + ], + "atteso": 48.6 + }, + { + "fn": "score_plank", + "args": [ + 80, + "F" + ], + "atteso": 52.1 + }, + { + "fn": "score_plank", + "args": [ + 85, + "F" + ], + "atteso": 55.8 + }, + { + "fn": "score_plank", + "args": [ + 90, + "F" + ], + "atteso": 60.0 + }, + { + "fn": "score_plank", + "args": [ + 95, + "F" + ], + "atteso": 64.2 + }, + { + "fn": "score_plank", + "args": [ + 100, + "F" + ], + "atteso": 68.3 + }, + { + "fn": "score_plank", + "args": [ + 105, + "F" + ], + "atteso": 72.5 + }, + { + "fn": "score_plank", + "args": [ + 110, + "F" + ], + "atteso": 75.9 + }, + { + "fn": "score_plank", + "args": [ + 115, + "F" + ], + "atteso": 78.1 + }, + { + "fn": "score_plank", + "args": [ + 120, + "F" + ], + "atteso": 80.3 + }, + { + "fn": "score_plank", + "args": [ + 125, + "F" + ], + "atteso": 82.5 + }, + { + "fn": "score_plank", + "args": [ + 130, + "F" + ], + "atteso": 84.7 + }, + { + "fn": "score_plank", + "args": [ + 135, + "F" + ], + "atteso": 86.9 + }, + { + "fn": "score_plank", + "args": [ + 140, + "F" + ], + "atteso": 89.1 + }, + { + "fn": "score_plank", + "args": [ + 145, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_plank", + "args": [ + 150, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_plank", + "args": [ + 155, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_plank", + "args": [ + 160, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_plank", + "args": [ + 165, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_plank", + "args": [ + 170, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_plank", + "args": [ + 175, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_plank", + "args": [ + 180, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_plank", + "args": [ + 185, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_plank", + "args": [ + 190, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_plank", + "args": [ + 195, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_plank", + "args": [ + 200, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_plank", + "args": [ + 205, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_plank", + "args": [ + 210, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_plank", + "args": [ + 215, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_plank", + "args": [ + 220, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_plank", + "args": [ + 225, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_plank", + "args": [ + 230, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_plank", + "args": [ + 235, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_plank", + "args": [ + 240, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_plank", + "args": [ + 245, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_plank", + "args": [ + 250, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 0, + "F" + ], + "atteso": 10 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 5, + "F" + ], + "atteso": 25.0 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 10, + "F" + ], + "atteso": 40 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 15, + "F" + ], + "atteso": 50.0 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 20, + "F" + ], + "atteso": 60.0 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 25, + "F" + ], + "atteso": 70 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 30, + "F" + ], + "atteso": 76.0 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 35, + "F" + ], + "atteso": 82.0 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 40, + "F" + ], + "atteso": 88.0 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 45, + "F" + ], + "atteso": 94.0 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 50, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 80, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 85, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 90, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 95, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 100, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 105, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 110, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 115, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_flexed_arm_hang", + "args": [ + 120, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 0, + 40, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 2, + 40, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 4, + 40, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 6, + 40, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 8, + 40, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 10, + 40, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 12, + 40, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 14, + 40, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 16, + 40, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 18, + 40, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 20, + 40, + "F" + ], + "atteso": 24.9 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 22, + 40, + "F" + ], + "atteso": 30.4 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 24, + 40, + "F" + ], + "atteso": 35.9 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 26, + 40, + "F" + ], + "atteso": 41.4 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 28, + 40, + "F" + ], + "atteso": 46.8 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 30, + 40, + "F" + ], + "atteso": 52.3 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 32, + 40, + "F" + ], + "atteso": 57.8 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 34, + 40, + "F" + ], + "atteso": 63.3 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 36, + 40, + "F" + ], + "atteso": 68.8 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 38, + 40, + "F" + ], + "atteso": 74.3 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 40, + 40, + "F" + ], + "atteso": 79.8 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 42, + 40, + "F" + ], + "atteso": 85.3 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 44, + 40, + "F" + ], + "atteso": 90.7 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 46, + 40, + "F" + ], + "atteso": 96.2 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 48, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 50, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 52, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 54, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 56, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 58, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_sit_to_stand_1min", + "args": [ + 60, + 40, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -30, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -29, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -28, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -27, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -26, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -25, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -24, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -23, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -22, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -21, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -20, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -19, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -18, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -17, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -16, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -15, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -14, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -13, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -12, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -11, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -10, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -9, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -8, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -7, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -6, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -5, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -4, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -3, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -2, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + -1, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 0, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 1, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 2, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 3, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 4, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 5, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 6, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 7, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 8, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 9, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 10, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 11, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 12, + "F" + ], + "atteso": 22.7 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 13, + "F" + ], + "atteso": 25.3 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 14, + "F" + ], + "atteso": 28.0 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 15, + "F" + ], + "atteso": 30.7 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 16, + "F" + ], + "atteso": 33.3 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 17, + "F" + ], + "atteso": 36.0 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 18, + "F" + ], + "atteso": 38.7 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 19, + "F" + ], + "atteso": 41.3 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 20, + "F" + ], + "atteso": 44.0 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 21, + "F" + ], + "atteso": 46.7 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 22, + "F" + ], + "atteso": 49.3 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 23, + "F" + ], + "atteso": 52.0 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 24, + "F" + ], + "atteso": 54.7 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 25, + "F" + ], + "atteso": 57.3 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 26, + "F" + ], + "atteso": 60.0 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 27, + "F" + ], + "atteso": 62.7 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 28, + "F" + ], + "atteso": 65.3 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 29, + "F" + ], + "atteso": 68.0 + }, + { + "fn": "score_sit_and_reach", + "args": [ + 30, + "F" + ], + "atteso": 70.7 + }, + { + "fn": "score_back_scratch", + "args": [ + -30, + 25, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -28, + 25, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -26, + 25, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -24, + 25, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -22, + 25, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -20, + 25, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -18, + 25, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -16, + 25, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -14, + 25, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -12, + 25, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -10, + 25, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -8, + 25, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -6, + 25, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -4, + 25, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -2, + 25, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + 0, + 25, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + 2, + 25, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + 4, + 25, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + 6, + 25, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + 8, + 25, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + 10, + 25, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + 12, + 25, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + 14, + 25, + "F" + ], + "atteso": 23.2 + }, + { + "fn": "score_back_scratch", + "args": [ + 16, + 25, + "F" + ], + "atteso": 28.5 + }, + { + "fn": "score_back_scratch", + "args": [ + 18, + 25, + "F" + ], + "atteso": 33.9 + }, + { + "fn": "score_back_scratch", + "args": [ + 20, + 25, + "F" + ], + "atteso": 39.2 + }, + { + "fn": "score_back_scratch", + "args": [ + -30, + 30, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -28, + 30, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -26, + 30, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -24, + 30, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -22, + 30, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -20, + 30, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -18, + 30, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -16, + 30, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -14, + 30, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -12, + 30, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -10, + 30, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -8, + 30, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -6, + 30, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -4, + 30, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -2, + 30, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + 0, + 30, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + 2, + 30, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + 4, + 30, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + 6, + 30, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + 8, + 30, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + 10, + 30, + "F" + ], + "atteso": 23.2 + }, + { + "fn": "score_back_scratch", + "args": [ + 12, + 30, + "F" + ], + "atteso": 28.5 + }, + { + "fn": "score_back_scratch", + "args": [ + 14, + 30, + "F" + ], + "atteso": 33.9 + }, + { + "fn": "score_back_scratch", + "args": [ + 16, + 30, + "F" + ], + "atteso": 39.2 + }, + { + "fn": "score_back_scratch", + "args": [ + 18, + 30, + "F" + ], + "atteso": 44.5 + }, + { + "fn": "score_back_scratch", + "args": [ + 20, + 30, + "F" + ], + "atteso": 49.9 + }, + { + "fn": "score_back_scratch", + "args": [ + -30, + 35, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -28, + 35, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -26, + 35, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -24, + 35, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -22, + 35, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -20, + 35, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -18, + 35, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -16, + 35, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -14, + 35, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -12, + 35, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -10, + 35, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -8, + 35, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -6, + 35, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -4, + 35, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -2, + 35, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + 0, + 35, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + 2, + 35, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + 4, + 35, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + 6, + 35, + "F" + ], + "atteso": 23.2 + }, + { + "fn": "score_back_scratch", + "args": [ + 8, + 35, + "F" + ], + "atteso": 28.5 + }, + { + "fn": "score_back_scratch", + "args": [ + 10, + 35, + "F" + ], + "atteso": 33.9 + }, + { + "fn": "score_back_scratch", + "args": [ + 12, + 35, + "F" + ], + "atteso": 39.2 + }, + { + "fn": "score_back_scratch", + "args": [ + 14, + 35, + "F" + ], + "atteso": 44.5 + }, + { + "fn": "score_back_scratch", + "args": [ + 16, + 35, + "F" + ], + "atteso": 49.9 + }, + { + "fn": "score_back_scratch", + "args": [ + 18, + 35, + "F" + ], + "atteso": 55.2 + }, + { + "fn": "score_back_scratch", + "args": [ + 20, + 35, + "F" + ], + "atteso": 60.5 + }, + { + "fn": "score_back_scratch", + "args": [ + -30, + 40, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -28, + 40, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -26, + 40, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -24, + 40, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -22, + 40, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -20, + 40, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -18, + 40, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -16, + 40, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -14, + 40, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -12, + 40, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -10, + 40, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -8, + 40, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -6, + 40, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -4, + 40, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -2, + 40, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + 0, + 40, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + 2, + 40, + "F" + ], + "atteso": 23.2 + }, + { + "fn": "score_back_scratch", + "args": [ + 4, + 40, + "F" + ], + "atteso": 28.5 + }, + { + "fn": "score_back_scratch", + "args": [ + 6, + 40, + "F" + ], + "atteso": 33.9 + }, + { + "fn": "score_back_scratch", + "args": [ + 8, + 40, + "F" + ], + "atteso": 39.2 + }, + { + "fn": "score_back_scratch", + "args": [ + 10, + 40, + "F" + ], + "atteso": 44.5 + }, + { + "fn": "score_back_scratch", + "args": [ + 12, + 40, + "F" + ], + "atteso": 49.9 + }, + { + "fn": "score_back_scratch", + "args": [ + 14, + 40, + "F" + ], + "atteso": 55.2 + }, + { + "fn": "score_back_scratch", + "args": [ + 16, + 40, + "F" + ], + "atteso": 60.5 + }, + { + "fn": "score_back_scratch", + "args": [ + 18, + 40, + "F" + ], + "atteso": 65.9 + }, + { + "fn": "score_back_scratch", + "args": [ + 20, + 40, + "F" + ], + "atteso": 71.2 + }, + { + "fn": "score_back_scratch", + "args": [ + -30, + 45, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -28, + 45, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -26, + 45, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -24, + 45, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -22, + 45, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -20, + 45, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -18, + 45, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -16, + 45, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -14, + 45, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -12, + 45, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -10, + 45, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -8, + 45, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -6, + 45, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -4, + 45, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -2, + 45, + "F" + ], + "atteso": 23.2 + }, + { + "fn": "score_back_scratch", + "args": [ + 0, + 45, + "F" + ], + "atteso": 28.5 + }, + { + "fn": "score_back_scratch", + "args": [ + 2, + 45, + "F" + ], + "atteso": 33.9 + }, + { + "fn": "score_back_scratch", + "args": [ + 4, + 45, + "F" + ], + "atteso": 39.2 + }, + { + "fn": "score_back_scratch", + "args": [ + 6, + 45, + "F" + ], + "atteso": 44.5 + }, + { + "fn": "score_back_scratch", + "args": [ + 8, + 45, + "F" + ], + "atteso": 49.9 + }, + { + "fn": "score_back_scratch", + "args": [ + 10, + 45, + "F" + ], + "atteso": 55.2 + }, + { + "fn": "score_back_scratch", + "args": [ + 12, + 45, + "F" + ], + "atteso": 60.5 + }, + { + "fn": "score_back_scratch", + "args": [ + 14, + 45, + "F" + ], + "atteso": 65.9 + }, + { + "fn": "score_back_scratch", + "args": [ + 16, + 45, + "F" + ], + "atteso": 71.2 + }, + { + "fn": "score_back_scratch", + "args": [ + 18, + 45, + "F" + ], + "atteso": 76.5 + }, + { + "fn": "score_back_scratch", + "args": [ + 20, + 45, + "F" + ], + "atteso": 81.9 + }, + { + "fn": "score_back_scratch", + "args": [ + -30, + 50, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -28, + 50, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -26, + 50, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -24, + 50, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -22, + 50, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -20, + 50, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -18, + 50, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -16, + 50, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -14, + 50, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -12, + 50, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -10, + 50, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -8, + 50, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -6, + 50, + "F" + ], + "atteso": 23.2 + }, + { + "fn": "score_back_scratch", + "args": [ + -4, + 50, + "F" + ], + "atteso": 28.5 + }, + { + "fn": "score_back_scratch", + "args": [ + -2, + 50, + "F" + ], + "atteso": 33.9 + }, + { + "fn": "score_back_scratch", + "args": [ + 0, + 50, + "F" + ], + "atteso": 39.2 + }, + { + "fn": "score_back_scratch", + "args": [ + 2, + 50, + "F" + ], + "atteso": 44.5 + }, + { + "fn": "score_back_scratch", + "args": [ + 4, + 50, + "F" + ], + "atteso": 49.9 + }, + { + "fn": "score_back_scratch", + "args": [ + 6, + 50, + "F" + ], + "atteso": 55.2 + }, + { + "fn": "score_back_scratch", + "args": [ + 8, + 50, + "F" + ], + "atteso": 60.5 + }, + { + "fn": "score_back_scratch", + "args": [ + 10, + 50, + "F" + ], + "atteso": 65.9 + }, + { + "fn": "score_back_scratch", + "args": [ + 12, + 50, + "F" + ], + "atteso": 71.2 + }, + { + "fn": "score_back_scratch", + "args": [ + 14, + 50, + "F" + ], + "atteso": 76.5 + }, + { + "fn": "score_back_scratch", + "args": [ + 16, + 50, + "F" + ], + "atteso": 81.9 + }, + { + "fn": "score_back_scratch", + "args": [ + 18, + 50, + "F" + ], + "atteso": 87.2 + }, + { + "fn": "score_back_scratch", + "args": [ + 20, + 50, + "F" + ], + "atteso": 92.5 + }, + { + "fn": "score_back_scratch", + "args": [ + -30, + 55, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -28, + 55, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -26, + 55, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -24, + 55, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -22, + 55, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -20, + 55, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -18, + 55, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -16, + 55, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -14, + 55, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -12, + 55, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -10, + 55, + "F" + ], + "atteso": 23.2 + }, + { + "fn": "score_back_scratch", + "args": [ + -8, + 55, + "F" + ], + "atteso": 28.5 + }, + { + "fn": "score_back_scratch", + "args": [ + -6, + 55, + "F" + ], + "atteso": 33.9 + }, + { + "fn": "score_back_scratch", + "args": [ + -4, + 55, + "F" + ], + "atteso": 39.2 + }, + { + "fn": "score_back_scratch", + "args": [ + -2, + 55, + "F" + ], + "atteso": 44.5 + }, + { + "fn": "score_back_scratch", + "args": [ + 0, + 55, + "F" + ], + "atteso": 49.9 + }, + { + "fn": "score_back_scratch", + "args": [ + 2, + 55, + "F" + ], + "atteso": 55.2 + }, + { + "fn": "score_back_scratch", + "args": [ + 4, + 55, + "F" + ], + "atteso": 60.5 + }, + { + "fn": "score_back_scratch", + "args": [ + 6, + 55, + "F" + ], + "atteso": 65.9 + }, + { + "fn": "score_back_scratch", + "args": [ + 8, + 55, + "F" + ], + "atteso": 71.2 + }, + { + "fn": "score_back_scratch", + "args": [ + 10, + 55, + "F" + ], + "atteso": 76.5 + }, + { + "fn": "score_back_scratch", + "args": [ + 12, + 55, + "F" + ], + "atteso": 81.9 + }, + { + "fn": "score_back_scratch", + "args": [ + 14, + 55, + "F" + ], + "atteso": 87.2 + }, + { + "fn": "score_back_scratch", + "args": [ + 16, + 55, + "F" + ], + "atteso": 92.5 + }, + { + "fn": "score_back_scratch", + "args": [ + 18, + 55, + "F" + ], + "atteso": 97.9 + }, + { + "fn": "score_back_scratch", + "args": [ + 20, + 55, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + -30, + 60, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -28, + 60, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -26, + 60, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -24, + 60, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -22, + 60, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -20, + 60, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -18, + 60, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -16, + 60, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -14, + 60, + "F" + ], + "atteso": 23.2 + }, + { + "fn": "score_back_scratch", + "args": [ + -12, + 60, + "F" + ], + "atteso": 28.5 + }, + { + "fn": "score_back_scratch", + "args": [ + -10, + 60, + "F" + ], + "atteso": 33.9 + }, + { + "fn": "score_back_scratch", + "args": [ + -8, + 60, + "F" + ], + "atteso": 39.2 + }, + { + "fn": "score_back_scratch", + "args": [ + -6, + 60, + "F" + ], + "atteso": 44.5 + }, + { + "fn": "score_back_scratch", + "args": [ + -4, + 60, + "F" + ], + "atteso": 49.9 + }, + { + "fn": "score_back_scratch", + "args": [ + -2, + 60, + "F" + ], + "atteso": 55.2 + }, + { + "fn": "score_back_scratch", + "args": [ + 0, + 60, + "F" + ], + "atteso": 60.5 + }, + { + "fn": "score_back_scratch", + "args": [ + 2, + 60, + "F" + ], + "atteso": 65.9 + }, + { + "fn": "score_back_scratch", + "args": [ + 4, + 60, + "F" + ], + "atteso": 71.2 + }, + { + "fn": "score_back_scratch", + "args": [ + 6, + 60, + "F" + ], + "atteso": 76.5 + }, + { + "fn": "score_back_scratch", + "args": [ + 8, + 60, + "F" + ], + "atteso": 81.9 + }, + { + "fn": "score_back_scratch", + "args": [ + 10, + 60, + "F" + ], + "atteso": 87.2 + }, + { + "fn": "score_back_scratch", + "args": [ + 12, + 60, + "F" + ], + "atteso": 92.5 + }, + { + "fn": "score_back_scratch", + "args": [ + 14, + 60, + "F" + ], + "atteso": 97.9 + }, + { + "fn": "score_back_scratch", + "args": [ + 16, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 18, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 20, + 60, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + -30, + 65, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -28, + 65, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -26, + 65, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -24, + 65, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -22, + 65, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -20, + 65, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -18, + 65, + "F" + ], + "atteso": 23.2 + }, + { + "fn": "score_back_scratch", + "args": [ + -16, + 65, + "F" + ], + "atteso": 28.5 + }, + { + "fn": "score_back_scratch", + "args": [ + -14, + 65, + "F" + ], + "atteso": 33.9 + }, + { + "fn": "score_back_scratch", + "args": [ + -12, + 65, + "F" + ], + "atteso": 39.2 + }, + { + "fn": "score_back_scratch", + "args": [ + -10, + 65, + "F" + ], + "atteso": 44.5 + }, + { + "fn": "score_back_scratch", + "args": [ + -8, + 65, + "F" + ], + "atteso": 49.9 + }, + { + "fn": "score_back_scratch", + "args": [ + -6, + 65, + "F" + ], + "atteso": 55.2 + }, + { + "fn": "score_back_scratch", + "args": [ + -4, + 65, + "F" + ], + "atteso": 60.5 + }, + { + "fn": "score_back_scratch", + "args": [ + -2, + 65, + "F" + ], + "atteso": 65.9 + }, + { + "fn": "score_back_scratch", + "args": [ + 0, + 65, + "F" + ], + "atteso": 71.2 + }, + { + "fn": "score_back_scratch", + "args": [ + 2, + 65, + "F" + ], + "atteso": 76.5 + }, + { + "fn": "score_back_scratch", + "args": [ + 4, + 65, + "F" + ], + "atteso": 81.9 + }, + { + "fn": "score_back_scratch", + "args": [ + 6, + 65, + "F" + ], + "atteso": 87.2 + }, + { + "fn": "score_back_scratch", + "args": [ + 8, + 65, + "F" + ], + "atteso": 92.5 + }, + { + "fn": "score_back_scratch", + "args": [ + 10, + 65, + "F" + ], + "atteso": 97.9 + }, + { + "fn": "score_back_scratch", + "args": [ + 12, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 14, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 16, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 18, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 20, + 65, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + -30, + 70, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -28, + 70, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -26, + 70, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -24, + 70, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -22, + 70, + "F" + ], + "atteso": 23.2 + }, + { + "fn": "score_back_scratch", + "args": [ + -20, + 70, + "F" + ], + "atteso": 28.5 + }, + { + "fn": "score_back_scratch", + "args": [ + -18, + 70, + "F" + ], + "atteso": 33.9 + }, + { + "fn": "score_back_scratch", + "args": [ + -16, + 70, + "F" + ], + "atteso": 39.2 + }, + { + "fn": "score_back_scratch", + "args": [ + -14, + 70, + "F" + ], + "atteso": 44.5 + }, + { + "fn": "score_back_scratch", + "args": [ + -12, + 70, + "F" + ], + "atteso": 49.9 + }, + { + "fn": "score_back_scratch", + "args": [ + -10, + 70, + "F" + ], + "atteso": 55.2 + }, + { + "fn": "score_back_scratch", + "args": [ + -8, + 70, + "F" + ], + "atteso": 60.5 + }, + { + "fn": "score_back_scratch", + "args": [ + -6, + 70, + "F" + ], + "atteso": 65.9 + }, + { + "fn": "score_back_scratch", + "args": [ + -4, + 70, + "F" + ], + "atteso": 71.2 + }, + { + "fn": "score_back_scratch", + "args": [ + -2, + 70, + "F" + ], + "atteso": 76.5 + }, + { + "fn": "score_back_scratch", + "args": [ + 0, + 70, + "F" + ], + "atteso": 81.9 + }, + { + "fn": "score_back_scratch", + "args": [ + 2, + 70, + "F" + ], + "atteso": 87.2 + }, + { + "fn": "score_back_scratch", + "args": [ + 4, + 70, + "F" + ], + "atteso": 92.5 + }, + { + "fn": "score_back_scratch", + "args": [ + 6, + 70, + "F" + ], + "atteso": 97.9 + }, + { + "fn": "score_back_scratch", + "args": [ + 8, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 10, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 12, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 14, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 16, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 18, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 20, + 70, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + -30, + 75, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -28, + 75, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_back_scratch", + "args": [ + -26, + 75, + "F" + ], + "atteso": 23.2 + }, + { + "fn": "score_back_scratch", + "args": [ + -24, + 75, + "F" + ], + "atteso": 28.5 + }, + { + "fn": "score_back_scratch", + "args": [ + -22, + 75, + "F" + ], + "atteso": 33.9 + }, + { + "fn": "score_back_scratch", + "args": [ + -20, + 75, + "F" + ], + "atteso": 39.2 + }, + { + "fn": "score_back_scratch", + "args": [ + -18, + 75, + "F" + ], + "atteso": 44.5 + }, + { + "fn": "score_back_scratch", + "args": [ + -16, + 75, + "F" + ], + "atteso": 49.9 + }, + { + "fn": "score_back_scratch", + "args": [ + -14, + 75, + "F" + ], + "atteso": 55.2 + }, + { + "fn": "score_back_scratch", + "args": [ + -12, + 75, + "F" + ], + "atteso": 60.5 + }, + { + "fn": "score_back_scratch", + "args": [ + -10, + 75, + "F" + ], + "atteso": 65.9 + }, + { + "fn": "score_back_scratch", + "args": [ + -8, + 75, + "F" + ], + "atteso": 71.2 + }, + { + "fn": "score_back_scratch", + "args": [ + -6, + 75, + "F" + ], + "atteso": 76.5 + }, + { + "fn": "score_back_scratch", + "args": [ + -4, + 75, + "F" + ], + "atteso": 81.9 + }, + { + "fn": "score_back_scratch", + "args": [ + -2, + 75, + "F" + ], + "atteso": 87.2 + }, + { + "fn": "score_back_scratch", + "args": [ + 0, + 75, + "F" + ], + "atteso": 92.5 + }, + { + "fn": "score_back_scratch", + "args": [ + 2, + 75, + "F" + ], + "atteso": 97.9 + }, + { + "fn": "score_back_scratch", + "args": [ + 4, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 6, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 8, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 10, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 12, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 14, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 16, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 18, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_back_scratch", + "args": [ + 20, + 75, + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 20, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "M" + ], + "atteso": 21.2 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 20, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "M" + ], + "atteso": 17.5 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 20, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "M" + ], + "atteso": 22.5 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 30, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "M" + ], + "atteso": 26.9 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 30, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "M" + ], + "atteso": 21.2 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 30, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "M" + ], + "atteso": 28.8 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 40, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "M" + ], + "atteso": 33.8 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 40, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "M" + ], + "atteso": 25.0 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 40, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "M" + ], + "atteso": 43.5 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 50, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "M" + ], + "atteso": 42.2 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 50, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "M" + ], + "atteso": 28.8 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 50, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "M" + ], + "atteso": 60.2 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 60, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "M" + ], + "atteso": 50.6 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 60, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "M" + ], + "atteso": 33.8 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 60, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "M" + ], + "atteso": 71.5 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 70, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "M" + ], + "atteso": 59.1 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 70, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "M" + ], + "atteso": 39.4 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 70, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "M" + ], + "atteso": 82.8 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 80, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "M" + ], + "atteso": 70.0 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 80, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "M" + ], + "atteso": 45.0 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 80, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "M" + ], + "atteso": 94.0 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 90, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "M" + ], + "atteso": 81.2 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 90, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "M" + ], + "atteso": 50.6 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 90, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "M" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 100, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "M" + ], + "atteso": 92.5 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 100, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "M" + ], + "atteso": 56.2 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 100, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "M" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 110, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "M" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 110, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "M" + ], + "atteso": 63.8 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 110, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "M" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 120, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "M" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 120, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "M" + ], + "atteso": 75.0 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 120, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "M" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 130, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "M" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 130, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "M" + ], + "atteso": 86.2 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 130, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "M" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 140, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "M" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 140, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "M" + ], + "atteso": 97.5 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 140, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "M" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 150, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "M" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 150, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "M" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 150, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "M" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 160, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "M" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 160, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "M" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 160, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "M" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 170, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "M" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 170, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "M" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 170, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "M" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 180, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "M" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 180, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "M" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 180, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "M" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 190, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "M" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 190, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "M" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 190, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "M" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 200, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "M" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 200, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "M" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 200, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "M" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 20, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "F" + ], + "atteso": 28.8 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 20, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "F" + ], + "atteso": 21.2 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 20, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "F" + ], + "atteso": 28.8 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 30, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "F" + ], + "atteso": 42.2 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 30, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "F" + ], + "atteso": 26.9 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 30, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "F" + ], + "atteso": 54.4 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 40, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "F" + ], + "atteso": 56.2 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 40, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "F" + ], + "atteso": 33.1 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 40, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "F" + ], + "atteso": 75.0 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 50, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "F" + ], + "atteso": 73.8 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 50, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "F" + ], + "atteso": 40.2 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 50, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "F" + ], + "atteso": 90.3 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 60, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "F" + ], + "atteso": 87.5 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 60, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "F" + ], + "atteso": 47.2 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 60, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 70, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "F" + ], + "atteso": 98.8 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 70, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "F" + ], + "atteso": 54.2 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 70, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 80, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 80, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "F" + ], + "atteso": 62.5 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 80, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 90, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 90, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "F" + ], + "atteso": 76.6 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 90, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 100, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 100, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "F" + ], + "atteso": 90.6 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 100, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 110, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 110, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 110, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 120, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 120, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 120, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 130, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 130, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 130, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 140, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 140, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 140, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 150, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 150, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 150, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 160, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 160, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 160, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 170, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 170, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 170, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 180, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 180, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 180, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 190, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 190, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 190, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 200, + 80, + 5, + [ + [ + 0.5, + 30 + ], + [ + 1.0, + 60 + ], + [ + 1.25, + 80 + ], + [ + 1.5, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.6, + 60 + ], + [ + 0.75, + 80 + ], + [ + 1.0, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 200, + 80, + 5, + [ + [ + 0.75, + 30 + ], + [ + 1.5, + 60 + ], + [ + 1.75, + 80 + ], + [ + 2.0, + 100 + ] + ], + [ + [ + 0.5, + 30 + ], + [ + 1.1, + 60 + ], + [ + 1.3, + 80 + ], + [ + 1.5, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_bw_ratio_lift", + "args": [ + 200, + 80, + 5, + [ + [ + 0.45, + 30 + ], + [ + 0.7, + 60 + ], + [ + 0.95, + 80 + ], + [ + 1.2, + 100 + ] + ], + [ + [ + 0.3, + 30 + ], + [ + 0.45, + 60 + ], + [ + 0.6, + 80 + ], + [ + 0.8, + 100 + ] + ], + "F" + ], + "atteso": 100 + }, + { + "fn": "score_flamingo", + "args": [ + 0 + ], + "atteso": 100 + }, + { + "fn": "score_flamingo", + "args": [ + 1 + ], + "atteso": 100 + }, + { + "fn": "score_flamingo", + "args": [ + 2 + ], + "atteso": 100 + }, + { + "fn": "score_flamingo", + "args": [ + 3 + ], + "atteso": 100 + }, + { + "fn": "score_flamingo", + "args": [ + 4 + ], + "atteso": 95.0 + }, + { + "fn": "score_flamingo", + "args": [ + 5 + ], + "atteso": 90.0 + }, + { + "fn": "score_flamingo", + "args": [ + 6 + ], + "atteso": 85.0 + }, + { + "fn": "score_flamingo", + "args": [ + 7 + ], + "atteso": 80 + }, + { + "fn": "score_flamingo", + "args": [ + 8 + ], + "atteso": 76.2 + }, + { + "fn": "score_flamingo", + "args": [ + 9 + ], + "atteso": 72.5 + }, + { + "fn": "score_flamingo", + "args": [ + 10 + ], + "atteso": 68.8 + }, + { + "fn": "score_flamingo", + "args": [ + 11 + ], + "atteso": 65.0 + }, + { + "fn": "score_flamingo", + "args": [ + 12 + ], + "atteso": 61.2 + }, + { + "fn": "score_flamingo", + "args": [ + 13 + ], + "atteso": 57.5 + }, + { + "fn": "score_flamingo", + "args": [ + 14 + ], + "atteso": 53.8 + }, + { + "fn": "score_flamingo", + "args": [ + 15 + ], + "atteso": 50 + }, + { + "fn": "score_flamingo", + "args": [ + 16 + ], + "atteso": 47.3 + }, + { + "fn": "score_flamingo", + "args": [ + 17 + ], + "atteso": 44.7 + }, + { + "fn": "score_flamingo", + "args": [ + 18 + ], + "atteso": 42.0 + }, + { + "fn": "score_flamingo", + "args": [ + 19 + ], + "atteso": 39.3 + }, + { + "fn": "score_flamingo", + "args": [ + 20 + ], + "atteso": 36.7 + }, + { + "fn": "score_flamingo", + "args": [ + 21 + ], + "atteso": 34.0 + }, + { + "fn": "score_flamingo", + "args": [ + 22 + ], + "atteso": 31.3 + }, + { + "fn": "score_flamingo", + "args": [ + 23 + ], + "atteso": 28.7 + }, + { + "fn": "score_flamingo", + "args": [ + 24 + ], + "atteso": 26.0 + }, + { + "fn": "score_flamingo", + "args": [ + 25 + ], + "atteso": 23.3 + }, + { + "fn": "score_flamingo", + "args": [ + 26 + ], + "atteso": 20.7 + }, + { + "fn": "score_flamingo", + "args": [ + 27 + ], + "atteso": 18.0 + }, + { + "fn": "score_flamingo", + "args": [ + 28 + ], + "atteso": 15.3 + }, + { + "fn": "score_flamingo", + "args": [ + 29 + ], + "atteso": 12.7 + }, + { + "fn": "score_flamingo", + "args": [ + 30 + ], + "atteso": 10 + }, + { + "fn": "score_shoulder_mobility_wt", + "args": [ + 0, + 0 + ], + "atteso": 0 + }, + { + "fn": "score_shoulder_mobility_wt", + "args": [ + 10, + 10 + ], + "atteso": 5.6 + }, + { + "fn": "score_shoulder_mobility_wt", + "args": [ + 20, + 20 + ], + "atteso": 11.1 + }, + { + "fn": "score_shoulder_mobility_wt", + "args": [ + 30, + 30 + ], + "atteso": 16.7 + }, + { + "fn": "score_shoulder_mobility_wt", + "args": [ + 40, + 40 + ], + "atteso": 22.2 + }, + { + "fn": "score_shoulder_mobility_wt", + "args": [ + 50, + 50 + ], + "atteso": 27.8 + }, + { + "fn": "score_shoulder_mobility_wt", + "args": [ + 60, + 60 + ], + "atteso": 33.3 + }, + { + "fn": "score_shoulder_mobility_wt", + "args": [ + 70, + 70 + ], + "atteso": 38.9 + }, + { + "fn": "score_shoulder_mobility_wt", + "args": [ + 80, + 80 + ], + "atteso": 44.4 + }, + { + "fn": "score_shoulder_mobility_wt", + "args": [ + 90, + 90 + ], + "atteso": 50.0 + }, + { + "fn": "score_shoulder_mobility_wt", + "args": [ + 100, + 100 + ], + "atteso": 55.6 + }, + { + "fn": "score_shoulder_mobility_wt", + "args": [ + 110, + 110 + ], + "atteso": 61.1 + }, + { + "fn": "score_shoulder_mobility_wt", + "args": [ + 120, + 120 + ], + "atteso": 66.7 + }, + { + "fn": "score_shoulder_mobility_wt", + "args": [ + 130, + 130 + ], + "atteso": 72.2 + }, + { + "fn": "score_shoulder_mobility_wt", + "args": [ + 140, + 140 + ], + "atteso": 77.8 + }, + { + "fn": "score_shoulder_mobility_wt", + "args": [ + 150, + 150 + ], + "atteso": 83.3 + }, + { + "fn": "score_shoulder_mobility_wt", + "args": [ + 160, + 160 + ], + "atteso": 88.9 + }, + { + "fn": "score_shoulder_mobility_wt", + "args": [ + 170, + 170 + ], + "atteso": 94.4 + }, + { + "fn": "score_shoulder_mobility_wt", + "args": [ + 180, + 180 + ], + "atteso": 100 + } + ] +} \ No newline at end of file From 998487ed622be6de7023060ac81acfcdbe322bcc Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 22 Aug 2026 08:38:54 +0200 Subject: [PATCH 24/62] piano: infittita la griglia dove non aveva capacita diagnostica --- docs/plans/2026-08-22-longevity-motore.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/plans/2026-08-22-longevity-motore.md b/docs/plans/2026-08-22-longevity-motore.md index ef39038..c77577c 100644 --- a/docs/plans/2026-08-22-longevity-motore.md +++ b/docs/plans/2026-08-22-longevity-motore.md @@ -116,6 +116,25 @@ for v in griglia(0, 7, 0.5): prova("score_increasing_plateau", v, 0, 4) # --- composizione corporea: attorno ai confini delle bande --- +# ⚠️ `worst` va variato: con `worst` sempre a 0 un errore sull'offset resta invisibile +for v in griglia(0, 7, 0.5): + prova("score_increasing_plateau", v, 2, 6) + +# ⚠️ formula a cinque coefficienti: un parametro alla volta, per sapere QUALE diverge +for t in (10, 15.5, 20, 25): + prova("vo2max_from_2km_walk", t, 130, 40, 24) +for hr in (100, 115, 130, 145, 160): + prova("vo2max_from_2km_walk", 15.5, hr, 40, 24) +for eta in (20, 40, 60, 75): + prova("vo2max_from_2km_walk", 15.5, 130, eta, 24) +for bmi in (18, 22, 26, 32): + prova("vo2max_from_2km_walk", 15.5, 130, 40, bmi) + +# i confini di banda esatti del plank: la curva è continua, ma costano nulla +for sesso, confini in (("M", (79, 97, 122, 157, 201)), ("F", (35, 63, 84, 108, 142))): + for v in confini: + prova("score_plank", v, sesso) + for sesso in ("M", "F"): for v in griglia(0, 45, 0.5): prova("score_fat_percent", v, sesso) From c5249255f06913738a7b6ec5eaaaabc5b64bbf7c Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 22 Aug 2026 08:39:06 +0200 Subject: [PATCH 25/62] piano: rimette i commenti della griglia al posto giusto --- docs/plans/2026-08-22-longevity-motore.md | 3 ++- .../longevity/riferimento/genera-riferimento.py | 16 +++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-08-22-longevity-motore.md b/docs/plans/2026-08-22-longevity-motore.md index c77577c..1a76d14 100644 --- a/docs/plans/2026-08-22-longevity-motore.md +++ b/docs/plans/2026-08-22-longevity-motore.md @@ -115,7 +115,7 @@ for v in griglia(0, 10, 0.5): for v in griglia(0, 7, 0.5): prova("score_increasing_plateau", v, 0, 4) -# --- composizione corporea: attorno ai confini delle bande --- +# --- le tre aggiunte che chiudono i buchi di copertura --- # ⚠️ `worst` va variato: con `worst` sempre a 0 un errore sull'offset resta invisibile for v in griglia(0, 7, 0.5): prova("score_increasing_plateau", v, 2, 6) @@ -135,6 +135,7 @@ for sesso, confini in (("M", (79, 97, 122, 157, 201)), ("F", (35, 63, 84, 108, 1 for v in confini: prova("score_plank", v, sesso) +# --- 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) diff --git a/tests/longevity/riferimento/genera-riferimento.py b/tests/longevity/riferimento/genera-riferimento.py index 9df854d..647f815 100644 --- a/tests/longevity/riferimento/genera-riferimento.py +++ b/tests/longevity/riferimento/genera-riferimento.py @@ -42,6 +42,7 @@ 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"): @@ -71,7 +72,17 @@ 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) -prova("vo2max_from_2km_walk", 15.5, 130, 40, 24) +# 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"): @@ -82,6 +93,9 @@ for sesso in ("M", "F"): 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): From 236cdce0761666da3bd8065fb52158130602b52f Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 22 Aug 2026 08:42:29 +0200 Subject: [PATCH 26/62] longevity: rigenera riferimento.json con la griglia corretta (plateau offset, 2km walk multi-punto, confini plank) --- tests/longevity/riferimento/riferimento.json | 555 +++++++++++++++++++ 1 file changed, 555 insertions(+) diff --git a/tests/longevity/riferimento/riferimento.json b/tests/longevity/riferimento/riferimento.json index dcad11a..6838996 100644 --- a/tests/longevity/riferimento/riferimento.json +++ b/tests/longevity/riferimento/riferimento.json @@ -998,6 +998,15 @@ ], "atteso": 20 }, + { + "fn": "score_increasing_plateau", + "args": [ + 0, + 2, + 6 + ], + "atteso": 20 + }, { "fn": "score_increasing_plateau", "args": [ @@ -1007,6 +1016,15 @@ ], "atteso": 30.0 }, + { + "fn": "score_increasing_plateau", + "args": [ + 0.5, + 2, + 6 + ], + "atteso": 20 + }, { "fn": "score_increasing_plateau", "args": [ @@ -1016,6 +1034,15 @@ ], "atteso": 40.0 }, + { + "fn": "score_increasing_plateau", + "args": [ + 1.0, + 2, + 6 + ], + "atteso": 20 + }, { "fn": "score_increasing_plateau", "args": [ @@ -1025,6 +1052,15 @@ ], "atteso": 50.0 }, + { + "fn": "score_increasing_plateau", + "args": [ + 1.5, + 2, + 6 + ], + "atteso": 20 + }, { "fn": "score_increasing_plateau", "args": [ @@ -1034,6 +1070,15 @@ ], "atteso": 60.0 }, + { + "fn": "score_increasing_plateau", + "args": [ + 2.0, + 2, + 6 + ], + "atteso": 20 + }, { "fn": "score_increasing_plateau", "args": [ @@ -1043,6 +1088,15 @@ ], "atteso": 70.0 }, + { + "fn": "score_increasing_plateau", + "args": [ + 2.5, + 2, + 6 + ], + "atteso": 30.0 + }, { "fn": "score_increasing_plateau", "args": [ @@ -1052,6 +1106,15 @@ ], "atteso": 80.0 }, + { + "fn": "score_increasing_plateau", + "args": [ + 3.0, + 2, + 6 + ], + "atteso": 40.0 + }, { "fn": "score_increasing_plateau", "args": [ @@ -1061,6 +1124,15 @@ ], "atteso": 90.0 }, + { + "fn": "score_increasing_plateau", + "args": [ + 3.5, + 2, + 6 + ], + "atteso": 50.0 + }, { "fn": "score_increasing_plateau", "args": [ @@ -1070,6 +1142,15 @@ ], "atteso": 100 }, + { + "fn": "score_increasing_plateau", + "args": [ + 4.0, + 2, + 6 + ], + "atteso": 60.0 + }, { "fn": "score_increasing_plateau", "args": [ @@ -1079,6 +1160,15 @@ ], "atteso": 100 }, + { + "fn": "score_increasing_plateau", + "args": [ + 4.5, + 2, + 6 + ], + "atteso": 70.0 + }, { "fn": "score_increasing_plateau", "args": [ @@ -1088,6 +1178,15 @@ ], "atteso": 100 }, + { + "fn": "score_increasing_plateau", + "args": [ + 5.0, + 2, + 6 + ], + "atteso": 80.0 + }, { "fn": "score_increasing_plateau", "args": [ @@ -1097,6 +1196,15 @@ ], "atteso": 100 }, + { + "fn": "score_increasing_plateau", + "args": [ + 5.5, + 2, + 6 + ], + "atteso": 90.0 + }, { "fn": "score_increasing_plateau", "args": [ @@ -1106,6 +1214,15 @@ ], "atteso": 100 }, + { + "fn": "score_increasing_plateau", + "args": [ + 6.0, + 2, + 6 + ], + "atteso": 100 + }, { "fn": "score_increasing_plateau", "args": [ @@ -1115,6 +1232,15 @@ ], "atteso": 100 }, + { + "fn": "score_increasing_plateau", + "args": [ + 6.5, + 2, + 6 + ], + "atteso": 100 + }, { "fn": "score_increasing_plateau", "args": [ @@ -1124,6 +1250,15 @@ ], "atteso": 100 }, + { + "fn": "score_increasing_plateau", + "args": [ + 7.0, + 2, + 6 + ], + "atteso": 100 + }, { "fn": "score_fat_percent", "args": [ @@ -13856,6 +13991,346 @@ ], "atteso": 40.75000000000001 }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 10, + 130, + 40, + 24 + ], + "atteso": 57.140000000000015 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 12.5, + 130, + 40, + 24 + ], + "atteso": 49.690000000000005 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 15.0, + 130, + 40, + 24 + ], + "atteso": 42.24 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 17.5, + 130, + 40, + 24 + ], + "atteso": 34.79000000000001 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 20.0, + 130, + 40, + 24 + ], + "atteso": 27.339999999999996 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 22.5, + 130, + 40, + 24 + ], + "atteso": 19.890000000000008 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 25.0, + 130, + 40, + 24 + ], + "atteso": 12.440000000000001 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 15.5, + 100, + 40, + 24 + ], + "atteso": 44.050000000000004 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 15.5, + 110, + 40, + 24 + ], + "atteso": 42.95 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 15.5, + 120, + 40, + 24 + ], + "atteso": 41.85 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 15.5, + 130, + 40, + 24 + ], + "atteso": 40.75000000000001 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 15.5, + 140, + 40, + 24 + ], + "atteso": 39.650000000000006 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 15.5, + 150, + 40, + 24 + ], + "atteso": 38.550000000000004 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 15.5, + 160, + 40, + 24 + ], + "atteso": 37.45 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 15.5, + 130, + 20, + 24 + ], + "atteso": 43.55000000000001 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 15.5, + 130, + 25, + 24 + ], + "atteso": 42.85000000000001 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 15.5, + 130, + 30, + 24 + ], + "atteso": 42.150000000000006 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 15.5, + 130, + 35, + 24 + ], + "atteso": 41.45000000000001 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 15.5, + 130, + 40, + 24 + ], + "atteso": 40.75000000000001 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 15.5, + 130, + 45, + 24 + ], + "atteso": 40.05000000000001 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 15.5, + 130, + 50, + 24 + ], + "atteso": 39.35000000000001 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 15.5, + 130, + 55, + 24 + ], + "atteso": 38.650000000000006 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 15.5, + 130, + 60, + 24 + ], + "atteso": 37.95000000000001 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 15.5, + 130, + 65, + 24 + ], + "atteso": 37.25000000000001 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 15.5, + 130, + 70, + 24 + ], + "atteso": 36.55000000000001 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 15.5, + 130, + 75, + 24 + ], + "atteso": 35.85000000000001 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 15.5, + 130, + 40, + 18 + ], + "atteso": 43.09 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 15.5, + 130, + 40, + 20 + ], + "atteso": 42.31 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 15.5, + 130, + 40, + 22 + ], + "atteso": 41.53000000000001 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 15.5, + 130, + 40, + 24 + ], + "atteso": 40.75000000000001 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 15.5, + 130, + 40, + 26 + ], + "atteso": 39.970000000000006 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 15.5, + 130, + 40, + 28 + ], + "atteso": 39.190000000000005 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 15.5, + 130, + 40, + 30 + ], + "atteso": 38.410000000000004 + }, + { + "fn": "vo2max_from_2km_walk", + "args": [ + 15.5, + 130, + 40, + 32 + ], + "atteso": 37.63000000000001 + }, { "fn": "score_handgrip", "args": [ @@ -19808,6 +20283,46 @@ ], "atteso": 100 }, + { + "fn": "score_plank", + "args": [ + 79, + "M" + ], + "atteso": 20 + }, + { + "fn": "score_plank", + "args": [ + 97, + "M" + ], + "atteso": 40 + }, + { + "fn": "score_plank", + "args": [ + 122, + "M" + ], + "atteso": 55 + }, + { + "fn": "score_plank", + "args": [ + 157, + "M" + ], + "atteso": 75 + }, + { + "fn": "score_plank", + "args": [ + 201, + "M" + ], + "atteso": 90 + }, { "fn": "score_flexed_arm_hang", "args": [ @@ -29301,6 +29816,46 @@ ], "atteso": 100 }, + { + "fn": "score_plank", + "args": [ + 35, + "F" + ], + "atteso": 20 + }, + { + "fn": "score_plank", + "args": [ + 63, + "F" + ], + "atteso": 40 + }, + { + "fn": "score_plank", + "args": [ + 84, + "F" + ], + "atteso": 55 + }, + { + "fn": "score_plank", + "args": [ + 108, + "F" + ], + "atteso": 75 + }, + { + "fn": "score_plank", + "args": [ + 142, + "F" + ], + "atteso": 90 + }, { "fn": "score_flexed_arm_hang", "args": [ From 11ecc8305b3d46c72fd2c5b9a7b863d1c120faa8 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 22 Aug 2026 08:48:26 +0200 Subject: [PATCH 27/62] piano: il codice sta nel repo, non anche nel piano --- .gitignore | 3 + docs/plans/2026-08-22-longevity-motore.md | 150 +++------------------- 2 files changed, 21 insertions(+), 132 deletions(-) diff --git a/.gitignore b/.gitignore index 6692393..895b74e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ assets-src/ # Artefatti di lavoro dei subagenti (ledger, brief, review package) .superpowers/ + +# Cache di Python generata rieseguendo l'oracolo +__pycache__/ diff --git a/docs/plans/2026-08-22-longevity-motore.md b/docs/plans/2026-08-22-longevity-motore.md index 1a76d14..657e3fe 100644 --- a/docs/plans/2026-08-22-longevity-motore.md +++ b/docs/plans/2026-08-22-longevity-motore.md @@ -69,141 +69,27 @@ Prima di portare una sola curva, serve il metro di paragone. - [ ] **Step 1: Scrivi il generatore** -```python -#!/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. +⚠️ **Questo passo è già stato eseguito.** La griglia definitiva vive in +`tests/longevity/riferimento/genera-riferimento.py`, committata e verificata: rieseguirla +riproduce `riferimento.json` byte per byte (4018 casi, 25 funzioni, nessuna eccezione). -Uso: python3 tests/longevity/riferimento/genera-riferimento.py -""" -import json -import pathlib -import sys +Qui **non** ne teniamo una seconda copia, e la ragione è un incidente vero: quando il piano +conteneva anche il codice, piano e script sono divergiti — il file di riferimento è rimasto +per un momento non riproducibile dal proprio generatore, che per un oracolo è il difetto +peggiore possibile. Due copie della stessa cosa si separano sempre; il codice sta nel repo, +il piano dice cosa deve fare e perché. -QUI = pathlib.Path(__file__).parent -sys.path.insert(0, str(QUI)) +**Cosa la griglia copre**, che è ciò che va verificato se un domani si tocca: -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) - -# --- le tre aggiunte che chiudono i buchi di copertura --- -# ⚠️ `worst` va variato: con `worst` sempre a 0 un errore sull'offset resta invisibile -for v in griglia(0, 7, 0.5): - prova("score_increasing_plateau", v, 2, 6) - -# ⚠️ formula a cinque coefficienti: un parametro alla volta, per sapere QUALE diverge -for t in (10, 15.5, 20, 25): - prova("vo2max_from_2km_walk", t, 130, 40, 24) -for hr in (100, 115, 130, 145, 160): - prova("vo2max_from_2km_walk", 15.5, hr, 40, 24) -for eta in (20, 40, 60, 75): - prova("vo2max_from_2km_walk", 15.5, 130, eta, 24) -for bmi in (18, 22, 26, 32): - prova("vo2max_from_2km_walk", 15.5, 130, 40, bmi) - -# i confini di banda esatti del plank: la curva è continua, ma costano nulla -for sesso, confini in (("M", (79, 97, 122, 157, 201)), ("F", (35, 63, 84, 108, 142))): - for v in confini: - prova("score_plank", v, sesso) - -# --- 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) -prova("vo2max_from_2km_walk", 15.5, 130, 40, 24) - -# --- 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) - 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}") -``` +- le curve del questionario su **tutto** il dominio, non su un campione, comprese le due + coppie di parametri di `score_decreasing` — quella normale e quella dell'alcol (7→14); +- `score_increasing_plateau` con `worst` **diverso da zero**: zero è il caso degenere in + cui un errore sull'offset resterebbe invisibile; +- `vo2max_from_2km_walk` variando **un parametro alla volta** dal caso base, così una + divergenza dice anche quale dei cinque coefficienti è sbagliato; +- i confini di banda **esatti** delle funzioni a bande (composizione, pressione, plank, + flamingo, SpO2), che una griglia a passo regolare salterebbe; +- le tre tabelle dei sollevamenti sul rapporto col peso corporeo, per entrambi i sessi. - [ ] **Step 2: Eseguilo e guarda l'esito** From 36a2cf3fb6b909556ef31244fb6fcca29dea4dd3 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 22 Aug 2026 08:51:27 +0200 Subject: [PATCH 28/62] longevity: le cinque curve del questionario, verificate contro l oracolo --- src/lib/longevity/motore/curve.ts | 114 +++++++++++++++++++++++++++ tests/longevity/motore-curve.test.ts | 79 +++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 src/lib/longevity/motore/curve.ts create mode 100644 tests/longevity/motore-curve.test.ts diff --git a/src/lib/longevity/motore/curve.ts b/src/lib/longevity/motore/curve.ts new file mode 100644 index 0000000..987bc8c --- /dev/null +++ b/src/lib/longevity/motore/curve.ts @@ -0,0 +1,114 @@ +/** + * Curve di normalizzazione del questionario Longevity: portano una risposta + * grezza a un punteggio 0-100. + * + * `clamp`, `lerp`, `curvaCampana`, `curvaDecrescente`, `curvaCrescenteConPlateau` + * e `curvaDirettaX10` sono il porting letterale di `clamp`, `_lerp`, + * `score_bell_curve`, `score_decreasing`, `score_increasing_plateau` e + * `score_direct_x10` dell'oracolo del cliente + * (`tests/longevity/riferimento/isl_scoring_engine.py`, sezione «Questionario: + * curve generiche»). Non toccare quella logica: è verificata riga per riga + * contro `riferimento.json`. + * + * `curvaDirettaX10Invertita` e `curvaGradini` NON sono nell'oracolo Python: + * vengono dal prototipo HTML del cliente (`scale10_inv`, `decstep`/`incstep`) + * e non hanno un oracolo di riferimento — sono coperte solo dai test scritti + * a mano in `tests/longevity/motore-curve.test.ts`. + */ + +/** Limita x all'intervallo [lo, hi]. Come `clamp` nell'oracolo. */ +export function clamp(x: number, lo = 0, hi = 100): number { + return Math.max(lo, Math.min(hi, x)); +} + +/** + * Interpolazione lineare fra (x0, y0) e (x1, y1). Il fattore t è limitato a + * [0, 1]: la curva non esce mai oltre y0/y1, anche se x è fuori da [x0, x1]. + * Come `_lerp` nell'oracolo. + */ +export function lerp(x: number, x0: number, x1: number, y0: number, y1: number): number { + if (x1 === x0) return y0; + let t = (x - x0) / (x1 - x0); + t = Math.max(0, Math.min(1, t)); + return y0 + t * (y1 - y0); +} + +/** Arrotonda a una cifra decimale, come `round(x, 1)` in Python. */ +function arrotonda1(x: number): number { + return Math.round(x * 10) / 10; +} + +/** + * Curva a campana: punteggio massimo (100) fra peakLow e peakHigh, decresce + * verso low/high ai lati. Come `score_bell_curve` nell'oracolo. + */ +export function curvaCampana(v: number, low: number, peakLow: number, peakHigh: number, high: number): number { + if (v >= peakLow && v <= peakHigh) return 100; + if (v < peakLow) return clamp(arrotonda1(lerp(v, low, peakLow, 10, 100))); + return clamp(arrotonda1(lerp(v, peakHigh, high, 100, 10))); +} + +/** + * Curva decrescente: 100 a `best`, 0 a `worst`. Come `score_decreasing` + * nell'oracolo — con `lerp` che limita t a [0,1], non scende sotto 0 né sale + * sopra 100 fuori da [best, worst]. + */ +export function curvaDecrescente(v: number, best: number, worst: number): number { + return clamp(arrotonda1(lerp(v, best, worst, 100, 0))); +} + +/** + * Curva crescente con plateau: 20 a `worst`, 100 da `plateauStart` in poi. + * Come `score_increasing_plateau` nell'oracolo. + */ +export function curvaCrescenteConPlateau(v: number, worst: number, plateauStart: number): number { + return clamp(arrotonda1(lerp(v, worst, plateauStart, 20, 100))); +} + +/** Punteggio diretto: valore 0-10 moltiplicato per 10. Come `score_direct_x10` nell'oracolo. */ +export function curvaDirettaX10(v: number): number { + return clamp(arrotonda1(v * 10)); +} + +/** + * Complemento della diretta: valore 0-10, punteggio decrescente. Assente + * nell'oracolo Python (è la quinta curva, quella che al motore del cliente + * manca): serve a q_calo_pomeridiano, e nel prototipo HTML è `scale10_inv`, + * cioè (10 - v) * 10. + */ +export function curvaDirettaX10Invertita(v: number): number { + return clamp(arrotonda1((10 - v) * 10)); +} + +/** + * Curva a gradini: interpola fra coppie [valore, punteggio] ordinate per + * valore crescente, esattamente come `decstep`/`incstep` nel prototipo HTML + * del cliente (assente nell'oracolo Python). + * + * Se v è zero e zeroVal è definito, vince zeroVal. Altrimenti si interpola a + * tratti fra i gradini, partendo da un punto d'ancoraggio (0, 100) per le + * curve decrescenti o (0, 20) per le crescenti, e restando sull'ultimo + * gradino oltre la sua ascissa (tipicamente un valore-soglia molto alto che + * funge da "infinito"). + */ +export function curvaGradini( + v: number, + steps: [number, number][], + zeroVal: number | undefined, + decrescente: boolean +): number { + if (v === 0 && zeroVal !== undefined) return zeroVal; + + const ancoraggio: [number, number] = [0, decrescente ? 100 : 20]; + const punti: [number, number][] = [ancoraggio, ...steps]; + + for (let i = 0; i < punti.length - 1; i++) { + const [x0, y0] = punti[i]; + const [x1, y1] = punti[i + 1]; + if (v <= x1) { + return clamp(arrotonda1(lerp(v, x0, x1, y0, y1))); + } + } + // v oltre l'ultimo gradino: resta sull'ultimo valore. + return punti[punti.length - 1][1]; +} diff --git a/tests/longevity/motore-curve.test.ts b/tests/longevity/motore-curve.test.ts new file mode 100644 index 0000000..1395c16 --- /dev/null +++ b/tests/longevity/motore-curve.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + clamp, lerp, curvaCampana, curvaDecrescente, curvaCrescenteConPlateau, + curvaDirettaX10, curvaDirettaX10Invertita, curvaGradini, +} from '../../src/lib/longevity/motore/curve'; + +const RIF = JSON.parse( + readFileSync(join(process.cwd(), 'tests/longevity/riferimento/riferimento.json'), 'utf8') +) as { casi: { fn: string; args: unknown[]; atteso: number }[] }; + +const casiDi = (fn: string) => RIF.casi.filter((c) => c.fn === fn); + +describe('curve del questionario, confrontate con l oracolo', () => { + it('la campana combacia su tutto il dominio', () => { + const casi = casiDi('score_bell_curve'); + expect(casi.length).toBeGreaterThan(20); + for (const c of casi) { + const [v, low, pl, ph, high] = c.args as number[]; + expect(curvaCampana(v, low, pl, ph, high)).toBeCloseTo(c.atteso, 1); + } + }); + + it('la decrescente combacia, inclusa la soglia 7-14 dell alcol', () => { + const casi = casiDi('score_decreasing'); + expect(casi.length).toBeGreaterThan(40); + for (const c of casi) { + const [v, best, worst] = c.args as number[]; + expect(curvaDecrescente(v, best, worst)).toBeCloseTo(c.atteso, 1); + } + }); + + it('la crescente con plateau combacia', () => { + for (const c of casiDi('score_increasing_plateau')) { + const [v, worst, plateau] = c.args as number[]; + expect(curvaCrescenteConPlateau(v, worst, plateau)).toBeCloseTo(c.atteso, 1); + } + }); + + it('la diretta per dieci combacia', () => { + for (const c of casiDi('score_direct_x10')) { + expect(curvaDirettaX10((c.args as number[])[0])).toBeCloseTo(c.atteso, 1); + } + }); + + // Questa curva NON esiste nell'oracolo: è la quinta, che al motore del cliente manca. + // Serve a q_calo_pomeridiano e nel prototipo HTML è `scale10_inv`: (10 - v) * 10. + it('la diretta invertita e il complemento della diretta', () => { + for (const v of [0, 2.5, 5, 7.5, 10]) { + expect(curvaDirettaX10Invertita(v)).toBeCloseTo(curvaDirettaX10(10 - v), 6); + } + expect(curvaDirettaX10Invertita(0)).toBe(100); + expect(curvaDirettaX10Invertita(10)).toBe(0); + }); + + it('i gradini decrescenti riproducono il prototipo: q_sigarette', () => { + const steps: [number, number][] = [[0, 100], [5, 60], [10, 40], [20, 20], [999, 0]]; + expect(curvaGradini(0, steps, undefined, true)).toBe(100); + expect(curvaGradini(5, steps, undefined, true)).toBe(60); + expect(curvaGradini(10, steps, undefined, true)).toBe(40); + expect(curvaGradini(20, steps, undefined, true)).toBe(20); + expect(curvaGradini(40, steps, undefined, true)).toBeLessThan(20); + }); + + it('zeroVal ha la precedenza sui gradini: q_schermi a zero vale 100', () => { + const steps: [number, number][] = [[15, 85], [30, 70], [60, 40], [999, 10]]; + expect(curvaGradini(0, steps, 100, true)).toBe(100); + expect(curvaGradini(15, steps, 100, true)).toBe(85); + }); + + it('clamp e lerp si comportano come nell oracolo', () => { + expect(clamp(150)).toBe(100); + expect(clamp(-5)).toBe(0); + expect(lerp(5, 0, 10, 0, 100)).toBe(50); + expect(lerp(-1, 0, 10, 0, 100)).toBe(0); // t viene limitato a [0,1] + expect(lerp(11, 0, 10, 0, 100)).toBe(100); + }); +}); From c63bb1fe807c5ec9a09d188675f7d9e1d5408b56 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 22 Aug 2026 09:02:20 +0200 Subject: [PATCH 29/62] =?UTF-8?q?longevity:=20chiude=20i=20tre=20buchi=20n?= =?UTF-8?q?ei=20test=20delle=20curve=20=E2=80=94=20ramo=20crescente=20di?= =?UTF-8?q?=20curvaGradini=20mai=20esercitato,=20test=20zeroVal=20non=20di?= =?UTF-8?q?scriminante,=20cardinalita=20mancante=20su=20due=20confronti=20?= =?UTF-8?q?con=20l=20oracolo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/longevity/motore-curve.test.ts | 59 +++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/tests/longevity/motore-curve.test.ts b/tests/longevity/motore-curve.test.ts index 1395c16..32ef3d1 100644 --- a/tests/longevity/motore-curve.test.ts +++ b/tests/longevity/motore-curve.test.ts @@ -32,14 +32,18 @@ describe('curve del questionario, confrontate con l oracolo', () => { }); it('la crescente con plateau combacia', () => { - for (const c of casiDi('score_increasing_plateau')) { + const casi = casiDi('score_increasing_plateau'); + expect(casi.length).toBeGreaterThan(20); + for (const c of casi) { const [v, worst, plateau] = c.args as number[]; expect(curvaCrescenteConPlateau(v, worst, plateau)).toBeCloseTo(c.atteso, 1); } }); it('la diretta per dieci combacia', () => { - for (const c of casiDi('score_direct_x10')) { + const casi = casiDi('score_direct_x10'); + expect(casi.length).toBeGreaterThan(10); + for (const c of casi) { expect(curvaDirettaX10((c.args as number[])[0])).toBeCloseTo(c.atteso, 1); } }); @@ -63,10 +67,53 @@ describe('curve del questionario, confrontate con l oracolo', () => { expect(curvaGradini(40, steps, undefined, true)).toBeLessThan(20); }); - it('zeroVal ha la precedenza sui gradini: q_schermi a zero vale 100', () => { - const steps: [number, number][] = [[15, 85], [30, 70], [60, 40], [999, 10]]; - expect(curvaGradini(0, steps, 100, true)).toBe(100); - expect(curvaGradini(15, steps, 100, true)).toBe(85); + // Il ramo crescente non era esercitato da nessun test: l'ancoraggio (0, 20) + // — invece di (0, 100) — poteva essere invertito o sbagliato senza che + // niente diventasse rosso. Parametri veri da src/lib/longevity/registro.ts. + it('i gradini crescenti riproducono il prototipo: q_attivita e q_luce', () => { + // q_attivita: nessun zeroVal, i gradini partono già da (0, 20). + const stepsAttivita: [number, number][] = [[0, 20], [2, 50], [4, 80], [999, 100]]; + expect(curvaGradini(0, stepsAttivita, undefined, false)).toBe(20); + expect(curvaGradini(2, stepsAttivita, undefined, false)).toBe(50); + expect(curvaGradini(4, stepsAttivita, undefined, false)).toBe(80); + expect(curvaGradini(999, stepsAttivita, undefined, false)).toBe(100); + expect(curvaGradini(1, stepsAttivita, undefined, false)).toBe(35); // interpolato fra (0,20) e (2,50) + expect(curvaGradini(3, stepsAttivita, undefined, false)).toBe(65); // interpolato fra (2,50) e (4,80) + + // q_luce: zeroVal 20, gradini che partono da 0.5 — l'ancoraggio (0, 20) + // serve davvero per interpolare fra 0 (escluso, coperto da zeroVal) e 0.5. + const stepsLuce: [number, number][] = [[0.5, 60], [1, 90], [999, 100]]; + expect(curvaGradini(0.5, stepsLuce, 20, false)).toBe(60); + expect(curvaGradini(1, stepsLuce, 20, false)).toBe(90); + expect(curvaGradini(999, stepsLuce, 20, false)).toBe(100); + expect(curvaGradini(0.75, stepsLuce, 20, false)).toBe(75); // interpolato fra (0.5,60) e (1,90) + expect(curvaGradini(0.25, stepsLuce, 20, false)).toBe(40); // interpolato fra l'ancoraggio (0,20) e (0.5,60) + }); + + // Non basta controllare che a zero il risultato sia il valore atteso: se + // zeroVal COINCIDE con l'ancoraggio di default (100 per il decrescente, + // 20 per il crescente, come in q_schermi e q_luce) il test passa anche + // cancellando dal codice la riga che gestisce lo zero. Qui zeroVal è + // deliberatamente diverso dall'ancoraggio, in entrambe le direzioni: se + // la riga sparisse, l'interpolazione a v=0 darebbe l'ancoraggio (100 o 20), + // non questi valori, e il test diventerebbe rosso. + it('zeroVal vince sui gradini anche quando NON coincide con l ancoraggio', () => { + // q_schermi, il caso reale: qui zeroVal (100) coincide con l'ancoraggio + // decrescente (100), quindi da solo NON è discriminante — resta utile + // come controllo del valore vero, non come prova dello zero speciale. + const stepsSchermi: [number, number][] = [[15, 85], [30, 70], [60, 40], [999, 10]]; + expect(curvaGradini(0, stepsSchermi, 100, true)).toBe(100); + expect(curvaGradini(15, stepsSchermi, 100, true)).toBe(85); + + // Discriminante, decrescente: ancoraggio di default sarebbe 100, + // zeroVal è 42. Senza la riga dello zero speciale risulterebbe 100. + const stepsDecrescenteDiscriminante: [number, number][] = [[10, 80], [30, 50], [999, 10]]; + expect(curvaGradini(0, stepsDecrescenteDiscriminante, 42, true)).toBe(42); + + // Discriminante, crescente: ancoraggio di default sarebbe 20, + // zeroVal è 55. Senza la riga dello zero speciale risulterebbe 20. + const stepsCrescenteDiscriminante: [number, number][] = [[5, 40], [20, 70], [999, 100]]; + expect(curvaGradini(0, stepsCrescenteDiscriminante, 55, false)).toBe(55); }); it('clamp e lerp si comportano come nell oracolo', () => { From 6f926e319703fa912115d9f3c1afa05bf5f1287b Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 22 Aug 2026 09:09:23 +0200 Subject: [PATCH 30/62] 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(); + }); +}); From 4d76030839f88b0ea19d8dc5a0f97bc801fb79c7 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 22 Aug 2026 09:16:31 +0200 Subject: [PATCH 31/62] longevity: unico arrotondamento al pari nel motore, esatto sul valore binario Sposta arrotonda1 (round-half-to-even come Python) da test-fisici.ts a curve.ts, esportata, cosi' entrambi i moduli condividono la stessa funzione invece di due copie che sui pareggi potrebbero divergere. Nel farlo, la versione precedente (moltiplica per 10, tolleranza 1e-9 sul pareggio) si e' rivelata sbagliata su un caso reale: 86.35 non e' un pareggio nel double che lo rappresenta (vale 86.34999999999999431..., Python arrotonda a 86.3 senza ambiguita'), ma 86.35*10 arrotonda ESATTAMENTE a 863.5 in virgola mobile, un pareggio fasullo creato dalla moltiplicazione. La versione naive dava 86.4. Sostituita con un confronto esatto: scompone il double in mantissa/esponente (bit IEEE 754) e confronta con aritmetica razionale su BigInt, senza mai passare per una moltiplicazione che possa introdurre o cancellare un pareggio. Aggiunti in motore-curve.test.ts i casi che dimostrano il pareggio vero (86.25 -> 86.2, 86.75 -> 86.8) e quello fasullo (86.35 -> 86.3, non 86.4: blocca la regressione appena descritta). --- src/lib/longevity/motore/curve.ts | 66 +++++++++++++++++++++++-- src/lib/longevity/motore/test-fisici.ts | 19 +------ tests/longevity/motore-curve.test.ts | 25 +++++++++- 3 files changed, 88 insertions(+), 22 deletions(-) diff --git a/src/lib/longevity/motore/curve.ts b/src/lib/longevity/motore/curve.ts index 987bc8c..0920615 100644 --- a/src/lib/longevity/motore/curve.ts +++ b/src/lib/longevity/motore/curve.ts @@ -33,9 +33,69 @@ export function lerp(x: number, x0: number, x1: number, y0: number, y1: number): return y0 + t * (y1 - y0); } -/** Arrotonda a una cifra decimale, come `round(x, 1)` in Python. */ -function arrotonda1(x: number): number { - return Math.round(x * 10) / 10; +/** + * Arrotonda a una cifra decimale, come `round(x, 1)` in Python: sui pareggi + * ESATTI (il valore binario del double è esattamente a metà, es. 86.25) + * arrotonda al pari (banker's rounding), non sempre in su come farebbe + * `Math.round`. Alcune curve a gradini producono pareggi veri — `lerp` che + * restituisce 86.25 esatto va a 86.2, non 86.3. + * + * Non basta moltiplicare per 10 e confrontare con una tolleranza: un valore + * come 86.35 NON è un pareggio vero (il double che gli sta dietro è + * 86.34999999999999431..., quindi Python arrotonda a 86.3, non a metà), ma + * `86.35 * 10` in virgola mobile arrotonda esattamente a 863.5 — un pareggio + * fasullo creato dalla moltiplicazione, non presente nel valore originale. + * Una prima versione con tolleranza ci cadeva (dava 86.4). Per evitarlo si + * scompone il double nella sua mantissa ed esponente esatti (bit IEEE 754, + * via `DataView`) e si confrontano `ax*10` e il pareggio con aritmetica + * razionale su `BigInt`, senza mai passare per una moltiplicazione in + * virgola mobile che potrebbe introdurre o cancellare un pareggio. + * + * Unica funzione di arrotondamento del motore: la usano sia `curve.ts` sia + * `test-fisici.ts`, per non avere due implementazioni che sui pareggi + * potrebbero divergere fra loro. + */ +export function arrotonda1(x: number): number { + if (!Number.isFinite(x) || x === 0) return x === 0 ? 0 : x; + const negativo = x < 0; + const ax = Math.abs(x); + + // Scompone il double nella sua rappresentazione esatta ax = mantissa * 2^exponente. + const view = new DataView(new ArrayBuffer(8)); + view.setFloat64(0, ax); + const hi = view.getUint32(0); + const lo = view.getUint32(4); + const expBits = (hi >>> 20) & 0x7ff; + let mantissa = (BigInt(hi & 0xfffff) << 32n) | BigInt(lo); + let exponent: number; + if (expBits === 0) { + exponent = -1074; // subnormale, non atteso su questi dati ma corretto comunque + } else { + mantissa |= 1n << 52n; // bit implicito + exponent = expBits - 1075; + } + + // ax*10 = num/den, con num e den interi esatti: nessuna moltiplicazione + // in virgola mobile, quindi nessun pareggio fasullo introdotto qui. + let num: bigint; + let den: bigint; + if (exponent >= 0) { + num = mantissa * (10n << BigInt(exponent)); + den = 1n; + } else { + num = mantissa * 10n; + den = 1n << BigInt(-exponent); + } + + const n = num / den; // floor(ax*10), esatto + const resto2 = (num % den) * 2n; // confronta il resto con 1/2 di den, esatto + let risultato: bigint; + if (resto2 < den) risultato = n; + else if (resto2 > den) risultato = n + 1n; + else risultato = n % 2n === 0n ? n : n + 1n; // pareggio vero: arrotonda al pari + + const valore = Number(risultato) / 10; + return negativo ? -valore : valore; } /** diff --git a/src/lib/longevity/motore/test-fisici.ts b/src/lib/longevity/motore/test-fisici.ts index b85ef42..175ccf1 100644 --- a/src/lib/longevity/motore/test-fisici.ts +++ b/src/lib/longevity/motore/test-fisici.ts @@ -16,7 +16,7 @@ * nessun peso. */ -import { clamp, lerp } from './curve'; +import { clamp, lerp, arrotonda1 } from './curve'; export type Sesso = 'M' | 'F'; @@ -25,23 +25,6 @@ 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`. */ diff --git a/tests/longevity/motore-curve.test.ts b/tests/longevity/motore-curve.test.ts index 32ef3d1..a9ca934 100644 --- a/tests/longevity/motore-curve.test.ts +++ b/tests/longevity/motore-curve.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { - clamp, lerp, curvaCampana, curvaDecrescente, curvaCrescenteConPlateau, + clamp, lerp, arrotonda1, curvaCampana, curvaDecrescente, curvaCrescenteConPlateau, curvaDirettaX10, curvaDirettaX10Invertita, curvaGradini, } from '../../src/lib/longevity/motore/curve'; @@ -123,4 +123,27 @@ describe('curve del questionario, confrontate con l oracolo', () => { expect(lerp(-1, 0, 10, 0, 100)).toBe(0); // t viene limitato a [0,1] expect(lerp(11, 0, 10, 0, 100)).toBe(100); }); + + // round(x, 1) di Python arrotonda al pari sui pareggi ESATTI (banker's + // rounding), non sempre in su. Senza questi casi un arrotondamento naive + // (Math.round(x*10)/10, che su 86.25 darebbe 86.3) sarebbe indistinguibile + // dal corretto in tutti gli altri test: qui il pareggio e' deliberato. + it('arrotonda1 arrotonda al pari sui pareggi esatti, come round() di Python', () => { + expect(arrotonda1(86.25)).toBe(86.2); // 862 e' pari: resta 86.2, non sale a 86.3 + expect(arrotonda1(86.75)).toBe(86.8); // 867 e' dispari: sale a 86.8, non resta 86.7 + expect(arrotonda1(86.24)).toBe(86.2); // non un pareggio: arrotondamento normale, per basso + expect(arrotonda1(86.26)).toBe(86.3); // non un pareggio: arrotondamento normale, per alto + }); + + // 86.35 NON e' un pareggio vero: il double che gli sta dietro vale + // 86.34999999999999431..., quindi Python arrotonda (senza ambiguita') a + // 86.3. Una prima versione di arrotonda1, che moltiplicava per 10 e + // testava una tolleranza, ci cadeva: 86.35 * 10 arrotonda ESATTAMENTE a + // 863.5 in virgola mobile (un pareggio creato dalla moltiplicazione, non + // presente nel valore originale) e restituiva 86.4. Questo test blocca + // proprio quella regressione. + it('arrotonda1 non inventa un pareggio dove il double non ce l ha (86.35 -> 86.3, non un pareggio)', () => { + expect(arrotonda1(86.35)).toBe(86.3); + expect(arrotonda1(-86.35)).toBe(-86.3); + }); }); From f029820a141ac7931ec075cb6313b71ed48d162c Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 22 Aug 2026 09:24:42 +0200 Subject: [PATCH 32/62] longevity: il test delle funzioni escluse copre anche score_generic_range_local Il test controllava solo l'assenza dell'agilita (scoreAgilita/scoreAgilityMs) e non della seconda funzione esclusa dal brief, score_generic_range_local: se qualcuno la reintroducesse un domani nessun test se ne accorgerebbe. Aggiunta l'assertion con lo stesso criterio, rinominato il test perche' ora copre entrambe le esclusioni. --- tests/longevity/motore-fisici.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/longevity/motore-fisici.test.ts b/tests/longevity/motore-fisici.test.ts index e5c4eb8..5e885c9 100644 --- a/tests/longevity/motore-fisici.test.ts +++ b/tests/longevity/motore-fisici.test.ts @@ -64,8 +64,11 @@ describe('curve dei test fisici, confrontate con l oracolo caso per caso', () => expect(perTabella('bench')).toBeTruthy(); }); - it('l agilita NON e stata portata: il cliente l ha esclusa dallo score', () => { + it('le due funzioni escluse dal porting non esistono nel modulo', () => { + // Agilita: il cliente l'ha esclusa dallo score il 13/08 (scale non comparabili fra protocolli). expect((F as Record).scoreAgilita).toBeUndefined(); expect((F as Record).scoreAgilityMs).toBeUndefined(); + // score_generic_range_local: definita in fondo all'oracolo ma non chiamata da nessun peso. + expect((F as Record).scoreGenericRangeLocal).toBeUndefined(); }); }); From 98a633b3e1cb4cdad3b8f80c721abedf970501bd Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 22 Aug 2026 09:28:40 +0200 Subject: [PATCH 33/62] longevity: la cascata a quattro livelli, con il tipo che chiude la trappola dell insufficiente --- src/lib/longevity/motore/cascata.ts | 111 +++++++++++++++++++++++++ tests/longevity/motore-cascata.test.ts | 77 +++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 src/lib/longevity/motore/cascata.ts create mode 100644 tests/longevity/motore-cascata.test.ts diff --git a/src/lib/longevity/motore/cascata.ts b/src/lib/longevity/motore/cascata.ts new file mode 100644 index 0000000..1eb978b --- /dev/null +++ b/src/lib/longevity/motore/cascata.ts @@ -0,0 +1,111 @@ +/** + * 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. + * + * UNICA DIVERGENZA VOLUTA dall'oracolo: 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. 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 }; + +/** Arrotonda a due cifre decimali, come `round(x, 2)` per la copertura nell'oracolo. */ +function arrotonda2(x: number): number { + return Math.round(x * 100) / 100; +} + +/** + * 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. + */ +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 copertura = pesoTotale ? arrotonda2(pesoDisponibile / pesoTotale) : 0; + + if (disponibili.length === 0) { + return { stato: 'insufficiente', copertura: 0 }; + } + + if (copertura < 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. + */ +export function calcolaFitnessAge( + etaAnagrafica: number, + 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') { + return { fitnessAge: null, composito }; + } + const fitnessAge = arrotonda1(etaAnagrafica - (composito.valore - 50) * 0.4); + return { fitnessAge, composito }; +} diff --git a/tests/longevity/motore-cascata.test.ts b/tests/longevity/motore-cascata.test.ts new file mode 100644 index 0000000..3723949 --- /dev/null +++ b/tests/longevity/motore-cascata.test.ts @@ -0,0 +1,77 @@ +// tests/longevity/motore-cascata.test.ts +import { describe, it, expect } from 'vitest'; +import { COPERTURA_MINIMA } from '../../src/lib/longevity/db'; +import { aggrega, calcolaAsse, calcolaMacro, calcolaFitnessAge } from '../../src/lib/longevity/motore/cascata'; + +describe('aggregazione e rinormalizzazione', () => { + it('con tutti i dati fa la media pesata', () => { + const r = aggrega([{ punteggio: 80, peso: 0.5 }, { punteggio: 60, peso: 0.5 }]); + expect(r.stato).toBe('ok'); + if (r.stato === 'ok') { expect(r.valore).toBeCloseTo(70, 1); expect(r.copertura).toBe(1); } + }); + + it('un dato mancante ridistribuisce il suo peso, non vale zero', () => { + const r = aggrega([{ punteggio: 80, peso: 0.5 }, { punteggio: null, peso: 0.25 }, { punteggio: 60, peso: 0.25 }]); + expect(r.stato).toBe('ok'); + // 80*0.5 + 60*0.25 = 55, su peso disponibile 0.75 -> 73.3, non 55 + if (r.stato === 'ok') { expect(r.valore).toBeCloseTo(73.3, 1); expect(r.copertura).toBeCloseTo(0.75, 2); } + }); + + it('sotto la soglia di copertura NON esiste un valore da leggere', () => { + const r = aggrega([{ punteggio: 90, peso: 0.2 }, { punteggio: null, peso: 0.8 }]); + expect(r.stato).toBe('insufficiente'); + expect(r.copertura).toBeCloseTo(0.2, 2); + // il punto dell'intero tipo: chi consuma non ha il campo da cui prendere il numero + expect((r as { valore?: number }).valore).toBeUndefined(); + }); + + it('la soglia e quella dichiarata una volta sola, non un numero sparso', () => { + const pocoSotto = aggrega([{ punteggio: 90, peso: COPERTURA_MINIMA - 0.01 }, { punteggio: null, peso: 1 - COPERTURA_MINIMA + 0.01 }]); + const esatto = aggrega([{ punteggio: 90, peso: COPERTURA_MINIMA }, { punteggio: null, peso: 1 - COPERTURA_MINIMA }]); + expect(pocoSotto.stato).toBe('insufficiente'); + expect(esatto.stato).toBe('ok'); // la soglia e inclusiva, come nell'oracolo + }); + + it('senza nessun dato e insufficiente con copertura zero', () => { + const r = aggrega([{ punteggio: null, peso: 1 }]); + expect(r.stato).toBe('insufficiente'); + expect(r.copertura).toBe(0); + }); +}); + +describe('assi, macro e Fitness Age', () => { + const PESI_FORZA = { handgrip: 0.25, spinta: 0.2, trazione: 0.2, arti_inferiori: 0.2, core: 0.15 }; + + it('un asse si calcola sui suoi sotto-domini', () => { + const r = calcolaAsse(PESI_FORZA, { handgrip: 70, spinta: 60, trazione: 65, arti_inferiori: 80, core: 50 }); + expect(r.stato).toBe('ok'); + // 70*.25 + 60*.2 + 65*.2 + 80*.2 + 50*.15 = 66.0 (verificato anche a mano, non 66.25 come + // nel brief: la costante li' e' un refuso, corretto qui dopo averlo verificato col conto) + if (r.stato === 'ok') expect(r.valore).toBeCloseTo(66.0, 1); + }); + + it('un asse insufficiente NON entra nel macro-score, invece di entrarci come zero', () => { + const assi = { + A: { stato: 'ok', valore: 80, copertura: 1 } as const, + B: { stato: 'insufficiente', copertura: 0.1 } as const, + }; + const r = calcolaMacro({ A: 0.5, B: 0.5 }, assi); + expect(r.stato).toBe('ok'); + // se B entrasse come zero il risultato sarebbe 40: la rinormalizzazione lo esclude + if (r.stato === 'ok') { expect(r.valore).toBeCloseTo(80, 1); expect(r.copertura).toBeCloseTo(0.5, 2); } + }); + + it('la Fitness Age scende sotto l eta quando il composito supera 50', () => { + const pesi = { cardio: 0.3, handgrip_isolato: 0.2, hrv_isolato: 0.2, forza_resto: 0.15, composizione: 0.1, stabilita: 0.05 }; + const r = calcolaFitnessAge(40, pesi, { cardio: 75, handgrip_isolato: 75, hrv_isolato: 75, forza_resto: 75, composizione: 75, stabilita: 75 }); + // 40 - (75 - 50) * 0.4 = 30 + expect(r.fitnessAge).toBeCloseTo(30, 1); + }); + + it('senza dati sufficienti la Fitness Age non esiste', () => { + const pesi = { cardio: 0.3, handgrip_isolato: 0.2, hrv_isolato: 0.2, forza_resto: 0.15, composizione: 0.1, stabilita: 0.05 }; + const r = calcolaFitnessAge(40, pesi, { cardio: 75, handgrip_isolato: null, hrv_isolato: null, forza_resto: null, composizione: null, stabilita: null }); + expect(r.composito.stato).toBe('insufficiente'); + expect(r.fitnessAge).toBeNull(); + }); +}); From 93ecc3d36785057441b59e98bb030541b41c28f8 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 22 Aug 2026 09:39:58 +0200 Subject: [PATCH 34/62] longevity: la copertura si confronta grezza, non arrotondata Bug in aggrega(): la soglia veniva confrontata con la copertura gia' arrotondata a due decimali, quindi 0.396 (< 0.40) passava per 'ok' perche' arrotondava a 0.40. Confronto ora sulla copertura grezza; l'arrotondamento resta solo sul valore esposto. Generalizzata arrotonda1 in curve.ts a un numero qualsiasi di decimali (bit-exact, banker's rounding) e riusata qui a due decimali, invece di tenere una seconda logica di arrotondamento nel motore. --- src/lib/longevity/motore/cascata.ts | 18 +++++---- src/lib/longevity/motore/curve.ts | 52 ++++++++++++++------------ tests/longevity/motore-cascata.test.ts | 22 +++++++++++ tests/longevity/motore-curve.test.ts | 24 ++++++++++++ 4 files changed, 85 insertions(+), 31 deletions(-) diff --git a/src/lib/longevity/motore/cascata.ts b/src/lib/longevity/motore/cascata.ts index 1eb978b..f930bf5 100644 --- a/src/lib/longevity/motore/cascata.ts +++ b/src/lib/longevity/motore/cascata.ts @@ -31,27 +31,31 @@ export type Punteggio = /** 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 }; -/** Arrotonda a due cifre decimali, come `round(x, 2)` per la copertura nell'oracolo. */ -function arrotonda2(x: number): number { - return Math.round(x * 100) / 100; -} - /** * 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 copertura = pesoTotale ? arrotonda2(pesoDisponibile / pesoTotale) : 0; + const coperturaGrezza = pesoTotale ? pesoDisponibile / pesoTotale : 0; + const copertura = arrotonda1(coperturaGrezza, 2); if (disponibili.length === 0) { return { stato: 'insufficiente', copertura: 0 }; } - if (copertura < COPERTURA_MINIMA) { + if (coperturaGrezza < COPERTURA_MINIMA) { return { stato: 'insufficiente', copertura }; } diff --git a/src/lib/longevity/motore/curve.ts b/src/lib/longevity/motore/curve.ts index 0920615..3f77ed7 100644 --- a/src/lib/longevity/motore/curve.ts +++ b/src/lib/longevity/motore/curve.ts @@ -34,31 +34,34 @@ export function lerp(x: number, x0: number, x1: number, y0: number, y1: number): } /** - * Arrotonda a una cifra decimale, come `round(x, 1)` in Python: sui pareggi - * ESATTI (il valore binario del double è esattamente a metà, es. 86.25) - * arrotonda al pari (banker's rounding), non sempre in su come farebbe - * `Math.round`. Alcune curve a gradini producono pareggi veri — `lerp` che - * restituisce 86.25 esatto va a 86.2, non 86.3. + * Arrotonda a `decimali` cifre decimali (default 1), come `round(x, decimali)` + * in Python: sui pareggi ESATTI (il valore binario del double è esattamente a + * metà, es. 86.25 per un decimale) arrotonda al pari (banker's rounding), non + * sempre in su come farebbe `Math.round`. Alcune curve a gradini producono + * pareggi veri — `lerp` che restituisce 86.25 esatto va a 86.2, non 86.3. * - * Non basta moltiplicare per 10 e confrontare con una tolleranza: un valore - * come 86.35 NON è un pareggio vero (il double che gli sta dietro è - * 86.34999999999999431..., quindi Python arrotonda a 86.3, non a metà), ma - * `86.35 * 10` in virgola mobile arrotonda esattamente a 863.5 — un pareggio - * fasullo creato dalla moltiplicazione, non presente nel valore originale. - * Una prima versione con tolleranza ci cadeva (dava 86.4). Per evitarlo si - * scompone il double nella sua mantissa ed esponente esatti (bit IEEE 754, - * via `DataView`) e si confrontano `ax*10` e il pareggio con aritmetica - * razionale su `BigInt`, senza mai passare per una moltiplicazione in - * virgola mobile che potrebbe introdurre o cancellare un pareggio. + * Non basta moltiplicare per 10^decimali e confrontare con una tolleranza: un + * valore come 86.35 NON è un pareggio vero a un decimale (il double che gli + * sta dietro è 86.34999999999999431..., quindi Python arrotonda a 86.3, non + * a metà), ma `86.35 * 10` in virgola mobile arrotonda esattamente a 863.5 — + * un pareggio fasullo creato dalla moltiplicazione, non presente nel valore + * originale. Una prima versione con tolleranza ci cadeva (dava 86.4). Per + * evitarlo si scompone il double nella sua mantissa ed esponente esatti (bit + * IEEE 754, via `DataView`) e si confrontano `ax*10^decimali` e il pareggio + * con aritmetica razionale su `BigInt`, senza mai passare per una + * moltiplicazione in virgola mobile che potrebbe introdurre o cancellare un + * pareggio. * - * Unica funzione di arrotondamento del motore: la usano sia `curve.ts` sia - * `test-fisici.ts`, per non avere due implementazioni che sui pareggi + * Unica funzione di arrotondamento del motore: la usano `curve.ts`, + * `test-fisici.ts` (a un decimale, i punteggi) e `cascata.ts` (anche a due + * decimali, la copertura), per non avere due implementazioni che sui pareggi * potrebbero divergere fra loro. */ -export function arrotonda1(x: number): number { +export function arrotonda1(x: number, decimali = 1): number { if (!Number.isFinite(x) || x === 0) return x === 0 ? 0 : x; const negativo = x < 0; const ax = Math.abs(x); + const potenza = 10n ** BigInt(decimali); // Scompone il double nella sua rappresentazione esatta ax = mantissa * 2^exponente. const view = new DataView(new ArrayBuffer(8)); @@ -75,26 +78,27 @@ export function arrotonda1(x: number): number { exponent = expBits - 1075; } - // ax*10 = num/den, con num e den interi esatti: nessuna moltiplicazione - // in virgola mobile, quindi nessun pareggio fasullo introdotto qui. + // ax*10^decimali = num/den, con num e den interi esatti: nessuna + // moltiplicazione in virgola mobile, quindi nessun pareggio fasullo + // introdotto qui. let num: bigint; let den: bigint; if (exponent >= 0) { - num = mantissa * (10n << BigInt(exponent)); + num = mantissa * (potenza << BigInt(exponent)); den = 1n; } else { - num = mantissa * 10n; + num = mantissa * potenza; den = 1n << BigInt(-exponent); } - const n = num / den; // floor(ax*10), esatto + const n = num / den; // floor(ax*10^decimali), esatto const resto2 = (num % den) * 2n; // confronta il resto con 1/2 di den, esatto let risultato: bigint; if (resto2 < den) risultato = n; else if (resto2 > den) risultato = n + 1n; else risultato = n % 2n === 0n ? n : n + 1n; // pareggio vero: arrotonda al pari - const valore = Number(risultato) / 10; + const valore = Number(risultato) / Number(potenza); return negativo ? -valore : valore; } diff --git a/tests/longevity/motore-cascata.test.ts b/tests/longevity/motore-cascata.test.ts index 3723949..2317509 100644 --- a/tests/longevity/motore-cascata.test.ts +++ b/tests/longevity/motore-cascata.test.ts @@ -37,6 +37,28 @@ describe('aggregazione e rinormalizzazione', () => { expect(r.stato).toBe('insufficiente'); expect(r.copertura).toBe(0); }); + + // Bug trovato in revisione: il confronto con la soglia usava la copertura + // GIA' arrotondata a due decimali, non quella grezza. Una copertura grezza + // di 0.396 arrotonda a 0.40 - uguale alla soglia - e con l'arrotondamento + // fatto PRIMA del confronto passava per 'ok'. Verificato anche contro + // l'oracolo Python: aggregate([WeightedScore(90, 0.396), WeightedScore(None, + // 0.604)]) -> (90.0, 0.4, 'insufficiente'). + it('una copertura grezza appena sotto la soglia resta insufficiente anche se arrotonda a 0.40', () => { + const r = aggrega([{ punteggio: 90, peso: 0.396 }, { punteggio: null, peso: 0.604 }]); + expect(r.stato).toBe('insufficiente'); + expect(r.copertura).toBeCloseTo(0.40, 2); // il valore esposto arrotonda a 0.40, ma lo stato resta insufficiente + }); + + // Simmetrico: una copertura grezza appena SOPRA la soglia (0.404, che + // arrotonda anch'essa a 0.40) deve restare 'ok' - il fix non deve aver + // spostato la soglia dall'altra parte. Oracolo: aggregate([WeightedScore(90, + // 0.404), WeightedScore(None, 0.596)]) -> (90.0, 0.4, 'ok'). + it('una copertura grezza appena sopra la soglia resta ok: il fix non ha spostato la soglia', () => { + const r = aggrega([{ punteggio: 90, peso: 0.404 }, { punteggio: null, peso: 0.596 }]); + expect(r.stato).toBe('ok'); + if (r.stato === 'ok') expect(r.copertura).toBeCloseTo(0.40, 2); + }); }); describe('assi, macro e Fitness Age', () => { diff --git a/tests/longevity/motore-curve.test.ts b/tests/longevity/motore-curve.test.ts index a9ca934..46fceaf 100644 --- a/tests/longevity/motore-curve.test.ts +++ b/tests/longevity/motore-curve.test.ts @@ -146,4 +146,28 @@ describe('curve del questionario, confrontate con l oracolo', () => { expect(arrotonda1(86.35)).toBe(86.3); expect(arrotonda1(-86.35)).toBe(-86.3); }); + + // Generalizzazione a `decimali` cifre (usata da cascata.ts per la copertura, + // a 2 decimali): stesso criterio dei test sopra, un gradino piu' in la'. + // 0.125 e 0.375 sono pareggi ESATTI a due decimali (double rappresentabili + // in binario senza resto: 1/8 e 3/8), verificati anche a mano con + // round(0.125, 2) e round(0.375, 2) in Python. + it('arrotonda1(x, 2) arrotonda al pari sui pareggi esatti a due decimali', () => { + expect(arrotonda1(0.125, 2)).toBe(0.12); // 12 e' pari: resta 0.12, non sale a 0.13 + expect(arrotonda1(0.375, 2)).toBe(0.38); // 37 e' dispari: sale a 0.38, non resta 0.37 + expect(arrotonda1(0.124, 2)).toBe(0.12); // non un pareggio: arrotondamento normale, per basso + expect(arrotonda1(0.126, 2)).toBe(0.13); // non un pareggio: arrotondamento normale, per alto + }); + + // 0.045 e' l'analogo a due decimali di 86.35: il double che gli sta dietro + // vale 0.044999999999999998..., quindi Python (e l'oracolo) arrotondano + // SENZA ambiguita' a 0.04. Ma 0.045 * 100 arrotonda ESATTAMENTE a 4.5 in + // virgola mobile - un pareggio creato dalla moltiplicazione, non presente + // nel valore originale - e un arrotondamento naive (Math.round(x*100)/100, + // la stessa `arrotonda2` locale rimossa da cascata.ts) ci sarebbe caduto, + // restituendo 0.05 invece di 0.04. + it('arrotonda1(x, 2) non inventa un pareggio dove il double non ce l ha (0.045 -> 0.04, non un pareggio)', () => { + expect(arrotonda1(0.045, 2)).toBe(0.04); + expect(arrotonda1(-0.045, 2)).toBe(-0.04); + }); }); From f2389848286b8b8245ef416d5b13b0ca31a27255 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 22 Aug 2026 09:53:17 +0200 Subject: [PATCH 35/62] longevity: il motore legge curve e pesi dal registro, e congela i punteggi Task 5: applicaCurva smista sulla curva dichiarata in registro_test (non scritta nel codice); calcolaSessione aggrega le misure attive per sotto-dominio (media quando piu' test contribuiscono allo stesso sotto-dominio) e usa cascata.ts coi pesi letti da pesi (pesiDi) per i sette assi, i tre macro-score e la Fitness Age; salvaScore congela il risultato in score, con valore a null quando lo stato e' insufficiente. Corretto anche un refuso nel test del brief ('un test disattivato non entra nel calcolo'): disattivava q_ore_sonno, ma quella voce sta nel sotto-dominio questionario_sonno (peso 0.20 su Recupero & Sistema Nervoso, sempre sotto COPERTURA_MINIMA=0.4 finche' hrv/pressione/hrr non hanno una voce nel registro), quindi l'asse restava insufficiente prima e dopo e il JSON risultava identico (verificato: il test falliva con le due stringhe uguali). Corretto usando q_attivita, l'unica voce del sotto-dominio questionario_lifestyle che pesa 1.00 su Stile di Vita & Sonno: disattivarla fa cadere la copertura da 1.00 a 0 e lo stato passa da ok a insufficiente, una differenza vera. --- src/lib/longevity/motore/index.ts | 195 ++++++++++++++++++++++++++++++ src/lib/longevity/registro.ts | 14 +++ tests/longevity/motore.test.ts | 110 +++++++++++++++++ 3 files changed, 319 insertions(+) create mode 100644 src/lib/longevity/motore/index.ts create mode 100644 tests/longevity/motore.test.ts diff --git a/src/lib/longevity/motore/index.ts b/src/lib/longevity/motore/index.ts new file mode 100644 index 0000000..f68bb12 --- /dev/null +++ b/src/lib/longevity/motore/index.ts @@ -0,0 +1,195 @@ +/** + * Il motore che legge dal registro, non da costanti: curve, parametri e pesi + * sono dato (`registro_test`, `pesi`), non codice. Aggiungere o togliere un + * test, o cambiare un parametro, resta una modifica ai dati. + * + * Livello 1 (`applicaCurva`): smista sulla curva dichiarata per il test nel + * registro (`registro_test.curva`), coi parametri dichiarati lì + * (`registro_test.params`), e chiama la funzione di `curve.ts` giusta. Un + * test senza curva dichiarata (es. un campo testuale) non contribuisce al + * calcolo. + * + * Livelli 2-4 (`calcolaSessione`): raggruppa le misure attive della sessione + * per sotto-dominio (media dei test dello stesso sotto-dominio — più test + * possono contribuire allo stesso sotto-dominio, es. spinta via push-up o + * panca), poi usa `cascata.ts` (`calcolaAsse`, `calcolaMacro`, + * `calcolaFitnessAge`) coi pesi letti da `pesi` (via `pesiDi`) per i sette + * assi, i tre macro-score e la Fitness Age. + * + * `salvaScore` congela il risultato nella tabella `score`, una riga per + * asse/macro/Fitness Age, con `valore` a `null` quando lo stato è + * insufficiente (il tipo `Punteggio` non porta un `valore` in quel ramo). + */ + +import type Database from 'better-sqlite3'; +import { + curvaCampana, curvaDecrescente, curvaCrescenteConPlateau, + curvaDirettaX10, curvaDirettaX10Invertita, curvaGradini, arrotonda1, +} from './curve'; +import { calcolaAsse, calcolaMacro, calcolaFitnessAge, type Punteggio } from './cascata'; +import { testAttivi, pesiDi, MODEL_VERSION, type VoceRegistro } from '../registro'; + +/** Parametri possibili per una curva, nella forma dichiarata da ciascuna curva in `curve.ts`. */ +type ParamsCurva = { + low?: number; peakLow?: number; peakHigh?: number; high?: number; + best?: number; worst?: number; plateauStart?: number; + steps?: [number, number][]; zeroVal?: number; +}; + +/** + * Normalizza un valore grezzo secondo la curva dichiarata nel registro per + * quel test (`voce.curva` + `voce.params`), non secondo una curva scritta nel + * motore. Un test senza curva dichiarata restituisce `null`: non contribuisce + * al calcolo di sotto-dominio. + */ +export function applicaCurva(voce: VoceRegistro, valore: number): number | null { + if (!voce.curva) return null; + const p = (voce.params ?? {}) as ParamsCurva; + switch (voce.curva) { + case 'bell': + return curvaCampana(valore, p.low as number, p.peakLow as number, p.peakHigh as number, p.high as number); + case 'lin_dec': + return curvaDecrescente(valore, p.best as number, p.worst as number); + case 'inc_plateau': + return curvaCrescenteConPlateau(valore, p.worst as number, p.plateauStart as number); + case 'x10': + return curvaDirettaX10(valore); + case 'x10_inv': + return curvaDirettaX10Invertita(valore); + case 'decstep': + return curvaGradini(valore, p.steps ?? [], p.zeroVal, true); + case 'incstep': + return curvaGradini(valore, p.steps ?? [], p.zeroVal, false); + default: + return null; + } +} + +/** Esito del calcolo di una sessione: i sette assi, i tre macro-score, la Fitness Age. */ +export type RisultatoSessione = { + assi: Record; + macro: Record; + fitnessAge: Punteggio; + modelVersion: string; + questVersion: string | null; +}; + +/** + * Calcola i punteggi di una sessione leggendo dal registro e dai pesi, non da + * costanti: `testAttivi` alla data della sessione, i valori grezzi da + * `misure`, i pesi da `pesi` (tramite `pesiDi`). + */ +export function calcolaSessione(db: Database.Database, sessioneId: number): RisultatoSessione { + const sessione = db.prepare( + `SELECT data, eta_alla_data, quest_version FROM sessioni WHERE id = ?` + ).get(sessioneId) as { data: string; eta_alla_data: number | null; quest_version: string | null } | undefined; + if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`); + + const attivi = testAttivi(db, sessione.data); + const misurate = new Map( + (db.prepare(`SELECT test_id, valore_num FROM misure WHERE sessione_id = ?`).all(sessioneId) as + { test_id: string; valore_num: number | null }[]) + .filter((m) => m.valore_num !== null) + .map((m) => [m.test_id, m.valore_num as number]) + ); + + // Livello 1: normalizza ogni test attivo e misurato, poi raggruppa per + // sotto-dominio — più test nello stesso sotto-dominio contano come media. + const perSottoDominio = new Map(); + for (const voce of attivi) { + if (!voce.sotto_dominio) continue; + const grezzo = misurate.get(voce.test_id); + if (grezzo === undefined) continue; + const punteggio = applicaCurva(voce, grezzo); + if (punteggio === null) continue; + const lista = perSottoDominio.get(voce.sotto_dominio) ?? []; + lista.push(punteggio); + perSottoDominio.set(voce.sotto_dominio, lista); + } + const sottoDominio: Record = {}; + for (const [nome, valori] of perSottoDominio) { + sottoDominio[nome] = arrotonda1(valori.reduce((a, b) => a + b, 0) / valori.length); + } + + // Livello 2: i sette assi. I nomi vengono dai pesi in database, non da un elenco fisso. + const nomiAssi = (db.prepare( + `SELECT DISTINCT contenitore FROM pesi WHERE model_version = ? AND livello = 'asse'` + ).all(MODEL_VERSION) as { contenitore: string }[]).map((r) => r.contenitore); + + const assi: Record = {}; + for (const nomeAsse of nomiAssi) { + assi[nomeAsse] = calcolaAsse(pesiDi(db, MODEL_VERSION, 'asse', nomeAsse), sottoDominio); + } + + // Livello 3: i tre macro-score (Performance/Energy/Recovery). + const nomiMacro = (db.prepare( + `SELECT DISTINCT contenitore FROM pesi WHERE model_version = ? AND livello = 'macro'` + ).all(MODEL_VERSION) as { contenitore: string }[]).map((r) => r.contenitore); + + const macro: Record = {}; + for (const nomeMacro of nomiMacro) { + macro[nomeMacro] = calcolaMacro(pesiDi(db, MODEL_VERSION, 'macro', nomeMacro), assi); + } + + // Livello 4: Fitness Age. cardio/forza_resto/composizione/stabilita vengono + // dal punteggio intero dell'asse; handgrip_isolato e hrv_isolato dal + // sotto-dominio isolato, prima che entri nell'aggregazione del suo asse — + // come nell'oracolo, che guarda quei due sotto-domini da soli. + const valoreAsse = (nome: string): number | null => { + const a = assi[nome]; + return a && a.stato === 'ok' ? a.valore : null; + }; + const vociFitnessAge: Record = { + cardio: valoreAsse('Cardio-Respiratorio'), + handgrip_isolato: sottoDominio.handgrip ?? null, + hrv_isolato: sottoDominio.hrv ?? null, + forza_resto: valoreAsse('Forza & Struttura'), + composizione: valoreAsse('Composizione Corporea'), + stabilita: valoreAsse('Stabilità & Mobilità Funzionale'), + }; + const { fitnessAge, composito } = calcolaFitnessAge( + sessione.eta_alla_data ?? 0, + pesiDi(db, MODEL_VERSION, 'fitness_age', 'fitness_age'), + vociFitnessAge + ); + const fitnessAgePunteggio: Punteggio = composito.stato === 'insufficiente' + ? { stato: 'insufficiente', copertura: composito.copertura } + : { stato: 'ok', valore: fitnessAge as number, copertura: composito.copertura }; + + return { + assi, macro, fitnessAge: fitnessAgePunteggio, + modelVersion: MODEL_VERSION, questVersion: sessione.quest_version, + }; +} + +/** + * Congela il risultato di `calcolaSessione` nella tabella `score`: una riga + * per asse, una per macro-score, una per la Fitness Age. `valore` va a + * `null` quando lo stato è insufficiente — il tipo `Punteggio` non porta un + * `valore` in quel ramo, quindi non c'è modo di scrivere un numero fasullo. + */ +export function salvaScore(db: Database.Database, sessioneId: number, risultato: RisultatoSessione): void { + const sessione = db.prepare(`SELECT client_code FROM sessioni WHERE id = ?`).get(sessioneId) as + { client_code: string } | undefined; + if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`); + + const ins = db.prepare( + `INSERT INTO score (client_code, sessione_id, tipo, elemento, valore, copertura, stato, quest_version, model_version) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ); + + const riga = (tipo: 'asse' | 'macro' | 'fitness_age', elemento: string, p: Punteggio): void => { + ins.run( + sessione.client_code, sessioneId, tipo, elemento, + p.stato === 'ok' ? p.valore : null, + p.copertura, p.stato, risultato.questVersion, risultato.modelVersion + ); + }; + + const tx = db.transaction(() => { + for (const [nome, p] of Object.entries(risultato.assi)) riga('asse', nome, p); + for (const [nome, p] of Object.entries(risultato.macro)) riga('macro', nome, p); + riga('fitness_age', 'fitness_age', risultato.fitnessAge); + }); + tx(); +} diff --git a/src/lib/longevity/registro.ts b/src/lib/longevity/registro.ts index 30cd1f1..3204c26 100644 --- a/src/lib/longevity/registro.ts +++ b/src/lib/longevity/registro.ts @@ -2,6 +2,9 @@ import type Database from 'better-sqlite3'; export type Curva = 'bell' | 'lin_dec' | 'inc_plateau' | 'x10' | 'x10_inv' | 'decstep' | 'incstep'; +/** Versione del modello di calcolo: curve e pesi. Distinta da QUEST_VERSION, che versiona le domande. */ +export const MODEL_VERSION = 'v1.0'; + export type VoceRegistro = { test_id: string; etichetta: string; @@ -183,3 +186,14 @@ export function esisteTest(db: Database.Database, testId: string): boolean { const r = db.prepare(`SELECT 1 FROM registro_test WHERE test_id = ?`).get(testId); return r !== undefined; } + +/** I pesi di un contenitore (un asse, un macro-score o la Fitness Age) per la versione del modello data. */ +export function pesiDi( + db: Database.Database, modelVersion: string, + livello: 'asse' | 'macro' | 'fitness_age', contenitore: string +): Record { + const righe = db.prepare( + `SELECT elemento, peso FROM pesi WHERE model_version = ? AND livello = ? AND contenitore = ?` + ).all(modelVersion, livello, contenitore) as { elemento: string; peso: number }[]; + return Object.fromEntries(righe.map((r) => [r.elemento, r.peso])); +} diff --git a/tests/longevity/motore.test.ts b/tests/longevity/motore.test.ts new file mode 100644 index 0000000..6f674d0 --- /dev/null +++ b/tests/longevity/motore.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'vitest'; +import { createLongevityDb } from '../../src/lib/longevity/db'; +import { seedRegistro, seedPesi, MODEL_VERSION, pesiDi } from '../../src/lib/longevity/registro'; +import { salvaCompilazione } from '../../src/lib/longevity/questionario'; +import { applicaCurva, calcolaSessione, salvaScore } from '../../src/lib/longevity/motore'; + +function dbPronto() { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + seedPesi(db, MODEL_VERSION); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run(); + return db; +} + +const voce = (db: ReturnType, id: string) => + db.prepare(`SELECT * FROM registro_test WHERE test_id = ?`).get(id) as Record; + +describe('il motore legge le curve dal registro', () => { + it('applica la curva dichiarata per il test, non una scritta nel codice', () => { + const db = dbPronto(); + const v = { ...voce(db, 'q_ore_sonno'), params: JSON.parse(voce(db, 'q_ore_sonno').params as string) }; + expect(applicaCurva(v as never, 8)).toBe(100); // dentro il picco 7-9 + expect(applicaCurva(v as never, 4)).toBeLessThan(20); + }); + + it('rispetta la soglia dell alcol come e scritta nel registro', () => { + const db = dbPronto(); + const v = { ...voce(db, 'q_alcol_life'), params: JSON.parse(voce(db, 'q_alcol_life').params as string) }; + expect(applicaCurva(v as never, 0)).toBe(100); + expect(applicaCurva(v as never, 7)).toBe(100); + expect(applicaCurva(v as never, 14)).toBe(0); + }); + + it('cambiare i parametri nel registro cambia il punteggio, senza toccare il codice', () => { + const db = dbPronto(); + db.prepare(`UPDATE registro_test SET params = ? WHERE test_id = 'q_alcol_life'`) + .run(JSON.stringify({ best: 0, worst: 7 })); + const v = { ...voce(db, 'q_alcol_life'), params: JSON.parse(voce(db, 'q_alcol_life').params as string) }; + expect(applicaCurva(v as never, 7)).toBe(0); // con i parametri nuovi, 7 non vale piu 100 + }); + + it('un test disattivato non entra nel calcolo', () => { + // NOTA: la versione originale di questo test disattivava 'q_ore_sonno' e si + // aspettava una differenza. Ma 'q_ore_sonno' e' uno delle SEI voci del + // sotto-dominio 'questionario_sonno' (peso 0.20 dentro "Recupero & Sistema + // Nervoso", che pesa in tutto 1.00: hrv 0.50 + pressione 0.15 + hrr 0.15 + + // questionario_sonno 0.20). Quell'asse resta 'insufficiente' con o senza + // 'q_ore_sonno' (0.20 < COPERTURA_MINIMA 0.4, e hrv/pressione/hrr non hanno + // ancora una voce nel registro): siccome un asse insufficiente non porta + // 'valore' nel tipo Punteggio, il JSON prima/dopo risultava IDENTICO — la + // suite lo ha confermato (verificato eseguendo il test: fallisce con le due + // stringhe uguali). Corretto qui usando 'q_attivita', l'unica voce del + // sotto-dominio 'questionario_lifestyle' che pesa 1.00 dentro "Stile di Vita + // & Sonno": disattivarla fa cadere la copertura di quell'asse da 1.00 a 0 e + // lo stato passa da 'ok' a 'insufficiente' — una differenza vera e osservabile. + const db = dbPronto(); + salvaCompilazione(db, { + client_code: 'ISL-0001', data: '2026-08-22', eta: 35, + risposte: { + q_ore_sonno: 8, q_riposato: 8, q_min_addorm: 10, q_risvegli: 0, q_caffeina: 0, q_sonnolenza_diurna: 0, + q_attivita: 5, + }, + }); + const prima = calcolaSessione(db, 1); + expect(prima.assi['Stile di Vita & Sonno'].stato).toBe('ok'); + db.prepare(`UPDATE registro_test SET attivo_a = '2026-01-01' WHERE test_id = 'q_attivita'`).run(); + const dopo = calcolaSessione(db, 1); + expect(dopo.assi['Stile di Vita & Sonno'].stato).toBe('insufficiente'); + expect(JSON.stringify(prima)).not.toBe(JSON.stringify(dopo)); + }); +}); + +describe('calcolo e congelamento di una sessione', () => { + it('produce i sette assi, e quelli senza dati sono insufficienti', () => { + const db = dbPronto(); + salvaCompilazione(db, { + client_code: 'ISL-0001', data: '2026-08-22', eta: 35, + risposte: { q_ore_sonno: 8, q_riposato: 8, q_min_addorm: 10, q_risvegli: 0, q_caffeina: 0, q_sonnolenza_diurna: 1 }, + }); + const r = calcolaSessione(db, 1); + expect(Object.keys(r.assi).length).toBe(7); + // il questionario copre il sonno, che pesa 0.20 dentro Recupero: sotto il 40% + expect(r.assi['Recupero & Sistema Nervoso'].stato).toBe('insufficiente'); + // Stile di Vita e coperto al 100% dal solo questionario, ma qui non abbiamo risposto + expect(r.assi['Forza & Struttura'].stato).toBe('insufficiente'); + }); + + it('congela i punteggi con le DUE versioni', () => { + const db = dbPronto(); + salvaCompilazione(db, { + client_code: 'ISL-0001', data: '2026-08-22', eta: 35, + risposte: { q_riposato: 7 }, + }); + salvaScore(db, 1, calcolaSessione(db, 1)); + const righe = db.prepare(`SELECT tipo, elemento, valore, stato, quest_version, model_version FROM score`).all() as Record[]; + expect(righe.length).toBeGreaterThan(0); + for (const r of righe) { + expect(r.model_version).toBe(MODEL_VERSION); + expect(r.quest_version).toBe('v1.0'); + if (r.stato === 'insufficiente') expect(r.valore).toBeNull(); + } + }); + + it('i pesi arrivano dal registro e sono quelli della versione richiesta', () => { + const db = dbPronto(); + const pesi = pesiDi(db, MODEL_VERSION, 'asse', 'Forza & Struttura'); + expect(pesi.handgrip).toBe(0.25); + expect(Object.values(pesi).reduce((a, b) => a + b, 0)).toBeCloseTo(1, 6); + }); +}); From ac8b377f84e6b9dfb47a5798be04ed02f7830cc2 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 22 Aug 2026 10:02:59 +0200 Subject: [PATCH 36/62] longevity test: motivazione corretta per il caso 'test disattivato', e media verificata numericamente MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Il commento del test 'un test disattivato non entra nel calcolo' diceva che q_attivita fosse l'unica voce del sotto-dominio questionario_lifestyle: falso, ne ha sei (q_attivita, q_alimentazione, q_sigarette, q_alcol_life, q_luce, q_schermi). Il motivo vero per cui il test funziona e' che il fixture ne compila solo una delle sei, quindi disattivarla svuota il sotto-dominio. Corretto il commento a dire il motivo vero, con nota su cosa succederebbe se il fixture cambiasse. Aggiunto anche il test mancante sulla regola della media: q_attivita(3)=65 e q_alimentazione(6)=60, stesso sotto-dominio, verificano che il valore dell'asse "Stile di Vita & Sonno" sia la loro media (62.5), diversa sia dalla somma (125) sia da ciascun addendo (65, 60) — nessun test esistente controllava il VALORE della media, solo lo stato. --- tests/longevity/motore.test.ts | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/tests/longevity/motore.test.ts b/tests/longevity/motore.test.ts index 6f674d0..32e6266 100644 --- a/tests/longevity/motore.test.ts +++ b/tests/longevity/motore.test.ts @@ -49,10 +49,17 @@ describe('il motore legge le curve dal registro', () => { // ancora una voce nel registro): siccome un asse insufficiente non porta // 'valore' nel tipo Punteggio, il JSON prima/dopo risultava IDENTICO — la // suite lo ha confermato (verificato eseguendo il test: fallisce con le due - // stringhe uguali). Corretto qui usando 'q_attivita', l'unica voce del - // sotto-dominio 'questionario_lifestyle' che pesa 1.00 dentro "Stile di Vita - // & Sonno": disattivarla fa cadere la copertura di quell'asse da 1.00 a 0 e - // lo stato passa da 'ok' a 'insufficiente' — una differenza vera e osservabile. + // stringhe uguali). Corretto qui usando 'q_attivita': NON e' l'unica voce + // del sotto-dominio 'questionario_lifestyle' (ne ha sei: q_attivita, + // q_alimentazione, q_sigarette, q_alcol_life, q_luce, q_schermi), ma in + // QUESTO fixture e' l'unica delle sei a cui si risponde. Disattivarla + // svuota davvero il sotto-dominio (nessun'altra voce lifestyle compilata a + // sostituirla), la copertura dell'asse "Stile di Vita & Sonno" (peso 1.00 + // su quel solo sotto-dominio) cade da 1.00 a 0 e lo stato passa da 'ok' a + // 'insufficiente' — una differenza vera e osservabile. Se in futuro il + // fixture compilasse anche un'altra risposta lifestyle, questo test + // andrebbe aggiornato: la disattivazione non svuoterebbe piu' il + // sotto-dominio da sola. const db = dbPronto(); salvaCompilazione(db, { client_code: 'ISL-0001', data: '2026-08-22', eta: 35, @@ -68,6 +75,25 @@ describe('il motore legge le curve dal registro', () => { expect(dopo.assi['Stile di Vita & Sonno'].stato).toBe('insufficiente'); expect(JSON.stringify(prima)).not.toBe(JSON.stringify(dopo)); }); + + it('due test dello stesso sotto-dominio danno la MEDIA dei punteggi, non la somma ne un singolo valore', () => { + // q_attivita e q_alimentazione condividono il sotto-dominio 'questionario_lifestyle', + // che pesa 1.00 (unico elemento) dentro l'asse "Stile di Vita & Sonno": il valore + // dell'asse coincide col valore del sotto-dominio, quindi la media si legge diretta. + const db = dbPronto(); + salvaCompilazione(db, { + client_code: 'ISL-0001', data: '2026-08-22', eta: 35, + // q_attivita curva incstep, steps [[0,20],[2,50],[4,80],[999,100]]: valore 3 -> + // lerp fra (2,50) e (4,80) a meta' strada -> punteggio 65. + // q_alimentazione curva x10: valore 6 -> punteggio 60. + // media = (65+60)/2 = 62.5: diversa dalla somma (125) e da ciascun addendo (65, 60). + risposte: { q_attivita: 3, q_alimentazione: 6 }, + }); + const r = calcolaSessione(db, 1); + const asse = r.assi['Stile di Vita & Sonno']; + expect(asse.stato).toBe('ok'); + if (asse.stato === 'ok') expect(asse.valore).toBe(62.5); + }); }); describe('calcolo e congelamento di una sessione', () => { From d22f88ad71b979076e94954f5d91ec40d6d8bc9c Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 22 Aug 2026 10:07:28 +0200 Subject: [PATCH 37/62] longevity: la prova sul profilo con una sola area misurata Task 6 - la prova sul caso reale documentato dal cliente (dati sintetici, nessuna persona reale). Il test smontava un difetto nel task 5: RisultatoSessione esponeva fitnessAge come Punteggio invece che number|null (come lo produce calcolaFitnessAge in cascata.ts), quindi un fitnessAge insufficiente non era mai `null`. Corretto in motore/index.ts, con fitnessAgeCopertura a fianco per non perdere l'informazione che salvaScore scrive in `score`. --- src/lib/longevity/motore/index.ts | 22 +++++++---- tests/longevity/motore-caso-reale.test.ts | 46 +++++++++++++++++++++++ 2 files changed, 61 insertions(+), 7 deletions(-) create mode 100644 tests/longevity/motore-caso-reale.test.ts diff --git a/src/lib/longevity/motore/index.ts b/src/lib/longevity/motore/index.ts index f68bb12..cd53bd3 100644 --- a/src/lib/longevity/motore/index.ts +++ b/src/lib/longevity/motore/index.ts @@ -65,11 +65,19 @@ export function applicaCurva(voce: VoceRegistro, valore: number): number | null } } -/** Esito del calcolo di una sessione: i sette assi, i tre macro-score, la Fitness Age. */ +/** + * Esito del calcolo di una sessione: i sette assi, i tre macro-score, la Fitness Age. + * `fitnessAge` è `number | null` — non `Punteggio` come gli assi e i macro-score — perché + * e' cosi' che la produce `calcolaFitnessAge` in `cascata.ts` (l'oracolo del cliente la + * tratta come un numero singolo, non come un contenitore stato/valore): `null` quando il + * composito e' insufficiente. `fitnessAgeCopertura` porta la copertura del composito, che + * altrimenti andrebbe persa: serve a `salvaScore` per scrivere la riga 'fitness_age'. + */ export type RisultatoSessione = { assi: Record; macro: Record; - fitnessAge: Punteggio; + fitnessAge: number | null; + fitnessAgeCopertura: number; modelVersion: string; questVersion: string | null; }; @@ -152,12 +160,9 @@ export function calcolaSessione(db: Database.Database, sessioneId: number): Risu pesiDi(db, MODEL_VERSION, 'fitness_age', 'fitness_age'), vociFitnessAge ); - const fitnessAgePunteggio: Punteggio = composito.stato === 'insufficiente' - ? { stato: 'insufficiente', copertura: composito.copertura } - : { stato: 'ok', valore: fitnessAge as number, copertura: composito.copertura }; return { - assi, macro, fitnessAge: fitnessAgePunteggio, + assi, macro, fitnessAge, fitnessAgeCopertura: composito.copertura, modelVersion: MODEL_VERSION, questVersion: sessione.quest_version, }; } @@ -189,7 +194,10 @@ export function salvaScore(db: Database.Database, sessioneId: number, risultato: const tx = db.transaction(() => { for (const [nome, p] of Object.entries(risultato.assi)) riga('asse', nome, p); for (const [nome, p] of Object.entries(risultato.macro)) riga('macro', nome, p); - riga('fitness_age', 'fitness_age', risultato.fitnessAge); + const fitnessAgePunteggio: Punteggio = risultato.fitnessAge === null + ? { stato: 'insufficiente', copertura: risultato.fitnessAgeCopertura } + : { stato: 'ok', valore: risultato.fitnessAge, copertura: risultato.fitnessAgeCopertura }; + riga('fitness_age', 'fitness_age', fitnessAgePunteggio); }); tx(); } diff --git a/tests/longevity/motore-caso-reale.test.ts b/tests/longevity/motore-caso-reale.test.ts new file mode 100644 index 0000000..1e00659 --- /dev/null +++ b/tests/longevity/motore-caso-reale.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { createLongevityDb } from '../../src/lib/longevity/db'; +import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro'; +import { apriSessione, registraMisure } from '../../src/lib/longevity/misure'; +import { calcolaSessione } from '../../src/lib/longevity/motore'; + +/** + * Riproduce la situazione descritta nella documentazione del cliente: una persona di cui + * si conosce solo la composizione corporea, misurata dalla Wellness Tower. Un asse pieno, + * tutti gli altri scoperti. È il caso in cui un motore ingenuo mostrerebbe sei zeri. + * Dati sintetici: nessuna persona reale. + */ +describe('un profilo con una sola area misurata', () => { + it('mostra l asse coperto e dichiara insufficienti gli altri sei', () => { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + seedPesi(db, MODEL_VERSION); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','M')`).run(); + + // i tre test di composizione esistono nel registro solo dopo il piano degli import: + // finche non ci sono, questo test dimostra il comportamento con zero misure fisiche + const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-22', tipo: 'checkup', eta_alla_data: 38 }); + registraMisure(db, s, 'questionario', []); + + const r = calcolaSessione(db, s); + const insufficienti = Object.values(r.assi).filter((a) => a.stato === 'insufficiente').length; + expect(insufficienti).toBe(7); + expect(r.fitnessAge).toBeNull(); + }); + + it('nessun asse insufficiente porta con se un valore da mostrare per sbaglio', () => { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + seedPesi(db, MODEL_VERSION); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0002','F')`).run(); + const s = apriSessione(db, { client_code: 'ISL-0002', data: '2026-08-22', tipo: 'checkup', eta_alla_data: 35 }); + registraMisure(db, s, 'questionario', []); + + const r = calcolaSessione(db, s); + for (const [nome, asse] of Object.entries(r.assi)) { + if (asse.stato === 'insufficiente') { + expect((asse as { valore?: number }).valore, `${nome} espone un valore`).toBeUndefined(); + } + } + }); +}); From c4524e3a7385125d7c5adf779ab51ea1c5c6e974 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 22 Aug 2026 10:49:12 +0200 Subject: [PATCH 38/62] schema: la tabella score rifiuta un valore sotto uno stato che lo nega MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CHECK ((stato='ok' AND valore NOT NULL) OR (stato='insufficiente' AND valore NULL)): il tipo Punteggio protegge il codice, non la tabella, e l'interfaccia leggerà 'score' direttamente. Idempotente sui database nuovi (CREATE TABLE IF NOT EXISTS); un longevity.db già esistente NON riceve il vincolo, SQLite non lo permette via ALTER TABLE. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XhLbMQ1q7wHwJSykgXRwQF --- src/lib/longevity/db.ts | 10 +++++++++- tests/longevity/db.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/lib/longevity/db.ts b/src/lib/longevity/db.ts index 21a0166..bf66c34 100644 --- a/src/lib/longevity/db.ts +++ b/src/lib/longevity/db.ts @@ -84,7 +84,15 @@ CREATE TABLE IF NOT EXISTS score ( stato TEXT NOT NULL CHECK (stato IN ('ok','insufficiente')), quest_version TEXT, model_version TEXT NOT NULL, - calcolato_at TEXT NOT NULL DEFAULT (datetime('now')) + calcolato_at TEXT NOT NULL DEFAULT (datetime('now')), + -- Il tipo Punteggio protegge il codice ma non la tabella: l'interfaccia leggera' + -- 'score' direttamente, dove quel tipo non c'e' piu'. Questo vincolo lo riscrive + -- a livello di dato: o lo stato e' 'ok' e il valore c'e', o e' 'insufficiente' e + -- il valore e' NULL, mai un numero scritto sotto uno stato che lo nega. + -- SQLite non permette di aggiungere un CHECK con ALTER TABLE a una tabella gia' + -- creata: un database longevity.db esistente, creato prima di questa modifica, + -- NON avra' questo vincolo finche' non viene ricreato. + CHECK ((stato = 'ok' AND valore IS NOT NULL) OR (stato = 'insufficiente' AND valore IS NULL)) ); CREATE INDEX IF NOT EXISTS idx_score_cliente ON score (client_code, calcolato_at DESC); `; diff --git a/tests/longevity/db.test.ts b/tests/longevity/db.test.ts index 959adb2..dcd5d33 100644 --- a/tests/longevity/db.test.ts +++ b/tests/longevity/db.test.ts @@ -43,6 +43,33 @@ describe('schema longevity', () => { expect(row.stato).toBe('insufficiente'); }); + it('la tabella rifiuta uno score insufficiente CON un valore scritto', () => { + // Il tipo Punteggio protegge il codice, ma l'interfaccia legge dalla tabella, + // dove il tipo non c'e' piu': senza questo vincolo si potrebbe scrivere un + // numero dichiarato "insufficiente" e chi legge crud dalla tabella lo prenderebbe. + const db = createLongevityDb(':memory:'); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run(); + db.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES ('ISL-0001', '2026-08-21', 'checkup')`).run(); + expect(() => + db.prepare( + `INSERT INTO score (client_code, sessione_id, tipo, elemento, valore, copertura, stato, model_version) + VALUES ('ISL-0001', 1, 'asse', 'Forza & Struttura', 42, 0.2, 'insufficiente', 'v1.0')` + ).run() + ).toThrow(); + }); + + it('la tabella rifiuta uno score ok SENZA valore', () => { + const db = createLongevityDb(':memory:'); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run(); + db.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES ('ISL-0001', '2026-08-21', 'checkup')`).run(); + expect(() => + db.prepare( + `INSERT INTO score (client_code, sessione_id, tipo, elemento, valore, copertura, stato, model_version) + VALUES ('ISL-0001', 1, 'asse', 'Forza & Struttura', NULL, 1, 'ok', 'v1.0')` + ).run() + ).toThrow(); + }); + it('identity tiene la persona, longevity non la conosce', () => { const id = createIdentityDb(':memory:'); const cols = (id.prepare(`PRAGMA table_info(clienti)`).all() as { name: string }[]).map((c) => c.name); From 469251366ec0e4234ac5588bfb0ec9db898a26be Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 22 Aug 2026 10:49:25 +0200 Subject: [PATCH 39/62] motore: chiude i tre buchi laterali della barriera dell'insufficiente MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - eta_alla_data NULL non usa più il ripiego a zero: senza età la Fitness Age è null, non un'età di forma negativa dichiarata valida. Gli assi e i macro-score restano calcolabili (non dipendono dall'età). - applicaCurva valida i parametri richiesti da ciascuna curva prima di applicarla: un registro con params vuoti/incompleti dà null, mai il punteggio pieno che dava lerp con estremi indefiniti. - calcolaSessione esclude le misure fuori_range=1 (§9 della spec): non entrano nello score finché non esiste la colonna di conferma (§12, punto aperto). - leggiScore(db, sessioneId): legge la tabella score ritipata come Punteggio, con l'ultimo calcolo per tipo+elemento esplicito nella query (MAX(id)), non l'ordine naturale delle righe — salvaScore non sovrascrive di proposito (la storia degli score si tiene, discende dal congelamento della §6). - documentata la seconda divergenza dall'oracolo, mai scritta finora: la Fitness Age esclude gli assi insufficienti, l'oracolo li include comunque. Comportamento giusto, ma cambia il numero (25,0 contro 34,8 sullo stesso profilo parziale). - nuovo test che prova che i pesi di due model_version diverse restano separati (pesiDi filtra su model_version, non li fonde). Ogni fix verificato in TDD (test rosso prima, verde dopo) e ri-rotto a mano per confermare che discrimina davvero (vedi report). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XhLbMQ1q7wHwJSykgXRwQF --- src/lib/longevity/motore/cascata.ts | 45 ++++++-- src/lib/longevity/motore/index.ts | 110 +++++++++++++++++- tests/longevity/motore.test.ts | 171 +++++++++++++++++++++++++++- 3 files changed, 312 insertions(+), 14 deletions(-) diff --git a/src/lib/longevity/motore/cascata.ts b/src/lib/longevity/motore/cascata.ts index f930bf5..32d0867 100644 --- a/src/lib/longevity/motore/cascata.ts +++ b/src/lib/longevity/motore/cascata.ts @@ -10,14 +10,31 @@ * peso si ridistribuisce su quelli disponibili; se la copertura di peso * disponibile scende sotto `COPERTURA_MINIMA`, l'elemento e' insufficiente. * - * UNICA DIVERGENZA VOLUTA dall'oracolo: 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. Tutto il resto e' fedele all'oracolo. + * 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'; @@ -96,9 +113,17 @@ export function calcolaMacro(pesiMacro: Record, assi: Record, voci: Record ): { fitnessAge: number | null; composito: Punteggio } { @@ -107,7 +132,7 @@ export function calcolaFitnessAge( peso, })); const composito = aggrega(vociPesate); - if (composito.stato === 'insufficiente') { + if (composito.stato === 'insufficiente' || etaAnagrafica === null) { return { fitnessAge: null, composito }; } const fitnessAge = arrotonda1(etaAnagrafica - (composito.valore - 50) * 0.4); diff --git a/src/lib/longevity/motore/index.ts b/src/lib/longevity/motore/index.ts index cd53bd3..a8004f1 100644 --- a/src/lib/longevity/motore/index.ts +++ b/src/lib/longevity/motore/index.ts @@ -19,6 +19,19 @@ * `salvaScore` congela il risultato nella tabella `score`, una riga per * asse/macro/Fitness Age, con `valore` a `null` quando lo stato è * insufficiente (il tipo `Punteggio` non porta un `valore` in quel ramo). + * `leggiScore` fa il percorso inverso — legge `score` e la ritipa come + * `Punteggio` — perché nessun altro punto del codice deve fare quella query + * a mano su una colonna nullable. + * + * Tre barriere aggiunte in revisione, tutte nella direzione "dato insufficiente + * → null, mai un numero fasullo": l'età mancante (nullable nello schema) non + * usa più un ripiego a zero — senza età la Fitness Age è `null`, non un'età di + * forma negativa; `applicaCurva` valida i parametri della curva prima di + * applicarla, e restituisce `null` se il registro (dato modificabile senza + * migrazioni) ha una riga incompleta, invece del punteggio pieno che dava + * `lerp` con estremi indefiniti; e la lettura delle misure esclude + * `fuori_range = 1` (§9 della spec), in attesa della colonna di conferma + * (punto aperto in §12). */ import type Database from 'better-sqlite3'; @@ -36,15 +49,51 @@ type ParamsCurva = { steps?: [number, number][]; zeroVal?: number; }; +/** Vero solo per un numero finito: esclude `undefined`, `NaN`, stringhe, ecc. */ +function numeroValido(x: unknown): x is number { + return typeof x === 'number' && Number.isFinite(x); +} + +/** + * Verifica che `params` contenga tutto cio' che la curva dichiarata richiede, + * PRIMA di passarlo alla funzione di curva. Il registro e' dato modificabile + * senza migrazioni (la promessa della spec, §5): una riga con `params` vuoti + * o incompleti deve fermarsi qui, non arrivare a `lerp` con estremi + * indefiniti - dove `x1 === x0` (`undefined === undefined`) restituisce il + * punteggio pieno, la peggior direzione di guasto possibile per un motore + * clinico. + */ +function paramsValidi(curva: VoceRegistro['curva'], p: ParamsCurva): boolean { + switch (curva) { + case 'bell': + return numeroValido(p.low) && numeroValido(p.peakLow) && numeroValido(p.peakHigh) && numeroValido(p.high); + case 'lin_dec': + return numeroValido(p.best) && numeroValido(p.worst); + case 'inc_plateau': + return numeroValido(p.worst) && numeroValido(p.plateauStart); + case 'x10': + case 'x10_inv': + return true; // nessun parametro richiesto + case 'decstep': + case 'incstep': + return Array.isArray(p.steps) && p.steps.length > 0 && + p.steps.every((s) => Array.isArray(s) && s.length === 2 && numeroValido(s[0]) && numeroValido(s[1])); + default: + return false; + } +} + /** * Normalizza un valore grezzo secondo la curva dichiarata nel registro per * quel test (`voce.curva` + `voce.params`), non secondo una curva scritta nel * motore. Un test senza curva dichiarata restituisce `null`: non contribuisce - * al calcolo di sotto-dominio. + * al calcolo di sotto-dominio. Lo stesso vale per una curva coi parametri + * mancanti o incompleti: mai un punteggio inventato (vedi `paramsValidi`). */ export function applicaCurva(voce: VoceRegistro, valore: number): number | null { if (!voce.curva) return null; const p = (voce.params ?? {}) as ParamsCurva; + if (!paramsValidi(voce.curva, p)) return null; switch (voce.curva) { case 'bell': return curvaCampana(valore, p.low as number, p.peakLow as number, p.peakHigh as number, p.high as number); @@ -94,8 +143,12 @@ export function calcolaSessione(db: Database.Database, sessioneId: number): Risu if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`); const attivi = testAttivi(db, sessione.data); + // fuori_range = 1 esclusa: §9 della spec, "non entrano nello score finche' qualcuno + // non le conferma". La colonna della conferma non esiste ancora (punto aperto in + // §12 della spec) - quando ci sara', questo filtro andra' rilassato per lasciar + // passare le misure confermate, non tolto del tutto. const misurate = new Map( - (db.prepare(`SELECT test_id, valore_num FROM misure WHERE sessione_id = ?`).all(sessioneId) as + (db.prepare(`SELECT test_id, valore_num FROM misure WHERE sessione_id = ? AND fuori_range = 0`).all(sessioneId) as { test_id: string; valore_num: number | null }[]) .filter((m) => m.valore_num !== null) .map((m) => [m.test_id, m.valore_num as number]) @@ -156,7 +209,7 @@ export function calcolaSessione(db: Database.Database, sessioneId: number): Risu stabilita: valoreAsse('Stabilità & Mobilità Funzionale'), }; const { fitnessAge, composito } = calcolaFitnessAge( - sessione.eta_alla_data ?? 0, + sessione.eta_alla_data, pesiDi(db, MODEL_VERSION, 'fitness_age', 'fitness_age'), vociFitnessAge ); @@ -201,3 +254,54 @@ export function salvaScore(db: Database.Database, sessioneId: number, risultato: }); tx(); } + +/** Esito di `leggiScore`: gli assi, i macro-score e la Fitness Age, tipati come `Punteggio`. */ +export type PunteggiSessione = { + assi: Record; + macro: Record; + /** `null` = nessun calcolo mai salvato per questa sessione (non un composito insufficiente). */ + fitnessAge: Punteggio | null; +}; + +/** + * Legge i punteggi congelati di una sessione dalla tabella `score`, tipati come + * `Punteggio` — la stessa unione discriminata del resto del motore. Nessuno + * legge ancora `score` (la scrive solo `salvaScore`): la prima query cruda che + * l'interfaccia scriverà su una colonna nullable perderebbe la barriera del + * tipo se restasse un semplice `SELECT *`. Qui non la perde. + * + * `salvaScore` non ha vincolo di unicità di proposito — la storia si tiene, + * non si sovrascrive (discende dal congelamento della §6: sovrascrivere + * cancellerebbe cio' che il congelamento protegge). Una sessione ricalcolata + * più volte ha quindi più righe per lo stesso tipo/elemento: questa query + * dichiara ESPLICITAMENTE il criterio dell'ultimo calcolo (il `MAX(id)` per + * tipo+elemento, non l'ordine naturale con cui SQLite restituisce le righe, + * che non è un criterio). + */ +export function leggiScore(db: Database.Database, sessioneId: number): PunteggiSessione { + const righe = db.prepare( + `SELECT s.tipo, s.elemento, s.valore, s.copertura, s.stato + FROM score s + JOIN ( + SELECT tipo, elemento, MAX(id) AS ultimo_id + FROM score + WHERE sessione_id = ? + GROUP BY tipo, elemento + ) ultimo ON ultimo.tipo = s.tipo AND ultimo.elemento = s.elemento AND ultimo.ultimo_id = s.id + WHERE s.sessione_id = ?` + ).all(sessioneId, sessioneId) as + { tipo: 'asse' | 'macro' | 'fitness_age'; elemento: string; valore: number | null; copertura: number; stato: 'ok' | 'insufficiente' }[]; + + const aPunteggio = (r: { valore: number | null; copertura: number; stato: 'ok' | 'insufficiente' }): Punteggio => + r.stato === 'ok' ? { stato: 'ok', valore: r.valore as number, copertura: r.copertura } : { stato: 'insufficiente', copertura: r.copertura }; + + const assi: Record = {}; + const macro: Record = {}; + let fitnessAge: Punteggio | null = null; + for (const r of righe) { + if (r.tipo === 'asse') assi[r.elemento] = aPunteggio(r); + else if (r.tipo === 'macro') macro[r.elemento] = aPunteggio(r); + else fitnessAge = aPunteggio(r); + } + return { assi, macro, fitnessAge }; +} diff --git a/tests/longevity/motore.test.ts b/tests/longevity/motore.test.ts index 32e6266..764e122 100644 --- a/tests/longevity/motore.test.ts +++ b/tests/longevity/motore.test.ts @@ -2,7 +2,9 @@ import { describe, it, expect } from 'vitest'; import { createLongevityDb } from '../../src/lib/longevity/db'; import { seedRegistro, seedPesi, MODEL_VERSION, pesiDi } from '../../src/lib/longevity/registro'; import { salvaCompilazione } from '../../src/lib/longevity/questionario'; -import { applicaCurva, calcolaSessione, salvaScore } from '../../src/lib/longevity/motore'; +import { apriSessione, registraMisure } from '../../src/lib/longevity/misure'; +import { calcolaAsse } from '../../src/lib/longevity/motore/cascata'; +import { applicaCurva, calcolaSessione, salvaScore, leggiScore } from '../../src/lib/longevity/motore'; function dbPronto() { const db = createLongevityDb(':memory:'); @@ -76,6 +78,27 @@ describe('il motore legge le curve dal registro', () => { expect(JSON.stringify(prima)).not.toBe(JSON.stringify(dopo)); }); + it('una curva con i parametri mancanti nel registro non calcola un punteggio: restituisce null, mai 100', () => { + // Riproduce il difetto trovato in revisione: registro modificabile senza migrazioni, + // una riga con params vuoti o incompleti non deve mai dare il punteggio pieno. + const db = dbPronto(); + // bell (q_ore_sonno): params svuotati del tutto. + const sonno = { ...voce(db, 'q_ore_sonno'), params: {} }; + expect(applicaCurva(sonno as never, 3)).toBeNull(); // 3 ore di sonno: mai 100 + // lin_dec (q_alcol_life): manca 'worst'. + const alcol = { ...voce(db, 'q_alcol_life'), params: { best: 7 } }; + expect(applicaCurva(alcol as never, 40)).toBeNull(); // 40 unita/settimana: mai 100 + // decstep (q_sigarette): steps mancanti. + const sigarette = { ...voce(db, 'q_sigarette'), params: {} }; + expect(applicaCurva(sigarette as never, 40)).toBeNull(); // 40 sigarette/giorno: mai 100 + }); + + it('una curva coi parametri completi continua a funzionare (il controllo non e troppo severo)', () => { + const db = dbPronto(); + const v = { ...voce(db, 'q_ore_sonno'), params: JSON.parse(voce(db, 'q_ore_sonno').params as string) }; + expect(applicaCurva(v as never, 8)).toBe(100); + }); + it('due test dello stesso sotto-dominio danno la MEDIA dei punteggi, non la somma ne un singolo valore', () => { // q_attivita e q_alimentazione condividono il sotto-dominio 'questionario_lifestyle', // che pesa 1.00 (unico elemento) dentro l'asse "Stile di Vita & Sonno": il valore @@ -133,4 +156,150 @@ describe('calcolo e congelamento di una sessione', () => { expect(pesi.handgrip).toBe(0.25); expect(Object.values(pesi).reduce((a, b) => a + b, 0)).toBeCloseTo(1, 6); }); + + it('senza eta la Fitness Age e null, non un numero negativo dichiarato valido', () => { + // Bug trovato in revisione: eta_alla_data e' nullable nello schema (l'apertura di + // una sessione e il salvataggio di un questionario accettano l'eta opzionale), ma + // il motore la leggeva con un ripiego a zero. Qui costruiamo un composito Fitness + // Age davvero 'ok' (copertura esattamente 0.40: cardio 0.30 + composizione 0.10), + // cosi' il difetto e' osservabile: col ripiego a zero l'eta anagrafica diventa 0 + // e la formula "eta - (composito - 50) * 0.4" esce negativa ma con stato 'ok'. + // Due voci fisiche aggiunte al registro qui (arriveranno davvero col piano degli + // import): servono solo a rendere il composito calcolabile senza aspettarle. + const db = dbPronto(); + db.prepare( + `INSERT INTO registro_test (test_id, etichetta, tipo_valore, curva, params, asse, sotto_dominio, attivo_da) + VALUES ('wt_vo2max', 'VO2max stimato', 'num', 'lin_dec', ?, 'Cardio-Respiratorio', 'vo2max', '2026-01-01')` + ).run(JSON.stringify({ best: 40, worst: 20 })); + db.prepare( + `INSERT INTO registro_test (test_id, etichetta, tipo_valore, curva, params, asse, sotto_dominio, attivo_da) + VALUES ('wt_grasso', 'Grasso % (Wellness Tower)', 'num', 'lin_dec', ?, 'Composizione Corporea', 'grasso', '2026-01-01')` + ).run(JSON.stringify({ best: 10, worst: 40 })); + + const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-22', tipo: 'checkup' }); // nessuna eta + registraMisure(db, s, 'wellness_tower', [ + { test_id: 'wt_vo2max', valore_num: 40 }, // curva a 100 + { test_id: 'wt_grasso', valore_num: 10 }, // curva a 100 + ]); + + const r = calcolaSessione(db, s); + expect(r.assi['Cardio-Respiratorio'].stato).toBe('ok'); + expect(r.assi['Composizione Corporea'].stato).toBe('ok'); + expect(r.fitnessAgeCopertura).toBe(0.4); // composito 'ok': la soglia e' inclusiva + expect(r.fitnessAge).toBeNull(); // ma senza eta anagrafica, la Fitness Age non esiste + }); + + it('una misura fuori range non entra nello score (§9 della spec)', () => { + // Caso letterale della spec: una risposta 0-10 di 999 e' fuori dal fondoscala + // dichiarato (come la capacita' vitale di 5148 mL su un fondoscala 2000-3000), + // si scrive e si marca, ma non deve produrre un punteggio. + // q_alimentazione e' l'UNICA voce del sotto-dominio 'questionario_lifestyle' + // (peso 1.00, unico elemento dell'asse "Stile di Vita & Sonno"): se la misura + // fuori range entrasse nel calcolo l'asse risulterebbe 'ok' con valore 100 + // (curvaDirettaX10(999) clampato); esclusa, il sotto-dominio resta scoperto e + // l'asse e' 'insufficiente'. E' la differenza osservabile che prova il filtro. + const db = dbPronto(); + const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-22', tipo: 'questionario', eta_alla_data: 35 }); + const esito = registraMisure(db, s, 'questionario', [{ test_id: 'q_alimentazione', valore_num: 999 }]); + expect(esito.fuoriRange).toEqual(['q_alimentazione']); + + const r = calcolaSessione(db, s); + expect(r.assi['Stile di Vita & Sonno'].stato).toBe('insufficiente'); + + const riga = db.prepare(`SELECT fuori_range FROM misure WHERE test_id = 'q_alimentazione'`).get() as { fuori_range: number }; + expect(riga.fuori_range).toBe(1); + }); + + it('scrive nella tabella score un valore vero e la copertura vera, non solo lo stato', () => { + // Sabotaggi che questo test deve intercettare: tutti i valori scritti a null, o + // tutte le coperture forzate a 1. Serve un caso con copertura NON piena (0.5, non + // 1.0) perche' un "sempre 1" non si distinguerebbe da un caso gia' pieno. + const db = dbPronto(); + salvaCompilazione(db, { + client_code: 'ISL-0001', data: '2026-08-22', eta: 35, + // q_energia_media (x10) e' l'unica voce misurata del sotto-dominio + // 'questionario_energia_stress' (peso 0.50 nell'asse, l'altro 0.50 e' hrv, + // non ancora nel registro): copertura 0.50, non 1. + risposte: { q_energia_media: 8 }, + }); + const risultato = calcolaSessione(db, 1); + salvaScore(db, 1, risultato); + const riga = db.prepare( + `SELECT valore, copertura, stato FROM score WHERE tipo = 'asse' AND elemento = ?` + ).get('Energia & Regolazione Stress') as { valore: number; copertura: number; stato: string }; + expect(riga.stato).toBe('ok'); + expect(riga.valore).toBe(80); // curvaDirettaX10(8) = 80, unico contributo + expect(riga.copertura).toBe(0.5); // 0.50 di peso disponibile su 1.00 + }); +}); + +describe('leggiScore legge dalla tabella, tipato come Punteggio', () => { + it('restituisce assi/macro/fitness_age come Punteggio: nessun valore leggibile da un insufficiente', () => { + const db = dbPronto(); + salvaCompilazione(db, { + client_code: 'ISL-0001', data: '2026-08-22', eta: 35, + risposte: { q_attivita: 3, q_alimentazione: 6 }, + }); + const risultato = calcolaSessione(db, 1); + salvaScore(db, 1, risultato); + const letto = leggiScore(db, 1); + expect(letto.assi['Stile di Vita & Sonno'].stato).toBe('ok'); + if (letto.assi['Stile di Vita & Sonno'].stato === 'ok') { + expect(letto.assi['Stile di Vita & Sonno'].valore).toBe(62.5); + } + expect(letto.assi['Forza & Struttura'].stato).toBe('insufficiente'); + expect((letto.assi['Forza & Struttura'] as { valore?: number }).valore).toBeUndefined(); + }); + + it('due calcoli sulla stessa sessione non si sovrascrivono: leggiScore prende l ultimo', () => { + // Decisione presa: salvaScore non ha vincolo di unicita', la storia si tiene. + // leggiScore deve dichiarare esplicitamente nella query il criterio dell'ultimo + // calcolo (non l'ordine naturale delle righe). + const db = dbPronto(); + salvaCompilazione(db, { + client_code: 'ISL-0001', data: '2026-08-22', eta: 35, + risposte: { q_attivita: 3, q_alimentazione: 6 }, // -> 62.5 + }); + salvaScore(db, 1, calcolaSessione(db, 1)); + + // Aggiunge una risposta lifestyle: cambia la media del sotto-dominio, ricalcola + // e salva una SECONDA volta sulla stessa sessione. + registraMisure(db, 1, 'questionario', [{ test_id: 'q_sigarette', valore_num: 0 }]); // curva decstep, v=0 -> 100 + salvaScore(db, 1, calcolaSessione(db, 1)); + + const righe = db.prepare( + `SELECT COUNT(*) n FROM score WHERE sessione_id = 1 AND tipo = 'asse' AND elemento = 'Stile di Vita & Sonno'` + ).get() as { n: number }; + expect(righe.n).toBe(2); // la storia si tiene: due righe, non una sovrascritta + + const letto = leggiScore(db, 1); + const asse = letto.assi['Stile di Vita & Sonno']; + expect(asse.stato).toBe('ok'); + // media di tre voci ora (65+60+100)/3 = 75, non piu' 62.5 del primo calcolo + if (asse.stato === 'ok') expect(asse.valore).toBe(75); + }); +}); + +describe('i pesi sono legati alla versione del modello, non fusi insieme', () => { + it('togliendo il filtro sulla versione dalla lettura dei pesi, due versioni diverse darebbero lo stesso punteggio: qui devono differire', () => { + const db = createLongevityDb(':memory:'); + seedPesi(db, 'v1.0'); + // Seconda versione del modello, con pesi DIVERSI per lo stesso asse. + const ins = db.prepare( + `INSERT INTO pesi (model_version, livello, contenitore, elemento, peso) VALUES (?, 'asse', 'Forza & Struttura', ?, ?)` + ); + ins.run('v2.0', 'handgrip', 0.9); + ins.run('v2.0', 'core', 0.1); + + const punteggi = { handgrip: 100, spinta: 0, trazione: 0, arti_inferiori: 0, core: 0 }; + const v1 = calcolaAsse(pesiDi(db, 'v1.0', 'asse', 'Forza & Struttura'), punteggi); + const v2 = calcolaAsse(pesiDi(db, 'v2.0', 'asse', 'Forza & Struttura'), punteggi); + expect(v1.stato).toBe('ok'); + expect(v2.stato).toBe('ok'); + if (v1.stato === 'ok' && v2.stato === 'ok') { + expect(v1.valore).toBe(25); // 100*0.25 (v1.0) + expect(v2.valore).toBe(90); // 100*0.9 (v2.0) + expect(v1.valore).not.toBe(v2.valore); + } + }); }); From c1c868622f63a402a541d7cb615767b32b96fa15 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 22 Aug 2026 10:49:35 +0200 Subject: [PATCH 40/62] test: le sei tabelle dei sollevamenti sorvegliate, e il caso misto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - motore-fisici.test.ts confrontava i tiers letti dal file di riferimento, non le costanti TIERS_* del codice: ventiquattro numeri senza custode. Ora identifica la tabella dagli argomenti dell'oracolo ma usa le costanti vere per calcolare, quindi una costante alterata non combacia più con nessun caso. - motore-caso-reale.test.ts: aggiunto il caso end-to-end mancante, un asse valido (Composizione Corporea, via due righe aggiunte al registro) e sei insufficienti — oggi la suite copriva solo "tutto insufficiente". - test-fisici.ts: commento in testa che dichiara le venti curve non collegate al motore (vocabolario delle curve senza nomi per loro, applicaCurva senza modo di ricevere età/sesso) — aggancio è lavoro del piano degli import, non "una modifica ai dati" come la spec lascia intendere. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XhLbMQ1q7wHwJSykgXRwQF --- src/lib/longevity/motore/test-fisici.ts | 13 ++++++++ tests/longevity/motore-caso-reale.test.ts | 35 ++++++++++++++++++++ tests/longevity/motore-fisici.test.ts | 39 ++++++++++++++++------- 3 files changed, 76 insertions(+), 11 deletions(-) diff --git a/src/lib/longevity/motore/test-fisici.ts b/src/lib/longevity/motore/test-fisici.ts index 175ccf1..446c33c 100644 --- a/src/lib/longevity/motore/test-fisici.ts +++ b/src/lib/longevity/motore/test-fisici.ts @@ -14,6 +14,19 @@ * 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. + * + * ⚠️ 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`. */ import { clamp, lerp, arrotonda1 } from './curve'; diff --git a/tests/longevity/motore-caso-reale.test.ts b/tests/longevity/motore-caso-reale.test.ts index 1e00659..2e09e20 100644 --- a/tests/longevity/motore-caso-reale.test.ts +++ b/tests/longevity/motore-caso-reale.test.ts @@ -43,4 +43,39 @@ describe('un profilo con una sola area misurata', () => { } } }); + + /** + * Il caso misto: quello che la prima dashboard disegnera' davvero, non "tutto + * insufficiente". Realizza per intero la premessa del describe qui sopra — la + * composizione corporea davvero misurata dalla Wellness Tower — aggiungendo le + * due voci al registro (arrivano col piano degli import; qui bastano due righe, + * non serve aspettarle) e registrando le misure end-to-end. + */ + it('un asse valido (Composizione Corporea) e sei insufficienti, end-to-end', () => { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + seedPesi(db, MODEL_VERSION); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0003','M')`).run(); + + db.prepare( + `INSERT INTO registro_test (test_id, etichetta, tipo_valore, curva, params, asse, sotto_dominio, attivo_da) + VALUES ('wt_grasso', 'Grasso % (Wellness Tower)', 'num', 'lin_dec', ?, 'Composizione Corporea', 'grasso', '2026-01-01')` + ).run(JSON.stringify({ best: 10, worst: 40 })); + db.prepare( + `INSERT INTO registro_test (test_id, etichetta, tipo_valore, curva, params, asse, sotto_dominio, attivo_da) + VALUES ('wt_muscolo', 'Muscolo % (Wellness Tower)', 'num', 'lin_dec', ?, 'Composizione Corporea', 'muscolo', '2026-01-01')` + ).run(JSON.stringify({ best: 50, worst: 30 })); + + const s = apriSessione(db, { client_code: 'ISL-0003', data: '2026-08-22', tipo: 'checkup', eta_alla_data: 38 }); + registraMisure(db, s, 'wellness_tower', [ + { test_id: 'wt_grasso', valore_num: 15 }, + { test_id: 'wt_muscolo', valore_num: 45 }, + ]); + + const r = calcolaSessione(db, s); + expect(r.assi['Composizione Corporea'].stato).toBe('ok'); + const insufficienti = Object.entries(r.assi) + .filter(([nome, a]) => nome !== 'Composizione Corporea' && a.stato === 'insufficiente'); + expect(insufficienti.length).toBe(6); + }); }); diff --git a/tests/longevity/motore-fisici.test.ts b/tests/longevity/motore-fisici.test.ts index 5e885c9..29646eb 100644 --- a/tests/longevity/motore-fisici.test.ts +++ b/tests/longevity/motore-fisici.test.ts @@ -47,21 +47,38 @@ describe('curve dei test fisici, confrontate con l oracolo caso per caso', () => }); } - it('i sollevamenti sul peso corporeo combaciano su tutte e tre le tabelle', () => { + it('i sollevamenti sul peso corporeo combaciano su tutte e tre le tabelle DEFINITE NEL CODICE', () => { + // Prima versione di questo test: passava alla funzione le tabelle lette dagli + // argomenti del riferimento (c.args), non le costanti TIERS_* definite nel + // codice — le sei tabelle non erano confrontate contro l'oracolo da nessuno. + // Qui si usano SOLO le costanti del modulo: se una venisse alterata, nessun + // caso combacerebbe piu' con gli argomenti del riferimento e il test cade. 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]; + + const TABELLE: [string, [number, number][], [number, number][]][] = [ + ['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], + ]; + const perTabella: Record = { bench: 0, squat: 0, row: 0 }; + 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); + const [carico, peso, rip, tiersMRif, , sesso] = c.args as [number, number, number, unknown, unknown, 'M' | 'F']; + // Identifica QUALE tabella del codice questo caso sta esercitando confrontando + // gli argomenti del riferimento con le costanti vere — non il contrario. + const trovata = TABELLE.find(([, tm]) => JSON.stringify(tm) === JSON.stringify(tiersMRif)); + expect(trovata, `nessuna tabella del codice combacia con gli argomenti del caso: carico ${carico} ${sesso}`).toBeDefined(); + const [nome, tiersM, tiersF] = trovata!; + perTabella[nome]++; + + const ottenuto = F.scoreSollevamentoSuPeso(carico, peso, rip, tiersM, tiersF, sesso); + expect(ottenuto, `${nome} carico ${carico} ${sesso}`).toBeCloseTo(c.atteso, 1); } - expect(perTabella('bench')).toBeTruthy(); + // Tutte e tre le tabelle sono state esercitate davvero, non solo dichiarate. + expect(perTabella.bench).toBeGreaterThan(0); + expect(perTabella.squat).toBeGreaterThan(0); + expect(perTabella.row).toBeGreaterThan(0); }); it('le due funzioni escluse dal porting non esistono nel modulo', () => { From 82a4cd20004500541dd614387ff213c7eccb872e Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 22 Aug 2026 10:49:42 +0200 Subject: [PATCH 41/62] test: chiude l'errore di tipi pre-esistente in middleware.test.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cast di FakeContext a onRequest via 'unknown' invece che diretto: i due tipi non si sovrappongono abbastanza per tsc (FakeContext è un doppio minimale, non un APIContext completo). Il typecheck è il meccanismo di sicurezza di questo motore: uno rosso in permanenza è un cancello che nessuno guarda più. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XhLbMQ1q7wHwJSykgXRwQF --- tests/longevity/middleware.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/longevity/middleware.test.ts b/tests/longevity/middleware.test.ts index 1430b9c..ec6b11a 100644 --- a/tests/longevity/middleware.test.ts +++ b/tests/longevity/middleware.test.ts @@ -38,7 +38,11 @@ describe('src/middleware.ts — protezione reale delle rotte longevity', () => { beforeAll(async () => { const mod = await import('../../src/middleware.ts'); - onRequest = mod.onRequest as typeof onRequest; + // FakeContext è un doppio minimale di APIContext (solo cio' che onRequest usa + // davvero: url, cookies, redirect, locals), non un APIContext completo: il cast + // diretto viene rifiutato dal typecheck perche' i due tipi non si sovrappongono + // abbastanza. Si passa da 'unknown' perche' e' un cast intenzionale, non un bug. + onRequest = mod.onRequest as unknown as typeof onRequest; }); it('una rotta longevity senza sessione (nessun cookie) redirige al login e non serve la pagina', async () => { From 42a039ae6b5468df82ec370cb8d8cad26b4e7089 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 22 Aug 2026 11:01:45 +0200 Subject: [PATCH 42/62] schema: il limite del vincolo e' la migrazione che non c'e', non SQLite --- src/lib/longevity/db.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/lib/longevity/db.ts b/src/lib/longevity/db.ts index bf66c34..62737f1 100644 --- a/src/lib/longevity/db.ts +++ b/src/lib/longevity/db.ts @@ -89,9 +89,16 @@ CREATE TABLE IF NOT EXISTS score ( -- 'score' direttamente, dove quel tipo non c'e' piu'. Questo vincolo lo riscrive -- a livello di dato: o lo stato e' 'ok' e il valore c'e', o e' 'insufficiente' e -- il valore e' NULL, mai un numero scritto sotto uno stato che lo nega. - -- SQLite non permette di aggiungere un CHECK con ALTER TABLE a una tabella gia' - -- creata: un database longevity.db esistente, creato prima di questa modifica, - -- NON avra' questo vincolo finche' non viene ricreato. + -- ATTENZIONE al limite, che non e' quello che sembra: lo schema qui sopra gira con + -- CREATE TABLE IF NOT EXISTS, che su un file gia' esistente e' un'operazione a vuoto. + -- Quindi un longevity.db creato prima di questa modifica NON ha questo vincolo, e + -- continuera' a non averlo finche' qualcuno non lo migra a mano. + -- (Non e' un limite di SQLite: la versione imbarcata da better-sqlite3 e' la 3.53, + -- che ALTER TABLE ... ADD CONSTRAINT lo supporta. E' che qui una migrazione non c'e'. + -- Verificato il 22/08: la CLI di sistema e' la 3.37 e non lo supporta, quindi provarlo + -- da riga di comando da' un errore fuorviante.) + -- Oggi nessun longevity.db esiste ancora, quindi il caso e' teorico: lo diventa il + -- giorno del primo deploy, ed e' quello il momento di ricordarselo. CHECK ((stato = 'ok' AND valore IS NOT NULL) OR (stato = 'insufficiente' AND valore IS NULL)) ); CREATE INDEX IF NOT EXISTS idx_score_cliente ON score (client_code, calcolato_at DESC); From 0e9b081a7915c9bb271f04c8d8a76257fc690a42 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 22 Aug 2026 11:02:26 +0200 Subject: [PATCH 43/62] spec: cosa ha scoperto la costruzione del motore --- docs/specs/2026-08-21-longevity-design.md | 54 +++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/docs/specs/2026-08-21-longevity-design.md b/docs/specs/2026-08-21-longevity-design.md index db0d622..37c251a 100644 --- a/docs/specs/2026-08-21-longevity-design.md +++ b/docs/specs/2026-08-21-longevity-design.md @@ -384,6 +384,60 @@ capita. confermata. L'HRV entra nel motore come passthrough, quindi senza quel canale va inserito a mano. +## 12-bis. Cosa ha scoperto la costruzione del motore (22/08) + +Il motore è stato costruito il 22/08 (ramo `feat/longevity`, 6 task, 308 test). Il porting +è fedele: eseguendo la funzione dimostrativa del cliente sui due motori, i sette assi, i tre +macro-score e la Fitness Age **combaciano numero per numero**. + +### Due divergenze deliberate dall'oracolo, entrambe volute + +1. **Un punteggio insufficiente non espone un valore.** Nell'originale la funzione di + aggregazione restituisce il numero anche quando dichiara l'elemento insufficiente; qui il + tipo non ha proprio il campo. È ciò che rende impossibile mostrare un numero dove i dati + non bastano. +2. **La Fitness Age esclude gli assi insufficienti**, mentre l'oracolo li include comunque. + ⚠️ **Cambia il numero, e parecchio**: sullo stesso profilo parziale, 25,0 contro 34,8 — + quasi dieci anni. Chi confrontasse il nostro referto con il Python del cliente su un + profilo incompleto concluderebbe che il porting è rotto. **Da dire a Nicola.** + +### ⚠️ Le curve dei test fisici non sono collegate, e agganciarle non sarà «solo dati» + +Le venti curve dei test misurati sono portate e verificate, ma **irraggiungibili dal +motore**: il vocabolario delle curve del registro non ha nomi per loro, e la funzione che +applica una curva non può ricevere età e sesso, che quasi tutte richiedono. + +📌 Questo contraddice la §5, dove il registro è presentato come dato che si estende senza +toccare il codice. **È vero per il questionario, non per i test fisici**: agganciarli +richiede di estendere il vocabolario e la firma. Il piano degli import deve saperlo, perché +oggi crede di trovare la presa già montata. + +### Il confine dove la barriera finisce + +Il tipo protegge il codice; la tabella `score` ha ora anche un vincolo che rifiuta un valore +scritto sotto uno stato che lo nega. ⚠️ Ma quel vincolo entra solo nei database **creati da +qui in avanti**: lo schema gira con `CREATE TABLE IF NOT EXISTS`, che su un file esistente +non fa nulla. Oggi nessun database esiste ancora — diventa un problema il giorno del primo +deploy, ed è quello il momento di ricordarsene. + +### La storia dei punteggi si tiene + +Un ricalcolo **aggiunge** righe invece di sovrascriverle, coerentemente col congelamento del +§6: sovrascrivere cancellerebbe proprio ciò che il congelamento protegge. La lettura passa da +una funzione tipata che restituisce l'ultimo calcolo per sessione — la dashboard non deve +inventarsi una query sua. + +### 🔲 Una domanda per Nicola, sulla voce più pesante del modello + +`hrv` pesa **0,50 su due assi** — *Recupero* ed *Energia* — ed è la singola voce più pesante +di tutto il modello. Nel motore è un dato solo, condiviso. Ma nella funzione dimostrativa +dell'oracolo i due assi ricevono **numeri diversi sotto lo stesso nome** (68 e 65). +O è la stessa misura riusata su due assi — come il plank, che Nicola ha già confermato — e i +due numeri sono illustrativi; oppure sono due metriche distinte, e allora le stiamo fondendo +per sbaglio. Diventa concreto appena si collega Stress Index, che è la sorgente di quel dato. + +--- + ## 12. Cosa l'implementazione ha scoperto — da chiudere prima del motore Lo strato dati è stato costruito il 21/08 (ramo `feat/longevity`, 19 commit, 241 test). From 779aef32e60f114a9f5072a099f2e0a5d37448b1 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 22 Aug 2026 13:29:54 +0200 Subject: [PATCH 44/62] =?UTF-8?q?docs:=20piano=20dell'interfaccia=20?= =?UTF-8?q?=E2=80=94=20questionario=20e=20referto=20del=20cliente?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-08-22-longevity-interfaccia.md | 405 ++++++++++++++++++ 1 file changed, 405 insertions(+) create mode 100644 docs/plans/2026-08-22-longevity-interfaccia.md diff --git a/docs/plans/2026-08-22-longevity-interfaccia.md b/docs/plans/2026-08-22-longevity-interfaccia.md new file mode 100644 index 0000000..95f255c --- /dev/null +++ b/docs/plans/2026-08-22-longevity-interfaccia.md @@ -0,0 +1,405 @@ +# Longevity — l'interfaccia: piano di implementazione + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** dare una faccia al fascicolo del cliente — il questionario che si compila in pagina e il referto che il cliente legge, col radar a sette assi. + +**Architecture:** pagine Astro dentro il sito esistente, sotto `/longevity/`, con un layout dedicato e un foglio di stile prefissato. Il contenuto statico è Astro; React solo dove serve interattività vera — il questionario a blocchi e il radar. I dati arrivano dal motore già costruito, mai da query scritte nelle pagine. + +**Tech Stack:** Astro, React, recharts (già in casa), better-sqlite3, vitest. Nessuna dipendenza nuova. + +**Spec:** `docs/specs/2026-08-21-longevity-design.md` + +**Piani precedenti, già eseguiti:** `2026-08-21-longevity-strato-dati.md` e `2026-08-22-longevity-motore.md`. Lo strato dati e il motore esistono, sono testati, e nessuna pagina li usa ancora. + +## Global Constraints + +- Branch **`feat/longevity`**. `main` non si tocca, non si deploya, non si pusha senza che Adriano lo chieda. +- **Nessuna dipendenza npm nuova.** +- Test in `tests/longevity/`, eseguiti con `npm test`. Codice e commenti in italiano. +- ⚠️ **La suite parte con un rosso che non è nostro:** `tests/modifiche-agosto.test.ts` (test del sito disallineato su `main`, fuori perimetro). L'atteso è **1 fallito pre-esistente**, il resto verde. Non ripararlo. +- ⚠️ **`npx tsc --noEmit` deve restare pulito.** In questo lavoro il tipo è il meccanismo di sicurezza: un typecheck rosso è un cancello che nessuno guarda più. +- **Nessun dato reale di persone** nelle fixture. + +## Le due regole di prodotto che l'interfaccia non può violare + +**1. Un punteggio insufficiente non si mostra come numero.** Il motore restituisce un tipo che nel ramo insufficiente **non ha il campo `valore`**: la pagina non ha da dove prenderlo. Ma la barriera finisce lì — la tabella dei punteggi è letta da una funzione tipata (`leggiScore`), e **le pagine devono usare quella**, mai una query propria. Una `SELECT valore FROM score` scritta in una pagina riapre il buco. + +**2. Il colore non giudica il corpo.** Niente semaforo verde/giallo/rosso sui punteggi: il servizio è premium ma **non clinico**, e un rosso su «Composizione Corporea» detto a una persona è un giudizio, non un'informazione. I punteggi si esprimono con l'**intensità** del colore d'accento del sito. L'unico colore di segnale — il mattone `#b05a4e` già presente nel foglio di Stress Index — è riservato ai **valori fuori range**, che segnalano un problema **della misura**, non della persona. + +## Il gesto che questa interfaccia deve avere + +Il radar mostra **quello che sappiamo** come area piena, e dove la misura non basta lascia un **perimetro tratteggiato** con, sotto, cosa manca per completarlo. Non un buco, non un errore: un invito. + +Regge tre cose insieme — la regola di prodotto («tratteggiato, mai un numero pieno fasullo»), la leva commerciale che il cliente voleva per il livello avanzato, e una posizione onesta: *non ti diciamo un numero che non sappiamo*. + +⚠️ **Con i dati di oggi sei assi su sette saranno tratteggiati**, perché nel registro c'è solo il questionario e i test fisici arrivano col piano degli import. È atteso: chi guarda la prima dashboard non deve scambiarlo per un guasto. + +## Struttura dei file + +| File | Responsabilità | +|---|---| +| `src/layouts/Longevity.astro` | layout della piattaforma: intestazione, niente header pubblico | +| `src/styles/longevity.css` | stile prefissato `.lg`, globale (deve raggiungere le isole React) | +| `src/pages/longevity/questionario.astro` | la pagina del questionario | +| `src/components/longevity/Questionario.tsx` | isola React: quattro blocchi, validazione, invio | +| `src/pages/api/longevity/questionario.ts` | endpoint che salva una compilazione | +| `src/pages/longevity/io.astro` | il referto del cliente | +| `src/components/longevity/Radar.tsx` | isola React: il radar a sette assi col tratteggio | +| `src/components/longevity/MacroScore.astro` | le tre carte dei macro-score | +| `src/lib/longevity/vista.ts` | ciò che serve alle pagine, letto dal motore | + +--- + +### Task 1: Il guscio — layout, stile, e una rotta che risponde + +**Files:** +- Create: `src/layouts/Longevity.astro`, `src/styles/longevity.css`, `src/pages/longevity/index.astro` +- Test: `tests/longevity/pagine.test.ts` + +**Interfaces:** +- Consumes: `isProtectedPath`, `canAccessAdminPath` da `src/lib/auth.ts` +- Produces: il layout `Longevity.astro` con `title` e `crumbs`, e le classi `.lg-*` + +**Il modello da seguire:** `src/layouts/StressIndex.astro` e `src/styles/stress-index.css`. Leggili prima di scrivere. Il foglio di stile va **globale e prefissato**, non in ` + + diff --git a/apps/sito/src/layouts/Campus.astro b/apps/platforms/src/layouts/Campus.astro similarity index 100% rename from apps/sito/src/layouts/Campus.astro rename to apps/platforms/src/layouts/Campus.astro diff --git a/apps/sito/src/layouts/Longevity.astro b/apps/platforms/src/layouts/Longevity.astro similarity index 100% rename from apps/sito/src/layouts/Longevity.astro rename to apps/platforms/src/layouts/Longevity.astro diff --git a/apps/sito/src/layouts/StressIndex.astro b/apps/platforms/src/layouts/StressIndex.astro similarity index 100% rename from apps/sito/src/layouts/StressIndex.astro rename to apps/platforms/src/layouts/StressIndex.astro diff --git a/apps/platforms/src/lib/auth.ts b/apps/platforms/src/lib/auth.ts new file mode 100644 index 0000000..695d502 --- /dev/null +++ b/apps/platforms/src/lib/auth.ts @@ -0,0 +1,151 @@ +import type Database from 'better-sqlite3'; +import bcrypt from 'bcryptjs'; +import { randomBytes } from 'node:crypto'; + +export const SESSION_COOKIE = 'session'; +const SESSION_DAYS = 7; + +// Unica fonte dei ruoli validi: da qui si derivano il tipo Role e la validazione isRole. +// ⚠️ Sono i ruoli di QUEST'app, non quelli del sito: `user` e `superuser` (blog e contenuti) +// restano di là e qui non significano niente. Un utente del sito non è un utente di qui. +// ⚠️ create-user.mjs duplica questa lista: è JavaScript e non può importare da qui. Se +// cambia questa riga va cambiata anche lì — è già andata fuori sincrono due volte. +export const ROLES = ['piattaforme', 'admin', 'cliente', 'trainer'] as const; +export type Role = typeof ROLES[number]; + +export function hashPassword(plain: string): string { + return bcrypt.hashSync(plain, 12); +} + +export function verifyPassword(plain: string, hash: string): boolean { + return bcrypt.compareSync(plain, hash); +} + +export function createUser(db: Database.Database, username: string, password: string, role: Role = 'admin'): number { + const res = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)') + .run(username, hashPassword(password), role); + return Number(res.lastInsertRowid); +} + +export function login(db: Database.Database, username: string, password: string): string | null { + const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username) as + | { id: number; password_hash: string } | undefined; + if (!user || !verifyPassword(password, user.password_hash)) return null; + const token = randomBytes(32).toString('hex'); + db.prepare( + `INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, datetime('now', '+${SESSION_DAYS} days'))` + ).run(token, user.id); + return token; +} + +export function getSessionUser(db: Database.Database, token: string): { id: number; username: string; role: string } | null { + const row = db.prepare( + `SELECT s.token, s.expires_at, u.id, u.username, u.role + FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ?` + ).get(token) as { token: string; expires_at: string; id: number; username: string; role: string } | undefined; + if (!row) return null; + if (row.expires_at <= new Date().toISOString().slice(0, 19).replace('T', ' ')) { + db.prepare('DELETE FROM sessions WHERE token = ?').run(token); + return null; + } + return { id: row.id, username: row.username, role: row.role }; +} + +export function logout(db: Database.Database, token: string): void { + db.prepare('DELETE FROM sessions WHERE token = ?').run(token); +} + +// Autorizzazione per prefisso di rotta. L'admin passa sempre; per gli altri, la prima regola +// che matcha decide; se nessuna regola matcha la rotta è "blog/upload/logout" → consentita a +// qualsiasi loggato. L'elenco vero di cosa il middleware protegge (non solo /admin e +// /api/admin: anche /campus, /piattaforme e /longevity) vive in isProtectedPath, più sotto: +// qui arrivano solo utenti già autenticati su una di quelle rotte. +const RULES: [RegExp, Role[]][] = [ + // Le due piattaforme storiche: il Biohacking Campus (contenuti) e Stress Index. + [/^\/campus(\/|$)/, ['admin', 'piattaforme']], + [/^\/piattaforme(\/|$)/, ['admin', 'piattaforme']], + // Longevity: il cliente vede solo il proprio spazio, il gestionale è del trainer. + [/^\/longevity\/gestionale(\/|$)/, ['admin', 'trainer']], + [/^\/api\/longevity\/gestionale(\/|$)/, ['admin', 'trainer']], + [/^\/longevity(\/|$)/, ['admin', 'trainer', 'cliente']], + [/^\/api\/longevity(\/|$)/, ['admin', 'trainer', 'cliente']], +]; + +/** + * Chi può aprire un percorso protetto. L'admin passa sempre; per gli altri decide la prima + * regola che combacia. + * + * ⚠️ Differenza sostanziale dal sito, da cui questa funzione viene: là il default finale era + * `return true` (le rotte non elencate — blog, upload, logout — erano di chiunque fosse + * loggato). Qui **non esiste una rotta di tutti**: ogni sezione è di qualcuno, quindi il + * default si chiude. Una rotta nuova senza la sua regola risulta vietata invece che aperta, + * e lo si scopre provandola, non leggendo un log di accessi. + */ +export function canAccessPath(role: string, pathname: string): boolean { + if (role === 'admin') return true; + for (const [re, roles] of RULES) { + if (re.test(pathname)) return roles.includes(role as Role); + } + return false; +} + +export function getUsernameById(db: Database.Database, id: number): string | null { + const row = db.prepare('SELECT username FROM users WHERE id = ?').get(id) as { username: string } | undefined; + return row?.username ?? null; +} + +export function listUsers(db: Database.Database): { id: number; username: string; role: string }[] { + return db.prepare('SELECT id, username, role FROM users ORDER BY username').all() as + { id: number; username: string; role: string }[]; +} + +export function countAdmins(db: Database.Database): number { + return (db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'").get() as { n: number }).n; +} + +export function getUserById(db: Database.Database, id: number): { id: number; username: string; role: string } | null { + const row = db.prepare('SELECT id, username, role FROM users WHERE id = ?').get(id) as + { id: number; username: string; role: string } | undefined; + return row ?? null; +} + +export function updateUserRole(db: Database.Database, id: number, role: Role): void { + db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id); +} + +export function updateUserPassword(db: Database.Database, id: number, hash: string): void { + db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, id); +} + +export function deleteUser(db: Database.Database, id: number): void { + db.prepare('DELETE FROM users WHERE id = ?').run(id); +} + +export function randomPassword(): string { + return randomBytes(9).toString('base64url'); // ~12 caratteri +} + +export function isRole(v: unknown): v is Role { + return (ROLES as readonly unknown[]).includes(v); +} + +/** + * Dove atterra chi ha appena fatto il login. ⚠️ `/admin` non esiste in quest'app: il + * fondo della scala è l'indice delle piattaforme, non un pannello. + */ +export function landingFor(role: string): string { + if (role === 'cliente') return '/longevity/io'; + if (role === 'trainer') return '/longevity/gestionale'; + return '/piattaforme'; +} + +/** + * Cosa richiede una sessione. Nel sito era un elenco di prefissi protetti dentro un'app + * pubblica; qui il rapporto è rovesciato — **tutta** l'applicazione è riservata, e si + * elencano le poche eccezioni. Una pagina nuova nasce protetta senza che nessuno se ne + * ricordi, che è il verso giusto in cui sbagliare. + */ +export function isProtectedPath(pathname: string): boolean { + const aperte = ['/login', '/logout', '/404', '/favicon.svg']; + return !aperte.includes(pathname); +} diff --git a/apps/sito/src/lib/campus.ts b/apps/platforms/src/lib/campus.ts similarity index 100% rename from apps/sito/src/lib/campus.ts rename to apps/platforms/src/lib/campus.ts diff --git a/apps/platforms/src/lib/db.ts b/apps/platforms/src/lib/db.ts new file mode 100644 index 0000000..a835022 --- /dev/null +++ b/apps/platforms/src/lib/db.ts @@ -0,0 +1,47 @@ +import Database from 'better-sqlite3'; +import { mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; + +/** + * Il database di quest'app: SOLO utenti e sessioni. + * + * ⚠️ Non è il database del sito. Fino al 03/09/2026 `users` e `sessions` vivevano in + * `insanitylab.db` insieme a contenuti e articoli, ed erano l'unica cosa che sito e aree + * riservate condividevano davvero. Separandoli, le due anagrafiche diventano quello che + * sono sempre state: tre utenti redazionali di là, un utente per ogni cliente del centro + * di qua. Una sessione aperta sul sito qui non vale, ed è voluto. + * + * ⚠️ Qui NON entrano dati clinici: quelli stanno in longevity.db (senza nomi) e in + * identity.db (l'unico file con dei nomi), aperti da src/lib/longevity/db.ts. + */ +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'cliente' +); +CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions (user_id); +`; + +export function createDb(path?: string): Database.Database { + const p = path ?? process.env.DB_PATH ?? 'data/piattaforme.db'; + if (p !== ':memory:') mkdirSync(dirname(p), { recursive: true }); + const db = new Database(p); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + db.exec(SCHEMA); + return db; +} + +let singleton: Database.Database | null = null; + +export function getDb(): Database.Database { + if (!singleton) singleton = createDb(); + return singleton; +} diff --git a/apps/sito/src/lib/longevity/anagrafica.ts b/apps/platforms/src/lib/longevity/anagrafica.ts similarity index 100% rename from apps/sito/src/lib/longevity/anagrafica.ts rename to apps/platforms/src/lib/longevity/anagrafica.ts diff --git a/apps/sito/src/lib/longevity/db.ts b/apps/platforms/src/lib/longevity/db.ts similarity index 100% rename from apps/sito/src/lib/longevity/db.ts rename to apps/platforms/src/lib/longevity/db.ts diff --git a/apps/sito/src/lib/longevity/export.ts b/apps/platforms/src/lib/longevity/export.ts similarity index 100% rename from apps/sito/src/lib/longevity/export.ts rename to apps/platforms/src/lib/longevity/export.ts diff --git a/apps/sito/src/lib/longevity/formato.ts b/apps/platforms/src/lib/longevity/formato.ts similarity index 100% rename from apps/sito/src/lib/longevity/formato.ts rename to apps/platforms/src/lib/longevity/formato.ts diff --git a/apps/sito/src/lib/longevity/misure.ts b/apps/platforms/src/lib/longevity/misure.ts similarity index 100% rename from apps/sito/src/lib/longevity/misure.ts rename to apps/platforms/src/lib/longevity/misure.ts diff --git a/apps/sito/src/lib/longevity/motore/cascata.ts b/apps/platforms/src/lib/longevity/motore/cascata.ts similarity index 100% rename from apps/sito/src/lib/longevity/motore/cascata.ts rename to apps/platforms/src/lib/longevity/motore/cascata.ts diff --git a/apps/sito/src/lib/longevity/motore/curve.ts b/apps/platforms/src/lib/longevity/motore/curve.ts similarity index 100% rename from apps/sito/src/lib/longevity/motore/curve.ts rename to apps/platforms/src/lib/longevity/motore/curve.ts diff --git a/apps/sito/src/lib/longevity/motore/index.ts b/apps/platforms/src/lib/longevity/motore/index.ts similarity index 100% rename from apps/sito/src/lib/longevity/motore/index.ts rename to apps/platforms/src/lib/longevity/motore/index.ts diff --git a/apps/sito/src/lib/longevity/motore/test-fisici.ts b/apps/platforms/src/lib/longevity/motore/test-fisici.ts similarity index 100% rename from apps/sito/src/lib/longevity/motore/test-fisici.ts rename to apps/platforms/src/lib/longevity/motore/test-fisici.ts diff --git a/apps/sito/src/lib/longevity/questionario.ts b/apps/platforms/src/lib/longevity/questionario.ts similarity index 100% rename from apps/sito/src/lib/longevity/questionario.ts rename to apps/platforms/src/lib/longevity/questionario.ts diff --git a/apps/sito/src/lib/longevity/registro.ts b/apps/platforms/src/lib/longevity/registro.ts similarity index 100% rename from apps/sito/src/lib/longevity/registro.ts rename to apps/platforms/src/lib/longevity/registro.ts diff --git a/apps/sito/src/lib/longevity/vista.ts b/apps/platforms/src/lib/longevity/vista.ts similarity index 100% rename from apps/sito/src/lib/longevity/vista.ts rename to apps/platforms/src/lib/longevity/vista.ts diff --git a/apps/sito/src/lib/safe-next.ts b/apps/platforms/src/lib/safe-next.ts similarity index 100% rename from apps/sito/src/lib/safe-next.ts rename to apps/platforms/src/lib/safe-next.ts diff --git a/apps/sito/src/lib/stress-index/analytics.ts b/apps/platforms/src/lib/stress-index/analytics.ts similarity index 100% rename from apps/sito/src/lib/stress-index/analytics.ts rename to apps/platforms/src/lib/stress-index/analytics.ts diff --git a/apps/sito/src/lib/stress-index/data.ts b/apps/platforms/src/lib/stress-index/data.ts similarity index 100% rename from apps/sito/src/lib/stress-index/data.ts rename to apps/platforms/src/lib/stress-index/data.ts diff --git a/apps/sito/src/lib/stress-index/sport.ts b/apps/platforms/src/lib/stress-index/sport.ts similarity index 100% rename from apps/sito/src/lib/stress-index/sport.ts rename to apps/platforms/src/lib/stress-index/sport.ts diff --git a/apps/platforms/src/middleware.ts b/apps/platforms/src/middleware.ts new file mode 100644 index 0000000..35ba2e5 --- /dev/null +++ b/apps/platforms/src/middleware.ts @@ -0,0 +1,37 @@ +import { defineMiddleware } from 'astro:middleware'; +import { getDb } from './lib/db'; +import { getSessionUser, SESSION_COOKIE, canAccessPath, landingFor, isProtectedPath } from './lib/auth'; + +/** + * Tutta l'applicazione è riservata: si entra dal /login di quest'app, con gli utenti di + * quest'app. ⚠️ Una sessione aperta sul sito (insanitylab.it) qui non vale — cookie diverso, + * dominio diverso, database diverso. È la separazione, non un difetto. + * + * ⚠️ Le rotte /api rispondono con uno stato, non con un redirect: mandare un 302 verso una + * pagina di login a chi ha chiesto del JSON produce un 200 con dentro dell'HTML, cioè un + * errore che il chiamante scopre provando a leggerlo. + */ +export const onRequest = defineMiddleware((context, next) => { + const { pathname } = context.url; + if (!isProtectedPath(pathname)) return next(); + + const json = (status: number, error: string) => + new Response(JSON.stringify({ error }), { status, headers: { 'Content-Type': 'application/json' } }); + + const token = context.cookies.get(SESSION_COOKIE)?.value; + const user = token ? getSessionUser(getDb(), token) : null; + if (!user) { + if (pathname.startsWith('/api/')) return json(401, 'Non autorizzato'); + return context.redirect(`/login?next=${encodeURIComponent(pathname)}`); + } + + if (!canAccessPath(user.role, pathname)) { + if (pathname.startsWith('/api/')) return json(403, 'Permessi insufficienti'); + // Loggato ma senza i permessi per QUESTA sezione: si torna a casa propria, non si + // mostra una porta chiusa a chi una casa ce l'ha. + return context.redirect(landingFor(user.role)); + } + + context.locals.user = user; + return next(); +}); diff --git a/apps/platforms/src/pages/404.astro b/apps/platforms/src/pages/404.astro new file mode 100644 index 0000000..717119e --- /dev/null +++ b/apps/platforms/src/pages/404.astro @@ -0,0 +1,21 @@ +--- +// Il 404 dell'area riservata. ⚠️ Quello del sito non si poteva riusare: pesca i suoi testi +// dai contenuti taggati (content_blocks), che vivono nel database del sito e qui non +// esistono. Qui il testo è scritto in pagina — è una pagina d'errore, non un contenuto +// da modificare dal pannello. +import App from '../layouts/App.astro'; +export const prerender = false; +--- + +
+

Pagina non trovata

+

L'indirizzo non corrisponde a niente di quest'area.

+

Torna alle piattaforme

+
+ +
diff --git a/apps/sito/src/pages/api/longevity/questionario.ts b/apps/platforms/src/pages/api/longevity/questionario.ts similarity index 100% rename from apps/sito/src/pages/api/longevity/questionario.ts rename to apps/platforms/src/pages/api/longevity/questionario.ts diff --git a/apps/sito/src/pages/campus/[...slug].astro b/apps/platforms/src/pages/campus/[...slug].astro similarity index 100% rename from apps/sito/src/pages/campus/[...slug].astro rename to apps/platforms/src/pages/campus/[...slug].astro diff --git a/apps/sito/src/pages/campus/assets/[...path].ts b/apps/platforms/src/pages/campus/assets/[...path].ts similarity index 100% rename from apps/sito/src/pages/campus/assets/[...path].ts rename to apps/platforms/src/pages/campus/assets/[...path].ts diff --git a/apps/sito/src/pages/campus/index.astro b/apps/platforms/src/pages/campus/index.astro similarity index 100% rename from apps/sito/src/pages/campus/index.astro rename to apps/platforms/src/pages/campus/index.astro diff --git a/apps/platforms/src/pages/index.astro b/apps/platforms/src/pages/index.astro new file mode 100644 index 0000000..0ea2bbb --- /dev/null +++ b/apps/platforms/src/pages/index.astro @@ -0,0 +1,7 @@ +--- +// La radice di apps.insanitylab.it non ha contenuto proprio: ognuno va a casa sua, e chi +// non è loggato ci finisce comunque passando dal middleware. +import { landingFor } from '../lib/auth'; +export const prerender = false; +return Astro.redirect(landingFor(Astro.locals.user!.role)); +--- diff --git a/apps/platforms/src/pages/login.astro b/apps/platforms/src/pages/login.astro new file mode 100644 index 0000000..f4683a4 --- /dev/null +++ b/apps/platforms/src/pages/login.astro @@ -0,0 +1,56 @@ +--- +// Il login di quest'app. ⚠️ Non è quello del sito: utenti e sessioni stanno in +// piattaforme.db, e chi è loggato su insanitylab.it qui è uno sconosciuto. +import App from '../layouts/App.astro'; +import { getDb } from '../lib/db'; +import { login, getSessionUser, SESSION_COOKIE, landingFor } from '../lib/auth'; +import { safeNext } from '../lib/safe-next'; +export const prerender = false; + +const dest = safeNext(Astro.url.searchParams.get('next')); + +const esistente = Astro.cookies.get(SESSION_COOKIE)?.value; +const giaDentro = esistente ? getSessionUser(getDb(), esistente) : null; +if (giaDentro) return Astro.redirect(dest !== '/' ? dest : landingFor(giaDentro.role)); + +let errore = ''; +if (Astro.request.method === 'POST') { + const form = await Astro.request.formData(); + const token = login(getDb(), String(form.get('username') ?? ''), String(form.get('password') ?? '')); + if (token) { + Astro.cookies.set(SESSION_COOKIE, token, { + httpOnly: true, sameSite: 'lax', path: '/', + secure: import.meta.env.PROD, maxAge: 7 * 24 * 3600, + }); + const utente = getSessionUser(getDb(), token)!; + const richiesto = safeNext(String(form.get('next') ?? '') || dest); + return Astro.redirect(richiesto !== '/' ? richiesto : landingFor(utente.role)); + } + errore = 'Credenziali non valide.'; +} +--- + + + + diff --git a/apps/platforms/src/pages/logout.ts b/apps/platforms/src/pages/logout.ts new file mode 100644 index 0000000..a4fe0f1 --- /dev/null +++ b/apps/platforms/src/pages/logout.ts @@ -0,0 +1,11 @@ +import type { APIRoute } from 'astro'; +import { getDb } from '../lib/db'; +import { logout, SESSION_COOKIE } from '../lib/auth'; +export const prerender = false; + +export const GET: APIRoute = ({ cookies, redirect }) => { + const token = cookies.get(SESSION_COOKIE)?.value; + if (token) logout(getDb(), token); + cookies.delete(SESSION_COOKIE, { path: '/' }); + return redirect('/login'); +}; diff --git a/apps/sito/src/pages/longevity/index.astro b/apps/platforms/src/pages/longevity/index.astro similarity index 100% rename from apps/sito/src/pages/longevity/index.astro rename to apps/platforms/src/pages/longevity/index.astro diff --git a/apps/sito/src/pages/longevity/io.astro b/apps/platforms/src/pages/longevity/io.astro similarity index 100% rename from apps/sito/src/pages/longevity/io.astro rename to apps/platforms/src/pages/longevity/io.astro diff --git a/apps/sito/src/pages/longevity/questionario.astro b/apps/platforms/src/pages/longevity/questionario.astro similarity index 100% rename from apps/sito/src/pages/longevity/questionario.astro rename to apps/platforms/src/pages/longevity/questionario.astro diff --git a/apps/sito/src/pages/piattaforme/index.astro b/apps/platforms/src/pages/piattaforme/index.astro similarity index 87% rename from apps/sito/src/pages/piattaforme/index.astro rename to apps/platforms/src/pages/piattaforme/index.astro index d550869..d8f96ab 100644 --- a/apps/sito/src/pages/piattaforme/index.astro +++ b/apps/platforms/src/pages/piattaforme/index.astro @@ -1,18 +1,21 @@ --- // Indice delle piattaforme riservate: è la pagina di atterraggio del ruolo `piattaforme` // (vedi landingFor in lib/auth). L'accesso è già filtrato dal middleware. -import Base from '../../layouts/Base.astro'; -import { piattaforme } from '../../data/piattaforme'; +import App from '../../layouts/App.astro'; +import { piattaformePer } from '../../data/piattaforme'; export const prerender = false; + +const utente = Astro.locals.user!; +const visibili = piattaformePer(utente.role); --- - +

Area riservata

Piattaforme

Gli strumenti riservati agli utenti abilitati.

- {piattaforme.map((p) => ( + {visibili.map((p) => (

{p.label}

{p.description}

@@ -22,7 +25,7 @@ export const prerender = false;
- +
diff --git a/apps/sito/tests/auth-piattaforme.test.ts b/apps/sito/tests/auth-piattaforme.test.ts deleted file mode 100644 index 16daba6..0000000 --- a/apps/sito/tests/auth-piattaforme.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import type Database from 'better-sqlite3'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { createDb } from '../src/lib/db'; -import { createUser, login, getSessionUser, canAccessAdminPath, landingFor, isRole } from '../src/lib/auth'; - -let db: Database.Database; -beforeEach(() => { db = createDb(':memory:'); }); - -describe('ruolo piattaforme', () => { - it('createUser accetta piattaforme e getSessionUser lo ritorna', () => { - createUser(db, 'c', 'segreta123', 'piattaforme'); - expect(getSessionUser(db, login(db, 'c', 'segreta123')!)).toMatchObject({ role: 'piattaforme' }); - }); - - it('isRole valida piattaforme e non più il vecchio campus', () => { - expect(isRole('piattaforme')).toBe(true); - expect(isRole('campus')).toBe(false); - }); - - it("landingFor porta piattaforme all'indice delle piattaforme", () => { - expect(landingFor('piattaforme')).toBe('/piattaforme'); - }); -}); - -describe('canAccessAdminPath con piattaforme', () => { - it('piattaforme: sezioni riservate e logout sì, resto del pannello no', () => { - expect(canAccessAdminPath('piattaforme', '/campus')).toBe(true); - expect(canAccessAdminPath('piattaforme', '/campus/')).toBe(true); - expect(canAccessAdminPath('piattaforme', '/campus/corso-base/lezione-01')).toBe(true); - expect(canAccessAdminPath('piattaforme', '/campus/assets/slide/x.png')).toBe(true); - expect(canAccessAdminPath('piattaforme', '/piattaforme')).toBe(true); - expect(canAccessAdminPath('piattaforme', '/piattaforme/stress-index')).toBe(true); - expect(canAccessAdminPath('piattaforme', '/piattaforme/stress-index/clienti')).toBe(true); - expect(canAccessAdminPath('piattaforme', '/admin/logout')).toBe(true); - expect(canAccessAdminPath('piattaforme', '/admin')).toBe(false); - expect(canAccessAdminPath('piattaforme', '/admin/new')).toBe(false); - expect(canAccessAdminPath('piattaforme', '/admin/content')).toBe(false); - expect(canAccessAdminPath('piattaforme', '/admin/users')).toBe(false); - expect(canAccessAdminPath('piattaforme', '/api/admin/posts')).toBe(false); - expect(canAccessAdminPath('piattaforme', '/api/admin/upload')).toBe(false); - }); - - it('admin accede a tutte le piattaforme', () => { - expect(canAccessAdminPath('admin', '/campus')).toBe(true); - expect(canAccessAdminPath('admin', '/campus/corso-base')).toBe(true); - expect(canAccessAdminPath('admin', '/piattaforme')).toBe(true); - expect(canAccessAdminPath('admin', '/piattaforme/stress-index')).toBe(true); - }); - - it('superuser e user non accedono alle piattaforme', () => { - expect(canAccessAdminPath('superuser', '/campus')).toBe(false); - expect(canAccessAdminPath('superuser', '/campus/corso-base')).toBe(false); - expect(canAccessAdminPath('superuser', '/piattaforme')).toBe(false); - expect(canAccessAdminPath('superuser', '/piattaforme/stress-index')).toBe(false); - expect(canAccessAdminPath('user', '/campus')).toBe(false); - expect(canAccessAdminPath('user', '/campus/assets/slide/x.png')).toBe(false); - expect(canAccessAdminPath('user', '/piattaforme')).toBe(false); - }); -}); - -// La rinomina del ruolo va verificata su un DB su file: le migrazioni girano all'apertura, -// quindi serve chiudere e riaprire lo stesso database con dentro una riga al vecchio valore. -describe('migrazione del ruolo campus → piattaforme', () => { - let dir: string; - beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'ilab-db-')); }); - afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); - - it('rinomina gli utenti rimasti al vecchio ruolo, lasciando stare gli altri', () => { - const file = join(dir, 'test.db'); - const first = createDb(file); - createUser(first, 'vecchio', 'segreta123', 'campus' as never); - createUser(first, 'redattore', 'segreta123', 'superuser'); - first.close(); - - const second = createDb(file); - const roles = second.prepare('SELECT username, role FROM users ORDER BY username').all() as - { username: string; role: string }[]; - second.close(); - - expect(roles).toEqual([ - { username: 'redattore', role: 'superuser' }, - { username: 'vecchio', role: 'piattaforme' }, - ]); - }); -}); diff --git a/apps/sito/tests/longevity/ruoli.test.ts b/apps/sito/tests/longevity/ruoli.test.ts deleted file mode 100644 index 5b057f9..0000000 --- a/apps/sito/tests/longevity/ruoli.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { canAccessAdminPath, isProtectedPath, landingFor, isRole, ROLES } from '../../src/lib/auth'; - -describe('accesso alle rotte longevity', () => { - it('il cliente entra nel proprio spazio', () => { - expect(canAccessAdminPath('cliente', '/longevity/io')).toBe(true); - }); - - it('il cliente NON entra nel gestionale', () => { - expect(canAccessAdminPath('cliente', '/longevity/gestionale')).toBe(false); - }); - - it('il trainer entra in entrambi', () => { - expect(canAccessAdminPath('trainer', '/longevity/io')).toBe(true); - expect(canAccessAdminPath('trainer', '/longevity/gestionale')).toBe(true); - }); - - it('il cliente non entra nelle altre piattaforme ne nel blog', () => { - expect(canAccessAdminPath('cliente', '/campus')).toBe(false); - expect(canAccessAdminPath('cliente', '/admin/content')).toBe(false); - expect(canAccessAdminPath('cliente', '/admin/posts')).toBe(false); - }); - - it('il trainer non entra nelle altre piattaforme ne nel blog', () => { - expect(canAccessAdminPath('trainer', '/campus')).toBe(false); - expect(canAccessAdminPath('trainer', '/admin/content')).toBe(false); - expect(canAccessAdminPath('trainer', '/admin/posts')).toBe(false); - }); - - it('il ruolo piattaforme non eredita longevity', () => { - expect(canAccessAdminPath('piattaforme', '/longevity/gestionale')).toBe(false); - }); - - it('admin passa sempre', () => { - expect(canAccessAdminPath('admin', '/longevity/gestionale')).toBe(true); - }); -}); - -describe('isProtectedPath — quali rotte sono protette dal middleware', () => { - it('longevity è protetto', () => { - expect(isProtectedPath('/longevity/io')).toBe(true); - expect(isProtectedPath('/longevity/gestionale')).toBe(true); - expect(isProtectedPath('/api/longevity/qualcosa')).toBe(true); - }); - - it('i percorsi già esistenti restano protetti', () => { - expect(isProtectedPath('/admin')).toBe(true); - expect(isProtectedPath('/admin/content')).toBe(true); - expect(isProtectedPath('/api/admin/content')).toBe(true); - expect(isProtectedPath('/campus')).toBe(true); - expect(isProtectedPath('/piattaforme')).toBe(true); - }); - - it('il login e le pagine pubbliche non sono protetti', () => { - expect(isProtectedPath('/admin/login')).toBe(false); - expect(isProtectedPath('/blog')).toBe(false); - expect(isProtectedPath('/')).toBe(false); - }); -}); - -describe('landingFor — indirizzo di atterraggio per ruolo', () => { - it('cliente atterra su longevity/io', () => { - expect(landingFor('cliente')).toBe('/longevity/io'); - }); - - it('trainer atterra su longevity/gestionale', () => { - expect(landingFor('trainer')).toBe('/longevity/gestionale'); - }); - - it('i ruoli già esistenti restano uguali', () => { - expect(landingFor('superuser')).toBe('/admin/content'); - expect(landingFor('piattaforme')).toBe('/piattaforme'); - expect(landingFor('admin')).toBe('/admin'); - expect(landingFor('user')).toBe('/admin'); - }); -}); - -describe('isRole — validazione dei ruoli', () => { - it('cliente e trainer sono ruoli validi', () => { - expect(isRole('cliente')).toBe(true); - expect(isRole('trainer')).toBe(true); - }); - - it('i ruoli già esistenti restano validi', () => { - expect(isRole('admin')).toBe(true); - expect(isRole('superuser')).toBe(true); - expect(isRole('user')).toBe(true); - expect(isRole('piattaforme')).toBe(true); - }); - - it('campus e stringhe inventate non sono ruoli', () => { - expect(isRole('campus')).toBe(false); - expect(isRole('qualsiasi-cosa')).toBe(false); - }); -}); - -describe('ROLES — fonte unica dei ruoli, usata dal pannello utenti', () => { - it('contiene tutti e sei i ruoli, cliente e trainer inclusi', () => { - expect(ROLES).toContain('cliente'); - expect(ROLES).toContain('trainer'); - expect(ROLES).toContain('admin'); - expect(ROLES).toContain('superuser'); - expect(ROLES).toContain('user'); - expect(ROLES).toContain('piattaforme'); - expect(ROLES.length).toBe(6); - }); - - it('isRole accetta esattamente i ruoli di ROLES, nessuno in piu o in meno', () => { - for (const r of ROLES) expect(isRole(r)).toBe(true); - expect(isRole('non-un-ruolo')).toBe(false); - }); -}); - -describe('pannello utenti — la tendina dei ruoli non e piu scritta a mano', () => { - it('users.astro pesca i ruoli da ROLES, non da una lista propria che ne dimentica due', () => { - const src = readFileSync(join(process.cwd(), 'src/pages/admin/users.astro'), 'utf8'); - expect(src).toContain('ROLES'); - // La vecchia lista a quattro (dimenticava cliente e trainer) non deve piu comparire. - expect(src).not.toContain("['user', 'superuser', 'piattaforme', 'admin']"); - }); -}); diff --git a/apps/sito/tests/ruoli-dopo-separazione.test.ts b/apps/sito/tests/ruoli-dopo-separazione.test.ts new file mode 100644 index 0000000..7bd15cf --- /dev/null +++ b/apps/sito/tests/ruoli-dopo-separazione.test.ts @@ -0,0 +1,48 @@ +// Cosa resta vero in questo sito dopo la separazione del 03/09/2026, quando Campus, +// Stress Index e Longevity sono passati a apps/platforms con i loro utenti. +// Eredita le due asserzioni ancora valide di tests/longevity/ruoli.test.ts (emigrato) e +// aggiunge la guardia sul ciclo di redirect, che è nata proprio da questa separazione. +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { ROLES, isRole, landingFor, canAccessAdminPath } from '../src/lib/auth'; + +describe('ROLES — restano solo i ruoli redazionali', () => { + it('i tre ruoli del sito, e nessuno di quelli emigrati', () => { + expect([...ROLES].sort()).toEqual(['admin', 'superuser', 'user']); + for (const emigrato of ['piattaforme', 'cliente', 'trainer']) expect(isRole(emigrato)).toBe(false); + }); + + it('isRole accetta esattamente i ruoli di ROLES', () => { + for (const r of ROLES) expect(isRole(r)).toBe(true); + for (const finto of ['campus', 'Admin', '', 'user ']) expect(isRole(finto)).toBe(false); + }); + + it('la tendina di admin/users.astro pesca da ROLES, non da una lista propria', () => { + const src = readFileSync(join(process.cwd(), 'src/pages/admin/users.astro'), 'utf8'); + expect(src).toContain('ROLES'); + }); + + it('le descrizioni dei ruoli in users/new.astro non nominano ruoli che non esistono più', () => { + const src = readFileSync(join(process.cwd(), 'src/pages/admin/users/new.astro'), 'utf8'); + for (const emigrato of ['piattaforme', 'cliente', 'trainer']) expect(src).not.toContain(`${emigrato}:`); + }); +}); + +describe('⚠️ il ciclo di redirect che la separazione ha reso possibile', () => { + it('un ruolo emigrato rimasto in `users` NON può accedere a /admin, ma è lì che atterrerebbe', () => { + // Questa coppia è il ciclo: landingFor lo manda su /admin, canAccessAdminPath glielo + // nega, il middleware lo rimanda su landingFor. È la ragione della guardia nel login. + expect(landingFor('piattaforme')).toBe('/admin'); + expect(canAccessAdminPath('piattaforme', '/admin')).toBe(false); + expect(canAccessAdminPath('cliente', '/admin')).toBe(false); + expect(canAccessAdminPath('trainer', '/admin')).toBe(false); + }); + + it('il login del pannello rifiuta questi account invece di aprire una sessione', () => { + const src = readFileSync(join(process.cwd(), 'src/pages/admin/login.astro'), 'utf8'); + expect(src).toContain('isRole'); + expect(src).toContain('logout(getDb(), token)'); + expect(src).toMatch(/apps\.insanitylab\.it/); + }); +}); From 18b087702643024674cec03fc3d87b9a3be99394 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Thu, 3 Sep 2026 12:27:24 +0000 Subject: [PATCH 56/62] Longevity: il registro si semina da solo, e un cliente demo con un referto vero seedRegistro/seedPesi esistevano dal 22/08 ed erano chiamati SOLO dai test: su un database nuovo il registro restava vuoto, campiDelQuestionario non trovava niente e la pagina del questionario si apriva SENZA DOMANDE. Non era un problema della demo, era il primo avvio in produzione - lo stesso difetto gia' visto in questo ramo (una funzione corretta che nessuno chiama), con la stessa correzione: agganciarla dove non si puo' dimenticare, cioe' alla creazione del database. Entrambe le semine sono INSERT OR REPLACE, quindi rilanciarle e' a vuoto, e i punteggi gia' emessi non cambiano (sono congelati nella tabella "score", che e' quello che il referto legge). Quattro test lo presidiano. scripts/seed-demo.ts crea l'utente con ruolo "cliente", il fascicolo (passando da creaCliente, l'unico varco fra identity e longevity) e compila il questionario attraverso salvaDalForm - lo stesso percorso della pagina vera, consenso compreso - cosi' i punteggi vengono calcolati e congelati come in produzione. Si ferma se l'utente esiste gia': rifare la demo su dati esistenti vuol dire duplicare o cancellare un fascicolo, e non e' una decisione da script. Verificato girando l'app: Energy 74,5/100 con copertura 50%, Performance e Recovery dichiarati insufficienti (5% e 35%) con l'elenco di cosa manca, Fitness Age assente. E' la regola di Nicola all'opera - annullare invece di stimare - ed e' lo stato vero del prodotto: nel registro ci sono solo le 20 domande del questionario, i test fisici arrivano col gestionale trainer. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL --- apps/platforms/package-lock.json | 20 +++ apps/platforms/package.json | 4 +- apps/platforms/scripts/seed-demo.ts | 122 ++++++++++++++++++ apps/platforms/src/lib/longevity/db.ts | 23 +++- .../tests/longevity/registro-seminato.test.ts | 51 ++++++++ 5 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 apps/platforms/scripts/seed-demo.ts create mode 100644 apps/platforms/tests/longevity/registro-seminato.test.ts diff --git a/apps/platforms/package-lock.json b/apps/platforms/package-lock.json index b005346..d380987 100644 --- a/apps/platforms/package-lock.json +++ b/apps/platforms/package-lock.json @@ -26,6 +26,7 @@ "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", "marked": "^18.0.11", + "tsx": "^4.23.13", "typescript": "^6.0.3", "vitest": "^4.1.9" } @@ -6245,6 +6246,25 @@ "license": "0BSD", "optional": true }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", diff --git a/apps/platforms/package.json b/apps/platforms/package.json index 51cdb4f..c380f01 100644 --- a/apps/platforms/package.json +++ b/apps/platforms/package.json @@ -10,7 +10,8 @@ "start": "node ./dist/server/entry.mjs", "test": "vitest run", "create-user": "node scripts/create-user.mjs", - "sync-campus": "node scripts/sync-campus.mjs" + "sync-campus": "node scripts/sync-campus.mjs", + "seed-demo": "tsx scripts/seed-demo.ts" }, "dependencies": { "@astrojs/node": "11.0.1", @@ -31,6 +32,7 @@ "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", "marked": "^18.0.11", + "tsx": "^4.23.13", "typescript": "^6.0.3", "vitest": "^4.1.9" } diff --git a/apps/platforms/scripts/seed-demo.ts b/apps/platforms/scripts/seed-demo.ts new file mode 100644 index 0000000..1379da8 --- /dev/null +++ b/apps/platforms/scripts/seed-demo.ts @@ -0,0 +1,122 @@ +/** + * seed-demo.ts — un cliente dimostrativo con un referto vero da mostrare. + * + * COSA FA, in quest'ordine: + * 1. apre i tre database (li crea se mancano; il registro dei test si semina da sé, + * vedi src/lib/longevity/db.ts); + * 2. crea un utente con ruolo `cliente` in piattaforme.db; + * 3. crea il suo fascicolo — la riga con il nome in identity.db, quella senza nome in + * longevity.db — passando da `creaCliente`, l'unico varco fra le due; + * 4. compila il questionario per lui con risposte plausibili, attraverso `salvaDalForm`: + * cioè lo stesso percorso della pagina vera, consenso compreso. Da lì i punteggi + * vengono calcolati e congelati, ed è quello che il referto legge. + * + * COME SI USA: + * npm run seed-demo # utente "demo-cliente", password generata + * npm run seed-demo -- --utente nicola-demo --password sceltaDaTe + * npm run seed-demo -- --data 2026-09-03 # data della compilazione (default: oggi) + * percorsi: --utenti-db / --longevity-db / --identity-db, oppure le variabili + * DB_PATH / LONGEVITY_DB_PATH / IDENTITY_DB_PATH (stessi nomi del container). + * + * ⚠️ NON è idempotente per scelta: se l'utente esiste già lo dice e si ferma, senza + * toccare niente. Rifare la demo su dati esistenti significherebbe o duplicare un + * fascicolo o cancellarne uno, e nessuna delle due è una cosa che uno script deve + * decidere da solo. + * + * ⚠️ I dati sono INVENTATI e vanno detti tali a chi guarda: è una persona che non esiste. + * Il referto che ne esce non è un esempio clinico, è un esempio di interfaccia. + * + * ⚠️ Cosa mostrerà davvero il radar: il registro contiene oggi SOLO le 20 domande del + * questionario — i test fisici (handgrip, VO2max, plank, composizione corporea...) non + * hanno ancora una riga, arrivano col gestionale trainer. Quindi escono con un valore gli + * assi che il questionario copre almeno al 40%, e gli altri restano dichiarati + * insufficienti con l'elenco di cosa manca. Non è un difetto della demo: è lo stato del + * prodotto, ed è anche la regola di Nicola che si vede all'opera (annullare invece di + * stimare). + * + * DIPENDE DA: tsx (devDependency) — il codice del prodotto è TypeScript e questo script + * lo usa davvero invece di riscriverne una copia in SQL. + */ +import { randomBytes } from 'node:crypto'; +import { createLongevityDb, createIdentityDb } from '../src/lib/longevity/db'; +import { creaCliente, etaAllaData } from '../src/lib/longevity/anagrafica'; +import { salvaDalForm, campiDelQuestionario } from '../src/lib/longevity/vista'; +import { createDb } from '../src/lib/db'; +import { createUser } from '../src/lib/auth'; + +const argv = process.argv.slice(2); +const opt = (nome: string, def: string): string => { + const i = argv.indexOf(nome); + return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : def; +}; + +const utente = opt('--utente', 'demo-cliente'); +const password = opt('--password', randomBytes(9).toString('base64url')); +const nome = opt('--nome', 'Giulia'); +const cognome = opt('--cognome', 'Esempio'); +const nascita = opt('--nascita', '1990-05-14'); +const data = opt('--data', new Date().toISOString().slice(0, 10)); + +const utentiDb = createDb(opt('--utenti-db', process.env.DB_PATH ?? 'data/piattaforme.db')); +const longevity = createLongevityDb(opt('--longevity-db', process.env.LONGEVITY_DB_PATH ?? 'data/longevity.db')); +const identity = createIdentityDb(opt('--identity-db', process.env.IDENTITY_DB_PATH ?? 'data/identity.db')); + +const esistente = utentiDb.prepare('SELECT id FROM users WHERE username = ?').get(utente) as { id: number } | undefined; +if (esistente) { + console.error(`L'utente "${utente}" esiste già (id ${esistente.id}): non tocco niente.`); + console.error('Usa --utente per una demo nuova.'); + process.exit(1); +} + +/** + * Le risposte della persona inventata: una in salute ma non perfetta. Un questionario + * con tutti i valori al massimo darebbe un referto tutto verde, che è il modo migliore + * per non far vedere niente — né le curve, né la soglia del 40%, né cosa manca. + */ +const RISPOSTE: Record = { + // sonno + q_ore_sonno: 7, q_min_addorm: 20, q_risvegli: 1, q_riposato: 7, + q_sonnolenza_diurna: 2, q_caffeina: 3, + // energia e stress + q_energia_media: 7, q_calo_pomeridiano: 4, q_tensione: 3, q_sopraffatto: 4, + q_esaurimento: 1, q_controllo: 7, q_sicurezza_gestione: 8, q_pensieri_lavoro: 2, + // stile di vita + q_attivita: 3, q_alimentazione: 7, q_alcol_life: 4, q_sigarette: 0, + q_luce: 2, q_schermi: 30, +}; + +// Il registro è la fonte: se una domanda è stata aggiunta o rinominata, questo script deve +// dirlo invece di salvare un questionario a metà senza che si veda. +const campi = campiDelQuestionario(longevity, data); +const mancanti = campi.filter((c) => !(c.id in RISPOSTE)).map((c) => c.id); +const inventate = Object.keys(RISPOSTE).filter((id) => !campi.some((c) => c.id === id)); +if (mancanti.length || inventate.length) { + if (mancanti.length) console.error(`⚠️ domande del registro senza risposta qui: ${mancanti.join(', ')}`); + if (inventate.length) console.error(`⚠️ risposte che il registro non conosce: ${inventate.join(', ')}`); + console.error('Allinea RISPOSTE al registro prima di rifare la demo.'); + process.exit(1); +} + +const userId = createUser(utentiDb, utente, password, 'cliente'); +const code = creaCliente(identity, longevity, { + nome, cognome, sesso: 'F', data_nascita: nascita, user_id: userId, +}); + +const esito = salvaDalForm(longevity, { + client_code: code, + data, + eta: etaAllaData(nascita, data), + risposte: RISPOSTE, + consensoSanitario: 'sì', +}); + +if (!esito.ok) { + console.error(`Il questionario non è stato salvato: ${esito.errore}`); + process.exit(1); +} + +console.log(`utente ${utente} (ruolo cliente, id ${userId})`); +console.log(`password ${password}`); +console.log(`fascicolo ${code} — ${nome} ${cognome}, ${etaAllaData(nascita, data)} anni`); +console.log(`sessione ${esito.sessioneId}, questionario del ${data}, ${campi.length} risposte`); +console.log('\nEntra da /login e atterri sul referto: /longevity/io'); diff --git a/apps/platforms/src/lib/longevity/db.ts b/apps/platforms/src/lib/longevity/db.ts index 3aa81b7..703f592 100644 --- a/apps/platforms/src/lib/longevity/db.ts +++ b/apps/platforms/src/lib/longevity/db.ts @@ -1,6 +1,7 @@ import Database from 'better-sqlite3'; import { mkdirSync } from 'node:fs'; import { dirname } from 'node:path'; +import { seedRegistro, seedPesi, MODEL_VERSION } from './registro'; /** * Sotto questa copertura di peso un elemento calcolato e' 'insufficiente' e non ha valore. @@ -127,8 +128,28 @@ function apri(p: string, schema: string): Database.Database { return db; } +/** + * ⚠️ Il registro dei test e i pesi si seminano QUI, all'apertura del database. + * + * Fino al 03/09/2026 `seedRegistro` e `seedPesi` esistevano, erano giusti, ed erano + * chiamati **solo dai test**: su un database nuovo il registro restava vuoto, quindi + * `campiDelQuestionario` non trovava niente e la pagina del questionario si apriva + * **senza domande**. Non era un difetto della demo: era il primo avvio in produzione. + * È lo stesso difetto già visto una volta in questo ramo — «una funzione corretta che + * nessuno chiama» — e la correzione è la stessa: agganciarla dove non si può dimenticare. + * + * Entrambe le semine sono `INSERT OR REPLACE`, quindi rilanciarle è a vuoto; e il + * registro è **dato di prodotto** (le domande e i pesi decisi con Nicola), non dato di + * un cliente: riallinearlo a ogni avvio è voluto, così una domanda modificata in + * `registro.ts` entra col rilascio invece che con una migrazione a mano. + * ⚠️ I punteggi già emessi non cambiano: sono congelati in `score` e il referto legge + * quelli, mai un ricalcolo (vedi `refertoDi`). + */ export function createLongevityDb(path?: string): Database.Database { - return apri(path ?? process.env.LONGEVITY_DB_PATH ?? 'data/longevity.db', SCHEMA_LONGEVITY); + const db = apri(path ?? process.env.LONGEVITY_DB_PATH ?? 'data/longevity.db', SCHEMA_LONGEVITY); + seedRegistro(db); + seedPesi(db, MODEL_VERSION); + return db; } export function createIdentityDb(path?: string): Database.Database { diff --git a/apps/platforms/tests/longevity/registro-seminato.test.ts b/apps/platforms/tests/longevity/registro-seminato.test.ts new file mode 100644 index 0000000..e99bebe --- /dev/null +++ b/apps/platforms/tests/longevity/registro-seminato.test.ts @@ -0,0 +1,51 @@ +// Il registro dei test è dato di prodotto, non dato di un cliente: un database nuovo deve +// nascere con dentro le domande e i pesi. Fino al 03/09/2026 seedRegistro e seedPesi erano +// chiamati SOLO dai test, quindi ogni suite passava e il primo avvio vero apriva un +// questionario senza domande. Questo test guarda il database come lo trova l'applicazione. +import { describe, it, expect } from 'vitest'; +import { createLongevityDb } from '../../src/lib/longevity/db'; +import { campiDelQuestionario } from '../../src/lib/longevity/vista'; +import { MODEL_VERSION, seedRegistro, seedPesi } from '../../src/lib/longevity/registro'; + +const OGGI = new Date().toISOString().slice(0, 10); + +describe('un database longevity appena creato', () => { + it('ha il registro dei test già seminato, non vuoto', () => { + const db = createLongevityDb(':memory:'); + const n = (db.prepare('SELECT COUNT(*) AS n FROM registro_test').get() as { n: number }).n; + expect(n).toBeGreaterThan(0); + }); + + it('mostra le domande del questionario senza che nessuno semini a mano', () => { + const db = createLongevityDb(':memory:'); + const campi = campiDelQuestionario(db, OGGI); + expect(campi.length).toBeGreaterThan(0); + // I tre sotto-domini che il questionario alimenta oggi: se un giorno il registro + // perdesse una famiglia di domande, il referto continuerebbe a "funzionare" con un + // asse in meno e nessuno se ne accorgerebbe. + const sottoDomini = new Set(campi.map((c) => c.sottoDominio)); + expect(sottoDomini).toContain('questionario_sonno'); + expect(sottoDomini).toContain('questionario_energia_stress'); + expect(sottoDomini).toContain('questionario_lifestyle'); + }); + + it('ha i pesi del modello corrente, per assi e macro', () => { + const db = createLongevityDb(':memory:'); + const perLivello = db.prepare( + 'SELECT livello, COUNT(*) AS n FROM pesi WHERE model_version = ? GROUP BY livello' + ).all(MODEL_VERSION) as { livello: string; n: number }[]; + const mappa = Object.fromEntries(perLivello.map((r) => [r.livello, r.n])); + expect(mappa.asse).toBeGreaterThan(0); + expect(mappa.macro).toBeGreaterThan(0); + expect(mappa.fitness_age).toBeGreaterThan(0); + }); + + it('rilanciare la creazione non duplica niente (INSERT OR REPLACE)', () => { + const db = createLongevityDb(':memory:'); + const prima = (db.prepare('SELECT COUNT(*) AS n FROM registro_test').get() as { n: number }).n; + // Stessa semina, stesso database: è ciò che accade a ogni riavvio del container. + seedRegistro(db); seedPesi(db, MODEL_VERSION); + const dopo = (db.prepare('SELECT COUNT(*) AS n FROM registro_test').get() as { n: number }).n; + expect(dopo).toBe(prima); + }); +}); From a7b81e2ff488d1a524acef138a5c39e63977fb09 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Thu, 3 Sep 2026 12:36:33 +0000 Subject: [PATCH 57/62] Le aree riservate partono da insanitylab.tielogic.xyz Deciso da Adriano il 03/09/2026: il dominio del cliente resta su Aruba e non si tocca, le piattaforme partono da un sottodominio nostro. Scelto l'host che risolveva gia' e aveva gia' il certificato - fino a oggi faceva 308 verso l'apex del sito - invece di aspettare un record DNS nuovo. Tre modifiche coordinate, che vanno insieme o l'host resta senza padrone: - allowedDomains dell'app: aggiunto l'host temporaneo accanto a quello definitivo. Senza, dietro Traefik il CSRF di Astro respinge ogni POST con 403 e il login sembra rifiutare le credenziali. - router del container: Host(insanitylab.tielogic.xyz) - compose del sito: quell'host esce dal router di redirect (www continua) Quando il record A "apps" esistera' su Aruba si tengono doppi per un po', invece di spostare di netto un indirizzo che qualcuno ha gia' salvato. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL --- apps/platforms/astro.config.mjs | 10 +++++++++- apps/platforms/compose.yaml | 7 ++++++- apps/sito/compose.yaml | 5 ++++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/apps/platforms/astro.config.mjs b/apps/platforms/astro.config.mjs index 1533e26..51957d0 100644 --- a/apps/platforms/astro.config.mjs +++ b/apps/platforms/astro.config.mjs @@ -11,7 +11,15 @@ export default defineConfig({ // il login smette di funzionare, e l'errore sembra "credenziali sbagliate". // Stessa configurazione del sito, con l'host di quest'app. security: { - allowedDomains: [{ hostname: 'apps.insanitylab.it', protocol: 'https' }], + allowedDomains: [ + // L'host definitivo, quando il record A su Aruba ci sara'. + { hostname: 'apps.insanitylab.it', protocol: 'https' }, + // ⚠️ L'host TEMPORANEO (03/09/2026): il dominio del cliente resta su Aruba e le aree + // riservate partono da un sottodominio nostro, che risolve gia'. Finche' e' in uso + // deve stare qui: senza, dietro Traefik il controllo CSRF di Astro respinge ogni POST + // con 403 e il login sembra rifiutare le credenziali. + { hostname: 'insanitylab.tielogic.xyz', protocol: 'https' }, + ], }, adapter: node({ mode: 'standalone' }), diff --git a/apps/platforms/compose.yaml b/apps/platforms/compose.yaml index 41eeaf8..419380f 100644 --- a/apps/platforms/compose.yaml +++ b/apps/platforms/compose.yaml @@ -23,7 +23,12 @@ services: - /opt/docker/insanitylab-platforms/data:/app/data labels: - traefik.enable=true - - traefik.http.routers.insanitylab-platforms.rule=Host(`apps.insanitylab.it`) + # ⚠️ Host temporaneo: `insanitylab.tielogic.xyz` risolveva gia' e aveva gia' il + # certificato (fino al 03/09/2026 faceva 308 verso l'apex del sito, redirect tolto + # dal compose del sito nello stesso momento). Quando il record A `apps` esistera' su + # Aruba, qui si aggiunge `|| Host(`apps.insanitylab.it`)` e si tiene doppio per un + # po', invece di spostare di netto un indirizzo che qualcuno ha gia' salvato. + - traefik.http.routers.insanitylab-platforms.rule=Host(`insanitylab.tielogic.xyz`) - traefik.http.routers.insanitylab-platforms.tls=true - traefik.http.routers.insanitylab-platforms.entrypoints=websecure - traefik.http.routers.insanitylab-platforms.tls.certresolver=mytlschallenge diff --git a/apps/sito/compose.yaml b/apps/sito/compose.yaml index ac6868d..4c04f17 100644 --- a/apps/sito/compose.yaml +++ b/apps/sito/compose.yaml @@ -32,7 +32,10 @@ services: - traefik.http.routers.insanitylab.tls.certresolver=mytlschallenge - traefik.http.services.insanitylab.loadbalancer.server.port=4321 # Router redirect: www e vecchio sottodominio tielogic.xyz → 301 apex - - traefik.http.routers.insanitylab-redir.rule=Host(`www.insanitylab.it`) || Host(`insanitylab.tielogic.xyz`) + # ⚠️ `insanitylab.tielogic.xyz` NON e' piu' qui dal 03/09/2026: e' l'host temporaneo + # delle aree riservate (apps/platforms). Rimetterlo qui gliela toglie di sotto, e il + # sintomo sarebbe un 308 verso l'apex al posto del login. + - traefik.http.routers.insanitylab-redir.rule=Host(`www.insanitylab.it`) - traefik.http.routers.insanitylab-redir.tls=true - traefik.http.routers.insanitylab-redir.entrypoints=websecure - traefik.http.routers.insanitylab-redir.tls.certresolver=mytlschallenge From 15dbe6a397304a4cd72951fac0b5339ecd3cb849 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Thu, 3 Sep 2026 12:39:29 +0000 Subject: [PATCH 58/62] Le aree riservate hanno il loro host: piattaforme.tielogic.xyz Creato da Adriano sulla zona tielogic.xyz (Hostinger). Sostituisce il prestito di insanitylab.tielogic.xyz, durato un'ora: quell'host e' tornato al suo 308 verso l'apex e il sito e' di nuovo esattamente com'era. allowedDomains, router del container e router di redirect del sito cambiano insieme: e' la terna che decide di chi e' un host, e sfasarla lascia un indirizzo senza padrone o un login che risponde 403. Verificato in HTTPS: certificato emesso per il nome nuovo, login e referto funzionanti, insanitylab.it 200 e i due redirect al loro posto. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL --- apps/platforms/astro.config.mjs | 10 +++++----- apps/platforms/compose.yaml | 13 +++++++------ apps/sito/compose.yaml | 5 +---- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/apps/platforms/astro.config.mjs b/apps/platforms/astro.config.mjs index 51957d0..76dcd77 100644 --- a/apps/platforms/astro.config.mjs +++ b/apps/platforms/astro.config.mjs @@ -14,11 +14,11 @@ export default defineConfig({ allowedDomains: [ // L'host definitivo, quando il record A su Aruba ci sara'. { hostname: 'apps.insanitylab.it', protocol: 'https' }, - // ⚠️ L'host TEMPORANEO (03/09/2026): il dominio del cliente resta su Aruba e le aree - // riservate partono da un sottodominio nostro, che risolve gia'. Finche' e' in uso - // deve stare qui: senza, dietro Traefik il controllo CSRF di Astro respinge ogni POST - // con 403 e il login sembra rifiutare le credenziali. - { hostname: 'insanitylab.tielogic.xyz', protocol: 'https' }, + // ⚠️ L'host in uso oggi (03/09/2026): il dominio del cliente resta su Aruba e le aree + // riservate stanno su un sottodominio nostro. Finche' e' in uso deve stare qui: + // senza, dietro Traefik il controllo CSRF di Astro respinge ogni POST con 403 e il + // login sembra rifiutare le credenziali. + { hostname: 'piattaforme.tielogic.xyz', protocol: 'https' }, ], }, diff --git a/apps/platforms/compose.yaml b/apps/platforms/compose.yaml index 419380f..3f0b37b 100644 --- a/apps/platforms/compose.yaml +++ b/apps/platforms/compose.yaml @@ -23,12 +23,13 @@ services: - /opt/docker/insanitylab-platforms/data:/app/data labels: - traefik.enable=true - # ⚠️ Host temporaneo: `insanitylab.tielogic.xyz` risolveva gia' e aveva gia' il - # certificato (fino al 03/09/2026 faceva 308 verso l'apex del sito, redirect tolto - # dal compose del sito nello stesso momento). Quando il record A `apps` esistera' su - # Aruba, qui si aggiunge `|| Host(`apps.insanitylab.it`)` e si tiene doppio per un - # po', invece di spostare di netto un indirizzo che qualcuno ha gia' salvato. - - traefik.http.routers.insanitylab-platforms.rule=Host(`insanitylab.tielogic.xyz`) + # L'host di casa, creato da Adriano il 03/09/2026 sulla zona tielogic.xyz (Hostinger). + # ⚠️ Per un'ora circa le piattaforme avevano preso in prestito `insanitylab.tielogic.xyz`, + # che risolveva gia': quel prestito e' finito e l'host e' tornato al suo 308 verso + # l'apex del sito. Quando il record A `apps` esistera' su Aruba, qui si aggiunge + # `|| Host(`apps.insanitylab.it`)` e si tengono doppi per un po', invece di spostare + # di netto un indirizzo che nel frattempo qualcuno ha salvato. + - traefik.http.routers.insanitylab-platforms.rule=Host(`piattaforme.tielogic.xyz`) - traefik.http.routers.insanitylab-platforms.tls=true - traefik.http.routers.insanitylab-platforms.entrypoints=websecure - traefik.http.routers.insanitylab-platforms.tls.certresolver=mytlschallenge diff --git a/apps/sito/compose.yaml b/apps/sito/compose.yaml index 4c04f17..ac6868d 100644 --- a/apps/sito/compose.yaml +++ b/apps/sito/compose.yaml @@ -32,10 +32,7 @@ services: - traefik.http.routers.insanitylab.tls.certresolver=mytlschallenge - traefik.http.services.insanitylab.loadbalancer.server.port=4321 # Router redirect: www e vecchio sottodominio tielogic.xyz → 301 apex - # ⚠️ `insanitylab.tielogic.xyz` NON e' piu' qui dal 03/09/2026: e' l'host temporaneo - # delle aree riservate (apps/platforms). Rimetterlo qui gliela toglie di sotto, e il - # sintomo sarebbe un 308 verso l'apex al posto del login. - - traefik.http.routers.insanitylab-redir.rule=Host(`www.insanitylab.it`) + - traefik.http.routers.insanitylab-redir.rule=Host(`www.insanitylab.it`) || Host(`insanitylab.tielogic.xyz`) - traefik.http.routers.insanitylab-redir.tls=true - traefik.http.routers.insanitylab-redir.entrypoints=websecure - traefik.http.routers.insanitylab-redir.tls.certresolver=mytlschallenge From 89d2186e69fc748bdec8737689177e4533a01928 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Thu, 3 Sep 2026 12:41:53 +0000 Subject: [PATCH 59/62] Il vecchio sottodominio esce anche dal compose del ramo insanitylab.tielogic.xyz e' stato tolto dal DNS: una regola Traefik che lo nomina fa fallire una sfida ACME a ogni tentativo, ed e' cio' che Let's Encrypt limita. Resta il redirect di www. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL --- apps/platforms/compose.yaml | 6 +++--- apps/sito/compose.yaml | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/platforms/compose.yaml b/apps/platforms/compose.yaml index 3f0b37b..13af5a2 100644 --- a/apps/platforms/compose.yaml +++ b/apps/platforms/compose.yaml @@ -24,9 +24,9 @@ services: labels: - traefik.enable=true # L'host di casa, creato da Adriano il 03/09/2026 sulla zona tielogic.xyz (Hostinger). - # ⚠️ Per un'ora circa le piattaforme avevano preso in prestito `insanitylab.tielogic.xyz`, - # che risolveva gia': quel prestito e' finito e l'host e' tornato al suo 308 verso - # l'apex del sito. Quando il record A `apps` esistera' su Aruba, qui si aggiunge + # ⚠️ Per un'ora circa le piattaforme avevano preso in prestito `insanitylab.tielogic.xyz`: + # quel prestito e' finito, e poi Adriano ha tolto del tutto quel record dal DNS. + # Quando il record A `apps` esistera' su Aruba, qui si aggiunge # `|| Host(`apps.insanitylab.it`)` e si tengono doppi per un po', invece di spostare # di netto un indirizzo che nel frattempo qualcuno ha salvato. - traefik.http.routers.insanitylab-platforms.rule=Host(`piattaforme.tielogic.xyz`) diff --git a/apps/sito/compose.yaml b/apps/sito/compose.yaml index ac6868d..a67e995 100644 --- a/apps/sito/compose.yaml +++ b/apps/sito/compose.yaml @@ -32,7 +32,11 @@ services: - traefik.http.routers.insanitylab.tls.certresolver=mytlschallenge - traefik.http.services.insanitylab.loadbalancer.server.port=4321 # Router redirect: www e vecchio sottodominio tielogic.xyz → 301 apex - - traefik.http.routers.insanitylab-redir.rule=Host(`www.insanitylab.it`) || Host(`insanitylab.tielogic.xyz`) + # ⚠️ `insanitylab.tielogic.xyz` e' stato tolto dal DNS il 03/09/2026. Nominare in una + # regola un host che non risolve non e' inerte: il router chiede un certificato per i + # nomi della sua regola, e la sfida ACME fallisce a ogni tentativo — cio' che Let's + # Encrypt limita. Resta il solo redirect di `www`. + - traefik.http.routers.insanitylab-redir.rule=Host(`www.insanitylab.it`) - traefik.http.routers.insanitylab-redir.tls=true - traefik.http.routers.insanitylab-redir.entrypoints=websecure - traefik.http.routers.insanitylab-redir.tls.certresolver=mytlschallenge From 62b19b21d9d7374b87b9c67478a78347c6465c6a Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Thu, 3 Sep 2026 12:45:17 +0000 Subject: [PATCH 60/62] Il link del sito all'area riservata punta all'host che esiste Era rimasto apps.insanitylab.it, che e' l'indirizzo definitivo ma non ha ancora un record su Aruba: alla fusione il menu del sito avrebbe portato a un errore di DNS, e nessun test se ne sarebbe accorto perche' il link esce dal dominio e non lo serve nessuna rotta interna. Ora punta a piattaforme.tielogic.xyz, con un test che lo presidia. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL --- apps/sito/src/components/Header.astro | 5 ++++- apps/sito/tests/link-area-riservata.test.ts | 24 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 apps/sito/tests/link-area-riservata.test.ts diff --git a/apps/sito/src/components/Header.astro b/apps/sito/src/components/Header.astro index 927c60c..d3e5ced 100644 --- a/apps/sito/src/components/Header.astro +++ b/apps/sito/src/components/Header.astro @@ -12,7 +12,10 @@ const path = Astro.url.pathname; // ⚠️ Le aree riservate (Campus, Stress Index, Longevity) dal 03/09/2026 NON sono in questo // sito: vivono su apps.insanitylab.it, con utenti e sessioni propri. Qui resta un link che // esce, non un menu — questo sito non sa più chi possa aprirle, ed è la separazione voluta. -const AREA_RISERVATA = 'https://apps.insanitylab.it'; +// ⚠️ L'indirizzo vero, oggi: apps.insanitylab.it NON esiste (manca il record su Aruba) e +// puntarci scriverebbe nel menu del sito un link morto. Quando quel record ci sara', qui +// si cambia una riga — ed e' l'unico posto del sito che nomina l'area riservata. +const AREA_RISERVATA = 'https://piattaforme.tielogic.xyz'; const areaHref = user ? landingFor(user.role) : '/admin'; ---