use crate::io::units::make_ucf;
use crate::test_support::{no_pswitch, TestNetworkBuilder};
use crate::{
CurveKind, DemandModel, FlowUnits, HeadLossFormula, LinkKind, SimulationOptions, ValveType,
};
use approx::assert_relative_eq;
use crate::hydraulics::{build_solver_context, solve_hydraulic_step, SolveResult};
fn solve_once(
builder: TestNetworkBuilder,
) -> (Vec<crate::NodeState>, Vec<crate::LinkState>, SolveResult) {
let (net, mut ns, mut ls, favad) = builder.build_with_favad();
let mut ctx = build_solver_context(&net, &favad).unwrap();
let result =
solve_hydraulic_step(&net, &favad, &mut ctx, &mut ns, &mut ls, 0.0, no_pswitch).unwrap();
(ns, ls, result)
}
#[test]
fn single_hw_pipe_headloss() {
let (ns, ls, result) = solve_once(
TestNetworkBuilder::new()
.reservoir("R1", 100.0)
.junction("J1", 0.0, 100.0)
.hw_pipe("P1", "R1", "J1", 1000.0, 12.0, 100.0),
);
assert_eq!(result, SolveResult::Converged);
let ucf = make_ucf(FlowUnits::Gpm, 1.0);
let res_head = 100.0 / ucf.elev;
let d_m = 12.0 / ucf.diam;
let l_m = 1000.0 / ucf.elev;
let c = 100.0_f64;
let q_m3s: f64 = 100.0 / ucf.flow;
let r_hw = 10.67 * l_m / (c.powf(1.852) * d_m.powf(4.871));
let h_f = r_hw * q_m3s.powf(1.852);
let expected_junction_head = res_head - h_f;
assert_relative_eq!(ns[0].head, res_head, epsilon = 1e-6);
assert_relative_eq!(ns[1].head, expected_junction_head, epsilon = 0.01);
assert_relative_eq!(ls[0].flow, q_m3s, epsilon = 1e-6);
}
#[test]
fn single_hw_pipe_high_demand() {
let (ns, ls, result) = solve_once(
TestNetworkBuilder::new()
.reservoir("R1", 200.0)
.junction("J1", 0.0, 500.0)
.hw_pipe("P1", "R1", "J1", 5000.0, 12.0, 130.0),
);
assert_eq!(result, SolveResult::Converged);
let ucf = make_ucf(FlowUnits::Gpm, 1.0);
let d_m = 12.0 / ucf.diam;
let l_m = 5000.0 / ucf.elev;
let c = 130.0_f64;
let q_m3s: f64 = 500.0 / ucf.flow;
let r_hw = 10.67 * l_m / (c.powf(1.852) * d_m.powf(4.871));
let h_f = r_hw * q_m3s.powf(1.852);
let expected_head = 200.0 / ucf.elev - h_f;
assert_relative_eq!(ns[1].head, expected_head, epsilon = 0.01);
assert_relative_eq!(ls[0].flow, q_m3s, epsilon = 1e-6);
}
#[test]
fn single_dw_pipe_headloss() {
let mut builder = TestNetworkBuilder::new();
builder.options_mut().head_loss_formula = HeadLossFormula::DarcyWeisbach;
builder.options_mut().flow_units = FlowUnits::Lps;
let builder = builder
.reservoir("R1", 30.0) .junction("J1", 0.0, 10.0) .hw_pipe("P1", "R1", "J1", 500.0, 300.0, 0.1);
let (ns, ls, result) = solve_once(builder);
assert_eq!(result, SolveResult::Converged);
let ucf_lps = make_ucf(FlowUnits::Lps, 1.0);
let d_m = 300.0 / ucf_lps.diam; let l_m = 500.0 / ucf_lps.elev; let q_m3s = 10.0 / ucf_lps.flow; let area = std::f64::consts::PI * (d_m / 2.0).powi(2);
let g = 9.81_f64; let r_base = l_m / (2.0 * g * d_m * area * area);
let res_head_m = 30.0 / ucf_lps.elev;
assert_relative_eq!(ns[0].head, res_head_m, epsilon = 0.01);
assert_relative_eq!(ls[0].flow, q_m3s, epsilon = 1e-5);
let h_f = ns[0].head - ns[1].head;
assert!(h_f > 0.0, "headloss should be positive, got {h_f}");
assert!(ns[1].head > 0.0, "junction head should be positive");
let h_f_approx = 0.025 * r_base * q_m3s * q_m3s;
assert_relative_eq!(h_f, h_f_approx, max_relative = 0.3); }
#[test]
fn two_pipes_series_mass_balance() {
let (ns, ls, result) = solve_once(
TestNetworkBuilder::new()
.reservoir("R1", 150.0)
.junction("J1", 0.0, 50.0)
.junction("J2", 0.0, 50.0)
.hw_pipe("P1", "R1", "J1", 1000.0, 12.0, 100.0)
.hw_pipe("P2", "J1", "J2", 1000.0, 12.0, 100.0),
);
assert_eq!(result, SolveResult::Converged);
let ucf = make_ucf(FlowUnits::Gpm, 1.0);
let q1_m3s = 100.0 / ucf.flow; let q2_m3s = 50.0 / ucf.flow;
assert_relative_eq!(ls[0].flow, q1_m3s, epsilon = 1e-5);
assert_relative_eq!(ls[1].flow, q2_m3s, epsilon = 1e-5);
let d_m = 12.0 / ucf.diam;
let l_m = 1000.0 / ucf.elev;
let r_hw = 10.67 * l_m / (100.0_f64.powf(1.852) * d_m.powf(4.871));
let hf1 = r_hw * q1_m3s.powf(1.852);
let hf2 = r_hw * q2_m3s.powf(1.852);
let res_head = 150.0 / ucf.elev;
assert_relative_eq!(ns[0].head, res_head, epsilon = 1e-6); assert_relative_eq!(ns[1].head, res_head - hf1, epsilon = 0.01); assert_relative_eq!(ns[2].head, res_head - hf1 - hf2, epsilon = 0.01); }
#[test]
fn two_pipes_parallel_flow_split() {
let (ns, ls, result) = solve_once(
TestNetworkBuilder::new()
.reservoir("R1", 100.0)
.junction("J1", 0.0, 200.0) .hw_pipe("P1", "R1", "J1", 2000.0, 12.0, 100.0)
.hw_pipe("P2", "R1", "J1", 2000.0, 12.0, 100.0),
);
assert_eq!(result, SolveResult::Converged);
let ucf = make_ucf(FlowUnits::Gpm, 1.0);
let q_each_m3s = (200.0 / 2.0) / ucf.flow;
assert_relative_eq!(ls[0].flow, q_each_m3s, epsilon = 1e-5);
assert_relative_eq!(ls[1].flow, q_each_m3s, epsilon = 1e-5);
let d_m = 12.0 / ucf.diam;
let l_m = 2000.0 / ucf.elev;
let r_hw = 10.67 * l_m / (100.0_f64.powf(1.852) * d_m.powf(4.871));
let hf = r_hw * q_each_m3s.powf(1.852);
assert_relative_eq!(ns[1].head, 100.0 / ucf.elev - hf, epsilon = 0.01);
}
#[test]
fn parallel_pipes_unequal_roughness() {
let (ns, ls, result) = solve_once(
TestNetworkBuilder::new()
.reservoir("R1", 100.0)
.junction("J1", 0.0, 200.0)
.hw_pipe("P1", "R1", "J1", 1000.0, 12.0, 130.0) .hw_pipe("P2", "R1", "J1", 1000.0, 12.0, 80.0), );
assert_eq!(result, SolveResult::Converged);
let ucf = make_ucf(FlowUnits::Gpm, 1.0);
let q_total_m3s = 200.0 / ucf.flow;
assert_relative_eq!(ls[0].flow + ls[1].flow, q_total_m3s, epsilon = 1e-5);
assert!(
ls[0].flow > ls[1].flow,
"smoother pipe should carry more flow"
);
let headloss_p1 = ns[0].head - ns[1].head;
let headloss_p2 = ns[0].head - ns[1].head; assert_relative_eq!(headloss_p1, headloss_p2, epsilon = 1e-10);
let d_m = 12.0 / ucf.diam;
let l_m = 1000.0 / ucf.elev;
let r1 = 10.67 * l_m / (130.0_f64.powf(1.852) * d_m.powf(4.871));
let r2 = 10.67 * l_m / (80.0_f64.powf(1.852) * d_m.powf(4.871));
let hf1 = r1 * ls[0].flow.powf(1.852);
let hf2 = r2 * ls[1].flow.powf(1.852);
assert_relative_eq!(hf1, hf2, epsilon = 0.01);
}
#[test]
fn prv_limits_downstream_pressure() {
let (ns, _ls, result) = solve_once(
TestNetworkBuilder::new()
.reservoir("R1", 200.0)
.junction("J1", 0.0, 0.0)
.junction("J2", 0.0, 100.0)
.hw_pipe("P1", "R1", "J1", 1000.0, 12.0, 100.0)
.valve("V1", "J1", "J2", ValveType::Prv, 12.0, 50.0) .hw_pipe("P2", "J2", "J2_end", 1.0, 12.0, 100.0) .junction("J2_end", 0.0, 0.0), );
assert_eq!(result, SolveResult::Converged);
let ucf = make_ucf(FlowUnits::Gpm, 1.0);
let setting_m = 50.0 / ucf.pressure;
assert_relative_eq!(ns[2].head, setting_m, epsilon = 0.2);
assert!(ns[1].head > setting_m, "upstream must exceed PRV setting");
}
#[test]
fn constant_hp_pump_head_gain() {
let (ns, ls, result) = solve_once(
TestNetworkBuilder::new()
.reservoir("R1", 0.0)
.junction("J1", 0.0, 100.0)
.const_hp_pump("PMP1", "R1", "J1", 10.0)
.hw_pipe("P1", "J1", "J1_end", 100.0, 12.0, 100.0)
.junction("J1_end", 0.0, 0.0), );
assert_eq!(result, SolveResult::Converged);
let ucf = make_ucf(FlowUnits::Gpm, 1.0);
let power_w = 10.0 / ucf.power;
let gamma = 9810.0_f64;
let pump_flow = ls[0].flow;
assert!(pump_flow > 0.0, "pump should have positive flow");
let actual_head_gain = ns[1].head - ns[0].head;
assert!(
actual_head_gain > 3.0,
"pump should add significant head, got {actual_head_gain}"
);
let energy = gamma * pump_flow.abs() * actual_head_gain;
assert_relative_eq!(energy, power_w, max_relative = 0.02);
}
#[test]
fn tank_inflow_consistent_with_head() {
let (ns, ls, result) = solve_once(
TestNetworkBuilder::new()
.reservoir("R1", 200.0) .tank("T1", 100.0, 10.0, 0.0, 30.0, 40.0) .hw_pipe("P1", "R1", "T1", 1000.0, 12.0, 100.0),
);
assert_eq!(result, SolveResult::Converged);
let ucf = make_ucf(FlowUnits::Gpm, 1.0);
let res_head_m = 200.0 / ucf.elev;
let tank_init_head_m = (100.0 + 10.0) / ucf.elev;
let tank_head = ns[1].head;
let res_head = ns[0].head;
assert_relative_eq!(res_head, res_head_m, epsilon = 1e-6);
assert_relative_eq!(tank_head, tank_init_head_m, epsilon = 0.2);
let hf_actual = res_head - tank_head;
assert!(hf_actual > 0.0, "flow should be from reservoir to tank");
let d_m = 12.0 / ucf.diam;
let l_m = 1000.0 / ucf.elev;
let r_hw = 10.67 * l_m / (100.0_f64.powf(1.852) * d_m.powf(4.871));
let q_expected = (hf_actual / r_hw).powf(1.0 / 1.852);
assert_relative_eq!(ls[0].flow, q_expected, epsilon = 1e-4);
assert_relative_eq!(ns[1].net_flow.abs(), ls[0].flow.abs(), epsilon = 1e-4);
}
#[test]
fn zero_demand_heads_equal_reservoir() {
let (ns, ls, result) = solve_once(
TestNetworkBuilder::new()
.reservoir("R1", 100.0)
.junction("J1", 50.0, 0.0)
.junction("J2", 30.0, 0.0)
.hw_pipe("P1", "R1", "J1", 1000.0, 12.0, 100.0)
.hw_pipe("P2", "J1", "J2", 1000.0, 12.0, 100.0),
);
assert_eq!(result, SolveResult::Converged);
let ucf = make_ucf(FlowUnits::Gpm, 1.0);
let res_head = 100.0 / ucf.elev;
assert_relative_eq!(ns[0].head, res_head, epsilon = 1e-6);
assert_relative_eq!(ns[1].head, res_head, epsilon = 0.001);
assert_relative_eq!(ns[2].head, res_head, epsilon = 0.001);
assert!(ls[0].flow.abs() < 1e-6, "P1 flow should be ~0");
assert!(ls[1].flow.abs() < 1e-6, "P2 flow should be ~0");
}
#[test]
fn junction_pressure_positive() {
let (ns, _ls, result) = solve_once(
TestNetworkBuilder::new()
.reservoir("R1", 200.0)
.junction("J1", 50.0, 100.0)
.hw_pipe("P1", "R1", "J1", 1000.0, 12.0, 100.0),
);
assert_eq!(result, SolveResult::Converged);
let ucf = make_ucf(FlowUnits::Gpm, 1.0);
let elev_j1_m = 50.0 / ucf.elev;
let pressure_m = ns[1].head - elev_j1_m;
assert!(pressure_m > 0.0, "junction should have positive pressure");
assert!(
pressure_m < 150.0 / ucf.elev,
"pressure should be less than static"
);
assert!(
pressure_m > 100.0 / ucf.elev,
"pressure shouldn't drop too much for 100 GPM in 12in pipe"
);
}
#[test]
fn check_valve_blocks_reverse_flow() {
let (ns, ls, result) = solve_once(
TestNetworkBuilder::new()
.reservoir("R_low", 50.0)
.reservoir("R_high", 200.0)
.junction("J1", 0.0, 0.0)
.cv_pipe("P_cv", "R_low", "J1", 1000.0, 12.0, 100.0)
.hw_pipe("P2", "R_high", "J1", 1000.0, 12.0, 100.0),
);
assert_eq!(result, SolveResult::Converged);
let ucf = make_ucf(FlowUnits::Gpm, 1.0);
assert_relative_eq!(ns[2].head, 200.0 / ucf.elev, epsilon = 0.1);
assert!(
ls[0].flow.abs() < 1e-5,
"CV pipe flow should be near-zero, got {}",
ls[0].flow
);
assert!(
ls[1].flow.abs() < 1e-5,
"P2 flow should be ~0 with no demand"
);
}
#[test]
fn closed_pipe_no_flow() {
let builder = TestNetworkBuilder::new()
.reservoir("R1", 200.0)
.reservoir("R2", 100.0)
.junction("J1", 0.0, 0.0)
.hw_pipe("P1", "R1", "J1", 1000.0, 12.0, 100.0)
.hw_pipe("P2", "R2", "J1", 1000.0, 12.0, 100.0);
let (net, mut ns, mut ls, favad) = builder.build_with_favad();
ls[0].status = crate::LinkStatus::Closed;
let mut ctx = build_solver_context(&net, &favad).unwrap();
let result =
solve_hydraulic_step(&net, &favad, &mut ctx, &mut ns, &mut ls, 0.0, no_pswitch).unwrap();
assert_eq!(result, SolveResult::Converged);
let ucf = make_ucf(FlowUnits::Gpm, 1.0);
assert_relative_eq!(ns[2].head, 100.0 / ucf.elev, epsilon = 0.1);
assert!(ls[0].flow.abs() < 1e-4, "closed pipe flow should be ~0");
}
#[test]
fn pump_with_head_curve() {
let (ns, ls, result) = solve_once(
TestNetworkBuilder::new()
.reservoir("R1", 0.0)
.junction("J1", 0.0, 100.0) .curve("pump_curve", CurveKind::PumpHead, &[(150.0, 100.0)])
.pump("PMP1", "R1", "J1", "pump_curve"),
);
assert_eq!(result, SolveResult::Converged);
assert!(
ns[1].head > ns[0].head,
"pump should raise downstream head: J1={}, R1={}",
ns[1].head,
ns[0].head
);
let ucf = make_ucf(FlowUnits::Gpm, 1.0);
let q_m3s: f64 = 100.0 / ucf.flow;
assert_relative_eq!(ls[0].flow, q_m3s, epsilon = 1e-5);
}
#[test]
fn emitter_flow_proportional_to_pressure() {
let (ns, _ls, result) = solve_once(
TestNetworkBuilder::new()
.reservoir("R1", 200.0)
.junction_with_emitter("J1", 100.0, 0.0, 1.0) .hw_pipe("P1", "R1", "J1", 1000.0, 12.0, 100.0),
);
assert_eq!(result, SolveResult::Converged);
let ucf = make_ucf(FlowUnits::Gpm, 1.0);
let pressure_m = ns[1].head - 100.0 / ucf.elev;
assert!(
pressure_m > 0.0,
"emitter node should have positive pressure"
);
assert!(
ns[1].emitter_flow.abs() > 1e-8,
"emitter should discharge flow, got {}",
ns[1].emitter_flow
);
assert!(ns[1].emitter_flow > 0.0, "emitter flow should be positive");
}
#[test]
fn two_reservoirs_one_junction() {
let (ns, ls, result) = solve_once(
TestNetworkBuilder::new()
.reservoir("R1", 200.0)
.reservoir("R2", 100.0)
.junction("J1", 0.0, 200.0)
.hw_pipe("P1", "R1", "J1", 1000.0, 12.0, 100.0) .hw_pipe("P2", "R2", "J1", 1000.0, 12.0, 100.0),
);
assert_eq!(result, SolveResult::Converged);
assert!(ls[0].flow > 0.0, "R1 should supply flow");
assert!(ls[0].flow > ls[1].flow, "higher reservoir supplies more");
let ucf = make_ucf(FlowUnits::Gpm, 1.0);
let q_total_m3s: f64 = 200.0 / ucf.flow;
assert_relative_eq!(ls[0].flow + ls[1].flow, q_total_m3s, epsilon = 1e-4);
let res1_head = 200.0 / ucf.elev;
let res2_head = 100.0 / ucf.elev;
assert!(ns[2].head > res2_head, "head > lower reservoir");
assert!(ns[2].head < res1_head, "head < higher reservoir");
let d_m = 12.0 / ucf.diam;
let l_m = 1000.0 / ucf.elev;
let r_hw = 10.67 * l_m / (100.0_f64.powf(1.852) * d_m.powf(4.871));
let hf1 = r_hw * ls[0].flow.powf(1.852);
let hf2 = r_hw * ls[1].flow.abs().powf(1.852);
assert_relative_eq!(res1_head - ns[2].head, hf1, epsilon = 0.01);
assert_relative_eq!(ns[2].head - res2_head, hf2, epsilon = 0.01);
}
#[test]
fn single_cm_pipe_headloss() {
let mut builder = TestNetworkBuilder::new();
builder.options_mut().head_loss_formula = HeadLossFormula::ChezyManning;
let builder = builder
.reservoir("R1", 100.0)
.junction("J1", 0.0, 100.0)
.hw_pipe("P1", "R1", "J1", 1000.0, 12.0, 0.013);
let (ns, ls, result) = solve_once(builder);
assert_eq!(result, SolveResult::Converged);
let ucf = make_ucf(FlowUnits::Gpm, 1.0);
let q_m3s = 100.0 / ucf.flow;
assert_relative_eq!(ls[0].flow, q_m3s, epsilon = 1e-5);
let n = 0.013_f64;
let d_m = 12.0 / ucf.diam;
let l_m = 1000.0 / ucf.elev;
let a = std::f64::consts::PI * d_m * d_m / 4.0;
let r_h = d_m / 4.0;
let k_m = 1.0_f64; let r_cm = n * n * l_m / (k_m * k_m * r_h.powf(4.0 / 3.0) * a * a);
let hf = r_cm * q_m3s * q_m3s;
let expected_head = 100.0 / ucf.elev - hf;
assert_relative_eq!(ns[1].head, expected_head, epsilon = 0.01);
}
#[test]
fn minor_loss_adds_to_friction() {
let (ns_clean, ls_clean, r1) = solve_once(
TestNetworkBuilder::new()
.reservoir("R1", 100.0)
.junction("J1", 0.0, 100.0)
.hw_pipe("P1", "R1", "J1", 1000.0, 12.0, 100.0),
);
assert_eq!(r1, SolveResult::Converged);
let (ns_k10, ls_k10, r2) = {
let builder = TestNetworkBuilder::new()
.reservoir("R1", 100.0)
.junction("J1", 0.0, 100.0)
.hw_pipe("P1", "R1", "J1", 1000.0, 12.0, 100.0);
let (mut net, mut ns, mut ls, favad) = builder.build_with_favad();
if let crate::LinkKind::Pipe(ref mut p) = net.links[0].kind {
p.minor_loss = 10.0;
}
let mut ctx = build_solver_context(&net, &favad).unwrap();
let result =
solve_hydraulic_step(&net, &favad, &mut ctx, &mut ns, &mut ls, 0.0, no_pswitch)
.unwrap();
(ns, ls, result)
};
assert_eq!(r2, SolveResult::Converged);
let ucf = make_ucf(FlowUnits::Gpm, 1.0);
let q_m3s = 100.0 / ucf.flow;
assert_relative_eq!(ls_clean[0].flow, q_m3s, epsilon = 1e-5);
assert_relative_eq!(ls_k10[0].flow, q_m3s, epsilon = 1e-5);
assert!(
ns_k10[1].head < ns_clean[1].head,
"K=10 should have more headloss: clean={}, k10={}",
ns_clean[1].head,
ns_k10[1].head
);
let delta_head = ns_clean[1].head - ns_k10[1].head;
assert!(delta_head > 0.0, "minor loss should reduce head");
}
#[test]
fn psv_maintains_upstream_pressure() {
let (ns, _ls, result) = solve_once(
TestNetworkBuilder::new()
.reservoir("R1", 200.0)
.junction("J1", 0.0, 50.0)
.junction("J2", 0.0, 50.0)
.hw_pipe("P1", "R1", "J1", 1000.0, 12.0, 100.0)
.valve("V1", "J1", "J2", ValveType::Psv, 12.0, 60.0) .hw_pipe("P2", "J2", "J2_end", 1.0, 12.0, 100.0)
.junction("J2_end", 0.0, 0.0),
);
assert_eq!(result, SolveResult::Converged);
let ucf = make_ucf(FlowUnits::Gpm, 1.0);
let setting_m = 60.0 / ucf.pressure;
assert!(
ns[1].head >= setting_m - 0.2,
"PSV upstream head should be >= setting: J1={}, setting_m={}",
ns[1].head,
setting_m
);
}
#[test]
fn fcv_limits_flow() {
let (_ns, ls, result) = solve_once(
TestNetworkBuilder::new()
.reservoir("R1", 100.0)
.junction("J1", 0.0, 0.0)
.junction("J2", 0.0, 100.0) .hw_pipe("P1", "R1", "J1", 500.0, 12.0, 100.0)
.valve("V1", "J1", "J2", ValveType::Fcv, 12.0, 100.0), );
assert_eq!(result, SolveResult::Converged);
let ucf = make_ucf(FlowUnits::Gpm, 1.0);
let q_setting_m3s = 100.0 / ucf.flow;
assert_relative_eq!(ls[1].flow, q_setting_m3s, epsilon = 1e-5);
}
#[test]
fn tcv_throttles_flow() {
let (_ns_base, _ls_base, r1) = solve_once(
TestNetworkBuilder::new()
.reservoir("R1", 200.0)
.junction("J1", 0.0, 100.0)
.hw_pipe("P1", "R1", "J1", 1000.0, 12.0, 100.0),
);
assert_eq!(r1, SolveResult::Converged);
let (ns_tcv, _ls_tcv, r2) = solve_once(
TestNetworkBuilder::new()
.reservoir("R1", 200.0)
.junction("J1", 0.0, 0.0)
.junction("J2", 0.0, 100.0)
.hw_pipe("P1", "R1", "J1", 1000.0, 12.0, 100.0)
.valve("V1", "J1", "J2", ValveType::Tcv, 12.0, 50.0)
.hw_pipe("P2", "J2", "J2_end", 1.0, 12.0, 100.0)
.junction("J2_end", 0.0, 0.0),
);
assert_eq!(r2, SolveResult::Converged);
assert!(
ns_tcv[2].head < ns_tcv[1].head,
"TCV should create headloss: J1={}, J2={}",
ns_tcv[1].head,
ns_tcv[2].head
);
}
#[test]
fn pbv_fixed_pressure_drop() {
let (ns, _ls, result) = solve_once(
TestNetworkBuilder::new()
.reservoir("R1", 200.0)
.junction("J1", 0.0, 0.0)
.junction("J2", 0.0, 100.0)
.hw_pipe("P1", "R1", "J1", 100.0, 300.0, 100.0) .valve("V1", "J1", "J2", ValveType::Pbv, 300.0, 30.0) .hw_pipe("P2", "J2", "J2_end", 1.0, 300.0, 100.0)
.junction("J2_end", 0.0, 0.0),
);
assert_eq!(result, SolveResult::Converged);
let ucf = make_ucf(FlowUnits::Gpm, 1.0);
let setting_m = 30.0 / ucf.pressure;
let actual_drop = ns[1].head - ns[2].head;
assert_relative_eq!(actual_drop, setting_m, epsilon = 0.3);
}
#[test]
fn four_node_loop_kirchhoff() {
let (ns, ls, result) = solve_once(
TestNetworkBuilder::new()
.reservoir("R1", 200.0)
.junction("J1", 0.0, 50.0)
.junction("J2", 0.0, 50.0)
.junction("J3", 0.0, 50.0)
.hw_pipe("P1", "R1", "J1", 500.0, 12.0, 100.0)
.hw_pipe("P2", "R1", "J2", 500.0, 12.0, 100.0)
.hw_pipe("P3", "J1", "J3", 500.0, 12.0, 100.0)
.hw_pipe("P4", "J2", "J3", 500.0, 12.0, 100.0)
.hw_pipe("P5", "J1", "J2", 200.0, 8.0, 100.0), );
assert_eq!(result, SolveResult::Converged);
let ucf = make_ucf(FlowUnits::Gpm, 1.0);
let total_demand_m3s = 150.0 / ucf.flow;
let total_supply = ls[0].flow + ls[1].flow; assert_relative_eq!(total_supply, total_demand_m3s, epsilon = 1e-4);
for i in 1..4 {
assert!(
ns[i].head < 200.0 / ucf.elev,
"J{} head should be < reservoir",
i
);
assert!(ns[i].head > 0.0, "J{} head should be positive", i);
}
let head_diff = (ns[1].head - ns[2].head).abs();
assert!(
head_diff < 5.0 / ucf.elev,
"symmetric nodes should have similar heads: diff={head_diff}"
);
let d_m = 12.0 / ucf.diam;
let l_m = 500.0 / ucf.elev;
let r_p1 = 10.67 * l_m / (100.0_f64.powf(1.852) * d_m.powf(4.871));
let hf_p1 = r_p1 * ls[0].flow.abs().powf(1.852);
assert_relative_eq!(200.0 / ucf.elev - ns[1].head, hf_p1, epsilon = 0.05);
}
#[test]
fn multiple_demands_sum() {
let (ns, ls, result) = solve_once(
TestNetworkBuilder::new()
.reservoir("R1", 100.0)
.junction("J1", 0.0, 15.0) .hw_pipe("P1", "R1", "J1", 1000.0, 12.0, 100.0),
);
assert_eq!(result, SolveResult::Converged);
let ucf = make_ucf(FlowUnits::Gpm, 1.0);
let q_m3s = 15.0 / ucf.flow;
assert_relative_eq!(ls[0].flow, q_m3s, epsilon = 1e-5);
let d_m = 12.0 / ucf.diam;
let l_m = 1000.0 / ucf.elev;
let r_hw = 10.67 * l_m / (100.0_f64.powf(1.852) * d_m.powf(4.871));
let hf = r_hw * q_m3s.powf(1.852);
assert_relative_eq!(ns[1].head, 100.0 / ucf.elev - hf, epsilon = 0.01);
}
#[test]
fn negative_demand_source() {
let (ns, ls, result) = solve_once(
TestNetworkBuilder::new()
.reservoir("R_ref", 100.0) .junction("J_source", 100.0, -200.0) .junction("J_sink", 0.0, 200.0) .hw_pipe("P_ref", "R_ref", "J_source", 1.0, 12.0, 100.0) .hw_pipe("P1", "J_source", "J_sink", 1000.0, 12.0, 100.0),
);
assert_eq!(result, SolveResult::Converged);
let ucf = make_ucf(FlowUnits::Gpm, 1.0);
let q_m3s = 200.0 / ucf.flow;
assert_relative_eq!(ls[1].flow, q_m3s, epsilon = 1e-3);
assert!(ns[1].head > ns[2].head, "source should be at higher head");
}
#[test]
fn specific_gravity_affects_pressure_not_head() {
let (ns_10, ls_10, r1) = solve_once(
TestNetworkBuilder::new()
.reservoir("R1", 100.0)
.junction("J1", 0.0, 100.0)
.hw_pipe("P1", "R1", "J1", 1000.0, 12.0, 100.0),
);
assert_eq!(r1, SolveResult::Converged);
let mut builder = TestNetworkBuilder::new();
builder.options_mut().specific_gravity = 1.1;
let (ns_11, ls_11, r2) = solve_once(
builder
.reservoir("R1", 100.0)
.junction("J1", 0.0, 100.0)
.hw_pipe("P1", "R1", "J1", 1000.0, 12.0, 100.0),
);
assert_eq!(r2, SolveResult::Converged);
assert_relative_eq!(ns_10[1].head, ns_11[1].head, epsilon = 0.01);
assert_relative_eq!(ls_10[0].flow, ls_11[0].flow, epsilon = 1e-6);
let ucf = make_ucf(FlowUnits::Gpm, 1.0);
let p_10 = ns_10[1].head * ucf.pressure * 1.0;
let p_11 = ns_11[1].head * ucf.pressure * 1.1;
assert!(
(p_11 - p_10).abs() > 1.0,
"SG=1.1 should give higher PSI: p10={p_10}, p11={p_11}"
);
assert_relative_eq!(p_11 / p_10, 1.1, epsilon = 0.001);
}
#[test]
fn dead_end_no_flow() {
let (ns, ls, result) = solve_once(
TestNetworkBuilder::new()
.reservoir("R1", 200.0)
.junction("J1", 0.0, 100.0)
.junction("J_dead", 0.0, 0.0) .hw_pipe("P1", "R1", "J1", 1000.0, 12.0, 100.0)
.hw_pipe("P2", "J1", "J_dead", 500.0, 8.0, 100.0),
);
assert_eq!(result, SolveResult::Converged);
assert!(ls[1].flow.abs() < 1e-6, "dead-end pipe flow should be ~0");
assert_relative_eq!(ns[2].head, ns[1].head, epsilon = 0.01);
}
#[test]
fn emitter_backflow_at_negative_pressure() {
let mut builder = TestNetworkBuilder::new();
builder.options_mut().emitter_backflow = true;
let (ns, _ls, result) = solve_once(
builder
.reservoir("R1", 10.0) .junction_with_emitter("J1", 100.0, 0.0, 5.0) .hw_pipe("P1", "R1", "J1", 100.0, 12.0, 100.0),
);
assert_eq!(result, SolveResult::Converged);
let ucf = make_ucf(FlowUnits::Gpm, 1.0);
let elev_j1_m = 100.0 / ucf.elev; let pressure_m = ns[1].head - elev_j1_m;
assert!(pressure_m < 0.0, "should have negative pressure");
assert!(
ns[1].emitter_flow <= 0.0,
"emitter should backflow with negative pressure: got {}",
ns[1].emitter_flow
);
}
#[test]
fn demand_multiplier_scales_total_demand() {
let mut builder = TestNetworkBuilder::new();
builder.options_mut().demand_multiplier = 2.0;
let (ns, ls, result) = solve_once(
builder
.reservoir("R1", 200.0)
.junction("J1", 0.0, 100.0)
.junction("J2", 0.0, 80.0)
.junction("J3", 0.0, 60.0)
.hw_pipe("P1", "R1", "J1", 1000.0, 12.0, 100.0)
.hw_pipe("P2", "J1", "J2", 800.0, 10.0, 100.0)
.hw_pipe("P3", "J2", "J3", 600.0, 8.0, 100.0),
);
assert_eq!(result, SolveResult::Converged);
let ucf = make_ucf(FlowUnits::Gpm, 1.0);
let expected_total = (100.0 + 80.0 + 60.0) * 2.0 / ucf.flow;
assert_relative_eq!(ls[0].flow, expected_total, epsilon = 1e-5);
let served = ns[1].demand_flow + ns[2].demand_flow + ns[3].demand_flow;
assert_relative_eq!(served, expected_total, epsilon = 1e-5);
}
#[test]
fn pda_demand_reduces_below_full_demand() {
let mut builder = TestNetworkBuilder::new();
builder.options_mut().demand_model = DemandModel::PressureDriven;
builder.options_mut().pda_min_pressure = 0.0;
builder.options_mut().pda_required_pressure = 30.0;
builder.options_mut().pda_pressure_exponent = 0.5;
let (ns, _ls, result) = solve_once(
builder
.reservoir("R1", 100.0)
.junction("J1", 50.0, 100.0)
.hw_pipe("P1", "R1", "J1", 1000.0, 12.0, 100.0),
);
assert_eq!(result, SolveResult::Converged);
let ucf = make_ucf(FlowUnits::Gpm, 1.0);
let full_demand = 100.0 / ucf.flow; assert!(ns[1].demand_flow > 0.0, "PDA demand should be non-zero");
assert!(
ns[1].demand_flow < full_demand,
"PDA demand should be reduced under low pressure"
);
}
#[test]
fn leakage_favad_produces_leakage_flow() {
let builder = TestNetworkBuilder::new()
.reservoir("R1", 100.0)
.junction("J1", 0.0, 0.0)
.hw_pipe("P1", "R1", "J1", 1000.0, 12.0, 100.0);
let (mut net, mut ns, mut ls, _) = builder.build_with_favad();
if let LinkKind::Pipe(ref mut p) = net.links[0].kind {
p.leak_coeff_1 = 100.0;
p.leak_coeff_2 = 0.1;
}
let favad = net.compute_favad();
let mut ctx = build_solver_context(&net, &favad).unwrap();
let result =
solve_hydraulic_step(&net, &favad, &mut ctx, &mut ns, &mut ls, 0.0, no_pswitch).unwrap();
assert_eq!(result, SolveResult::Converged);
assert!(ns[1].leakage_flow > 0.0, "leakage should be positive");
assert!(ns[1].demand_flow.abs() < 1e-10);
assert!(ns[1].emitter_flow.abs() < 1e-10);
assert_relative_eq!(ls[0].flow, ns[1].leakage_flow, epsilon = 1e-6);
}
#[test]
fn pda_favad_combined_has_demand_and_leakage() {
let mut builder = TestNetworkBuilder::new();
builder.options_mut().demand_model = DemandModel::PressureDriven;
builder.options_mut().pda_min_pressure = 0.0;
builder.options_mut().pda_required_pressure = 30.0;
builder.options_mut().pda_pressure_exponent = 0.5;
let builder = builder
.reservoir("R1", 200.0)
.junction("J1", 50.0, 100.0)
.hw_pipe("P1", "R1", "J1", 1000.0, 12.0, 100.0);
let (mut net, mut ns, mut ls, _) = builder.build_with_favad();
if let LinkKind::Pipe(ref mut p) = net.links[0].kind {
p.leak_coeff_1 = 50.0;
p.leak_coeff_2 = 0.1;
}
let favad = net.compute_favad();
let mut ctx = build_solver_context(&net, &favad).unwrap();
let result =
solve_hydraulic_step(&net, &favad, &mut ctx, &mut ns, &mut ls, 0.0, no_pswitch).unwrap();
assert_eq!(result, SolveResult::Converged);
assert!(ns[1].demand_flow > 0.0);
assert!(ns[1].leakage_flow > 0.0);
assert_relative_eq!(
ls[0].flow,
ns[1].demand_flow + ns[1].leakage_flow,
epsilon = 1e-6
);
}
#[test]
fn emitter_pda_favad_combined_has_all_three_outflows() {
let mut builder = TestNetworkBuilder::new();
builder.options_mut().demand_model = DemandModel::PressureDriven;
builder.options_mut().pda_min_pressure = 0.0;
builder.options_mut().pda_required_pressure = 30.0;
builder.options_mut().pda_pressure_exponent = 0.5;
let builder = builder
.reservoir("R1", 200.0)
.junction_with_emitter("J1", 50.0, 100.0, 1.0)
.hw_pipe("P1", "R1", "J1", 1000.0, 12.0, 100.0);
let (mut net, mut ns, mut ls, _) = builder.build_with_favad();
if let LinkKind::Pipe(ref mut p) = net.links[0].kind {
p.leak_coeff_1 = 50.0;
p.leak_coeff_2 = 0.1;
}
let favad = net.compute_favad();
let mut ctx = build_solver_context(&net, &favad).unwrap();
let result =
solve_hydraulic_step(&net, &favad, &mut ctx, &mut ns, &mut ls, 0.0, no_pswitch).unwrap();
assert_eq!(result, SolveResult::Converged);
assert!(ns[1].demand_flow > 0.0);
assert!(ns[1].emitter_flow > 0.0);
assert!(ns[1].leakage_flow > 0.0);
assert_relative_eq!(
ls[0].flow,
ns[1].demand_flow + ns[1].emitter_flow + ns[1].leakage_flow,
epsilon = 1e-6
);
}
#[test]
fn fcv_prv_series_regulates_pressure() {
let (_ns, ls, result) = solve_once(
TestNetworkBuilder::new()
.junction("J1", 0.0, 0.0)
.junction("J2", 0.0, 0.0)
.junction("J3", 0.0, 0.0)
.junction("J4", 0.0, 0.0)
.reservoir("R1", 120.0)
.reservoir("R2", 60.0)
.hw_pipe("P1", "R1", "J1", 400.0, 12.0, 100.0)
.valve("FCV", "J1", "J2", ValveType::Fcv, 12.0, 2000.0)
.hw_pipe("P2", "J2", "J3", 200.0, 12.0, 100.0)
.valve("PRV", "J3", "J4", ValveType::Prv, 12.0, 30.0)
.hw_pipe("P3", "J4", "R2", 600.0, 12.0, 100.0),
);
assert_eq!(result, SolveResult::Converged);
assert!(
ls[1].flow.abs() > 1e-6,
"series FCV/PRV network should carry flow"
);
}
#[test]
fn prv_psv_series_enforces_both_constraints() {
let (ns, _ls, result) = solve_once(
TestNetworkBuilder::new()
.reservoir("R1", 200.0)
.junction("J1", 0.0, 20.0)
.junction("J2", 0.0, 10.0)
.junction("J3", 0.0, 10.0)
.junction("J4", 0.0, 15.0)
.hw_pipe("P1", "R1", "J1", 500.0, 12.0, 100.0)
.valve("PRV1", "J1", "J2", ValveType::Prv, 12.0, 50.0)
.hw_pipe("P2", "J2", "J3", 300.0, 12.0, 100.0)
.valve("PSV1", "J3", "J4", ValveType::Psv, 12.0, 30.0)
.hw_pipe("P3", "J4", "J1", 800.0, 12.0, 100.0),
);
assert_eq!(result, SolveResult::Converged);
let ucf = make_ucf(FlowUnits::Gpm, 1.0);
let prv_setting_m = 50.0 / ucf.pressure;
let psv_setting_m = 30.0 / ucf.pressure;
assert!(
ns[2].head <= prv_setting_m + 0.5,
"PRV downstream should be limited"
);
assert!(
ns[3].head >= psv_setting_m - 0.5,
"PSV upstream should be sustained"
);
}
#[test]
fn gpv_uses_headloss_curve() {
let builder = TestNetworkBuilder::new()
.reservoir("R1", 100.0)
.junction("J1", 0.0, 0.0)
.junction("J2", 0.0, 10.0)
.hw_pipe("P1", "R1", "J1", 300.0, 12.0, 100.0)
.valve("GPV", "J1", "J2", ValveType::Gpv, 12.0, 0.0)
.curve(
"HC1",
CurveKind::GpvHeadloss,
&[(0.0, 0.0), (50.0, 50.0), (150.0, 50.0)],
);
let (mut net, mut ns, mut ls, favad) = builder.build_with_favad();
if let LinkKind::Valve(ref mut v) = net.links[1].kind {
v.curve = Some("HC1".to_string());
}
let mut ctx = build_solver_context(&net, &favad).unwrap();
let result =
solve_hydraulic_step(&net, &favad, &mut ctx, &mut ns, &mut ls, 0.0, no_pswitch).unwrap();
assert_eq!(result, SolveResult::Converged);
assert!(ls[1].flow.abs() > 1e-6, "GPV should carry flow");
assert!(
ns[1].head > ns[2].head,
"GPV should impose headloss from J1 to J2"
);
}
#[test]
fn pcv_with_loss_curve_converges_and_carries_flow() {
let builder = TestNetworkBuilder::new()
.reservoir("R1", 200.0)
.reservoir("R2", 50.0)
.junction("J1", 0.0, 0.0)
.junction("J2", 0.0, 0.0)
.hw_pipe("P1", "R1", "J1", 1000.0, 12.0, 100.0)
.valve("PCV1", "J1", "J2", ValveType::Pcv, 12.0, 50.0)
.hw_pipe("P2", "J2", "R2", 1000.0, 12.0, 100.0)
.curve(
"LC1",
CurveKind::PcvLossRatio,
&[
(0.0, 0.0),
(25.0, 10.0),
(50.0, 40.0),
(75.0, 70.0),
(100.0, 100.0),
],
);
let (mut net, mut ns, mut ls, favad) = builder.build_with_favad();
if let LinkKind::Valve(ref mut v) = net.links[1].kind {
v.minor_loss = 5.0;
v.curve = Some("LC1".to_string());
}
let mut ctx = build_solver_context(&net, &favad).unwrap();
let result =
solve_hydraulic_step(&net, &favad, &mut ctx, &mut ns, &mut ls, 0.0, no_pswitch).unwrap();
assert_eq!(result, SolveResult::Converged);
assert!(ls[1].flow.abs() > 1e-8, "PCV should carry non-zero flow");
}
#[test]
fn flow_units_conversion_matrix_matches_internal_mass_balance() {
let cases: &[(FlowUnits, f64, f64, f64, f64, f64, f64)] = &[
(FlowUnits::Gpm, 500.0, 200.0, 300.0, 5000.0, 3000.0, 12.0),
(FlowUnits::Mgd, 1.0, 0.5, 300.0, 5000.0, 3000.0, 24.0),
(FlowUnits::Imgd, 1.0, 0.5, 300.0, 5000.0, 3000.0, 24.0),
(FlowUnits::Afd, 1.0, 0.5, 300.0, 5000.0, 3000.0, 24.0),
(FlowUnits::Cmd, 500.0, 200.0, 100.0, 1000.0, 800.0, 300.0),
(FlowUnits::Cmh, 5.0, 3.0, 100.0, 1000.0, 800.0, 200.0),
(FlowUnits::Cms, 0.5, 0.2, 100.0, 1000.0, 800.0, 600.0),
(FlowUnits::Lpm, 300.0, 150.0, 100.0, 1000.0, 800.0, 300.0),
(FlowUnits::Mld, 5.0, 2.0, 100.0, 1000.0, 800.0, 500.0),
(FlowUnits::Cfs, 10.0, 5.0, 300.0, 5000.0, 3000.0, 180.0),
];
for &(units, d1, d2, r_head, l1, l2, diam) in cases {
let options = SimulationOptions {
flow_units: units,
..Default::default()
};
let ucf = make_ucf(units, options.specific_gravity);
let expected_total = (d1 + d2) / ucf.flow;
let (ns, ls, result) = solve_once(
TestNetworkBuilder::new()
.with_options(options)
.reservoir("R1", r_head)
.junction("J1", 0.0, d1)
.junction("J2", 0.0, d2)
.hw_pipe("P1", "R1", "J1", l1, diam, 100.0)
.hw_pipe("P2", "J1", "J2", l2, diam, 100.0),
);
assert_eq!(result, SolveResult::Converged);
assert_relative_eq!(ls[0].flow, expected_total, epsilon = 1e-5);
assert_relative_eq!(
ns[1].demand_flow + ns[2].demand_flow,
expected_total,
epsilon = 1e-5
);
}
}
#[test]
fn closed_prv_status_blocks_valve_flow() {
let builder = TestNetworkBuilder::new()
.junction("J1", 0.0, 100.0)
.junction("J2", 0.0, 50.0)
.reservoir("R1", 100.0)
.hw_pipe("P1", "R1", "J1", 1000.0, 12.0, 100.0)
.hw_pipe("P2", "J1", "J2", 800.0, 10.0, 100.0)
.hw_pipe("P3", "R1", "J2", 1200.0, 10.0, 100.0)
.valve("V1", "J1", "J2", ValveType::Prv, 12.0, 50.0);
let (net, mut ns, mut ls, favad) = builder.build_with_favad();
ls[3].status = crate::LinkStatus::Closed;
let mut ctx = build_solver_context(&net, &favad).unwrap();
let result =
solve_hydraulic_step(&net, &favad, &mut ctx, &mut ns, &mut ls, 0.0, no_pswitch).unwrap();
assert_eq!(result, SolveResult::Converged);
assert!(ls[3].flow.abs() < 1e-8, "closed PRV should carry zero flow");
assert!(ls[2].flow.abs() > 1e-8, "alternate pipe should carry flow");
assert!(ns[1].demand_flow + ns[2].demand_flow > 0.0);
}
#[test]
fn max_iter_exceeded_returns_unbalanced() {
let mut builder = TestNetworkBuilder::new()
.reservoir("R1", 200.0)
.junction("J1", 0.0, 100.0)
.hw_pipe("P1", "R1", "J1", 1000.0, 12.0, 100.0);
builder.options_mut().max_iter = 1;
builder.options_mut().extra_iter = 0; builder.options_mut().flow_tol = 1e-20;
let (net, mut ns, mut ls, favad) = builder.build_with_favad();
let mut ctx = build_solver_context(&net, &favad).unwrap();
let result = solve_hydraulic_step(&net, &favad, &mut ctx, &mut ns, &mut ls, 0.0, no_pswitch)
.expect("should not error when extra_iter >= 0");
assert_eq!(result, SolveResult::Unbalanced);
}
#[test]
fn halt_on_non_convergence_returns_unbalanced_for_caller_to_handle() {
let mut builder = TestNetworkBuilder::new()
.reservoir("R1", 200.0)
.junction("J1", 0.0, 100.0)
.hw_pipe("P1", "R1", "J1", 1000.0, 12.0, 100.0);
builder.options_mut().max_iter = 1;
builder.options_mut().extra_iter = -1; builder.options_mut().flow_tol = 1e-20;
let (net, mut ns, mut ls, favad) = builder.build_with_favad();
let mut ctx = build_solver_context(&net, &favad).unwrap();
let result = solve_hydraulic_step(&net, &favad, &mut ctx, &mut ns, &mut ls, 0.0, no_pswitch)
.expect("solve_hydraulic_step should not Err on iteration overflow");
assert_eq!(result, SolveResult::Unbalanced);
}