From 2c225ce094e9656a247b0ce22ea1c9499159ac08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 15:06:55 +0200 Subject: [PATCH] feat(cycle): fold a positive LH/OPK surge into the ovulation estimate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stored positive ovulation test (LH surge) was ignored by the predictor. Wire it in as a Marquette/Fehring anchor: a positive surge predicts ovulation ~24-36h later, so the estimated ovulation is set one day after the last positive LH test in the current window, sharpening the estimate over the calendar back-calculation — most valuable in the thin-data (<3 cycle) case where the median/luteal back-calc is weak. The LH surge is a SIGNAL that refines, never a sole assertion: it re-anchors the still-estimated ovulation but never flips `ovulationConfirmed` (a single indicator is not the symptothermal double-check), and a confirmed symptothermal/temperature-trend ovulation always wins over it. The still-learning gate and the un-confirmed status are preserved. A stale prior-cycle surge whose next-start lands in the past is rejected. Citation: Fehring R.J. — Marquette Method; urinary LH peaks ~1 day before ovulation. --- src/lib/cycle/__tests__/prediction.test.ts | 98 ++++++++++++++++++++++ src/lib/cycle/index.ts | 1 + src/lib/cycle/prediction.ts | 56 ++++++++++++- src/lib/cycle/types.ts | 12 +++ 4 files changed, 166 insertions(+), 1 deletion(-) diff --git a/src/lib/cycle/__tests__/prediction.test.ts b/src/lib/cycle/__tests__/prediction.test.ts index 01d6e1d34..28928b6ce 100644 --- a/src/lib/cycle/__tests__/prediction.test.ts +++ b/src/lib/cycle/__tests__/prediction.test.ts @@ -4,6 +4,7 @@ import { addDays } from "../day-math"; import { clampLuteal, confirmSymptothermal, + detectLhSurgeOvulation, detectMucusPeak, detectTempShift, detectTemperatureTrend, @@ -560,6 +561,103 @@ describe("cycle/prediction — symptothermal confirmation (§4.2)", () => { }); }); +describe("cycle/prediction — Marquette LH/OPK ovulation anchor (§4, C2)", () => { + function lhDay(date: string): DayLogInput { + return { + date, + flow: null, + basalBodyTempC: null, + ovulationTest: "POSITIVE_LH_SURGE", + cervicalMucus: null, + }; + } + + it("detectLhSurgeOvulation: anchors ovulation one day after the LAST positive LH", () => { + const logs = [lhDay("2024-01-10"), lhDay("2024-01-11")]; + expect(detectLhSurgeOvulation(logs)).toBe("2024-01-12"); + expect(detectLhSurgeOvulation([])).toBeNull(); + }); + + it("predictCycle: a positive LH surge anchors ovulation on the surge, not nextStart−luteal", () => { + const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]); + const lastStart = cycles[cycles.length - 1].startDate; // 2024-03-25 + // The calendar back-calc would place ovulation at nextStart−14. A positive + // LH on lastStart+16 should anchor ovulation to lastStart+17. + const lhDate = addDays(lastStart, 16); + const logs = [lhDay(lhDate)]; + const today = addDays(lastStart, 18); + + const calendarOnly = predictCycle(cycles, [], BASE_PROFILE, today); + const withLh = predictCycle(cycles, logs, BASE_PROFILE, today); + + // The LH anchor moves the ovulation estimate off the calendar back-calc. + expect(withLh.predictedOvulation).toBe(addDays(lhDate, 1)); + expect(withLh.predictedOvulation).not.toBe(calendarOnly.predictedOvulation); + // next-start = anchored ovulation + luteal(14). + expect(withLh.nextPeriodStart).toBe(addDays(lhDate, 1 + 14)); + expect(withLh.method).toBe("BLENDED"); + // A single LH indicator never asserts CONFIRMED ovulation. + expect(withLh.ovulationConfirmed).toBe(false); + }); + + it("predictCycle: LH sharpens the estimate in the thin-data (1-cycle) case", () => { + const cycles = cyclesFromGaps("2024-01-01", [28]); // one completed length + const lastStart = cycles[cycles.length - 1].startDate; // 2024-01-29 + const lhDate = addDays(lastStart, 15); + const today = addDays(lastStart, 17); + const result = predictCycle(cycles, [lhDay(lhDate)], BASE_PROFILE, today); + expect(result.predictedOvulation).toBe(addDays(lhDate, 1)); + expect(result.ovulationConfirmed).toBe(false); + // The still-learning gate is preserved (1 cycle < 3). + expect(result.stillLearning).toBe(true); + }); + + it("predictCycle: a confirmed symptothermal ovulation wins over the LH anchor", () => { + const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]); + const lastStart = cycles[cycles.length - 1].startDate; + const ovDay = addDays(lastStart, 13); + const riseStart = addDays(ovDay, 1); + const baselineStart = addDays(riseStart, -6); + const logs: DayLogInput[] = []; + [36.4, 36.41, 36.39, 36.4, 36.42, 36.4].forEach((t, i) => + logs.push({ + date: addDays(baselineStart, i), + flow: null, + basalBodyTempC: t, + ovulationTest: null, + cervicalMucus: null, + }), + ); + const bbt = (date: string, t: number): DayLogInput => ({ + date, + flow: null, + basalBodyTempC: t, + ovulationTest: null, + cervicalMucus: null, + }); + logs.push(bbt(riseStart, 36.6)); + logs.push(bbt(addDays(riseStart, 1), 36.62)); + logs.push(bbt(addDays(riseStart, 2), 36.63)); + const mucus = (date: string, m: DayLogInput["cervicalMucus"]): DayLogInput => ({ + date, + flow: null, + basalBodyTempC: null, + ovulationTest: null, + cervicalMucus: m, + }); + logs.push(mucus(ovDay, "EGG_WHITE")); + logs.push(mucus(addDays(ovDay, 1), "STICKY")); + logs.push(mucus(addDays(ovDay, 2), "DRY")); + logs.push(mucus(addDays(ovDay, 3), "DRY")); + // A contradicting LH surge far from the temp shift must NOT override it. + logs.push(lhDay(addDays(ovDay, 5))); + + const result = predictCycle(cycles, logs, BASE_PROFILE, addDays(lastStart, 20)); + expect(result.ovulationConfirmed).toBe(true); + expect(result.predictedOvulation).toBe(ovDay); // symptothermal wins + }); +}); + describe("cycle/prediction — temperature-trend retrospective ovulation (§4.3)", () => { it("detectTemperatureTrend: 3-of-4 nights >= +0.15°C over the trailing 6-night mean", () => { const start = "2024-02-01"; diff --git a/src/lib/cycle/index.ts b/src/lib/cycle/index.ts index a0368f830..d8e2a2297 100644 --- a/src/lib/cycle/index.ts +++ b/src/lib/cycle/index.ts @@ -17,6 +17,7 @@ export { detectMucusPeak, confirmSymptothermal, detectTemperatureTrend, + detectLhSurgeOvulation, median, mad, resolveLuteal, diff --git a/src/lib/cycle/prediction.ts b/src/lib/cycle/prediction.ts index 735b65af8..716f6b3fe 100644 --- a/src/lib/cycle/prediction.ts +++ b/src/lib/cycle/prediction.ts @@ -31,6 +31,7 @@ import { HARD_CYCLE_MAX, HARD_CYCLE_MIN, HISTORY_WINDOW_N, + LH_SURGE_TO_OVULATION_DAYS, LOG_SPARSITY_SCALE, LUTEAL_DEFAULT, LUTEAL_MAX, @@ -471,6 +472,33 @@ export function detectTemperatureTrend( return null; } +/** + * §4 — Marquette (Fehring) LH/OPK ovulation anchor. A positive urinary-LH surge + * precedes ovulation by ~24–36 h, so the estimated ovulation day is one day + * after the LAST positive LH test in the supplied logs (the surge can read + * positive across consecutive days; the last positive is closest to ovulation). + * + * This ANCHORS the ovulation estimate — it never confirms it. A single LH + * indicator is not the symptothermal double-check, so the caller folds this in + * as a refining signal that sharpens the estimate without flipping + * `ovulationConfirmed`. Returns the estimated ovulation day, or null when no + * positive LH test is present. + * + * Citation: Fehring R.J. — Marquette Method; urinary LH peaks ~1 day before + * ovulation. + */ +export function detectLhSurgeOvulation( + dayLogs: readonly DayLogInput[], +): string | null { + const positives = dayLogs + .filter((l) => l.ovulationTest === "POSITIVE_LH_SURGE") + .map((l) => l.date) + .sort((a, b) => dayDiff(a, b)); + if (positives.length === 0) return null; + const lastPositive = positives[positives.length - 1]; + return addDays(lastPositive, LH_SURGE_TO_OVULATION_DAYS); +} + /* ------------------------------------------------------------------ */ /* §3 — confidence + band */ /* ------------------------------------------------------------------ */ @@ -571,7 +599,7 @@ export function resolveLuteal( * engine returns the window and the caller hides it). * * @param cycles confirmed (non-predicted) cycles; order-independent. - * @param dayLogs all day logs the device holds (for period, symptothermal). + * @param dayLogs all day logs the device holds (period, symptothermal, LH/OPK). * @param profile user priors + mode flags. * @param today `YYYY-MM-DD` reference day (for adherence density). * @param nights optional nightly passive-temperature series (Apple Watch). @@ -670,6 +698,32 @@ export function predictCycle( } } + // -------- §4 Marquette LH/OPK ANCHOR (refines, never confirms) -------- + // A positive LH surge predicts ovulation ~24–36 h later (Marquette/Fehring). + // It is a single indicator, NOT the symptothermal double-check, so it only + // RE-ANCHORS the ovulation estimate — it never flips `ovulationConfirmed`. + // When the symptothermal or temperature-trend layer already confirmed, that + // stronger signal wins and the LH anchor is ignored. Otherwise the LH surge + // sharpens the still-estimated ovulation (and the derived next-start) over + // the calendar back-calculation — most valuable in the thin-data (<3 cycle) + // case where the median/luteal back-calc is weak. The still-learning gate and + // the un-confirmed status are both preserved (honesty principle). + if (!ovulationConfirmed) { + const lhOvulation = detectLhSurgeOvulation(currentDayLogs); + if (lhOvulation) { + const lhNextStart = addDays(lhOvulation, lutealLength); + // Only adopt a future-dated anchor (a positive-LH next-start in the past + // is a stale prior-cycle signal that slipped the window). + if (dayDiff(lhNextStart, today) >= 0) { + predictedOvulation = lhOvulation; + nextStart = lhNextStart; + // method records the mix but ovulationConfirmed stays FALSE — a single + // LH indicator never asserts confirmed ovulation. + method = "BLENDED"; + } + } + } + // -------- §3 band half-width -------- const { cAdherence, logSparsity } = adherenceFactors( lastConfirmedStart, diff --git a/src/lib/cycle/types.ts b/src/lib/cycle/types.ts index 993bfb4ad..062cdc5fd 100644 --- a/src/lib/cycle/types.ts +++ b/src/lib/cycle/types.ts @@ -139,6 +139,18 @@ export const HALF_WIDTH_MULT_TEMP_TREND = 0.75; /** §4 — symptothermal agreement tolerance between temp-shift and mucus peak (days). */ export const SYMPTOTHERMAL_AGREE_DAYS = 2; +/** + * §4 — Marquette (Fehring) LH/OPK ovulation lag. A positive urinary-LH surge + * precedes ovulation by ~24–36 h, so ovulation is estimated one day after the + * (last) positive LH test. Used to ANCHOR — never to confirm — the ovulation + * estimate: a single LH indicator is not the symptothermal double-check, so it + * sharpens the estimate without asserting confirmed ovulation. + * + * Citation: Fehring R.J. — Marquette Method; urinary LH peaks ~1 day before + * ovulation. ESHRE/Wilcox fertile-window literature. + */ +export const LH_SURGE_TO_OVULATION_DAYS = 1; + /** * §4 — trailing lookback (days) for the symptothermal / temperature-trend * detectors. The 3-over-6 BBT rule needs 6 baseline readings before the rise,