Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/adapters/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ struct CmdInput {
}

pub fn print_output(out: &CalculationOutput, args: &Args) -> Result<(), AppError> {
validate_finite_output(out)?;

if args.json {
let s = serde_json::to_string_pretty(&out)
.map_err(|source| AppError::SerializeOutput { source })?;
Expand All @@ -98,3 +100,18 @@ pub fn print_output(out: &CalculationOutput, args: &Args) -> Result<(), AppError

Ok(())
}

fn validate_finite_output(out: &CalculationOutput) -> Result<(), AppError> {
let values = [
out.sp,
out.sa,
out.density_kg_per_m3,
out.sg_20_20,
out.sg_25_25,
];
if values.into_iter().all(f64::is_finite) {
Ok(())
} else {
Err(AppError::NonFiniteOutput)
}
}
4 changes: 2 additions & 2 deletions src/adapters/teos10.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@ pub fn ct_from_t(sa: f64, temp: f64, p_dbar: f64) -> f64 {
}

/// In-situ density ρ from SA, CT and p (TEOS-10, 75-term polynomial).
/// Falls back to 1025.0 kg/m³ if the `gsw` library returns an error.
/// Returns `NaN` if the `gsw` library reports an error.
pub fn rho(sa: f64, ct: f64, p_dbar: f64) -> f64 {
gsw_teos10::volume::rho(sa, ct, p_dbar).unwrap_or(1025.0)
gsw_teos10::volume::rho(sa, ct, p_dbar).unwrap_or(f64::NAN)
}

#[cfg(all(test, not(feature = "approx_ct")))]
Expand Down
4 changes: 4 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ pub enum AppError {
source: serde_json::Error,
},

#[cfg(feature = "cli")]
#[error("Computation produced non-finite values; please verify inputs and assumptions")]
NonFiniteOutput,

#[error("Unexpected error: {0}")]
Other(String),

Expand Down
4 changes: 4 additions & 0 deletions src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ pub struct Inputs {

impl Assumptions {
pub fn normalized(mut self) -> Self {
if !self.salinity_norm.is_finite() || self.salinity_norm <= 0.0 {
self.salinity_norm = 35.0;
}

if self.rn_compat
&& self
.ref_alk_dkh
Expand Down
6 changes: 3 additions & 3 deletions src/salinity/calculator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ pub enum CalcResult {
/// units are indicated by the field name:
/// - `mg_l`: milligrams per litre (mg/L) at the sample density
/// - `mgkg`: milligrams per kilogram (mg/kg) at the sample density
/// - `mg_l_sp35`: mg/L normalized to SP = 35
/// - `mgkg_sp35`: mg/kg normalized to SP = 35
/// - `mg_l_sp35`: mg/L normalized to configured salinity target (default 35)
/// - `mgkg_sp35`: mg/kg normalized to configured salinity target (default 35)
///
/// `norm_factor` is the multiplicative factor used to normalize component
/// values to SP = 35.
Expand Down Expand Up @@ -207,7 +207,7 @@ pub fn calc_salinity_sp_iterative(
.map(|(k, v)| (*k, *v / kg_per_l))
.collect();

let norm_factor = 35.0 / sp.max(TINY);
let norm_factor = ass.salinity_norm / sp.max(TINY);
let mg_l_norm: Vec<(&str, f64)> = mg_l_table
.iter()
.map(|(k, v)| (*k, *v * norm_factor))
Expand Down
35 changes: 35 additions & 0 deletions tests/cli_errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,38 @@ fn cli_reports_invalid_json_in_file() {
.failure()
.stderr(predicate::str::contains("Invalid JSON in input document"));
}

#[test]
fn cli_fails_when_computation_produces_non_finite_values() {
let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("salinity_rs");
let inputs = serde_json::json!({
"na": 11980.0,
"ca": 357.0,
"mg": 1246.0,
"k": 464.0,
"sr": 6.96,
"br": 73.2,
"cl": 19570.0,
"f": 1.14,
"s": 814.0,
"b": 5.57,
"alk_dkh": null
})
.to_string();

let assumptions = serde_json::json!({
"temp": 1e9,
"pressure_dbar": 0.0
})
.to_string();

cmd.arg("--json")
.arg("--inputs-json")
.arg(inputs)
.arg("--assumptions-json")
.arg(assumptions);

cmd.assert()
.failure()
.stderr(predicate::str::contains("non-finite"));
}
74 changes: 74 additions & 0 deletions tests/salinity_acceptance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ fn approx_in_range(v: f64, min: f64, max: f64) {
assert!((min..=max).contains(&v), "value {v} not in [{min}, {max}]");
}

fn approx_eq(v: f64, expected: f64, tol: f64) {
assert!(
(v - expected).abs() <= tol,
"value {v} differs from expected {expected} by more than {tol}"
);
}

#[test]
fn calculates_salinity_reasonable_without_explicit_cl() {
// Same inputs as above but without explicit chloride; the improved estimator
Expand Down Expand Up @@ -71,3 +78,70 @@ fn calculates_salinity_for_sample_inputs_within_reasonable_bounds() {
let sg_20 = specific_gravity(sp, 20.0, 0.0);
approx_in_range(sg_20, 0.98, 1.10);
}

#[test]
fn salinity_norm_changes_component_normalization_factor() {
let inputs = Inputs {
na: 11_980.0,
ca: 357.0,
mg: 1_246.0,
k: 464.0,
sr: 6.96,
br: 73.2,
cl: Some(19_570.0),
f: Some(1.14),
s: 814.0,
b: 5.57,
alk_dkh: None,
};

let ass35 = Assumptions {
return_components: true,
salinity_norm: 35.0,
..Default::default()
};
let ass50 = Assumptions {
return_components: true,
salinity_norm: 50.0,
..Default::default()
};

let factor35 = match calc_salinity_sp_teos10(&inputs, &ass35, 30, 1e-8) {
CalcResult::Detailed(d) => d.components.norm_factor,
CalcResult::Simple(_) => panic!("expected detailed output"),
};
let factor50 = match calc_salinity_sp_teos10(&inputs, &ass50, 30, 1e-8) {
CalcResult::Detailed(d) => d.components.norm_factor,
CalcResult::Simple(_) => panic!("expected detailed output"),
};

assert!((factor50 / factor35 - (50.0 / 35.0)).abs() < 1e-12);
}

#[test]
fn summary_matches_reference_values_for_known_sample() {
let inputs = Inputs {
na: 11_980.0,
ca: 357.0,
mg: 1_246.0,
k: 464.0,
sr: 6.96,
br: 73.2,
cl: Some(19_570.0),
f: Some(1.14),
s: 814.0,
b: 5.57,
alk_dkh: None,
};
let ass = Assumptions {
..Default::default()
};

let summary = salinity_rs::compute_summary(&inputs, &ass);

approx_eq(summary.sp, 35.2417, 1e-4);
approx_eq(summary.sa, 35.407_879_719_085_71, 1e-9);
approx_eq(summary.density_kg_per_m3, 1_024.945_755_987_009_5, 1e-9);
approx_eq(summary.sg_20_20, 1.026_579_948_021_515_4, 1e-12);
approx_eq(summary.sg_25_25, 1.026_237_065_651_817_4, 1e-12);
}
Loading