app/lib/risk_engine/calculator.dart
John Mizerek c92ecf5774 Add onboarding slideshow, tighten estimate ranges
- 3-slide onboarding: baseline, questions, results
- Replaced welcome screen with onboarding flow
- Tightened uncertainty range from ±40% to ±20%
- Ranges now more precise (e.g., 58-86 vs 40-104 months)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-20 21:38:38 -08:00

176 lines
5.1 KiB
Dart
Raw 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';
import '../models/models.dart';
import 'hazard_ratios.dart';
import 'mortality_tables.dart';
const String modelVersion = '1.0';
/// Maximum combined hazard ratio (prevents unrealistic compounding).
const double _maxCombinedHR = 4.0;
/// Damping factor for delta calculation (conservative estimate).
const double _dampingFactor = 0.3;
/// Uncertainty range multipliers (±20% around midpoint).
const double _lowMultiplier = 0.8;
const double _highMultiplier = 1.2;
/// Calculate combined hazard ratio from behavioral inputs.
double computeCombinedHazard(BehavioralInputs inputs, double bmi) {
double hr = 1.0;
hr *= getSmokingHR(inputs.smoking, inputs.cigarettesPerDay);
hr *= getAlcoholHR(inputs.alcohol);
hr *= getSleepHR(inputs.sleepHours, inputs.sleepConsistent);
hr *= getActivityHR(inputs.activity);
hr *= getBmiHR(bmi);
hr *= getDrivingHR(inputs.driving);
hr *= getWorkHoursHR(inputs.workHours);
return min(hr, _maxCombinedHR);
}
/// Calculate lifespan delta when modifying a behavior to optimal.
LifespanDelta _computeDelta(
double baselineYears,
double currentHR,
double modifiedHR,
String behaviorKey,
) {
if (currentHR <= modifiedHR) {
// No improvement possible or already optimal
return LifespanDelta(
lowMonths: 0,
highMonths: 0,
confidence: getConfidenceForBehavior(behaviorKey),
);
}
// Delta years ≈ baselineYears × (1 - modifiedHR/currentHR) × dampingFactor
final rawDeltaYears =
baselineYears * (1 - modifiedHR / currentHR) * _dampingFactor;
// Convert to months with uncertainty range
final midpointMonths = rawDeltaYears * 12;
final lowMonths = (midpointMonths * _lowMultiplier).round();
final highMonths = (midpointMonths * _highMultiplier).round();
return LifespanDelta(
lowMonths: max(0, lowMonths),
highMonths: max(0, highMonths),
confidence: getConfidenceForBehavior(behaviorKey),
);
}
/// Get modified inputs with a specific behavior set to optimal.
BehavioralInputs _setToOptimal(BehavioralInputs inputs, String behaviorKey) {
switch (behaviorKey) {
case 'smoking':
return inputs.copyWith(
smoking: SmokingStatus.never,
cigarettesPerDay: 0,
);
case 'alcohol':
return inputs.copyWith(alcohol: AlcoholLevel.none);
case 'sleep':
return inputs.copyWith(sleepHours: 7.5, sleepConsistent: true);
case 'activity':
return inputs.copyWith(activity: ActivityLevel.high);
case 'driving':
return inputs.copyWith(driving: DrivingExposure.low);
case 'workHours':
return inputs.copyWith(workHours: WorkHoursLevel.normal);
default:
return inputs;
}
}
/// Check if a behavior is already at optimal level.
bool _isOptimal(BehavioralInputs inputs, String behaviorKey) {
switch (behaviorKey) {
case 'smoking':
return inputs.smoking == SmokingStatus.never;
case 'alcohol':
return inputs.alcohol == AlcoholLevel.none ||
inputs.alcohol == AlcoholLevel.light;
case 'sleep':
return inputs.sleepHours >= 7 &&
inputs.sleepHours <= 8 &&
inputs.sleepConsistent;
case 'activity':
return inputs.activity == ActivityLevel.high;
case 'driving':
return inputs.driving == DrivingExposure.low;
case 'workHours':
return inputs.workHours == WorkHoursLevel.normal;
default:
return true;
}
}
/// List of modifiable behavior keys.
const _modifiableBehaviors = [
'smoking',
'alcohol',
'sleep',
'activity',
'driving',
'workHours',
];
/// Calculate ranked factors for a user profile and behavioral inputs.
CalculationResult calculateRankedFactors(
UserProfile profile,
BehavioralInputs inputs,
) {
// Get baseline remaining life expectancy
final baselineYears = getRemainingLifeExpectancy(
profile.age,
profile.sex,
profile.country,
);
// Apply existing condition modifiers (reduces baseline)
final conditionHR = getDiagnosisHR(profile.diagnoses);
final adjustedBaselineYears = baselineYears / conditionHR;
// Calculate current combined HR from behaviors
final currentHR = computeCombinedHazard(inputs, profile.bmi);
// Calculate delta for each modifiable behavior
final factors = <RankedFactor>[];
for (final behaviorKey in _modifiableBehaviors) {
// Skip if already optimal
if (_isOptimal(inputs, behaviorKey)) continue;
// Compute HR with this behavior set to optimal
final modifiedInputs = _setToOptimal(inputs, behaviorKey);
final modifiedHR = computeCombinedHazard(modifiedInputs, profile.bmi);
final delta = _computeDelta(
adjustedBaselineYears,
currentHR,
modifiedHR,
behaviorKey,
);
// Only include if there's meaningful gain (> 1 month)
if (delta.highMonths >= 1) {
factors.add(RankedFactor(
behaviorKey: behaviorKey,
displayName: getDisplayName(behaviorKey),
delta: delta,
));
}
}
// Sort by midpoint delta descending
factors.sort((a, b) => b.delta.midpointMonths.compareTo(a.delta.midpointMonths));
return CalculationResult(
rankedFactors: factors,
modelVersion: modelVersion,
calculatedAt: DateTime.now(),
);
}