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}