use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::calculator::{CalcError, Calculator};
use crate::license::CalculatorLicense;
use crate::response::CalculationResponse;
pub const NAME: &str = "cha2ds2vasc";
pub const REFERENCE: &str = "Lip GYH, Nieuwlaat R, Pisters R, et al. Refining clinical risk stratification for predicting \
stroke and thromboembolism in atrial fibrillation using a novel risk factor-based approach: the \
Euro Heart Survey on Atrial Fibrillation. Chest. 2010;137(2):263-272. Thresholds per NICE NG196.";
pub const LICENSE: CalculatorLicense = CalculatorLicense {
license: "Public-domain method - implemented from the primary literature",
source_url: "https://doi.org/10.1378/chest.09-1584",
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Sex {
Male,
Female,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Cha2ds2VascInput {
pub age: u8,
pub sex: Sex,
pub congestive_heart_failure: bool,
pub hypertension: bool,
pub diabetes: bool,
pub stroke_tia_thromboembolism: bool,
pub vascular_disease: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Recommendation {
NotRecommended,
Consider,
Offer,
}
impl Recommendation {
fn slug(self) -> &'static str {
match self {
Recommendation::NotRecommended => "not-recommended",
Recommendation::Consider => "consider",
Recommendation::Offer => "offer",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cha2ds2VascOutcome {
pub score: u8,
pub age_points: u8,
pub non_sex_score: u8,
pub recommendation: Recommendation,
pub interpretation: String,
}
fn age_points(age: u8) -> u8 {
if age >= 75 {
2
} else if age >= 65 {
1
} else {
0
}
}
pub fn compute(input: &Cha2ds2VascInput) -> Result<Cha2ds2VascOutcome, CalcError> {
let age_points = age_points(input.age);
let sex_point = u8::from(input.sex == Sex::Female);
let non_sex_score = age_points
+ u8::from(input.congestive_heart_failure)
+ u8::from(input.hypertension)
+ u8::from(input.diabetes)
+ 2 * u8::from(input.stroke_tia_thromboembolism)
+ u8::from(input.vascular_disease);
let score = non_sex_score + sex_point;
let recommendation = if non_sex_score == 0 {
Recommendation::NotRecommended
} else if score == 1 {
Recommendation::Consider
} else {
Recommendation::Offer
};
let interpretation = match recommendation {
Recommendation::NotRecommended => {
if input.sex == Sex::Female && score == 1 {
"Score 1 from female sex alone. Female sex is an age-dependent risk modifier, not an \
independent indication: this is low risk and anticoagulation is not recommended (NICE NG196)."
.to_string()
} else {
"Score 0: low risk. Anticoagulation is not recommended (NICE NG196).".to_string()
}
}
Recommendation::Consider => format!(
"Score {score}: consider anticoagulation, weighing bleeding risk (e.g. ORBIT or \
HAS-BLED) and patient preference (NICE NG196)."
),
Recommendation::Offer => format!(
"Score {score}: offer anticoagulation, taking bleeding risk into account (NICE NG196). \
Stroke risk rises with the score."
),
};
Ok(Cha2ds2VascOutcome {
score,
age_points,
non_sex_score,
recommendation,
interpretation,
})
}
pub fn build_response(input: &Cha2ds2VascInput) -> Result<CalculationResponse, CalcError> {
let o = compute(input)?;
let mut working = Map::new();
working.insert("total_score".into(), json!(o.score));
working.insert("age_points".into(), json!(o.age_points));
working.insert(
"congestive_heart_failure".into(),
json!(u8::from(input.congestive_heart_failure)),
);
working.insert("hypertension".into(), json!(u8::from(input.hypertension)));
working.insert("diabetes".into(), json!(u8::from(input.diabetes)));
working.insert(
"stroke_tia_thromboembolism".into(),
json!(2 * u8::from(input.stroke_tia_thromboembolism)),
);
working.insert(
"vascular_disease".into(),
json!(u8::from(input.vascular_disease)),
);
working.insert(
"sex_point".into(),
json!(u8::from(input.sex == Sex::Female)),
);
working.insert("recommendation".into(), json!(o.recommendation.slug()));
Ok(CalculationResponse {
calculator: NAME.to_string(),
result: json!(o.score),
interpretation: o.interpretation,
working,
reference: REFERENCE.to_string(),
})
}
pub struct Cha2ds2Vasc;
impl Calculator for Cha2ds2Vasc {
fn name(&self) -> &'static str {
NAME
}
fn title(&self) -> &'static str {
"CHA2DS2-VASc Stroke Risk (AF)"
}
fn description(&self) -> &'static str {
"Stroke risk in non-valvular atrial fibrillation, guiding anticoagulation (NICE NG196)."
}
fn reference(&self) -> &'static str {
REFERENCE
}
fn license(&self) -> CalculatorLicense {
LICENSE
}
fn input_schema(&self) -> Value {
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Cha2ds2VascInput",
"type": "object",
"additionalProperties": false,
"required": [
"age", "sex", "congestive_heart_failure", "hypertension",
"diabetes", "stroke_tia_thromboembolism", "vascular_disease"
],
"properties": {
"age": {
"type": "integer",
"minimum": 18,
"maximum": 120,
"description": "Age in years (65-74 scores 1, 75+ scores 2)"
},
"sex": {
"type": "string",
"enum": ["male", "female"],
"description": "Sex; female scores 1, as an age-dependent risk modifier",
"definition": {
"concept": "Sex category (Sc)",
"statement": "Female sex scores 1 point.",
"caveats": "Female sex is a risk modifier, not an independent indication: a score of 1 from sex alone is low risk. Recent schemes (CHA2DS2-VA) drop the sex criterion.",
"source": { "citation": "Lip GYH et al. Chest. 2010;137(2):263-272.", "url": "https://doi.org/10.1378/chest.09-1584" },
"status": "draft"
}
},
"congestive_heart_failure": {
"type": "boolean",
"description": "Heart failure or moderate-to-severe LV systolic dysfunction (C)",
"definition": {
"concept": "Congestive heart failure / LV dysfunction (C)",
"statement": "Signs/symptoms of heart failure, or objective moderate-to-severe LV systolic dysfunction, or recent decompensated heart failure.",
"includes": ["HFrEF and HFpEF", "Recent decompensation requiring hospitalisation", "Moderate-to-severe LV systolic dysfunction on imaging"],
"excludes": ["An isolated historical mention with no current evidence or objective dysfunction"],
"snomedEcl": "<< 42343007 |Congestive heart failure (disorder)|",
"source": { "citation": "Lip GYH et al. Chest. 2010;137(2):263-272.", "url": "https://doi.org/10.1378/chest.09-1584" },
"status": "draft"
}
},
"hypertension": {
"type": "boolean",
"description": "Hypertension - diagnosed, treated, or BP consistently >140/90 (H)",
"definition": {
"concept": "Hypertension (H)",
"statement": "A diagnosis of hypertension, treatment with antihypertensives, or resting BP consistently above 140/90 mmHg.",
"includes": ["On antihypertensive treatment", "Diagnosed hypertension", "Repeated resting BP >140/90 mmHg"],
"excludes": ["A single isolated elevated reading without diagnosis or treatment"],
"snomedEcl": "<< 38341003 |Hypertensive disorder, systemic arterial (disorder)|",
"source": { "citation": "Lip GYH et al. Chest. 2010;137(2):263-272.", "url": "https://doi.org/10.1378/chest.09-1584" },
"status": "draft"
}
},
"diabetes": {
"type": "boolean",
"description": "Diabetes mellitus, type 1 or type 2 (D)",
"definition": {
"concept": "Diabetes mellitus (D)",
"statement": "Established type 1 or type 2 diabetes mellitus.",
"includes": ["Type 1 diabetes", "Type 2 diabetes", "On glucose-lowering treatment"],
"excludes": ["Pre-diabetes / impaired glucose tolerance does NOT count"],
"snomedEcl": "<< 73211009 |Diabetes mellitus (disorder)| MINUS << 714628002 |Prediabetes (finding)|",
"source": { "citation": "Lip GYH et al. Chest. 2010;137(2):263-272.", "url": "https://doi.org/10.1378/chest.09-1584" },
"status": "draft"
}
},
"stroke_tia_thromboembolism": {
"type": "boolean",
"description": "Prior stroke, TIA, or systemic ARTERIAL thromboembolism (S2, 2 points)",
"definition": {
"concept": "Stroke / TIA / thromboembolism (S2)",
"statement": "Prior ischaemic stroke, transient ischaemic attack, or systemic arterial thromboembolism.",
"includes": ["Prior ischaemic stroke", "Transient ischaemic attack (TIA)", "Systemic arterial thromboembolism"],
"excludes": ["Venous thromboembolism (DVT or PE) does NOT count - this criterion is arterial"],
"snomedEcl": "(<< 230690007 |Cerebrovascular accident (disorder)| OR << 266257000 |Transient ischemic attack (disorder)|) MINUS << 118927008 |Disorder of venous system (disorder)|",
"source": { "citation": "Lip GYH et al. Chest. 2010;137(2):263-272.", "url": "https://doi.org/10.1378/chest.09-1584" },
"status": "draft"
}
},
"vascular_disease": {
"type": "boolean",
"description": "Vascular disease: prior MI, peripheral arterial disease, or aortic plaque (V)",
"definition": {
"concept": "Vascular disease (V)",
"statement": "Established ARTERIAL vascular disease.",
"includes": ["Prior myocardial infarction", "Peripheral arterial disease", "Complex aortic plaque"],
"excludes": [
"Venous thromboembolism (DVT or PE) does NOT count - this criterion is arterial",
"Isolated stable coronary artery disease without prior MI is disputed; see caveats"
],
"caveats": "Guidelines differ on whether stable CAD without MI qualifies. Aortic plaque means complex/atheromatous plaque, not incidental calcification.",
"snomedEcl": "(<< 22298006 |Myocardial infarction (disorder)| OR << 400047006 |Peripheral vascular disease (disorder)|) MINUS << 118927008 |Disorder of venous system (disorder)|",
"source": { "citation": "Lip GYH et al. Chest. 2010;137(2):263-272.", "url": "https://doi.org/10.1378/chest.09-1584" },
"status": "draft"
}
}
}
})
}
fn calculate(&self, input: &Value) -> Result<CalculationResponse, CalcError> {
let parsed: Cha2ds2VascInput = serde_json::from_value(input.clone())
.map_err(|e| CalcError::InvalidInput(e.to_string()))?;
build_response(&parsed)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn base(age: u8, sex: Sex) -> Cha2ds2VascInput {
Cha2ds2VascInput {
age,
sex,
congestive_heart_failure: false,
hypertension: false,
diabetes: false,
stroke_tia_thromboembolism: false,
vascular_disease: false,
}
}
#[test]
fn age_bands() {
assert_eq!(age_points(64), 0);
assert_eq!(age_points(65), 1);
assert_eq!(age_points(74), 1);
assert_eq!(age_points(75), 2);
}
#[test]
fn worked_example_female_htn_dm() {
let mut i = base(70, Sex::Female);
i.hypertension = true;
i.diabetes = true;
let o = compute(&i).unwrap();
assert_eq!(o.score, 4);
assert_eq!(o.recommendation, Recommendation::Offer);
}
#[test]
fn stroke_scores_two() {
let mut i = base(80, Sex::Male);
i.stroke_tia_thromboembolism = true;
let o = compute(&i).unwrap();
assert_eq!(o.score, 4);
assert_eq!(o.recommendation, Recommendation::Offer);
}
#[test]
fn female_sex_only_is_low_risk() {
let o = compute(&base(60, Sex::Female)).unwrap();
assert_eq!(o.score, 1);
assert_eq!(o.non_sex_score, 0);
assert_eq!(o.recommendation, Recommendation::NotRecommended);
assert!(o.interpretation.contains("female sex alone"));
}
#[test]
fn male_zero_is_low_risk() {
let o = compute(&base(60, Sex::Male)).unwrap();
assert_eq!(o.score, 0);
assert_eq!(o.recommendation, Recommendation::NotRecommended);
}
#[test]
fn male_score_one_is_consider() {
let o = compute(&base(70, Sex::Male)).unwrap();
assert_eq!(o.score, 1);
assert_eq!(o.recommendation, Recommendation::Consider);
}
#[test]
fn maximum_score_is_nine() {
let i = Cha2ds2VascInput {
age: 80,
sex: Sex::Female,
congestive_heart_failure: true,
hypertension: true,
diabetes: true,
stroke_tia_thromboembolism: true,
vascular_disease: true,
};
let o = compute(&i).unwrap();
assert_eq!(o.score, 9);
}
#[test]
fn vascular_disease_contributes_one() {
let mut i = base(60, Sex::Male);
i.vascular_disease = true;
let o = compute(&i).unwrap();
assert_eq!(o.score, 1);
assert_eq!(o.recommendation, Recommendation::Consider);
}
#[test]
fn dynamic_calculate_matches_typed() {
let value = json!({
"age": 70, "sex": "female", "congestive_heart_failure": false,
"hypertension": true, "diabetes": true,
"stroke_tia_thromboembolism": false, "vascular_disease": false
});
let mut typed = base(70, Sex::Female);
typed.hypertension = true;
typed.diabetes = true;
let dynamic = Cha2ds2Vasc.calculate(&value).unwrap();
assert_eq!(dynamic, build_response(&typed).unwrap());
assert_eq!(dynamic.result, json!(4));
}
#[test]
fn vascular_definition_excludes_vte() {
let schema = Cha2ds2Vasc.input_schema();
let excludes = &schema["properties"]["vascular_disease"]["definition"]["excludes"];
assert!(
excludes[0]
.as_str()
.unwrap()
.contains("Venous thromboembolism")
);
let ecl = schema["properties"]["vascular_disease"]["definition"]["snomedEcl"]
.as_str()
.unwrap();
assert!(
ecl.contains("MINUS"),
"vascular ECL must exclude the venous hierarchy"
);
}
}