feat(cycle): confirm the mucus peak with the Sensiplan post-peak count

The mucus peak was taken as the last egg-white/watery day with no
confirmation, so a stray late fertile-mucus entry corrupted the peak.
Implement the Sensiplan Höhepunkt rule: the peak day is the LAST
best-quality (egg-white/watery) day that is followed by at least 3
consecutive drier observed days, each strictly lower quality. The peak is
only confirmable retrospectively, after those 3 days, so a stray late
egg-white entry not yet followed by 3 drier days cannot move an
already-confirmed peak. Quality is scored on the t/f/S/+S ladder;
unobserved days are skipped so a logging gap does not break the count.

Citation: Arbeitsgruppe NFP / Raith-Paula & Frank-Herrmann; myNFP
"Zervixschleim beobachten".
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 14:53:24 +02:00
1 parent d98711a246
commit cd55c60fae
2 files changed
+147 -21

No files matched your search

+87 -11
View File
@@ -4,6 +4,7 @@ import { addDays } from "../day-math";
import {
clampLuteal,
confirmSymptothermal,
detectMucusPeak,
detectTempShift,
detectTemperatureTrend,
estimateCycleLength,
@@ -388,6 +389,67 @@ describe("cycle/prediction — symptothermal confirmation (§4.2)", () => {
expect(shift?.evaluationCompleteDate).toBe(addDays(start, 9));
});
it("detectMucusPeak: confirms the last best-quality day followed by 3 drier days", () => {
const m = (date: string, q: DayLogInput["cervicalMucus"]): DayLogInput => ({
date,
flow: null,
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: q,
});
const start = "2024-01-01";
const logs = [
m(addDays(start, 0), "STICKY"),
m(addDays(start, 1), "CREAMY"),
m(addDays(start, 2), "EGG_WHITE"), // peak candidate
m(addDays(start, 3), "CREAMY"),
m(addDays(start, 4), "STICKY"),
m(addDays(start, 5), "DRY"),
];
expect(detectMucusPeak(logs)).toBe(addDays(start, 2));
});
it("detectMucusPeak: returns null until 3 drier days follow the best-quality day", () => {
const m = (date: string, q: DayLogInput["cervicalMucus"]): DayLogInput => ({
date,
flow: null,
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: q,
});
const start = "2024-01-01";
// egg-white then only 2 drier observed days → not yet confirmable.
const logs = [
m(addDays(start, 0), "EGG_WHITE"),
m(addDays(start, 1), "CREAMY"),
m(addDays(start, 2), "DRY"),
];
expect(detectMucusPeak(logs)).toBeNull();
});
it("detectMucusPeak: a stray late egg-white entry no longer moves a confirmed peak", () => {
const m = (date: string, q: DayLogInput["cervicalMucus"]): DayLogInput => ({
date,
flow: null,
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: q,
});
const start = "2024-01-01";
const logs = [
m(addDays(start, 0), "EGG_WHITE"), // true peak
m(addDays(start, 1), "CREAMY"),
m(addDays(start, 2), "STICKY"),
m(addDays(start, 3), "DRY"),
// a stray late egg-white with NO 3 drier days after it (only 1 logged
// day follows) — must not be confirmed as a new peak.
m(addDays(start, 8), "EGG_WHITE"),
m(addDays(start, 9), "DRY"),
];
// The confirmed peak stays on the true (early) egg-white day, not the stray.
expect(detectMucusPeak(logs)).toBe(addDays(start, 0));
});
it("confirmSymptothermal requires temp-shift and mucus-peak within ±2 days", () => {
const logs: DayLogInput[] = [];
const start = "2024-01-01";
@@ -397,14 +459,19 @@ describe("cycle/prediction — symptothermal confirmation (§4.2)", () => {
logs.push(bbtDay(addDays(start, 6), 36.55));
logs.push(bbtDay(addDays(start, 7), 36.6));
logs.push(bbtDay(addDays(start, 8), 36.62));
// temp ovulation = 2024-01-06. Add a mucus peak (EGG_WHITE) on 2024-01-06.
logs.push({
date: "2024-01-06",
// temp ovulation = 2024-01-06. Add a mucus peak (EGG_WHITE) on 2024-01-06,
// confirmed by 3 drier days after it (Sensiplan post-peak count).
const mucusDay = (date: string, m: DayLogInput["cervicalMucus"]): DayLogInput => ({
date,
flow: null,
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: "EGG_WHITE",
cervicalMucus: m,
});
logs.push(mucusDay("2024-01-06", "EGG_WHITE"));
logs.push(mucusDay("2024-01-07", "STICKY"));
logs.push(mucusDay("2024-01-08", "DRY"));
logs.push(mucusDay("2024-01-09", "DRY"));
expect(confirmSymptothermal(logs)).toBe("2024-01-06");
});
@@ -425,14 +492,18 @@ describe("cycle/prediction — symptothermal confirmation (§4.2)", () => {
logs.push(bbtDay(riseStart, 36.6));
logs.push(bbtDay(addDays(riseStart, 1), 36.62));
logs.push(bbtDay(addDays(riseStart, 2), 36.63));
// mucus peak agreeing with the temp ovulation.
logs.push({
date: ovDay,
// mucus peak agreeing with the temp ovulation, confirmed by 3 drier days.
const mucusDay = (date: string, m: DayLogInput["cervicalMucus"]): DayLogInput => ({
date,
flow: null,
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: "EGG_WHITE",
cervicalMucus: m,
});
logs.push(mucusDay(ovDay, "EGG_WHITE"));
logs.push(mucusDay(addDays(ovDay, 1), "STICKY"));
logs.push(mucusDay(addDays(ovDay, 2), "DRY"));
logs.push(mucusDay(addDays(ovDay, 3), "DRY"));
const result = predictCycle(cycles, logs, BASE_PROFILE, addDays(lastStart, 16));
expect(result.ovulationConfirmed).toBe(true);
@@ -494,13 +565,18 @@ describe("cycle/prediction — multi-cycle window scoping (QA HIGH)", () => {
logs.push(bbtDay(riseStart, 36.6));
logs.push(bbtDay(addDays(riseStart, 1), 36.62));
logs.push(bbtDay(addDays(riseStart, 2), 36.63));
logs.push({
date: ovDay,
const mucusDay = (date: string, m: DayLogInput["cervicalMucus"]): DayLogInput => ({
date,
flow: null,
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: "EGG_WHITE",
cervicalMucus: m,
});
// Peak on ovDay confirmed by 3 drier observed days (Sensiplan post-peak count).
logs.push(mucusDay(ovDay, "EGG_WHITE"));
logs.push(mucusDay(addDays(ovDay, 1), "STICKY"));
logs.push(mucusDay(addDays(ovDay, 2), "DRY"));
logs.push(mucusDay(addDays(ovDay, 3), "DRY"));
return logs;
}
+60 -10
View File
@@ -356,20 +356,70 @@ export function detectTempShift(
}
/**
* §4.2(b) — cervical-mucus peak: the LAST day of egg-white/watery mucus.
* Infertile phase confirmed on peak+3; ovulation is taken at the peak day for
* the agreement check.
* Mucus quality magnitude on the Sensiplan t/f/S/+S ladder (0 = driest,
* 4 = best/peak quality). EGG_WHITE/WATERY are the peak-quality (+S) classes;
* STICKY/CREAMY are lower fertile-mucus, DRY is t. A null reading is not a
* "drier day" — it is an unobserved day and does not count toward the post-peak
* run (the run is over observed mucus days only).
*/
function mucusQuality(m: DayLogInput["cervicalMucus"]): number {
switch (m) {
case "EGG_WHITE":
return 4;
case "WATERY":
return 3;
case "CREAMY":
return 2;
case "STICKY":
return 1;
case "DRY":
return 0;
default:
return -1; // not observed
}
}
/** Peak-quality mucus = the best (+S) classes: egg-white / watery. */
const MUCUS_PEAK_QUALITY = 3;
/**
* §4.2(b) — sensiplan mucus peak (Höhepunkt). The peak day is the LAST day of
* best-quality (egg-white / watery / spinnbar) mucus that is FOLLOWED by at
* least 3 consecutive drier observed days (each strictly lower quality than the
* peak). The peak is only confirmable retrospectively, after those 3 days — so a
* stray late egg-white entry that is NOT yet followed by 3 drier days cannot
* move an already-confirmed peak.
*
* Returns the confirmed peak day, or null when no best-quality day has yet been
* followed by 3 drier observed days.
*
* Citation: "Höhepunkt = the last day of best-quality (S+) mucus; evaluation
* completes on the evening of the 3rd day after the change to poorer quality."
* Arbeitsgruppe NFP / Raith-Paula & Frank-Herrmann; myNFP "Zervixschleim
* beobachten".
*/
export function detectMucusPeak(
dayLogs: readonly DayLogInput[],
): string | null {
const peakDays = dayLogs
.filter(
(l) => l.cervicalMucus === "EGG_WHITE" || l.cervicalMucus === "WATERY",
)
.map((l) => l.date)
.sort((a, b) => dayDiff(a, b));
return peakDays.length > 0 ? peakDays[peakDays.length - 1] : null;
// Observed mucus days only, oldest→newest. Unobserved days are skipped so a
// gap in logging doesn't break the 3-drier-day post-peak run.
const observed = dayLogs
.filter((l) => mucusQuality(l.cervicalMucus) >= 0)
.map((l) => ({ date: l.date, q: mucusQuality(l.cervicalMucus) }))
.sort((a, b) => dayDiff(a.date, b.date));
// Walk every best-quality day; a candidate peak is confirmed iff the next 3
// observed days are all strictly lower quality. Scan forward and keep the
// LATEST confirmed peak (a true later peak supersedes an earlier one).
let confirmedPeak: string | null = null;
for (let i = 0; i < observed.length; i++) {
if (observed[i].q < MUCUS_PEAK_QUALITY) continue;
const following = observed.slice(i + 1, i + 4);
if (following.length < 3) continue; // not yet evaluable
const allDrier = following.every((d) => d.q < observed[i].q);
if (allDrier) confirmedPeak = observed[i].date;
}
return confirmedPeak;
}
/**