Skip to main content

hydra_engine_wds/model/
network.rs

1use std::collections::HashMap;
2
3// ── §2.1 Scalar parameters ────────────────────────────────────────────────────
4
5/// Head-loss formula used by the hydraulic solver (§2.1).
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum HeadLossFormula {
8    /// Hazen-Williams empirical formula (default).
9    HazenWilliams,
10    /// Darcy-Weisbach mechanistic formula.
11    DarcyWeisbach,
12    /// Chezy-Manning formula.
13    ChezyManning,
14}
15
16/// Demand model: demand-driven (DDA) or pressure-driven (PDA) (§2.1).
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum DemandModel {
19    /// Demand-driven analysis: demands are always fully satisfied regardless of pressure.
20    DemandDriven,
21    /// Pressure-driven analysis: delivered demand scales with available pressure.
22    PressureDriven,
23}
24
25/// Named user-facing flow unit variant (spec.md §3).
26///
27/// Identifies the scalar applied at the input/output boundary. Does not affect
28/// the internal unit system or which formula constants are used.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum FlowUnits {
31    // US customary group (ft, ft³/s base)
32    /// Cubic feet per second.
33    Cfs,
34    /// Gallons per minute.
35    Gpm,
36    /// Million gallons per day.
37    Mgd,
38    /// Imperial million gallons per day.
39    Imgd,
40    /// Acre-feet per day.
41    Afd,
42    // SI/metric group (m, m³/s base)
43    /// Litres per second.
44    Lps,
45    /// Litres per minute.
46    Lpm,
47    /// Megalitres per day.
48    Mld,
49    /// Cubic metres per hour.
50    Cmh,
51    /// Cubic metres per day.
52    Cmd,
53    /// Cubic metres per second.
54    Cms,
55}
56
57/// Deterministic advisory effort category for compute-heavy operations.
58///
59/// Returned by the simulation and analysis runtime estimators. The estimate
60/// is advisory only — it does not alter solver behaviour, time-step selection,
61/// or analysis algorithms. For identical inputs the output is always the same
62/// (`Low` < `Medium` < `High` is a total order).
63#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
64pub enum RuntimeEstimate {
65    #[default]
66    /// Expected wall-clock time < ~600 ms.
67    Low,
68    /// Expected wall-clock time ~600 ms – 3 s.
69    Medium,
70    /// Expected wall-clock time > ~3 s.
71    High,
72}
73
74/// Water quality simulation mode (§2.1).
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum QualityMode {
77    /// No quality simulation — quality engine is skipped entirely.
78    None,
79    /// Dissolved-constituent transport: concentration (mg/L) is advected,
80    /// mixed, and subject to bulk and wall reactions (§6.5). Sources inject
81    /// via `CONCENTRATION`, `MASS`, `SETPOINT`, or `FLOWPACED` types.
82    Chemical,
83    /// Water-age tracking: the "concentration" is residence time (hours).
84    /// Incremented by δt/3600 at every quality sub-step; reservoirs hold
85    /// age = 0. No reactions. Sources are implicit everywhere.
86    Age,
87    /// Source-trace analysis: fraction of flow (%) originating from the
88    /// designated `trace_node`. That node is a 100 % source; all other
89    /// fixed-grade inflows inject 0 %. No reactions.
90    Trace,
91}
92
93/// Wall reaction order: zero-order or first-order (§2.1).
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum WallOrder {
96    /// Zero-order wall reaction.
97    Zero,
98    /// First-order wall reaction.
99    One,
100}
101
102/// Report output statistic aggregation type (from `TIMES` `STATISTIC`).
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
104pub enum StatisticType {
105    /// Report all timesteps (default).
106    #[default]
107    Series,
108    /// Report time-averaged values.
109    Average,
110    /// Report minimum values.
111    Minimum,
112    /// Report maximum values.
113    Maximum,
114    /// Report max − min.
115    Range,
116}
117
118/// Status reporting level in the `[REPORT]` section.
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
120pub enum ReportStatus {
121    /// No status reporting.
122    #[default]
123    No,
124    /// Report status changes only.
125    Yes,
126    /// Report all solver iterations.
127    Full,
128}
129
130/// Selection of nodes or links for reporting.
131#[derive(Debug, Clone, PartialEq, Default)]
132pub enum ReportSelection {
133    /// No items reported (default).
134    #[default]
135    None,
136    /// All items reported.
137    All,
138    /// Specific items by ID.
139    Some(Vec<String>),
140}
141
142/// Per-field reporting options from the `[REPORT]` section.
143#[derive(Debug, Clone)]
144pub struct ReportFieldOption {
145    /// Whether this field is included in the `.rpt` output.
146    pub enabled: bool,
147    /// Optional decimal precision override for this field.
148    pub precision: Option<u32>,
149    /// Optional lower-bound threshold filter for this field.
150    pub above: Option<f64>,
151    /// Optional upper-bound threshold filter for this field.
152    pub below: Option<f64>,
153}
154
155/// Options from the `[REPORT]` INP section. Controls `.rpt` file output
156/// formatting; does not affect simulation results.
157#[derive(Debug, Clone)]
158pub struct ReportOptions {
159    /// Lines per report page (0 = no page breaks); default 0.
160    pub page_size: u32,
161    /// Solver status reporting level.
162    pub status: ReportStatus,
163    /// Whether to print input/output summary.
164    pub summary: bool,
165    /// Whether to print warning messages.
166    pub messages: bool,
167    /// Whether to print energy report.
168    pub energy: bool,
169    /// Which nodes to include in the report.
170    pub nodes: ReportSelection,
171    /// Which links to include in the report.
172    pub links: ReportSelection,
173    /// Alternate report output filename.
174    pub file: Option<String>,
175    /// Per-field formatting options (field name → options).
176    pub fields: HashMap<String, ReportFieldOption>,
177}
178
179impl Default for ReportOptions {
180    fn default() -> Self {
181        ReportOptions {
182            page_size: 0,
183            status: ReportStatus::No,
184            summary: true,
185            messages: true,
186            energy: false,
187            nodes: ReportSelection::None,
188            links: ReportSelection::None,
189            file: None,
190            fields: HashMap::new(),
191        }
192    }
193}
194
195/// Global simulation parameters (§2.1). All fields are static after loading.
196///
197/// `SimulationOptions::default()` returns the canonical default values defined
198/// in §2.1.  Callers typically construct this with `..SimulationOptions::default()`
199/// and override only the fields that differ from the spec defaults.
200#[derive(Debug, Clone)]
201pub struct SimulationOptions {
202    /// Total simulation duration (s); 0 = single steady-state step.
203    pub duration: f64,
204    /// Hydraulic time step Δt_h (s); default 3600.
205    pub hyd_step: f64,
206    /// Quality time step δt_q (s); must satisfy 0 < δt_q ≤ hyd_step.
207    pub qual_step: f64,
208    /// Interval at which results are written to the output file (s); default 3600.
209    pub report_step: f64,
210    /// Simulation time at which reporting begins (s); default 0.
211    pub report_start: f64,
212    /// Pattern time step Δt_p (s); default 3600.
213    pub pattern_step: f64,
214    /// Time offset for pattern evaluation (s from midnight); default 0.
215    pub pattern_start: f64,
216    /// Wall-clock time of simulation t=0 (s from midnight).
217    pub start_clocktime: f64,
218    /// Head-loss formula used for pipes.
219    pub head_loss_formula: HeadLossFormula,
220    /// Demand allocation model (DDA or PDA).
221    pub demand_model: DemandModel,
222    /// Flow unit system used for input/output (does not affect internal solver).
223    pub flow_units: FlowUnits,
224    /// Kinematic viscosity of water (m²/s); default ≈ 1.022×10⁻⁶.
225    pub viscosity: f64,
226    /// Molecular diffusivity of the tracer chemical (m²/s); default ≈ 1.208×10⁻⁹.
227    pub diffusivity: f64,
228    /// Specific gravity of the fluid relative to water; default 1.0.
229    pub specific_gravity: f64,
230    /// Global demand multiplier applied to all base demands; default 1.0.
231    pub demand_multiplier: f64,
232    /// Pattern ID applied to demand categories with no explicit pattern;
233    /// `None` means a multiplier of 1.0 is used.
234    pub default_pattern: Option<String>,
235    /// PDA minimum pressure — below this, delivered demand is 0 (m).
236    pub pda_min_pressure: f64,
237    /// PDA required pressure — at or above this, full demand is delivered (m).
238    pub pda_required_pressure: f64,
239    /// PDA pressure exponent n in the Wagner equation; default 0.5.
240    pub pda_pressure_exponent: f64,
241    /// Whether emitters allow reverse flow (backflow into the network).
242    pub emitter_backflow: bool,
243    /// Water quality simulation mode.
244    pub quality_mode: QualityMode,
245    /// Node ID for source tracing; required when `quality_mode = Trace`.
246    pub trace_node: Option<String>,
247    /// Chemical species name (e.g. "Chlorine"); from `QUALITY Chemical <name>`.
248    pub chem_name: String,
249    /// Chemical concentration units (e.g. "mg/L"); from `QUALITY Chemical <name> <units>`.
250    pub chem_units: String,
251    /// Maximum Newton-Raphson iterations; default 200.
252    pub max_iter: u32,
253    /// Extra frozen-status iterations on non-convergence; −1 = halt; default −1.
254    pub extra_iter: i32,
255    /// Head tolerance εH for link status transitions (m); default 1.524×10⁻⁴.
256    pub head_tol: f64,
257    /// Absolute flow tolerance εQ for link status transition tests (m³/s); default 2.832×10⁻⁶.
258    /// Distinct from `flow_tol`: `flow_tol` governs solver convergence (relative criterion);
259    /// `flow_change_tol` governs link status transition conditions only.
260    pub flow_change_tol: f64,
261    /// Relative flow accuracy (Hacc) for solver convergence; default 0.001.
262    pub flow_tol: f64,
263    /// Absolute per-link head balance error limit (m); 0 = disabled; default 0.
264    pub head_error_limit: f64,
265    /// Absolute maximum flow change per iteration (m³/s); 0 = disabled; default 0.
266    pub flow_change_limit: f64,
267    /// Minimum gradient clamp for emitter/pump coefficient linearisation; default 1e-7.
268    pub rq_tol: f64,
269    /// Relative flow accuracy threshold below which damping activates; 0 = disabled.
270    pub damp_limit: f64,
271    /// Status check interval (iterations); default 2.
272    pub check_freq: u32,
273    /// Iteration count after which status checks stop; default 10.
274    pub max_check: u32,
275    /// Bulk reaction order for pipes; default 1.0.
276    pub bulk_order: f64,
277    /// Bulk reaction order for tanks; default 1.0.
278    pub tank_order: f64,
279    /// Wall reaction order (zero or first); default first.
280    pub wall_order: WallOrder,
281    /// Global bulk reaction rate coefficient (1/day for first-order).
282    pub bulk_coeff: f64,
283    /// Global wall reaction rate coefficient (m/day for first-order).
284    pub wall_coeff: f64,
285    /// Limiting concentration for reactions (mg/L); 0 = no limit.
286    pub conc_limit: f64,
287    /// Global unit energy cost ($/kWh).
288    pub energy_price: f64,
289    /// Pattern ID modulating the energy price over time.
290    pub energy_price_pattern: Option<String>,
291    /// Global default pump efficiency fraction.
292    pub energy_efficiency: f64,
293    /// Global demand charge (cost per peak kW); 0 = disabled.
294    pub peak_demand_charge: f64,
295    /// Roughness–reaction correlation factor Rf; 0 = disabled.
296    pub roughness_reaction_factor: f64,
297    /// Rule evaluation sub-step duration (s).
298    pub rule_timestep: f64,
299    /// Segment merge tolerance Ctol; default 0.01.
300    pub quality_tolerance: f64,
301    /// Report statistic aggregation type (from `TIMES` `STATISTIC`).
302    pub statistic: StatisticType,
303}
304
305impl Default for SimulationOptions {
306    /// Returns the canonical default values from §2.1.
307    fn default() -> Self {
308        // qual_step default = hyd_step / 10; we use 3600 s nominal hyd_step
309        // so qual_step = 360 s, which satisfies the [1, hyd_step] constraint.
310        // Callers that set a different hyd_step must also update qual_step.
311        let hyd_step: f64 = 3600.0;
312        let qual_step: f64 = (hyd_step / 10.0).clamp(1.0, hyd_step);
313        let rule_timestep: f64 = (hyd_step / 10.0).clamp(f64::MIN_POSITIVE, hyd_step);
314        SimulationOptions {
315            duration: 0.0,
316            hyd_step,
317            qual_step,
318            report_step: 3600.0,
319            report_start: 0.0,
320            pattern_step: 3600.0,
321            pattern_start: 0.0,
322            start_clocktime: 0.0,
323            head_loss_formula: HeadLossFormula::HazenWilliams,
324            demand_model: DemandModel::DemandDriven,
325            flow_units: FlowUnits::Gpm,
326            viscosity: 1.022e-6,
327            diffusivity: 1.208e-9,
328            specific_gravity: 1.0,
329            demand_multiplier: 1.0,
330            default_pattern: None,
331            pda_min_pressure: 0.0,
332            pda_required_pressure: 0.0,
333            pda_pressure_exponent: 0.5,
334            emitter_backflow: true,
335            quality_mode: QualityMode::None,
336            trace_node: None,
337            chem_name: String::new(),
338            chem_units: String::new(),
339            max_iter: 200,
340            extra_iter: -1,
341            head_tol: 1.524e-4,
342            flow_change_tol: 2.832e-6,
343            flow_tol: 0.001,
344            head_error_limit: 0.0,
345            flow_change_limit: 0.0,
346            rq_tol: 1.0e-7,
347            damp_limit: 0.0,
348            check_freq: 2,
349            max_check: 10,
350            bulk_order: 1.0,
351            tank_order: 1.0,
352            wall_order: WallOrder::One,
353            bulk_coeff: 0.0,
354            wall_coeff: 0.0,
355            conc_limit: 0.0,
356            energy_price: 0.0,
357            energy_price_pattern: None,
358            energy_efficiency: 0.75,
359            peak_demand_charge: 0.0,
360            roughness_reaction_factor: 0.0,
361            rule_timestep,
362            quality_tolerance: 0.01,
363            statistic: StatisticType::Series,
364        }
365    }
366}
367
368// ── §2.2 Patterns ─────────────────────────────────────────────────────────────
369
370/// A repeating sequence of dimensionless multipliers (§2.2). Static.
371#[derive(Debug, Clone)]
372pub struct Pattern {
373    /// String identifier for this pattern.
374    pub id: String,
375    /// Multipliers [F₀, F₁, …, F_{L−1}]. Length ≥ 1.
376    pub factors: Vec<f64>,
377}
378
379impl Pattern {
380    /// Returns the multiplier active at simulation time `t` (seconds).
381    ///
382    /// Implements §2.2: $p = \lfloor (t + t_{\text{start}}) / \Delta t_p \rfloor$,
383    /// active multiplier = $F[p \bmod L]$.
384    ///
385    /// `pattern_step` and `pattern_start` come from `SimulationOptions`.
386    pub fn eval(&self, t: f64, pattern_step: f64, pattern_start: f64) -> f64 {
387        let p = ((t + pattern_start) / pattern_step).floor() as i64;
388        let l = self.factors.len() as i64;
389        // Use rem_euclid so negative t values wrap correctly.
390        let idx = p.rem_euclid(l) as usize;
391        self.factors[idx]
392    }
393}
394
395// ── §2.3 Curves ───────────────────────────────────────────────────────────────
396
397/// Semantic kind of a piecewise-linear curve (§2.3).
398#[derive(Debug, Clone, Copy, PartialEq, Eq)]
399pub enum CurveKind {
400    /// A curve not yet assigned to a specific usage. Skips kind-specific
401    /// validation (y-monotonicity, range checks) since its purpose is unknown.
402    Generic,
403    /// Pump head vs. flow curve.
404    PumpHead,
405    /// Pump efficiency vs. flow curve.
406    PumpEfficiency,
407    /// Constant-HP pump volume curve.
408    PumpVolume,
409    /// Tank volume vs. level curve.
410    TankVolume,
411    /// General Purpose Valve headloss vs. flow curve.
412    GpvHeadloss,
413    /// Positional Control Valve loss ratio vs. flow curve.
414    PcvLossRatio,
415}
416
417/// A single (x, y) sample point on a curve.
418#[derive(Debug, Clone, Copy, PartialEq)]
419pub struct CurvePoint {
420    /// The independent-axis value (e.g. flow rate for a pump head curve).
421    pub x: f64,
422    /// The dependent-axis value (e.g. head gain for a pump head curve).
423    pub y: f64,
424}
425
426/// A piecewise-linear mapping from x to y (§2.3). Static.
427///
428/// `points` has x strictly increasing and length ≥ 2.
429#[derive(Debug, Clone)]
430pub struct Curve {
431    /// String identifier for this curve.
432    pub id: String,
433    /// Semantic kind, used to select validation rules.
434    pub kind: CurveKind,
435    /// Ordered sample points with strictly increasing x-values. Length ≥ 2.
436    pub points: Vec<CurvePoint>,
437}
438
439impl Curve {
440    /// Evaluates the curve at `x` using piecewise-linear interpolation.
441    ///
442    /// Implements §2.3: when `x` is inside the range the bracketing segment is
443    /// used directly.  When `x` is outside the range the nearest endpoint
444    /// segment is extended linearly (extrapolation).
445    ///
446    /// For single-point curves, returns the y-value of that point (constant).
447    pub fn eval(&self, x: f64) -> f64 {
448        let pts = &self.points;
449        if pts.len() == 1 {
450            return pts[0].y;
451        }
452        // Below the first point — extrapolate from the first segment.
453        if x <= pts[0].x {
454            let p0 = &pts[0];
455            let p1 = &pts[1];
456            let dx = p1.x - p0.x;
457            debug_assert!(dx > 0.0, "curve points must have strictly increasing x");
458            return p0.y + (p1.y - p0.y) * (x - p0.x) / dx;
459        }
460        // Above the last point — extrapolate from the last segment.
461        let last = pts.len() - 1;
462        if x >= pts[last].x {
463            let p0 = &pts[last - 1];
464            let p1 = &pts[last];
465            let dx = p1.x - p0.x;
466            debug_assert!(dx > 0.0, "curve points must have strictly increasing x");
467            return p0.y + (p1.y - p0.y) * (x - p0.x) / dx;
468        }
469        // Interior — binary search for the bracketing segment [k-1, k].
470        let k = pts.partition_point(|p| p.x <= x);
471        // k is the first index where pts[k].x > x, so the bracket is [k-1, k].
472        let p0 = &pts[k - 1];
473        let p1 = &pts[k];
474        p0.y + (p1.y - p0.y) * (x - p0.x) / (p1.x - p0.x)
475    }
476}
477
478// ── §2.5 Demand categories ────────────────────────────────────────────────────
479
480/// A single demand category attached to a junction (§2.5).
481#[derive(Debug, Clone)]
482pub struct DemandCategory {
483    /// Base demand flow rate (internal m³/s); multiplied by pattern and global multiplier.
484    pub base_demand: f64,
485    /// Pattern ID; `None` falls back to the global default pattern (§2.1).
486    pub pattern: Option<String>,
487    /// Optional descriptive name from `[DEMANDS]`.
488    pub name: Option<String>,
489}
490
491impl Junction {
492    /// Total instantaneous demand at time `t` (§2.5).
493    ///
494    /// $D_i(t) = \sum_k \text{base}_k \times D_{\text{mult}} \times F_{\text{pattern}}(t)$
495    ///
496    /// The pattern lookup procedure:
497    /// 1. Use the demand category's own pattern ID if set.
498    /// 2. Otherwise fall back to `default_pattern` from options (if set).
499    /// 3. Otherwise use multiplier 1.0.
500    pub fn total_demand(
501        &self,
502        t: f64,
503        opts: &SimulationOptions,
504        patterns: &[Pattern],
505        pattern_index: &HashMap<String, usize>,
506    ) -> f64 {
507        let lookup = |id: &str| pattern_index.get(id).map(|&i| &patterns[i]);
508        self.demands
509            .iter()
510            .map(|d| {
511                let multiplier = d
512                    .pattern
513                    .as_deref()
514                    .or(opts.default_pattern.as_deref())
515                    .and_then(lookup)
516                    .map_or(1.0, |pat| {
517                        pat.eval(t, opts.pattern_step, opts.pattern_start)
518                    });
519                d.base_demand * opts.demand_multiplier * multiplier
520            })
521            .sum()
522    }
523}
524
525// ── §2.7 Quality sources ──────────────────────────────────────────────────────
526
527/// Quality source injection type (§2.7).
528#[derive(Debug, Clone, Copy, PartialEq, Eq)]
529pub enum SourceType {
530    /// Injects at a fixed concentration (mg/L).
531    Concentration,
532    /// Injects at a fixed mass flow rate (mg/min).
533    Mass,
534    /// Overrides node concentration with a fixed setpoint (mg/L).
535    Setpoint,
536    /// Scales concentration proportional to outflow from node (mg/L applied to outflow).
537    FlowPaced,
538}
539
540/// A quality source attached to a node (§2.7).
541#[derive(Debug, Clone)]
542pub struct QualitySource {
543    /// 1-based node index.
544    pub node: usize,
545    /// Injection type (concentration, mass, setpoint, or flow-paced).
546    pub kind: SourceType,
547    /// Base injection value before pattern scaling.
548    pub base_value: f64,
549    /// Optional pattern ID modulating injection over time.
550    pub pattern: Option<String>,
551}
552
553impl QualitySource {
554    /// Effective injection value at time `t` (§2.7).
555    ///
556    /// = `base_value` × $F_{\text{pattern}}(t)$, or `base_value` if no pattern.
557    pub fn effective_value(
558        &self,
559        t: f64,
560        opts: &SimulationOptions,
561        patterns: &[Pattern],
562        pattern_index: &HashMap<String, usize>,
563    ) -> f64 {
564        let multiplier = self
565            .pattern
566            .as_deref()
567            .and_then(|id| pattern_index.get(id).map(|&i| &patterns[i]))
568            .map_or(1.0, |pat| {
569                pat.eval(t, opts.pattern_step, opts.pattern_start)
570            });
571        self.base_value * multiplier
572    }
573}
574
575// ── §2.4 Nodes ────────────────────────────────────────────────────────────────
576
577/// Properties common to all node types (§2.4.1). Static.
578#[derive(Debug, Clone)]
579pub struct NodeBase {
580    /// String identifier for this node.
581    pub id: String,
582    /// 1-based index assigned at load time.
583    pub index: usize,
584    /// Node elevation (internal length units, m).
585    pub elevation: f64,
586    /// Initial water quality concentration (mg/L, h, or % depending on mode).
587    pub initial_quality: f64,
588}
589
590/// An ordinary demand node whose head is solved at each hydraulic step (§2.4.2).
591#[derive(Debug, Clone)]
592pub struct Junction {
593    /// Demand categories attached to this junction (§2.5).
594    pub demands: Vec<DemandCategory>,
595    /// Emitter discharge coefficient Ke (m³/s per m^ne); 0 = no emitter.
596    pub emitter_coeff: f64,
597    /// Emitter pressure exponent ne; default 0.5.
598    pub emitter_exp: f64,
599}
600
601/// A fixed-grade node whose head is known at all times (§2.4.3).
602#[derive(Debug, Clone)]
603pub struct Reservoir {
604    /// Optional pattern ID modulating head over time.
605    pub head_pattern: Option<String>,
606}
607
608impl Reservoir {
609    /// Hydraulic head at time `t` (§2.4.3).
610    ///
611    /// If a `head_pattern` is set, $H = \text{elevation} \times F_{\text{pattern}}(t)$;
612    /// otherwise $H = \text{elevation}$.
613    pub fn head(
614        &self,
615        elevation: f64,
616        t: f64,
617        opts: &SimulationOptions,
618        patterns: &[Pattern],
619        pattern_index: &HashMap<String, usize>,
620    ) -> f64 {
621        let multiplier = self
622            .head_pattern
623            .as_deref()
624            .and_then(|id| pattern_index.get(id).map(|&i| &patterns[i]))
625            .map_or(1.0, |pat| {
626                pat.eval(t, opts.pattern_step, opts.pattern_start)
627            });
628        elevation * multiplier
629    }
630}
631
632/// Tank mixing model (§2.4.4).
633#[derive(Debug, Clone, Copy, PartialEq, Eq)]
634pub enum MixModel {
635    /// Completely mixed (single compartment).
636    Cstr,
637    /// Two-compartment mixing.
638    TwoCompartment,
639    /// First-in first-out plug flow.
640    Fifo,
641    /// Last-in first-out.
642    Lifo,
643}
644
645/// A storage node whose head evolves over time (§2.4.4).
646#[derive(Debug, Clone)]
647pub struct Tank {
648    /// Minimum operating level above bottom elevation (m).
649    pub min_level: f64,
650    /// Maximum operating level above bottom elevation (m).
651    pub max_level: f64,
652    /// Initial water level above bottom elevation (m).
653    pub initial_level: f64,
654    /// Diameter of cylindrical tank (m); used when `vol_curve` is `None`.
655    pub diameter: f64,
656    /// Explicit minimum volume (m³); if > 0, overrides the value computed
657    /// from `diameter` and `min_level`.
658    pub min_volume: f64,
659    /// Optional volume curve ID (kind = `TankVolume`).
660    pub volume_curve: Option<String>,
661    /// Water mixing model used inside this tank.
662    pub mix_model: MixModel,
663    /// Inlet-zone volume fraction; meaningful only for `TwoCompartment`.
664    pub mix_fraction: f64,
665    /// Bulk reaction rate coefficient (overrides global).
666    pub bulk_coeff: f64,
667    /// Whether tank can overflow (spill above `max_level`).
668    pub overflow: bool,
669    /// Optional pattern ID modulating head over time for fixed-head operation.
670    pub head_pattern: Option<String>,
671}
672
673impl Tank {
674    /// Bottom elevation = node elevation − `min_level` (§2.4.4).
675    pub fn bottom_elevation(&self, node_elevation: f64) -> f64 {
676        node_elevation - self.min_level
677    }
678
679    /// Hydraulic head from the current level (§2.4.4).
680    ///
681    /// $H = \text{bottom\_elevation} + \text{level}$
682    pub fn head_from_level(&self, node_elevation: f64, level: f64) -> f64 {
683        self.bottom_elevation(node_elevation) + level
684    }
685
686    /// Cross-section area $A$ at the given level (§2.4.4).
687    ///
688    /// - Cylindrical tank: $A = \pi d^2 / 4$ (constant).
689    /// - Volume-curve tank: $A(h) = dV/dh$ approximated at `level` via the
690    ///   finite-difference slope of the two bracketing curve points.
691    ///
692    /// `curves` is the full network curve table; `vol_curve` is resolved by ID.
693    pub fn area(&self, level: f64, curves: &[Curve]) -> f64 {
694        if let Some(ref curve_id) = self.volume_curve {
695            if let Some(curve) = curves.iter().find(|c| c.id == *curve_id) {
696                return Self::area_from_volume_curve(curve, level);
697            }
698        }
699        // Cylindrical fallback.
700        std::f64::consts::PI * self.diameter * self.diameter / 4.0
701    }
702
703    /// Computes $A(h) = dV/dh$ from a `TankVolume` curve.
704    ///
705    /// Uses the slope of the bracketing segment (same interpolation as §2.3),
706    /// clamped to the slope of the nearest endpoint segment when outside range.
707    fn area_from_volume_curve(curve: &Curve, level: f64) -> f64 {
708        let pts = &curve.points;
709        // Below the first point — use slope of the first segment.
710        if level <= pts[0].x {
711            let dx = pts[1].x - pts[0].x;
712            return (pts[1].y - pts[0].y) / dx;
713        }
714        let last = pts.len() - 1;
715        // Above the last point — use slope of the last segment.
716        if level >= pts[last].x {
717            let dx = pts[last].x - pts[last - 1].x;
718            return (pts[last].y - pts[last - 1].y) / dx;
719        }
720        // Interior — binary search for the bracketing segment [k-1, k].
721        let k = pts.partition_point(|p| p.x <= level);
722        let dx = pts[k].x - pts[k - 1].x;
723        (pts[k].y - pts[k - 1].y) / dx
724    }
725
726    /// Volume corresponding to `level` using the volume curve (§2.4.4),
727    /// or the cylindrical approximation when no curve is present.
728    pub fn volume_from_level(&self, level: f64, curves: &[Curve]) -> f64 {
729        if let Some(ref curve_id) = self.volume_curve {
730            if let Some(curve) = curves.iter().find(|c| c.id == *curve_id) {
731                return curve.eval(level);
732            }
733        }
734        // Cylindrical.
735        let a = std::f64::consts::PI * self.diameter * self.diameter / 4.0;
736        a * level
737    }
738
739    /// Level corresponding to `volume` (inverse of `volume_from_level`).
740    ///
741    /// For cylindrical tanks: $h = V / A$.
742    /// For volume-curve tanks: binary-search the curve for the bracketing
743    /// segment and invert the linear segment.
744    pub fn level_from_volume(&self, volume: f64, curves: &[Curve]) -> f64 {
745        if let Some(ref curve_id) = self.volume_curve {
746            if let Some(curve) = curves.iter().find(|c| c.id == *curve_id) {
747                return Self::invert_volume_curve(curve, volume);
748            }
749        }
750        // Cylindrical.
751        let a = std::f64::consts::PI * self.diameter * self.diameter / 4.0;
752        volume / a
753    }
754
755    /// Inverts a `TankVolume` curve: given a volume, returns the level.
756    fn invert_volume_curve(curve: &Curve, volume: f64) -> f64 {
757        let pts = &curve.points;
758        // Below minimium volume — extrapolate from first segment.
759        if volume <= pts[0].y {
760            let dv = pts[1].y - pts[0].y;
761            if dv == 0.0 {
762                return pts[0].x;
763            }
764            return pts[0].x + (pts[1].x - pts[0].x) * (volume - pts[0].y) / dv;
765        }
766        let last = pts.len() - 1;
767        // Above maximum volume — extrapolate from last segment.
768        if volume >= pts[last].y {
769            let dv = pts[last].y - pts[last - 1].y;
770            if dv == 0.0 {
771                return pts[last].x;
772            }
773            return pts[last - 1].x
774                + (pts[last].x - pts[last - 1].x) * (volume - pts[last - 1].y) / dv;
775        }
776        // Interior — find bracketing segment by y (volume), then invert.
777        let k = pts.partition_point(|p| p.y <= volume);
778        let dv = pts[k].y - pts[k - 1].y;
779        if dv == 0.0 {
780            return pts[k - 1].x;
781        }
782        pts[k - 1].x + (pts[k].x - pts[k - 1].x) * (volume - pts[k - 1].y) / dv
783    }
784}
785
786/// Type-specific data for a node.
787#[derive(Debug, Clone)]
788pub enum NodeKind {
789    /// Demand node.
790    Junction(Junction),
791    /// Fixed-grade boundary node.
792    Reservoir(Reservoir),
793    /// Variable-level storage node.
794    Tank(Tank),
795}
796
797/// A node in the network graph (§2.4).
798#[derive(Debug, Clone)]
799pub struct Node {
800    /// Static properties common to all node types.
801    pub base: NodeBase,
802    /// Type-specific static data.
803    pub kind: NodeKind,
804    /// At most one quality source per node (§2.7).
805    pub source: Option<QualitySource>,
806}
807
808// ── §2.6 Links ────────────────────────────────────────────────────────────────
809
810/// Operational status of a link (§2.6.1 and §3.9).
811///
812/// `XPressure` and `XFcv` are computed states (per-step only);
813/// they are not valid as `init_status` values.
814#[derive(Debug, Clone, Copy, PartialEq, Eq)]
815pub enum LinkStatus {
816    /// Fully open.
817    Open,
818    /// Fully closed.
819    Closed,
820    /// Actively controlled (valves).
821    Active,
822    /// PRV/PSV: reverse pressure gradient present (computed state only).
823    XPressure,
824    /// FCV: flow setpoint cannot be enforced (computed state only).
825    XFcv,
826    /// Pump: head gain required exceeds shutoff head (computed state only; §3.9).
827    XHead,
828    /// Pump: constant-HP pump with Q ≤ 0 (computed state only; §3.9).
829    TempClosed,
830}
831
832/// Properties common to all link types (§2.6.1). Static.
833#[derive(Debug, Clone)]
834pub struct LinkBase {
835    /// String identifier for this link.
836    pub id: String,
837    /// 1-based index assigned at load time.
838    pub index: usize,
839    /// 1-based index of the start node (positive flow direction: from → to).
840    pub from_node: usize,
841    /// 1-based index of the end node.
842    pub to_node: usize,
843    /// Initial operational status at simulation start.
844    pub initial_status: LinkStatus,
845    /// Initial relative speed ω for pumps; initial setpoint for valves; unused for pipes.
846    /// `None` means MISSING — the valve is "fixed" (§2.6.4) and its
847    /// status will not be changed by automatic status logic.
848    pub initial_setting: Option<f64>,
849}
850
851impl LinkBase {
852    /// 0-based index of the start node (positive flow direction: from → to).
853    ///
854    /// All internal solvers and writers use 0-based indexing; this accessor
855    /// centralises the single `from_node - 1` conversion in one place.
856    #[inline]
857    pub fn from_idx(&self) -> usize {
858        self.from_node - 1
859    }
860
861    /// 0-based index of the end node.
862    #[inline]
863    pub fn to_idx(&self) -> usize {
864        self.to_node - 1
865    }
866}
867
868/// A pipe in the network (§2.6.2).
869#[derive(Debug, Clone)]
870pub struct Pipe {
871    /// Pipe length (internal length units, m).
872    pub length: f64,
873    /// Internal diameter (internal length units, m).
874    pub diameter: f64,
875    /// Roughness coefficient (Hazen-Williams C or Darcy-Weisbach ε).
876    pub roughness: f64,
877    /// Minor loss coefficient (dimensionless); 0 = no minor loss.
878    pub minor_loss: f64,
879    /// Whether a check valve is installed (prevents reverse flow).
880    pub check_valve: bool,
881    /// `None` → use global `bulk_coeff`.
882    pub bulk_coeff: Option<f64>,
883    /// `None` → use global `wall_coeff`.
884    pub wall_coeff: Option<f64>,
885    /// FAVAD fixed-area leakage coefficient (m³/s per m^0.5) per end node.
886    pub leak_coeff_1: f64,
887    /// FAVAD variable-area leakage coefficient (m³/s per m^1.5) per end node.
888    pub leak_coeff_2: f64,
889}
890
891/// Pump head-curve type (§2.6.3).
892#[derive(Debug, Clone, Copy, PartialEq, Eq)]
893pub enum PumpCurveType {
894    /// Three-point power-function curve.
895    PowerFunction,
896    /// Constant horsepower.
897    ConstHp,
898    /// User-supplied head-flow curve.
899    Custom,
900}
901
902/// A pump link (§2.6.3).
903#[derive(Debug, Clone)]
904pub struct Pump {
905    /// The form of the head-curve relationship for this pump.
906    pub curve_type: PumpCurveType,
907    /// Curve ID for head vs. flow (`PumpHead` kind); `None` for `ConstHp`.
908    pub head_curve: Option<String>,
909    /// Rated power (W); only used for `ConstHp`.
910    pub power: Option<f64>,
911    /// Optional efficiency curve ID (`PumpEfficiency` kind).
912    pub efficiency_curve: Option<String>,
913    /// Fallback efficiency fraction when no curve is available.
914    pub default_efficiency: f64,
915    /// Pattern ID modulating pump speed over time.
916    pub speed_pattern: Option<String>,
917    /// Per-pump unit energy price ($/kWh); `None` → use global.
918    pub energy_price: Option<f64>,
919    /// Pattern ID modulating energy price over time.
920    pub price_pattern: Option<String>,
921}
922
923/// Valve type (§2.6.4).
924#[derive(Debug, Clone, Copy, PartialEq, Eq)]
925pub enum ValveType {
926    /// Pressure Reducing Valve.
927    Prv,
928    /// Pressure Sustaining Valve.
929    Psv,
930    /// Flow Control Valve.
931    Fcv,
932    /// Throttle Control Valve.
933    Tcv,
934    /// General Purpose Valve.
935    Gpv,
936    /// Positional Control Valve.
937    Pcv,
938    /// Pressure Breaker Valve.
939    Pbv,
940}
941
942/// A valve link (§2.6.4).
943///
944/// For GPV, `curve` holds the head-loss curve ID (kind = `GpvHeadloss`).
945/// For PCV, `curve` holds the loss-ratio curve ID (kind = `PcvLossRatio`).
946/// For all other valve types it is `None` and the setpoint is encoded in
947/// `LinkBase::init_setting`.
948#[derive(Debug, Clone)]
949pub struct Valve {
950    /// The type of this valve.
951    pub valve_type: ValveType,
952    /// Nominal diameter (internal length units, m).
953    pub diameter: f64,
954    /// Minor loss coefficient (dimensionless); 0 = no minor loss.
955    pub minor_loss: f64,
956    /// Curve ID; required for `Gpv` (kind = `GpvHeadloss`) and `Pcv`
957    /// (kind = `PcvLossRatio`); `None` for all other valve types.
958    pub curve: Option<String>,
959}
960
961/// Type-specific data for a link.
962#[derive(Debug, Clone)]
963pub enum LinkKind {
964    /// Pipe link.
965    Pipe(Pipe),
966    /// Pump link.
967    Pump(Pump),
968    /// Valve link.
969    Valve(Valve),
970}
971
972/// A link in the network graph (§2.6).
973#[derive(Debug, Clone)]
974pub struct Link {
975    /// Static properties common to all link types.
976    pub base: LinkBase,
977    /// Type-specific static data.
978    pub kind: LinkKind,
979}
980
981// ── §2.8 Controls ─────────────────────────────────────────────────────────────
982
983/// Trigger kind for a simple control (§2.8.1).
984#[derive(Debug, Clone, Copy, PartialEq, Eq)]
985pub enum TriggerType {
986    /// Fires after elapsed simulation time.
987    Timer,
988    /// Fires at a specific time of day.
989    TimeOfDay,
990    /// Fires when a node grade rises above a threshold.
991    HiLevel,
992    /// Fires when a node grade falls below a threshold.
993    LowLevel,
994}
995
996/// A simple control that fires at most once per hydraulic time step (§2.8.1).
997#[derive(Debug, Clone)]
998pub struct SimpleControl {
999    /// 1-based link index.
1000    pub link: usize,
1001    /// What kind of event triggers this control.
1002    pub trigger_type: TriggerType,
1003    /// Absolute simulation time (s) for `Timer`; seconds from midnight for `TimeOfDay`.
1004    pub trigger_time: Option<f64>,
1005    /// 1-based node index; used for `HiLevel`/`LowLevel`.
1006    pub trigger_node: Option<usize>,
1007    /// Hydraulic grade threshold (m); used for `HiLevel`/`LowLevel`.
1008    pub trigger_grade: Option<f64>,
1009    /// Target status; `None` if only a setting change is intended.
1010    pub action_status: Option<LinkStatus>,
1011    /// Target setting value; `None` if only a status change is intended.
1012    pub action_setting: Option<f64>,
1013    /// Whether this control is active; disabled controls are never evaluated.
1014    pub enabled: bool,
1015}
1016
1017/// The object a rule premise refers to (§2.8.2).
1018#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1019pub enum PremiseObject {
1020    /// Network node (1-based index).
1021    Node(usize),
1022    /// Network link (1-based index).
1023    Link(usize),
1024    /// Simulation clock.
1025    Clock,
1026}
1027
1028/// Attribute tested by a rule premise (§2.8.2).
1029#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1030pub enum PremiseAttribute {
1031    /// Hydraulic head (m).
1032    Head,
1033    /// Pressure (m).
1034    Pressure,
1035    /// Demand (flow units).
1036    Demand,
1037    /// Tank water level (m).
1038    Level,
1039    /// Link flow rate (flow units).
1040    Flow,
1041    /// Link status (open/closed).
1042    Status,
1043    /// Link setting value.
1044    Setting,
1045    /// Pump power (kW).
1046    Power,
1047    /// Time to fill a tank (hours).
1048    FillTime,
1049    /// Time to drain a tank (hours).
1050    DrainTime,
1051    /// Time of day (hours from midnight).
1052    ClockTime,
1053    /// Elapsed simulation time (hours).
1054    Time,
1055}
1056
1057/// Relational operator in a rule premise (§2.8.2).
1058#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1059pub enum PremiseOperator {
1060    /// Equal.
1061    Eq,
1062    /// Not equal.
1063    Neq,
1064    /// Less than.
1065    Lt,
1066    /// Greater than.
1067    Gt,
1068    /// Less than or equal.
1069    Le,
1070    /// Greater than or equal.
1071    Ge,
1072}
1073
1074/// Logical connective joining consecutive premises (§2.8.2).
1075///
1076/// `And` binds more tightly than `Or`.
1077#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1078pub enum LogicOp {
1079    /// Logical conjunction (binds tighter than `Or`).
1080    And,
1081    /// Logical disjunction.
1082    Or,
1083}
1084
1085/// A single predicate clause in a rule (§2.8.2).
1086#[derive(Debug, Clone)]
1087pub struct Premise {
1088    /// The network object being tested.
1089    pub object: PremiseObject,
1090    /// The attribute of that object to compare.
1091    pub attribute: PremiseAttribute,
1092    /// The relational comparison operator.
1093    pub operator: PremiseOperator,
1094    /// The right-hand-side threshold value.
1095    pub value: f64,
1096    /// Connective joining this premise to the next; `None` for the last premise.
1097    pub connective: Option<LogicOp>,
1098}
1099
1100/// Value applied by a rule action (§2.8.2).
1101#[derive(Debug, Clone)]
1102pub enum ActionValue {
1103    /// Set the link's operational status.
1104    Status(LinkStatus),
1105    /// Set the link's numeric setting.
1106    Setting(f64),
1107}
1108
1109/// A single action applied by a rule (§2.8.2).
1110#[derive(Debug, Clone)]
1111pub struct RuleAction {
1112    /// 1-based link index.
1113    pub link: usize,
1114    /// The new status or setting value to apply to the link.
1115    pub value: ActionValue,
1116}
1117
1118/// A rule-based control (§2.8.2).
1119#[derive(Debug, Clone)]
1120pub struct Rule {
1121    /// Numeric priority; lower value wins when rules conflict.
1122    pub priority: f64,
1123    /// Ordered list of predicate clauses forming the rule condition.
1124    pub premises: Vec<Premise>,
1125    /// Actions applied when the rule condition evaluates to true.
1126    pub then_actions: Vec<RuleAction>,
1127    /// Actions applied when the rule condition evaluates to false.
1128    pub else_actions: Vec<RuleAction>,
1129}
1130
1131// ── Top-level network ─────────────────────────────────────────────────────────
1132
1133/// The complete network data model (§2). Populated once at load time.
1134///
1135/// Nodes and links are stored in `Vec`s; their `base.index` fields are
1136/// 1-based, so `vec[i]` has `base.index == i + 1`.
1137#[derive(Debug, Clone)]
1138pub struct Network {
1139    /// Up to 3 title lines from the `[TITLE]` section (for binary output prolog).
1140    pub title: Vec<String>,
1141    /// Simulation parameters from `[OPTIONS]` and `[TIMES]` sections.
1142    pub options: SimulationOptions,
1143    /// All patterns from the `[PATTERNS]` section.
1144    pub patterns: Vec<Pattern>,
1145    /// All curves from the `[CURVES]` section.
1146    pub curves: Vec<Curve>,
1147    /// All nodes (junctions, reservoirs, tanks).
1148    pub nodes: Vec<Node>,
1149    /// All links (pipes, pumps, valves).
1150    pub links: Vec<Link>,
1151    /// Simple controls from the `[CONTROLS]` section.
1152    pub controls: Vec<SimpleControl>,
1153    /// Rule-based controls from the `[RULES]` section.
1154    pub rules: Vec<Rule>,
1155    /// Index mapping pattern ID → position in `patterns`. Built once at load
1156    /// time via [`Network::build_pattern_index`] so hot-path lookups are O(1).
1157    pub pattern_index: HashMap<String, usize>,
1158    /// Report formatting options from the `[REPORT]` INP section.
1159    pub report: ReportOptions,
1160    /// Node coordinates from the `[COORDINATES]` INP section: node ID → (x, y).
1161    pub coordinates: HashMap<String, (f64, f64)>,
1162    /// Link vertex points from the `[VERTICES]` INP section: link ID → [(x, y), …].
1163    pub vertices: HashMap<String, Vec<(f64, f64)>>,
1164    /// Node tags from the `[TAGS]` INP section: node ID → tag string.
1165    pub node_tags: HashMap<String, String>,
1166    /// Link tags from the `[TAGS]` INP section: link ID → tag string.
1167    pub link_tags: HashMap<String, String>,
1168}
1169
1170impl Network {
1171    /// Populates `pattern_index` from the current `patterns` vec. Must be
1172    /// called once after construction (before any simulation work).
1173    pub fn build_pattern_index(&mut self) {
1174        self.pattern_index = self
1175            .patterns
1176            .iter()
1177            .enumerate()
1178            .map(|(i, p)| (p.id.clone(), i))
1179            .collect();
1180    }
1181
1182    /// O(1) pattern lookup by ID. Returns `None` if the ID does not exist.
1183    pub fn pattern_by_id(&self, id: &str) -> Option<&Pattern> {
1184        self.pattern_index.get(id).map(|&i| &self.patterns[i])
1185    }
1186}
1187
1188// ── §2.10 FAVAD load-time aggregation ─────────────────────────────────────────
1189
1190/// Per-junction FAVAD resistance coefficients computed at load time (§2.10).
1191///
1192/// Indexed 0-based (junction `i` → entry `i`). Only junctions carry FAVAD
1193/// coefficients; reservoirs and tanks are excluded.
1194///
1195/// These values live outside the `Network` struct because the spec explicitly
1196/// states they are not stored in the data model proper (§2.10). Compute once
1197/// via [`Network::compute_favad`] before the first hydraulic solve.
1198#[derive(Debug, Clone)]
1199pub struct FavadCoeffs {
1200    /// $c_{\text{fa},i}$ indexed by 0-based junction position in `network.nodes`.
1201    pub c_fa: Vec<f64>,
1202    /// $c_{\text{va},i}$ indexed by 0-based junction position in `network.nodes`.
1203    pub c_va: Vec<f64>,
1204}
1205
1206impl Network {
1207    /// Computes per-junction FAVAD resistance coefficients (§2.10).
1208    ///
1209    /// For each pipe, the $K_1$ and $K_2$ FAVAD coefficients are split between
1210    /// its two end nodes according to whether the opposite end is a junction or a
1211    /// fixed-grade node (reservoir or tank).  Only junctions accumulate FAVAD
1212    /// coefficients; reservoirs and tanks are skipped.
1213    ///
1214    /// Returns a [`FavadCoeffs`] whose `c_fa` and `c_va` vectors are indexed by
1215    /// the 0-based position of each node in `self.nodes`; non-junction entries
1216    /// are always 0.
1217    pub fn compute_favad(&self) -> FavadCoeffs {
1218        let n = self.nodes.len();
1219        let mut k_fa = vec![0.0_f64; n];
1220        let mut k_va = vec![0.0_f64; n];
1221
1222        let is_junction = |idx_1based: usize| -> bool {
1223            if idx_1based < 1 || idx_1based > n {
1224                return false;
1225            }
1226            matches!(self.nodes[idx_1based - 1].kind, NodeKind::Junction(_))
1227        };
1228
1229        for link in &self.links {
1230            let pipe = match &link.kind {
1231                LinkKind::Pipe(p) => p,
1232                _ => continue,
1233            };
1234            if pipe.leak_coeff_1 == 0.0 && pipe.leak_coeff_2 == 0.0 {
1235                continue;
1236            }
1237
1238            let f = link.base.from_node;
1239            let t = link.base.to_node;
1240            let f_is_junc = is_junction(f);
1241            let t_is_junc = is_junction(t);
1242
1243            // Splitting rule (§2.10): if both ends are junctions each gets ½;
1244            // if only one end is a junction it gets the full coefficient.
1245            let both_junctions = f_is_junc && t_is_junc;
1246            let factor = if both_junctions { 0.5 } else { 1.0 };
1247
1248            if f_is_junc {
1249                k_fa[f - 1] += factor * pipe.leak_coeff_1;
1250                k_va[f - 1] += factor * pipe.leak_coeff_2;
1251            }
1252            if t_is_junc {
1253                k_fa[t - 1] += factor * pipe.leak_coeff_1;
1254                k_va[t - 1] += factor * pipe.leak_coeff_2;
1255            }
1256        }
1257
1258        // Invert to resistance coefficients (§2.10).
1259        let c_fa: Vec<f64> = k_fa
1260            .iter()
1261            .map(|&k| if k > 0.0 { 1.0 / (k * k) } else { 0.0 })
1262            .collect();
1263        let c_va: Vec<f64> = k_va
1264            .iter()
1265            .map(|&k| {
1266                if k > 0.0 {
1267                    1.0 / k.powf(2.0 / 3.0)
1268                } else {
1269                    0.0
1270                }
1271            })
1272            .collect();
1273
1274        FavadCoeffs { c_fa, c_va }
1275    }
1276}
1277
1278// ── Tests ─────────────────────────────────────────────────────────────────────
1279
1280#[cfg(test)]
1281mod tests {
1282    use super::*;
1283
1284    // ── Pattern::eval ─────────────────────────────────────────────────────────
1285
1286    #[test]
1287    fn pattern_eval_selects_first_factor_at_t_zero() {
1288        let p = Pattern {
1289            id: "P1".into(),
1290            factors: vec![0.5, 1.0, 1.5],
1291        };
1292        // t=0, start=0, step=3600 → p= floor(0/3600)=0 → idx 0.
1293        assert_eq!(p.eval(0.0, 3600.0, 0.0), 0.5);
1294    }
1295
1296    #[test]
1297    fn pattern_eval_wraps_beyond_length() {
1298        let p = Pattern {
1299            id: "P1".into(),
1300            factors: vec![0.5, 1.0, 1.5],
1301        };
1302        // t=3*3600=10800 → p=3 → idx = 3 % 3 = 0 → 0.5.
1303        assert_eq!(p.eval(3.0 * 3600.0, 3600.0, 0.0), 0.5);
1304        // t=4*3600=14400 → p=4 → idx = 4 % 3 = 1 → 1.0.
1305        assert_eq!(p.eval(4.0 * 3600.0, 3600.0, 0.0), 1.0);
1306    }
1307
1308    #[test]
1309    fn pattern_eval_pattern_start_shifts_index() {
1310        let p = Pattern {
1311            id: "P1".into(),
1312            factors: vec![10.0, 20.0, 30.0],
1313        };
1314        // t=0, start=3600, step=3600 → p = floor(3600/3600) = 1 → 20.0.
1315        assert_eq!(p.eval(0.0, 3600.0, 3600.0), 20.0);
1316    }
1317
1318    // ── Curve::eval ──────────────────────────────────────────────────────────
1319
1320    fn two_point_curve() -> Curve {
1321        Curve {
1322            id: "C".into(),
1323            kind: CurveKind::Generic,
1324            points: vec![
1325                CurvePoint { x: 0.0, y: 0.0 },
1326                CurvePoint { x: 10.0, y: 20.0 },
1327            ],
1328        }
1329    }
1330
1331    #[test]
1332    fn curve_eval_single_point_returns_constant() {
1333        let c = Curve {
1334            id: "C".into(),
1335            kind: CurveKind::Generic,
1336            points: vec![CurvePoint { x: 5.0, y: 42.0 }],
1337        };
1338        assert_eq!(c.eval(0.0), 42.0);
1339        assert_eq!(c.eval(100.0), 42.0);
1340    }
1341
1342    #[test]
1343    fn curve_eval_interior_interpolation() {
1344        let c = two_point_curve();
1345        // At x=5 (midpoint) → y = 10.0.
1346        assert!((c.eval(5.0) - 10.0).abs() < 1e-12);
1347    }
1348
1349    #[test]
1350    fn curve_eval_below_range_extrapolates() {
1351        let c = two_point_curve();
1352        // Below x=0 — extend first segment: slope = 20/10 = 2.
1353        // eval(-5) = 0 + 2 * (-5 - 0) = -10.
1354        assert!((c.eval(-5.0) - (-10.0)).abs() < 1e-12);
1355    }
1356
1357    #[test]
1358    fn curve_eval_above_range_extrapolates() {
1359        let c = two_point_curve();
1360        // Above x=10 — extend last segment: slope = 2.
1361        // eval(15) = 0 + 2 * (15 - 0) = 30.
1362        assert!((c.eval(15.0) - 30.0).abs() < 1e-12);
1363    }
1364
1365    // ── Tank::head_from_level ─────────────────────────────────────────────────
1366
1367    #[test]
1368    fn tank_head_from_level() {
1369        let t = Tank {
1370            min_level: 2.0,
1371            max_level: 10.0,
1372            initial_level: 5.0,
1373            diameter: 4.0,
1374            min_volume: 0.0,
1375            volume_curve: None,
1376            mix_model: MixModel::Cstr,
1377            mix_fraction: 1.0,
1378            bulk_coeff: 0.0,
1379            overflow: false,
1380            head_pattern: None,
1381        };
1382        // elevation=50, min_level=2 → bottom=48; head = 48 + level=5 = 53.
1383        assert!((t.head_from_level(50.0, 5.0) - 53.0).abs() < 1e-12);
1384    }
1385
1386    // ── Tank::volume_from_level ───────────────────────────────────────────────
1387
1388    #[test]
1389    fn tank_volume_from_level_cylindrical() {
1390        let t = Tank {
1391            min_level: 0.0,
1392            max_level: 10.0,
1393            initial_level: 5.0,
1394            diameter: 4.0, // radius = 2
1395            min_volume: 0.0,
1396            volume_curve: None,
1397            mix_model: MixModel::Cstr,
1398            mix_fraction: 1.0,
1399            bulk_coeff: 0.0,
1400            overflow: false,
1401            head_pattern: None,
1402        };
1403        // V = π * (4/2)² * level = π * 4 * 3 = 12π.
1404        let expected = std::f64::consts::PI * 4.0 * 3.0;
1405        assert!((t.volume_from_level(3.0, &[]) - expected).abs() < 1e-10);
1406    }
1407
1408    // ── Junction::total_demand ────────────────────────────────────────────────
1409
1410    #[test]
1411    fn junction_total_demand_no_pattern_uses_base_times_multiplier() {
1412        let j = Junction {
1413            demands: vec![DemandCategory {
1414                base_demand: 0.01,
1415                pattern: None,
1416                name: None,
1417            }],
1418            emitter_coeff: 0.0,
1419            emitter_exp: 0.5,
1420        };
1421        let opts = SimulationOptions {
1422            demand_multiplier: 2.0,
1423            ..Default::default()
1424        };
1425        let total = j.total_demand(0.0, &opts, &[], &HashMap::new());
1426        // base_demand=0.01, multiplier=2.0, pattern factor=1.0 → 0.02.
1427        assert!((total - 0.02).abs() < 1e-12);
1428    }
1429
1430    #[test]
1431    fn junction_total_demand_with_pattern_factor() {
1432        let pat = Pattern {
1433            id: "P1".into(),
1434            factors: vec![0.5, 2.0],
1435        };
1436        let mut pattern_index = HashMap::new();
1437        pattern_index.insert("P1".to_string(), 0usize);
1438        let j = Junction {
1439            demands: vec![DemandCategory {
1440                base_demand: 0.1,
1441                pattern: Some("P1".into()),
1442                name: None,
1443            }],
1444            emitter_coeff: 0.0,
1445            emitter_exp: 0.5,
1446        };
1447        let opts = SimulationOptions::default();
1448        // t=3600, step=3600, start=0 → p=1 → factor=2.0.
1449        let total = j.total_demand(3600.0, &opts, &[pat], &pattern_index);
1450        // 0.1 * 1.0 * 2.0 = 0.2.
1451        assert!((total - 0.2).abs() < 1e-12);
1452    }
1453
1454    #[test]
1455    fn junction_total_demand_sums_multiple_categories() {
1456        let j = Junction {
1457            demands: vec![
1458                DemandCategory {
1459                    base_demand: 0.01,
1460                    pattern: None,
1461                    name: None,
1462                },
1463                DemandCategory {
1464                    base_demand: 0.02,
1465                    pattern: None,
1466                    name: None,
1467                },
1468            ],
1469            emitter_coeff: 0.0,
1470            emitter_exp: 0.5,
1471        };
1472        let opts = SimulationOptions::default();
1473        let total = j.total_demand(0.0, &opts, &[], &HashMap::new());
1474        assert!((total - 0.03).abs() < 1e-12);
1475    }
1476}