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 = "fena";
pub const LICENSE: CalculatorLicense = CalculatorLicense {
license: "Public-domain method - implemented from the primary literature",
source_url: "https://doi.org/10.1001/jama.1976.03270060029020",
};
pub const REFERENCE: &str = "Espinel CH. The FENa test. Use in the differential diagnosis of acute renal failure. \
JAMA. 1976;236(6):579-581. doi:10.1001/jama.1976.03270060029020";
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FenaInput {
pub urine_sodium: f64,
pub plasma_sodium: f64,
pub urine_creatinine: f64,
pub plasma_creatinine: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct FenaOutcome {
pub fena_percent: f64,
pub interpretation: String,
}
pub fn compute(input: &FenaInput) -> Result<FenaOutcome, CalcError> {
if input.urine_sodium <= 0.0 || !input.urine_sodium.is_finite() {
return Err(CalcError::InvalidInput(
"urine_sodium must be positive".into(),
));
}
if input.plasma_sodium <= 0.0 || !input.plasma_sodium.is_finite() {
return Err(CalcError::InvalidInput(
"plasma_sodium must be positive".into(),
));
}
if input.urine_creatinine <= 0.0 || !input.urine_creatinine.is_finite() {
return Err(CalcError::InvalidInput(
"urine_creatinine must be positive".into(),
));
}
if input.plasma_creatinine <= 0.0 || !input.plasma_creatinine.is_finite() {
return Err(CalcError::InvalidInput(
"plasma_creatinine must be positive".into(),
));
}
let fena = (input.urine_sodium * input.plasma_creatinine)
/ (input.plasma_sodium * input.urine_creatinine)
* 100.0;
let category = if fena < 1.0 {
"pre-renal aetiology (or conditions causing low FENa with intrinsic disease: contrast nephropathy, myoglobinuria, early obstruction)"
} else if fena <= 2.0 {
"indeterminate - clinical context and trend are required"
} else {
"intrinsic renal failure (e.g. acute tubular necrosis)"
};
let interpretation = format!(
"FENa {:.2}% - consistent with {}. Note: FENa is unreliable after diuretics, in CKD, \
non-oliguric ATN, or early obstruction. Consider FEUrea in patients on diuretics.",
fena, category
);
Ok(FenaOutcome {
fena_percent: fena,
interpretation,
})
}
pub fn build_response(input: &FenaInput) -> Result<CalculationResponse, CalcError> {
let o = compute(input)?;
let mut working = Map::new();
working.insert(
"fena_percent".into(),
json!(format!("{:.2}", o.fena_percent)),
);
Ok(CalculationResponse {
calculator: NAME.to_string(),
result: json!(format!("{:.2}", o.fena_percent)),
interpretation: o.interpretation,
working,
reference: REFERENCE.to_string(),
})
}
pub struct Fena;
impl Calculator for Fena {
fn name(&self) -> &'static str {
NAME
}
fn title(&self) -> &'static str {
"FENa (Fractional Excretion of Sodium)"
}
fn description(&self) -> &'static str {
"Differentiates pre-renal from intrinsic renal failure using urine and plasma sodium and creatinine."
}
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": "FenaInput",
"type": "object",
"additionalProperties": false,
"required": ["urine_sodium", "plasma_sodium", "urine_creatinine", "plasma_creatinine"],
"properties": {
"urine_sodium": {
"type": "number",
"exclusiveMinimum": 0,
"description": "Urine sodium (mEq/L or mmol/L)"
},
"plasma_sodium": {
"type": "number",
"exclusiveMinimum": 0,
"description": "Plasma/serum sodium (same unit as urine_sodium)"
},
"urine_creatinine": {
"type": "number",
"exclusiveMinimum": 0,
"description": "Urine creatinine (any unit, must match plasma_creatinine unit)"
},
"plasma_creatinine": {
"type": "number",
"exclusiveMinimum": 0,
"description": "Plasma/serum creatinine (same unit as urine_creatinine)"
}
}
})
}
fn calculate(&self, input: &Value) -> Result<CalculationResponse, CalcError> {
let parsed: FenaInput = serde_json::from_value(input.clone())
.map_err(|e| CalcError::InvalidInput(e.to_string()))?;
build_response(&parsed)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn inp(u_na: f64, p_na: f64, u_cr: f64, p_cr: f64) -> FenaInput {
FenaInput {
urine_sodium: u_na,
plasma_sodium: p_na,
urine_creatinine: u_cr,
plasma_creatinine: p_cr,
}
}
#[test]
fn prerenal_low_fena() {
let o = compute(&inp(20.0, 140.0, 120.0, 2.0)).unwrap();
assert!(
(o.fena_percent - 0.238).abs() < 0.001,
"got {}",
o.fena_percent
);
assert!(o.interpretation.contains("pre-renal"));
}
#[test]
fn intrinsic_high_fena() {
let o = compute(&inp(80.0, 140.0, 30.0, 2.0)).unwrap();
assert!(
(o.fena_percent - 3.810).abs() < 0.01,
"got {}",
o.fena_percent
);
assert!(o.interpretation.contains("intrinsic"));
}
#[test]
fn indeterminate_range() {
let o = compute(&inp(42.0, 140.0, 40.0, 2.0)).unwrap();
assert!(
(o.fena_percent - 1.5).abs() < 0.001,
"got {}",
o.fena_percent
);
assert!(o.interpretation.contains("indeterminate"));
}
#[test]
fn rejects_zero_inputs() {
assert!(compute(&inp(0.0, 140.0, 40.0, 2.0)).is_err());
assert!(compute(&inp(20.0, 0.0, 40.0, 2.0)).is_err());
assert!(compute(&inp(20.0, 140.0, 0.0, 2.0)).is_err());
assert!(compute(&inp(20.0, 140.0, 40.0, 0.0)).is_err());
}
#[test]
fn dynamic_calculate_matches_typed() {
let value = json!({
"urine_sodium": 20.0,
"plasma_sodium": 140.0,
"urine_creatinine": 120.0,
"plasma_creatinine": 2.0
});
let dynamic = Fena.calculate(&value).unwrap();
let typed = build_response(&inp(20.0, 140.0, 120.0, 2.0)).unwrap();
assert_eq!(dynamic, typed);
}
}