/** * ───────────────────────────────────────────────────────────────────────────── * Détection des pics et creux (partie 4/4 du moteur) — portage de * `PharmacokineticEngine.detectExtrema` (v1.2.3) : triangles ▲▼ aux extrema * locaux de chaque courbe, avec valeurs estimées (v1.2.6). * * PUR, testable en Node (web/tests/extrema.test.js). * ───────────────────────────────────────────────────────────────────────────── */ /** * Un extremum détecté : pic (isPeak = true) ou creux, avec sa valeur. */ export class Extremum { /** @param {number} timestamp · @param {number} value · @param {boolean} isPeak */ constructor(timestamp, value, isPeak) { this.timestamp = timestamp; this.value = value; this.isPeak = isPeak; } } /** * Détecte les maxima et minima LOCAUX d'une courbe (les pics après chaque * injection et les creux juste avant la suivante, en régime d'équilibre). * * - les plateaux (valeurs égales consécutives) comptent pour UN seul extremum ; * - les extrema consécutifs de même type sont FUSIONNÉS (le plus extrême gagne) ; * - les oscillations d'amplitude < minSwingAbs sont ignorées (bruit * d'arrondi) — utiliser ~2 pg/mL pour l'E2 et ~0,02 ng/mL pour la T. * * @param {import('./pk-engine.js').LevelPoint[]} points Points de courbe * @param {(p:object)=>number} selector Ce qu'on analyse (`p => p.e2` ou `p => p.t`) * @param {number} [minSwingAbs=2.0] Amplitude minimale d'une oscillation * @returns {Extremum[]} */ export function detectExtrema(points, selector, minSwingAbs = 2.0) { if (points.length < 3) return []; const values = points.map(selector); const abs = Math.abs; // 1) extrema locaux stricts (un plateau = son premier point) const raw = []; // [index, isPeak] let i = 1; while (i < values.length - 1) { const v = values[i]; const prev = values[i - 1]; let j = i; while (j < values.length - 1 && values[j + 1] === v) j++; // plateau if (j >= values.length - 1) break; const next = values[j + 1]; if (v > prev && v > next) { raw.push([i, true]); } else if (v < prev && v < next) { raw.push([i, false]); } i = j + 1; } // 2) alternance pic/creux + filtre d'amplitude const out = []; for (const [idx, isPeak] of raw) { const last = out[out.length - 1] || null; if (last && last.isPeak === isPeak) { // doublon de même type : garder le plus extrême const better = isPeak ? values[idx] > last.value : values[idx] < last.value; if (better) out[out.length - 1] = new Extremum(points[idx].timestamp, values[idx], isPeak); continue; } if (last && abs(last.value - values[idx]) < minSwingAbs) continue; out.push(new Extremum(points[idx].timestamp, values[idx], isPeak)); } return out; }