app/lib/risk_engine/calculator.dart
John Mizerek fb494a349c Fix: Don't recommend quitting to former smokers
Former smokers have already quit - they can't take further action
on smoking. Treat them as optimal for the smoking behavior.

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

201 lines
5.7 KiB
Dart

import 'dart:math';
import '../models/models.dart';
import 'hazard_ratios.dart';
import 'mortality_tables.dart';
const String modelVersion = '1.1';
/// 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;
// Screen 1 factors
hr *= getSmokingHR(inputs.smoking, inputs.cigarettesPerDay);
hr *= getAlcoholHR(inputs.alcohol);
hr *= getSleepHR(inputs.sleepHours, inputs.sleepConsistent);
hr *= getActivityHR(inputs.activity);
hr *= getBmiHR(bmi);
// Screen 2 factors
hr *= getDietHR(inputs.diet);
hr *= getProcessedFoodHR(inputs.processedFood);
hr *= getDrugUseHR(inputs.drugUse);
hr *= getSocialHR(inputs.social);
hr *= getStressHR(inputs.stress);
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) {
return LifespanDelta(
lowMonths: 0,
highMonths: 0,
confidence: getConfidenceForBehavior(behaviorKey),
);
}
final rawDeltaYears =
baselineYears * (1 - modifiedHR / currentHR) * _dampingFactor;
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 'diet':
return inputs.copyWith(diet: DietQuality.excellent);
case 'processedFood':
return inputs.copyWith(processedFood: ProcessedFoodLevel.rarely);
case 'drugUse':
return inputs.copyWith(drugUse: DrugUse.none);
case 'social':
return inputs.copyWith(social: SocialConnection.strong);
case 'stress':
return inputs.copyWith(stress: StressLevel.low);
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':
// Former smokers have already quit - no further action possible
return inputs.smoking == SmokingStatus.never ||
inputs.smoking == SmokingStatus.former;
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 'diet':
return inputs.diet == DietQuality.excellent;
case 'processedFood':
return inputs.processedFood == ProcessedFoodLevel.rarely;
case 'drugUse':
return inputs.drugUse == DrugUse.none;
case 'social':
return inputs.social == SocialConnection.strong;
case 'stress':
return inputs.stress == StressLevel.low;
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',
'diet',
'processedFood',
'drugUse',
'social',
'stress',
'driving',
'workHours',
];
/// Calculate ranked factors for a user profile and behavioral inputs.
CalculationResult calculateRankedFactors(
UserProfile profile,
BehavioralInputs inputs,
) {
final baselineYears = getRemainingLifeExpectancy(
profile.age,
profile.sex,
profile.country,
);
final conditionHR = getDiagnosisHR(profile.diagnoses);
final adjustedBaselineYears = baselineYears / conditionHR;
final currentHR = computeCombinedHazard(inputs, profile.bmi);
final factors = <RankedFactor>[];
for (final behaviorKey in _modifiableBehaviors) {
if (_isOptimal(inputs, behaviorKey)) continue;
final modifiedInputs = _setToOptimal(inputs, behaviorKey);
final modifiedHR = computeCombinedHazard(modifiedInputs, profile.bmi);
final delta = _computeDelta(
adjustedBaselineYears,
currentHR,
modifiedHR,
behaviorKey,
);
if (delta.highMonths >= 1) {
factors.add(RankedFactor(
behaviorKey: behaviorKey,
displayName: getDisplayName(behaviorKey),
delta: delta,
));
}
}
factors.sort(
(a, b) => b.delta.midpointMonths.compareTo(a.delta.midpointMonths));
return CalculationResult(
rankedFactors: factors,
modelVersion: modelVersion,
calculatedAt: DateTime.now(),
);
}