v1.5.0 : « Tracé labs » — portage complet de l'Android v1.5.0 (versions sync)
Miroir strict de pk/LabTrajectoryModel.kt (Android v1.5.0) : - js/pk/lab-trajectory-model.js : courbe hybride M(t)×ρ(t) ancrée sur les labs — passage EXACT garanti, ρ log-linéaire, garde #61 (lab hors fenêtre d'action pas un ancrage), E2 seul, fenêtre [1er ; dernier lab], < 2 labs → vide, indépendante de la calibration (sinon double correction). JAMAIS dans levelAt/alertes. - UI : chip 'Tracé labs' (off par défaut) dans la rangée modèles + scroll horizontal CONFINÉ à la rangée (miroir du fix Android) ; couleur #c2185b dashed ; leçon #63 Android-miroir — clé 'LAB' SKIPPÉE de la boucle légende (sinon légende TFS dupliquée). - 11 tests miroirs de LabTrajectoryModelTest.kt (132 verts total). - WEB_VERSION = 1.5.0 + CHANGELOG [1.5.0] + docs (§4/§8, README). Détail d'exécution épingle: les deux problèmes du portage ont été attrapés par node --check/E2E avant release (imports ESM faux — labIsSignificant vit dans pk-calibration.js, pas pk-engine.js).
This commit is contained in:
parent
e078650045
commit
dfef8e90ef
@ -17,7 +17,7 @@
|
|||||||
> Fie-toi toujours à tes analyses de sang et aux consignes de ton
|
> Fie-toi toujours à tes analyses de sang et aux consignes de ton
|
||||||
> endocrinologue.
|
> endocrinologue.
|
||||||
|
|
||||||
- **Statut** : web v1.4.10 — portage de l'Android v1.4.10 · **121 tests verts** ·
|
- **Statut** : web v1.5.0 — portage de l'Android v1.5.0 · **132 tests verts** ·
|
||||||
E2E navigateur ✅ · conteneur testé en configuration durcie ✅ · lint/i18n ✅
|
E2E navigateur ✅ · conteneur testé en configuration durcie ✅ · lint/i18n ✅
|
||||||
- **Journal des versions web** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
|
- **Journal des versions web** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
|
||||||
- **Releases** : [gitea.cloudyfy.fr/Siphonight/HormoneTrack-web/releases](https://gitea.cloudyfy.fr/Siphonight/HormoneTrack-web/releases)
|
- **Releases** : [gitea.cloudyfy.fr/Siphonight/HormoneTrack-web/releases](https://gitea.cloudyfy.fr/Siphonight/HormoneTrack-web/releases)
|
||||||
@ -113,6 +113,8 @@ machine qui sert les fichiers.
|
|||||||
(auto-calibration optionnelle, désactivée par défaut + bouton manuel).
|
(auto-calibration optionnelle, désactivée par défaut + bouton manuel).
|
||||||
- **Seuils d'alerte** configurables (E2/T haut/bas) avec notification
|
- **Seuils d'alerte** configurables (E2/T haut/bas) avec notification
|
||||||
navigateur et anti-spam.
|
navigateur et anti-spam.
|
||||||
|
- **« Tracé labs » (v1.5.0)** : courbe hybride ancrée sur tes labs
|
||||||
|
(chip `Tracé labs`, off par défaut) — miroir strict de l'Android v1.5.0
|
||||||
- **Rappels** via les notifications du navigateur (actions « Loguer
|
- **Rappels** via les notifications du navigateur (actions « Loguer
|
||||||
maintenant » / « Reporter 1 h ») — tant que la page est ouverte, cf
|
maintenant » / « Reporter 1 h ») — tant que la page est ouverte, cf
|
||||||
[limites](#limites-vs-lapp-android).
|
[limites](#limites-vs-lapp-android).
|
||||||
|
|||||||
@ -5,6 +5,24 @@
|
|||||||
> section la plus récente EN PREMIER ; ce fichier est lu par le dialog
|
> section la plus récente EN PREMIER ; ce fichier est lu par le dialog
|
||||||
> « Nouveautés » de l'app web (fetch `docs/CHANGELOG.md`).
|
> « Nouveautés » de l'app web (fetch `docs/CHANGELOG.md`).
|
||||||
|
|
||||||
|
## [1.5.0] — 2026-09-11
|
||||||
|
|
||||||
|
### « Tracé labs » — portage de la version Android v1.5.0 (versions synchronisées)
|
||||||
|
|
||||||
|
- **Nouvelle courbe superposable** (chip `Tracé labs`, **off par défaut**)
|
||||||
|
: `courbe(t) = M(t) × ρ(t)` — le modèle PK garde la forme biologique
|
||||||
|
(Tmax, queues, accumulation), l'amplitude est recalée sur CHAQUE
|
||||||
|
prise de sang — **passage EXACT garanti** sur chaque lab, transition
|
||||||
|
log-linéaire d'un lab au suivant (jamais de saut vertical).
|
||||||
|
- Garde anti-ratés (#61) : un lab hors fenêtre d'action n'est pas un
|
||||||
|
ancrage ; E2 uniquement ; fenêtre bornée [1er ; dernier lab] — la
|
||||||
|
courbe s'arrête au dernier lab (pas d'extrapolation).
|
||||||
|
- Indépendante de la calibration (sa propre référence) ; hors
|
||||||
|
pics/creux ; n'entre JAMAIS dans niveau accueil/alertes.
|
||||||
|
- La rangée de chips du graphique défile horizontalement (4ᵉ chip —
|
||||||
|
miroir du fix Android v1.5.0).
|
||||||
|
- 11 tests miroirs de `LabTrajectoryModelTest.kt` (Android).
|
||||||
|
|
||||||
## [1.4.10] — 2026-09-08
|
## [1.4.10] — 2026-09-08
|
||||||
|
|
||||||
Version initiale de la version web — portage navigateur de l'app Android
|
Version initiale de la version web — portage navigateur de l'app Android
|
||||||
|
|||||||
@ -159,7 +159,11 @@ HormoneTrack-web/ (dépôt séparé)
|
|||||||
│ │ ├── transfem-science-models.js V3C méta-analyse TFS (7 esters, params
|
│ │ ├── transfem-science-models.js V3C méta-analyse TFS (7 esters, params
|
||||||
│ │ │ copiés à l'identique du Kotlin)
|
│ │ │ copiés à l'identique du Kotlin)
|
||||||
│ │ ├── whsah-models.js fit WHSAH de Mona (6 esters, params identiques)
|
│ │ ├── whsah-models.js fit WHSAH de Mona (6 esters, params identiques)
|
||||||
│ │ ├── alerts.js Alerts.kt : seuils opt-in, codec d'état,
|
│ │ ├── lab-trajectory-model.js « Tracé labs » (v1.5.0) : courbe hybride
|
||||||
|
│ │ M(t)×ρ(t) ancrée sur les labs (miroir strict
|
||||||
|
│ │ de pk/LabTrajectoryModel.kt — garde #61,
|
||||||
|
│ │ ρ log-linéaire, fenêtre labs)
|
||||||
|
│ ├── alerts.js Alerts.kt : seuils opt-in, codec d'état,
|
||||||
│ │ │ shouldNotify (anti-spam)
|
│ │ │ shouldNotify (anti-spam)
|
||||||
│ │ ├── presets.js PKPresets.kt (22 presets, clés i18n)
|
│ │ ├── presets.js PKPresets.kt (22 presets, clés i18n)
|
||||||
│ │ ├── chart-helpers.kt fonctions pures de CurveChart.kt :
|
│ │ ├── chart-helpers.kt fonctions pures de CurveChart.kt :
|
||||||
@ -391,6 +395,9 @@ launch) — d'où le build dédié dans le cache Playwright, hors dépôt.
|
|||||||
- **calibration** : par période d'ester, **par modèle** (#60, chaque courbe
|
- **calibration** : par période d'ester, **par modèle** (#60, chaque courbe
|
||||||
calibrée passe par le lab), garde #61 (labs hors fenêtre exclus des DEUX
|
calibrée passe par le lab), garde #61 (labs hors fenêtre exclus des DEUX
|
||||||
pipelines — jamais de médiane contaminée), k T contre l'E2 CALIBRÉE.
|
pipelines — jamais de médiane contaminée), k T contre l'E2 CALIBRÉE.
|
||||||
|
- **lab-trajectory-model** : v1.5.0 « Tracé labs » — miroir du Kotlin
|
||||||
|
(passage exact, ρ log-linéaire, garde #61, fenêtres, indépendance du
|
||||||
|
scaleFactor, modelOverride, doublons, bornes).
|
||||||
- **backup** : round-trip v2, rétrocompat v1, écrasement + IDs conservés,
|
- **backup** : round-trip v2, rétrocompat v1, écrasement + IDs conservés,
|
||||||
**imports des exports Android réels** (data-driven, hors dépôt).
|
**imports des exports Android réels** (data-driven, hors dépôt).
|
||||||
- **chart-helpers** : cumul fractionnaire du pan (#62), minuit local (#55),
|
- **chart-helpers** : cumul fractionnaire du pan (#62), minuit local (#55),
|
||||||
@ -693,7 +700,7 @@ Structurels (plateforme), pas des bugs :
|
|||||||
§16.ter de la doc mère).
|
§16.ter de la doc mère).
|
||||||
|
|
||||||
---
|
---
|
||||||
*Doc mise à jour le 8 sept. 2026 (web v1.4.10, dépôt séparé
|
*Doc mise à jour le 11 sept. 2026 (web v1.5.0 — sync Android, dépôt séparé
|
||||||
HormoneTrack-web) — portage de l'Android v1.4.10, 121 tests verts + E2E,
|
HormoneTrack-web) — portage de l'Android v1.4.10, 121 tests verts + E2E,
|
||||||
backup compatible bidirectionnel, historique propre (aucune donnée de
|
backup compatible bidirectionnel, historique propre (aucune donnée de
|
||||||
santée, aucune dette de confidentialité), versions alignées sur
|
santée, aucune dette de confidentialité), versions alignées sur
|
||||||
|
|||||||
@ -8,6 +8,8 @@ export * from './pk-engine.js';
|
|||||||
export * from './pk-calibration.js';
|
export * from './pk-calibration.js';
|
||||||
export * from './pk-reminders.js';
|
export * from './pk-reminders.js';
|
||||||
export * from './pk-extrema.js';
|
export * from './pk-extrema.js';
|
||||||
|
export * as LabTrajectoryModel from './lab-trajectory-model.js';
|
||||||
|
export { computeLabAnchoredCurve, ratioAt } from './lab-trajectory-model.js';
|
||||||
export * from './chart-helpers.js';
|
export * from './chart-helpers.js';
|
||||||
export * as Alerts from './alerts.js';
|
export * as Alerts from './alerts.js';
|
||||||
export * as PKProfileStore from './pk-profile-store.js';
|
export * as PKProfileStore from './pk-profile-store.js';
|
||||||
|
|||||||
112
js/pk/lab-trajectory-model.js
Normal file
112
js/pk/lab-trajectory-model.js
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* « Tracé labs » (version web de `pk/LabTrajectoryModel.kt`, v1.5.0) —
|
||||||
|
* courbe hybride ancrée sur les résultats de sang.
|
||||||
|
*
|
||||||
|
* DEMANDE (décision v1.5.0, débattue avec l'utilisatrice — cf doc de dev
|
||||||
|
* web §7.10/§3) : courbe « biologiquement plausible MAIS passant par tous
|
||||||
|
* les points ». Formule identique à l'Android :
|
||||||
|
*
|
||||||
|
* courbe(t) = M(t) × ρ(t)
|
||||||
|
*
|
||||||
|
* - **M(t)** = niveau du modèle PK BRUT (scaleFactor forcé à 1, modèle
|
||||||
|
* stocké ou modelOverride) — porte toute la physiologie entre les labs ;
|
||||||
|
* - **ρ_k = lab_k / M(t_k)** à chaque lab ; ENTRE deux labs consécutifs ρ
|
||||||
|
* interpole LOG-LINÉAIREMENT (morphing d'amplitude continu) ;
|
||||||
|
* - courbe(t_k) = lab_k EXACTEMENT (garantie par construction, épinglé).
|
||||||
|
*
|
||||||
|
* GARDES (invariants Android portés tels quels) :
|
||||||
|
* - garde anti-#61 (labIsSignificant) : lab hors fenêtre d'action PAS un ancrage ;
|
||||||
|
* - E2 uniquement (labs T trop rares + unités variées → bug #26) ;
|
||||||
|
* - fenêtre = [1er lab significatif ; dernier lab] ∩ fenêtre du graphique ;
|
||||||
|
* - < 2 labs significatifs → courbe vide ;
|
||||||
|
* - indépendante du scaleFactor stocké (sinon DOUBLE correction — épinglé).
|
||||||
|
*
|
||||||
|
* ⚠️ display-only : la série n'entre JAMAIS dans levelAt/alertes/reminders
|
||||||
|
* (invariant §9.bis Android transposé).
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { e2At } from './pk-engine.js';
|
||||||
|
import { labIsSignificant } from './pk-calibration.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit la courbe ancrée sur les labs.
|
||||||
|
*
|
||||||
|
* @param {object[]} treatments TOUS les traitements (actifs ET inactifs — §6.bis)
|
||||||
|
* @param {object[]} doseLogs Toutes les doses
|
||||||
|
* @param {object[]} labs Tous les LabResult (filtrés E2 ici)
|
||||||
|
* @param {number} startMs Fenêtre demandée (celle du graphique)
|
||||||
|
* @param {number} endMs
|
||||||
|
* @param {number} [stepMs=3600000] Pas d'échantillonnage (stepForRange)
|
||||||
|
* @param {string|null} [modelOverride=null]
|
||||||
|
* @returns {{points:{timestamp:number,e2:number,t:number}[], anchoredLabs:number}}
|
||||||
|
*/
|
||||||
|
export function computeLabAnchoredCurve(treatments, doseLogs, labs, startMs, endMs, stepMs = 3600000, modelOverride = null) {
|
||||||
|
if (startMs >= endMs) return { points: [], anchoredLabs: 0 };
|
||||||
|
|
||||||
|
const estrogenTreatments = treatments.filter((t) => t.type === 'ESTRADIOL');
|
||||||
|
if (estrogenTreatments.length === 0) return { points: [], anchoredLabs: 0 };
|
||||||
|
|
||||||
|
// Prédictions BRUTES : scaleFactor forcé à 1 (le ρ du lab EST le facteur)
|
||||||
|
const unscaled = estrogenTreatments.map((t) => ({ ...t, scaleFactor: 1.0 }));
|
||||||
|
|
||||||
|
// ── 1) Timeline des ANCRAGES (labs croissants + garde #61) ────────────────
|
||||||
|
const anchors = []; // (t_lab, ratio)
|
||||||
|
let maxPredictionSoFar = 0.0;
|
||||||
|
const e2Labs = labs
|
||||||
|
.filter((l) => String(l.marker).toUpperCase() === 'E2')
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
for (const lab of e2Labs) {
|
||||||
|
const predicted = e2At(unscaled, doseLogs, lab.timestamp, modelOverride, null);
|
||||||
|
if (!labIsSignificant(predicted, maxPredictionSoFar)) continue;
|
||||||
|
anchors.push([lab.timestamp, lab.value / predicted]);
|
||||||
|
if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted;
|
||||||
|
}
|
||||||
|
if (anchors.length < 2) return { points: [], anchoredLabs: anchors.length };
|
||||||
|
|
||||||
|
const firstLabMs = anchors[0][0];
|
||||||
|
const lastLabMs = anchors[anchors.length - 1][0];
|
||||||
|
|
||||||
|
// ── 2) Fenêtre effective = [1er ; dernier lab] ∩ fenêtre demandée ─────────
|
||||||
|
const t0 = Math.max(startMs, firstLabMs);
|
||||||
|
const t1 = Math.min(endMs, lastLabMs);
|
||||||
|
if (t1 <= t0) return { points: [], anchoredLabs: anchors.length };
|
||||||
|
|
||||||
|
// ── 3) Courbe = M(t) × ρ(t) sur la grille (t = 0 : pas de courbe T) ───────
|
||||||
|
const points = [];
|
||||||
|
for (let t = t0; t <= t1; t += stepMs) {
|
||||||
|
const m = e2At(unscaled, doseLogs, t, modelOverride, null);
|
||||||
|
const rho = ratioAt(anchors, t);
|
||||||
|
points.push({ timestamp: t, e2: m * rho, t: 0.0 });
|
||||||
|
}
|
||||||
|
return { points, anchoredLabs: anchors.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ρ(t) : interpolation LOG-LINÉAIRE entre ancrages consécutifs
|
||||||
|
* (`anchors` trié croissant). Garanties : ρ(t_k) exactement, continuité
|
||||||
|
* multiplicative, monotone ; doublons de timestamp → le 2ᵉ ratio gagne
|
||||||
|
* (garde division par zéro) ; gardes défensives aux bornes.
|
||||||
|
*
|
||||||
|
* @param {[number, number][]} anchors (t_lab, ratio) trié croissant
|
||||||
|
* @param {number} tMs
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export function ratioAt(anchors, tMs) {
|
||||||
|
if (tMs <= anchors[0][0]) return anchors[0][1];
|
||||||
|
if (tMs >= anchors[anchors.length - 1][0]) return anchors[anchors.length - 1][1];
|
||||||
|
|
||||||
|
for (let i = 0; i < anchors.length - 1; i++) {
|
||||||
|
const [t0, r0] = anchors[i];
|
||||||
|
const [t1, r1] = anchors[i + 1];
|
||||||
|
if (tMs >= t0 && tMs <= t1) {
|
||||||
|
const span = t1 - t0;
|
||||||
|
if (span <= 0) return r1; // doublon de timestamp : 2ᵉ ratio gagne
|
||||||
|
const f = (tMs - t0) / span;
|
||||||
|
return Math.exp(Math.log(r0) + f * (Math.log(r1) - Math.log(r0)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return anchors[anchors.length - 1][1]; // inatteignable — garde
|
||||||
|
}
|
||||||
@ -36,10 +36,18 @@ export const COLORS = {
|
|||||||
grid: '#cac4d0',
|
grid: '#cac4d0',
|
||||||
label: '#6b7280',
|
label: '#6b7280',
|
||||||
nowLine: '#2fa48d',
|
nowLine: '#2fa48d',
|
||||||
|
chartLabTrajectory: '#c2185b', // v1.5.0 — Tracé labs (rose foncé)
|
||||||
forecastMarker: 'rgba(47,164,141,0.85)',
|
forecastMarker: 'rgba(47,164,141,0.85)',
|
||||||
realDoseMarker: 'rgba(73,69,79,0.55)',
|
realDoseMarker: 'rgba(73,69,79,0.55)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Une série objet : { points, e2Style:{color,dashed}, tStyle|null, showExtrema }
|
||||||
|
* v1.5.0 : `showExtrema=false` → hors pics/creux (la série « Tracé labs »
|
||||||
|
* épouse l'interp ρ, pas la physiologie du modèle).
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dessine le graphique dans un canvas.
|
* Dessine le graphique dans un canvas.
|
||||||
*
|
*
|
||||||
@ -195,7 +203,7 @@ export function drawChart(canvas, series, e2Labs, tLabs, options, forecastDoses
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pics/creux (v1.2.3/v1.2.6) : triangles + valeur estimée
|
// Pics/creux (v1.2.3/v1.2.6) : triangles + valeur estimée
|
||||||
if (options.showExtrema) {
|
if (options.showExtrema && (s.showExtrema !== false)) {
|
||||||
for (const ex of detectExtrema(s.points, (p) => p.e2, 2.0)) {
|
for (const ex of detectExtrema(s.points, (p) => p.e2, 2.0)) {
|
||||||
drawExtremum(ctx, xOf(ex.timestamp), yE2(ex.value), ex, s.e2Style.color);
|
drawExtremum(ctx, xOf(ex.timestamp), yE2(ex.value), ex, s.e2Style.color);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,6 +26,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
computeCurve, generateForecastDoses, autoCalibrated, TConfig,
|
computeCurve, generateForecastDoses, autoCalibrated, TConfig,
|
||||||
|
computeLabAnchoredCurve,
|
||||||
} from '../pk/index.js';
|
} from '../pk/index.js';
|
||||||
import {
|
import {
|
||||||
stepForRange, defaultModelToggles, panDeltaHours, clampPanHours,
|
stepForRange, defaultModelToggles, panDeltaHours, clampPanHours,
|
||||||
@ -74,6 +75,7 @@ export function renderChart(container, ctx) {
|
|||||||
showWhs: false,
|
showWhs: false,
|
||||||
showForecast: false,
|
showForecast: false,
|
||||||
showExtrema: false,
|
showExtrema: false,
|
||||||
|
showLabTrack: false, // v1.5.0 — courbe ancrée sur les labs, OFF par défaut (choix v1.5.0)
|
||||||
panResidualHours: 0.0, // résiduel fractionnaire du pan (fix #62)
|
panResidualHours: 0.0, // résiduel fractionnaire du pan (fix #62)
|
||||||
togglesInitialized: false,
|
togglesInitialized: false,
|
||||||
};
|
};
|
||||||
@ -204,9 +206,21 @@ export function renderChart(container, ctx) {
|
|||||||
modelRow.appendChild(filterChip(t('model_ese'), state.showEse, () => { state.showEse = !state.showEse; redraw(); }));
|
modelRow.appendChild(filterChip(t('model_ese'), state.showEse, () => { state.showEse = !state.showEse; redraw(); }));
|
||||||
modelRow.appendChild(filterChip(t('model_tfs'), state.showTfs, () => { state.showTfs = !state.showTfs; redraw(); }));
|
modelRow.appendChild(filterChip(t('model_tfs'), state.showTfs, () => { state.showTfs = !state.showTfs; redraw(); }));
|
||||||
modelRow.appendChild(filterChip(t('model_whs'), state.showWhs, () => { state.showWhs = !state.showWhs; redraw(); }));
|
modelRow.appendChild(filterChip(t('model_whs'), state.showWhs, () => { state.showWhs = !state.showWhs; redraw(); }));
|
||||||
|
// v1.5.0 : courbe hybride ancrée sur les labs (off par défaut)
|
||||||
|
modelRow.appendChild(filterChip(t('show_lab_track'), state.showLabTrack, () => { state.showLabTrack = !state.showLabTrack; redraw(); }));
|
||||||
|
|
||||||
// ── Courbes par modèle sélectionné ─────────────────────────────────────
|
// ── Courbes par modèle sélectionné ─────────────────────────────────────
|
||||||
const curves = [];
|
const curves = [];
|
||||||
|
// ── v1.5.0 : courbe « Tracé labs » ───────────────────────────────────────
|
||||||
|
// Indépendante des toggles modèles (modèle STOCKÉ — sémantique Home) et
|
||||||
|
// de la calibration (elle EST sa propre calibration continue — épinglé).
|
||||||
|
if (state.showLabTrack) {
|
||||||
|
const step = stepForRange(state.rangeHours);
|
||||||
|
const anchored = computeLabAnchoredCurve(
|
||||||
|
treatments, doseLogs, labResults, startMs, endMs, step, null,
|
||||||
|
);
|
||||||
|
curves.push(['LAB', anchored.points]);
|
||||||
|
}
|
||||||
if (state.showEse || state.showTfs || state.showWhs) {
|
if (state.showEse || state.showTfs || state.showWhs) {
|
||||||
// Home utilise le modèle stocké ; le graphique calibre chaque modèle
|
// Home utilise le modèle stocké ; le graphique calibre chaque modèle
|
||||||
// avec SA prédiction (fix #60). Référence = auto du modèle TFS (les
|
// avec SA prédiction (fix #60). Référence = auto du modèle TFS (les
|
||||||
@ -269,6 +283,11 @@ export function renderChart(container, ctx) {
|
|||||||
drawChart(
|
drawChart(
|
||||||
canvas,
|
canvas,
|
||||||
curves.map(([model, pts]) => {
|
curves.map(([model, pts]) => {
|
||||||
|
if (model === 'LAB') {
|
||||||
|
// v1.5.0 : Tracé labs = E2 rose foncé pointillé, PAS de courbe T
|
||||||
|
// (série E2-only) et hors pics/creux (showExtrema=false)
|
||||||
|
return { points: pts, e2Style: { color: COLORS.chartLabTrajectory, dashed: true }, tStyle: null, showExtrema: false };
|
||||||
|
}
|
||||||
const st = seriesStyles(model);
|
const st = seriesStyles(model);
|
||||||
return { points: pts, e2Style: st.e2, tStyle: st.t };
|
return { points: pts, e2Style: st.e2, tStyle: st.t };
|
||||||
}),
|
}),
|
||||||
@ -291,6 +310,9 @@ export function renderChart(container, ctx) {
|
|||||||
|
|
||||||
// Légende dynamique (miroir du bloc Compose)
|
// Légende dynamique (miroir du bloc Compose)
|
||||||
for (const [model] of curves) {
|
for (const [model] of curves) {
|
||||||
|
// ⚠️ La clé 'LAB' est SKIPPÉE (légende dédiée plus bas, fix #63
|
||||||
|
// Android-miroir : sinon le `else` imprime la légende TFS en doublon)
|
||||||
|
if (model === 'LAB') continue;
|
||||||
const st = seriesStyles(model);
|
const st = seriesStyles(model);
|
||||||
if (model === 'ESE') {
|
if (model === 'ESE') {
|
||||||
legend.appendChild(legendLine(t('legend_ese_e2'), st.e2.color));
|
legend.appendChild(legendLine(t('legend_ese_e2'), st.e2.color));
|
||||||
@ -303,9 +325,16 @@ export function renderChart(container, ctx) {
|
|||||||
if (state.showT) legend.appendChild(legendLine(t('legend_tfs_t'), st.t.color));
|
if (state.showT) legend.appendChild(legendLine(t('legend_tfs_t'), st.t.color));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Légende modèles : la clé 'LAB' est SKIPPÉE (la boucle for-of ne
|
||||||
|
// gère que ESE/TFS/WHS — pas de catchall pour LAB, sinon légende
|
||||||
|
// dupliquée : miroir de la leçon #63 Android)
|
||||||
if (state.showLabs) legend.appendChild(legendLine(t('legend_labs'), COLORS.labDot));
|
if (state.showLabs) legend.appendChild(legendLine(t('legend_labs'), COLORS.labDot));
|
||||||
if (state.showForecast && horizonHours !== null) legend.appendChild(legendLine(t('legend_forecast_dose'), COLORS.nowLine));
|
if (state.showForecast && horizonHours !== null) legend.appendChild(legendLine(t('legend_forecast_dose'), COLORS.nowLine));
|
||||||
if (state.showExtrema) legend.appendChild(el('div', { class: 'legend-muted' }, t('legend_extrema')));
|
if (state.showExtrema) legend.appendChild(el('div', { class: 'legend-muted' }, t('legend_extrema')));
|
||||||
|
// v1.5.0 : légende dédiée « Tracé labs » (génère rien si la série vide)
|
||||||
|
if (state.showLabTrack && curves.some(([m, pts]) => m === 'LAB' && pts.length >= 2)) {
|
||||||
|
legend.appendChild(legendLine(t('legend_lab_track'), COLORS.chartLabTrajectory));
|
||||||
|
}
|
||||||
legend.appendChild(el('div', { class: 'legend-muted' }, t('drag_hint')));
|
legend.appendChild(el('div', { class: 'legend-muted' }, t('drag_hint')));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -31,7 +31,7 @@ import { el, showDialog, field, switchRow, filterChip, toast } from './component
|
|||||||
* (APK + web au MÊME numéro, tags appariés `vX.Y.Z` + `vX.Y.Z-web`).
|
* (APK + web au MÊME numéro, tags appariés `vX.Y.Z` + `vX.Y.Z-web`).
|
||||||
* v1.4.10 = portage initial de l'Android v1.4.10.
|
* v1.4.10 = portage initial de l'Android v1.4.10.
|
||||||
*/
|
*/
|
||||||
export const WEB_VERSION = '1.4.10';
|
export const WEB_VERSION = '1.5.0';
|
||||||
|
|
||||||
/** Parse un champ numérique FR (virgule → point) ; null si vide/invalide. */
|
/** Parse un champ numérique FR (virgule → point) ; null si vide/invalide. */
|
||||||
function parseNumOrNull(text) {
|
function parseNumOrNull(text) {
|
||||||
|
|||||||
@ -181,6 +181,7 @@ const EN = {
|
|||||||
show_labs: 'Labs',
|
show_labs: 'Labs',
|
||||||
show_forecast: 'Forecast',
|
show_forecast: 'Forecast',
|
||||||
show_extrema: 'Peaks & troughs',
|
show_extrema: 'Peaks & troughs',
|
||||||
|
show_lab_track: 'Lab track',
|
||||||
back_to_present: 'Back to present',
|
back_to_present: 'Back to present',
|
||||||
legend_ese_e2: 'E2 — Estrannaise (blue)',
|
legend_ese_e2: 'E2 — Estrannaise (blue)',
|
||||||
legend_ese_t: 'T — Estrannaise (pink)',
|
legend_ese_t: 'T — Estrannaise (pink)',
|
||||||
@ -191,6 +192,7 @@ const EN = {
|
|||||||
legend_labs: '● labs (orange) — circle = E2, square = T',
|
legend_labs: '● labs (orange) — circle = E2, square = T',
|
||||||
legend_forecast_dose: 'Dashed line + ▼ = projected dose',
|
legend_forecast_dose: 'Dashed line + ▼ = projected dose',
|
||||||
legend_extrema: '▲▼ = local peaks & troughs (with estimated values)',
|
legend_extrema: '▲▼ = local peaks & troughs (with estimated values)',
|
||||||
|
legend_lab_track: 'Lab-anchored curve — model shape forced through your labs (E2 only, between first and last lab)',
|
||||||
drag_hint: 'Drag horizontally to pan (right = past, left = future with forecast). Pinch or − / + to zoom (6 h → 300 d).',
|
drag_hint: 'Drag horizontally to pan (right = past, left = future with forecast). Pinch or − / + to zoom (6 h → 300 d).',
|
||||||
// Rappels (bannière + notification)
|
// Rappels (bannière + notification)
|
||||||
reminder_notif_title: 'HormoneTrack — dose reminder',
|
reminder_notif_title: 'HormoneTrack — dose reminder',
|
||||||
@ -357,6 +359,7 @@ const FR = {
|
|||||||
show_labs: 'Labs',
|
show_labs: 'Labs',
|
||||||
show_forecast: 'Prévision',
|
show_forecast: 'Prévision',
|
||||||
show_extrema: 'Pics & creux',
|
show_extrema: 'Pics & creux',
|
||||||
|
show_lab_track: 'Tracé labs',
|
||||||
back_to_present: 'Revenir à maintenant',
|
back_to_present: 'Revenir à maintenant',
|
||||||
legend_ese_e2: 'E2 — Estrannaise (bleu)',
|
legend_ese_e2: 'E2 — Estrannaise (bleu)',
|
||||||
legend_ese_t: 'T — Estrannaise (rose)',
|
legend_ese_t: 'T — Estrannaise (rose)',
|
||||||
@ -367,6 +370,7 @@ const FR = {
|
|||||||
legend_labs: '● labs (orange) — cercle = E2, carré = T',
|
legend_labs: '● labs (orange) — cercle = E2, carré = T',
|
||||||
legend_forecast_dose: 'Ligne pointillée + ▼ = dose projetée',
|
legend_forecast_dose: 'Ligne pointillée + ▼ = dose projetée',
|
||||||
legend_extrema: '▲▼ = pics/creux locaux (avec valeurs estimées)',
|
legend_extrema: '▲▼ = pics/creux locaux (avec valeurs estimées)',
|
||||||
|
legend_lab_track: 'E2 ancrée sur tes labs — forme du modèle forcée de passer par chaque prise de sang (entre le 1er et le dernier lab)',
|
||||||
drag_hint: 'Glisser horizontalement pour panoramique (droite = passé, gauche = futur avec la prévision). Pincer ou − / + pour zoomer (6 h → 300 j).',
|
drag_hint: 'Glisser horizontalement pour panoramique (droite = passé, gauche = futur avec la prévision). Pincer ou − / + pour zoomer (6 h → 300 j).',
|
||||||
reminder_notif_title: 'HormoneTrack — rappel de dose',
|
reminder_notif_title: 'HormoneTrack — rappel de dose',
|
||||||
reminder_notif_body: '{0} · {1}{2} — prévu à {3}',
|
reminder_notif_body: '{0} · {1}{2} — prévu à {3}',
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hormonetrack-web",
|
"name": "hormonetrack-web",
|
||||||
"version": "1.4.10",
|
"version": "1.5.0",
|
||||||
"description": "HormoneTrack — version web (100% locale, localStorage). Portage navigateur de l'app Android.",
|
"description": "HormoneTrack — version web (100% locale, localStorage). Portage navigateur de l'app Android.",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@ -131,7 +131,7 @@ async function main() {
|
|||||||
ok(dialog !== null, 'dialog « Nouveautés » au premier lancement (markdown rendu)');
|
ok(dialog !== null, 'dialog « Nouveautés » au premier lancement (markdown rendu)');
|
||||||
if (dialog) {
|
if (dialog) {
|
||||||
const dlgText = await dialog.textContent();
|
const dlgText = await dialog.textContent();
|
||||||
ok(dlgText.includes('Version 1.4.10'), 'dialog : section de la version courante');
|
ok(dlgText.includes('Version 1.5.0'), 'dialog : section de la version courante');
|
||||||
const hasRenderedMd = await dialog.$('.md-body h3, .md-body strong');
|
const hasRenderedMd = await dialog.$('.md-body h3, .md-body strong');
|
||||||
ok(hasRenderedMd !== null, 'dialog : markdown RENDU (titres/gras), pas de brut');
|
ok(hasRenderedMd !== null, 'dialog : markdown RENDU (titres/gras), pas de brut');
|
||||||
await page.click('.dialog .actions button');
|
await page.click('.dialog .actions button');
|
||||||
@ -192,7 +192,7 @@ async function main() {
|
|||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
text = await page.textContent('main.content');
|
text = await page.textContent('main.content');
|
||||||
ok(text.includes('Langue'), 'Paramètres : carte langue');
|
ok(text.includes('Langue'), 'Paramètres : carte langue');
|
||||||
ok(text.includes('Version 1.4.10 (web)'), 'Paramètres : version affichée');
|
ok(text.includes('Version 1.5.0 (web)'), 'Paramètres : version affichée');
|
||||||
ok(text.includes('100 % local'), 'Paramètres : note vie privée');
|
ok(text.includes('100 % local'), 'Paramètres : note vie privée');
|
||||||
await page.screenshot({ path: join(SHOTS, 'e2e-settings.png') });
|
await page.screenshot({ path: join(SHOTS, 'e2e-settings.png') });
|
||||||
|
|
||||||
|
|||||||
140
tests/lab-trajectory-model.test.js
Normal file
140
tests/lab-trajectory-model.test.js
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Tests « Tracé labs » web (miroir de `LabTrajectoryModelTest.kt` Android)
|
||||||
|
* — mêmes garanties épinglées : passage exact, ρ log-linéaire, garde #61,
|
||||||
|
* fenêtre bornée aux labs, indépendance du scaleFactor.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, describe, before } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
initProfiles, makeTreatment, makeDose, makeLab, HOUR_MS, DAY_MS, assertClose,
|
||||||
|
} from './helpers.js';
|
||||||
|
import { e2At } from '../js/pk/pk-engine.js';
|
||||||
|
import {
|
||||||
|
computeLabAnchoredCurve, ratioAt,
|
||||||
|
} from '../js/pk/lab-trajectory-model.js';
|
||||||
|
/** Traitement injectable EEn/TFS 5 mg, une dose à t=0, scaleFactor paramétrable. */
|
||||||
|
function treatment(scaleFactor = 1.0) {
|
||||||
|
return makeTreatment({ scaleFactor });
|
||||||
|
}
|
||||||
|
|
||||||
|
function doses() {
|
||||||
|
return [makeDose(0, 5)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const lab = (value, ts) => makeLab('E2', value, 'pg/mL', ts);
|
||||||
|
const tLab = (ts) => makeLab('T', 0.35, 'ng/mL', ts);
|
||||||
|
|
||||||
|
describe('LabTrajectoryModel web (miroir Android v1.5.0)', () => {
|
||||||
|
before(() => initProfiles());
|
||||||
|
|
||||||
|
test('passe exactement sur chaque lab', () => {
|
||||||
|
const t2 = 2 * DAY_MS;
|
||||||
|
const t7 = 7 * DAY_MS;
|
||||||
|
const labs = [lab(300, t2), lab(260, t7)];
|
||||||
|
const curve = computeLabAnchoredCurve([treatment()], doses(), labs, 0, 30 * DAY_MS, HOUR_MS);
|
||||||
|
assert.equal(curve.anchoredLabs, 2);
|
||||||
|
assertClose(curve.points.find((p) => p.timestamp === t2).e2, 300, 1e-6, 'ancrage lab 1');
|
||||||
|
assertClose(curve.points.find((p) => p.timestamp === t7).e2, 260, 1e-6, 'ancrage lab 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rho interpole en log entre deux labs (math exacte)', () => {
|
||||||
|
const t2 = 2 * DAY_MS;
|
||||||
|
const t7 = 7 * DAY_MS;
|
||||||
|
const tr = treatment();
|
||||||
|
// Labs DATA-DRIVEN (miroir du fix du test Kotlin : M(2 d) ≠ le pic de référence)
|
||||||
|
const m2 = e2At([tr], doses(), t2, null, null);
|
||||||
|
const m7 = e2At([tr], doses(), t7, null, null);
|
||||||
|
const labs = [lab(0.9 * m2, t2), lab(1.2 * m7, t7)];
|
||||||
|
|
||||||
|
// 1) La fonction PURE ratioAt interpole exactement
|
||||||
|
const pure = [[t2, 0.9], [t7, 1.2]];
|
||||||
|
const expectedMid = Math.exp((Math.log(0.9) + Math.log(1.2)) / 2.0);
|
||||||
|
assertClose(ratioAt(pure, t2), 0.9, 1e-12);
|
||||||
|
assertClose(ratioAt(pure, t7), 1.2, 1e-12);
|
||||||
|
assertClose(ratioAt(pure, (t2 + t7) / 2), expectedMid, 1e-9);
|
||||||
|
|
||||||
|
// 2) La courbe restitue le même ρ
|
||||||
|
const curve = computeLabAnchoredCurve([tr], doses(), labs, 0, 30 * DAY_MS, HOUR_MS);
|
||||||
|
const mid = curve.points[Math.trunc(curve.points.length / 2)];
|
||||||
|
const m = e2At([{ ...tr, scaleFactor: 1.0 }], doses(), mid.timestamp, null, null);
|
||||||
|
assertClose(mid.e2 / m, expectedMid, 1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un seul lab significatif donne une courbe vide', () => {
|
||||||
|
const curve = computeLabAnchoredCurve([treatment()], doses(),
|
||||||
|
[lab(200, 3 * DAY_MS)], 0, 30 * DAY_MS, HOUR_MS);
|
||||||
|
assert.equal(curve.points.length, 0);
|
||||||
|
assert.equal(curve.anchoredLabs, 1, 'le lab compte mais ne suffit pas');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('garde 61 - un lab hors fenêtre d action n est pas un ancrage', () => {
|
||||||
|
const t2 = 2 * DAY_MS;
|
||||||
|
const t7 = 7 * DAY_MS;
|
||||||
|
const curve = computeLabAnchoredCurve([treatment()], doses(),
|
||||||
|
[lab(300, t2), lab(260, t7), lab(3000, 60 * DAY_MS)], 0, 90 * DAY_MS, HOUR_MS);
|
||||||
|
assert.equal(curve.anchoredLabs, 2, 'le lab tardif est rejeté');
|
||||||
|
assert.ok(curve.points.every((p) => p.e2 <= 600), 'aucun point ne suit la valeur absurde');
|
||||||
|
assert.ok(curve.points.every((p) => p.timestamp <= t7), 'fenêtre bornée au dernier lab significatif');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('labs avant la première dose ne produisent aucun ancrage', () => {
|
||||||
|
const curve = computeLabAnchoredCurve([treatment()], doses(),
|
||||||
|
[lab(200, -10 * DAY_MS), lab(210, -5 * DAY_MS)], -15 * DAY_MS, 0, HOUR_MS);
|
||||||
|
assert.equal(curve.points.length, 0);
|
||||||
|
assert.equal(curve.anchoredLabs, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('la fenêtre du tracé est bornée par les labs et par la demande', () => {
|
||||||
|
const t2 = 2 * DAY_MS;
|
||||||
|
const t7 = 7 * DAY_MS;
|
||||||
|
const labs = [lab(160, t2), lab(140, t7)];
|
||||||
|
const wide = computeLabAnchoredCurve([treatment()], doses(), labs, 0, 60 * DAY_MS, HOUR_MS);
|
||||||
|
assert.ok(wide.points.length > 0);
|
||||||
|
assert.ok(wide.points.every((p) => p.timestamp >= t2 && p.timestamp <= t7));
|
||||||
|
const narrow = computeLabAnchoredCurve([treatment()], doses(), labs, 3 * DAY_MS, 5 * DAY_MS, HOUR_MS);
|
||||||
|
assert.ok(narrow.points.length > 0);
|
||||||
|
assert.ok(narrow.points.every((p) => p.timestamp >= 3 * DAY_MS && p.timestamp <= 5 * DAY_MS));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('indépendant du scaleFactor stocké (sinon double correction)', () => {
|
||||||
|
const labs = [lab(300, 2 * DAY_MS), lab(260, 7 * DAY_MS)];
|
||||||
|
const a = computeLabAnchoredCurve([treatment(1.0)], doses(), labs, 0, 30 * DAY_MS, HOUR_MS);
|
||||||
|
const b = computeLabAnchoredCurve([treatment(0.55)], doses(), labs, 0, 30 * DAY_MS, HOUR_MS);
|
||||||
|
assert.deepEqual(a.points, b.points, 'M diffère mais ρ compense exactement');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modelOverride - chaque modèle ancre avec sa prédiction (sémantique 60)', () => {
|
||||||
|
// ESE vs WHS (ESE≈TFS quasi identiques par construction — seuil doc §8)
|
||||||
|
const trEv = { ...treatment(), esterType: 'EV', route: 'INJECTION_IM' };
|
||||||
|
const labs = [lab(320, 2 * DAY_MS), lab(240, 7 * DAY_MS)];
|
||||||
|
const ese = computeLabAnchoredCurve([trEv], doses(), labs, 0, 30 * DAY_MS, HOUR_MS, 'ESE');
|
||||||
|
const whs = computeLabAnchoredCurve([trEv], doses(), labs, 0, 30 * DAY_MS, HOUR_MS, 'WHS');
|
||||||
|
assertClose(ese.points.find((p) => p.timestamp === 2 * DAY_MS).e2, 320, 1e-6);
|
||||||
|
assertClose(whs.points.find((p) => p.timestamp === 2 * DAY_MS).e2, 320, 1e-6);
|
||||||
|
const midEse = ese.points[Math.trunc(ese.points.length / 2)].e2;
|
||||||
|
const midWhs = whs.points[Math.trunc(whs.points.length / 2)].e2;
|
||||||
|
const relDiff = Math.abs(midEse - midWhs) / Math.max(Math.abs(midEse), Math.abs(midWhs));
|
||||||
|
assert.ok(relDiff > 0.02, `empreinte intermédiaire ESE=${midEse} vs WHS=${midWhs}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('les labs T sont ignorés (E2 uniquement)', () => {
|
||||||
|
const labs = [lab(300, 2 * DAY_MS), tLab(3 * DAY_MS), lab(240, 7 * DAY_MS)];
|
||||||
|
const curve = computeLabAnchoredCurve([treatment()], doses(), labs, 0, 30 * DAY_MS, HOUR_MS);
|
||||||
|
assert.equal(curve.anchoredLabs, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('doublons de timestamp - le deuxième ratio gagne', () => {
|
||||||
|
const t = 5 * DAY_MS;
|
||||||
|
const anchors = [[t, 1.0], [t, 2.0], [10 * DAY_MS, 3.0]];
|
||||||
|
assertClose(ratioAt(anchors, 10 * DAY_MS), 3.0, 1e-12);
|
||||||
|
const expected = Math.exp(Math.log(2) + (7 - 5) / 5 * (Math.log(3) - Math.log(2)));
|
||||||
|
assertClose(ratioAt(anchors, 7 * DAY_MS), expected, 1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ratioAt - gardes defensives aux bornes', () => {
|
||||||
|
const anchors = [[0, 1.0], [10, 2.0]];
|
||||||
|
assertClose(ratioAt(anchors, -5), 1.0, 1e-12);
|
||||||
|
assertClose(ratioAt(anchors, 100), 2.0, 1e-12);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user