use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HeadLossFormula {
HazenWilliams,
DarcyWeisbach,
ChezyManning,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DemandModel {
DemandDriven,
PressureDriven,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FlowUnits {
Cfs,
Gpm,
Mgd,
Imgd,
Afd,
Lps,
Lpm,
Mld,
Cmh,
Cmd,
Cms,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum RuntimeEstimate {
#[default]
Low,
Medium,
High,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QualityMode {
None,
Chemical,
Age,
Trace,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WallOrder {
Zero,
One,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StatisticType {
#[default]
Series,
Average,
Minimum,
Maximum,
Range,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ReportStatus {
#[default]
No,
Yes,
Full,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub enum ReportSelection {
#[default]
None,
All,
Some(Vec<String>),
}
#[derive(Debug, Clone)]
pub struct ReportFieldOption {
pub enabled: bool,
pub precision: Option<u32>,
pub above: Option<f64>,
pub below: Option<f64>,
}
#[derive(Debug, Clone)]
pub struct ReportOptions {
pub page_size: u32,
pub status: ReportStatus,
pub summary: bool,
pub messages: bool,
pub energy: bool,
pub nodes: ReportSelection,
pub links: ReportSelection,
pub file: Option<String>,
pub fields: HashMap<String, ReportFieldOption>,
}
impl Default for ReportOptions {
fn default() -> Self {
ReportOptions {
page_size: 0,
status: ReportStatus::No,
summary: true,
messages: true,
energy: false,
nodes: ReportSelection::None,
links: ReportSelection::None,
file: None,
fields: HashMap::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct SimulationOptions {
pub duration: f64,
pub hyd_step: f64,
pub qual_step: f64,
pub report_step: f64,
pub report_start: f64,
pub pattern_step: f64,
pub pattern_start: f64,
pub start_clocktime: f64,
pub head_loss_formula: HeadLossFormula,
pub demand_model: DemandModel,
pub flow_units: FlowUnits,
pub viscosity: f64,
pub diffusivity: f64,
pub specific_gravity: f64,
pub demand_multiplier: f64,
pub default_pattern: Option<String>,
pub pda_min_pressure: f64,
pub pda_required_pressure: f64,
pub pda_pressure_exponent: f64,
pub emitter_backflow: bool,
pub quality_mode: QualityMode,
pub trace_node: Option<String>,
pub chem_name: String,
pub chem_units: String,
pub max_iter: u32,
pub extra_iter: i32,
pub head_tol: f64,
pub flow_change_tol: f64,
pub flow_tol: f64,
pub head_error_limit: f64,
pub flow_change_limit: f64,
pub rq_tol: f64,
pub damp_limit: f64,
pub check_freq: u32,
pub max_check: u32,
pub bulk_order: f64,
pub tank_order: f64,
pub wall_order: WallOrder,
pub bulk_coeff: f64,
pub wall_coeff: f64,
pub conc_limit: f64,
pub energy_price: f64,
pub energy_price_pattern: Option<String>,
pub energy_efficiency: f64,
pub peak_demand_charge: f64,
pub roughness_reaction_factor: f64,
pub rule_timestep: f64,
pub quality_tolerance: f64,
pub statistic: StatisticType,
}
impl Default for SimulationOptions {
fn default() -> Self {
let hyd_step: f64 = 3600.0;
let qual_step: f64 = (hyd_step / 10.0).clamp(1.0, hyd_step);
let rule_timestep: f64 = (hyd_step / 10.0).clamp(f64::MIN_POSITIVE, hyd_step);
SimulationOptions {
duration: 0.0,
hyd_step,
qual_step,
report_step: 3600.0,
report_start: 0.0,
pattern_step: 3600.0,
pattern_start: 0.0,
start_clocktime: 0.0,
head_loss_formula: HeadLossFormula::HazenWilliams,
demand_model: DemandModel::DemandDriven,
flow_units: FlowUnits::Gpm,
viscosity: 1.022e-6,
diffusivity: 1.208e-9,
specific_gravity: 1.0,
demand_multiplier: 1.0,
default_pattern: None,
pda_min_pressure: 0.0,
pda_required_pressure: 0.0,
pda_pressure_exponent: 0.5,
emitter_backflow: true,
quality_mode: QualityMode::None,
trace_node: None,
chem_name: String::new(),
chem_units: String::new(),
max_iter: 200,
extra_iter: -1,
head_tol: 1.524e-4,
flow_change_tol: 2.832e-6,
flow_tol: 0.001,
head_error_limit: 0.0,
flow_change_limit: 0.0,
rq_tol: 1.0e-7,
damp_limit: 0.0,
check_freq: 2,
max_check: 10,
bulk_order: 1.0,
tank_order: 1.0,
wall_order: WallOrder::One,
bulk_coeff: 0.0,
wall_coeff: 0.0,
conc_limit: 0.0,
energy_price: 0.0,
energy_price_pattern: None,
energy_efficiency: 0.75,
peak_demand_charge: 0.0,
roughness_reaction_factor: 0.0,
rule_timestep,
quality_tolerance: 0.01,
statistic: StatisticType::Series,
}
}
}
#[derive(Debug, Clone)]
pub struct Pattern {
pub id: String,
pub factors: Vec<f64>,
}
impl Pattern {
pub fn eval(&self, t: f64, pattern_step: f64, pattern_start: f64) -> f64 {
let p = ((t + pattern_start) / pattern_step).floor() as i64;
let l = self.factors.len() as i64;
let idx = p.rem_euclid(l) as usize;
self.factors[idx]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CurveKind {
Generic,
PumpHead,
PumpEfficiency,
PumpVolume,
TankVolume,
GpvHeadloss,
PcvLossRatio,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CurvePoint {
pub x: f64,
pub y: f64,
}
#[derive(Debug, Clone)]
pub struct Curve {
pub id: String,
pub kind: CurveKind,
pub points: Vec<CurvePoint>,
}
impl Curve {
pub fn eval(&self, x: f64) -> f64 {
let pts = &self.points;
if pts.len() == 1 {
return pts[0].y;
}
if x <= pts[0].x {
let p0 = &pts[0];
let p1 = &pts[1];
let dx = p1.x - p0.x;
debug_assert!(dx > 0.0, "curve points must have strictly increasing x");
return p0.y + (p1.y - p0.y) * (x - p0.x) / dx;
}
let last = pts.len() - 1;
if x >= pts[last].x {
let p0 = &pts[last - 1];
let p1 = &pts[last];
let dx = p1.x - p0.x;
debug_assert!(dx > 0.0, "curve points must have strictly increasing x");
return p0.y + (p1.y - p0.y) * (x - p0.x) / dx;
}
let k = pts.partition_point(|p| p.x <= x);
let p0 = &pts[k - 1];
let p1 = &pts[k];
p0.y + (p1.y - p0.y) * (x - p0.x) / (p1.x - p0.x)
}
}
#[derive(Debug, Clone)]
pub struct DemandCategory {
pub base_demand: f64,
pub pattern: Option<String>,
pub name: Option<String>,
}
impl Junction {
pub fn total_demand(
&self,
t: f64,
opts: &SimulationOptions,
patterns: &[Pattern],
pattern_index: &HashMap<String, usize>,
) -> f64 {
let lookup = |id: &str| pattern_index.get(id).map(|&i| &patterns[i]);
self.demands
.iter()
.map(|d| {
let multiplier = d
.pattern
.as_deref()
.or(opts.default_pattern.as_deref())
.and_then(lookup)
.map_or(1.0, |pat| {
pat.eval(t, opts.pattern_step, opts.pattern_start)
});
d.base_demand * opts.demand_multiplier * multiplier
})
.sum()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceType {
Concentration,
Mass,
Setpoint,
FlowPaced,
}
#[derive(Debug, Clone)]
pub struct QualitySource {
pub node: usize,
pub kind: SourceType,
pub base_value: f64,
pub pattern: Option<String>,
}
impl QualitySource {
pub fn effective_value(
&self,
t: f64,
opts: &SimulationOptions,
patterns: &[Pattern],
pattern_index: &HashMap<String, usize>,
) -> f64 {
let multiplier = self
.pattern
.as_deref()
.and_then(|id| pattern_index.get(id).map(|&i| &patterns[i]))
.map_or(1.0, |pat| {
pat.eval(t, opts.pattern_step, opts.pattern_start)
});
self.base_value * multiplier
}
}
#[derive(Debug, Clone)]
pub struct NodeBase {
pub id: String,
pub index: usize,
pub elevation: f64,
pub initial_quality: f64,
}
#[derive(Debug, Clone)]
pub struct Junction {
pub demands: Vec<DemandCategory>,
pub emitter_coeff: f64,
pub emitter_exp: f64,
}
#[derive(Debug, Clone)]
pub struct Reservoir {
pub head_pattern: Option<String>,
}
impl Reservoir {
pub fn head(
&self,
elevation: f64,
t: f64,
opts: &SimulationOptions,
patterns: &[Pattern],
pattern_index: &HashMap<String, usize>,
) -> f64 {
let multiplier = self
.head_pattern
.as_deref()
.and_then(|id| pattern_index.get(id).map(|&i| &patterns[i]))
.map_or(1.0, |pat| {
pat.eval(t, opts.pattern_step, opts.pattern_start)
});
elevation * multiplier
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MixModel {
Cstr,
TwoCompartment,
Fifo,
Lifo,
}
#[derive(Debug, Clone)]
pub struct Tank {
pub min_level: f64,
pub max_level: f64,
pub initial_level: f64,
pub diameter: f64,
pub min_volume: f64,
pub volume_curve: Option<String>,
pub mix_model: MixModel,
pub mix_fraction: f64,
pub bulk_coeff: f64,
pub overflow: bool,
pub head_pattern: Option<String>,
}
impl Tank {
pub fn bottom_elevation(&self, node_elevation: f64) -> f64 {
node_elevation - self.min_level
}
pub fn head_from_level(&self, node_elevation: f64, level: f64) -> f64 {
self.bottom_elevation(node_elevation) + level
}
pub fn area(&self, level: f64, curves: &[Curve]) -> f64 {
if let Some(ref curve_id) = self.volume_curve {
if let Some(curve) = curves.iter().find(|c| c.id == *curve_id) {
return Self::area_from_volume_curve(curve, level);
}
}
std::f64::consts::PI * self.diameter * self.diameter / 4.0
}
fn area_from_volume_curve(curve: &Curve, level: f64) -> f64 {
let pts = &curve.points;
if level <= pts[0].x {
let dx = pts[1].x - pts[0].x;
return (pts[1].y - pts[0].y) / dx;
}
let last = pts.len() - 1;
if level >= pts[last].x {
let dx = pts[last].x - pts[last - 1].x;
return (pts[last].y - pts[last - 1].y) / dx;
}
let k = pts.partition_point(|p| p.x <= level);
let dx = pts[k].x - pts[k - 1].x;
(pts[k].y - pts[k - 1].y) / dx
}
pub fn volume_from_level(&self, level: f64, curves: &[Curve]) -> f64 {
if let Some(ref curve_id) = self.volume_curve {
if let Some(curve) = curves.iter().find(|c| c.id == *curve_id) {
return curve.eval(level);
}
}
let a = std::f64::consts::PI * self.diameter * self.diameter / 4.0;
a * level
}
pub fn level_from_volume(&self, volume: f64, curves: &[Curve]) -> f64 {
if let Some(ref curve_id) = self.volume_curve {
if let Some(curve) = curves.iter().find(|c| c.id == *curve_id) {
return Self::invert_volume_curve(curve, volume);
}
}
let a = std::f64::consts::PI * self.diameter * self.diameter / 4.0;
volume / a
}
fn invert_volume_curve(curve: &Curve, volume: f64) -> f64 {
let pts = &curve.points;
if volume <= pts[0].y {
let dv = pts[1].y - pts[0].y;
if dv == 0.0 {
return pts[0].x;
}
return pts[0].x + (pts[1].x - pts[0].x) * (volume - pts[0].y) / dv;
}
let last = pts.len() - 1;
if volume >= pts[last].y {
let dv = pts[last].y - pts[last - 1].y;
if dv == 0.0 {
return pts[last].x;
}
return pts[last - 1].x
+ (pts[last].x - pts[last - 1].x) * (volume - pts[last - 1].y) / dv;
}
let k = pts.partition_point(|p| p.y <= volume);
let dv = pts[k].y - pts[k - 1].y;
if dv == 0.0 {
return pts[k - 1].x;
}
pts[k - 1].x + (pts[k].x - pts[k - 1].x) * (volume - pts[k - 1].y) / dv
}
}
#[derive(Debug, Clone)]
pub enum NodeKind {
Junction(Junction),
Reservoir(Reservoir),
Tank(Tank),
}
#[derive(Debug, Clone)]
pub struct Node {
pub base: NodeBase,
pub kind: NodeKind,
pub source: Option<QualitySource>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LinkStatus {
Open,
Closed,
Active,
XPressure,
XFcv,
XHead,
TempClosed,
}
#[derive(Debug, Clone)]
pub struct LinkBase {
pub id: String,
pub index: usize,
pub from_node: usize,
pub to_node: usize,
pub initial_status: LinkStatus,
pub initial_setting: Option<f64>,
}
impl LinkBase {
#[inline]
pub fn from_idx(&self) -> usize {
self.from_node - 1
}
#[inline]
pub fn to_idx(&self) -> usize {
self.to_node - 1
}
}
#[derive(Debug, Clone)]
pub struct Pipe {
pub length: f64,
pub diameter: f64,
pub roughness: f64,
pub minor_loss: f64,
pub check_valve: bool,
pub bulk_coeff: Option<f64>,
pub wall_coeff: Option<f64>,
pub leak_coeff_1: f64,
pub leak_coeff_2: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PumpCurveType {
PowerFunction,
ConstHp,
Custom,
}
#[derive(Debug, Clone)]
pub struct Pump {
pub curve_type: PumpCurveType,
pub head_curve: Option<String>,
pub power: Option<f64>,
pub efficiency_curve: Option<String>,
pub default_efficiency: f64,
pub speed_pattern: Option<String>,
pub energy_price: Option<f64>,
pub price_pattern: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValveType {
Prv,
Psv,
Fcv,
Tcv,
Gpv,
Pcv,
Pbv,
}
#[derive(Debug, Clone)]
pub struct Valve {
pub valve_type: ValveType,
pub diameter: f64,
pub minor_loss: f64,
pub curve: Option<String>,
}
#[derive(Debug, Clone)]
pub enum LinkKind {
Pipe(Pipe),
Pump(Pump),
Valve(Valve),
}
#[derive(Debug, Clone)]
pub struct Link {
pub base: LinkBase,
pub kind: LinkKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TriggerType {
Timer,
TimeOfDay,
HiLevel,
LowLevel,
}
#[derive(Debug, Clone)]
pub struct SimpleControl {
pub link: usize,
pub trigger_type: TriggerType,
pub trigger_time: Option<f64>,
pub trigger_node: Option<usize>,
pub trigger_grade: Option<f64>,
pub action_status: Option<LinkStatus>,
pub action_setting: Option<f64>,
pub enabled: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PremiseObject {
Node(usize),
Link(usize),
Clock,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PremiseAttribute {
Head,
Pressure,
Demand,
Level,
Flow,
Status,
Setting,
Power,
FillTime,
DrainTime,
ClockTime,
Time,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PremiseOperator {
Eq,
Neq,
Lt,
Gt,
Le,
Ge,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogicOp {
And,
Or,
}
#[derive(Debug, Clone)]
pub struct Premise {
pub object: PremiseObject,
pub attribute: PremiseAttribute,
pub operator: PremiseOperator,
pub value: f64,
pub connective: Option<LogicOp>,
}
#[derive(Debug, Clone)]
pub enum ActionValue {
Status(LinkStatus),
Setting(f64),
}
#[derive(Debug, Clone)]
pub struct RuleAction {
pub link: usize,
pub value: ActionValue,
}
#[derive(Debug, Clone)]
pub struct Rule {
pub priority: f64,
pub premises: Vec<Premise>,
pub then_actions: Vec<RuleAction>,
pub else_actions: Vec<RuleAction>,
}
#[derive(Debug, Clone)]
pub struct Network {
pub title: Vec<String>,
pub options: SimulationOptions,
pub patterns: Vec<Pattern>,
pub curves: Vec<Curve>,
pub nodes: Vec<Node>,
pub links: Vec<Link>,
pub controls: Vec<SimpleControl>,
pub rules: Vec<Rule>,
pub pattern_index: HashMap<String, usize>,
pub report: ReportOptions,
pub coordinates: HashMap<String, (f64, f64)>,
pub vertices: HashMap<String, Vec<(f64, f64)>>,
pub node_tags: HashMap<String, String>,
pub link_tags: HashMap<String, String>,
}
impl Network {
pub fn build_pattern_index(&mut self) {
self.pattern_index = self
.patterns
.iter()
.enumerate()
.map(|(i, p)| (p.id.clone(), i))
.collect();
}
pub fn pattern_by_id(&self, id: &str) -> Option<&Pattern> {
self.pattern_index.get(id).map(|&i| &self.patterns[i])
}
}
#[derive(Debug, Clone)]
pub struct FavadCoeffs {
pub c_fa: Vec<f64>,
pub c_va: Vec<f64>,
}
impl Network {
pub fn compute_favad(&self) -> FavadCoeffs {
let n = self.nodes.len();
let mut k_fa = vec![0.0_f64; n];
let mut k_va = vec![0.0_f64; n];
let is_junction = |idx_1based: usize| -> bool {
if idx_1based < 1 || idx_1based > n {
return false;
}
matches!(self.nodes[idx_1based - 1].kind, NodeKind::Junction(_))
};
for link in &self.links {
let pipe = match &link.kind {
LinkKind::Pipe(p) => p,
_ => continue,
};
if pipe.leak_coeff_1 == 0.0 && pipe.leak_coeff_2 == 0.0 {
continue;
}
let f = link.base.from_node;
let t = link.base.to_node;
let f_is_junc = is_junction(f);
let t_is_junc = is_junction(t);
let both_junctions = f_is_junc && t_is_junc;
let factor = if both_junctions { 0.5 } else { 1.0 };
if f_is_junc {
k_fa[f - 1] += factor * pipe.leak_coeff_1;
k_va[f - 1] += factor * pipe.leak_coeff_2;
}
if t_is_junc {
k_fa[t - 1] += factor * pipe.leak_coeff_1;
k_va[t - 1] += factor * pipe.leak_coeff_2;
}
}
let c_fa: Vec<f64> = k_fa
.iter()
.map(|&k| if k > 0.0 { 1.0 / (k * k) } else { 0.0 })
.collect();
let c_va: Vec<f64> = k_va
.iter()
.map(|&k| {
if k > 0.0 {
1.0 / k.powf(2.0 / 3.0)
} else {
0.0
}
})
.collect();
FavadCoeffs { c_fa, c_va }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pattern_eval_selects_first_factor_at_t_zero() {
let p = Pattern {
id: "P1".into(),
factors: vec![0.5, 1.0, 1.5],
};
assert_eq!(p.eval(0.0, 3600.0, 0.0), 0.5);
}
#[test]
fn pattern_eval_wraps_beyond_length() {
let p = Pattern {
id: "P1".into(),
factors: vec![0.5, 1.0, 1.5],
};
assert_eq!(p.eval(3.0 * 3600.0, 3600.0, 0.0), 0.5);
assert_eq!(p.eval(4.0 * 3600.0, 3600.0, 0.0), 1.0);
}
#[test]
fn pattern_eval_pattern_start_shifts_index() {
let p = Pattern {
id: "P1".into(),
factors: vec![10.0, 20.0, 30.0],
};
assert_eq!(p.eval(0.0, 3600.0, 3600.0), 20.0);
}
fn two_point_curve() -> Curve {
Curve {
id: "C".into(),
kind: CurveKind::Generic,
points: vec![
CurvePoint { x: 0.0, y: 0.0 },
CurvePoint { x: 10.0, y: 20.0 },
],
}
}
#[test]
fn curve_eval_single_point_returns_constant() {
let c = Curve {
id: "C".into(),
kind: CurveKind::Generic,
points: vec![CurvePoint { x: 5.0, y: 42.0 }],
};
assert_eq!(c.eval(0.0), 42.0);
assert_eq!(c.eval(100.0), 42.0);
}
#[test]
fn curve_eval_interior_interpolation() {
let c = two_point_curve();
assert!((c.eval(5.0) - 10.0).abs() < 1e-12);
}
#[test]
fn curve_eval_below_range_extrapolates() {
let c = two_point_curve();
assert!((c.eval(-5.0) - (-10.0)).abs() < 1e-12);
}
#[test]
fn curve_eval_above_range_extrapolates() {
let c = two_point_curve();
assert!((c.eval(15.0) - 30.0).abs() < 1e-12);
}
#[test]
fn tank_head_from_level() {
let t = Tank {
min_level: 2.0,
max_level: 10.0,
initial_level: 5.0,
diameter: 4.0,
min_volume: 0.0,
volume_curve: None,
mix_model: MixModel::Cstr,
mix_fraction: 1.0,
bulk_coeff: 0.0,
overflow: false,
head_pattern: None,
};
assert!((t.head_from_level(50.0, 5.0) - 53.0).abs() < 1e-12);
}
#[test]
fn tank_volume_from_level_cylindrical() {
let t = Tank {
min_level: 0.0,
max_level: 10.0,
initial_level: 5.0,
diameter: 4.0, min_volume: 0.0,
volume_curve: None,
mix_model: MixModel::Cstr,
mix_fraction: 1.0,
bulk_coeff: 0.0,
overflow: false,
head_pattern: None,
};
let expected = std::f64::consts::PI * 4.0 * 3.0;
assert!((t.volume_from_level(3.0, &[]) - expected).abs() < 1e-10);
}
#[test]
fn junction_total_demand_no_pattern_uses_base_times_multiplier() {
let j = Junction {
demands: vec![DemandCategory {
base_demand: 0.01,
pattern: None,
name: None,
}],
emitter_coeff: 0.0,
emitter_exp: 0.5,
};
let opts = SimulationOptions {
demand_multiplier: 2.0,
..Default::default()
};
let total = j.total_demand(0.0, &opts, &[], &HashMap::new());
assert!((total - 0.02).abs() < 1e-12);
}
#[test]
fn junction_total_demand_with_pattern_factor() {
let pat = Pattern {
id: "P1".into(),
factors: vec![0.5, 2.0],
};
let mut pattern_index = HashMap::new();
pattern_index.insert("P1".to_string(), 0usize);
let j = Junction {
demands: vec![DemandCategory {
base_demand: 0.1,
pattern: Some("P1".into()),
name: None,
}],
emitter_coeff: 0.0,
emitter_exp: 0.5,
};
let opts = SimulationOptions::default();
let total = j.total_demand(3600.0, &opts, &[pat], &pattern_index);
assert!((total - 0.2).abs() < 1e-12);
}
#[test]
fn junction_total_demand_sums_multiple_categories() {
let j = Junction {
demands: vec![
DemandCategory {
base_demand: 0.01,
pattern: None,
name: None,
},
DemandCategory {
base_demand: 0.02,
pattern: None,
name: None,
},
],
emitter_coeff: 0.0,
emitter_exp: 0.5,
};
let opts = SimulationOptions::default();
let total = j.total_demand(0.0, &opts, &[], &HashMap::new());
assert!((total - 0.03).abs() < 1e-12);
}
}