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 = "abpi";
pub const LICENSE: CalculatorLicense = CalculatorLicense {
license: "Public-domain method - implemented from published clinical guidance (NICE CG147)",
source_url: "https://www.nice.org.uk/guidance/cg147",
};
pub const REFERENCE: &str = "National Institute for Health and Care Excellence. Peripheral arterial disease: diagnosis \
and management. Clinical guideline CG147. London: NICE; 2012 (updated 2020). \
https://www.nice.org.uk/guidance/cg147";
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct AbpiInput {
pub right_ankle_systolic: f64,
pub left_ankle_systolic: f64,
pub right_brachial_systolic: f64,
pub left_brachial_systolic: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Band {
HighCalcified,
Normal,
Borderline,
MildModeratePad,
SeverePad,
}
impl Band {
fn from_abpi(abpi: f64) -> Self {
if abpi > 1.4 {
Band::HighCalcified
} else if abpi >= 1.0 {
Band::Normal
} else if abpi >= 0.91 {
Band::Borderline
} else if abpi >= 0.5 {
Band::MildModeratePad
} else {
Band::SeverePad
}
}
fn slug(self) -> &'static str {
match self {
Band::HighCalcified => "high_calcified",
Band::Normal => "normal",
Band::Borderline => "borderline",
Band::MildModeratePad => "mild_moderate_pad",
Band::SeverePad => "severe_pad",
}
}
fn descriptor(self) -> &'static str {
match self {
Band::HighCalcified => {
"abnormally high - calcified, non-compressible vessels (reading unreliable)"
}
Band::Normal => "normal",
Band::Borderline => "borderline",
Band::MildModeratePad => "mild-to-moderate peripheral arterial disease",
Band::SeverePad => "severe PAD / critical limb ischaemia",
}
}
fn severity(self) -> u8 {
match self {
Band::SeverePad => 4,
Band::MildModeratePad => 3,
Band::HighCalcified => 2,
Band::Borderline => 1,
Band::Normal => 0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LegResult {
pub abpi: f64,
pub band: Band,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AbpiOutcome {
pub right: LegResult,
pub left: LegResult,
pub brachial_used: f64,
pub overall_abpi: f64,
pub overall_band: Band,
pub interpretation: String,
}
fn round2(x: f64) -> f64 {
(x * 100.0).round() / 100.0
}
fn require_positive(value: f64, label: &str) -> Result<(), CalcError> {
if !value.is_finite() || value <= 0.0 {
return Err(CalcError::InvalidInput(format!(
"{label} must be a positive number (mmHg)"
)));
}
Ok(())
}
pub fn compute(input: &AbpiInput) -> Result<AbpiOutcome, CalcError> {
require_positive(input.right_ankle_systolic, "right_ankle_systolic")?;
require_positive(input.left_ankle_systolic, "left_ankle_systolic")?;
require_positive(input.right_brachial_systolic, "right_brachial_systolic")?;
require_positive(input.left_brachial_systolic, "left_brachial_systolic")?;
let brachial_used = input
.right_brachial_systolic
.max(input.left_brachial_systolic);
let right_abpi = round2(input.right_ankle_systolic / brachial_used);
let left_abpi = round2(input.left_ankle_systolic / brachial_used);
let right = LegResult {
abpi: right_abpi,
band: Band::from_abpi(right_abpi),
};
let left = LegResult {
abpi: left_abpi,
band: Band::from_abpi(left_abpi),
};
let worst = if (left.band.severity(), -left.abpi) > (right.band.severity(), -right.abpi) {
left
} else {
right
};
let overall_abpi = worst.abpi;
let overall_band = worst.band;
let action = match overall_band {
Band::HighCalcified => {
"A high ABPI usually means calcified, non-compressible arteries (common in diabetes \
and CKD) and does NOT indicate good perfusion - the reading is unreliable and may mask severe \
disease. Do not use it to clear compression therapy; assess perfusion by other means (e.g. toe \
pressures, waveforms) and seek vascular advice if PAD is suspected."
}
Band::Normal => {
"Normal ABPI: significant PAD is unlikely and compression therapy is generally safe \
per local policy. A normal/raised ABPI does not fully exclude PAD if clinical suspicion is high."
}
Band::Borderline => {
"Borderline ABPI: PAD cannot be confidently excluded. Correlate clinically; consider \
caution with compression and reassessment or further vascular assessment per local policy."
}
Band::MildModeratePad => {
"Mild-to-moderate PAD. Compression therapy is generally safe with caution per local \
policy (reduced compression and close monitoring are often advised in this range). Manage \
cardiovascular risk factors and consider vascular referral."
}
Band::SeverePad => {
"Severe PAD / critical limb ischaemia. Do NOT apply compression therapy. Arrange \
urgent vascular referral."
}
};
let interpretation = format!(
"Right leg ABPI {right_abpi:.2} ({}); left leg ABPI {left_abpi:.2} ({}). Denominator \
(highest brachial systolic) {brachial_used:.0} mmHg. Overall (worse leg) ABPI {overall_abpi:.2}: \
{}. {action}",
right.band.descriptor(),
left.band.descriptor(),
overall_band.descriptor(),
);
Ok(AbpiOutcome {
right,
left,
brachial_used,
overall_abpi,
overall_band,
interpretation,
})
}
pub fn build_response(input: &AbpiInput) -> Result<CalculationResponse, CalcError> {
let o = compute(input)?;
let mut working = Map::new();
working.insert("right_abpi".into(), json!(o.right.abpi));
working.insert("left_abpi".into(), json!(o.left.abpi));
working.insert("brachial_used".into(), json!(o.brachial_used));
working.insert(
"right_interpretation".into(),
json!(o.right.band.descriptor()),
);
working.insert(
"left_interpretation".into(),
json!(o.left.band.descriptor()),
);
working.insert("right_band".into(), json!(o.right.band.slug()));
working.insert("left_band".into(), json!(o.left.band.slug()));
working.insert("overall_band".into(), json!(o.overall_band.slug()));
Ok(CalculationResponse {
calculator: NAME.to_string(),
result: json!(o.overall_abpi),
interpretation: o.interpretation,
working,
reference: REFERENCE.to_string(),
})
}
pub struct Abpi;
impl Calculator for Abpi {
fn name(&self) -> &'static str {
NAME
}
fn title(&self) -> &'static str {
"ABPI (Ankle-Brachial Pressure Index)"
}
fn description(&self) -> &'static str {
"Ankle-Brachial Pressure Index per leg from ankle and brachial systolic pressures; screens for peripheral arterial disease and informs compression-therapy safety."
}
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": "AbpiInput",
"type": "object",
"additionalProperties": false,
"required": [
"right_ankle_systolic",
"left_ankle_systolic",
"right_brachial_systolic",
"left_brachial_systolic"
],
"properties": {
"right_ankle_systolic": {
"type": "number",
"exclusiveMinimum": 0,
"description": "Highest ankle systolic pressure in the right leg (mmHg)",
"definition": {
"concept": "Right ankle systolic pressure",
"statement": "The higher of the right dorsalis pedis and posterior tibial systolic pressures, by Doppler.",
"caveats": "Use the higher of the two ankle vessels for that leg; the ankle reading forms the numerator of that leg's ABPI.",
"source": {
"citation": "NICE CG147. Peripheral arterial disease: diagnosis and management.",
"url": "https://www.nice.org.uk/guidance/cg147"
},
"status": "draft"
}
},
"left_ankle_systolic": {
"type": "number",
"exclusiveMinimum": 0,
"description": "Highest ankle systolic pressure in the left leg (mmHg)"
},
"right_brachial_systolic": {
"type": "number",
"exclusiveMinimum": 0,
"description": "Brachial systolic pressure in the right arm (mmHg)"
},
"left_brachial_systolic": {
"type": "number",
"exclusiveMinimum": 0,
"description": "Brachial systolic pressure in the left arm (mmHg)",
"definition": {
"concept": "Brachial systolic pressure",
"statement": "The higher of the two arms' brachial systolic pressures is the single denominator for both legs' ABPI.",
"excludes": [
"A HIGH ABPI (>1.4) does NOT mean good perfusion: it usually reflects calcified, non-compressible vessels (common in diabetes and CKD), gives a falsely raised ratio, and must not be used to clear compression therapy"
],
"source": {
"citation": "NICE CG147. Peripheral arterial disease: diagnosis and management.",
"url": "https://www.nice.org.uk/guidance/cg147"
},
"status": "draft"
}
}
}
})
}
fn calculate(&self, input: &Value) -> Result<CalculationResponse, CalcError> {
let parsed: AbpiInput = serde_json::from_value(input.clone())
.map_err(|e| CalcError::InvalidInput(e.to_string()))?;
build_response(&parsed)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn input(ra: f64, la: f64, rb: f64, lb: f64) -> AbpiInput {
AbpiInput {
right_ankle_systolic: ra,
left_ankle_systolic: la,
right_brachial_systolic: rb,
left_brachial_systolic: lb,
}
}
#[test]
fn normal_both_legs() {
let o = compute(&input(130.0, 130.0, 120.0, 120.0)).unwrap();
assert_eq!(o.right.abpi, 1.08);
assert_eq!(o.left.abpi, 1.08);
assert_eq!(o.brachial_used, 120.0);
assert_eq!(o.overall_band, Band::Normal);
assert_eq!(o.overall_abpi, 1.08);
}
#[test]
fn higher_brachial_is_the_denominator() {
let o = compute(&input(120.0, 120.0, 110.0, 140.0)).unwrap();
assert_eq!(o.brachial_used, 140.0);
assert_eq!(o.right.abpi, round2(120.0 / 140.0)); assert_eq!(o.left.abpi, round2(120.0 / 140.0));
}
#[test]
fn mild_moderate_pad() {
let o = compute(&input(90.0, 90.0, 120.0, 120.0)).unwrap();
assert_eq!(o.overall_abpi, 0.75);
assert_eq!(o.overall_band, Band::MildModeratePad);
assert!(o.interpretation.contains("caution"));
}
#[test]
fn severe_pad_blocks_compression() {
let o = compute(&input(50.0, 50.0, 120.0, 120.0)).unwrap();
assert_eq!(o.overall_abpi, 0.42);
assert_eq!(o.overall_band, Band::SeverePad);
assert!(o.interpretation.contains("Do NOT apply compression"));
assert!(o.interpretation.contains("urgent vascular referral"));
}
#[test]
fn high_calcified_is_flagged_unreliable() {
let o = compute(&input(200.0, 200.0, 120.0, 120.0)).unwrap();
assert!(o.overall_abpi > 1.4);
assert_eq!(o.overall_band, Band::HighCalcified);
assert!(
o.interpretation
.contains("does NOT indicate good perfusion")
);
assert!(o.interpretation.contains("calcified"));
}
#[test]
fn differing_legs_report_both_and_worst_drives_overall() {
let o = compute(&input(130.0, 80.0, 120.0, 120.0)).unwrap();
assert_eq!(o.right.abpi, 1.08);
assert_eq!(o.right.band, Band::Normal);
assert_eq!(o.left.abpi, round2(80.0 / 120.0)); assert_eq!(o.left.band, Band::MildModeratePad);
assert_eq!(o.overall_abpi, o.left.abpi);
assert_eq!(o.overall_band, Band::MildModeratePad);
}
#[test]
fn calcified_leg_outranks_normal_leg_overall() {
let o = compute(&input(200.0, 120.0, 120.0, 120.0)).unwrap();
assert_eq!(o.right.band, Band::HighCalcified);
assert_eq!(o.left.band, Band::Normal);
assert_eq!(o.overall_band, Band::HighCalcified);
}
#[test]
fn band_boundaries() {
assert_eq!(Band::from_abpi(1.41), Band::HighCalcified);
assert_eq!(Band::from_abpi(1.40), Band::Normal);
assert_eq!(Band::from_abpi(1.00), Band::Normal);
assert_eq!(Band::from_abpi(0.99), Band::Borderline);
assert_eq!(Band::from_abpi(0.91), Band::Borderline);
assert_eq!(Band::from_abpi(0.90), Band::MildModeratePad);
assert_eq!(Band::from_abpi(0.50), Band::MildModeratePad);
assert_eq!(Band::from_abpi(0.49), Band::SeverePad);
}
#[test]
fn rejects_non_positive_and_non_finite() {
assert!(compute(&input(0.0, 120.0, 120.0, 120.0)).is_err());
assert!(compute(&input(120.0, -1.0, 120.0, 120.0)).is_err());
assert!(compute(&input(120.0, 120.0, 0.0, 120.0)).is_err());
assert!(compute(&input(120.0, 120.0, 120.0, f64::NAN)).is_err());
assert!(compute(&input(120.0, 120.0, 120.0, f64::INFINITY)).is_err());
}
#[test]
fn working_map_has_expected_keys() {
let r = build_response(&input(130.0, 80.0, 120.0, 120.0)).unwrap();
assert!(r.working.contains_key("right_abpi"));
assert!(r.working.contains_key("left_abpi"));
assert!(r.working.contains_key("brachial_used"));
assert!(r.working.contains_key("right_interpretation"));
assert!(r.working.contains_key("left_interpretation"));
assert_eq!(r.working["brachial_used"], json!(120.0));
}
#[test]
fn schema_flags_high_abpi_safety_exclusion() {
let schema = Abpi.input_schema();
let def = &schema["properties"]["left_brachial_systolic"]["definition"];
assert!(
def["excludes"][0]
.as_str()
.unwrap()
.contains("good perfusion")
);
}
#[test]
fn dynamic_calculate_matches_typed() {
let value = json!({
"right_ankle_systolic": 130.0,
"left_ankle_systolic": 80.0,
"right_brachial_systolic": 120.0,
"left_brachial_systolic": 110.0
});
let dynamic = Abpi.calculate(&value).unwrap();
let typed = build_response(&input(130.0, 80.0, 120.0, 110.0)).unwrap();
assert_eq!(dynamic, typed);
}
}