1use std::{f64, time::SystemTime};
8
9use super::accounting::{self, AccountingState};
10use super::controls;
11use super::timestep;
12use crate::hydraulics::{self as hydraulics, SolveResult, SolverContext};
13use crate::io::HydSnapshot;
14use crate::quality::{self as quality, QualityState};
15use crate::{
16 FavadCoeffs, FlowUnits, LinkKind, LinkState, LinkStatus, Network, NodeKind, NodeState,
17 QualityMode,
18};
19
20#[path = "lifecycle.rs"]
21mod lifecycle;
22#[path = "mutation.rs"]
23mod mutation;
24#[path = "results.rs"]
25mod results;
26pub use results::{LinkResult, NodeResult, ResultRanges};
27#[path = "types.rs"]
28mod types;
29#[path = "writable.rs"]
30mod writable;
31
32use types::Phase;
33pub use types::{
34 LinkProperty, LinkQuantity, NodeProperty, NodeQuantity, SessionError, SimWarning, WarningKind,
35};
36
37pub struct Simulation {
44 phase: Phase,
45
46 network: Option<Network>,
48 favad: Option<FavadCoeffs>,
49 solver_ctx: Option<SolverContext>,
50
51 node_states: Vec<NodeState>,
53 link_states: Vec<LinkState>,
54 current_t: f64,
55 next_report_t: f64, report_count: usize, hyd_snapshots: Vec<HydSnapshot>,
60
61 quality_state: Option<QualityState>,
63 quality_t: f64,
64
65 accounting: Option<AccountingState>,
67
68 warnings: Vec<SimWarning>,
70 neg_pressure_seen: Vec<bool>,
73
74 analysis_begun: Option<SystemTime>,
76 analysis_ended: Option<SystemTime>,
77}
78
79impl Simulation {
82 fn require_phase(&self, expected: Phase) -> Result<(), SessionError> {
83 if self.phase != expected {
84 Err(SessionError::InvalidPhase {
85 expected: expected.name().to_string(),
86 actual: self.phase.name().to_string(),
87 })
88 } else {
89 Ok(())
90 }
91 }
92
93 fn require_loaded_network(&self) -> Result<&Network, SessionError> {
94 self.network
95 .as_ref()
96 .ok_or_else(|| SessionError::InvalidPhase {
97 expected: "Loaded".into(),
98 actual: Phase::Created.name().to_string(),
99 })
100 }
101
102 fn maybe_record_snapshot(&mut self, new_t: f64) {
111 let network = match &self.network {
112 Some(n) => n,
113 None => return,
114 };
115 let duration = network.options.duration;
116 if new_t > duration + 1e-6 {
117 return;
118 }
119
120 let quality_enabled = network.options.quality_mode != QualityMode::None;
121 let at_or_past_report = new_t >= self.next_report_t - 1e-6;
122 if quality_enabled || at_or_past_report {
123 self.hyd_snapshots.push(HydSnapshot {
124 t: new_t,
125 node_states: self.node_states.clone(),
126 link_states: self.link_states.clone(),
127 });
128 }
129
130 let report_step = network.options.report_step;
132 let report_start = network.options.report_start;
133 while new_t >= self.next_report_t - 1e-6 && self.next_report_t <= duration + 1e-6 {
134 self.report_count += 1;
135 self.next_report_t = report_start + report_step * (self.report_count as f64);
136 }
137 }
138
139 fn snapshot_near(&self, t: f64) -> Option<&HydSnapshot> {
143 if self.hyd_snapshots.is_empty() {
144 return None;
145 }
146 let idx = self.hyd_snapshots.partition_point(|s| s.t < t);
148 let candidates = [idx.checked_sub(1), Some(idx)]
149 .into_iter()
150 .flatten()
151 .filter(|&i| i < self.hyd_snapshots.len());
152 candidates
153 .min_by(|&a, &b| {
154 let da = (self.hyd_snapshots[a].t - t).abs();
155 let db = (self.hyd_snapshots[b].t - t).abs();
156 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
157 })
158 .map(|i| &self.hyd_snapshots[i])
159 .filter(|s| (s.t - t).abs() < 0.5)
160 }
161
162 fn find_snapshot_index_at(&self, t: f64) -> Option<usize> {
164 self.hyd_snapshots
165 .iter()
166 .position(|s| (s.t - t).abs() < 0.5)
167 }
168
169 fn first_snapshot_states(&self) -> (&[NodeState], &[LinkState]) {
172 match self.hyd_snapshots.first() {
173 Some(s) => (&s.node_states, &s.link_states),
174 None => (&self.node_states, &self.link_states),
175 }
176 }
177}
178
179fn init_node_states(network: &Network) -> Vec<NodeState> {
183 network
184 .nodes
185 .iter()
186 .map(|n| {
187 let mut ns = NodeState::default();
188 ns.head = match &n.kind {
191 NodeKind::Junction(_) => 0.0,
192 NodeKind::Reservoir(_) => n.base.elevation,
193 NodeKind::Tank(t) => t.head_from_level(n.base.elevation, t.initial_level),
194 };
195 ns.level = match &n.kind {
196 NodeKind::Tank(t) => t.initial_level,
197 _ => 0.0,
198 };
199 ns.volume = match &n.kind {
200 NodeKind::Tank(t) => {
201 if let Some(ref cv_id) = t.volume_curve {
203 if let Some(curve) = network.curves.iter().find(|c| c.id == *cv_id) {
204 return NodeState {
205 head: ns.head,
206 level: t.initial_level,
207 volume: curve.eval(t.initial_level),
208 quality: n.base.initial_quality,
209 ..NodeState::default()
210 };
211 }
212 }
213 std::f64::consts::PI * (t.diameter / 2.0).powi(2) * t.initial_level
214 }
215 _ => 0.0,
216 };
217 ns.quality = n.base.initial_quality;
218 ns
219 })
220 .collect()
221}
222
223fn init_link_states(network: &Network) -> Vec<LinkState> {
225 network
226 .links
227 .iter()
228 .map(|l| {
229 let flow = if l.base.initial_status == LinkStatus::Closed {
230 1.0e-6 } else {
232 match &l.kind {
233 LinkKind::Pipe(pipe) => {
234 std::f64::consts::PI * pipe.diameter * pipe.diameter / 4.0
236 }
237 LinkKind::Pump(pump) => {
238 let speed = l.base.initial_setting.unwrap_or(1.0);
239 let q0 = pump_design_flow(pump, &network.curves);
240 speed * q0
241 }
242 LinkKind::Valve(v) => {
243 std::f64::consts::PI * v.diameter * v.diameter / 4.0
245 }
246 }
247 };
248 let mut status = l.base.initial_status;
253 let mut setting = l.base.initial_setting;
254 if let LinkKind::Valve(v) = &l.kind {
255 if v.valve_type == crate::ValveType::Gpv {
256 status = LinkStatus::Open;
258 } else {
259 if status != LinkStatus::Active {
260 setting = None;
261 }
262 if matches!(
263 v.valve_type,
264 crate::ValveType::Prv | crate::ValveType::Psv | crate::ValveType::Fcv
265 ) && setting.is_some()
266 {
267 status = LinkStatus::Active;
268 }
269 }
270 }
271 LinkState {
272 flow,
273 status,
274 setting: setting.unwrap_or(f64::NAN),
275 quality: 0.0,
276 reaction_rate: 0.0,
277 }
278 })
279 .collect()
280}
281
282fn pump_design_flow(pump: &crate::Pump, curves: &[crate::Curve]) -> f64 {
288 match pump.curve_type {
289 crate::PumpCurveType::ConstHp => 0.028317,
290 crate::PumpCurveType::PowerFunction => {
291 if let Some(ref cid) = pump.head_curve {
292 if let Some(curve) = curves.iter().find(|c| &c.id == cid) {
293 if curve.points.len() >= 3 {
294 return curve.points[1].x; } else if !curve.points.is_empty() {
296 return curve.points[0].x;
297 }
298 }
299 }
300 0.028317 }
302 crate::PumpCurveType::Custom => {
303 if let Some(ref cid) = pump.head_curve {
304 if let Some(curve) = curves.iter().find(|c| &c.id == cid) {
305 if curve.points.len() >= 2 {
306 let first = curve.points.first().unwrap().x;
307 let last = curve.points.last().unwrap().x;
308 return (first + last) / 2.0;
309 }
310 }
311 }
312 0.028317 }
314 }
315}
316
317fn node_index_by_id(network: &Network, id: &str) -> Option<usize> {
319 network.nodes.iter().position(|n| n.base.id == id)
320}
321
322fn link_index_by_id(network: &Network, id: &str) -> Option<usize> {
324 network.links.iter().position(|l| l.base.id == id)
325}
326
327fn link_status_to_f64(status: LinkStatus) -> f64 {
328 match status {
329 LinkStatus::Closed | LinkStatus::XPressure | LinkStatus::XHead | LinkStatus::TempClosed => {
330 0.0
331 }
332 LinkStatus::Open => 1.0,
333 LinkStatus::Active | LinkStatus::XFcv => 2.0,
334 }
335}
336
337#[cfg(test)]
340mod tests {
341 use super::*;
342 use crate::{
343 DemandCategory, HeadLossFormula, Junction, Link, LinkBase, LinkKind, Node, NodeBase,
344 NodeKind, Pipe, Reservoir, SimulationOptions,
345 };
346
347 fn simple_network() -> Network {
349 let options = SimulationOptions {
350 duration: 3600.0,
351 hyd_step: 3600.0,
352 report_step: 3600.0,
353 report_start: 0.0,
354 ..SimulationOptions::default()
355 };
356 Network {
357 title: vec![],
358 options,
359 patterns: vec![],
360 curves: vec![],
361 nodes: vec![
362 Node {
363 base: NodeBase {
364 id: "R1".into(),
365 index: 1,
366 elevation: 100.0,
367 initial_quality: 0.0,
368 },
369 kind: NodeKind::Reservoir(Reservoir { head_pattern: None }),
370 source: None,
371 },
372 Node {
373 base: NodeBase {
374 id: "J1".into(),
375 index: 2,
376 elevation: 0.0,
377 initial_quality: 0.0,
378 },
379 kind: NodeKind::Junction(Junction {
380 demands: vec![DemandCategory {
381 base_demand: 0.01,
382 pattern: None,
383 name: None,
384 }],
385 emitter_coeff: 0.0,
386 emitter_exp: 0.5,
387 }),
388 source: None,
389 },
390 ],
391 links: vec![Link {
392 base: LinkBase {
393 id: "P1".into(),
394 index: 1,
395 from_node: 1,
396 to_node: 2,
397 initial_status: LinkStatus::Open,
398 initial_setting: Some(1.0),
399 },
400 kind: LinkKind::Pipe(Pipe {
401 length: 1000.0,
402 diameter: 0.3,
403 roughness: 100.0,
404 minor_loss: 0.0,
405 check_valve: false,
406 bulk_coeff: None,
407 wall_coeff: None,
408 leak_coeff_1: 0.0,
409 leak_coeff_2: 0.0,
410 }),
411 }],
412 controls: vec![],
413 rules: vec![],
414 pattern_index: std::collections::HashMap::new(),
415 report: crate::ReportOptions::default(),
416 coordinates: std::collections::HashMap::new(),
417 vertices: std::collections::HashMap::new(),
418 node_tags: std::collections::HashMap::new(),
419 link_tags: std::collections::HashMap::new(),
420 }
421 }
422
423 #[test]
424 fn session_create_and_load() {
425 let mut sess = Simulation::create();
426 assert_eq!(sess.phase, Phase::Created);
427 sess.load(simple_network()).expect("load failed");
428 assert_eq!(sess.phase, Phase::Loaded);
429 }
430
431 #[test]
432 fn session_run_hydraulics_completes() {
433 let mut sess = Simulation::create();
434 sess.load(simple_network()).expect("load failed");
435 sess.run_hydraulics().expect("run_hydraulics failed");
436 assert_eq!(sess.phase, Phase::HydraulicsDone);
437 }
438
439 #[test]
440 fn session_snapshot_recorded_after_step() {
441 let mut sess = Simulation::create();
442 sess.load(simple_network()).expect("load failed");
443 sess.run_hydraulics().expect("run_hydraulics failed");
444 assert!(!sess.hyd_snapshots.is_empty());
446 }
447
448 #[test]
449 fn get_node_head_after_hydraulics() {
450 let mut sess = Simulation::create();
451 sess.load(simple_network()).expect("load failed");
452 sess.run_hydraulics().expect("run_hydraulics failed");
453 let snap_t = sess.hyd_snapshots[0].t;
455 let head = sess
456 .get_node_result("R1", NodeQuantity::Head, snap_t)
457 .expect("get_node_result failed");
458 assert!((head - 100.0).abs() < 1.0, "head = {head}");
460 }
461
462 #[test]
463 fn get_link_flow_after_hydraulics() {
464 let mut sess = Simulation::create();
465 sess.load(simple_network()).expect("load failed");
466 sess.run_hydraulics().expect("run_hydraulics failed");
467 let snap_t = sess.hyd_snapshots[0].t;
468 let flow = sess
469 .get_link_result("P1", LinkQuantity::Flow, snap_t)
470 .expect("get_link_result failed");
471 assert!(flow >= 0.0, "flow = {flow}");
473 }
474
475 #[test]
476 fn friction_factor_zero_for_non_darcy_weisbach() {
477 let mut sess = Simulation::create();
478 sess.load(simple_network()).expect("load failed");
479 sess.run_hydraulics().expect("run_hydraulics failed");
480 let snap_t = sess.hyd_snapshots[0].t;
481 let friction_factor = sess
482 .get_link_result("P1", LinkQuantity::FrictionFactor, snap_t)
483 .expect("get_link_result failed");
484
485 assert_eq!(friction_factor, 0.0);
486 }
487
488 #[test]
489 fn friction_factor_positive_for_darcy_weisbach_pipe() {
490 let mut network = simple_network();
491 network.options.head_loss_formula = HeadLossFormula::DarcyWeisbach;
492
493 let mut sess = Simulation::create();
494 sess.load(network).expect("load failed");
495 sess.run_hydraulics().expect("run_hydraulics failed");
496 let snap_t = sess.hyd_snapshots[0].t;
497 let friction_factor = sess
498 .get_link_result("P1", LinkQuantity::FrictionFactor, snap_t)
499 .expect("get_link_result failed");
500
501 assert!(
502 friction_factor.is_finite(),
503 "friction_factor = {friction_factor}"
504 );
505 assert!(friction_factor > 0.0, "friction_factor = {friction_factor}");
506 }
507
508 #[test]
509 fn friction_factor_zero_for_zero_flow_pipe() {
510 let mut network = simple_network();
511 network.options.head_loss_formula = HeadLossFormula::DarcyWeisbach;
512 if let NodeKind::Junction(junction) = &mut network.nodes[1].kind {
513 junction.demands[0].base_demand = 0.0;
514 }
515
516 let mut sess = Simulation::create();
517 sess.load(network).expect("load failed");
518 sess.run_hydraulics().expect("run_hydraulics failed");
519 let snap_t = sess.hyd_snapshots[0].t;
520 let friction_factor = sess
521 .get_link_result("P1", LinkQuantity::FrictionFactor, snap_t)
522 .expect("get_link_result failed");
523
524 assert_eq!(friction_factor, 0.0);
525 }
526
527 #[test]
528 fn unknown_node_id_returns_error() {
529 let mut sess = Simulation::create();
530 sess.load(simple_network()).expect("load");
531 sess.run_hydraulics().unwrap();
532 let t = sess.hyd_snapshots[0].t;
533 let err = sess.get_node_result("ZZZZ", NodeQuantity::Head, t);
534 assert!(matches!(err, Err(SessionError::UnknownId(_))));
535 }
536
537 #[test]
538 fn wrong_phase_returns_error() {
539 let mut sess = Simulation::create();
540 let err = sess.run_hydraulics();
542 assert!(matches!(err, Err(SessionError::InvalidPhase { .. })));
543 }
544
545 #[test]
546 fn set_link_property_changes_roughness() {
547 let mut sess = Simulation::create();
548 sess.load(simple_network()).expect("load");
549 sess.set_link_property("P1", LinkProperty::Roughness, 50.0)
550 .expect("set_link_property");
551 let network = sess.network.as_ref().unwrap();
552 if let LinkKind::Pipe(p) = &network.links[0].kind {
553 assert!((p.roughness - 50.0).abs() < 1e-10);
554 } else {
555 panic!("expected pipe");
556 }
557 }
558
559 #[test]
560 fn flow_balance_accessible_after_hydraulics() {
561 let mut sess = Simulation::create();
562 sess.load(simple_network()).expect("load");
563 sess.run_hydraulics().unwrap();
564 let fb = sess.get_flow_balance().expect("get_flow_balance");
565 let ratio = fb.balance_ratio(
567 sess.node_states
568 .iter()
569 .enumerate()
570 .filter_map(|(i, ns)| {
571 if matches!(
572 sess.network.as_ref().unwrap().nodes[i].kind,
573 NodeKind::Tank(_)
574 ) {
575 Some(ns.volume)
576 } else {
577 None
578 }
579 })
580 .sum::<f64>(),
581 );
582 assert!(ratio >= 0.0);
584 }
585
586 #[test]
587 fn step_quality_direct_loop_terminates() {
588 let mut net = simple_network();
592 net.options.duration = 2.0 * 3600.0;
593 net.options.hyd_step = 3600.0;
594 net.options.qual_step = 360.0;
595 net.options.report_step = 3600.0;
596 net.options.report_start = 0.0;
597 net.options.quality_mode = QualityMode::Age;
598
599 let mut sess = Simulation::create();
600 sess.load(net).expect("load");
601 sess.run_hydraulics().expect("run_hydraulics");
602
603 let mut steps = 0usize;
605 let mut total_t = 0.0_f64;
606 loop {
607 let dt = sess.step_quality().expect("step_quality");
608 if dt == 0.0 {
609 break;
610 }
611 total_t += dt;
612 steps += 1;
613 assert!(
614 steps < 1000,
615 "step_quality did not terminate within 1000 steps"
616 );
617 }
618
619 assert_eq!(sess.phase, Phase::QualityDone);
620 assert!((total_t - 2.0 * 3600.0).abs() < 1.0, "total_t = {total_t}");
621 }
622
623 #[test]
624 fn step_quality_and_run_quality_produce_same_results() {
625 let mut net = simple_network();
628 net.options.duration = 2.0 * 3600.0;
629 net.options.hyd_step = 3600.0;
630 net.options.qual_step = 360.0;
631 net.options.report_step = 3600.0;
632 net.options.report_start = 0.0;
633 net.options.quality_mode = QualityMode::Age;
634
635 let mut sess_a = Simulation::create();
637 sess_a.load(net.clone()).expect("load");
638 sess_a.run_hydraulics().expect("run_hydraulics");
639 sess_a.run_quality().expect("run_quality");
640
641 let mut sess_b = Simulation::create();
643 sess_b.load(net).expect("load");
644 sess_b.run_hydraulics().expect("run_hydraulics");
645 loop {
646 let dt = sess_b.step_quality().expect("step_quality");
647 if dt == 0.0 {
648 break;
649 }
650 }
651
652 let times_a = sess_a.snapshot_times();
654 let times_b = sess_b.snapshot_times();
655 assert_eq!(times_a, times_b);
656 for &t in ×_a {
657 let q_a = sess_a
658 .get_node_result("J1", NodeQuantity::Quality, t)
659 .unwrap();
660 let q_b = sess_b
661 .get_node_result("J1", NodeQuantity::Quality, t)
662 .unwrap();
663 assert!(
664 (q_a - q_b).abs() < 1e-9,
665 "quality mismatch at t={t}: run_quality={q_a}, step_quality={q_b}"
666 );
667 }
668 }
669
670 #[test]
671 fn step_quality_returns_zero_immediately_when_quality_none() {
672 let mut sess = Simulation::create();
675 sess.load(simple_network()).expect("load");
676 sess.run_hydraulics().expect("run_hydraulics");
677 let dt = sess.step_quality().expect("step_quality");
678 assert_eq!(dt, 0.0);
679 assert_eq!(sess.phase, Phase::QualityDone);
680 }
681
682 #[test]
685 fn mean_velocity_positive_for_flowing_pipe() {
686 let mut sess = Simulation::create();
687 sess.load(simple_network()).expect("load");
688 sess.run_hydraulics().expect("run_hydraulics");
689 let t = sess.hyd_snapshots[0].t;
690 let v = sess
691 .get_link_result("P1", LinkQuantity::MeanVelocity, t)
692 .expect("get_link_result");
693 assert!(v > 0.0, "expected positive velocity, got {v}");
694 }
695
696 #[test]
697 fn unit_head_loss_positive_for_flowing_pipe() {
698 let mut sess = Simulation::create();
699 sess.load(simple_network()).expect("load");
700 sess.run_hydraulics().expect("run_hydraulics");
701 let t = sess.hyd_snapshots[0].t;
702 let uhl = sess
703 .get_link_result("P1", LinkQuantity::UnitHeadLoss, t)
704 .expect("get_link_result");
705 assert!(uhl > 0.0, "expected positive unit head loss, got {uhl}");
706 }
707
708 #[test]
709 fn link_status_open_returns_one() {
710 let mut sess = Simulation::create();
711 sess.load(simple_network()).expect("load");
712 sess.run_hydraulics().expect("run_hydraulics");
713 let t = sess.hyd_snapshots[0].t;
714 let status = sess
715 .get_link_result("P1", LinkQuantity::Status, t)
716 .expect("get_link_result");
717 assert_eq!(status, 1.0);
719 }
720
721 #[test]
722 fn link_setting_returns_setting_for_pipe() {
723 let mut sess = Simulation::create();
724 sess.load(simple_network()).expect("load");
725 sess.run_hydraulics().expect("run_hydraulics");
726 let t = sess.hyd_snapshots[0].t;
727 let setting = sess
728 .get_link_result("P1", LinkQuantity::Setting, t)
729 .expect("get_link_result");
730 assert!(setting.is_finite(), "setting = {setting}");
732 }
733
734 #[test]
735 fn gauge_pressure_for_junction_equals_head_minus_elevation() {
736 let mut sess = Simulation::create();
737 sess.load(simple_network()).expect("load");
738 sess.run_hydraulics().expect("run_hydraulics");
739 let t = sess.hyd_snapshots[0].t;
740 let head = sess
741 .get_node_result("J1", NodeQuantity::Head, t)
742 .expect("head");
743 let gp = sess
744 .get_node_result("J1", NodeQuantity::GaugePressure, t)
745 .expect("gauge_pressure");
746 assert!((gp - head).abs() < 1e-9, "gp={gp}, head={head}");
748 }
749
750 #[test]
751 fn demand_for_reservoir_returns_net_flow() {
752 let mut sess = Simulation::create();
753 sess.load(simple_network()).expect("load");
754 sess.run_hydraulics().expect("run_hydraulics");
755 let t = sess.hyd_snapshots[0].t;
756 let demand = sess
757 .get_node_result("R1", NodeQuantity::Demand, t)
758 .expect("demand");
759 assert!(demand < 0.0, "reservoir net_flow = {demand}");
761 }
762
763 #[test]
764 fn no_snapshot_at_time_returns_error() {
765 let mut sess = Simulation::create();
766 sess.load(simple_network()).expect("load");
767 sess.run_hydraulics().expect("run_hydraulics");
768 let err = sess.get_node_result("J1", NodeQuantity::Head, 999_999.0);
769 assert!(matches!(err, Err(SessionError::NoSnapshotAtTime { .. })));
770 }
771
772 #[test]
773 fn node_ids_empty_before_load() {
774 let sess = Simulation::create();
775 assert!(sess.node_ids().is_empty());
776 }
777
778 #[test]
779 fn link_ids_empty_before_load() {
780 let sess = Simulation::create();
781 assert!(sess.link_ids().is_empty());
782 }
783
784 #[test]
785 fn pump_ids_empty_before_load() {
786 let sess = Simulation::create();
787 assert!(sess.pump_ids().is_empty());
788 }
789
790 #[test]
791 fn flow_units_none_before_load() {
792 let sess = Simulation::create();
793 assert!(sess.flow_units().is_none());
794 }
795
796 #[test]
797 fn get_pump_energy_error_for_non_pump() {
798 let mut sess = Simulation::create();
799 sess.load(simple_network()).expect("load");
800 sess.run_hydraulics().expect("run_hydraulics");
801 let err = sess.get_pump_energy("P1");
803 assert!(matches!(err, Err(SessionError::UnknownId(_))));
804 }
805
806 #[test]
807 fn link_status_to_f64_encoding() {
808 assert_eq!(link_status_to_f64(LinkStatus::Open), 1.0);
809 assert_eq!(link_status_to_f64(LinkStatus::Closed), 0.0);
810 assert_eq!(link_status_to_f64(LinkStatus::TempClosed), 0.0);
811 assert_eq!(link_status_to_f64(LinkStatus::XHead), 0.0);
812 assert_eq!(link_status_to_f64(LinkStatus::Active), 2.0);
813 assert_eq!(link_status_to_f64(LinkStatus::XFcv), 2.0);
814 }
815
816 #[test]
819 fn from_network_succeeds_with_valid_network() {
820 let sess = Simulation::from_network(simple_network()).expect("from_network");
821 assert_eq!(sess.phase, Phase::Loaded);
822 }
823
824 #[test]
825 fn set_node_property_elevation_changes_elevation() {
826 let mut sess = Simulation::create();
827 sess.load(simple_network()).expect("load");
828 sess.set_node_property("J1", NodeProperty::Elevation, 25.0)
829 .expect("set_node_property");
830 let elev = sess.network.as_ref().unwrap().nodes[1].base.elevation;
831 assert!((elev - 25.0).abs() < 1e-10);
832 }
833
834 #[test]
835 fn set_node_property_initial_quality_changes_quality() {
836 let mut sess = Simulation::create();
837 sess.load(simple_network()).expect("load");
838 sess.set_node_property("J1", NodeProperty::InitialQuality, 0.8)
839 .expect("set_node_property");
840 let iq = sess.network.as_ref().unwrap().nodes[1].base.initial_quality;
841 assert!((iq - 0.8).abs() < 1e-10);
842 }
843
844 #[test]
845 fn set_link_property_initial_status_closes_link() {
846 let mut sess = Simulation::create();
847 sess.load(simple_network()).expect("load");
848 sess.set_link_property("P1", LinkProperty::InitialStatus, 0.0)
849 .expect("set_link_property");
850 let status = sess.network.as_ref().unwrap().links[0].base.initial_status;
851 assert_eq!(status, LinkStatus::Closed);
852 }
853
854 #[test]
855 fn set_link_property_initial_setting_changes_setting() {
856 let mut sess = Simulation::create();
857 sess.load(simple_network()).expect("load");
858 sess.set_link_property("P1", LinkProperty::InitialSetting, 1.5)
859 .expect("set_link_property");
860 let setting = sess.network.as_ref().unwrap().links[0].base.initial_setting;
861 assert_eq!(setting, Some(1.5));
862 }
863
864 #[test]
865 fn set_node_property_unknown_id_returns_error() {
866 let mut sess = Simulation::create();
867 sess.load(simple_network()).expect("load");
868 let err = sess.set_node_property("ZZZZ", NodeProperty::Elevation, 1.0);
869 assert!(matches!(err, Err(SessionError::UnknownId(_))));
870 }
871
872 #[test]
873 fn set_node_property_before_load_returns_invalid_phase() {
874 let mut sess = Simulation::create();
875 let err = sess.set_node_property("J1", NodeProperty::Elevation, 1.0);
876 assert!(matches!(err, Err(SessionError::InvalidPhase { .. })));
877 }
878
879 #[test]
880 fn peak_demand_cost_is_zero_when_no_pumps() {
881 let mut sess = Simulation::create();
882 sess.load(simple_network()).expect("load");
883 sess.run_hydraulics().expect("run_hydraulics");
884 assert_eq!(sess.peak_demand_cost(), 0.0);
886 }
887
888 #[test]
889 fn snapshots_are_report_only_when_quality_none() {
890 let mut net = simple_network();
891 net.options.duration = 3.0 * 3600.0;
892 net.options.hyd_step = 3600.0;
893 net.options.report_step = 2.0 * 3600.0;
894 net.options.report_start = 0.0;
895 net.options.quality_mode = QualityMode::None;
896
897 let mut sess = Simulation::create();
898 sess.load(net).expect("load");
899 sess.run_hydraulics().expect("run_hydraulics");
900 let ts = sess.snapshot_times();
901
902 assert_eq!(ts, vec![0.0, 7200.0]);
903 }
904
905 #[test]
906 fn snapshots_remain_per_step_when_quality_enabled() {
907 let mut net = simple_network();
908 net.options.duration = 3.0 * 3600.0;
909 net.options.hyd_step = 3600.0;
910 net.options.report_step = 2.0 * 3600.0;
911 net.options.report_start = 0.0;
912 net.options.quality_mode = QualityMode::Age;
913
914 let mut sess = Simulation::create();
915 sess.load(net).expect("load");
916 sess.run_hydraulics().expect("run_hydraulics");
917 let ts = sess.snapshot_times();
918
919 assert_eq!(ts, vec![0.0, 3600.0, 7200.0, 10800.0]);
920 }
921}