use crate::test_support::TestNetworkBuilder;
use crate::{MixModel, QualityMode, QualitySource, SimulationOptions, SourceType};
use approx::{assert_abs_diff_eq, assert_relative_eq};
use crate::quality::{advance_quality, init_quality};
#[test]
fn chemical_front_reaches_downstream() {
let builder = TestNetworkBuilder::new()
.with_options(SimulationOptions {
quality_mode: QualityMode::Chemical,
qual_step: 10.0,
duration: 7200.0,
..SimulationOptions::default()
})
.reservoir("R1", 100.0)
.node_quality("R1", 10.0)
.junction("J1", 0.0, 0.0) .hw_pipe("P1", "R1", "J1", 100.0, 12.0, 100.0);
let (net, ns, mut ls, _favad) = builder.build_with_favad();
ls[0].flow = 0.028317_f64;
let mut state = init_quality(&net, &ns, &ls).unwrap();
let d_m = 0.3048_f64;
let l_m = 30.48_f64;
let pipe_vol = std::f64::consts::PI * (d_m / 2.0).powi(2) * l_m;
let travel_time = pipe_vol / 0.028317_f64;
let dt_h = travel_time * 3.0;
advance_quality(&mut state, &net, &ns, &ls, dt_h, 0.0);
assert_abs_diff_eq!(state.node_conc[1], 10.0, epsilon = 0.1);
assert_abs_diff_eq!(state.node_conc[0], 10.0, epsilon = 0.01);
}
#[test]
fn zero_flow_no_transport() {
let builder = TestNetworkBuilder::new()
.with_options(SimulationOptions {
quality_mode: QualityMode::Chemical,
qual_step: 60.0,
duration: 7200.0,
..SimulationOptions::default()
})
.reservoir("R1", 100.0)
.node_quality("R1", 10.0)
.junction("J1", 0.0, 0.0)
.hw_pipe("P1", "R1", "J1", 1000.0, 12.0, 100.0);
let (net, ns, mut ls, _) = builder.build_with_favad();
ls[0].flow = 0.0;
let mut state = init_quality(&net, &ns, &ls).unwrap();
advance_quality(&mut state, &net, &ns, &ls, 3600.0, 0.0);
assert_abs_diff_eq!(state.node_conc[1], 0.0, epsilon = 0.1);
}
#[test]
fn age_increases_uniformly_no_flow() {
let builder = TestNetworkBuilder::new()
.with_options(SimulationOptions {
quality_mode: QualityMode::Age,
qual_step: 3600.0,
hyd_step: 3600.0,
duration: 7200.0,
..SimulationOptions::default()
})
.reservoir("R1", 100.0) .junction("J1", 0.0, 0.0)
.hw_pipe("P1", "R1", "J1", 100.0, 12.0, 100.0);
let (net, ns, mut ls, _) = builder.build_with_favad();
ls[0].flow = 0.0;
let mut state = init_quality(&net, &ns, &ls).unwrap();
advance_quality(&mut state, &net, &ns, &ls, 3600.0, 0.0);
if let Some(pq) = &state.pipe_quality[0] {
for seg in &pq.segments {
assert_abs_diff_eq!(seg.concentration, 1.0, epsilon = 1e-12);
}
}
advance_quality(&mut state, &net, &ns, &ls, 3600.0, 3600.0);
if let Some(pq) = &state.pipe_quality[0] {
for seg in &pq.segments {
assert_abs_diff_eq!(seg.concentration, 2.0, epsilon = 1e-12);
}
}
}
#[test]
fn first_order_bulk_decay() {
let builder = TestNetworkBuilder::new()
.with_options(SimulationOptions {
quality_mode: QualityMode::Chemical,
qual_step: 60.0, hyd_step: 3600.0,
duration: 7200.0,
bulk_coeff: -0.5, ..SimulationOptions::default()
})
.reservoir("R1", 100.0)
.node_quality("R1", 10.0)
.junction("J1", 0.0, 0.0)
.hw_pipe("P1", "R1", "J1", 100.0, 12.0, 100.0);
let (net, ns, mut ls, _) = builder.build_with_favad();
ls[0].flow = 0.0;
let mut state = init_quality(&net, &ns, &ls).unwrap();
if let Some(pq) = &mut state.pipe_quality[0] {
for seg in &mut pq.segments {
seg.concentration = 10.0;
}
}
state.mass_balance.init = crate::quality::shared::total_mass(&state);
let dt_h = 3600.0;
advance_quality(&mut state, &net, &ns, &ls, dt_h, 0.0);
let kb_per_sec: f64 = -0.5 / 86400.0;
let c_expected = 10.0 * (kb_per_sec * dt_h).exp();
if let Some(pq) = &state.pipe_quality[0] {
for seg in &pq.segments {
assert_relative_eq!(seg.concentration, c_expected, max_relative = 0.01);
}
}
}
#[test]
fn mass_conserved_no_reactions() {
let builder = TestNetworkBuilder::new()
.with_options(SimulationOptions {
quality_mode: QualityMode::Chemical,
qual_step: 30.0,
duration: 7200.0,
..SimulationOptions::default()
})
.reservoir("R1", 100.0)
.node_quality("R1", 5.0)
.junction("J1", 0.0, 0.0)
.junction("J2", 0.0, 0.0)
.hw_pipe("P1", "R1", "J1", 200.0, 12.0, 100.0)
.hw_pipe("P2", "J1", "J2", 200.0, 12.0, 100.0);
let (net, ns, mut ls, _) = builder.build_with_favad();
ls[0].flow = 0.0;
ls[1].flow = 0.0;
let mut state = init_quality(&net, &ns, &ls).unwrap();
if let Some(pq) = &mut state.pipe_quality[0] {
for seg in &mut pq.segments {
seg.concentration = 8.0;
}
}
if let Some(pq) = &mut state.pipe_quality[1] {
for seg in &mut pq.segments {
seg.concentration = 3.0;
}
}
state.mass_balance.init = crate::quality::shared::total_mass(&state);
advance_quality(&mut state, &net, &ns, &ls, 600.0, 0.0);
let ratio = state.mass_balance.ratio();
assert_abs_diff_eq!(ratio, 1.0, epsilon = 1e-10);
}
#[test]
fn trace_propagates_from_source() {
let builder = TestNetworkBuilder::new()
.with_options(SimulationOptions {
quality_mode: QualityMode::Trace,
trace_node: Some("R1".to_string()),
qual_step: 10.0,
duration: 7200.0,
..SimulationOptions::default()
})
.reservoir("R1", 100.0)
.junction("J1", 0.0, 0.0)
.hw_pipe("P1", "R1", "J1", 100.0, 12.0, 100.0);
let (net, ns, mut ls, _) = builder.build_with_favad();
ls[0].flow = 1.0;
let mut state = init_quality(&net, &ns, &ls).unwrap();
assert_abs_diff_eq!(state.node_conc[0], 100.0, epsilon = 1e-10);
advance_quality(&mut state, &net, &ns, &ls, 500.0, 0.0);
assert_abs_diff_eq!(state.node_conc[1], 100.0, epsilon = 1.0);
}
#[test]
fn cstr_tank_mixing_approaches_inflow_concentration() {
let builder = TestNetworkBuilder::new()
.with_options(SimulationOptions {
quality_mode: QualityMode::Chemical,
qual_step: 60.0,
duration: 86400.0,
..SimulationOptions::default()
})
.reservoir("R1", 200.0)
.node_quality("R1", 10.0)
.tank("T1", 100.0, 10.0, 0.0, 30.0, 20.0)
.node_quality("T1", 0.0) .hw_pipe("P1", "R1", "T1", 100.0, 12.0, 100.0);
let (net, mut ns, mut ls, _) = builder.build_with_favad();
ls[0].flow = 0.5;
ns[1].net_flow = 0.5;
let mut state = init_quality(&net, &ns, &ls).unwrap();
if let Some(pq) = &mut state.pipe_quality[0] {
for seg in &mut pq.segments {
seg.concentration = 10.0;
}
}
for step in 0..10 {
advance_quality(&mut state, &net, &ns, &ls, 3600.0, step as f64 * 3600.0);
}
if let Some(tq) = &state.tank_quality[1] {
match tq {
crate::quality::shared::TankQuality::Cstr { conc, .. } => {
assert!(*conc > 5.0, "tank conc should approach inflow: got {conc}");
assert!(*conc <= 10.0, "tank conc should not exceed inflow conc");
}
_ => panic!("expected CSTR tank"),
}
}
}
#[test]
fn per_pipe_bulk_coeff_overrides_global() {
let builder = TestNetworkBuilder::new()
.with_options(SimulationOptions {
quality_mode: QualityMode::Chemical,
qual_step: 60.0,
hyd_step: 3600.0,
duration: 7200.0,
bulk_coeff: -1.0, ..SimulationOptions::default()
})
.reservoir("R1", 100.0)
.node_quality("R1", 10.0)
.junction("J1", 0.0, 0.0)
.junction("J2", 0.0, 0.0)
.hw_pipe("P1", "R1", "J1", 100.0, 12.0, 100.0) .hw_pipe_with_bulk("P2", "J1", "J2", 100.0, 12.0, 100.0, 0.0);
let (net, ns, mut ls, _) = builder.build_with_favad();
ls[0].flow = 0.0; ls[1].flow = 0.0;
let mut state = init_quality(&net, &ns, &ls).unwrap();
for k in 0..2 {
if let Some(pq) = &mut state.pipe_quality[k] {
for seg in &mut pq.segments {
seg.concentration = 10.0;
}
}
}
advance_quality(&mut state, &net, &ns, &ls, 3600.0, 0.0);
let c_p1 = state.pipe_quality[0].as_ref().unwrap().segments[0].concentration;
let c_p2 = state.pipe_quality[1].as_ref().unwrap().segments[0].concentration;
assert!(c_p1 < 10.0, "P1 should decay with global kb: got {c_p1}");
assert_abs_diff_eq!(c_p2, 10.0, epsilon = 1e-10,);
}
#[test]
fn chemical_propagates_through_series_pipes() {
let builder = TestNetworkBuilder::new()
.with_options(SimulationOptions {
quality_mode: QualityMode::Chemical,
qual_step: 10.0,
duration: 7200.0,
..SimulationOptions::default()
})
.reservoir("R1", 100.0)
.node_quality("R1", 10.0)
.junction("J1", 0.0, 0.0)
.junction("J2", 0.0, 0.0)
.hw_pipe("P1", "R1", "J1", 100.0, 12.0, 100.0)
.hw_pipe("P2", "J1", "J2", 100.0, 12.0, 100.0);
let (net, ns, mut ls, _) = builder.build_with_favad();
ls[0].flow = 1.0;
ls[1].flow = 1.0;
let mut state = init_quality(&net, &ns, &ls).unwrap();
advance_quality(&mut state, &net, &ns, &ls, 600.0, 0.0);
assert_abs_diff_eq!(state.node_conc[1], 10.0, epsilon = 0.5);
assert_abs_diff_eq!(state.node_conc[2], 10.0, epsilon = 0.5);
}
#[test]
fn fifo_tank_preserves_slug_order() {
let builder = TestNetworkBuilder::new()
.with_options(SimulationOptions {
quality_mode: QualityMode::Chemical,
qual_step: 10.0,
duration: 7200.0,
..SimulationOptions::default()
})
.reservoir("R1", 200.0)
.node_quality("R1", 10.0)
.tank_with_mixing("T1", 100.0, 10.0, 0.0, 20.0, 10.0, MixModel::Fifo, 1.0)
.junction("J1", 0.0, 0.0)
.hw_pipe("P1", "R1", "T1", 10.0, 12.0, 100.0)
.hw_pipe("P2", "T1", "J1", 10.0, 12.0, 100.0);
let (net, ns, mut ls, _) = builder.build_with_favad();
ls[0].flow = 1.0; ls[1].flow = 1.0;
let mut state = init_quality(&net, &ns, &ls).unwrap();
advance_quality(&mut state, &net, &ns, &ls, 2000.0, 0.0);
assert_abs_diff_eq!(state.node_conc[1], 10.0, epsilon = 1.0);
}
#[test]
fn lifo_tank_newest_exits_first() {
let builder = TestNetworkBuilder::new()
.with_options(SimulationOptions {
quality_mode: QualityMode::Chemical,
qual_step: 10.0,
duration: 7200.0,
..SimulationOptions::default()
})
.reservoir("R1", 200.0)
.node_quality("R1", 10.0)
.tank_with_mixing("T1", 100.0, 10.0, 0.0, 20.0, 10.0, MixModel::Lifo, 1.0)
.junction("J1", 0.0, 0.0)
.hw_pipe("P1", "R1", "T1", 10.0, 12.0, 100.0)
.hw_pipe("P2", "T1", "J1", 10.0, 12.0, 100.0);
let (net, ns, mut ls, _) = builder.build_with_favad();
ls[0].flow = 1.0;
ls[1].flow = 1.0;
let mut state = init_quality(&net, &ns, &ls).unwrap();
advance_quality(&mut state, &net, &ns, &ls, 200.0, 0.0);
assert!(
state.node_conc[1] > 5.0,
"LIFO should discharge newest water first: got {}",
state.node_conc[1]
);
}
#[test]
fn two_comp_mixing_approaches_inflow() {
let builder = TestNetworkBuilder::new()
.with_options(SimulationOptions {
quality_mode: QualityMode::Chemical,
qual_step: 10.0,
duration: 7200.0,
..SimulationOptions::default()
})
.reservoir("R1", 200.0)
.node_quality("R1", 10.0)
.tank_with_mixing(
"T1",
100.0,
10.0,
0.0,
20.0,
10.0,
MixModel::TwoCompartment,
0.5, )
.junction("J1", 0.0, 0.0)
.hw_pipe("P1", "R1", "T1", 10.0, 12.0, 100.0)
.hw_pipe("P2", "T1", "J1", 10.0, 12.0, 100.0);
let (net, ns, mut ls, _) = builder.build_with_favad();
ls[0].flow = 1.0;
ls[1].flow = 1.0;
let mut state = init_quality(&net, &ns, &ls).unwrap();
advance_quality(&mut state, &net, &ns, &ls, 5000.0, 0.0);
assert_abs_diff_eq!(state.node_conc[1], 10.0, epsilon = 1.5);
}
#[test]
fn water_age_equals_travel_time() {
let builder = TestNetworkBuilder::new()
.with_options(SimulationOptions {
quality_mode: QualityMode::Age,
qual_step: 10.0,
duration: 7200.0,
..SimulationOptions::default()
})
.reservoir("R1", 100.0)
.junction("J1", 0.0, 0.0)
.hw_pipe("P1", "R1", "J1", 100.0, 12.0, 100.0);
let (net, ns, mut ls, _) = builder.build_with_favad();
ls[0].flow = 0.028317_f64;
let mut state = init_quality(&net, &ns, &ls).unwrap();
let d_m = 0.3048_f64;
let l_m = 30.48_f64;
let pipe_vol = std::f64::consts::PI * (d_m / 2.0).powi(2) * l_m;
let travel_time = pipe_vol / 0.028317_f64;
advance_quality(&mut state, &net, &ns, &ls, 600.0, 0.0);
let age_hours = travel_time / 3600.0;
assert_abs_diff_eq!(state.node_conc[0], 0.0, epsilon = 0.01); assert_abs_diff_eq!(state.node_conc[1], age_hours, epsilon = 0.01);
}
#[test]
fn trace_source_reaches_100_downstream() {
let builder = TestNetworkBuilder::new()
.with_options(SimulationOptions {
quality_mode: QualityMode::Trace,
trace_node: Some("R1".to_string()),
qual_step: 10.0,
duration: 7200.0,
..SimulationOptions::default()
})
.reservoir("R1", 100.0)
.junction("J1", 0.0, 0.0)
.hw_pipe("P1", "R1", "J1", 100.0, 12.0, 100.0);
let (net, ns, mut ls, _) = builder.build_with_favad();
ls[0].flow = 1.0;
let mut state = init_quality(&net, &ns, &ls).unwrap();
advance_quality(&mut state, &net, &ns, &ls, 600.0, 0.0);
assert_abs_diff_eq!(state.node_conc[0], 100.0, epsilon = 1.0);
assert_abs_diff_eq!(state.node_conc[1], 100.0, epsilon = 1.0);
}
#[test]
fn q_stag_threshold_allows_low_flow_transport() {
let builder = TestNetworkBuilder::new()
.with_options(SimulationOptions {
quality_mode: QualityMode::Chemical,
qual_step: 10.0,
duration: 400.0,
..SimulationOptions::default()
})
.reservoir("R1", 100.0)
.node_quality("R1", 10.0)
.junction("J1", 0.0, 0.0)
.hw_pipe("P1", "R1", "J1", 1.0, 1.0, 100.0);
let (net, ns, mut ls, _) = builder.build_with_favad();
ls[0].flow = 1e-6;
let mut state = init_quality(&net, &ns, &ls).unwrap();
advance_quality(&mut state, &net, &ns, &ls, 400.0, 0.0);
assert_relative_eq!(state.node_conc[1], 10.0, max_relative = 0.05);
}
#[test]
fn mass_source_injection_formula() {
let builder = TestNetworkBuilder::new()
.with_options(SimulationOptions {
quality_mode: QualityMode::Chemical,
qual_step: 60.0,
duration: 60.0,
..SimulationOptions::default()
})
.reservoir("R1", 100.0)
.junction("J1", 0.0, 0.0)
.hw_pipe("P1", "R1", "J1", 100.0, 12.0, 100.0);
let (mut net, mut ns, mut ls, _) = builder.build_with_favad();
net.nodes[1].source = Some(QualitySource {
node: 2, kind: SourceType::Mass,
base_value: 60.0, pattern: None,
});
ns[1].demand_flow = 1e-3;
ls[0].flow = 1e-3;
let mut state = init_quality(&net, &ns, &ls).unwrap();
advance_quality(&mut state, &net, &ns, &ls, 60.0, 0.0);
assert_abs_diff_eq!(state.node_conc[1], 1.0, epsilon = 1e-9);
}
#[test]
fn bulk_reaction_accumulator_uses_si_conversion() {
let builder = TestNetworkBuilder::new()
.with_options(SimulationOptions {
quality_mode: QualityMode::Chemical,
qual_step: 3600.0,
hyd_step: 3600.0,
duration: 3600.0,
bulk_coeff: -1.0, ..SimulationOptions::default()
})
.reservoir("R1", 100.0)
.junction("J1", 0.0, 0.0)
.node_quality("J1", 10.0) .hw_pipe("P1", "R1", "J1", 100.0, 12.0, 100.0);
let (net, ns, mut ls, _) = builder.build_with_favad();
ls[0].flow = 0.0;
let mut state = init_quality(&net, &ns, &ls).unwrap();
advance_quality(&mut state, &net, &ns, &ls, 3600.0, 0.0);
let ucf_elev = 3.2808_f64; let ucf_diam = 39.370_f64; let l_m = 100.0 / ucf_elev;
let d_m = 12.0 / ucf_diam;
let v_pipe = std::f64::consts::PI * (d_m / 2.0).powi(2) * l_m;
let kb_per_s = -1.0_f64 / 86400.0;
let c0 = 10.0_f64;
let dt = 3600.0_f64;
let delta_c_bulk = (kb_per_s * c0 * dt).abs(); let expected_accumulator = delta_c_bulk * v_pipe;
assert_relative_eq!(
state.mass_balance.reacted_bulk,
expected_accumulator,
max_relative = 1e-6
);
let duration_hours = 1.0_f64;
let expected_rate_mg_hr = expected_accumulator * 1000.0 / duration_hours;
assert_relative_eq!(
state.mass_balance.reacted_bulk * 1000.0 / duration_hours,
expected_rate_mg_hr,
max_relative = 1e-6
);
}