#[derive(Debug, Clone)]
pub struct OutProlog {
pub magic: i32,
pub version: i32,
pub n_nodes: usize,
pub n_tanks: usize,
pub n_links: usize,
pub n_pumps: usize,
pub n_valves: usize,
pub quality_flag: i32,
pub trace_node: i32,
pub flow_units: i32,
pub pressure_units: i32,
pub report_statistic: i32,
pub report_start: i32,
pub report_step: i32,
pub duration: i32,
pub tank_areas: Vec<f32>,
pub elevations: Vec<f32>,
pub lengths: Vec<f32>,
pub diameters: Vec<f32>,
}
#[derive(Debug, Clone)]
pub struct PumpEnergyRecord {
pub link_index: i32,
pub pct_online: f32,
pub avg_efficiency: f32,
pub avg_kwh_per_flow: f32,
pub avg_kw: f32,
pub peak_kw: f32,
pub avg_cost_per_day: f32,
}
#[derive(Debug, Clone)]
pub struct OutEnergy {
pub pumps: Vec<PumpEnergyRecord>,
pub demand_charge: f32,
}
#[derive(Debug, Clone)]
pub struct PeriodResult {
pub node_demand: Vec<f32>,
pub node_head: Vec<f32>,
pub node_pressure: Vec<f32>,
pub node_quality: Vec<f32>,
pub link_flow: Vec<f32>,
pub link_velocity: Vec<f32>,
pub link_headloss: Vec<f32>,
pub link_quality: Vec<f32>,
pub link_status: Vec<f32>,
pub link_setting: Vec<f32>,
pub link_reaction_rate: Vec<f32>,
pub link_friction_factor: Vec<f32>,
}
#[derive(Debug, Clone, Copy)]
pub struct OutReactions {
pub bulk_rate: f32,
pub wall_rate: f32,
pub tank_rate: f32,
pub source_rate: f32,
}
#[derive(Debug, Clone, Copy)]
pub struct OutEpilog {
pub n_periods: i32,
pub warning_flag: i32,
pub magic: i32,
}
#[derive(Debug)]
pub struct OutFile {
pub prolog: OutProlog,
pub energy: OutEnergy,
pub periods: Vec<PeriodResult>,
pub reactions: OutReactions,
pub epilog: OutEpilog,
}
#[derive(Debug, Clone)]
pub struct OutMetadata {
pub n_nodes: usize,
pub n_tanks: usize,
pub n_links: usize,
pub n_pumps: usize,
pub quality_flag: i32,
pub report_start: f64,
pub report_step: f64,
pub n_periods: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutValidityKind {
Missing,
Io,
Incomplete,
Corrupt,
Unsupported,
}
#[derive(Debug, Clone)]
pub struct OutValidityError {
pub kind: OutValidityKind,
pub detail: String,
}
impl std::fmt::Display for OutValidityError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let tag = match self.kind {
OutValidityKind::Missing => "missing",
OutValidityKind::Io => "io",
OutValidityKind::Incomplete => "incomplete",
OutValidityKind::Corrupt => "corrupt",
OutValidityKind::Unsupported => "unsupported",
};
write!(f, "Invalid .out ({tag}): {}", self.detail)
}
}
impl std::error::Error for OutValidityError {}
impl OutMetadata {
pub fn prolog_bytes(&self) -> u64 {
(884 + 36 * self.n_nodes + 52 * self.n_links + 8 * self.n_tanks) as u64
}
pub fn energy_bytes(&self) -> u64 {
(28 * self.n_pumps + 4) as u64
}
pub fn dynamic_offset(&self) -> u64 {
self.prolog_bytes() + self.energy_bytes()
}
pub fn period_bytes(&self) -> u64 {
(4 * (4 * self.n_nodes + 8 * self.n_links)) as u64
}
pub fn snapshot_times(&self) -> Vec<f64> {
(0..self.n_periods)
.map(|i| self.report_start + (i as f64) * self.report_step)
.collect()
}
}
pub fn read_metadata(path: &std::path::Path) -> Result<OutMetadata, String> {
read_metadata_checked(path).map_err(|e| e.to_string())
}
pub fn read_metadata_checked(path: &std::path::Path) -> Result<OutMetadata, OutValidityError> {
use std::io::{Read, Seek, SeekFrom};
let mut f = std::fs::File::open(path).map_err(|e| {
let kind = if e.kind() == std::io::ErrorKind::NotFound {
OutValidityKind::Missing
} else {
OutValidityKind::Io
};
OutValidityError {
kind,
detail: format!("failed to open file: {e}"),
}
})?;
let file_len = f
.metadata()
.map(|m| m.len())
.map_err(|e| OutValidityError {
kind: OutValidityKind::Io,
detail: format!("failed to read file metadata: {e}"),
})?;
if file_len < 72 {
return Err(OutValidityError {
kind: OutValidityKind::Incomplete,
detail: format!("file too short: {file_len} bytes (minimum 72 for header+epilog)"),
});
}
let mut hdr = [0u8; 60];
if let Err(e) = f.read_exact(&mut hdr) {
return Err(OutValidityError {
kind: if e.kind() == std::io::ErrorKind::UnexpectedEof {
OutValidityKind::Incomplete
} else {
OutValidityKind::Io
},
detail: format!("failed to read header: {e}"),
});
}
let i32_at = |off: usize| i32::from_le_bytes(hdr[off..off + 4].try_into().unwrap());
let magic = i32_at(0);
if magic != 516_114_521 {
return Err(OutValidityError {
kind: OutValidityKind::Corrupt,
detail: format!("unexpected start magic: {magic}"),
});
}
let version = i32_at(4);
if version != 20_012 {
return Err(OutValidityError {
kind: OutValidityKind::Unsupported,
detail: format!("unsupported .out version: {version}"),
});
}
let n_nodes_i = i32_at(8);
let n_tanks_i = i32_at(12);
let n_links_i = i32_at(16);
let n_pumps_i = i32_at(20);
let n_valves_i = i32_at(24);
if n_nodes_i < 0 || n_tanks_i < 0 || n_links_i < 0 || n_pumps_i < 0 || n_valves_i < 0 {
return Err(OutValidityError {
kind: OutValidityKind::Corrupt,
detail: "negative object counts in header".to_string(),
});
}
let n_nodes = n_nodes_i as usize;
let n_tanks = n_tanks_i as usize;
let n_links = n_links_i as usize;
let n_pumps = n_pumps_i as usize;
if n_tanks > n_nodes {
return Err(OutValidityError {
kind: OutValidityKind::Corrupt,
detail: format!("invalid counts: n_tanks ({n_tanks}) > n_nodes ({n_nodes})"),
});
}
if n_pumps > n_links {
return Err(OutValidityError {
kind: OutValidityKind::Corrupt,
detail: format!("invalid counts: n_pumps ({n_pumps}) > n_links ({n_links})"),
});
}
let quality_flag = i32_at(28);
if !(0..=3).contains(&quality_flag) {
return Err(OutValidityError {
kind: OutValidityKind::Unsupported,
detail: format!("unsupported quality flag: {quality_flag}"),
});
}
let report_start = i32_at(48) as f64;
let report_step = i32_at(52) as f64;
if let Err(e) = f.seek(SeekFrom::End(-12)) {
return Err(OutValidityError {
kind: OutValidityKind::Io,
detail: format!("failed to seek epilog: {e}"),
});
}
let mut epi = [0u8; 12];
if let Err(e) = f.read_exact(&mut epi) {
return Err(OutValidityError {
kind: if e.kind() == std::io::ErrorKind::UnexpectedEof {
OutValidityKind::Incomplete
} else {
OutValidityKind::Io
},
detail: format!("failed to read epilog: {e}"),
});
}
let n_periods_i = i32::from_le_bytes(epi[0..4].try_into().unwrap());
if n_periods_i < 0 {
return Err(OutValidityError {
kind: OutValidityKind::Incomplete,
detail: format!("negative period count in epilog: {n_periods_i}"),
});
}
let n_periods = n_periods_i as usize;
let magic_end = i32::from_le_bytes(epi[8..12].try_into().unwrap());
if magic_end != 516_114_521 {
return Err(OutValidityError {
kind: OutValidityKind::Incomplete,
detail: format!("unexpected end magic: {magic_end}"),
});
}
let checked_mul = |a: u64, b: u64| {
a.checked_mul(b).ok_or_else(|| OutValidityError {
kind: OutValidityKind::Corrupt,
detail: "layout size overflow".to_string(),
})
};
let checked_add = |a: u64, b: u64| {
a.checked_add(b).ok_or_else(|| OutValidityError {
kind: OutValidityKind::Corrupt,
detail: "layout size overflow".to_string(),
})
};
let prolog_bytes = checked_add(
checked_add(884, checked_mul(36, n_nodes as u64)?)?,
checked_add(
checked_mul(52, n_links as u64)?,
checked_mul(8, n_tanks as u64)?,
)?,
)?;
let energy_bytes = checked_add(checked_mul(28, n_pumps as u64)?, 4)?;
let period_bytes = checked_mul(
4,
checked_add(
checked_mul(4, n_nodes as u64)?,
checked_mul(8, n_links as u64)?,
)?,
)?;
let dynamic_bytes = checked_mul(period_bytes, n_periods as u64)?;
let expected_total = checked_add(
checked_add(checked_add(prolog_bytes, energy_bytes)?, dynamic_bytes)?,
28,
)?;
if file_len < expected_total {
return Err(OutValidityError {
kind: OutValidityKind::Incomplete,
detail: format!(
"file truncated: {file_len} bytes, expected at least {expected_total} bytes"
),
});
}
Ok(OutMetadata {
n_nodes,
n_tanks,
n_links,
n_pumps,
quality_flag,
report_start,
report_step,
n_periods,
})
}
pub fn read_energy(path: &std::path::Path, meta: &OutMetadata) -> Result<OutEnergy, String> {
use std::io::{Read, Seek, SeekFrom};
let mut f = std::fs::File::open(path).map_err(|e| e.to_string())?;
f.seek(SeekFrom::Start(meta.prolog_bytes()))
.map_err(|e| e.to_string())?;
let mut pump_records = Vec::with_capacity(meta.n_pumps);
for _ in 0..meta.n_pumps {
let mut buf = [0u8; 28];
f.read_exact(&mut buf).map_err(|e| e.to_string())?;
pump_records.push(PumpEnergyRecord {
link_index: i32::from_le_bytes(buf[0..4].try_into().unwrap()),
pct_online: f32::from_le_bytes(buf[4..8].try_into().unwrap()),
avg_efficiency: f32::from_le_bytes(buf[8..12].try_into().unwrap()),
avg_kwh_per_flow: f32::from_le_bytes(buf[12..16].try_into().unwrap()),
avg_kw: f32::from_le_bytes(buf[16..20].try_into().unwrap()),
peak_kw: f32::from_le_bytes(buf[20..24].try_into().unwrap()),
avg_cost_per_day: f32::from_le_bytes(buf[24..28].try_into().unwrap()),
});
}
let mut charge_buf = [0u8; 4];
f.read_exact(&mut charge_buf).map_err(|e| e.to_string())?;
Ok(OutEnergy {
pumps: pump_records,
demand_charge: f32::from_le_bytes(charge_buf),
})
}
pub fn read_period(
path: &std::path::Path,
meta: &OutMetadata,
period: usize,
) -> Result<PeriodResult, String> {
use std::io::{Read, Seek, SeekFrom};
if period >= meta.n_periods {
return Err(format!(
"Period {period} out of range (0..{})",
meta.n_periods
));
}
let nn = meta.n_nodes;
let nl = meta.n_links;
let pbytes = meta.period_bytes() as usize;
let mut buf = vec![0u8; pbytes];
let mut f = std::fs::File::open(path)
.map_err(|e| format!("Invalid .out (io): failed to open file: {e}"))?;
let offset = meta.dynamic_offset() + (period as u64) * meta.period_bytes();
f.seek(SeekFrom::Start(offset))
.map_err(|e| format!("Invalid .out (io): failed to seek to period {period}: {e}"))?;
f.read_exact(&mut buf).map_err(|e| {
let kind = if e.kind() == std::io::ErrorKind::UnexpectedEof {
"incomplete"
} else {
"io"
};
format!("Invalid .out ({kind}): failed to read period {period}: {e}")
})?;
let f32_slice = |start: usize, count: usize| -> Vec<f32> {
(0..count)
.map(|i| {
let off = (start + i) * 4;
f32::from_le_bytes(buf[off..off + 4].try_into().unwrap())
})
.collect()
};
let node_demand = f32_slice(0, nn);
let node_head = f32_slice(nn, nn);
let node_pressure = f32_slice(2 * nn, nn);
let node_quality = f32_slice(3 * nn, nn);
let lb = 4 * nn;
let link_flow = f32_slice(lb, nl);
let link_velocity = f32_slice(lb + nl, nl);
let link_headloss = f32_slice(lb + 2 * nl, nl);
let link_quality = f32_slice(lb + 3 * nl, nl);
let link_status = f32_slice(lb + 4 * nl, nl);
let link_setting = f32_slice(lb + 5 * nl, nl);
let link_reaction_rate = f32_slice(lb + 6 * nl, nl);
let link_friction_factor = f32_slice(lb + 7 * nl, nl);
Ok(PeriodResult {
node_demand,
node_head,
node_pressure,
node_quality,
link_flow,
link_velocity,
link_headloss,
link_quality,
link_status,
link_setting,
link_reaction_rate,
link_friction_factor,
})
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ResultRanges {
pub pressure_min: f64,
pub pressure_max: f64,
pub head_min: f64,
pub head_max: f64,
pub demand_min: f64,
pub demand_max: f64,
pub flow_min: f64,
pub flow_max: f64,
pub velocity_min: f64,
pub velocity_max: f64,
pub quality_min: Option<f64>,
pub quality_max: Option<f64>,
}
impl Default for ResultRanges {
fn default() -> Self {
Self {
pressure_min: f64::INFINITY,
pressure_max: f64::NEG_INFINITY,
head_min: f64::INFINITY,
head_max: f64::NEG_INFINITY,
demand_min: f64::INFINITY,
demand_max: f64::NEG_INFINITY,
flow_min: f64::INFINITY,
flow_max: f64::NEG_INFINITY,
velocity_min: f64::INFINITY,
velocity_max: f64::NEG_INFINITY,
quality_min: None,
quality_max: None,
}
}
}
impl ResultRanges {
pub fn sanitise(&mut self) {
fn fix(min: &mut f64, max: &mut f64, default_min: f64, default_max: f64) {
if !min.is_finite() {
*min = default_min;
}
if !max.is_finite() {
*max = default_max;
}
if (*max - *min).abs() < 1e-9 {
*max = *min + 1.0;
}
}
fix(&mut self.pressure_min, &mut self.pressure_max, 0.0, 80.0);
fix(&mut self.head_min, &mut self.head_max, 0.0, 100.0);
fix(&mut self.demand_min, &mut self.demand_max, 0.0, 10.0);
fix(&mut self.flow_min, &mut self.flow_max, 0.0, 100.0);
fix(&mut self.velocity_min, &mut self.velocity_max, 0.0, 5.0);
if let (Some(qmin), Some(qmax)) = (&mut self.quality_min, &mut self.quality_max) {
fix(qmin, qmax, 0.0, 1.0);
}
}
pub fn update_from_period(&mut self, pr: &PeriodResult) {
for &v in &pr.node_pressure {
let v = v as f64;
if v < self.pressure_min {
self.pressure_min = v;
}
if v > self.pressure_max {
self.pressure_max = v;
}
}
for &v in &pr.node_head {
let v = v as f64;
if v < self.head_min {
self.head_min = v;
}
if v > self.head_max {
self.head_max = v;
}
}
for &v in &pr.node_demand {
let v = v as f64;
if v < self.demand_min {
self.demand_min = v;
}
if v > self.demand_max {
self.demand_max = v;
}
}
for &v in &pr.link_flow {
let v = v as f64;
if v < self.flow_min {
self.flow_min = v;
}
if v > self.flow_max {
self.flow_max = v;
}
}
for &v in &pr.link_velocity {
let v = v as f64;
if v < self.velocity_min {
self.velocity_min = v;
}
if v > self.velocity_max {
self.velocity_max = v;
}
}
for &v in pr.node_quality.iter().chain(pr.link_quality.iter()) {
let v = v as f64;
match &mut self.quality_min {
Some(m) => {
if v < *m {
*m = v;
}
}
None => {
self.quality_min = Some(v);
}
}
match &mut self.quality_max {
Some(m) => {
if v > *m {
*m = v;
}
}
None => {
self.quality_max = Some(v);
}
}
}
}
}
pub fn scan_ranges(
path: &std::path::Path,
meta: &OutMetadata,
max_samples: usize,
) -> Result<ResultRanges, String> {
use std::io::{Read, Seek, SeekFrom};
let nn = meta.n_nodes;
let nl = meta.n_links;
let np = meta.n_periods;
let pbytes = meta.period_bytes() as usize;
let sample_indices: Vec<usize> = if np <= max_samples {
(0..np).collect()
} else {
(0..max_samples)
.map(|i| i * (np - 1) / (max_samples - 1))
.collect()
};
let mut ranges = ResultRanges::default();
let mut f = std::fs::File::open(path)
.map_err(|e| format!("Invalid .out (io): failed to open file: {e}"))?;
let mut buf = vec![0u8; pbytes];
let f32_at = |buf: &[u8], idx: usize| -> f32 {
let off = idx * 4;
f32::from_le_bytes(buf[off..off + 4].try_into().unwrap())
};
for &p in &sample_indices {
let offset = meta.dynamic_offset() + (p as u64) * meta.period_bytes();
f.seek(SeekFrom::Start(offset))
.map_err(|e| format!("Invalid .out (io): failed to seek to period {p}: {e}"))?;
f.read_exact(&mut buf).map_err(|e| {
let kind = if e.kind() == std::io::ErrorKind::UnexpectedEof {
"incomplete"
} else {
"io"
};
format!("Invalid .out ({kind}): failed to read period {p}: {e}")
})?;
for i in 0..nn {
let d = f32_at(&buf, i) as f64;
let h = f32_at(&buf, nn + i) as f64;
let pr = f32_at(&buf, 2 * nn + i) as f64;
if d < ranges.demand_min {
ranges.demand_min = d;
}
if d > ranges.demand_max {
ranges.demand_max = d;
}
if h < ranges.head_min {
ranges.head_min = h;
}
if h > ranges.head_max {
ranges.head_max = h;
}
if pr < ranges.pressure_min {
ranges.pressure_min = pr;
}
if pr > ranges.pressure_max {
ranges.pressure_max = pr;
}
if meta.quality_flag != 0 {
let q = f32_at(&buf, 3 * nn + i) as f64;
match &mut ranges.quality_min {
Some(m) => {
if q < *m {
*m = q;
}
}
None => {
ranges.quality_min = Some(q);
}
}
match &mut ranges.quality_max {
Some(m) => {
if q > *m {
*m = q;
}
}
None => {
ranges.quality_max = Some(q);
}
}
}
}
let link_base = 4 * nn;
for i in 0..nl {
let fv = f32_at(&buf, link_base + i) as f64;
let vv = f32_at(&buf, link_base + nl + i) as f64;
if fv < ranges.flow_min {
ranges.flow_min = fv;
}
if fv > ranges.flow_max {
ranges.flow_max = fv;
}
if vv < ranges.velocity_min {
ranges.velocity_min = vv;
}
if vv > ranges.velocity_max {
ranges.velocity_max = vv;
}
if meta.quality_flag != 0 {
let q = f32_at(&buf, link_base + 3 * nl + i) as f64;
match &mut ranges.quality_min {
Some(m) => {
if q < *m {
*m = q;
}
}
None => {
ranges.quality_min = Some(q);
}
}
match &mut ranges.quality_max {
Some(m) => {
if q > *m {
*m = q;
}
}
None => {
ranges.quality_max = Some(q);
}
}
}
}
}
ranges.sanitise();
Ok(ranges)
}
#[derive(Debug)]
pub struct AnalyticsScan {
pub node_min_pressure: Vec<f64>,
pub link_max_velocity: Vec<f64>,
pub mb_series: Vec<f64>,
pub total_inflow: f64,
pub total_outflow: f64,
pub tank_head: Vec<Vec<f64>>,
}
pub fn scan_analytics(path: &std::path::Path, meta: &OutMetadata) -> Result<AnalyticsScan, String> {
let n_nodes = meta.n_nodes;
let n_tanks = meta.n_tanks;
let n_links = meta.n_links;
let n_periods = meta.n_periods;
let tank_start = n_nodes.saturating_sub(n_tanks);
let mut node_min_pressure: Vec<f64> = vec![f64::INFINITY; n_nodes];
let mut link_max_velocity: Vec<f64> = vec![0.0_f64; n_links];
let mut mb_series: Vec<f64> = vec![0.0_f64; n_periods];
let mut total_inflow: f64 = 0.0;
let mut total_outflow: f64 = 0.0;
let mut tank_head: Vec<Vec<f64>> = vec![vec![0.0_f64; n_periods]; n_tanks];
for p in 0..n_periods {
let pr = read_period(path, meta, p)?;
let mut period_inflow = 0.0_f64;
let mut period_outflow = 0.0_f64;
for &d in &pr.node_demand {
let d = d as f64;
if d > 0.0 {
period_inflow += d;
} else {
period_outflow -= d;
}
}
mb_series[p] = if period_inflow > 0.0 {
(period_outflow / period_inflow * 100.0).min(100.0)
} else {
100.0
};
total_inflow += period_inflow;
total_outflow += period_outflow;
for (i, &p_val) in pr.node_pressure.iter().enumerate() {
let v = p_val as f64;
if v < node_min_pressure[i] {
node_min_pressure[i] = v;
}
}
for (ti, h_val) in pr.node_head[tank_start..].iter().enumerate() {
if ti < n_tanks {
tank_head[ti][p] = *h_val as f64;
}
}
for (i, &v_val) in pr.link_velocity.iter().enumerate() {
let v = (v_val as f64).abs();
if v > link_max_velocity[i] {
link_max_velocity[i] = v;
}
}
}
Ok(AnalyticsScan {
node_min_pressure,
link_max_velocity,
mb_series,
total_inflow,
total_outflow,
tank_head,
})
}
pub fn parse(data: &[u8]) -> Result<OutFile, String> {
if data.len() < 12 {
return Err(format!("too short: {} bytes (minimum 12)", data.len()));
}
let epi_off = data.len() - 12;
let n_periods = i32::from_le_bytes(data[epi_off..epi_off + 4].try_into().unwrap()) as usize;
let mut cur = Cursor::new(data);
let magic_start = cur.read_i32()?;
if magic_start != 516_114_521 {
return Err(format!("unexpected magic at start: {magic_start}"));
}
let version = cur.read_i32()?;
let n_nodes = cur.read_i32()? as usize;
let n_tanks = cur.read_i32()? as usize;
let n_links = cur.read_i32()? as usize;
let n_pumps = cur.read_i32()? as usize;
let n_valves = cur.read_i32()? as usize;
let quality_flag = cur.read_i32()?;
let trace_node = cur.read_i32()?;
let flow_units = cur.read_i32()?;
let pressure_units = cur.read_i32()?;
let report_statistic = cur.read_i32()?;
let report_start = cur.read_i32()?;
let report_step = cur.read_i32()?;
let duration = cur.read_i32()?;
cur.skip(824)?;
cur.skip(32 * n_nodes + 32 * n_links + 12 * n_links + 4 * n_tanks)?;
let tank_areas = cur.read_f32s(n_tanks)?;
let elevations = cur.read_f32s(n_nodes)?;
let lengths = cur.read_f32s(n_links)?;
let diameters = cur.read_f32s(n_links)?;
let prolog = OutProlog {
magic: magic_start,
version,
n_nodes,
n_tanks,
n_links,
n_pumps,
n_valves,
quality_flag,
trace_node,
flow_units,
pressure_units,
report_statistic,
report_start,
report_step,
duration,
tank_areas,
elevations,
lengths,
diameters,
};
let mut pump_records = Vec::with_capacity(n_pumps);
for _ in 0..n_pumps {
let link_index = cur.read_i32()?;
let pct_online = cur.read_f32()?;
let avg_efficiency = cur.read_f32()?;
let avg_kwh_per_flow = cur.read_f32()?;
let avg_kw = cur.read_f32()?;
let peak_kw = cur.read_f32()?;
let avg_cost_per_day = cur.read_f32()?;
pump_records.push(PumpEnergyRecord {
link_index,
pct_online,
avg_efficiency,
avg_kwh_per_flow,
avg_kw,
peak_kw,
avg_cost_per_day,
});
}
let demand_charge = cur.read_f32()?;
let energy = OutEnergy {
pumps: pump_records,
demand_charge,
};
let mut periods = Vec::with_capacity(n_periods);
for _ in 0..n_periods {
let node_demand = cur.read_f32s(n_nodes)?;
let node_head = cur.read_f32s(n_nodes)?;
let node_pressure = cur.read_f32s(n_nodes)?;
let node_quality = cur.read_f32s(n_nodes)?;
let link_flow = cur.read_f32s(n_links)?;
let link_velocity = cur.read_f32s(n_links)?;
let link_headloss = cur.read_f32s(n_links)?;
let link_quality = cur.read_f32s(n_links)?;
let link_status = cur.read_f32s(n_links)?;
let link_setting = cur.read_f32s(n_links)?;
let link_reaction_rate = cur.read_f32s(n_links)?;
let link_friction_factor = cur.read_f32s(n_links)?;
periods.push(PeriodResult {
node_demand,
node_head,
node_pressure,
node_quality,
link_flow,
link_velocity,
link_headloss,
link_quality,
link_status,
link_setting,
link_reaction_rate,
link_friction_factor,
});
}
let bulk_rate = cur.read_f32()?;
let wall_rate = cur.read_f32()?;
let tank_rate = cur.read_f32()?;
let source_rate = cur.read_f32()?;
let reactions = OutReactions {
bulk_rate,
wall_rate,
tank_rate,
source_rate,
};
let n_periods_check = cur.read_i32()?;
let warning_flag = cur.read_i32()?;
let magic_end = cur.read_i32()?;
if magic_end != 516_114_521 {
return Err(format!("unexpected magic at end: {magic_end}"));
}
let epilog = OutEpilog {
n_periods: n_periods_check,
warning_flag,
magic: magic_end,
};
Ok(OutFile {
prolog,
energy,
periods,
reactions,
epilog,
})
}
struct Cursor<'a> {
data: &'a [u8],
pos: usize,
}
impl<'a> Cursor<'a> {
fn new(data: &'a [u8]) -> Self {
Self { data, pos: 0 }
}
fn read_i32(&mut self) -> Result<i32, String> {
let end = self.pos + 4;
if end > self.data.len() {
return Err(format!("unexpected EOF reading i32 at offset {}", self.pos));
}
let v = i32::from_le_bytes(self.data[self.pos..end].try_into().unwrap());
self.pos = end;
Ok(v)
}
fn read_f32(&mut self) -> Result<f32, String> {
let end = self.pos + 4;
if end > self.data.len() {
return Err(format!("unexpected EOF reading f32 at offset {}", self.pos));
}
let v = f32::from_le_bytes(self.data[self.pos..end].try_into().unwrap());
self.pos = end;
Ok(v)
}
fn read_f32s(&mut self, n: usize) -> Result<Vec<f32>, String> {
let end = self.pos + n * 4;
if end > self.data.len() {
return Err(format!(
"unexpected EOF reading {} f32 values at offset {}",
n, self.pos
));
}
let mut v = Vec::with_capacity(n);
for i in 0..n {
let off = self.pos + i * 4;
v.push(f32::from_le_bytes(
self.data[off..off + 4].try_into().unwrap(),
));
}
self.pos = end;
Ok(v)
}
fn skip(&mut self, n: usize) -> Result<(), String> {
let end = self.pos + n;
if end > self.data.len() {
return Err(format!(
"unexpected EOF skipping {} bytes at offset {}",
n, self.pos
));
}
self.pos = end;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::io::out_writer;
use crate::io::WritableSimulation;
use std::io::Cursor as StdCursor;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
fn make_minimal_out(n_nodes: usize, n_tanks: usize, n_links: usize, n_pumps: usize) -> Vec<u8> {
let prolog = 884 + 36 * n_nodes + 52 * n_links + 8 * n_tanks;
let energy = 28 * n_pumps + 4;
let n_periods: usize = 1;
let period = 4 * (4 * n_nodes + 8 * n_links);
let reactions: usize = 16;
let epilog: usize = 12;
let size = prolog + energy + n_periods * period + reactions + epilog;
let mut data = vec![0u8; size];
data[0..4].copy_from_slice(&516_114_521_i32.to_le_bytes());
data[4..8].copy_from_slice(&20012_i32.to_le_bytes());
data[8..12].copy_from_slice(&(n_nodes as i32).to_le_bytes());
data[12..16].copy_from_slice(&(n_tanks as i32).to_le_bytes());
data[16..20].copy_from_slice(&(n_links as i32).to_le_bytes());
data[20..24].copy_from_slice(&(n_pumps as i32).to_le_bytes());
let epi = size - 12;
data[epi..epi + 4].copy_from_slice(&(n_periods as i32).to_le_bytes());
data[epi + 8..epi + 12].copy_from_slice(&516_114_521_i32.to_le_bytes());
data
}
#[test]
fn parse_rejects_too_short_input() {
assert!(parse(&[0u8; 4]).is_err());
}
#[test]
fn parse_rejects_wrong_magic() {
let data = make_minimal_out(2, 1, 1, 0);
let mut bad = data.clone();
bad[0..4].copy_from_slice(&0_i32.to_le_bytes());
assert!(parse(&bad).is_err());
}
#[test]
fn parse_rejects_wrong_end_magic() {
let mut data = make_minimal_out(2, 1, 1, 0);
let len = data.len();
data[len - 4..len].copy_from_slice(&0_i32.to_le_bytes());
assert!(parse(&data).is_err());
}
#[test]
fn parse_dimensions_are_correct() {
let data = make_minimal_out(4, 2, 3, 1);
let out = parse(&data).expect("parse");
assert_eq!(out.prolog.n_nodes, 4);
assert_eq!(out.prolog.n_tanks, 2);
assert_eq!(out.prolog.n_links, 3);
assert_eq!(out.prolog.n_pumps, 1);
assert_eq!(out.prolog.elevations.len(), 4);
assert_eq!(out.prolog.tank_areas.len(), 2);
assert_eq!(out.prolog.lengths.len(), 3);
assert_eq!(out.prolog.diameters.len(), 3);
assert_eq!(out.energy.pumps.len(), 1);
assert_eq!(out.periods.len(), 1);
assert_eq!(out.periods[0].node_demand.len(), 4);
assert_eq!(out.periods[0].link_flow.len(), 3);
}
#[test]
fn parse_roundtrip_vs_writer() {
use std::path::Path;
struct MockSession {
network: crate::Network,
snapshots: Vec<crate::io::HydSnapshot>,
}
impl WritableSimulation for MockSession {
fn net(&self) -> &crate::Network {
&self.network
}
fn snapshots(&self) -> &[crate::io::HydSnapshot] {
&self.snapshots
}
fn pump_energy_at(&self, _: usize) -> Option<&crate::io::PumpEnergy> {
None
}
fn peak_demand_kw(&self) -> f64 {
0.0
}
fn mass_balance(&self) -> Option<&crate::io::MassBalance> {
None
}
fn warnings(&self) -> &[crate::io::SimWarning] {
&[]
}
fn pump_energy_by_id(&self, _: &str) -> Option<&crate::io::PumpEnergy> {
None
}
fn analysis_times(
&self,
) -> (Option<std::time::SystemTime>, Option<std::time::SystemTime>) {
(None, None)
}
fn flow_balance(&self) -> Option<&crate::io::FlowBalance> {
None
}
fn flow_balance_summary(&self) -> Option<crate::io::FlowBalanceSummary> {
None
}
}
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("tests/fixtures/single_pipe_hw.inp");
let Ok(bytes) = std::fs::read(&path) else {
return;
};
let network = crate::io::parse(&bytes).expect("parse network");
let n_nodes = network.nodes.len();
let n_links = network.links.len();
let node_states = network
.nodes
.iter()
.map(|n| crate::NodeState {
head: n.base.elevation,
..Default::default()
})
.collect();
let link_states = network
.links
.iter()
.map(|_| crate::LinkState::default())
.collect();
let session = MockSession {
network,
snapshots: vec![crate::io::HydSnapshot {
t: 0.0,
node_states,
link_states,
}],
};
let mut buf = StdCursor::new(Vec::new());
out_writer::write_binary_output(&mut buf, &session, "test.inp", "", crate::FlowUnits::Gpm)
.expect("write");
let raw = buf.into_inner();
let out = parse(&raw).expect("parse writer output");
assert_eq!(out.prolog.n_nodes, n_nodes);
assert_eq!(out.prolog.n_links, n_links);
assert_eq!(out.periods.len(), 1);
assert_eq!(out.periods[0].node_demand.len(), n_nodes);
assert_eq!(out.periods[0].link_flow.len(), n_links);
}
fn write_temp_bytes(data: &[u8]) -> std::path::PathBuf {
let mut path = std::env::temp_dir();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let pid = std::process::id();
let seq = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
path.push(format!("hydra-out-reader-test-{pid}-{nanos}-{seq}.out"));
std::fs::write(&path, data).expect("write temp .out");
path
}
#[test]
fn read_metadata_checked_classifies_corrupt_magic() {
let mut data = make_minimal_out(2, 1, 1, 0);
data[0..4].copy_from_slice(&0_i32.to_le_bytes());
let path = write_temp_bytes(&data);
let err = read_metadata_checked(&path).expect_err("expected corrupt classification");
let _ = std::fs::remove_file(&path);
assert_eq!(err.kind, OutValidityKind::Corrupt);
}
#[test]
fn read_metadata_checked_classifies_incomplete_truncation() {
let data = make_minimal_out(3, 1, 2, 0);
let truncated_len = data.len().saturating_sub(64);
let path = write_temp_bytes(&data[..truncated_len]);
let err = read_metadata_checked(&path).expect_err("expected incomplete classification");
let _ = std::fs::remove_file(&path);
assert_eq!(err.kind, OutValidityKind::Incomplete);
}
#[test]
fn read_metadata_checked_classifies_unsupported_version() {
let mut data = make_minimal_out(2, 1, 1, 0);
data[4..8].copy_from_slice(&12345_i32.to_le_bytes());
let path = write_temp_bytes(&data);
let err = read_metadata_checked(&path).expect_err("expected unsupported classification");
let _ = std::fs::remove_file(&path);
assert_eq!(err.kind, OutValidityKind::Unsupported);
}
#[test]
fn read_metadata_missing_file_classified_as_missing() {
let path = std::path::PathBuf::from("/tmp/hydra_test_this_file_does_not_exist_ever.out");
let err = read_metadata_checked(&path).expect_err("expected missing classification");
assert_eq!(err.kind, OutValidityKind::Missing);
}
#[test]
fn read_metadata_checked_succeeds_on_valid_file() {
let data = make_minimal_out(3, 1, 2, 0);
let path = write_temp_bytes(&data);
let meta = read_metadata_checked(&path).expect("valid file should parse");
let _ = std::fs::remove_file(&path);
assert_eq!(meta.n_nodes, 3);
assert_eq!(meta.n_tanks, 1);
assert_eq!(meta.n_links, 2);
assert_eq!(meta.n_pumps, 0);
assert_eq!(meta.n_periods, 1);
}
#[test]
fn out_metadata_byte_size_calculations() {
let meta = OutMetadata {
n_nodes: 4,
n_tanks: 2,
n_links: 3,
n_pumps: 1,
quality_flag: 0,
report_start: 0.0,
report_step: 3600.0,
n_periods: 5,
};
assert_eq!(meta.prolog_bytes(), (884 + 36 * 4 + 52 * 3 + 8 * 2) as u64);
assert_eq!(meta.energy_bytes(), (28 + 4) as u64);
assert_eq!(meta.period_bytes(), (4 * (4 * 4 + 8 * 3)) as u64);
assert_eq!(
meta.dynamic_offset(),
meta.prolog_bytes() + meta.energy_bytes()
);
}
#[test]
fn out_metadata_snapshot_times() {
let meta = OutMetadata {
n_nodes: 2,
n_tanks: 1,
n_links: 1,
n_pumps: 0,
quality_flag: 0,
report_start: 0.0,
report_step: 3600.0,
n_periods: 3,
};
assert_eq!(meta.snapshot_times(), vec![0.0, 3600.0, 7200.0]);
}
#[test]
fn result_ranges_sanitise_replaces_infinities() {
let mut r = ResultRanges::default();
r.sanitise();
assert!(r.pressure_min.is_finite(), "pressure_min should be finite");
assert!(r.pressure_max.is_finite(), "pressure_max should be finite");
assert!(r.head_min.is_finite(), "head_min should be finite");
assert!(r.head_max.is_finite(), "head_max should be finite");
assert!(r.demand_min.is_finite(), "demand_min should be finite");
assert!(r.demand_max.is_finite(), "demand_max should be finite");
assert!(r.flow_min.is_finite(), "flow_min should be finite");
assert!(r.flow_max.is_finite(), "flow_max should be finite");
assert!(r.velocity_min.is_finite(), "velocity_min should be finite");
assert!(r.velocity_max.is_finite(), "velocity_max should be finite");
}
#[test]
fn result_ranges_sanitise_expands_equal_min_max() {
let mut r = ResultRanges {
pressure_min: 5.0,
pressure_max: 5.0, head_min: f64::INFINITY,
head_max: f64::NEG_INFINITY,
demand_min: f64::INFINITY,
demand_max: f64::NEG_INFINITY,
flow_min: f64::INFINITY,
flow_max: f64::NEG_INFINITY,
velocity_min: f64::INFINITY,
velocity_max: f64::NEG_INFINITY,
..Default::default()
};
r.sanitise();
assert!(
r.pressure_max > r.pressure_min,
"equal min/max should be expanded: min={}, max={}",
r.pressure_min,
r.pressure_max
);
}
#[test]
fn result_ranges_update_from_period_tracks_min_max() {
let pr = PeriodResult {
node_demand: vec![1.0, 3.0],
node_head: vec![10.0, 20.0],
node_pressure: vec![5.0, 15.0],
node_quality: vec![],
link_flow: vec![2.0],
link_velocity: vec![0.5],
link_headloss: vec![0.0],
link_quality: vec![],
link_status: vec![1.0],
link_setting: vec![1.0],
link_reaction_rate: vec![0.0],
link_friction_factor: vec![0.0],
};
let mut ranges = ResultRanges::default();
ranges.update_from_period(&pr);
assert_eq!(ranges.pressure_min, 5.0);
assert_eq!(ranges.pressure_max, 15.0);
assert_eq!(ranges.demand_min, 1.0);
assert_eq!(ranges.demand_max, 3.0);
assert_eq!(ranges.flow_min, 2.0);
assert_eq!(ranges.velocity_min, 0.5);
}
}