Skip to main content

hydra_engine_wds/io/
mod.rs

1// io — I/O layer for hydra-engine: format parsing and output writing.
2//
3// This module owns all format-specific reading and writing. Writers are
4// generic over `WritableSimulation` so the trait object can be provided by
5// the simulation module without creating a circular module dependency.
6
7/// Analysis artifact I/O — the persisted `analysis.json` schema.
8pub mod analysis_io;
9/// INP (EPANET input file) reader — public entry point is [`parse`].
10pub mod inp_reader;
11/// INP (EPANET input file) writer — public entry point is [`write_inp`].
12pub mod inp_writer;
13/// Binary `.out` result file reader.
14pub mod out_reader;
15/// Binary `.out` result file writer (used during simulation).
16pub mod out_writer;
17/// `.rpt` plain-text report writer.
18pub mod rpt_writer;
19/// EPANET unit conversion factors.
20pub mod units;
21
22pub use inp_writer::write_inp;
23
24use std::fmt;
25
26use crate::{Network, ValidationError};
27
28// ── Parse entry point (§4 of crates/engine-wds/src/model/spec.md) ───────────
29
30/// Error returned by [`parse`] when a model file cannot be processed.
31#[derive(Debug)]
32pub enum ParseError {
33    /// The file format was not recognised (not an INP file).
34    UnrecognisedFormat,
35    /// The file parsed successfully but failed one or more §2.9 validation checks.
36    ValidationFailed(Vec<ValidationError>),
37    /// A specific field value was syntactically valid but semantically out of range.
38    InvalidField {
39        /// The name of the offending INP field.
40        field: String,
41        /// Human-readable explanation of why the value is invalid.
42        reason: String,
43    },
44}
45
46impl fmt::Display for ParseError {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        match self {
49            Self::UnrecognisedFormat => write!(f, "unrecognised model file format"),
50            Self::ValidationFailed(errs) => write!(f, "validation failed: {} error(s)", errs.len()),
51            Self::InvalidField { field, reason } => {
52                write!(f, "invalid field '{field}': {reason}")
53            }
54        }
55    }
56}
57
58impl std::error::Error for ParseError {}
59
60/// Parse a model file from raw bytes, returning a fully validated `Network`.
61///
62/// Format detection is by content: if the first non-whitespace byte is `[` or
63/// `;` the input is treated as an EPANET 2.3 INP file. Anything else is an
64/// error.
65pub fn parse(bytes: &[u8]) -> Result<Network, ParseError> {
66    let first = bytes
67        .iter()
68        .find(|&&b| !b.is_ascii_whitespace())
69        .copied()
70        .unwrap_or(0);
71
72    match first {
73        b'[' | b';' => inp_reader::parse_inp(bytes),
74        _ => Err(ParseError::UnrecognisedFormat),
75    }
76}
77
78// ── Result types (moved from hydra-simulation) ────────────────────────────────
79
80/// Non-fatal diagnostic condition attached to a simulation time step (§8.4).
81#[derive(Debug, Clone)]
82pub struct SimWarning {
83    /// Simulation time (s) at which the condition was observed.
84    pub t: f64,
85    /// The category and details of the non-fatal condition.
86    pub kind: WarningKind,
87}
88
89/// Category of non-fatal diagnostic (§8.4).
90#[derive(Debug, Clone)]
91pub enum WarningKind {
92    /// Hydraulic solver exceeded `max_iter`; continued with `extra_iter` frozen-status loop.
93    UnbalancedHydraulics,
94    /// Negative pressure at a junction in DDA mode.
95    NegativePressure {
96        /// Zero-based index of the junction in `Network::nodes`.
97        node_index: usize,
98    },
99    /// Pump operation in reverse-flow (XHEAD) condition.
100    PumpXHead {
101        /// Zero-based index of the pump in `Network::links`.
102        link_index: usize,
103    },
104}
105
106/// Node result quantities available via `get_node_result` (§8.2.1).
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum NodeQuantity {
109    /// Hydraulic head (internal length unit).
110    Head,
111    /// Gauge pressure = head − elevation (internal length unit).
112    GaugePressure,
113    /// Demand delivered (internal volume/time unit).
114    Demand,
115    /// Water quality (units depend on `quality_mode`).
116    Quality,
117}
118
119/// Link result quantities available via `get_link_result` (§8.2.1).
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum LinkQuantity {
122    /// Flow rate (internal volume/time unit; positive = from→to).
123    Flow,
124    /// Mean velocity = flow / (π(D/2)²) (internal length/time unit; pipes only; else 0).
125    MeanVelocity,
126    /// Unit head loss = |Δh| / length (pipes only; else 0).
127    UnitHeadLoss,
128    /// Darcy–Weisbach friction factor (DW formula only; pipes only; else 0).
129    FrictionFactor,
130    /// Water quality (units depend on `quality_mode`).
131    Quality,
132    /// Link status as a float: 0 = Closed, 1 = Open, 2 = Active.
133    Status,
134    /// Link setting (pump speed fraction or valve pressure setting).
135    Setting,
136}
137
138/// Accumulated energy statistics for a single pump (§7.1).
139///
140/// Indexed parallel to `network.links`; entries for non-pump links are
141/// uninitialised and should not be read.
142#[derive(Debug, Clone, Default)]
143pub struct PumpEnergy {
144    /// Accumulated electrical energy (kWh).
145    pub kwh: f64,
146    /// Accumulated time-weighted energy intensity (kWh / (flow unit)).
147    pub kwh_per_flow: f64,
148    /// Total time (s) the pump carried positive flow.
149    pub time_online: f64,
150    /// Peak electrical power observed (kW).
151    pub max_kw: f64,
152    /// Accumulated energy cost (currency, matching `energy_price` units).
153    pub total_cost: f64,
154    /// Accumulated `η * Δt` while pump was running, used to derive `avg_efficiency`.
155    pub efficiency_sum: f64,
156}
157
158impl PumpEnergy {
159    /// Time-weighted average efficiency fraction while pump was running (§7.1).
160    pub fn avg_efficiency(&self) -> f64 {
161        if self.time_online > 0.0 {
162            self.efficiency_sum / self.time_online
163        } else {
164            0.0
165        }
166    }
167}
168
169/// Volumetric flow balance accumulated over the full simulation (§7.2).
170#[derive(Debug, Clone)]
171pub struct FlowBalance {
172    /// Integrated supply into the network (m³).
173    pub total_inflow: f64,
174    /// Integrated withdrawal from the network (m³).
175    pub total_outflow: f64,
176    /// Integrated unmet demand in PDA mode (m³); not in the ratio.
177    pub demand_deficit: f64,
178    /// Total tank volume at simulation start (m³).
179    pub initial_tank_volume: f64,
180}
181
182impl FlowBalance {
183    /// Volume balance ratio ρ_v (§7.2).
184    ///
185    /// `current_tank_volume` is the current total volume across all tanks.
186    pub fn balance_ratio(&self, current_tank_volume: f64) -> f64 {
187        let delta_v = current_tank_volume - self.initial_tank_volume;
188        let numerator = self.total_outflow + delta_v.max(0.0);
189        let denominator = self.total_inflow + (-delta_v).max(0.0);
190        if denominator == 0.0 {
191            1.0
192        } else {
193            numerator / denominator
194        }
195    }
196
197    /// Compute the complete flow balance summary given the final tank volume.
198    pub fn summarize(&self, final_tank_volume: f64) -> FlowBalanceSummary {
199        let tank_change = final_tank_volume - self.initial_tank_volume;
200        let unaccounted = self.total_inflow - self.total_outflow - tank_change;
201        let ratio = self.balance_ratio(final_tank_volume);
202        FlowBalanceSummary {
203            total_inflow: self.total_inflow,
204            total_outflow: self.total_outflow,
205            tank_change,
206            unaccounted,
207            ratio,
208        }
209    }
210}
211
212/// Derived flow balance results ready for display or serialisation.
213#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
214pub struct FlowBalanceSummary {
215    /// Total volume supplied into the network (m³).
216    pub total_inflow: f64,
217    /// Total volume consumed / withdrawn (m³).
218    pub total_outflow: f64,
219    /// Change in total tank storage (current − initial), positive = net fill.
220    pub tank_change: f64,
221    /// Unaccounted volume: inflow − outflow − tank_change.
222    pub unaccounted: f64,
223    /// Volume balance ratio (≈ 1.0 when balanced).
224    pub ratio: f64,
225}
226
227/// Running constituent mass balance (§6.9).
228#[derive(Debug, Clone, Default)]
229pub struct MassBalance {
230    /// Mass present in the network at simulation start (mg).
231    pub init: f64,
232    /// Total mass injected by sources over the simulation (mg).
233    pub added: f64,
234    /// Total mass removed by demand withdrawals (mg).
235    pub demand: f64,
236    /// Net mass consumed by reactions (positive = removed from water = decay).
237    pub reacted: f64,
238    /// Mass present in the network at simulation end (mg).
239    pub final_mass: f64,
240    /// Mass consumed by bulk pipe reactions (mg).
241    pub reacted_bulk: f64,
242    /// Mass consumed by pipe wall reactions (mg).
243    pub reacted_wall: f64,
244    /// Mass consumed by tank reactions (mg).
245    pub reacted_tank: f64,
246    /// Alias for `added`; retained for EPANET compatibility.
247    pub source: f64,
248}
249
250impl MassBalance {
251    /// Balance ratio ρ_m (§6.9). A value ≈ 1 confirms conservation.
252    pub fn ratio(&self) -> f64 {
253        let input = self.init + self.added + (-self.reacted).max(0.0);
254        let output = self.demand + self.reacted.max(0.0) + self.final_mass;
255        if input <= 0.0 {
256            return 1.0;
257        }
258        output / input
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use std::path::Path;
266
267    #[test]
268    fn parse_rejects_unrecognised_format() {
269        let bytes = b"{\"not\":\"inp\"}";
270        let err = parse(bytes).expect_err("should reject non-INP content");
271        assert!(matches!(err, ParseError::UnrecognisedFormat));
272    }
273
274    #[test]
275    fn parse_accepts_whitespace_then_inp_section() {
276        let inp_path = Path::new(env!("CARGO_MANIFEST_DIR"))
277            .join("../..")
278            .join("tests/fixtures/single_pipe_hw.inp");
279        let bytes = std::fs::read(inp_path).expect("read fixture inp");
280        let mut with_prefix = b"\n\t  ".to_vec();
281        with_prefix.extend_from_slice(&bytes);
282
283        let network = parse(&with_prefix).expect("parse fixture as INP");
284        assert!(!network.nodes.is_empty());
285        assert!(!network.links.is_empty());
286    }
287
288    #[test]
289    fn pump_energy_avg_efficiency_zero_when_offline() {
290        let pe = PumpEnergy::default();
291        assert_eq!(pe.avg_efficiency(), 0.0);
292    }
293
294    #[test]
295    fn pump_energy_avg_efficiency_time_weighted() {
296        let pe = PumpEnergy {
297            efficiency_sum: 1800.0,
298            time_online: 3600.0,
299            ..PumpEnergy::default()
300        };
301        assert!((pe.avg_efficiency() - 0.5).abs() < 1e-12);
302    }
303
304    #[test]
305    fn flow_balance_ratio_accounts_for_storage_change_direction() {
306        let fb = FlowBalance {
307            total_inflow: 100.0,
308            total_outflow: 90.0,
309            demand_deficit: 0.0,
310            initial_tank_volume: 50.0,
311        };
312        // Tank fills by 10: numerator adds +10.
313        assert!((fb.balance_ratio(60.0) - 1.0).abs() < 1e-12);
314        // Tank drains by 10: denominator adds +10.
315        assert!((fb.balance_ratio(40.0) - (90.0 / 110.0)).abs() < 1e-12);
316    }
317
318    #[test]
319    fn mass_balance_ratio_defaults_to_one_when_no_input_mass() {
320        let mb = MassBalance::default();
321        assert_eq!(mb.ratio(), 1.0);
322    }
323}
324
325/// Hydraulic state snapshot at a single simulation time (§8.2).
326#[derive(Debug, Clone)]
327pub struct HydSnapshot {
328    /// Simulation time (s).
329    pub t: f64,
330    /// Per-node hydraulic and quality state at time `t`.
331    pub node_states: Vec<crate::NodeState>,
332    /// Per-link hydraulic and quality state at time `t`.
333    pub link_states: Vec<crate::LinkState>,
334}
335
336// ── WritableSimulation trait ──────────────────────────────────────────────────
337
338/// Read-only view of a completed (or in-progress) simulation that the writers
339/// need. Implemented by `crate::simulation::Simulation`.
340///
341/// The trait is intentionally narrow — it exposes only what the writers
342/// actually access, avoiding leaking internal solver state into the public API.
343pub trait WritableSimulation {
344    /// The `Network` data model for this simulation.
345    fn net(&self) -> &crate::Network;
346    /// All hydraulic snapshots stored during the simulation.
347    fn snapshots(&self) -> &[HydSnapshot];
348    /// Pump energy record at `link_index`, or `None` if no accounting state is
349    /// available (e.g. hydraulics not yet run).
350    fn pump_energy_at(&self, link_index: usize) -> Option<&PumpEnergy>;
351    /// Peak simultaneous electrical demand across all pumps (kW).
352    fn peak_demand_kw(&self) -> f64;
353    /// Mass balance from the quality engine. `None` if quality not yet run.
354    fn mass_balance(&self) -> Option<&MassBalance>;
355    /// Non-fatal diagnostics emitted during the simulation.
356    fn warnings(&self) -> &[SimWarning];
357    /// Look up a pump's energy record by its string ID. Returns `None` if the
358    /// ID is unknown or the link is not a pump.
359    fn pump_energy_by_id(&self, pump_id: &str) -> Option<&PumpEnergy>;
360    /// The hydraulic and quality analysis start and finish wall-clock times.
361    fn analysis_times(&self) -> (Option<std::time::SystemTime>, Option<std::time::SystemTime>);
362    /// Flow balance from accounting. `None` if hydraulics not yet run.
363    fn flow_balance(&self) -> Option<&FlowBalance>;
364    /// Derived flow balance summary. `None` if hydraulics not yet run or
365    /// if the simulation lacks the data needed to compute final tank volume.
366    fn flow_balance_summary(&self) -> Option<FlowBalanceSummary>;
367}