HormoneTrack-web/js/pk/pk-profile-store.js
Siphonight f5951e1806 v1.8.0 : recommandation de prochaine prise de sang (portage Android, miroir)
- 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.
2026-09-17 18:46:51 +02:00

212 lines
8.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ─────────────────────────────────────────────────────────────────────────────
* 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;
}