pub mod analysis_io;
pub mod inp_reader;
pub mod inp_writer;
pub mod out_reader;
pub mod out_writer;
pub mod rpt_writer;
pub mod units;
pub use inp_writer::write_inp;
use std::fmt;
use crate::{Network, ValidationError};
#[derive(Debug)]
pub enum ParseError {
UnrecognisedFormat,
ValidationFailed(Vec<ValidationError>),
InvalidField {
field: String,
reason: String,
},
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnrecognisedFormat => write!(f, "unrecognised model file format"),
Self::ValidationFailed(errs) => write!(f, "validation failed: {} error(s)", errs.len()),
Self::InvalidField { field, reason } => {
write!(f, "invalid field '{field}': {reason}")
}
}
}
}
impl std::error::Error for ParseError {}
pub fn parse(bytes: &[u8]) -> Result<Network, ParseError> {
let first = bytes
.iter()
.find(|&&b| !b.is_ascii_whitespace())
.copied()
.unwrap_or(0);
match first {
b'[' | b';' => inp_reader::parse_inp(bytes),
_ => Err(ParseError::UnrecognisedFormat),
}
}
#[derive(Debug, Clone)]
pub struct SimWarning {
pub t: f64,
pub kind: WarningKind,
}
#[derive(Debug, Clone)]
pub enum WarningKind {
UnbalancedHydraulics,
NegativePressure {
node_index: usize,
},
PumpXHead {
link_index: usize,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NodeQuantity {
Head,
GaugePressure,
Demand,
Quality,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LinkQuantity {
Flow,
MeanVelocity,
UnitHeadLoss,
FrictionFactor,
Quality,
Status,
Setting,
}
#[derive(Debug, Clone, Default)]
pub struct PumpEnergy {
pub kwh: f64,
pub kwh_per_flow: f64,
pub time_online: f64,
pub max_kw: f64,
pub total_cost: f64,
pub efficiency_sum: f64,
}
impl PumpEnergy {
pub fn avg_efficiency(&self) -> f64 {
if self.time_online > 0.0 {
self.efficiency_sum / self.time_online
} else {
0.0
}
}
}
#[derive(Debug, Clone)]
pub struct FlowBalance {
pub total_inflow: f64,
pub total_outflow: f64,
pub demand_deficit: f64,
pub initial_tank_volume: f64,
}
impl FlowBalance {
pub fn balance_ratio(&self, current_tank_volume: f64) -> f64 {
let delta_v = current_tank_volume - self.initial_tank_volume;
let numerator = self.total_outflow + delta_v.max(0.0);
let denominator = self.total_inflow + (-delta_v).max(0.0);
if denominator == 0.0 {
1.0
} else {
numerator / denominator
}
}
pub fn summarize(&self, final_tank_volume: f64) -> FlowBalanceSummary {
let tank_change = final_tank_volume - self.initial_tank_volume;
let unaccounted = self.total_inflow - self.total_outflow - tank_change;
let ratio = self.balance_ratio(final_tank_volume);
FlowBalanceSummary {
total_inflow: self.total_inflow,
total_outflow: self.total_outflow,
tank_change,
unaccounted,
ratio,
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct FlowBalanceSummary {
pub total_inflow: f64,
pub total_outflow: f64,
pub tank_change: f64,
pub unaccounted: f64,
pub ratio: f64,
}
#[derive(Debug, Clone, Default)]
pub struct MassBalance {
pub init: f64,
pub added: f64,
pub demand: f64,
pub reacted: f64,
pub final_mass: f64,
pub reacted_bulk: f64,
pub reacted_wall: f64,
pub reacted_tank: f64,
pub source: f64,
}
impl MassBalance {
pub fn ratio(&self) -> f64 {
let input = self.init + self.added + (-self.reacted).max(0.0);
let output = self.demand + self.reacted.max(0.0) + self.final_mass;
if input <= 0.0 {
return 1.0;
}
output / input
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn parse_rejects_unrecognised_format() {
let bytes = b"{\"not\":\"inp\"}";
let err = parse(bytes).expect_err("should reject non-INP content");
assert!(matches!(err, ParseError::UnrecognisedFormat));
}
#[test]
fn parse_accepts_whitespace_then_inp_section() {
let inp_path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("tests/fixtures/single_pipe_hw.inp");
let bytes = std::fs::read(inp_path).expect("read fixture inp");
let mut with_prefix = b"\n\t ".to_vec();
with_prefix.extend_from_slice(&bytes);
let network = parse(&with_prefix).expect("parse fixture as INP");
assert!(!network.nodes.is_empty());
assert!(!network.links.is_empty());
}
#[test]
fn pump_energy_avg_efficiency_zero_when_offline() {
let pe = PumpEnergy::default();
assert_eq!(pe.avg_efficiency(), 0.0);
}
#[test]
fn pump_energy_avg_efficiency_time_weighted() {
let pe = PumpEnergy {
efficiency_sum: 1800.0,
time_online: 3600.0,
..PumpEnergy::default()
};
assert!((pe.avg_efficiency() - 0.5).abs() < 1e-12);
}
#[test]
fn flow_balance_ratio_accounts_for_storage_change_direction() {
let fb = FlowBalance {
total_inflow: 100.0,
total_outflow: 90.0,
demand_deficit: 0.0,
initial_tank_volume: 50.0,
};
assert!((fb.balance_ratio(60.0) - 1.0).abs() < 1e-12);
assert!((fb.balance_ratio(40.0) - (90.0 / 110.0)).abs() < 1e-12);
}
#[test]
fn mass_balance_ratio_defaults_to_one_when_no_input_mass() {
let mb = MassBalance::default();
assert_eq!(mb.ratio(), 1.0);
}
}
#[derive(Debug, Clone)]
pub struct HydSnapshot {
pub t: f64,
pub node_states: Vec<crate::NodeState>,
pub link_states: Vec<crate::LinkState>,
}
pub trait WritableSimulation {
fn net(&self) -> &crate::Network;
fn snapshots(&self) -> &[HydSnapshot];
fn pump_energy_at(&self, link_index: usize) -> Option<&PumpEnergy>;
fn peak_demand_kw(&self) -> f64;
fn mass_balance(&self) -> Option<&MassBalance>;
fn warnings(&self) -> &[SimWarning];
fn pump_energy_by_id(&self, pump_id: &str) -> Option<&PumpEnergy>;
fn analysis_times(&self) -> (Option<std::time::SystemTime>, Option<std::time::SystemTime>);
fn flow_balance(&self) -> Option<&FlowBalance>;
fn flow_balance_summary(&self) -> Option<FlowBalanceSummary>;
}