use crate::{AbstainReason, Atom, Hyp, Observation, OntologySystem, Operator, Outcome};
pub struct Curb65Operator {
pub version: String,
}
impl Curb65Operator {
pub fn new(version: impl Into<String>) -> Self {
Curb65Operator {
version: version.into(),
}
}
fn calculate_curb65_score(observations: &[Observation]) -> (u32, bool) {
let mut score = 0;
let mut urea_available = false;
if observations
.iter()
.any(|obs| obs.code == "CONFUSION" && obs.value.as_bool().unwrap_or(false))
{
score += 1;
}
if let Some(obs) = observations
.iter()
.find(|obs| obs.code == "UREA" || obs.code == "BUN")
{
if let Some(val) = obs.value.as_f64() {
urea_available = true;
let elevated = if obs.code == "UREA" {
val > 7.0
} else {
val > 19.0
};
if elevated {
score += 1;
}
}
}
if let Some(obs) = observations.iter().find(|obs| obs.code == "RESP-RATE") {
if let Some(rr) = obs.value.as_f64() {
if rr >= 30.0 {
score += 1;
}
}
}
if let Some(obs) = observations.iter().find(|obs| obs.code == "SBP") {
if let Some(sbp) = obs.value.as_f64() {
if sbp < 90.0 {
score += 1;
}
}
}
if let Some(obs) = observations.iter().find(|obs| obs.code == "DBP") {
if let Some(dbp) = obs.value.as_f64() {
if dbp <= 60.0 {
score += 1;
}
}
}
if let Some(obs) = observations.iter().find(|obs| obs.code == "AGE") {
if let Some(age) = obs.value.as_f64() {
if age >= 65.0 {
score += 1;
}
}
}
(score, urea_available)
}
fn determine_disposition(score: u32) -> &'static str {
match score {
0..=1 => "OUTPATIENT",
2 => "WARD-ADMISSION",
3..=5 => "ICU-EVALUATION",
_ => "ICU-EVALUATION", }
}
fn disposition_to_atom(&self, disposition: &str, score: u32) -> Atom {
Atom {
system: OntologySystem::SNOMED,
code: format!("CURB65-CAP-{}", disposition),
preferred_term: format!("CAP {}: CURB-65 score {}", disposition, score),
version: self.version.clone(),
}
}
}
impl Operator for Curb65Operator {
fn apply(&self, h: &Hyp, e: &crate::Evidence) -> Outcome<Hyp, AbstainReason> {
let (score, _urea_available) = Self::calculate_curb65_score(&e.observations);
let disposition = Self::determine_disposition(score);
let cap_atom = self.disposition_to_atom(disposition, score);
let mut new_atoms = h.atoms().to_vec();
new_atoms.push(cap_atom);
Outcome::Refined(Hyp::new(new_atoms))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Evidence, Observation, Provenance, ProvenanceOrigin};
use chrono::Utc;
use std::collections::BTreeMap;
fn test_evidence_with_observations(observations: Vec<Observation>) -> Evidence {
Evidence::new(
observations,
Provenance::new(
ProvenanceOrigin::new("test", "SNOMED", "test"),
Utc::now(),
crate::Ver::new("test", "curb65", "0.1.0"),
BTreeMap::new(),
),
)
}
#[test]
fn test_curb65_low_risk_score_0() {
let op = Curb65Operator::new("0.1.0");
let observations = vec![];
let e = test_evidence_with_observations(observations);
let h = Hyp::unknown();
let outcome = op.apply(&h, &e);
match outcome {
Outcome::Refined(h_prime) => {
assert!(h_prime < h);
let atoms = h_prime.atoms();
assert!(atoms.iter().any(|a| a.code.contains("OUTPATIENT")));
}
Outcome::Abstain(_) => panic!("should refine with CURB-65 calculation"),
}
}
#[test]
fn test_curb65_low_risk_score_1() {
let op = Curb65Operator::new("0.1.0");
let observations = vec![Observation::new("AGE", serde_json::json!(72.0))];
let e = test_evidence_with_observations(observations);
let h = Hyp::unknown();
let outcome = op.apply(&h, &e);
match outcome {
Outcome::Refined(h_prime) => {
let atoms = h_prime.atoms();
assert!(atoms.iter().any(|a| a.code.contains("OUTPATIENT")));
}
Outcome::Abstain(_) => panic!("should refine"),
}
}
#[test]
fn test_curb65_moderate_risk_score_2() {
let op = Curb65Operator::new("0.1.0");
let observations = vec![
Observation::new("AGE", serde_json::json!(72.0)),
Observation::new("RESP-RATE", serde_json::json!(32.0)),
];
let e = test_evidence_with_observations(observations);
let h = Hyp::unknown();
let outcome = op.apply(&h, &e);
match outcome {
Outcome::Refined(h_prime) => {
let atoms = h_prime.atoms();
assert!(atoms.iter().any(|a| a.code.contains("WARD-ADMISSION")));
}
Outcome::Abstain(_) => panic!("should refine"),
}
}
#[test]
fn test_curb65_high_risk_score_3() {
let op = Curb65Operator::new("0.1.0");
let observations = vec![
Observation::new("CONFUSION", serde_json::json!(true)),
Observation::new("UREA", serde_json::json!(8.0)),
Observation::new("RESP-RATE", serde_json::json!(32.0)),
];
let e = test_evidence_with_observations(observations);
let h = Hyp::unknown();
let outcome = op.apply(&h, &e);
match outcome {
Outcome::Refined(h_prime) => {
let atoms = h_prime.atoms();
assert!(atoms.iter().any(|a| a.code.contains("ICU-EVALUATION")));
}
Outcome::Abstain(_) => panic!("should refine"),
}
}
#[test]
fn test_curb65_high_risk_score_5() {
let op = Curb65Operator::new("0.1.0");
let observations = vec![
Observation::new("CONFUSION", serde_json::json!(true)),
Observation::new("UREA", serde_json::json!(10.0)),
Observation::new("RESP-RATE", serde_json::json!(35.0)),
Observation::new("SBP", serde_json::json!(85.0)),
Observation::new("AGE", serde_json::json!(75.0)),
];
let e = test_evidence_with_observations(observations);
let h = Hyp::unknown();
let outcome = op.apply(&h, &e);
match outcome {
Outcome::Refined(h_prime) => {
let atoms = h_prime.atoms();
assert!(atoms.iter().any(|a| a.code.contains("ICU-EVALUATION")));
}
Outcome::Abstain(_) => panic!("should refine"),
}
}
#[test]
fn test_curb65_bun_criterion() {
let op = Curb65Operator::new("0.1.0");
let observations = vec![
Observation::new("BUN", serde_json::json!(25.0)), Observation::new("AGE", serde_json::json!(70.0)),
];
let e = test_evidence_with_observations(observations);
let h = Hyp::unknown();
let outcome = op.apply(&h, &e);
match outcome {
Outcome::Refined(h_prime) => {
let atoms = h_prime.atoms();
assert!(atoms.iter().any(|a| a.code.contains("WARD-ADMISSION")));
}
Outcome::Abstain(_) => panic!("should refine"),
}
}
#[test]
fn test_curb65_boundary_score_2_and_3() {
let op = Curb65Operator::new("0.1.0");
let observations = vec![
Observation::new("AGE", serde_json::json!(68.0)),
Observation::new("CONFUSION", serde_json::json!(true)),
];
let e = test_evidence_with_observations(observations);
let h = Hyp::unknown();
let outcome = op.apply(&h, &e);
match outcome {
Outcome::Refined(h_prime) => {
let atoms = h_prime.atoms();
assert!(atoms.iter().any(|a| a.code.contains("WARD-ADMISSION")));
}
Outcome::Abstain(_) => panic!("should refine at boundary"),
}
}
#[test]
fn test_curb65_monotonicity() {
let op = Curb65Operator::new("0.1.0");
let observations = vec![
Observation::new("CONFUSION", serde_json::json!(true)),
Observation::new("UREA", serde_json::json!(9.0)),
];
let e = test_evidence_with_observations(observations);
let h = Hyp::unknown();
let outcome = op.apply(&h, &e);
match outcome {
Outcome::Refined(h_prime) => {
assert!(
h_prime <= h,
"INV-PS-03: refined hypothesis must refine input"
);
}
Outcome::Abstain(_) => panic!("test setup should not abstain"),
}
}
#[test]
fn test_curb65_dbp_criterion_not_lost() {
let op = Curb65Operator::new("0.1.0");
let observations = vec![
Observation::new("SBP", serde_json::json!(95.0)),
Observation::new("DBP", serde_json::json!(55.0)),
Observation::new("AGE", serde_json::json!(70.0)),
];
let e = test_evidence_with_observations(observations);
let h = Hyp::unknown();
let outcome = op.apply(&h, &e);
match outcome {
Outcome::Refined(h_prime) => {
let atoms = h_prime.atoms();
assert!(atoms.iter().any(|a| a.code.contains("WARD-ADMISSION")));
}
Outcome::Abstain(_) => panic!("should refine; DBP criterion should be evaluated"),
}
}
#[test]
fn test_curb65_unparseable_urea_not_available() {
let op = Curb65Operator::new("0.1.0");
let mut obs = vec![
Observation::new("AGE", serde_json::json!(75.0)),
Observation::new("CONFUSION", serde_json::json!(true)),
];
obs.push(Observation::new("UREA", serde_json::json!("pending")));
let e = test_evidence_with_observations(obs);
let h = Hyp::unknown();
let outcome = op.apply(&h, &e);
match outcome {
Outcome::Refined(h_prime) => {
let atoms = h_prime.atoms();
assert!(atoms.iter().any(|a| a.code.contains("WARD-ADMISSION")));
assert!(!atoms.iter().any(|a| a.code.contains("ICU-EVALUATION")));
}
Outcome::Abstain(_) => {
panic!("should refine; unparseable urea should not cause abstention")
}
}
}
}