clincalc 0.2.2

Open, auditable clinical calculators: a pure scoring engine plus the `clincalc` CLI in one crate. The engine is a serde-only leaf (build with default-features = false); the default `cli` feature adds the `clincalc` binary.
Documentation
// SPDX-FileCopyrightText: 2026 Marcus Baw and Baw Medical Ltd
// SPDX-License-Identifier: AGPL-3.0-or-later

//! ACC/AHA 2013 Pooled Cohort Equations for 10-year ASCVD risk.

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 = "ascvd";
pub const REFERENCE: &str = "Goff DC Jr, Lloyd-Jones DM, Bennett G, et al. 2013 ACC/AHA Guideline on the Assessment of Cardiovascular Risk. Circulation. 2014;129(25 Suppl 2):S49-S73. Pooled Cohort Equations coefficients from the guideline risk-assessment report.";
pub const LICENSE: CalculatorLicense = CalculatorLicense {
    license: "Public-domain guideline equation - implemented from ACC/AHA published coefficients",
    source_url: "https://doi.org/10.1161/01.cir.0000437741.48606.98",
};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Sex {
    Male,
    Female,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Race {
    White,
    Black,
}

#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AscvdInput {
    pub age: u8,
    pub sex: Sex,
    pub race: Race,
    pub total_cholesterol_mg_dl: f64,
    pub hdl_cholesterol_mg_dl: f64,
    pub systolic_bp_mm_hg: f64,
    pub treated_bp: bool,
    pub current_smoker: bool,
    pub diabetes: bool,
}

#[derive(Debug, Clone, PartialEq)]
pub struct AscvdOutcome {
    pub risk_percent: f64,
    pub risk_band: RiskBand,
    pub interpretation: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RiskBand {
    Low,
    Borderline,
    Intermediate,
    High,
}
impl RiskBand {
    fn slug(self) -> &'static str {
        match self {
            RiskBand::Low => "low",
            RiskBand::Borderline => "borderline",
            RiskBand::Intermediate => "intermediate",
            RiskBand::High => "high",
        }
    }
}

pub fn compute(i: &AscvdInput) -> Result<AscvdOutcome, CalcError> {
    validate(i)?;
    let age = f64::from(i.age).ln();
    let tc = i.total_cholesterol_mg_dl.ln();
    let hdl = i.hdl_cholesterol_mg_dl.ln();
    let sbp = i.systolic_bp_mm_hg.ln();
    let sum = match (i.sex, i.race) {
        (Sex::Male, Race::White) => {
            12.344 * age + 11.853 * tc - 2.664 * age * tc - 7.990 * hdl
                + 1.769 * age * hdl
                + if i.treated_bp {
                    1.797 * sbp
                } else {
                    1.764 * sbp
                }
                + if i.current_smoker {
                    7.837 - 1.795 * age
                } else {
                    0.0
                }
                + if i.diabetes { 0.658 } else { 0.0 }
        }
        (Sex::Female, Race::White) => {
            -29.799 * age + 4.884 * age * age + 13.540 * tc - 3.114 * age * tc - 13.578 * hdl
                + 3.149 * age * hdl
                + if i.treated_bp {
                    2.019 * sbp
                } else {
                    1.957 * sbp
                }
                + if i.current_smoker {
                    7.574 - 1.665 * age
                } else {
                    0.0
                }
                + if i.diabetes { 0.661 } else { 0.0 }
        }
        (Sex::Male, Race::Black) => {
            2.469 * age + 0.302 * tc - 0.307 * hdl
                + if i.treated_bp {
                    1.916 * sbp
                } else {
                    1.809 * sbp
                }
                + if i.current_smoker { 0.549 } else { 0.0 }
                + if i.diabetes { 0.645 } else { 0.0 }
        }
        (Sex::Female, Race::Black) => {
            17.114 * age + 0.940 * tc - 18.920 * hdl
                + 4.475 * age * hdl
                + if i.treated_bp {
                    29.291 * sbp - 6.432 * age * sbp
                } else {
                    27.820 * sbp - 6.087 * age * sbp
                }
                + if i.current_smoker { 0.691 } else { 0.0 }
                + if i.diabetes { 0.874 } else { 0.0 }
        }
    };
    let (baseline, mean): (f64, f64) = match (i.sex, i.race) {
        (Sex::Male, Race::White) => (0.9144, 61.18),
        (Sex::Female, Race::White) => (0.9665, -29.18),
        (Sex::Male, Race::Black) => (0.8954, 19.54),
        (Sex::Female, Race::Black) => (0.9533, 86.61),
    };
    let risk = 1.0 - baseline.powf((sum - mean).exp());
    let risk_percent = risk * 100.0;
    let risk_band = if risk_percent < 5.0 {
        RiskBand::Low
    } else if risk_percent < 7.5 {
        RiskBand::Borderline
    } else if risk_percent < 20.0 {
        RiskBand::Intermediate
    } else {
        RiskBand::High
    };
    let interpretation = format!(
        "10-year ASCVD risk {:.1}% ({:?}). The Pooled Cohort Equations are intended for US adults aged 40-79 without established ASCVD; calibration varies by population and they are not the UK QRISK standard.",
        risk_percent, risk_band
    );
    Ok(AscvdOutcome {
        risk_percent,
        risk_band,
        interpretation,
    })
}

fn validate(i: &AscvdInput) -> Result<(), CalcError> {
    if !(40..=79).contains(&i.age) {
        return Err(CalcError::InvalidInput(
            "age must be 40-79 for the Pooled Cohort Equations".into(),
        ));
    }
    for (name, value, min, max) in [
        (
            "total_cholesterol_mg_dl",
            i.total_cholesterol_mg_dl,
            130.0,
            320.0,
        ),
        (
            "hdl_cholesterol_mg_dl",
            i.hdl_cholesterol_mg_dl,
            20.0,
            100.0,
        ),
        ("systolic_bp_mm_hg", i.systolic_bp_mm_hg, 90.0, 200.0),
    ] {
        if !(min..=max).contains(&value) || !value.is_finite() {
            return Err(CalcError::InvalidInput(format!(
                "{name} must be finite and between {min} and {max}"
            )));
        }
    }
    Ok(())
}

pub fn build_response(i: &AscvdInput) -> Result<CalculationResponse, CalcError> {
    let o = compute(i)?;
    let mut working = Map::new();
    working.insert("risk_percent".into(), json!(round1(o.risk_percent)));
    working.insert("risk_band".into(), json!(o.risk_band.slug()));
    working.insert("unit".into(), json!("%"));
    Ok(CalculationResponse {
        calculator: NAME.to_string(),
        result: json!(round1(o.risk_percent)),
        interpretation: o.interpretation,
        working,
        reference: REFERENCE.to_string(),
    })
}
fn round1(v: f64) -> f64 {
    (v * 10.0).round() / 10.0
}

pub struct Ascvd;
impl Calculator for Ascvd {
    fn name(&self) -> &'static str {
        NAME
    }
    fn title(&self) -> &'static str {
        "ASCVD Pooled Cohort Equations"
    }
    fn description(&self) -> &'static str {
        "ACC/AHA 2013 10-year ASCVD risk estimate for US adults aged 40-79."
    }
    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": "AscvdInput", "type": "object", "additionalProperties": false, "required": ["age", "sex", "race", "total_cholesterol_mg_dl", "hdl_cholesterol_mg_dl", "systolic_bp_mm_hg", "treated_bp", "current_smoker", "diabetes"], "properties": { "age": { "type": "integer", "minimum": 40, "maximum": 79 }, "sex": { "type": "string", "enum": ["male", "female"] }, "race": { "type": "string", "enum": ["white", "black"], "description": "PCE coefficient set. The original equations provide Black and White coefficient sets only." }, "total_cholesterol_mg_dl": { "type": "number", "minimum": 130, "maximum": 320 }, "hdl_cholesterol_mg_dl": { "type": "number", "minimum": 20, "maximum": 100 }, "systolic_bp_mm_hg": { "type": "number", "minimum": 90, "maximum": 200 }, "treated_bp": { "type": "boolean" }, "current_smoker": { "type": "boolean" }, "diabetes": { "type": "boolean" } } })
    }
    fn calculate(&self, input: &Value) -> Result<CalculationResponse, CalcError> {
        let parsed: AscvdInput = serde_json::from_value(input.clone())
            .map_err(|e| CalcError::InvalidInput(e.to_string()))?;
        build_response(&parsed)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn computes_plausible_white_male_example() {
        let out = compute(&AscvdInput {
            age: 55,
            sex: Sex::Male,
            race: Race::White,
            total_cholesterol_mg_dl: 213.0,
            hdl_cholesterol_mg_dl: 50.0,
            systolic_bp_mm_hg: 120.0,
            treated_bp: false,
            current_smoker: false,
            diabetes: false,
        })
        .unwrap();
        assert_eq!(round1(out.risk_percent), 5.4);
    }
    #[test]
    fn diabetes_and_smoking_raise_risk() {
        let base = AscvdInput {
            age: 60,
            sex: Sex::Female,
            race: Race::Black,
            total_cholesterol_mg_dl: 200.0,
            hdl_cholesterol_mg_dl: 50.0,
            systolic_bp_mm_hg: 130.0,
            treated_bp: true,
            current_smoker: false,
            diabetes: false,
        };
        let low = compute(&base).unwrap().risk_percent;
        let high = compute(&AscvdInput {
            current_smoker: true,
            diabetes: true,
            ..base
        })
        .unwrap()
        .risk_percent;
        assert!(high > low);
    }
}