/** * ───────────────────────────────────────────────────────────────────────────── * Modèle **Estrannaise analytique** (v1.9.0, miroir fidèle de * `pk/EstrannaiseModels.kt`) — remplace définitivement les tables horaires * ODS pour le modèle ESE. * * SOURCE ORIGINELLE : https://github.com/WHSAH/estrannaise.js/ — le modèle 3 * compartiments avec les paramètres d'ajustement **publiés par Estrannaise** * (t en JOURS, dose en mg → pg/mL) : * * C(t) = dose · d · k1·k2 · [ e^(−k1·t)/((k1−k2)(k1−k3)) * − e^(−k2·t)/((k1−k2)(k2−k3)) * + e^(−k3·t)/((k1−k3)(k2−k3)) ] * * FIDÉLITÉ (épinglée par EstrannaiseModelsTest) : cette forme close * reproduit les anciennes tables ODS à l'identique (RMS 0,00 sur 0→200 j, * écart de pic ≤ 0,1 %) — l'ODS n'était que l'échantillonnage horaire de * ces formules. Décision v1.9.0 : « tous les modèles ont maintenant leurs * sources originelles — on abandonne complètement les liens avec le * fichier ODS ». * * NUAGE D'INCERTITUDE (v1.9.0, exclusif ESE) : Estrannaise publie le * **posterior MCMC** de ses paramètres (313 échantillons par ester — * `assets/mcmc_samples.json`, 48 Ko, fetché au démarrage). Le graphique * peut dessiner un nuage diffus de courbes montrant la plage * d'imprécision du modèle. TFS/WHSAH ne publient pas de posterior. * * PUR sauf [init] (fetch de l'asset MCMC) — la formule est testable en * Node (tests/estrannaise-models.test.js). * ───────────────────────────────────────────────────────────────────────────── */ // ── Paramètres d'ajustement publiés par Estrannaise (src/modeldata.js) ────── // Clés = clés esters de l'app. Mapping depuis le repo : « EV im »→EV, // « EUn im »→EU, « EEn im »→EEn, « EC im »→EC, « EB im »→EB, // « EUn casubq »→EUCS (nouveau code ester v1.9.0). const PK_PARAMETERS = { EV: [478.0, 0.236, 4.85, 1.24], EU: [471.5, 0.01729, 6.528, 2.285], EEN: [191.4, 0.119, 0.601, 0.402], EC: [246.0, 0.0825, 3.57, 0.669], EB: [1893.1, 0.67, 61.5, 4.34], EUCS: [16.15, 0.046, 0.022, 0.101], }; let mcmc = null; // posterior MCMC (chargé via init()) — { ester: [[d,k1,k2,k3] × 313] } /** * Fetch de l'asset `assets/mcmc_samples.json` (48 Ko) puis init — appelé au * démarrage par app.js (remplace l'ancien fetch des tables ODS, 550 Ko). * Silencieux en cas d'échec (le nuage restera simplement vide — les courbes * nommées ne dépendent pas du posterior). */ export async function init() { try { const resp = await fetch('assets/mcmc_samples.json'); if (resp.ok) initWithJson(await resp.text()); } catch (_e) { // best-effort : sans MCMC, pas de nuage — les courbes restent calculables } } export function hasModel(ester) { return Object.prototype.hasOwnProperty.call(PK_PARAMETERS, String(ester).toUpperCase()); } /** Les paramètres de l'ester, ou null (lookup insensible à la casse). */ export function model(ester) { const key = String(ester).toUpperCase(); if (!hasModel(key)) return null; const [d, k1, k2, k3] = PK_PARAMETERS[key]; return { d, k1, k2, k3, /** t½ terminale ANALYTIQUE : la phase la plus lente domine la queue. */ get terminalHalfLifeDays() { return Math.log(2) / Math.min(k1, k2, k3); }, }; } /** Forme close 3C (t en JOURS) — cas général + 3 cas dégénérés (taux égaux). */ function curveDays(t, dose, m) { if (t < 0) return 0.0; let ret = 0.0; if (dose > 0 && m.d > 0) { const { k1, k2, k3 } = m; if (k1 === k2 && k2 === k3) { ret += dose * m.d * k1 * k1 * t * t * Math.exp(-k1 * t) / 2; } else if (k1 === k2 && k2 !== k3) { ret += dose * m.d * k1 * k1 * (Math.exp(-k3 * t) - Math.exp(-k1 * t) * (1 + (k1 - k3) * t)) / (k1 - k3) / (k1 - k3); } else if (k1 !== k2 && k1 === k3) { ret += dose * m.d * k1 * k2 * (Math.exp(-k2 * t) - Math.exp(-k1 * t) * (1 + (k1 - k2) * t)) / (k1 - k2) / (k1 - k2); } else if (k1 !== k2 && k2 === k3) { ret += dose * m.d * k1 * k2 * (Math.exp(-k1 * t) - Math.exp(-k2 * t) * (1 - (k1 - k2) * t)) / (k1 - k2) / (k1 - k2); } else { ret += dose * m.d * k1 * k2 * ( Math.exp(-k1 * t) / (k1 - k2) / (k1 - k3) - Math.exp(-k2 * t) / (k1 - k2) / (k2 - k3) + Math.exp(-k3 * t) / (k1 - k3) / (k2 - k3) ); } } return Number.isNaN(ret) ? 0.0 : ret; } /** * Réponse normalisée (pg/mL PAR mg) à dtHours — t converti en jours ici. * dtHours ≤ 0 → 0. */ export function sample(ester, dtHours) { const m = model(ester); if (!m || dtHours <= 0) return 0.0; return curveDays(dtHours / 24, 1.0, m); } /** Courbe avec un jeu de paramètres DONNÉ (échantillon MCMC) — nuage. */ export function sampleWithParams(ester, dtHours, p) { if (dtHours <= 0) return 0.0; return curveDays(dtHours / 24, 1.0, p); } /** * Charge le posterior MCMC depuis `assets/mcmc_samples.json` (appelé au * démarrage par app.js — remplace le fetch des tables ODS). * @param {string} json contenu JSON brut */ export function initWithJson(json) { const raw = JSON.parse(json); // Mapper vers des objets {d, k1, k2, k3} (miroir du Param Kotlin) — les // tableaux bruts du JSON ne portent pas les noms de champs const parsed = {}; for (const [ester, arr] of Object.entries(raw)) { parsed[ester.toUpperCase()] = arr.map(([d, k1, k2, k3]) => ({ d, k1, k2, k3 })); } mcmc = parsed; } /** Les échantillons MCMC d'un ester (vide si non chargé / inconnu). */ export function mcmcSamples(ester) { return (mcmc && mcmc[String(ester).toUpperCase()]) || []; }