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}