Skip to main content

hydra_engine_wds/simulation/
lifecycle.rs

1use super::*;
2
3impl Default for Simulation {
4    fn default() -> Self {
5        Self::create()
6    }
7}
8
9impl Simulation {
10    /// Allocate a new empty session (§8.3 `create()`).
11    pub fn create() -> Self {
12        Simulation {
13            phase: Phase::Created,
14            network: None,
15            favad: None,
16            solver_ctx: None,
17            node_states: vec![],
18            link_states: vec![],
19            current_t: 0.0,
20            next_report_t: 0.0,
21            report_count: 0,
22            hyd_snapshots: vec![],
23            quality_state: None,
24            quality_t: 0.0,
25            accounting: None,
26            warnings: vec![],
27            neg_pressure_seen: vec![],
28            analysis_begun: None,
29            analysis_ended: None,
30        }
31    }
32
33    /// Create a session from a network and validate/load it.
34    ///
35    /// This is a convenience for the common sequence:
36    /// 1. `Simulation::create()`
37    /// 2. `session.load(network)`
38    pub fn from_network(network: Network) -> Result<Self, SessionError> {
39        let mut session = Self::create();
40        session.load(network)?;
41        Ok(session)
42    }
43
44    /// Load and validate a network, preparing for simulation.
45    /// Load and validate a network, preparing for simulation (§8.3 `load()`).
46    ///
47    /// Runs the §2.9 validation checks. Returns `SessionError::ValidationFailed`
48    /// if any check fails. On success the session transitions to `Loaded`.
49    pub fn load(&mut self, network: Network) -> Result<(), SessionError> {
50        // Validate (§8.1.2 / §2.9).
51        network.validate().map_err(SessionError::ValidationFailed)?;
52
53        // Build FAVAD coefficients (§2.10).
54        let favad = network.compute_favad();
55
56        // Build solver context (§3.6 Phase 1 + 2).
57        let solver_ctx = hydraulics::build_solver_context(&network, &favad)
58            .map_err(SessionError::HydraulicSolve)?;
59
60        // Initialise node states from static data.
61        let node_states = init_node_states(&network);
62        let link_states = init_link_states(&network);
63
64        // Initialise accounting.
65        let accounting = accounting::init_accounting(&network, &node_states);
66
67        let options = &network.options;
68        let next_report = options.report_start;
69
70        self.network = Some(network);
71        self.favad = Some(favad);
72        self.solver_ctx = Some(solver_ctx);
73        self.node_states = node_states;
74        self.link_states = link_states;
75        self.current_t = 0.0;
76        self.next_report_t = next_report;
77        self.hyd_snapshots = vec![];
78        self.quality_state = None;
79        self.quality_t = 0.0;
80        self.accounting = Some(accounting);
81        self.warnings = vec![];
82        self.neg_pressure_seen = vec![false; self.node_states.len()];
83        self.phase = Phase::Loaded;
84        Ok(())
85    }
86
87    /// Run the complete extended-period hydraulic simulation (§8.3 `run_hydraulics()`).
88    ///
89    /// Requires the session to be in `Loaded` phase.
90    pub fn run_hydraulics(&mut self) -> Result<(), SessionError> {
91        self.require_phase(Phase::Loaded)?;
92        self.analysis_begun = Some(SystemTime::now());
93        loop {
94            let dt = self.step_hydraulics()?;
95            if dt == 0.0 {
96                break;
97            }
98        }
99        self.analysis_ended = Some(SystemTime::now());
100        Ok(())
101    }
102
103    /// Run the full simulation to completion (hydraulics then quality).
104    ///
105    /// This is the easiest entry point for most users:
106    /// 1. [`Simulation::load`]
107    /// 2. `run()`
108    /// 3. query results via [`Simulation::snapshot_times`],
109    ///    [`Simulation::get_node_result`], and [`Simulation::get_link_result`].
110    pub fn run(&mut self) -> Result<(), SessionError> {
111        self.run_hydraulics()?;
112        self.run_quality()
113    }
114
115    /// Advance the hydraulic simulation by one adaptive time step (§8.3 `step_hydraulics()`).
116    ///
117    /// Returns the duration of the step taken (s). Returns 0.0 when the
118    /// simulation has reached its end time.
119    pub fn step_hydraulics(&mut self) -> Result<f64, SessionError> {
120        self.require_phase(Phase::Loaded)?;
121
122        // Record the wall-clock start time on the first step call.
123        if self.analysis_begun.is_none() {
124            self.analysis_begun = Some(SystemTime::now());
125        }
126
127        let network = self
128            .network
129            .as_ref()
130            .expect("invariant: network set in load()");
131        let t = self.current_t;
132        let duration = network.options.duration;
133
134        if t > duration {
135            self.phase = Phase::HydraulicsDone;
136            return Ok(0.0);
137        }
138
139        // Apply pump speed patterns: setting = init_setting × pattern_factor.
140        // Done before simple controls so controls can override (matches EPANET).
141        let network = self
142            .network
143            .as_ref()
144            .expect("invariant: network set in load()");
145        for (k, link) in network.links.iter().enumerate() {
146            if let LinkKind::Pump(pump) = &link.kind {
147                if let Some(ref pat_id) = pump.speed_pattern {
148                    if let Some(pat) = network.pattern_by_id(pat_id) {
149                        let factor = pat.eval(
150                            t,
151                            network.options.pattern_step,
152                            network.options.pattern_start,
153                        );
154                        self.link_states[k].setting =
155                            link.base.initial_setting.unwrap_or(1.0) * factor;
156                    }
157                }
158            }
159        }
160
161        // Apply simple controls (§4.1 — evaluated once before the solve).
162        let network = self
163            .network
164            .as_ref()
165            .expect("invariant: network set in load()");
166        let _changed =
167            controls::apply_simple_controls(network, &self.node_states, &mut self.link_states, t);
168
169        // Solve (§3). Rule-based controls are evaluated AFTER the solve,
170        // within the time-step computation — see the rule sub-step loop below.
171        let network = self
172            .network
173            .as_ref()
174            .expect("invariant: network set in load()");
175        let favad = self.favad.as_ref().expect("invariant: favad set in load()");
176        let solver_context = self
177            .solver_ctx
178            .as_mut()
179            .expect("invariant: solver_ctx set in load()");
180        let result = hydraulics::solve_hydraulic_step(
181            network,
182            favad,
183            solver_context,
184            &mut self.node_states,
185            &mut self.link_states,
186            t,
187            controls::pswitch,
188        )
189        .map_err(SessionError::HydraulicSolve)?;
190
191        if result == SolveResult::Unbalanced {
192            self.warnings.push(SimWarning {
193                t,
194                kind: WarningKind::UnbalancedHydraulics,
195            });
196            // EPANET: Haltflag — if ExtraIter == -1, terminate after this step.
197            if network.options.extra_iter < 0 {
198                self.maybe_record_snapshot(t);
199                self.phase = Phase::HydraulicsDone;
200                return Ok(0.0);
201            }
202        }
203
204        // Emit pressure warnings for junctions in DDA mode.
205        // EPANET: only for junctions where head < elevation AND demand > 0.
206        // Deduplicated per node — only the first occurrence is recorded.
207        let network = self
208            .network
209            .as_ref()
210            .expect("invariant: network set in load()");
211        for (i, node) in network.nodes.iter().enumerate() {
212            if !self.neg_pressure_seen[i]
213                && matches!(node.kind, NodeKind::Junction(_))
214                && self.node_states[i].head < node.base.elevation
215                && self.node_states[i].demand_flow > 0.0
216            {
217                self.neg_pressure_seen[i] = true;
218                self.warnings.push(SimWarning {
219                    t,
220                    kind: WarningKind::NegativePressure { node_index: i },
221                });
222            }
223        }
224
225        // Emit pump out-of-range warnings (EPANET writehydwarn flag=4).
226        // EPANET checks: status >= OPEN, flow > setting*Qmax or flow < 0.
227        let network = self
228            .network
229            .as_ref()
230            .expect("invariant: network set in load()");
231        let ctx = self
232            .solver_ctx
233            .as_ref()
234            .expect("invariant: solver_ctx set in load()");
235        for (k, link) in network.links.iter().enumerate() {
236            if let LinkKind::Pump(_) = &link.kind {
237                let link_state = &self.link_states[k];
238                if matches!(link_state.status, LinkStatus::Open | LinkStatus::Active) {
239                    let qmax = ctx.pump_qmax(k);
240                    if link_state.flow > link_state.setting * qmax || link_state.flow < 0.0 {
241                        self.warnings.push(SimWarning {
242                            t,
243                            kind: WarningKind::PumpXHead { link_index: k },
244                        });
245                    }
246                }
247            }
248        }
249
250        // Record snapshot at t AFTER solve, BEFORE tank advance.
251        // This matches EPANET's output ordering: solve → output → advance.
252        self.maybe_record_snapshot(t);
253
254        // Compute adaptive Δt AFTER solve (§5.2) so current flows are used
255        // for the control timestep prediction (§5.2.1).
256        let network = self
257            .network
258            .as_ref()
259            .expect("invariant: network set in load()");
260        let mut dt = timestep::adaptive_timestep(t, network, &self.node_states);
261
262        // Shorten timestep for approaching simple controls (§5.2.1).
263        let dt_control =
264            timestep::control_timestep(t, network, &self.node_states, &self.link_states);
265        if dt_control < dt {
266            dt = dt_control;
267        }
268
269        if dt == 0.0 {
270            // Final step: solved and recorded at t=duration, no advance needed.
271            // EPANET (nexthyd): when Dur == 0, still accumulates energy with
272            // dt normalised to 1 hour (3600 s).  For non-zero duration, this
273            // is the last step so no further energy accumulation is needed
274            // (integral was accumulated in all previous steps).
275            if duration == 0.0 {
276                let network = self
277                    .network
278                    .as_ref()
279                    .expect("invariant: network set in load()");
280                let pump_powers = accounting::precompute_pump_powers(
281                    network,
282                    &self.node_states,
283                    &self.link_states,
284                );
285                let accounting = self
286                    .accounting
287                    .as_mut()
288                    .expect("invariant: accounting set in load()");
289                accounting::accumulate_step(
290                    accounting,
291                    network,
292                    &self.node_states,
293                    &pump_powers,
294                    3600.0,
295                    t,
296                    0.0,
297                );
298            }
299            self.phase = Phase::HydraulicsDone;
300            return Ok(0.0);
301        }
302
303        // ── Rule sub-step loop (§4.2.1) ──────────────────────────────────
304        // Advance tank levels in sub-steps, evaluating rule-based controls at
305        // each sub-step.  If a rule fires (any action changes a link state),
306        // the hydraulic period is shortened to the elapsed sub-step time.
307        // When no rules exist, advance tanks by the full dt in one step.
308        //
309        // Pre-compute pump powers BEFORE tank levels are advanced, matching
310        // EPANET's getallpumpsenergy() → timestep() → addenergy() ordering.
311        let network = self
312            .network
313            .as_ref()
314            .expect("invariant: network set in load()");
315        let pump_powers =
316            accounting::precompute_pump_powers(network, &self.node_states, &self.link_states);
317        let mut step_overflow: f64 = 0.0;
318        if !network.rules.is_empty() {
319            let rule_step = network.options.rule_timestep;
320            let mut elapsed = 0.0;
321
322            // First sub-step aligned to even multiples of rule_step from t=0
323            // (§4.2.1): δ = rule_step − (t mod rule_step), may be < rule_step.
324            let first_dt = {
325                let rem = t % rule_step;
326                let d = rule_step - rem;
327                if d <= 0.0 || d > rule_step {
328                    rule_step
329                } else {
330                    d
331                }
332            };
333            let mut dt1 = first_dt.min(dt);
334            if dt1 == 0.0 {
335                dt1 = rule_step.min(dt);
336            }
337
338            loop {
339                // Advance tank levels by sub-step.
340                let updates = timestep::update_tank_levels(network, &self.node_states, dt1);
341                for u in &updates {
342                    let node_state = &mut self.node_states[u.node_index];
343                    node_state.head = u.new_head;
344                    node_state.level = u.new_level;
345                    node_state.volume = u.new_volume;
346                    step_overflow += u.overflow_volume;
347                }
348                elapsed += dt1;
349
350                // Evaluate rules at the sub-stepped time (t + elapsed).
351                let sub_t = t + elapsed;
352                if let Some((actions, _then_fired)) =
353                    controls::eval_rules(network, &self.node_states, &self.link_states, sub_t)
354                {
355                    let any_changed =
356                        controls::apply_link_actions(&mut self.link_states, &actions, network);
357                    if any_changed {
358                        // Rule fired — shorten the hydraulic period to elapsed.
359                        dt = elapsed;
360                        break;
361                    }
362                }
363
364                // Update remaining time.
365                let remaining = dt - elapsed;
366                if remaining <= 0.0 {
367                    break;
368                }
369                dt1 = rule_step.min(remaining);
370            }
371        } else {
372            // No rules — advance tanks by the full dt in one step.
373            let updates = timestep::update_tank_levels(network, &self.node_states, dt);
374            for u in &updates {
375                let node_state = &mut self.node_states[u.node_index];
376                node_state.head = u.new_head;
377                node_state.level = u.new_level;
378                node_state.volume = u.new_volume;
379                step_overflow += u.overflow_volume;
380            }
381        }
382
383        // Accumulate accounting (uses the possibly-shortened dt and pre-computed pump powers).
384        let network = self
385            .network
386            .as_ref()
387            .expect("invariant: network set in load()");
388        let accounting = self
389            .accounting
390            .as_mut()
391            .expect("invariant: accounting set in load()");
392        accounting::accumulate_step(
393            accounting,
394            network,
395            &self.node_states,
396            &pump_powers,
397            dt,
398            t,
399            step_overflow,
400        );
401
402        let new_t = t + dt;
403        self.current_t = new_t;
404
405        Ok(dt)
406    }
407
408    /// Run the complete quality simulation (§8.3 `run_quality()`).
409    ///
410    /// Requires hydraulics to be done.
411    pub fn run_quality(&mut self) -> Result<(), SessionError> {
412        self.require_phase(Phase::HydraulicsDone)?;
413        // Initialise quality state.
414        let network = self
415            .network
416            .as_ref()
417            .expect("invariant: network set in load()");
418        if network.options.quality_mode == QualityMode::None {
419            self.analysis_ended = Some(SystemTime::now());
420            self.phase = Phase::QualityDone;
421            return Ok(());
422        }
423        // Use first snapshot states for initialisation.
424        let (init_ns, init_ls) = self.first_snapshot_states();
425        let qs = quality::init_quality(network, init_ns, init_ls)
426            .map_err(SessionError::QualityEngine)?;
427
428        // Write initial quality (node_conc and avgqual) into the first snapshot (t=0).
429        // For Trace mode this ensures the trace node reports 100 % at t=0.
430        if let Some(snap0) = self.hyd_snapshots.first_mut() {
431            for (i, ns) in snap0.node_states.iter_mut().enumerate() {
432                ns.quality = qs.node_conc[i];
433            }
434            for (k, ls) in snap0.link_states.iter_mut().enumerate() {
435                ls.quality = quality::avg_link_quality(
436                    &qs,
437                    k,
438                    network.links[k].base.from_idx(),
439                    network.links[k].base.to_idx(),
440                );
441            }
442        }
443
444        self.quality_state = Some(qs);
445        self.quality_t = 0.0;
446        loop {
447            let dt = self.step_quality()?;
448            if dt == 0.0 {
449                break;
450            }
451        }
452        self.analysis_ended = Some(SystemTime::now());
453        Ok(())
454    }
455
456    /// Advance the quality simulation by one hydraulic time step's worth of
457    /// sub-steps (§8.3 `step_quality()`).
458    ///
459    /// Returns the hydraulic duration advanced (s). Returns 0.0 at end.
460    pub fn step_quality(&mut self) -> Result<f64, SessionError> {
461        if self.phase != Phase::HydraulicsDone && self.phase != Phase::QualityDone {
462            return Err(SessionError::InvalidPhase {
463                expected: "HydraulicsDone".into(),
464                actual: self.phase.name().to_string(),
465            });
466        }
467
468        // Lazy-initialise quality state on the first call so that step_quality()
469        // can be used directly (e.g. in a CLI progress loop) without requiring a
470        // prior run_quality() call.  run_quality() already initialises explicitly
471        // before its own loop, so quality_state will be Some when reached there
472        // and this block is skipped.
473        if self.quality_state.is_none() {
474            let network = self
475                .network
476                .as_ref()
477                .expect("invariant: network set in load()");
478            if network.options.quality_mode == QualityMode::None {
479                self.analysis_ended = Some(SystemTime::now());
480                self.phase = Phase::QualityDone;
481                return Ok(0.0);
482            }
483            let (init_ns, init_ls) = self.first_snapshot_states();
484            let qs = quality::init_quality(network, init_ns, init_ls)
485                .map_err(SessionError::QualityEngine)?;
486            if let Some(snap0) = self.hyd_snapshots.first_mut() {
487                for (i, ns) in snap0.node_states.iter_mut().enumerate() {
488                    ns.quality = qs.node_conc[i];
489                }
490                for (k, ls) in snap0.link_states.iter_mut().enumerate() {
491                    ls.quality = quality::avg_link_quality(
492                        &qs,
493                        k,
494                        network.links[k].base.from_idx(),
495                        network.links[k].base.to_idx(),
496                    );
497                }
498            }
499            self.quality_state = Some(qs);
500            self.quality_t = 0.0;
501        }
502
503        let network = self
504            .network
505            .as_ref()
506            .expect("invariant: network set in load()");
507        let duration = network.options.duration;
508        let qt = self.quality_t;
509        if qt >= duration {
510            self.analysis_ended = Some(SystemTime::now());
511            self.phase = Phase::QualityDone;
512            return Ok(0.0);
513        }
514
515        // Find the snapshot at qt — this gives the flow field for this period.
516        let snap_idx = self.find_snapshot_index_at(qt);
517        let snap_idx = match snap_idx {
518            Some(idx) => idx,
519            None => {
520                self.phase = Phase::QualityDone;
521                return Ok(0.0);
522            }
523        };
524
525        // dt_h = time from this snapshot to the next one (or end of simulation).
526        // Quality results are written to the NEXT snapshot because EPANET
527        // reports initial quality at t=0 and the quality after transport at
528        // subsequent report times.
529        let next_snap_idx = snap_idx + 1;
530        let next_t = if next_snap_idx < self.hyd_snapshots.len() {
531            self.hyd_snapshots[next_snap_idx].t
532        } else {
533            duration
534        };
535        let dt_h = next_t - qt;
536        if dt_h <= 0.0 {
537            self.phase = Phase::QualityDone;
538            return Ok(0.0);
539        }
540
541        // Borrow node/link states from the snapshot without cloning.
542        // NLL ensures these shared borrows end after advance_quality returns,
543        // allowing the mutable write-back to next_snap_idx below.
544        let node_states = &self.hyd_snapshots[snap_idx].node_states;
545        let link_states = &self.hyd_snapshots[snap_idx].link_states;
546
547        if let Some(qs) = self.quality_state.as_mut() {
548            quality::advance_quality(qs, network, node_states, link_states, dt_h, qt);
549            // Write-back quality to the NEXT snapshot (the one at t=next_t).
550            // Quality at snap[0] (t=0) keeps its initial values.
551            if next_snap_idx < self.hyd_snapshots.len() {
552                let snap = &mut self.hyd_snapshots[next_snap_idx];
553                for (i, ns) in snap.node_states.iter_mut().enumerate() {
554                    ns.quality = qs.node_conc[i];
555                }
556                for (k, ls) in snap.link_states.iter_mut().enumerate() {
557                    ls.quality = quality::avg_link_quality(
558                        qs,
559                        k,
560                        network.links[k].base.from_idx(),
561                        network.links[k].base.to_idx(),
562                    );
563                    ls.reaction_rate = qs.pipe_rate_coeff[k];
564                }
565            }
566            self.quality_t = qt + dt_h;
567        }
568
569        if self.quality_t >= duration {
570            self.phase = Phase::QualityDone;
571        }
572        Ok(dt_h)
573    }
574}