mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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";
|
||||
|
||||
@@ -17,6 +17,7 @@ export {
|
||||
detectMucusPeak,
|
||||
confirmSymptothermal,
|
||||
detectTemperatureTrend,
|
||||
detectLhSurgeOvulation,
|
||||
median,
|
||||
mad,
|
||||
resolveLuteal,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user