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 = "epds";
pub const LICENSE: CalculatorLicense = CalculatorLicense {
license: "Free to reproduce in full for clinical or research use with the source citation; no further permission required (Cox, Holden & Sagovsky 1987)",
source_url: "https://doi.org/10.1192/bjp.150.6.782",
};
pub const REFERENCE: &str = "Cox JL, Holden JM, Sagovsky R. Detection of postnatal depression: development of the \
10-item Edinburgh Postnatal Depression Scale. Br J Psychiatry. 1987;150:782-786. \
doi:10.1192/bjp.150.6.782";
pub const ITEM_COUNT: usize = 10;
pub const SELF_HARM_ITEM: usize = 9;
pub const POSSIBLE_THRESHOLD: u16 = 10;
pub const PROBABLE_THRESHOLD: u16 = 13;
pub const REVERSE_SCORED_ITEMS: [u8; 7] = [3, 5, 6, 7, 8, 9, 10];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EpdsInput {
pub responses: Vec<u8>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Likelihood {
Unlikely,
Possible,
Probable,
}
impl Likelihood {
pub fn from_total(total: u16) -> Self {
if total >= PROBABLE_THRESHOLD {
Likelihood::Probable
} else if total >= POSSIBLE_THRESHOLD {
Likelihood::Possible
} else {
Likelihood::Unlikely
}
}
pub fn slug(self) -> &'static str {
match self {
Likelihood::Unlikely => "unlikely",
Likelihood::Possible => "possible",
Likelihood::Probable => "probable",
}
}
fn label(self) -> &'static str {
match self {
Likelihood::Unlikely => "depression unlikely",
Likelihood::Possible => "possible depression",
Likelihood::Probable => "probable depression",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpdsOutcome {
pub total: u16,
pub likelihood: Likelihood,
pub self_harm_flag: bool,
pub interpretation: String,
}
pub fn compute(input: &EpdsInput) -> Result<EpdsOutcome, CalcError> {
if input.responses.len() != ITEM_COUNT {
return Err(CalcError::InvalidInput(format!(
"expected {ITEM_COUNT} responses, got {}",
input.responses.len()
)));
}
for (i, &v) in input.responses.iter().enumerate() {
if v > 3 {
return Err(CalcError::InvalidInput(format!(
"response {} = {v} is out of range 0-3",
i + 1
)));
}
}
let total: u16 = input.responses.iter().map(|&v| v as u16).sum();
let likelihood = Likelihood::from_total(total);
let self_harm_flag = input.responses[SELF_HARM_ITEM] >= 1;
let mut interpretation = format!(
"Total score {total}/30 indicates {} (threshold >=10 possible, >=13 probable).",
likelihood.label()
);
if self_harm_flag {
interpretation.push_str(
" Item 10 (thoughts of self-harm) is positive: a suicide-risk assessment is \
indicated regardless of the total score.",
);
}
interpretation
.push_str(" The EPDS is a screening aid for clinical judgement; it is not a diagnosis.");
Ok(EpdsOutcome {
total,
likelihood,
self_harm_flag,
interpretation,
})
}
pub fn build_response(input: &EpdsInput) -> Result<CalculationResponse, CalcError> {
let o = compute(input)?;
let mut working = Map::new();
working.insert("total_score".into(), json!(o.total));
working.insert("likelihood".into(), json!(o.likelihood.slug()));
working.insert("self_harm_item_flag".into(), json!(o.self_harm_flag));
working.insert("answers".into(), json!(input.responses));
Ok(CalculationResponse {
calculator: NAME.to_string(),
result: json!(o.total),
interpretation: o.interpretation,
working,
reference: REFERENCE.to_string(),
})
}
pub struct Epds;
impl Calculator for Epds {
fn name(&self) -> &'static str {
NAME
}
fn title(&self) -> &'static str {
"Edinburgh Postnatal Depression Scale (EPDS)"
}
fn description(&self) -> &'static str {
"Ten-item perinatal depression screen (0-30); >=10 possible, >=13 probable; item 10 flags self-harm risk."
}
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": "EpdsInput",
"type": "object",
"additionalProperties": false,
"required": ["responses"],
"properties": {
"responses": {
"type": "array",
"description": "Ten responses (Q1-Q10), each ALREADY SCORED 0-3 per the EPDS key. On the form items 1,2,4 score 0-1-2-3 top-to-bottom while items 3,5,6,7,8,9,10 are reverse scored 3-2-1-0; orient them before submitting.",
"items": { "type": "integer", "minimum": 0, "maximum": 3 },
"minItems": 10,
"maxItems": 10,
"definition": {
"concept": "EPDS item responses",
"statement": "Each item rates how the mother has felt over the LAST 7 DAYS, scored 0-3 per the EPDS key.",
"includes": [
"Q1 I have been able to laugh and see the funny side of things",
"Q2 I have looked forward with enjoyment to things",
"Q3 I have blamed myself unnecessarily when things went wrong (reverse scored)",
"Q4 I have been anxious or worried for no good reason",
"Q5 I have felt scared or panicky for no very good reason (reverse scored)",
"Q6 Things have been getting on top of me (reverse scored)",
"Q7 I have been so unhappy that I have had difficulty sleeping (reverse scored)",
"Q8 I have felt sad or miserable (reverse scored)",
"Q9 I have been so unhappy that I have been crying (reverse scored)",
"Q10 The thought of harming myself has occurred to me (reverse scored)"
],
"excludes": [
"Responses must be the 0-3 scored value, NOT the literal answer position; reverse-scored items 3,5,6,7,8,9,10 must be oriented by the caller"
],
"caveats": "Q10 is a safety item: any non-zero score warrants suicide-risk assessment irrespective of the total.",
"source": {
"citation": "Cox JL, Holden JM, Sagovsky R. Br J Psychiatry. 1987;150:782-786.",
"url": "https://doi.org/10.1192/bjp.150.6.782"
},
"status": "draft"
}
}
}
})
}
fn calculate(&self, input: &Value) -> Result<CalculationResponse, CalcError> {
let parsed: EpdsInput = serde_json::from_value(input.clone())
.map_err(|e| CalcError::InvalidInput(e.to_string()))?;
build_response(&parsed)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn responses(v: [u8; 10]) -> EpdsInput {
EpdsInput {
responses: v.to_vec(),
}
}
#[test]
fn all_zero_is_unlikely() {
let o = compute(&responses([0; 10])).unwrap();
assert_eq!(o.total, 0);
assert_eq!(o.likelihood, Likelihood::Unlikely);
assert!(!o.self_harm_flag);
}
#[test]
fn band_boundaries_match_cox() {
assert_eq!(Likelihood::from_total(9), Likelihood::Unlikely);
assert_eq!(Likelihood::from_total(10), Likelihood::Possible);
assert_eq!(Likelihood::from_total(12), Likelihood::Possible);
assert_eq!(Likelihood::from_total(13), Likelihood::Probable);
assert_eq!(Likelihood::from_total(30), Likelihood::Probable);
}
#[test]
fn maximum_score_is_probable() {
let o = compute(&responses([3; 10])).unwrap();
assert_eq!(o.total, 30);
assert_eq!(o.likelihood, Likelihood::Probable);
assert!(o.self_harm_flag);
}
#[test]
fn self_harm_flag_is_independent_of_total() {
let o = compute(&responses([0, 0, 0, 0, 0, 0, 0, 0, 0, 3])).unwrap();
assert_eq!(o.total, 3);
assert_eq!(o.likelihood, Likelihood::Unlikely);
assert!(o.self_harm_flag);
assert!(o.interpretation.contains("suicide-risk assessment"));
}
#[test]
fn possible_band_without_self_harm() {
let o = compute(&responses([2, 2, 2, 2, 1, 1, 1, 0, 0, 0])).unwrap();
assert_eq!(o.total, 11);
assert_eq!(o.likelihood, Likelihood::Possible);
assert!(!o.self_harm_flag);
}
#[test]
fn wrong_length_and_range_are_rejected() {
assert!(
compute(&EpdsInput {
responses: vec![0; 9]
})
.is_err()
);
assert!(
compute(&EpdsInput {
responses: vec![0; 11]
})
.is_err()
);
assert!(
compute(&EpdsInput {
responses: vec![4, 0, 0, 0, 0, 0, 0, 0, 0, 0]
})
.is_err()
);
}
#[test]
fn dynamic_calculate_matches_typed() {
let arr = [1, 1, 2, 1, 2, 2, 1, 2, 1, 0];
let dynamic = Epds.calculate(&json!({ "responses": arr })).unwrap();
let typed = build_response(&responses(arr)).unwrap();
assert_eq!(dynamic, typed);
assert_eq!(dynamic.result, json!(13));
assert_eq!(dynamic.working["likelihood"], json!("probable"));
assert_eq!(dynamic.working["self_harm_item_flag"], json!(false));
}
#[test]
fn schema_carries_input_definition() {
let schema = Epds.input_schema();
let def = &schema["properties"]["responses"]["definition"];
assert!(
def["excludes"][0]
.as_str()
.unwrap()
.contains("NOT the literal answer position")
);
assert_eq!(def["status"], json!("draft"));
}
#[test]
fn reverse_scored_items_documented() {
assert_eq!(REVERSE_SCORED_ITEMS, [3, 5, 6, 7, 8, 9, 10]);
}
}