/** * ───────────────────────────────────────────────────────────────────────────── * PKProfileStore — chargeur de l'asset `pk_profiles.json` + échantillonnage * (portage web de `pk/PKProfileStore.kt`). * * L'asset contient les tables horaires Estrannaise extraites du tableur * `Estrogen.ods` : { "params": {D/k1/k2/k3…}, "profiles": { "EV_ese": [8001 * floats], … } } — profils NORMALISÉS (pg/mL par mg injecté) sur 8001 h. * * ⚠️ Les tables affichent 2 décimales → plancher d'affichage 0,01/0,00 en * queue (bug « courbe plate à 0 » du début du projet, cf DEVELOPPEMENT.md * Android §14 #20) : l'échantillonnage EXTRAPOLE au-delà de la table depuis * le dernier point encore ≥ 1 % du pic (cf `sample`). * * Ennavigateur : `init(url)` (fetch de l'asset). En Node (tests) : * `initWithJson(texte)` — point d'entrée PUR, identique au Kotlin. * ───────────────────────────────────────────────────────────────────────────── */ /** Modèle strict : seule cette table de suffixes est acceptée (bug §14 #21 : * tout modèle inconnu renvoyait silencieusement Estrannaise → validation stricte). */ const MODEL_SUFFIX = { TFS: 'tfs', ESE: 'ese' }; let profiles = null; // Map — null tant que non initialisé /** * Initialise le store depuis un JSON (PUR — testable en Node). * ⚠️ La racine du JSON contient `profiles` : lire la racine directement = le * crash du tout premier build (bug §14 #4). Structure attendue : * `{ "params": {...}, "profiles": { "_": [floats] } }`. * * @param {string} json Contenu texte de pk_profiles.json */ export function initWithJson(json) { const root = JSON.parse(json); const obj = root.profiles; // ⚠️ pas la racine (bug §14 #4) const parsed = new Map(); for (const [key, arr] of Object.entries(obj)) { parsed.set(key, Float64Array.from(arr)); } profiles = parsed; } /** * Initialise le store en navigateur (fetch de l'asset statique). * À appeler UNE fois au démarrage de l'app, avant tout rendu. * * @param {string} [url='assets/pk_profiles.json'] URL de l'asset */ export async function init(url = 'assets/pk_profiles.json') { const resp = await fetch(url); if (!resp.ok) throw new Error(`pk_profiles.json : HTTP ${resp.status}`); initWithJson(await resp.text()); } /** * Clé de profil pour un couple (ester, modèle) — ex. ("EV","ESE") → "EV_ese". * (Le Kotlin construit la même clé ; le lookup réel est insensible à la casse.) * * @param {string} ester "EV" / "EU" / "EEN" … * @param {string} mod "ESE" | "TFS" (les autres modèles n'ont pas de table) * @returns {string} clé de table, ex. "EV_ese" */ export function profileKey(ester, mod) { const suffix = mod === 'TFS' ? 'tfs' : 'ese'; return `${ester}_${suffix}`; } /** * Lookup INSENSIBLE À LA CASSE (bug #22 — LE bug « courbes vides » : l'asset * contient "EEn_ese" (casse biologique de l'ODS) alors que la constante de * l'app est Esters.EEN = "EEN" ; un lookup exact renvoyait null pour tout * traitement EEn → courbe E2 plate à 0). * * @param {string} key Clé de table ("EEn_ese" …) * @returns {Float64Array|null} */ function lookup(key) { if (!profiles) return null; return profiles.get(key) // fallback insensible à la casse (première clé qui matche) || [...profiles.entries()].find(([k]) => k.toLowerCase() === key.toLowerCase())?.[1] || null; } /** * @param {string} ester Clé ester * @param {string} mod "ESE" | "TFS" * @returns {boolean} true si une table horaire existe pour ce couple */ export function hasProfile(ester, mod) { return lookup(profileKey(ester, mod)) !== null; } /** * Longueur de table (heures) — utilisée par le moteur comme `cutoffHours` * pour les profils à tables (la contribution d'une dose est coupée au-delà). * * @param {string} ester Clé ester * @param {string} mod "ESE" | "TFS" * @returns {number} nombre de points (0 si pas de table) */ export function profileLength(ester, mod) { return lookup(profileKey(ester, mod))?.length ?? 0; } /** * Réponse normalisée (pg/mL par mg injecté) à dtHours après une injection de * 1 mg — modèle STRICT par tables : * - modèle inconnu (ni TFS ni ESE) → 0 (jamais de fallback silencieux — bug §14 #21) ; * - interpolation LINÉAIRE entre heures entières ; * - extrapolation TERMINALE au-delà de la table depuis le dernier point * ≥ 1 % du pic, avec la pente = décroissance moyenne des 48 h précédentes * (jamais avant le pic — cf plancher 0,01/0,00 de l'ODS, bug §14 #20). * * ⚠️ Tout est calculé en double précision JS (équivalent Double Kotlin — le * mélange Float/Double était une source d'erreurs de compilation côté Android). * * @param {string} ester Clé ester ("EV"…) * @param {string} mod "ESE" | "TFS" * @param {number} dtHours Heures depuis l'injection (≤ 0 → 0) * @returns {number} pg/mL par mg */ export function sample(ester, mod, dtHours) { if (!profiles || dtHours <= 0.0) return 0.0; const suffix = MODEL_SUFFIX[mod]; // strict : TFS→tfs, ESE→ese, autre → undefined if (!suffix) return 0.0; const arr = lookup(`${ester}_${suffix}`); if (!arr || arr.length < 2) return 0.0; const lastIdx = arr.length - 1; if (dtHours >= lastIdx) { // ── Extrapolation terminale (miroir exact du Kotlin) ─────────────────── // Les tables ODS sont arrondies à 2 décimales et s'effondrent en un // plancher 0,01/0,00 bien avant que la vraie valeur ne s'annule. // Extrapoler depuis la FIN de table donnait 0 à vie (ou une constante // plate) → on part du dernier point encore ≥ 1 % du pic, avec le TAUX // logarithmique moyen des 48 h précédentes (jamais avant le pic) — // extrapolation EXPONENTIELLE, donc jamais croissante. let peakIdx = 0; let peakV = 0; for (let idx = 0; idx < arr.length; idx++) { if (arr[idx] > peakV) { peakV = arr[idx]; peakIdx = idx; } } if (peakV <= 0) return 0.0; let j = lastIdx; while (j > 0 && arr[j] < peakV * 0.01) j--; if (j <= 0) return 0.0; const WINDOW = 48; const j0 = Math.max(peakIdx, j - WINDOW); let rate = 0.0; if (j > j0) { rate = Math.log(Math.max(arr[j], 1e-12) / Math.max(arr[j0], 1e-12)) / (j - j0); } return arr[j] * Math.exp(rate * (dtHours - j)); } // ── Interpolation linéaire entre heures entières ──────────────────────── const i0 = Math.floor(dtHours); const frac = dtHours - i0; if (i0 >= lastIdx) return arr[lastIdx]; // garde défensive return arr[i0] + (arr[i0 + 1] - arr[i0]) * frac; } /** @returns {boolean} true si le store a été initialisé (asset chargé). */ export function isInitialized() { return profiles !== null; }