use std::fmt::Write as _;
use super::units::make_ucf;
use crate::{
ActionValue, CurveKind, HeadLossFormula, LinkKind, LinkStatus, LogicOp, MixModel, Network,
NodeKind, PremiseAttribute, PremiseObject, PremiseOperator, QualityMode, ReportSelection,
ReportStatus, SourceType, StatisticType, TriggerType, ValveType, WallOrder,
};
pub fn write_inp(network: &Network) -> Vec<u8> {
let mut out = String::with_capacity(64 * 1024);
let ucf = make_ucf(network.options.flow_units, network.options.specific_gravity);
let is_dw = network.options.head_loss_formula == HeadLossFormula::DarcyWeisbach;
let node_id: Vec<&str> = {
let mut v = vec![""; network.nodes.len() + 1]; for n in &network.nodes {
if n.base.index < v.len() {
v[n.base.index] = &n.base.id;
}
}
v
};
let link_id: Vec<&str> = {
let mut v = vec![""; network.links.len() + 1];
for l in &network.links {
if l.base.index < v.len() {
v[l.base.index] = &l.base.id;
}
}
v
};
if !network.title.is_empty() {
out.push_str("[TITLE]\n");
for line in &network.title {
out.push_str(line);
out.push('\n');
}
out.push('\n');
}
{
let junctions: Vec<_> = network
.nodes
.iter()
.filter(|n| matches!(n.kind, NodeKind::Junction(_)))
.collect();
if !junctions.is_empty() {
out.push_str("[JUNCTIONS]\n");
out.push_str(";ID Elev Demand Pattern\n");
for n in &junctions {
if let NodeKind::Junction(ref j) = n.kind {
let elev = n.base.elevation * ucf.elev;
let base_demand = j
.demands
.first()
.map(|d| d.base_demand * ucf.flow)
.unwrap_or(0.0);
let pattern = j
.demands
.first()
.and_then(|d| d.pattern.as_deref())
.unwrap_or("");
let _ = writeln!(
out,
" {:<16} {:>12.4} {:>12.4} {}",
n.base.id, elev, base_demand, pattern
);
}
}
out.push('\n');
}
}
{
let reservoirs: Vec<_> = network
.nodes
.iter()
.filter(|n| matches!(n.kind, NodeKind::Reservoir(_)))
.collect();
if !reservoirs.is_empty() {
out.push_str("[RESERVOIRS]\n");
out.push_str(";ID Head Pattern\n");
for n in &reservoirs {
if let NodeKind::Reservoir(ref r) = n.kind {
let head = n.base.elevation * ucf.elev;
let pattern = r.head_pattern.as_deref().unwrap_or("");
let _ = writeln!(out, " {:<16} {:>12.4} {}", n.base.id, head, pattern);
}
}
out.push('\n');
}
}
{
let tanks: Vec<_> = network
.nodes
.iter()
.filter(|n| matches!(n.kind, NodeKind::Tank(_)))
.collect();
if !tanks.is_empty() {
out.push_str("[TANKS]\n");
out.push_str(";ID Elevation InitLevel MinLevel MaxLevel Diameter MinVol VolCurve Overflow\n");
for n in &tanks {
if let NodeKind::Tank(ref t) = n.kind {
let bottom_ft = n.base.elevation - t.min_level;
let bottom_user = bottom_ft * ucf.elev;
let init = t.initial_level * ucf.elev;
let min_l = t.min_level * ucf.elev;
let max_l = t.max_level * ucf.elev;
let diam = t.diameter * ucf.elev;
let min_v = t.min_volume * ucf.vol;
let vol_curve = t.volume_curve.as_deref().unwrap_or("");
let overflow = if t.overflow { "YES" } else { "" };
let _ =
writeln!(out,
" {:<16} {:>12.4} {:>12.4} {:>12.4} {:>12.4} {:>12.4} {:>12.4} {:<14}{}",
n.base.id, bottom_user, init, min_l, max_l, diam, min_v,
vol_curve, overflow);
}
}
out.push('\n');
}
}
{
let pipes: Vec<_> = network
.links
.iter()
.filter(|l| matches!(l.kind, LinkKind::Pipe(_)))
.collect();
if !pipes.is_empty() {
out.push_str("[PIPES]\n");
out.push_str(";ID Node1 Node2 Length Diameter Roughness MinorLoss Status\n");
for l in &pipes {
if let LinkKind::Pipe(ref p) = l.kind {
let from = node_id.get(l.base.from_node).copied().unwrap_or("");
let to = node_id.get(l.base.to_node).copied().unwrap_or("");
let len = p.length * ucf.elev;
let diam = p.diameter * ucf.diam;
let rough = if is_dw {
p.roughness * 1000.0 * ucf.elev
} else {
p.roughness
};
let minor = if p.minor_loss > 0.0 {
let d4 = p.diameter.powi(4);
p.minor_loss * d4 / 0.08262
} else {
0.0
};
let status = if p.check_valve {
"CV"
} else {
link_status_str(l.base.initial_status)
};
let _ = writeln!(
out,
" {:<16} {:<14} {:<14} {:>12.4} {:>12.4} {:>12.4} {:>12.4} {}",
l.base.id, from, to, len, diam, rough, minor, status
);
}
}
out.push('\n');
}
}
{
let pumps: Vec<_> = network
.links
.iter()
.filter(|l| matches!(l.kind, LinkKind::Pump(_)))
.collect();
if !pumps.is_empty() {
out.push_str("[PUMPS]\n");
out.push_str(";ID Node1 Node2 Parameters\n");
for l in &pumps {
if let LinkKind::Pump(ref p) = l.kind {
let from = node_id.get(l.base.from_node).copied().unwrap_or("");
let to = node_id.get(l.base.to_node).copied().unwrap_or("");
let mut params = String::new();
if let Some(ref curve_id) = p.head_curve {
let _ = write!(params, " HEAD {}", curve_id);
}
if let Some(pw) = p.power {
let pw_user = pw * ucf.power;
let _ = write!(params, " POWER {:.4}", pw_user);
}
if let Some(speed) = l.base.initial_setting {
if (speed - 1.0).abs() > 1e-9 {
let _ = write!(params, " SPEED {:.4}", speed);
}
}
if let Some(ref pat) = p.speed_pattern {
let _ = write!(params, " PATTERN {}", pat);
}
let _ = writeln!(out, " {:<16} {:<14} {:<14}{}", l.base.id, from, to, params);
}
}
out.push('\n');
}
}
{
let valves: Vec<_> = network
.links
.iter()
.filter(|l| matches!(l.kind, LinkKind::Valve(_)))
.collect();
if !valves.is_empty() {
out.push_str("[VALVES]\n");
out.push_str(";ID Node1 Node2 Diameter Type Setting MinorLoss\n");
for l in &valves {
if let LinkKind::Valve(ref v) = l.kind {
let from = node_id.get(l.base.from_node).copied().unwrap_or("");
let to = node_id.get(l.base.to_node).copied().unwrap_or("");
let diam = v.diameter * ucf.diam;
let vtype = valve_type_str(v.valve_type);
let setting_user = l
.base
.initial_setting
.map(|s| {
match v.valve_type {
ValveType::Prv | ValveType::Psv | ValveType::Pbv => {
s * ucf.pressure
}
ValveType::Fcv => s * ucf.flow,
_ => s, }
})
.unwrap_or(0.0);
let minor = if v.minor_loss > 0.0 {
let d4 = v.diameter.powi(4);
v.minor_loss * d4 / 0.08262
} else {
0.0
};
let _ = writeln!(
out,
" {:<16} {:<14} {:<14} {:>12.4} {:<8} {:>12.4} {:>12.4}",
l.base.id, from, to, diam, vtype, setting_user, minor
);
}
}
out.push('\n');
}
}
if !network.node_tags.is_empty() || !network.link_tags.is_empty() {
out.push_str("[TAGS]\n");
for n in &network.nodes {
if let Some(tag) = network.node_tags.get(&n.base.id) {
let _ = writeln!(out, " NODE {:<16} {}", n.base.id, tag);
}
}
for l in &network.links {
if let Some(tag) = network.link_tags.get(&l.base.id) {
let _ = writeln!(out, " LINK {:<16} {}", l.base.id, tag);
}
}
out.push('\n');
}
{
let mut any = false;
for n in &network.nodes {
if let NodeKind::Junction(ref j) = n.kind {
if j.demands.len() > 1 {
any = true;
break;
}
}
}
if any {
out.push_str("[DEMANDS]\n");
out.push_str(";Junction Demand Pattern Category\n");
for n in &network.nodes {
if let NodeKind::Junction(ref j) = n.kind {
for d in j.demands.iter().skip(1) {
let demand = d.base_demand * ucf.flow;
let pattern = d.pattern.as_deref().unwrap_or("");
let name = d.name.as_deref().unwrap_or("");
let _ = writeln!(
out,
" {:<16} {:>12.4} {:<14}{}",
n.base.id, demand, pattern, name
);
}
}
}
out.push('\n');
}
}
{
let mut status_lines: Vec<String> = Vec::new();
for l in &network.links {
match &l.kind {
LinkKind::Pipe(_) => {
if l.base.initial_status == LinkStatus::Closed {
status_lines.push(format!(" {:<16} Closed", l.base.id));
}
}
LinkKind::Pump(_) => {
if l.base.initial_status == LinkStatus::Closed {
status_lines.push(format!(" {:<16} Closed", l.base.id));
}
}
LinkKind::Valve(_) => {
match l.base.initial_status {
LinkStatus::Open => status_lines.push(format!(" {:<16} Open", l.base.id)),
LinkStatus::Closed => {
status_lines.push(format!(" {:<16} Closed", l.base.id))
}
_ => {}
}
}
}
}
if !status_lines.is_empty() {
out.push_str("[STATUS]\n");
out.push_str(";ID Status\n");
for line in status_lines {
out.push_str(&line);
out.push('\n');
}
out.push('\n');
}
}
if !network.patterns.is_empty() {
out.push_str("[PATTERNS]\n");
out.push_str(";ID Multipliers\n");
for pat in &network.patterns {
let chunks: Vec<_> = pat.factors.chunks(6).collect();
for chunk in chunks.iter() {
let vals: Vec<String> = chunk.iter().map(|f| format!("{:.4}", f)).collect();
let _ = writeln!(out, " {:<16} {}", pat.id, vals.join(" "));
}
}
out.push('\n');
}
if !network.curves.is_empty() {
out.push_str("[CURVES]\n");
out.push_str(";ID X-Value Y-Value\n");
for curve in &network.curves {
let kind_comment = match curve.kind {
CurveKind::PumpHead => Some(";PUMP"),
CurveKind::PumpEfficiency => Some(";EFFICIENCY"),
CurveKind::TankVolume => Some(";VOLUME"),
CurveKind::GpvHeadloss => Some(";HEADLOSS"),
_ => None,
};
if let Some(cmt) = kind_comment {
let _ = writeln!(out, "{}", cmt);
}
for pt in &curve.points {
let (xu, yu) = match curve.kind {
CurveKind::PumpHead => (pt.x * ucf.flow, pt.y * ucf.elev),
CurveKind::PumpEfficiency => (pt.x * ucf.flow, pt.y),
CurveKind::TankVolume => (pt.x * ucf.elev, pt.y * ucf.vol),
CurveKind::GpvHeadloss => (pt.x * ucf.flow, pt.y * ucf.elev),
_ => (pt.x, pt.y),
};
let _ = writeln!(out, " {:<16} {:>12.4} {:>12.4}", curve.id, xu, yu);
}
}
out.push('\n');
}
if !network.controls.is_empty() {
out.push_str("[CONTROLS]\n");
for ctrl in &network.controls {
if !ctrl.enabled {
continue;
}
let link_id_str = link_id.get(ctrl.link).copied().unwrap_or("?");
let action_str = match (ctrl.action_status, ctrl.action_setting) {
(Some(LinkStatus::Open), _) => "OPEN".to_string(),
(Some(LinkStatus::Closed), _) => "CLOSED".to_string(),
(_, Some(s)) => {
let link_setting_user =
if let Some(link) = network.links.get(ctrl.link.saturating_sub(1)) {
if let LinkKind::Valve(ref v) = link.kind {
match v.valve_type {
ValveType::Prv | ValveType::Psv | ValveType::Pbv => {
s * ucf.pressure
}
ValveType::Fcv => s * ucf.flow,
_ => s,
}
} else {
s }
} else {
s
};
format!("{:.4}", link_setting_user)
}
_ => continue,
};
let trigger_str = match ctrl.trigger_type {
TriggerType::Timer => {
let secs = ctrl.trigger_time.unwrap_or(0.0);
format!("AT TIME {}", fmt_duration_hm(secs))
}
TriggerType::TimeOfDay => {
let secs = ctrl.trigger_time.unwrap_or(0.0);
format!("AT CLOCKTIME {}", fmt_clocktime(secs))
}
TriggerType::HiLevel | TriggerType::LowLevel => {
let node_idx = ctrl.trigger_node.unwrap_or(0);
let node_id_str = node_id.get(node_idx).copied().unwrap_or("?");
let dir = if ctrl.trigger_type == TriggerType::HiLevel {
"ABOVE"
} else {
"BELOW"
};
let grade_internal = ctrl.trigger_grade.unwrap_or(0.0);
let grade_user =
if let Some(node) = network.nodes.get(node_idx.saturating_sub(1)) {
match &node.kind {
NodeKind::Tank(ref t) => {
let bottom = node.base.elevation - t.min_level;
(grade_internal - bottom) * ucf.elev
}
_ => (grade_internal - node.base.elevation) * ucf.pressure,
}
} else {
grade_internal
};
format!("IF NODE {} {} {:.4}", node_id_str, dir, grade_user)
}
};
let _ = writeln!(out, " LINK {} {} {}", link_id_str, action_str, trigger_str);
}
out.push('\n');
}
if !network.rules.is_empty() {
out.push_str("[RULES]\n");
for (ri, rule) in network.rules.iter().enumerate() {
let _ = writeln!(out, " RULE R{}", ri + 1);
for (pi, prem) in rule.premises.iter().enumerate() {
let connective = if pi == 0 {
"IF"
} else {
match prem.connective {
Some(LogicOp::And) | None => "AND",
Some(LogicOp::Or) => "OR",
}
};
let obj_str = match prem.object {
PremiseObject::Node(idx) => {
let nid = node_id.get(idx).copied().unwrap_or("?");
format!("NODE {}", nid)
}
PremiseObject::Link(idx) => {
let lid = link_id.get(idx).copied().unwrap_or("?");
format!("LINK {}", lid)
}
PremiseObject::Clock => "SYSTEM".to_string(),
};
let attr_str = premise_attr_str(prem.attribute);
let op_str = premise_op_str(prem.operator);
let value_user = convert_premise_value(prem, &ucf);
let _ = writeln!(
out,
" {} {} {} {} {:.4}",
connective, obj_str, attr_str, op_str, value_user
);
}
for action in &rule.then_actions {
let lid = link_id.get(action.link).copied().unwrap_or("?");
let val = rule_action_str(&action.value, action.link, &network.links, &ucf);
let _ = writeln!(out, " THEN LINK {} {}", lid, val);
}
for action in &rule.else_actions {
let lid = link_id.get(action.link).copied().unwrap_or("?");
let val = rule_action_str(&action.value, action.link, &network.links, &ucf);
let _ = writeln!(out, " ELSE LINK {} {}", lid, val);
}
if rule.priority != 0.0 {
let _ = writeln!(out, " PRIORITY {:.4}", rule.priority);
}
out.push('\n');
}
out.push('\n');
}
{
let opts = &network.options;
let mut energy_lines: Vec<String> = Vec::new();
let default_eff = opts.energy_efficiency * 100.0; if (default_eff - 75.0).abs() > 1e-6 {
energy_lines.push(format!(" Global Efficiency {:.4}", default_eff));
}
if opts.energy_price > 0.0 {
energy_lines.push(format!(" Global Price {:.4}", opts.energy_price));
}
if let Some(ref pat) = opts.energy_price_pattern {
energy_lines.push(format!(" Global Pattern {}", pat));
}
if opts.peak_demand_charge > 0.0 {
energy_lines.push(format!(
" Demand Charge {:.4}",
opts.peak_demand_charge
));
}
for l in &network.links {
if let LinkKind::Pump(ref p) = l.kind {
if let Some(price) = p.energy_price {
energy_lines.push(format!(" Pump {} Price {:.4}", l.base.id, price));
}
if let Some(ref pat) = p.price_pattern {
energy_lines.push(format!(" Pump {} Pattern {}", l.base.id, pat));
}
if let Some(ref eff) = p.efficiency_curve {
energy_lines.push(format!(" Pump {} Efficiency {}", l.base.id, eff));
}
}
}
if !energy_lines.is_empty() {
out.push_str("[ENERGY]\n");
for line in energy_lines {
out.push_str(&line);
out.push('\n');
}
out.push('\n');
}
}
{
let opts = &network.options;
let mut rxn_lines: Vec<String> = Vec::new();
if opts.bulk_order != 1.0 {
rxn_lines.push(format!(" Order Bulk {:.4}", opts.bulk_order));
}
if opts.tank_order != 1.0 {
rxn_lines.push(format!(" Order Tank {:.4}", opts.tank_order));
}
if opts.wall_order != WallOrder::One {
rxn_lines.push(format!(
" Order Wall {}",
match opts.wall_order {
WallOrder::Zero => 0,
WallOrder::One => 1,
}
));
}
if opts.bulk_coeff != 0.0 {
rxn_lines.push(format!(" Global Bulk {:.4}", opts.bulk_coeff * 86400.0));
}
if opts.wall_coeff != 0.0 {
rxn_lines.push(format!(" Global Wall {:.4}", opts.wall_coeff * 86400.0));
}
if opts.conc_limit != 0.0 {
rxn_lines.push(format!(" Limiting Potential {:.4}", opts.conc_limit));
}
if opts.roughness_reaction_factor != 0.0 {
rxn_lines.push(format!(
" Roughness Correlation {:.4}",
opts.roughness_reaction_factor
));
}
for l in &network.links {
if let LinkKind::Pipe(ref p) = l.kind {
if let Some(kb) = p.bulk_coeff {
rxn_lines.push(format!(" Bulk {:<16} {:.4}", l.base.id, kb * 86400.0));
}
if let Some(kw) = p.wall_coeff {
rxn_lines.push(format!(" Wall {:<16} {:.4}", l.base.id, kw * 86400.0));
}
}
}
for n in &network.nodes {
if let NodeKind::Tank(ref t) = n.kind {
if t.bulk_coeff != 0.0 {
rxn_lines.push(format!(
" Tank {:<16} {:.4}",
n.base.id,
t.bulk_coeff * 86400.0
));
}
}
}
if !rxn_lines.is_empty() {
out.push_str("[REACTIONS]\n");
for line in rxn_lines {
out.push_str(&line);
out.push('\n');
}
out.push('\n');
}
}
{
let sources: Vec<_> = network
.nodes
.iter()
.filter(|n| n.source.is_some())
.collect();
if !sources.is_empty() {
out.push_str("[SOURCES]\n");
out.push_str(";Node Type Quality Pattern\n");
for n in &sources {
if let Some(ref src) = n.source {
let src_type = match src.kind {
SourceType::Concentration => "CONCEN",
SourceType::Mass => "MASS",
SourceType::Setpoint => "SETPOINT",
SourceType::FlowPaced => "FLOWPACED",
};
let pattern = src.pattern.as_deref().unwrap_or("");
let _ = writeln!(
out,
" {:<16} {:<14} {:>12.4} {}",
n.base.id, src_type, src.base_value, pattern
);
}
}
out.push('\n');
}
}
{
let non_default: Vec<_> = network
.nodes
.iter()
.filter(|n| {
if let NodeKind::Tank(ref t) = n.kind {
t.mix_model != MixModel::Cstr || t.mix_fraction != 0.0
} else {
false
}
})
.collect();
if !non_default.is_empty() {
out.push_str("[MIXING]\n");
out.push_str(";Tank Model Fraction\n");
for n in &non_default {
if let NodeKind::Tank(ref t) = n.kind {
let model_str = match t.mix_model {
MixModel::Cstr => "MIXED",
MixModel::TwoCompartment => "2COMP",
MixModel::Fifo => "FIFO",
MixModel::Lifo => "LIFO",
};
if t.mix_model == MixModel::TwoCompartment {
let _ = writeln!(
out,
" {:<16} {:<14} {:.4}",
n.base.id, model_str, t.mix_fraction
);
} else {
let _ = writeln!(out, " {:<16} {}", n.base.id, model_str);
}
}
}
out.push('\n');
}
}
{
let emitters: Vec<_> = network
.nodes
.iter()
.filter(|n| {
if let NodeKind::Junction(ref j) = n.kind {
j.emitter_coeff > 0.0
} else {
false
}
})
.collect();
if !emitters.is_empty() {
out.push_str("[EMITTERS]\n");
out.push_str(";Junction Flow Coefficient\n");
for n in &emitters {
if let NodeKind::Junction(ref j) = n.kind {
let qexp = 1.0 / j.emitter_exp;
let ucf_emit = ucf.flow.powf(qexp) / ucf.pressure;
let coeff_user = (ucf_emit / j.emitter_coeff).powf(j.emitter_exp);
let _ = writeln!(out, " {:<16} {:>12.4}", n.base.id, coeff_user);
}
}
out.push('\n');
}
}
{
let non_zero: Vec<_> = network
.nodes
.iter()
.filter(|n| n.base.initial_quality != 0.0)
.collect();
if !non_zero.is_empty() {
out.push_str("[QUALITY]\n");
out.push_str(";Node InitQuality\n");
for n in &non_zero {
let _ = writeln!(out, " {:<16} {:>12.4}", n.base.id, n.base.initial_quality);
}
out.push('\n');
}
}
{
out.push_str("[OPTIONS]\n");
let opts = &network.options;
let _ = writeln!(out, " Units {}", flow_units_str(opts.flow_units));
let _ = writeln!(
out,
" Headloss {}",
match opts.head_loss_formula {
HeadLossFormula::HazenWilliams => "H-W",
HeadLossFormula::DarcyWeisbach => "D-W",
HeadLossFormula::ChezyManning => "C-M",
}
);
if opts.specific_gravity != 1.0 {
let _ = writeln!(out, " Specific Gravity {:.4}", opts.specific_gravity);
}
const VISCOS: f64 = 1.022e-6; const DIFFUS: f64 = 1.208e-9; if (opts.viscosity - VISCOS).abs() > 1e-20 {
let len2 = ucf.elev * ucf.elev;
let v_user = opts.viscosity * len2;
let _ = writeln!(out, " Viscosity {:.6}", v_user);
}
if (opts.diffusivity - DIFFUS).abs() > 1e-20 {
let len2 = ucf.elev * ucf.elev;
let d_user = opts.diffusivity * len2;
let _ = writeln!(out, " Diffusivity {:.6}", d_user);
}
let _ = writeln!(out, " Trials {}", opts.max_iter);
let _ = writeln!(out, " Accuracy {:.6}", opts.flow_tol);
if opts.head_error_limit > 0.0 {
let _ = writeln!(
out,
" HEADERROR {:.6}",
opts.head_error_limit * ucf.elev
);
}
if opts.flow_change_limit > 0.0 {
let _ = writeln!(
out,
" FLOWCHANGE {:.6}",
opts.flow_change_limit * ucf.flow
);
}
if opts.damp_limit > 0.0 {
let _ = writeln!(out, " DAMPLIMIT {:.6}", opts.damp_limit);
}
let _ = writeln!(
out,
" Unbalanced {}",
if opts.extra_iter < 0 {
"STOP".to_string()
} else {
format!("CONTINUE {}", opts.extra_iter)
}
);
let demand_model_str = match opts.demand_model {
crate::DemandModel::DemandDriven => "DDA",
crate::DemandModel::PressureDriven => "PDA",
};
let _ = writeln!(out, " Demand Model {}", demand_model_str);
if opts.demand_multiplier != 1.0 {
let _ = writeln!(out, " Demand Multiplier {:.4}", opts.demand_multiplier);
}
if let Some(ref pat) = opts.default_pattern {
let _ = writeln!(out, " Default Pattern {}", pat);
}
if opts.demand_model == crate::DemandModel::PressureDriven {
let _ = writeln!(
out,
" Minimum Pressure {:.4}",
opts.pda_min_pressure * ucf.pressure
);
let _ = writeln!(
out,
" Required Pressure {:.4}",
opts.pda_required_pressure * ucf.pressure
);
let _ = writeln!(out, " Pressure Exponent {:.4}", opts.pda_pressure_exponent);
}
match opts.quality_mode {
QualityMode::None => {
let _ = writeln!(out, " Quality None");
}
QualityMode::Chemical => {
if opts.chem_name.is_empty() {
let _ = writeln!(out, " Quality Chemical");
} else {
let _ = writeln!(
out,
" Quality {} {}",
opts.chem_name, opts.chem_units
);
}
}
QualityMode::Age => {
let _ = writeln!(out, " Quality Age");
}
QualityMode::Trace => {
let trace = opts.trace_node.as_deref().unwrap_or("");
let _ = writeln!(out, " Quality Trace {}", trace);
}
}
if opts.quality_tolerance != 0.01 {
let _ = writeln!(out, " Tolerance {:.6}", opts.quality_tolerance);
}
if !opts.emitter_backflow {
let _ = writeln!(out, " Emitter Exponent {:.4}", 0.5); }
if opts.check_freq != 2 {
let _ = writeln!(out, " CHECKFREQ {}", opts.check_freq);
}
if opts.max_check != 10 {
let _ = writeln!(out, " MAXCHECK {}", opts.max_check);
}
out.push('\n');
}
{
out.push_str("[TIMES]\n");
let opts = &network.options;
let _ = writeln!(
out,
" Duration {}",
fmt_duration_hm(opts.duration)
);
let _ = writeln!(
out,
" Hydraulic Timestep {}",
fmt_duration_hm(opts.hyd_step)
);
let _ = writeln!(
out,
" Quality Timestep {}",
fmt_duration_hm(opts.qual_step)
);
let _ = writeln!(
out,
" Report Timestep {}",
fmt_duration_hm(opts.report_step)
);
if opts.report_start > 0.0 {
let _ = writeln!(
out,
" Report Start {}",
fmt_duration_hm(opts.report_start)
);
}
if opts.pattern_step != opts.hyd_step {
let _ = writeln!(
out,
" Pattern Timestep {}",
fmt_duration_hm(opts.pattern_step)
);
}
if opts.pattern_start > 0.0 {
let _ = writeln!(
out,
" Pattern Start {}",
fmt_duration_hm(opts.pattern_start)
);
}
if opts.start_clocktime > 0.0 {
let _ = writeln!(
out,
" Start Clocktime {}",
fmt_clocktime(opts.start_clocktime)
);
}
if opts.statistic != StatisticType::Series {
let stat_str = match opts.statistic {
StatisticType::Average => "AVERAGE",
StatisticType::Minimum => "MINIMUM",
StatisticType::Maximum => "MAXIMUM",
StatisticType::Range => "RANGE",
StatisticType::Series => "NONE",
};
let _ = writeln!(out, " Statistic {}", stat_str);
}
out.push('\n');
}
{
let rep = &network.report;
let mut rep_lines: Vec<String> = Vec::new();
if rep.page_size > 0 {
rep_lines.push(format!(" Pagesize {}", rep.page_size));
}
let status_str = match rep.status {
ReportStatus::No => None,
ReportStatus::Yes => Some("Yes"),
ReportStatus::Full => Some("Full"),
};
if let Some(s) = status_str {
rep_lines.push(format!(" Status {}", s));
}
if !rep.summary {
rep_lines.push(" Summary No".to_string());
}
if rep.energy {
rep_lines.push(" Energy Yes".to_string());
}
let nodes_str = match &rep.nodes {
ReportSelection::None => None,
ReportSelection::All => Some("ALL".to_string()),
ReportSelection::Some(ids) => Some(ids.join(" ")),
};
if let Some(s) = nodes_str {
rep_lines.push(format!(" Nodes {}", s));
}
let links_str = match &rep.links {
ReportSelection::None => None,
ReportSelection::All => Some("ALL".to_string()),
ReportSelection::Some(ids) => Some(ids.join(" ")),
};
if let Some(s) = links_str {
rep_lines.push(format!(" Links {}", s));
}
if let Some(ref file) = rep.file {
rep_lines.push(format!(" File {}", file));
}
if !rep_lines.is_empty() {
out.push_str("[REPORT]\n");
for line in rep_lines {
out.push_str(&line);
out.push('\n');
}
out.push('\n');
}
}
if !network.coordinates.is_empty() {
out.push_str("[COORDINATES]\n");
out.push_str(";Node X-Coord Y-Coord\n");
for n in &network.nodes {
if let Some(&(x, y)) = network.coordinates.get(&n.base.id) {
let _ = writeln!(out, " {:<16} {:>16.4} {:>16.4}", n.base.id, x, y);
}
}
out.push('\n');
}
if !network.vertices.is_empty() {
out.push_str("[VERTICES]\n");
out.push_str(";Link X-Coord Y-Coord\n");
for l in &network.links {
if let Some(pts) = network.vertices.get(&l.base.id) {
for &(x, y) in pts {
let _ = writeln!(out, " {:<16} {:>16.4} {:>16.4}", l.base.id, x, y);
}
}
}
out.push('\n');
}
out.push_str("[END]\n");
out.into_bytes()
}
fn fmt_duration_hm(secs: f64) -> String {
if secs == 0.0 {
return "0:00".to_string();
}
let total_min = (secs / 60.0).round() as u64;
let h = total_min / 60;
let m = total_min % 60;
format!("{}:{:02}", h, m)
}
fn fmt_clocktime(secs: f64) -> String {
let total_min = (secs / 60.0).round() as u32 % (24 * 60);
let h24 = total_min / 60;
let m = total_min % 60;
let (h12, ampm) = if h24 < 12 {
(if h24 == 0 { 12 } else { h24 }, "AM")
} else {
(if h24 == 12 { 12 } else { h24 - 12 }, "PM")
};
if m == 0 {
format!("{} {}", h12, ampm)
} else {
format!("{}:{:02} {}", h12, m, ampm)
}
}
fn link_status_str(status: LinkStatus) -> &'static str {
match status {
LinkStatus::Open => "Open",
LinkStatus::Closed => "Closed",
_ => "Open",
}
}
fn valve_type_str(vtype: ValveType) -> &'static str {
match vtype {
ValveType::Prv => "PRV",
ValveType::Psv => "PSV",
ValveType::Pbv => "PBV",
ValveType::Fcv => "FCV",
ValveType::Tcv => "TCV",
ValveType::Gpv => "GPV",
ValveType::Pcv => "PCV",
}
}
fn flow_units_str(units: crate::FlowUnits) -> &'static str {
match units {
crate::FlowUnits::Cfs => "CFS",
crate::FlowUnits::Gpm => "GPM",
crate::FlowUnits::Mgd => "MGD",
crate::FlowUnits::Imgd => "IMGD",
crate::FlowUnits::Afd => "AFD",
crate::FlowUnits::Lps => "LPS",
crate::FlowUnits::Lpm => "LPM",
crate::FlowUnits::Mld => "MLD",
crate::FlowUnits::Cmh => "CMH",
crate::FlowUnits::Cmd => "CMD",
crate::FlowUnits::Cms => "CMS",
}
}
fn premise_attr_str(attr: PremiseAttribute) -> &'static str {
match attr {
PremiseAttribute::Head => "HEAD",
PremiseAttribute::Pressure => "PRESSURE",
PremiseAttribute::Demand => "DEMAND",
PremiseAttribute::Level => "LEVEL",
PremiseAttribute::Flow => "FLOW",
PremiseAttribute::Status => "STATUS",
PremiseAttribute::Setting => "SETTING",
PremiseAttribute::Power => "POWER",
PremiseAttribute::FillTime => "FILLTIME",
PremiseAttribute::DrainTime => "DRAINTIME",
PremiseAttribute::ClockTime => "CLOCKTIME",
PremiseAttribute::Time => "TIME",
}
}
fn premise_op_str(op: PremiseOperator) -> &'static str {
match op {
PremiseOperator::Eq => "=",
PremiseOperator::Neq => "<>",
PremiseOperator::Lt => "<",
PremiseOperator::Gt => ">",
PremiseOperator::Le => "<=",
PremiseOperator::Ge => ">=",
}
}
fn convert_premise_value(prem: &crate::Premise, ucf: &super::units::Ucf) -> f64 {
match prem.attribute {
PremiseAttribute::Demand | PremiseAttribute::Flow => prem.value * ucf.flow,
PremiseAttribute::Head | PremiseAttribute::Level => prem.value * ucf.elev,
PremiseAttribute::Pressure => prem.value * ucf.pressure,
_ => prem.value,
}
}
fn rule_action_str(
value: &ActionValue,
link_1based: usize,
links: &[crate::Link],
ucf: &super::units::Ucf,
) -> String {
match value {
ActionValue::Status(LinkStatus::Open) => "STATUS IS OPEN".to_string(),
ActionValue::Status(LinkStatus::Closed) => "STATUS IS CLOSED".to_string(),
ActionValue::Status(_) => "STATUS IS OPEN".to_string(),
ActionValue::Setting(s) => {
let setting_user = if let Some(link) = links.get(link_1based.saturating_sub(1)) {
if let LinkKind::Valve(ref v) = link.kind {
match v.valve_type {
ValveType::Prv | ValveType::Psv | ValveType::Pbv => s * ucf.pressure,
ValveType::Fcv => s * ucf.flow,
_ => *s,
}
} else {
*s }
} else {
*s
};
format!("SETTING IS {:.4}", setting_user)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::io::parse;
fn fixture(name: &str) -> std::path::PathBuf {
let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let root = manifest.parent().unwrap().parent().unwrap();
root.join("tests/fixtures").join(name)
}
fn round_trip_fixture(name: &str) {
let path = fixture(name);
let original_bytes =
std::fs::read(&path).unwrap_or_else(|e| panic!("could not read {name}: {e}"));
let net1 = parse(&original_bytes)
.unwrap_or_else(|e| panic!("first parse of {name} failed: {e:?}"));
let written = write_inp(&net1);
let net2 = parse(&written).unwrap_or_else(|e| {
let s = String::from_utf8_lossy(&written);
panic!("second parse of {name} failed: {e:?}\n\nwritten INP:\n{s}");
});
assert_eq!(
net1.nodes.len(),
net2.nodes.len(),
"{name}: node count changed after round-trip"
);
assert_eq!(
net1.links.len(),
net2.links.len(),
"{name}: link count changed after round-trip"
);
for n1 in &net1.nodes {
let n2 = net2
.nodes
.iter()
.find(|n| n.base.id == n1.base.id)
.unwrap_or_else(|| {
panic!("{name}: node '{}' missing after round-trip", n1.base.id)
});
assert!(
(n1.base.elevation - n2.base.elevation).abs() < 0.1,
"{name}: node '{}' elevation drifted: {} → {}",
n1.base.id,
n1.base.elevation,
n2.base.elevation
);
assert_eq!(
std::mem::discriminant(&n1.kind),
std::mem::discriminant(&n2.kind),
"{name}: node '{}' kind changed after round-trip",
n1.base.id
);
}
for l1 in &net1.links {
let l2 = net2
.links
.iter()
.find(|l| l.base.id == l1.base.id)
.unwrap_or_else(|| {
panic!("{name}: link '{}' missing after round-trip", l1.base.id)
});
assert_eq!(
std::mem::discriminant(&l1.kind),
std::mem::discriminant(&l2.kind),
"{name}: link '{}' kind changed after round-trip",
l1.base.id
);
}
}
#[test]
fn round_trip_four_node_loop() {
round_trip_fixture("four_node_loop.inp");
}
#[test]
fn round_trip_dual_reservoir() {
round_trip_fixture("dual_reservoir.inp");
}
#[test]
fn round_trip_multi_tank() {
round_trip_fixture("multi_tank.inp");
}
#[test]
fn round_trip_parallel_pumps() {
round_trip_fixture("parallel_pumps.inp");
}
#[test]
fn round_trip_multi_control() {
round_trip_fixture("multi_control.inp");
}
#[test]
fn round_trip_pipe_reactions() {
round_trip_fixture("pipe_reactions.inp");
}
#[test]
fn round_trip_demand_pattern() {
round_trip_fixture("demand_pattern.inp");
}
#[test]
fn round_trip_multiple_demands() {
round_trip_fixture("multiple_demands.inp");
}
#[test]
fn round_trip_emitter() {
round_trip_fixture("emitter.inp");
}
#[test]
fn round_trip_initial_quality() {
round_trip_fixture("initial_quality.inp");
}
#[test]
fn round_trip_parallel_pipes() {
round_trip_fixture("parallel_pipes.inp");
}
#[test]
fn round_trip_dead_end() {
round_trip_fixture("dead_end.inp");
}
#[test]
fn pipe_diameter_unit_conversion_round_trips() {
use super::super::units::make_ucf;
use crate::FlowUnits;
let ucf = make_ucf(FlowUnits::Lps, 1.0);
let d_ft = 0.5_f64;
let d_mm = d_ft * ucf.diam; let d_ft2 = d_mm / ucf.diam; assert!((d_ft - d_ft2).abs() < 1e-12);
}
#[test]
fn elevation_unit_conversion_round_trips() {
use super::super::units::make_ucf;
use crate::FlowUnits;
let ucf = make_ucf(FlowUnits::Lps, 1.0);
let elev_ft = 100.0_f64;
let elev_m = elev_ft * ucf.elev;
let elev_ft2 = elev_m / ucf.elev;
assert!((elev_ft - elev_ft2).abs() < 1e-12);
}
#[test]
fn minor_loss_round_trip() {
let d_ft = 0.5_f64; let kv_original = 0.25_f64;
let km = 0.02517 * kv_original / d_ft.powi(4);
let kv_recovered = km * d_ft.powi(4) / 0.02517;
assert!((kv_original - kv_recovered).abs() < 1e-12);
}
#[test]
fn tank_elevation_invariant() {
round_trip_fixture("multi_tank.inp");
let bottom_ft = 10.0_f64;
let min_level = 2.0_f64;
let node_elev = bottom_ft + min_level;
let bottom_recovered = node_elev - min_level;
assert!((bottom_ft - bottom_recovered).abs() < 1e-12);
}
#[test]
fn reaction_coeff_unit_conversion() {
let kb_per_s = -1.157e-5_f64; let kb_per_day = kb_per_s * 86400.0;
let kb_per_s2 = kb_per_day / 86400.0;
assert!((kb_per_s - kb_per_s2).abs() < 1e-20);
}
#[test]
fn fmt_duration_hm_zero() {
assert_eq!(fmt_duration_hm(0.0), "0:00");
}
#[test]
fn fmt_duration_hm_one_hour() {
assert_eq!(fmt_duration_hm(3600.0), "1:00");
}
#[test]
fn fmt_duration_hm_90_min() {
assert_eq!(fmt_duration_hm(5400.0), "1:30");
}
#[test]
fn fmt_duration_hm_24_hours() {
assert_eq!(fmt_duration_hm(86400.0), "24:00");
}
#[test]
fn fmt_clocktime_midnight() {
assert_eq!(fmt_clocktime(0.0), "12 AM");
}
#[test]
fn fmt_clocktime_noon() {
assert_eq!(fmt_clocktime(43200.0), "12 PM");
}
#[test]
fn fmt_clocktime_1pm() {
assert_eq!(fmt_clocktime(46800.0), "1 PM");
}
#[test]
fn fmt_clocktime_6_30_am() {
assert_eq!(fmt_clocktime(6.0 * 3600.0 + 30.0 * 60.0), "6:30 AM");
}
}