HormoneTrack-web/js/pk/pk-profile-store.js
Siphonight 491efef8eb v1.4.10 — portage initial de l'app Android v1.4.10
Portage navigateur complet de l'app Android HormoneTrack v1.4.10 :
- moteur PK fidèle (Estrannaise tables ODS, Transfem Science V3C,
  WHSAH fit Mona, Bateman) — paramètres identiques, invariants Android
  préservés (fixes #19-#23, #35, #52-#62), 121 tests Node épinglés
- calibration par période d'ester et par modèle affiché (#60/#61)
- UI 6 écrans + éditeur, CurveChart Canvas (zoom/pan fractionnaire,
  prévision, pics/creux, fuseau configurable), rappels web
  (Notification API), seuils d'alerte, i18n FR/EN
- sauvegarde JSON v2 compatible Android bidirectionnelle (rétrocompat v1)
- 100 % local : localStorage, aucun serveur applicatif, aucune télémétrie
- processus : scripts/check.sh (syntaxe, i18n, tests, E2E navigateur,
  smoke HTTP), docs séparées (README + DEVELOPPEMENT + CHANGELOG)
- dépôt GIT SÉPARÉ de l'Android : historique 100 % propre, versions
  alignées sur l'Android porté, changelogs indépendants

Non porté (impossible dans un navigateur, documenté §12) : agenda
récurrent, notifications onglet fermé, montre.
2026-09-08 14:34:58 +02:00

172 lines
7.1 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.
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;
}