Skip to main content

hydra_engine_wds/quality/
shared.rs

1use std::collections::VecDeque;
2
3/// Physical concentration ceiling (mg/L or equivalent). Used for clamping.
4pub(super) const C_MAX: f64 = 1.0e6;
5
6/// Stagnation threshold (m³/s). SI equivalent of EPANET's QZERO = 1.114e-5 ft³/s.
7/// Used to decide whether a link flow is stagnant for quality transport purposes (§6.3.1).
8pub(super) const Q_STAG: f64 = 3.154e-7;
9
10/// Returns the quality-engine flow direction for a given link flow: +1 positive,
11/// −1 negative, 0 stagnant (|q| < Q_STAG). Matches EPANET's FlowDir logic.
12pub(super) fn qual_flow_dir(q: f64) -> i8 {
13    if q.abs() < Q_STAG {
14        0
15    } else if q > 0.0 {
16        1
17    } else {
18        -1
19    }
20}
21
22/// A single Lagrangian parcel of water with uniform constituent concentration (§6.3).
23#[derive(Debug, Clone, Copy, PartialEq)]
24pub struct Segment {
25    /// Volume of this parcel (m³).
26    pub volume: f64,
27    /// Constituent concentration (mg/L for CHEMICAL; hours for AGE; % for TRACE).
28    pub concentration: f64,
29}
30
31/// Quality state for a single pipe (§6.3).
32#[derive(Debug, Clone)]
33pub struct PipeQuality {
34    pub segments: VecDeque<Segment>,
35    /// Last-known flow sign: `true` = positive (from_node → to_node).
36    pub flow_dir: i8,
37}
38
39/// Quality state for a single tank, keyed to its mixing model (§6.7).
40#[derive(Debug, Clone)]
41pub enum TankQuality {
42    Cstr {
43        volume: f64,
44        conc: f64,
45    },
46    TwoComp {
47        mix_vol: f64,
48        mix_conc: f64,
49        stag_vol: f64,
50        stag_conc: f64,
51    },
52    Fifo {
53        segments: VecDeque<Segment>,
54    },
55    Lifo {
56        segments: Vec<Segment>,
57    },
58}
59
60/// Errors returned by the quality engine.
61#[derive(Debug, PartialEq, Eq, Clone)]
62pub enum QualityError {
63    /// `run_quality` was called when the network's `quality_mode` is `None`.
64    ModeNone,
65}
66
67pub use crate::io::MassBalance;
68
69pub struct QualityState {
70    pub pipe_quality: Vec<Option<PipeQuality>>,
71    pub tank_quality: Vec<Option<TankQuality>>,
72    pub node_conc: Vec<f64>,
73    pub(super) node_links: Vec<Vec<usize>>,
74    pub(super) topo_order: Vec<usize>,
75    pub(super) adjacency: Vec<Vec<(usize, bool)>>,
76    pub(super) flow_dir: Vec<i8>,
77    pub(super) needs_topo: bool,
78    pub mass_balance: MassBalance,
79    pub pipe_rate_coeff: Vec<f64>,
80    pub(super) tank_overflows: Vec<bool>,
81}
82
83/// Returns the outflow concentration of a tank's quality state.
84pub(super) fn tank_outflow_conc(tq: &TankQuality) -> f64 {
85    match tq {
86        TankQuality::Cstr { conc, .. } => *conc,
87        TankQuality::TwoComp { mix_conc, .. } => *mix_conc,
88        TankQuality::Fifo { segments } => segments.front().map_or(0.0, |s| s.concentration),
89        TankQuality::Lifo { segments } => segments.last().map_or(0.0, |s| s.concentration),
90    }
91}
92
93/// §6.3.3 Pushes a new segment to the back (upstream end) of a pipe's deque.
94pub(super) fn push_segment_merge(segs: &mut VecDeque<Segment>, new: Segment, tol: f64) {
95    if tol > 0.0 {
96        if let Some(back) = segs.back_mut() {
97            if (back.concentration - new.concentration).abs() <= tol {
98                back.concentration = (back.concentration * back.volume
99                    + new.concentration * new.volume)
100                    / (back.volume + new.volume);
101                back.volume += new.volume;
102                return;
103            }
104        }
105    }
106    segs.push_back(new);
107}
108
109/// §6.9 Total constituent mass in all pipes and tanks.
110pub(super) fn total_mass(state: &QualityState) -> f64 {
111    let pipe_mass: f64 = state
112        .pipe_quality
113        .iter()
114        .flatten()
115        .flat_map(|pq| pq.segments.iter())
116        .map(|s| s.concentration * s.volume)
117        .sum();
118    let tank_mass: f64 = state
119        .tank_quality
120        .iter()
121        .flatten()
122        .map(|tq| match tq {
123            TankQuality::Cstr { volume, conc } => conc * volume,
124            TankQuality::TwoComp {
125                mix_vol,
126                mix_conc,
127                stag_vol,
128                stag_conc,
129            } => mix_conc * mix_vol + stag_conc * stag_vol,
130            TankQuality::Fifo { segments } => {
131                segments.iter().map(|s| s.concentration * s.volume).sum()
132            }
133            TankQuality::Lifo { segments } => {
134                segments.iter().map(|s| s.concentration * s.volume).sum()
135            }
136        })
137        .sum();
138    pipe_mass + tank_mass
139}
140
141// ── Tests ─────────────────────────────────────────────────────────────────────
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    // ── qual_flow_dir ─────────────────────────────────────────────────────────
148
149    #[test]
150    fn qual_flow_dir_stagnant_below_threshold() {
151        assert_eq!(qual_flow_dir(0.0), 0);
152        assert_eq!(qual_flow_dir(Q_STAG * 0.5), 0);
153        assert_eq!(qual_flow_dir(-Q_STAG * 0.5), 0);
154    }
155
156    #[test]
157    fn qual_flow_dir_positive_above_threshold() {
158        assert_eq!(qual_flow_dir(Q_STAG * 2.0), 1);
159        assert_eq!(qual_flow_dir(1.0), 1);
160    }
161
162    #[test]
163    fn qual_flow_dir_negative_above_threshold() {
164        assert_eq!(qual_flow_dir(-Q_STAG * 2.0), -1);
165        assert_eq!(qual_flow_dir(-1.0), -1);
166    }
167
168    // ── tank_outflow_conc ─────────────────────────────────────────────────────
169
170    #[test]
171    fn tank_outflow_conc_cstr_returns_conc() {
172        let tq = TankQuality::Cstr {
173            volume: 100.0,
174            conc: 0.5,
175        };
176        assert!((tank_outflow_conc(&tq) - 0.5).abs() < 1e-12);
177    }
178
179    #[test]
180    fn tank_outflow_conc_two_comp_returns_mix_zone() {
181        let tq = TankQuality::TwoComp {
182            mix_vol: 50.0,
183            mix_conc: 1.2,
184            stag_vol: 50.0,
185            stag_conc: 0.3,
186        };
187        assert!((tank_outflow_conc(&tq) - 1.2).abs() < 1e-12);
188    }
189
190    #[test]
191    fn tank_outflow_conc_fifo_returns_front() {
192        let mut segs = VecDeque::new();
193        segs.push_back(Segment {
194            volume: 10.0,
195            concentration: 0.7,
196        });
197        segs.push_back(Segment {
198            volume: 10.0,
199            concentration: 0.4,
200        });
201        let tq = TankQuality::Fifo { segments: segs };
202        assert!((tank_outflow_conc(&tq) - 0.7).abs() < 1e-12);
203    }
204
205    #[test]
206    fn tank_outflow_conc_lifo_returns_last() {
207        let tq = TankQuality::Lifo {
208            segments: vec![
209                Segment {
210                    volume: 10.0,
211                    concentration: 0.1,
212                },
213                Segment {
214                    volume: 10.0,
215                    concentration: 0.9,
216                },
217            ],
218        };
219        assert!((tank_outflow_conc(&tq) - 0.9).abs() < 1e-12);
220    }
221
222    // ── push_segment_merge ────────────────────────────────────────────────────
223
224    #[test]
225    fn push_segment_merge_appends_when_outside_tolerance() {
226        let mut segs = VecDeque::new();
227        segs.push_back(Segment {
228            volume: 1.0,
229            concentration: 0.0,
230        });
231        push_segment_merge(
232            &mut segs,
233            Segment {
234                volume: 2.0,
235                concentration: 1.0,
236            },
237            0.01,
238        );
239        assert_eq!(segs.len(), 2);
240        assert!((segs.back().unwrap().concentration - 1.0).abs() < 1e-12);
241    }
242
243    #[test]
244    fn push_segment_merge_merges_when_within_tolerance() {
245        let mut segs = VecDeque::new();
246        segs.push_back(Segment {
247            volume: 2.0,
248            concentration: 1.0,
249        });
250        // New segment with concentration 1.005 — within tol=0.01.
251        push_segment_merge(
252            &mut segs,
253            Segment {
254                volume: 2.0,
255                concentration: 1.005,
256            },
257            0.01,
258        );
259        assert_eq!(segs.len(), 1);
260        let merged = segs.back().unwrap();
261        assert!((merged.volume - 4.0).abs() < 1e-12);
262        // Weighted average: (1.0*2 + 1.005*2)/4 = 1.0025.
263        assert!((merged.concentration - 1.0025).abs() < 1e-12);
264    }
265
266    #[test]
267    fn push_segment_merge_zero_tol_always_appends() {
268        let mut segs = VecDeque::new();
269        segs.push_back(Segment {
270            volume: 1.0,
271            concentration: 1.0,
272        });
273        push_segment_merge(
274            &mut segs,
275            Segment {
276                volume: 1.0,
277                concentration: 1.0,
278            },
279            0.0,
280        );
281        // tol=0 → always append even if identical.
282        assert_eq!(segs.len(), 2);
283    }
284}