From 4da3d7791dea4e751f447e7235b7aebf0b9e263c Mon Sep 17 00:00:00 2001 From: u8array Date: Mon, 16 Feb 2026 07:21:17 +0100 Subject: [PATCH 1/5] Handle non-finite outputs as CLI errors --- src/adapters/cli.rs | 17 +++++++++++++++++ src/error.rs | 4 ++++ tests/cli_errors.rs | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/src/adapters/cli.rs b/src/adapters/cli.rs index 57d9312..210c2b0 100644 --- a/src/adapters/cli.rs +++ b/src/adapters/cli.rs @@ -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 })?; @@ -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) + } +} diff --git a/src/error.rs b/src/error.rs index 4927f0b..93bb71e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -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), diff --git a/tests/cli_errors.rs b/tests/cli_errors.rs index 82c93b5..c79b283 100644 --- a/tests/cli_errors.rs +++ b/tests/cli_errors.rs @@ -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")); +} From e1d9fb7101cb7d99459256d019100bef6a2b5dc1 Mon Sep 17 00:00:00 2001 From: u8array Date: Mon, 16 Feb 2026 07:21:53 +0100 Subject: [PATCH 2/5] Use salinity_norm for component normalization --- src/models.rs | 4 ++++ src/salinity/calculator.rs | 6 +++--- tests/salinity_acceptance.rs | 39 ++++++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/models.rs b/src/models.rs index 24e9fee..fa3dcd0 100644 --- a/src/models.rs +++ b/src/models.rs @@ -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 diff --git a/src/salinity/calculator.rs b/src/salinity/calculator.rs index d7fa65d..54715db 100644 --- a/src/salinity/calculator.rs +++ b/src/salinity/calculator.rs @@ -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. @@ -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)) diff --git a/tests/salinity_acceptance.rs b/tests/salinity_acceptance.rs index f95d30b..eec1dd6 100644 --- a/tests/salinity_acceptance.rs +++ b/tests/salinity_acceptance.rs @@ -71,3 +71,42 @@ 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); +} From 3c6eddb3fb084b63f10633102c0f01375a40feb4 Mon Sep 17 00:00:00 2001 From: u8array Date: Mon, 16 Feb 2026 07:22:15 +0100 Subject: [PATCH 3/5] Avoid masking TEOS density errors with fixed fallback --- src/adapters/teos10.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/adapters/teos10.rs b/src/adapters/teos10.rs index 4611248..795a7a7 100644 --- a/src/adapters/teos10.rs +++ b/src/adapters/teos10.rs @@ -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")))] From deeea2d867763f3a949e8ac97322b6a233d8ba59 Mon Sep 17 00:00:00 2001 From: u8array Date: Mon, 16 Feb 2026 07:22:47 +0100 Subject: [PATCH 4/5] Add tighter reference-value salinity acceptance test --- tests/salinity_acceptance.rs | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/salinity_acceptance.rs b/tests/salinity_acceptance.rs index eec1dd6..11524ee 100644 --- a/tests/salinity_acceptance.rs +++ b/tests/salinity_acceptance.rs @@ -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 @@ -110,3 +117,31 @@ fn salinity_norm_changes_component_normalization_factor() { 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); +} From f6edac7fd453fce6644c400a22456d5d212569ee Mon Sep 17 00:00:00 2001 From: u8array Date: Mon, 16 Feb 2026 08:22:36 +0100 Subject: [PATCH 5/5] fmt --- src/salinity/calculator.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/salinity/calculator.rs b/src/salinity/calculator.rs index 54715db..babcda4 100644 --- a/src/salinity/calculator.rs +++ b/src/salinity/calculator.rs @@ -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 configured salinity target (default 35) - /// - `mgkg_sp35`: mg/kg normalized to configured salinity target (default 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.