303 lines
11 KiB
Dart
303 lines
11 KiB
Dart
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 %.
|
||
);
|
||
});
|
||
});
|
||
} |