mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
1 parent
d98711a246
commit
cd55c60fae
2 files changed
+147
-21
No files matched your search
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user