- js/pk/lab-timing.js : miroir fidèle de pk/LabTiming.kt — creux prévisionnel (minimum de la courbe E2 entre 2 injections), saut au premier creux STABILISÉ (5 × t½ terminale : TFS/WHS analytiques, Estrannaise lue dans la table via le nouveau pkProfileStore.terminalHalfLifeDays — refactor de la pente d'extrapolation de sample(), zéro duplication), filtres honnêtes (injectable actif + Posologie requis, creux ≥ now+6 h et jamais déjà mesuré, horizon borné → null), shouldSuggestPosology (invite quand un injectable actif est sans Posologie — demande v1.8.0). - labs.js : encart « Prochaine prise de sang (suggestion) » ou invite, mutuellement exclusifs (miroir carte Android). - i18n FR/EN (labrec_*), WEB_VERSION 1.8.0, assertions E2E. - tests/lab-timing.test.js : 7 tests miroirs (152 verts au total) + E2E (encart rendu : titre, creux daté, créneau, stabilisation). - Doc : §4 arbre, §8 suite + E2E, CHANGELOG [1.8.0], README.
212 lines
8.6 KiB
JavaScript
212 lines
8.6 KiB
JavaScript
/**
|
||
* ─────────────────────────────────────────────────────────────────────────────
|
||
* 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<string, Float64Array> — 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": { "<ESTER>_<suffix>": [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.
|
||
const [j, rate] = terminalDecayParameters(arr) ?? [0, 0.0];
|
||
if (j <= 0) return 0.0;
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Paramètres de DÉCROISSANCE TERMINALE d'une table (v1.8.0 — partagés par
|
||
* [sample] et [terminalHalfLifeDays], miroir du Kotlin) : dernier point
|
||
* ≥ 1 % du pic + pente log-linéaire /h sur les 48 h précédentes (jamais
|
||
* avant le pic).
|
||
*
|
||
* @private
|
||
* @param {number[]} arr table horaire
|
||
* @returns {[number, number]|null} [index j, pente /h] — null si ni pic ni
|
||
* queue exploitable
|
||
*/
|
||
function terminalDecayParameters(arr) {
|
||
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 null;
|
||
let j = arr.length - 1;
|
||
while (j > 0 && arr[j] < peakV * 0.01) j--;
|
||
if (j <= 0) return null;
|
||
const WINDOW = 48;
|
||
const j0 = Math.max(peakIdx, j - WINDOW);
|
||
if (j <= j0) return null;
|
||
const rate = Math.log(Math.max(arr[j], 1e-12) / Math.max(arr[j0], 1e-12)) / (j - j0);
|
||
return [j, rate];
|
||
}
|
||
|
||
/**
|
||
* Demi-vie TERMINALE (JOURS) estimée depuis la table horaire (v1.8.0, miroir
|
||
* du Kotlin) : t½ = ln2 ÷ |pente terminale| — cf [terminalDecayParameters].
|
||
*
|
||
* Pourquoi : la recommandation de prochaine prise de sang (js/pk/lab-timing.js)
|
||
* juge de la STABILISATION du régime via 5 × t½ — et le modèle Estrannaise
|
||
* (tables ODS) n'a pas de forme close : sa t½ se LIT dans la table (les
|
||
* modèles TFS/WHSAH exposent la leur analytiquement).
|
||
*
|
||
* @param {string} ester Clé ester ("EV"…)
|
||
* @param {string} mod "ESE" | "TFS"
|
||
* @returns {number|null} t½ en jours, ou null si pas de décroissance
|
||
* terminale exploitable (ester inconnu, pic nul, pas de queue)
|
||
*/
|
||
export function terminalHalfLifeDays(ester, mod) {
|
||
if (!profiles) return null;
|
||
const suffix = MODEL_SUFFIX[mod];
|
||
if (!suffix) return 0.0;
|
||
const arr = lookup(`${ester}_${suffix}`);
|
||
if (!arr) return null;
|
||
const decay = terminalDecayParameters(arr);
|
||
if (!decay || decay[1] >= 0) return null;
|
||
return Math.log(2) / -decay[1] / 24.0; // pente /h → t½ en jours
|
||
}
|
||
|
||
/** @returns {boolean} true si le store a été initialisé (asset chargé). */
|
||
export function isInitialized() {
|
||
return profiles !== null;
|
||
}
|