mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(cycle): add both Sensiplan temperature exception rules
The BBT shift detector only applied the strict 3-over-6 rule (3 readings above the cover line, the 3rd >=0.2C above), so it under-detected ovulation on common ovulatory patterns. Add the two published Ausnahmeregeln: - 1. Ausnahmeregel (slow/staircase rise): when the 3rd high reading is above the cover line but not >=0.2C above, a 4th reading that need only be above the line confirms the shift. - 2. Ausnahmeregel (one value falls back): when exactly one of the three readings falls back to/below the cover line, it is discounted and a 4th reading that again clears 0.2C above the line confirms. The result now carries a `rule` (0|1|2) provenance field and the `evaluationCompleteDate` (evening of the 3rd or 4th high reading). The rules follow the Arbeitsgruppe NFP / Raith-Paula & Frank-Herrmann standard and are mutually exclusive by construction.
This commit is contained in:
1 parent
df67b60420
commit
d98711a246
2 files changed
+141
-13
No files matched your search
@@ -325,6 +325,69 @@ describe("cycle/prediction — symptothermal confirmation (§4.2)", () => {
|
||||
expect(detectTempShift(logs, 0.2)).toBeNull();
|
||||
});
|
||||
|
||||
it("detectTempShift rule 0: the regular rule reports rule:0 + evaluation on the 3rd day", () => {
|
||||
const logs: DayLogInput[] = [];
|
||||
const start = "2024-01-01";
|
||||
[36.4, 36.42, 36.38, 36.41, 36.4, 36.39].forEach((t, i) =>
|
||||
logs.push(bbtDay(addDays(start, i), t)),
|
||||
);
|
||||
logs.push(bbtDay(addDays(start, 6), 36.55));
|
||||
logs.push(bbtDay(addDays(start, 7), 36.6));
|
||||
logs.push(bbtDay(addDays(start, 8), 36.62)); // 3rd clears 0.2 over 36.42
|
||||
|
||||
const shift = detectTempShift(logs, 0.2);
|
||||
expect(shift?.rule).toBe(0);
|
||||
expect(shift?.ovulationDate).toBe(addDays(start, 5));
|
||||
expect(shift?.evaluationCompleteDate).toBe(addDays(start, 8));
|
||||
});
|
||||
|
||||
it("detectTempShift 1. Ausnahmeregel: slow/staircase rise confirms on the 4th reading", () => {
|
||||
// 1. Ausnahmeregel: 3rd high reading is above the line but NOT 0.2°C above;
|
||||
// a 4th reading above the line confirms (need only be above the line).
|
||||
const logs: DayLogInput[] = [];
|
||||
const start = "2024-01-01";
|
||||
[36.4, 36.42, 36.38, 36.41, 36.4, 36.39].forEach((t, i) =>
|
||||
logs.push(bbtDay(addDays(start, i), t)),
|
||||
);
|
||||
// cover line = 36.42. Staircase: all above the line, 3rd only +0.1.
|
||||
logs.push(bbtDay(addDays(start, 6), 36.45));
|
||||
logs.push(bbtDay(addDays(start, 7), 36.49));
|
||||
logs.push(bbtDay(addDays(start, 8), 36.52)); // +0.1 → regular rule fails
|
||||
// Without a 4th reading the strict + 1. rule cannot confirm.
|
||||
expect(detectTempShift([...logs], 0.2)).toBeNull();
|
||||
// 4th reading merely above the line (no 0.2 requirement) → confirmed by rule 1.
|
||||
logs.push(bbtDay(addDays(start, 9), 36.5));
|
||||
const shift = detectTempShift(logs, 0.2);
|
||||
expect(shift?.rule).toBe(1);
|
||||
expect(shift?.ovulationDate).toBe(addDays(start, 5));
|
||||
expect(shift?.evaluationCompleteDate).toBe(addDays(start, 9));
|
||||
});
|
||||
|
||||
it("detectTempShift 2. Ausnahmeregel: a single fall-back day confirms on a 4th ≥0.2°C reading", () => {
|
||||
// 2. Ausnahmeregel: one of the 3 readings falls back to/below the line; it
|
||||
// is discounted and a 4th reading is required that again clears 0.2°C.
|
||||
const logs: DayLogInput[] = [];
|
||||
const start = "2024-01-01";
|
||||
[36.4, 36.42, 36.38, 36.41, 36.4, 36.39].forEach((t, i) =>
|
||||
logs.push(bbtDay(addDays(start, i), t)),
|
||||
);
|
||||
// cover line = 36.42. 1st + 2nd above, 3rd falls back below the line.
|
||||
logs.push(bbtDay(addDays(start, 6), 36.6));
|
||||
logs.push(bbtDay(addDays(start, 7), 36.62));
|
||||
logs.push(bbtDay(addDays(start, 8), 36.4)); // falls back below 36.42
|
||||
// Without a 4th reading nothing confirms.
|
||||
expect(detectTempShift([...logs], 0.2)).toBeNull();
|
||||
// 4th reading that does NOT clear 0.2 must not confirm.
|
||||
const tooLow = [...logs, bbtDay(addDays(start, 9), 36.5)]; // +0.08 only
|
||||
expect(detectTempShift(tooLow, 0.2)).toBeNull();
|
||||
// 4th reading ≥0.2°C above the line → confirmed by rule 2.
|
||||
logs.push(bbtDay(addDays(start, 9), 36.63)); // +0.21 over 36.42
|
||||
const shift = detectTempShift(logs, 0.2);
|
||||
expect(shift?.rule).toBe(2);
|
||||
expect(shift?.ovulationDate).toBe(addDays(start, 5));
|
||||
expect(shift?.evaluationCompleteDate).toBe(addDays(start, 9));
|
||||
});
|
||||
|
||||
it("confirmSymptothermal requires temp-shift and mucus-peak within ±2 days", () => {
|
||||
const logs: DayLogInput[] = [];
|
||||
const start = "2024-01-01";
|
||||
|
||||
+78
-13
@@ -250,17 +250,41 @@ export function estimatePeriodLength(
|
||||
/* §4 — symptothermal + temperature-trend ovulation detection */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Which sensiplan temperature rule confirmed the shift (provenance). */
|
||||
export type TempShiftRule = 0 | 1 | 2;
|
||||
|
||||
interface TempShiftResult {
|
||||
/** Confirmed ovulation day = day before the first of the 3 elevated readings. */
|
||||
ovulationDate: string;
|
||||
/**
|
||||
* Which rule confirmed the rise: 0 = the regular 3-over-6 rule (3rd reading
|
||||
* ≥0.2°C above the cover line), 1 = the 1. Ausnahmeregel (slow/staircase rise,
|
||||
* 4th reading required), 2 = the 2. Ausnahmeregel (one of the 3 falls back to
|
||||
* the line, 4th reading required ≥0.2°C above).
|
||||
*/
|
||||
rule: TempShiftRule;
|
||||
/** Day the evaluation completed (evening of the 3rd or 4th high reading). */
|
||||
evaluationCompleteDate: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* §4.2(a) — sensiplan 3-over-6 BBT rule on manual basal temperatures.
|
||||
* The first day of 3 consecutive temps each strictly above the highest of the
|
||||
* immediately preceding 6 temps, AND the 3rd is >= 0.2°C above that 6-day max.
|
||||
* Ovulation = the last low-temp day before the rise (the day BEFORE the first
|
||||
* elevated reading).
|
||||
* §4.2(a) — sensiplan 3-over-6 BBT rule on manual basal temperatures, with both
|
||||
* published Ausnahmeregeln (exception rules).
|
||||
*
|
||||
* Cover line (Hüllkurve) = the highest of the 6 measured low values immediately
|
||||
* preceding the rise. Ovulation = the last low day before the rise (the day
|
||||
* BEFORE the first elevated reading).
|
||||
*
|
||||
* Rules (Arbeitsgruppe NFP / Raith-Paula & Frank-Herrmann; myNFP "Temperaturkurve
|
||||
* auswerten"; Generation-Pille "Die wichtigsten NFP-Regeln"):
|
||||
* - **Regular rule (0):** 3 consecutive readings strictly above the cover line,
|
||||
* the 3rd ≥0.2°C above it. Evaluation completes on the 3rd high day.
|
||||
* - **1. Ausnahmeregel (1):** if the 3rd reading is not ≥0.2°C above the line,
|
||||
* await a 4th reading which need only be above the line. Completes on the 4th.
|
||||
* - **2. Ausnahmeregel (2):** if exactly one of the 3 readings falls back to/below
|
||||
* the line, it is discounted and a 4th reading is required which must again be
|
||||
* ≥0.2°C above the line. Completes on the 4th.
|
||||
* The two exception rules are mutually exclusive by construction.
|
||||
*/
|
||||
export function detectTempShift(
|
||||
dayLogs: readonly DayLogInput[],
|
||||
@@ -274,17 +298,58 @@ export function detectTempShift(
|
||||
// Need 6 baseline + 3 elevated = 9 readings minimum. Scan from the END and
|
||||
// return the LATEST qualifying shift — for a multi-cycle BBT series the most
|
||||
// recent rise is the one that belongs to the current cycle; the earliest
|
||||
// match would confirm a months-old ovulation (QA: window scoping).
|
||||
// match would confirm a months-old ovulation (QA: window scoping). When a 4th
|
||||
// reading is needed (exception rules) the candidate's start index may run to
|
||||
// temps.length - 4; the loop starts there and skips candidates without enough
|
||||
// following readings for the rule that fires.
|
||||
for (let i = temps.length - 3; i >= 6; i--) {
|
||||
const baseline = temps.slice(i - 6, i);
|
||||
const sixMax = Math.max(...baseline.map((x) => x.t));
|
||||
const rise = [temps[i], temps[i + 1], temps[i + 2]];
|
||||
const allAbove = rise.every((r) => r.t > sixMax);
|
||||
// The 3rd elevated reading must clear the 6-day max by the threshold.
|
||||
const thirdClears = roundHalf(rise[2].t - sixMax, 2) >= thresholdC;
|
||||
if (allAbove && thirdClears) {
|
||||
// Ovulation = day before the first elevated reading.
|
||||
return { ovulationDate: addDays(rise[0].date, -1) };
|
||||
const r1 = temps[i];
|
||||
const r2 = temps[i + 1];
|
||||
const r3 = temps[i + 2];
|
||||
const r4 = temps[i + 3]; // may be undefined (no 4th reading available)
|
||||
|
||||
const above = (t: number) => t > sixMax;
|
||||
const clears = (t: number) => roundHalf(t - sixMax, 2) >= thresholdC;
|
||||
|
||||
// Regular rule (0): 3 above, 3rd clears the threshold.
|
||||
if (above(r1.t) && above(r2.t) && above(r3.t) && clears(r3.t)) {
|
||||
return {
|
||||
ovulationDate: addDays(r1.date, -1),
|
||||
rule: 0,
|
||||
evaluationCompleteDate: r3.date,
|
||||
};
|
||||
}
|
||||
|
||||
// 1. Ausnahmeregel (1): all 3 above the line but the 3rd does not clear
|
||||
// 0.2°C → require a 4th reading that is merely above the line.
|
||||
if (
|
||||
above(r1.t) &&
|
||||
above(r2.t) &&
|
||||
above(r3.t) &&
|
||||
!clears(r3.t) &&
|
||||
r4 != null &&
|
||||
above(r4.t)
|
||||
) {
|
||||
return {
|
||||
ovulationDate: addDays(r1.date, -1),
|
||||
rule: 1,
|
||||
evaluationCompleteDate: r4.date,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Ausnahmeregel (2): the 1st is above the line and EXACTLY ONE of the
|
||||
// 2nd/3rd falls back to/below the line → that value is discounted and a 4th
|
||||
// reading is required which must again clear 0.2°C above the line. (The 1st
|
||||
// high measurement itself must stay above the line — it anchors the rise.)
|
||||
const oneFellBack = above(r1.t) && above(r2.t) !== above(r3.t);
|
||||
if (oneFellBack && r4 != null && above(r4.t) && clears(r4.t)) {
|
||||
return {
|
||||
ovulationDate: addDays(r1.date, -1),
|
||||
rule: 2,
|
||||
evaluationCompleteDate: r4.date,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
Reference in new issue
Block a user