1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum RiskCategory {
24 Low,
25 Moderate,
26 High,
27 VeryHigh,
28}
29
30#[derive(Debug, Clone)]
34pub struct FraminghamInput {
35 pub age: u8,
36 pub sex_male: bool,
37 pub total_cholesterol_mmol: f64,
38 pub hdl_cholesterol_mmol: f64,
39 pub systolic_bp: f64,
40 pub bp_treated: bool,
42 pub current_smoker: bool,
43 pub diabetic: bool,
44}
45
46#[derive(Debug, Clone)]
47pub struct FraminghamResult {
48 pub risk_10yr: f64,
50 pub category: RiskCategory,
51 pub log_score: f64,
52}
53
54pub fn framingham_10yr_risk(input: &FraminghamInput) -> FraminghamResult {
55 let (b_age, b_tc, b_hdl, b_sbp_unt, b_sbp_trt, b_smoke, b_diab, baseline_surv, mean_sum) =
57 if input.sex_male {
58 (
59 3.06117,
60 1.12370,
61 -0.93263,
62 1.93303,
63 1.99881,
64 0.65451,
65 0.57367,
66 0.88936_f64,
67 23.9802_f64,
68 )
69 } else {
70 (
71 2.32888,
72 1.20904,
73 -0.70833,
74 2.76157,
75 2.82263,
76 0.52873,
77 0.69154,
78 0.95012_f64,
79 26.1931_f64,
80 )
81 };
82
83 let sbp_coeff = if input.bp_treated {
84 b_sbp_trt
85 } else {
86 b_sbp_unt
87 };
88
89 let log_score = b_age * (input.age as f64).ln()
90 + b_tc * input.total_cholesterol_mmol.ln()
91 + b_hdl * input.hdl_cholesterol_mmol.ln()
92 + sbp_coeff * input.systolic_bp.ln()
93 + b_smoke * if input.current_smoker { 1.0 } else { 0.0 }
94 + b_diab * if input.diabetic { 1.0 } else { 0.0 };
95
96 let risk_10yr = (1.0 - baseline_surv.powf((log_score - mean_sum).exp())).clamp(0.0, 1.0);
97
98 let category = if risk_10yr < 0.10 {
99 RiskCategory::Low
100 } else if risk_10yr <= 0.20 {
101 RiskCategory::Moderate
102 } else {
103 RiskCategory::High
104 };
105
106 FraminghamResult {
107 risk_10yr,
108 category,
109 log_score,
110 }
111}
112
113#[derive(Debug, Clone, Default)]
117pub struct Cha2ds2VascInput {
118 pub congestive_heart_failure: bool, pub hypertension: bool, pub age_75_or_older: bool, pub diabetes: bool, pub stroke_tia_history: bool, pub vascular_disease: bool, pub age_65_to_74: bool, pub sex_female: bool, }
127
128#[derive(Debug, Clone)]
129pub struct Cha2ds2VascResult {
130 pub score: u8,
131 pub annual_stroke_risk_pct: f64,
133 pub anticoagulation_recommended: bool,
135}
136
137pub fn cha2ds2_vasc_score(input: &Cha2ds2VascInput) -> Cha2ds2VascResult {
138 let score = input.congestive_heart_failure as u8
139 + input.hypertension as u8
140 + if input.age_75_or_older { 2 } else { 0 }
141 + input.diabetes as u8
142 + if input.stroke_tia_history { 2 } else { 0 }
143 + input.vascular_disease as u8
144 + input.age_65_to_74 as u8
145 + input.sex_female as u8;
146
147 let annual_risk = match score {
148 0 => 0.0,
149 1 => 1.3,
150 2 => 2.2,
151 3 => 3.2,
152 4 => 4.0,
153 5 => 6.7,
154 6 => 9.8,
155 7 => 9.6,
156 8 => 12.5,
157 _ => 15.2,
158 };
159
160 let anticoagulation_recommended = if input.sex_female {
161 score >= 3
162 } else {
163 score >= 2
164 };
165
166 Cha2ds2VascResult {
167 score,
168 annual_stroke_risk_pct: annual_risk,
169 anticoagulation_recommended,
170 }
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub enum Score2Region {
178 Low,
179 Moderate,
180 High,
181 VeryHigh,
182}
183
184#[derive(Debug, Clone)]
185pub struct Score2Input {
186 pub age: u8,
187 pub sex_male: bool,
188 pub systolic_bp: f64,
189 pub total_cholesterol_mmol: f64,
190 pub hdl_cholesterol_mmol: f64,
191 pub current_smoker: bool,
192 pub risk_region: Score2Region,
193}
194
195#[derive(Debug, Clone)]
196pub struct Score2Result {
197 pub risk_10yr_pct: f64,
198 pub category: RiskCategory,
199}
200
201pub fn score2_risk(input: &Score2Input) -> Score2Result {
202 let non_hdl = input.total_cholesterol_mmol - input.hdl_cholesterol_mmol;
203 let age_c = (input.age as f64 - 60.0) / 5.0;
204 let sbp_c = (input.systolic_bp - 120.0) / 20.0;
205 let chol_c = (non_hdl - 3.3) / 0.5;
206 let smoke = input.current_smoker as u8 as f64;
207
208 let (b_age, b_sbp, b_chol, b_smoke, baseline_surv) = if input.sex_male {
209 (0.3742_f64, 0.2628, 0.1401, 0.5865, 0.9605_f64)
210 } else {
211 (0.4648_f64, 0.3131, 0.1002, 0.7742, 0.9776_f64)
212 };
213
214 let linear = b_age * age_c + b_sbp * sbp_c + b_chol * chol_c + b_smoke * smoke;
215 let base_risk = 1.0 - baseline_surv.powf(linear.exp());
216
217 let calibrated_pct = (base_risk
218 * match input.risk_region {
219 Score2Region::Low => 0.71,
220 Score2Region::Moderate => 1.00,
221 Score2Region::High => 1.56,
222 Score2Region::VeryHigh => 2.27,
223 }
224 * 100.0)
225 .clamp(0.0, 100.0);
226
227 let category = if calibrated_pct < 5.0 {
228 RiskCategory::Low
229 } else if calibrated_pct <= 10.0 {
230 RiskCategory::Moderate
231 } else if calibrated_pct <= 20.0 {
232 RiskCategory::High
233 } else {
234 RiskCategory::VeryHigh
235 };
236
237 Score2Result {
238 risk_10yr_pct: calibrated_pct,
239 category,
240 }
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
247pub enum InteractionSeverity {
248 None = 0,
249 Minor = 1,
250 Moderate = 2,
251 Major = 3,
252 Contraindicated = 4,
253}
254
255#[derive(Debug, Clone)]
256pub struct DrugInteraction {
257 pub drug_a: u64,
258 pub drug_b: u64,
259 pub severity: InteractionSeverity,
260 pub mechanism: &'static str,
261}
262
263pub fn check_drug_interactions(active_medications: &[u64]) -> Vec<DrugInteraction> {
266 let pairs: &[(&str, &str, InteractionSeverity, &str)] = &[
268 (
269 "warfarin",
270 "ibuprofen",
271 InteractionSeverity::Major,
272 "CYP2C9 inhibition + antiplatelet effect → major bleeding",
273 ),
274 (
275 "warfarin",
276 "naproxen",
277 InteractionSeverity::Major,
278 "CYP2C9 inhibition + antiplatelet effect → major bleeding",
279 ),
280 (
281 "warfarin",
282 "aspirin",
283 InteractionSeverity::Moderate,
284 "Additive antiplatelet: monitor INR closely",
285 ),
286 (
287 "sertraline",
288 "phenelzine",
289 InteractionSeverity::Contraindicated,
290 "Serotonin syndrome risk (SSRI + MAOI)",
291 ),
292 (
293 "fluoxetine",
294 "selegiline",
295 InteractionSeverity::Contraindicated,
296 "Serotonin syndrome risk (SSRI + MAO-B)",
297 ),
298 (
299 "simvastatin",
300 "clarithromycin",
301 InteractionSeverity::Major,
302 "CYP3A4 inhibition → rhabdomyolysis risk",
303 ),
304 (
305 "atorvastatin",
306 "clarithromycin",
307 InteractionSeverity::Moderate,
308 "CYP3A4 inhibition → myopathy risk",
309 ),
310 (
311 "amiodarone",
312 "ciprofloxacin",
313 InteractionSeverity::Major,
314 "Additive QT prolongation → TdP risk",
315 ),
316 (
317 "methadone",
318 "azithromycin",
319 InteractionSeverity::Major,
320 "Additive QT prolongation → TdP risk",
321 ),
322 (
323 "lisinopril",
324 "spironolactone",
325 InteractionSeverity::Moderate,
326 "Hyperkalaemia risk (ACEi + K-sparing diuretic)",
327 ),
328 (
329 "ramipril",
330 "spironolactone",
331 InteractionSeverity::Moderate,
332 "Hyperkalaemia risk (ACEi + K-sparing diuretic)",
333 ),
334 (
335 "metformin",
336 "iohexol",
337 InteractionSeverity::Major,
338 "Lactic acidosis risk — hold metformin 48h before iodinated contrast",
339 ),
340 (
341 "lithium",
342 "ibuprofen",
343 InteractionSeverity::Major,
344 "NSAIDs reduce renal lithium clearance → toxicity",
345 ),
346 (
347 "lithium",
348 "diclofenac",
349 InteractionSeverity::Major,
350 "NSAIDs reduce renal lithium clearance → toxicity",
351 ),
352 (
353 "methotrexate",
354 "trimethoprim",
355 InteractionSeverity::Major,
356 "Additive folate antagonism → pancytopenia",
357 ),
358 (
359 "digoxin",
360 "amiodarone",
361 InteractionSeverity::Major,
362 "Amiodarone increases digoxin levels → toxicity",
363 ),
364 (
365 "clopidogrel",
366 "omeprazole",
367 InteractionSeverity::Moderate,
368 "CYP2C19 inhibition reduces clopidogrel activation",
369 ),
370 (
371 "tramadol",
372 "sertraline",
373 InteractionSeverity::Moderate,
374 "Serotonin syndrome + seizure threshold lowering",
375 ),
376 ];
377
378 let mut found = Vec::new();
379 for (i, &a) in active_medications.iter().enumerate() {
380 for &b in &active_medications[i + 1..] {
381 for &(na, nb, sev, mech) in pairs {
382 let ha = crate::q_hash(na);
383 let hb = crate::q_hash(nb);
384 if (a == ha && b == hb) || (a == hb && b == ha) {
385 found.push(DrugInteraction {
386 drug_a: a,
387 drug_b: b,
388 severity: sev,
389 mechanism: mech,
390 });
391 }
392 }
393 }
394 }
395 found
396}
397
398#[derive(Debug, Clone)]
401pub struct ContraindicationResult {
402 pub drug: u64,
403 pub condition_snomed: u64,
404 pub severity: InteractionSeverity,
405 pub reason: &'static str,
406}
407
408pub fn check_contraindications(
410 drug_hash: u64,
411 condition_hashes: &[u64],
412) -> Vec<ContraindicationResult> {
413 let table: &[(&str, &str, InteractionSeverity, &str)] = &[
415 (
416 "metformin",
417 "709044004",
418 InteractionSeverity::Contraindicated,
419 "CKD stage 4/5 (eGFR < 30): lactic acidosis risk",
420 ),
421 (
422 "nsaid",
423 "709044004",
424 InteractionSeverity::Major,
425 "CKD: NSAIDs worsen renal haemodynamics",
426 ),
427 (
428 "ibuprofen",
429 "709044004",
430 InteractionSeverity::Major,
431 "CKD: NSAIDs worsen renal haemodynamics",
432 ),
433 (
434 "naproxen",
435 "709044004",
436 InteractionSeverity::Major,
437 "CKD: NSAIDs worsen renal haemodynamics",
438 ),
439 (
440 "atenolol",
441 "195967001",
442 InteractionSeverity::Contraindicated,
443 "Asthma: non-selective beta-blockers precipitate bronchospasm",
444 ),
445 (
446 "propranolol",
447 "195967001",
448 InteractionSeverity::Contraindicated,
449 "Asthma: non-selective beta-blockers precipitate bronchospasm",
450 ),
451 (
452 "metoprolol",
453 "195967001",
454 InteractionSeverity::Moderate,
455 "Asthma: cardioselective beta-blocker — use with caution",
456 ),
457 (
458 "lithium",
459 "709044004",
460 InteractionSeverity::Contraindicated,
461 "CKD: lithium is renally cleared — nephrotoxicity risk",
462 ),
463 (
464 "clozapine",
465 "84989004",
466 InteractionSeverity::Major,
467 "Seizure disorder: clozapine lowers seizure threshold",
468 ),
469 (
470 "tramadol",
471 "84989004",
472 InteractionSeverity::Major,
473 "Seizure disorder: tramadol lowers seizure threshold",
474 ),
475 (
476 "warfarin",
477 "713078009",
478 InteractionSeverity::Major,
479 "Haemorrhagic stroke history: anticoagulation risk",
480 ),
481 (
482 "amiodarone",
483 "49436004",
484 InteractionSeverity::Major,
485 "Pulmonary disease: amiodarone pulmonary toxicity risk",
486 ),
487 (
488 "thalidomide",
489 "77386006",
490 InteractionSeverity::Contraindicated,
491 "Pregnancy: teratogen (Category X)",
492 ),
493 (
494 "isotretinoin",
495 "77386006",
496 InteractionSeverity::Contraindicated,
497 "Pregnancy: teratogen (Category X)",
498 ),
499 (
500 "methotrexate",
501 "77386006",
502 InteractionSeverity::Contraindicated,
503 "Pregnancy: teratogen — folate antagonist",
504 ),
505 (
506 "sildenafil",
507 "194828000",
508 InteractionSeverity::Contraindicated,
509 "Angina on nitrates: severe hypotension risk",
510 ),
511 ];
512
513 condition_hashes
514 .iter()
515 .filter_map(|&cond| {
516 table
517 .iter()
518 .find(|&&(dn, sn, _, _)| {
519 crate::q_hash(dn) == drug_hash && crate::q_hash(sn) == cond
520 })
521 .map(|&(_, sn, sev, reason)| ContraindicationResult {
522 drug: drug_hash,
523 condition_snomed: crate::q_hash(sn),
524 severity: sev,
525 reason,
526 })
527 })
528 .collect()
529}
530
531#[derive(Debug, Clone)]
534pub struct FhirObservation {
535 pub loinc_code: String,
537 pub value: f64,
538 pub unit_ucum: String,
540 pub reference_low: Option<f64>,
541 pub reference_high: Option<f64>,
542}
543
544#[derive(Debug, Clone, Copy, PartialEq, Eq)]
545pub enum ObservationStatus {
546 Normal,
547 Low,
548 High,
549 CriticalLow,
550 CriticalHigh,
551 Unknown,
552}
553
554#[derive(Debug, Clone)]
555pub struct FhirValidationResult {
556 pub is_valid: bool,
557 pub status: ObservationStatus,
558 pub interpretation_code: &'static str,
560}
561
562pub fn validate_fhir_observation(obs: &FhirObservation) -> FhirValidationResult {
563 const RANGES: &[(&str, f64, f64, f64, f64)] = &[
565 ("4548-4", 4.0, 5.7, 2.0, 15.0), ("1558-6", 3.9, 5.5, 2.2, 25.0), ("2093-3", 0.0, 5.17, 0.0, 15.0), ("2089-1", 0.0, 3.36, 0.0, 12.0), ("2085-9", 1.0, 3.0, 0.4, 5.0), ("2571-8", 0.0, 1.7, 0.0, 10.0), ("62238-1", 60.0, 200.0, 5.0, 200.0), ("9318-7", 0.0, 3.0, 0.0, 30.0), ("38483-4", 45.0, 90.0, 10.0, 1000.0), ("1742-6", 0.0, 41.0, 0.0, 1000.0), ("1920-8", 0.0, 40.0, 0.0, 1000.0), ("718-7", 120.0, 170.0, 60.0, 200.0), ("6690-2", 4.0, 11.0, 1.5, 30.0), ("30522-7", 0.0, 5.0, 0.0, 50.0), ("3016-3", 0.4, 4.0, 0.01, 100.0), ("3024-7", 9.0, 19.0, 0.0, 50.0), ("2143-6", 0.0, 500.0, 0.0, 2000.0), ("1989-3", 50.0, 250.0, 10.0, 400.0), ("20448-7", 2.6, 24.9, 0.0, 200.0), ("8480-6", 90.0, 140.0, 70.0, 220.0), ("8462-4", 60.0, 90.0, 40.0, 140.0), ("8867-4", 50.0, 100.0, 30.0, 200.0), ("80404-7", 20.0, 200.0, 0.0, 500.0), ("59408-5", 95.0, 100.0, 85.0, 100.0), ("44261-6", 0.0, 4.0, 0.0, 27.0), ("69737-5", 0.0, 4.0, 0.0, 21.0), ("93832-4", 70.0, 100.0, 40.0, 100.0), ];
593
594 let range = RANGES
595 .iter()
596 .find(|(code, ..)| *code == obs.loinc_code.as_str());
597
598 let status = if let Some(&(_, low, high, crit_low, crit_high)) = range {
599 let v = obs.value;
600 if v < crit_low {
601 ObservationStatus::CriticalLow
602 } else if v > crit_high {
603 ObservationStatus::CriticalHigh
604 } else if v < low {
605 ObservationStatus::Low
606 } else if v > high {
607 ObservationStatus::High
608 } else {
609 ObservationStatus::Normal
610 }
611 } else if let (Some(low), Some(high)) = (obs.reference_low, obs.reference_high) {
612 let v = obs.value;
613 if v < low * 0.5 {
614 ObservationStatus::CriticalLow
615 } else if v > high * 2.0 {
616 ObservationStatus::CriticalHigh
617 } else if v < low {
618 ObservationStatus::Low
619 } else if v > high {
620 ObservationStatus::High
621 } else {
622 ObservationStatus::Normal
623 }
624 } else {
625 ObservationStatus::Unknown
626 };
627
628 let interp = match status {
629 ObservationStatus::Normal => "N",
630 ObservationStatus::Low => "L",
631 ObservationStatus::High => "H",
632 ObservationStatus::CriticalLow => "LL",
633 ObservationStatus::CriticalHigh => "HH",
634 ObservationStatus::Unknown => "U",
635 };
636
637 FhirValidationResult {
638 is_valid: status != ObservationStatus::Unknown,
639 status,
640 interpretation_code: interp,
641 }
642}
643
644#[derive(Debug, Clone)]
647pub struct TimePoint {
648 pub timestamp_s: i64,
650 pub value: f64,
651}
652
653#[derive(Debug, Clone, Copy, PartialEq, Eq)]
654pub enum TrendDirection {
655 Improving,
656 Worsening,
657 Stable,
658 Insufficient,
659}
660
661#[derive(Debug, Clone)]
662pub struct TrendResult {
663 pub slope_per_day: f64,
665 pub r_squared: f64,
667 pub forecast: f64,
669 pub direction: TrendDirection,
670}
671
672pub fn longitudinal_trend(
675 series: &[TimePoint],
676 forecast_days: f64,
677 improvement_direction: i8,
678) -> TrendResult {
679 if series.len() < 2 {
680 let v = series.first().map(|p| p.value).unwrap_or(0.0);
681 return TrendResult {
682 slope_per_day: 0.0,
683 r_squared: 0.0,
684 forecast: v,
685 direction: TrendDirection::Insufficient,
686 };
687 }
688
689 let t0 = series[0].timestamp_s as f64;
690 let xs: Vec<f64> = series
691 .iter()
692 .map(|p| (p.timestamp_s as f64 - t0) / 86400.0)
693 .collect();
694 let ys: Vec<f64> = series.iter().map(|p| p.value).collect();
695 let n = xs.len() as f64;
696
697 let sx: f64 = xs.iter().sum();
698 let sy: f64 = ys.iter().sum();
699 let sxx: f64 = xs.iter().map(|x| x * x).sum();
700 let sxy: f64 = xs.iter().zip(ys.iter()).map(|(x, y)| x * y).sum();
701 let denom = n * sxx - sx * sx;
702
703 if denom.abs() < 1e-10 {
704 return TrendResult {
705 slope_per_day: 0.0,
706 r_squared: 1.0,
707 forecast: ys[0],
708 direction: TrendDirection::Stable,
709 };
710 }
711
712 let slope = (n * sxy - sx * sy) / denom;
713 let intercept = (sy - slope * sx) / n;
714 let y_mean = sy / n;
715
716 let ss_res: f64 = ys
717 .iter()
718 .zip(xs.iter())
719 .map(|(y, x)| (y - (intercept + slope * x)).powi(2))
720 .sum();
721 let ss_tot: f64 = ys.iter().map(|y| (y - y_mean).powi(2)).sum();
722 let r_squared = if ss_tot < 1e-10 {
723 1.0
724 } else {
725 1.0 - ss_res / ss_tot
726 };
727
728 let last_x = xs.last().copied().unwrap_or(0.0);
729 let forecast = intercept + slope * (last_x + forecast_days);
730
731 let direction = if slope.abs() < 0.001 {
732 TrendDirection::Stable
733 } else if (slope > 0.0 && improvement_direction > 0)
734 || (slope < 0.0 && improvement_direction < 0)
735 {
736 TrendDirection::Improving
737 } else {
738 TrendDirection::Worsening
739 };
740
741 TrendResult {
742 slope_per_day: slope,
743 r_squared,
744 forecast,
745 direction,
746 }
747}
748
749#[derive(Debug, Clone, Copy, PartialEq, Eq)]
752pub enum ExpressionDirection {
753 Upregulated,
754 Downregulated,
755 Unchanged,
756}
757
758#[derive(Debug, Clone)]
759pub struct GeneExpressionResult {
760 pub gene_id: u64,
761 pub fold_change: f64,
762 pub log2_fold_change: f64,
763 pub is_significant: bool,
764 pub direction: ExpressionDirection,
765}
766
767pub fn evaluate_gene_expression(
769 gene_id: u64,
770 baseline: f64,
771 treatment: f64,
772 fc_threshold: f64,
773) -> GeneExpressionResult {
774 let fold_change = if baseline > 1e-9 {
775 treatment / baseline
776 } else {
777 f64::INFINITY
778 };
779 let log2_fc = if fold_change.is_finite() {
780 fold_change.log2()
781 } else {
782 f64::INFINITY
783 };
784 let is_significant = fold_change >= fc_threshold
785 || (fold_change.is_finite() && fold_change <= 1.0 / fc_threshold);
786
787 let direction = if !is_significant {
788 ExpressionDirection::Unchanged
789 } else if fold_change >= 1.0 {
790 ExpressionDirection::Upregulated
791 } else {
792 ExpressionDirection::Downregulated
793 };
794
795 GeneExpressionResult {
796 gene_id,
797 fold_change,
798 log2_fold_change: log2_fc,
799 is_significant,
800 direction,
801 }
802}
803
804#[derive(Debug, Clone)]
807pub struct RenalInput {
808 pub age: u8,
809 pub sex_male: bool,
810 pub weight_kg: f64,
811 pub serum_creatinine: f64,
813}
814
815pub fn cockcroft_gault_crcl(input: &RenalInput) -> f64 {
817 let mut crcl = ((140.0 - input.age as f64) * input.weight_kg) / (72.0 * input.serum_creatinine);
818 if !input.sex_male {
819 crcl *= 0.85;
820 }
821 crcl
822}
823
824pub fn ckd_epi_egfr(input: &RenalInput) -> f64 {
826 let k = if input.sex_male { 0.9 } else { 0.7 };
827 let a = if input.sex_male { -0.302 } else { -0.241 };
828
829 let scr_k = input.serum_creatinine / k;
830 let min_val = scr_k.min(1.0);
831 let max_val = scr_k.max(1.0);
832
833 let mut egfr =
834 142.0 * min_val.powf(a) * max_val.powf(-1.200) * 0.9938_f64.powf(input.age as f64);
835 if !input.sex_male {
836 egfr *= 1.012;
837 }
838 egfr
839}
840
841#[derive(Debug, Clone)]
844pub struct PkOneCompartmentInput {
845 pub dose_mg: f64,
846 pub volume_distribution_l: f64,
848 pub clearance_l_hr: f64,
850 pub time_hr: f64,
852}
853
854#[derive(Debug, Clone)]
855pub struct PkResult {
856 pub concentration: f64,
858 pub half_life_hr: f64,
860}
861
862pub fn one_compartment_pk_model(input: &PkOneCompartmentInput) -> PkResult {
864 let k_el = input.clearance_l_hr / input.volume_distribution_l;
865 let c0 = input.dose_mg / input.volume_distribution_l;
866 let concentration = c0 * (-k_el * input.time_hr).exp();
867 let half_life_hr = 0.693147 / k_el;
868
869 PkResult {
870 concentration,
871 half_life_hr,
872 }
873}
874
875#[derive(Debug, Clone, Default)]
878pub struct SofaInput {
879 pub pao2_fio2_ratio: f64, pub platelets_10_9_l: f64,
881 pub bilirubin_mg_dl: f64,
882 pub map_mmhg: f64, pub dopamine_dose: f64, pub epinephrine_dose: f64, pub norepinephrine_dose: f64, pub glasgow_coma_scale: u8,
887 pub creatinine_mg_dl: f64,
888 pub urine_output_ml_d: f64,
889}
890
891pub fn sofa_score(input: &SofaInput) -> u8 {
893 let mut score = 0;
894
895 if input.pao2_fio2_ratio > 0.0 {
897 if input.pao2_fio2_ratio < 100.0 {
898 score += 4;
899 } else if input.pao2_fio2_ratio < 200.0 {
900 score += 3;
901 } else if input.pao2_fio2_ratio < 300.0 {
902 score += 2;
903 } else if input.pao2_fio2_ratio < 400.0 {
904 score += 1;
905 }
906 }
907
908 if input.platelets_10_9_l > 0.0 {
910 if input.platelets_10_9_l < 20.0 {
911 score += 4;
912 } else if input.platelets_10_9_l < 50.0 {
913 score += 3;
914 } else if input.platelets_10_9_l < 100.0 {
915 score += 2;
916 } else if input.platelets_10_9_l < 150.0 {
917 score += 1;
918 }
919 }
920
921 if input.bilirubin_mg_dl >= 12.0 {
923 score += 4;
924 } else if input.bilirubin_mg_dl >= 6.0 {
925 score += 3;
926 } else if input.bilirubin_mg_dl >= 2.0 {
927 score += 2;
928 } else if input.bilirubin_mg_dl >= 1.2 {
929 score += 1;
930 }
931
932 if input.dopamine_dose > 15.0 || input.epinephrine_dose > 0.1 || input.norepinephrine_dose > 0.1
934 {
935 score += 4;
936 } else if input.dopamine_dose > 5.0
937 || (input.epinephrine_dose > 0.0 && input.epinephrine_dose <= 0.1)
938 || (input.norepinephrine_dose > 0.0 && input.norepinephrine_dose <= 0.1)
939 {
940 score += 3;
941 } else if input.dopamine_dose > 0.0 {
942 score += 2;
943 } else if input.map_mmhg > 0.0 && input.map_mmhg < 70.0 {
944 score += 1;
945 }
946
947 if input.glasgow_coma_scale > 0 {
949 if input.glasgow_coma_scale < 6 {
950 score += 4;
951 } else if input.glasgow_coma_scale <= 9 {
952 score += 3;
953 } else if input.glasgow_coma_scale <= 12 {
954 score += 2;
955 } else if input.glasgow_coma_scale <= 14 {
956 score += 1;
957 }
958 }
959
960 if input.creatinine_mg_dl >= 5.0
962 || (input.urine_output_ml_d < 200.0 && input.urine_output_ml_d > 0.0)
963 {
964 score += 4;
965 } else if input.creatinine_mg_dl >= 3.5
966 || (input.urine_output_ml_d < 500.0 && input.urine_output_ml_d > 0.0)
967 {
968 score += 3;
969 } else if input.creatinine_mg_dl >= 2.0 {
970 score += 2;
971 } else if input.creatinine_mg_dl >= 1.2 {
972 score += 1;
973 }
974
975 score
976}
977
978#[cfg(test)]
981mod tests {
982 use super::*;
983
984 #[test]
985 fn framingham_high_risk_male() {
986 let r = framingham_10yr_risk(&FraminghamInput {
987 age: 60,
988 sex_male: true,
989 total_cholesterol_mmol: 6.5,
990 hdl_cholesterol_mmol: 0.9,
991 systolic_bp: 162.0,
992 bp_treated: false,
993 current_smoker: true,
994 diabetic: true,
995 });
996 assert!(r.risk_10yr > 0.20, "Expected >20% got {:.2}", r.risk_10yr);
997 assert_eq!(r.category, RiskCategory::High);
998 }
999
1000 #[test]
1001 fn framingham_low_risk_female() {
1002 let r = framingham_10yr_risk(&FraminghamInput {
1003 age: 40,
1004 sex_male: false,
1005 total_cholesterol_mmol: 4.5,
1006 hdl_cholesterol_mmol: 1.8,
1007 systolic_bp: 115.0,
1008 bp_treated: false,
1009 current_smoker: false,
1010 diabetic: false,
1011 });
1012 assert!(r.risk_10yr < 0.10, "Expected <10% got {:.2}", r.risk_10yr);
1013 assert_eq!(r.category, RiskCategory::Low);
1014 }
1015
1016 #[test]
1017 fn cha2ds2_max_score() {
1018 let r = cha2ds2_vasc_score(&Cha2ds2VascInput {
1019 congestive_heart_failure: true,
1020 hypertension: true,
1021 age_75_or_older: true,
1022 diabetes: true,
1023 stroke_tia_history: true,
1024 vascular_disease: true,
1025 age_65_to_74: false,
1026 sex_female: true,
1027 });
1028 assert_eq!(r.score, 9);
1029 assert!(r.anticoagulation_recommended);
1030 }
1031
1032 #[test]
1033 fn cha2ds2_zero_male() {
1034 let r = cha2ds2_vasc_score(&Cha2ds2VascInput::default());
1035 assert_eq!(r.score, 0);
1036 assert!(!r.anticoagulation_recommended);
1037 }
1038
1039 #[test]
1040 fn drug_interaction_warfarin_ibuprofen() {
1041 let meds = vec![crate::q_hash("warfarin"), crate::q_hash("ibuprofen")];
1042 let found = check_drug_interactions(&meds);
1043 assert!(!found.is_empty());
1044 assert_eq!(found[0].severity, InteractionSeverity::Major);
1045 }
1046
1047 #[test]
1048 fn drug_interaction_no_false_positive() {
1049 let meds = vec![crate::q_hash("paracetamol"), crate::q_hash("lactulose")];
1050 let found = check_drug_interactions(&meds);
1051 assert!(found.is_empty());
1052 }
1053
1054 #[test]
1055 fn fhir_hba1c_normal() {
1056 let r = validate_fhir_observation(&FhirObservation {
1057 loinc_code: "4548-4".into(),
1058 value: 5.2,
1059 unit_ucum: "%".into(),
1060 reference_low: None,
1061 reference_high: None,
1062 });
1063 assert_eq!(r.status, ObservationStatus::Normal);
1064 assert_eq!(r.interpretation_code, "N");
1065 }
1066
1067 #[test]
1068 fn fhir_hba1c_high() {
1069 let r = validate_fhir_observation(&FhirObservation {
1070 loinc_code: "4548-4".into(),
1071 value: 8.5,
1072 unit_ucum: "%".into(),
1073 reference_low: None,
1074 reference_high: None,
1075 });
1076 assert_eq!(r.status, ObservationStatus::High);
1077 assert_eq!(r.interpretation_code, "H");
1078 }
1079
1080 #[test]
1081 fn trend_worsening_bp() {
1082 let series = vec![
1083 TimePoint {
1084 timestamp_s: 0,
1085 value: 120.0,
1086 },
1087 TimePoint {
1088 timestamp_s: 86400,
1089 value: 125.0,
1090 },
1091 TimePoint {
1092 timestamp_s: 172800,
1093 value: 130.0,
1094 },
1095 TimePoint {
1096 timestamp_s: 259200,
1097 value: 135.0,
1098 },
1099 ];
1100 let r = longitudinal_trend(&series, 7.0, -1);
1101 assert!(r.slope_per_day > 4.0);
1102 assert_eq!(r.direction, TrendDirection::Worsening);
1103 assert!(r.r_squared > 0.99);
1104 }
1105
1106 #[test]
1107 fn gene_expression_upregulated() {
1108 let r = evaluate_gene_expression(0xDEAD, 100.0, 350.0, 2.0);
1109 assert!(r.is_significant);
1110 assert_eq!(r.direction, ExpressionDirection::Upregulated);
1111 assert!((r.log2_fold_change - 1.807).abs() < 0.01);
1112 }
1113
1114 #[test]
1115 fn test_cockcroft_gault() {
1116 let input = RenalInput {
1117 age: 60,
1118 sex_male: true,
1119 weight_kg: 80.0,
1120 serum_creatinine: 1.2,
1121 };
1122 let crcl = cockcroft_gault_crcl(&input);
1123 assert!((crcl - 74.07).abs() < 0.1);
1124 }
1125
1126 #[test]
1127 fn test_ckd_epi() {
1128 let input = RenalInput {
1129 age: 60,
1130 sex_male: false,
1131 weight_kg: 70.0,
1132 serum_creatinine: 1.2,
1133 };
1134 let egfr = ckd_epi_egfr(&input);
1135 assert!((egfr - 51.5).abs() < 1.0);
1136 }
1137
1138 #[test]
1139 fn test_one_compartment_pk() {
1140 let input = PkOneCompartmentInput {
1141 dose_mg: 1000.0,
1142 volume_distribution_l: 50.0,
1143 clearance_l_hr: 5.0,
1144 time_hr: 10.0,
1145 };
1146 let pk = one_compartment_pk_model(&input);
1147 assert!((pk.concentration - 7.35).abs() < 0.1);
1148 assert!((pk.half_life_hr - 6.93).abs() < 0.1);
1149 }
1150
1151 #[test]
1152 fn test_sofa_score() {
1153 let input = SofaInput {
1154 pao2_fio2_ratio: 250.0, platelets_10_9_l: 80.0, bilirubin_mg_dl: 3.0, map_mmhg: 65.0, dopamine_dose: 0.0,
1159 epinephrine_dose: 0.0,
1160 norepinephrine_dose: 0.0,
1161 glasgow_coma_scale: 13, creatinine_mg_dl: 2.5, urine_output_ml_d: 0.0,
1164 };
1165 let score = sofa_score(&input);
1166 assert_eq!(score, 10);
1167 }
1168}