glp-companion/test/features/medication/serum_calculator_test.dart

303 lines
11 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:math' as math;
import 'package:flutter_test/flutter_test.dart';
import 'package:glp1_companion/features/medication/domain/entities/dose_entry.dart';
import 'package:glp1_companion/features/medication/domain/usecases/calculate_serum_level.dart';
import 'package:glp1_companion/features/medication/domain/value_objects/glp1_compound.dart';
/// Pharmacokinetics regression suite for the single-compartment decay model
/// `C(t) = Σ D_i · 0.5^((t − t_i)/t_half)`.
///
/// Focuses on the multi-dose / steady-state behaviour that titration
/// planning depends on:
///
/// * **Semaglutide** — `t½ = 168 h`, i.e. exactly one weekly interval, so a
/// weekly dosing ladder has a per-interval decay factor `r = 0.5` and
/// converges to `peak → 2·D`, `trough → D`.
/// * **Tirzepatide** — `t½ = 120 h`, so weekly dosing decays by
/// `r = 0.5^(168/120) = 0.5^1.4 ≈ 0.3789` per interval and converges to
/// `peak → D/(1−r) ≈ 1.61·D`, `trough → D·r/(1−r) ≈ 0.61·D` — a wider
/// peak-to-trough fluctuation than semaglutide, as observed clinically.
DoseEntry _dose({
required String compound,
required double mg,
required DateTime administeredAt,
}) =>
DoseEntry(
compoundName: compound,
doseMg: mg,
administeredAt: administeredAt,
);
/// Independent reference implementation of the decay model (sums the
/// contribution of every dose in [doses] evaluated at [at]).
double _referenceSerum({
required Iterable<DoseEntry> doses,
required DateTime at,
required double halfLifeHours,
}) {
var total = 0.0;
for (final dose in doses) {
final elapsedHours = at.difference(dose.administeredAt).inMilliseconds /
Duration.millisecondsPerHour;
if (elapsedHours < 0) continue;
total +=
dose.doseMg * math.pow(0.5, elapsedHours / halfLifeHours).toDouble();
}
return total;
}
void main() {
const calculator = CalculateSerumLevel();
/// Fixed anchor so dose spacing is exact (no clock drift in assertions).
final epoch = DateTime(2026, 1, 5, 8);
const week = Duration(hours: 168);
/// Weekly dosing ladder: [count] doses of [mg] spaced exactly one
/// semaglutide half-life apart, starting at [epoch].
List<DoseEntry> weeklyLadder({
required String compound,
required double mg,
required int count,
}) => [
for (var i = 0; i < count; i++)
_dose(
compound: compound,
mg: mg,
administeredAt: epoch.add(week * i),
),
];
group('Semaglutide weekly steady state (t½ = 168 h)', () {
test('r = 0.5: trough converges to D, peak to 2·D', () {
const doseMg = 1.0;
// Peak after the 8th weekly dose: D·(2 − 0.5^7) ≈ 1.9922 mg.
final peak = calculator.evaluate(
doses: weeklyLadder(compound: 'Semaglutide', mg: doseMg, count: 8),
at: epoch.add(week * 7),
);
expect(peak, closeTo(doseMg * (2 - math.pow(0.5, 7)), 1e-9));
// Trough just before a hypothetical 9th dose: D·(1 − 0.5^8).
final trough = calculator.evaluate(
doses: weeklyLadder(compound: 'Semaglutide', mg: doseMg, count: 8),
at: epoch.add(week * 8),
);
expect(trough, closeTo(doseMg * (1 - math.pow(0.5, 8)), 1e-9));
// Both sit within 1 % of the analytic steady-state limits.
expect(peak, closeTo(2 * doseMg, 0.01 * 2 * doseMg));
expect(trough, closeTo(doseMg, 0.01 * doseMg));
});
test('troughs rise monotonically week over week toward the plateau', () {
final troughs = <double>[];
for (var n = 1; n <= 8; n++) {
troughs.add(
calculator.evaluate(
doses: weeklyLadder(compound: 'Semaglutide', mg: 1, count: n),
at: epoch.add(week * n),
),
);
}
for (var i = 1; i < troughs.length; i++) {
expect(troughs[i], greaterThan(troughs[i - 1]),
reason: 'accumulation must keep rising before steady state');
}
expect(troughs.first, closeTo(0.5, 1e-9));
expect(troughs.last, closeTo(1, 0.01));
});
test('peak-to-trough fluctuation converges on one dose (r = 0.5)', () {
final doses = weeklyLadder(compound: 'Semaglutide', mg: 2, count: 8);
final peak = calculator.evaluate(doses: doses, at: epoch.add(week * 7));
final trough = calculator.evaluate(doses: doses, at: epoch.add(week * 8));
// At week 8 the spread is D·(1 − 0.5^8) and tightens onto the
// steady-state fluctuation of exactly one dose (D = 2 mg) thereafter.
expect(
peak - trough,
closeTo(2 * (1 - math.pow(0.5, 8)), 1e-9),
);
});
});
group('Tirzepatide weekly steady state (t½ = 120 h)', () {
// Weekly decay factor for a 120 h half-life: 0.5^(168/120) = 0.5^1.4.
final r = math.pow(0.5, 168 / 120).toDouble();
test('accumulation matches the geometric series with r = 0.5^1.4', () {
const doseMg = 2.5;
const doses = 10;
final ladder = weeklyLadder(
compound: 'Tirzepatide',
mg: doseMg,
count: doses,
);
// Peak just after the 10th dose: D·(1 − r^10)/(1 − r).
final expectedPeak = doseMg * (1 - math.pow(r, doses)) / (1 - r);
final peak =
calculator.evaluate(doses: ladder, at: epoch.add(week * (doses - 1)));
expect(peak, closeTo(expectedPeak, 1e-9));
// Trough just before the next dose: D·r·(1 − r^10)/(1 − r).
final expectedTrough = expectedPeak * r;
final trough =
calculator.evaluate(doses: ladder, at: epoch.add(week * doses));
expect(trough, closeTo(expectedTrough, 1e-9));
// Converged within 0.1 % of the analytic steady state D/(1−r).
expect(peak, closeTo(doseMg / (1 - r), 0.001 * doseMg / (1 - r)));
});
test('one missed interval decays the latest dose by exactly one half-life',
() {
final doses = weeklyLadder(compound: 'Tirzepatide', mg: 5, count: 3);
// 120 h (one t½) after the latest dose: 5 mg → 2.5 mg, plus the
// residual of the two earlier weekly doses.
final at = epoch.add(week * 2 + const Duration(hours: 120));
expect(
calculator.evaluate(doses: doses, at: at),
closeTo(
_referenceSerum(doses: doses, at: at, halfLifeHours: 120),
1e-9,
),
);
expect(calculator.evaluate(doses: doses, at: at), greaterThan(2.5));
});
test('weekly dosing fluctuates more than semaglutide (1/r > 2)', () {
final semaLadder =
weeklyLadder(compound: 'Semaglutide', mg: 1, count: 12);
final tirzeLadder =
weeklyLadder(compound: 'Tirzepatide', mg: 1, count: 12);
double ratio(List<DoseEntry> doses) {
final peak = calculator.evaluate(doses: doses, at: epoch.add(week * 11));
final trough = calculator.evaluate(doses: doses, at: epoch.add(week * 12));
return peak / trough;
}
expect(ratio(semaLadder), closeTo(2, 1e-6));
expect(ratio(tirzeLadder), closeTo(1 / r, 1e-6));
expect(ratio(tirzeLadder), greaterThan(ratio(semaLadder)));
});
});
group('multi-dose superposition', () {
test('mixed-age semaglutide doses sum independently', () {
final doses = [
_dose(
compound: 'Semaglutide',
mg: 2,
administeredAt: epoch.subtract(const Duration(hours: 336)),
),
_dose(
compound: 'Semaglutide',
mg: 1,
administeredAt: epoch.subtract(const Duration(hours: 168)),
),
_dose(compound: 'Semaglutide', mg: 0.5, administeredAt: epoch),
];
// 2·0.5² + 1·0.5 + 0.5 = 1.5 mg.
expect(calculator.evaluate(doses: doses, at: epoch), closeTo(1.5, 1e-9));
});
test('mixed-age tirzepatide doses sum independently', () {
final doses = [
_dose(
compound: 'Tirzepatide',
mg: 5,
administeredAt: epoch.subtract(const Duration(hours: 240)),
),
_dose(
compound: 'Tirzepatide',
mg: 2.5,
administeredAt: epoch.subtract(const Duration(hours: 120)),
),
_dose(compound: 'Tirzepatide', mg: 2.5, administeredAt: epoch),
];
// 5·0.5² + 2.5·0.5 + 2.5 = 5.0 mg.
expect(calculator.evaluate(doses: doses, at: epoch), closeTo(5, 1e-9));
});
test('titration ladder: 0.25 mg ×4 weeks then 0.5 mg ×4 (semaglutide)',
() {
final doses = <DoseEntry>[
for (var i = 0; i < 4; i++)
_dose(
compound: 'Ozempic', // Brand alias must resolve to t½ = 168 h.
mg: 0.25,
administeredAt: epoch.add(week * i),
),
for (var i = 4; i < 8; i++)
_dose(
compound: 'Ozempic',
mg: 0.5,
administeredAt: epoch.add(week * i),
),
];
// Peak just after the first full 0.5 mg dose (week 5):
// 0.5 + 0.25·(0.5 + 0.25 + 0.125 + 0.0625) = 0.734375 mg.
final peakAt = epoch.add(week * 4);
final expectedPeak = _referenceSerum(
doses: doses,
at: peakAt,
halfLifeHours: Glp1Compound.semaglutide.halfLifeHours,
);
expect(expectedPeak, closeTo(0.734375, 1e-9));
expect(
calculator.evaluate(doses: doses, at: peakAt),
closeTo(expectedPeak, 1e-9),
);
// The trough before the next dose keeps accumulating (~0.4834 mg),
// well above the old 0.25 mg-dose plateau trough.
final nextTroughAt = epoch.add(week * 8);
final trough = calculator.evaluate(doses: doses, at: nextTroughAt);
expect(
trough,
closeTo(
_referenceSerum(
doses: doses,
at: nextTroughAt,
halfLifeHours: Glp1Compound.semaglutide.halfLifeHours,
),
1e-9,
),
);
expect(trough, greaterThan(0.48));
expect(trough, lessThan(1));
});
test('longer half-life retains more of an identical history', () {
final at = epoch.add(const Duration(hours: 120));
double retained(String compound) => calculator.evaluate(
doses: [_dose(compound: compound, mg: 4, administeredAt: epoch)],
at: at,
);
// After 120 h: tirzepatide keeps 50 %, semaglutide ~61.4 %.
expect(retained('Tirzepatide'), closeTo(2, 1e-9));
expect(
retained('Semaglutide'),
closeTo(4 * math.pow(0.5, 120 / 168), 1e-9),
);
expect(retained('Semaglutide'), greaterThan(retained('Tirzepatide')));
});
test('five half-lives wash out > 96 % of a single dose', () {
final at = epoch.add(const Duration(hours: 120 * 5));
expect(
calculator.evaluate(
doses: [_dose(compound: 'Tirzepatide', mg: 10, administeredAt: epoch)],
at: at,
),
closeTo(10 * math.pow(0.5, 5), 1e-9), // 0.3125 mg = 3.125 %.
);
});
});
}