feat(cycle): fold a positive LH/OPK surge into the ovulation estimate

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 15:06:55 +02:00
1 parent 4bfe9f11d7
commit 2c225ce094
4 files changed
+166 -1

No files matched your search

@@ -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";
+1
View File
@@ -17,6 +17,7 @@ export {
detectMucusPeak,
confirmSymptothermal,
detectTemperatureTrend,
detectLhSurgeOvulation,
median,
mad,
resolveLuteal,
+55 -1
View File
@@ -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,
+12
View File
@@ -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,