1use super::network::*;
2
3#[derive(Debug, Clone, PartialEq)]
10pub enum ValidationError {
11 LinkUnknownFromNode {
14 link_id: String,
16 node_index: usize,
18 },
19 LinkUnknownToNode {
21 link_id: String,
23 node_index: usize,
25 },
26
27 UnknownPatternRef {
30 object_id: String,
32 pattern_id: String,
34 },
35 UnknownCurveRef {
37 object_id: String,
39 curve_id: String,
41 },
42 WrongCurveKind {
44 object_id: String,
46 curve_id: String,
48 expected: CurveKind,
50 actual: CurveKind,
52 },
53 MissingRequiredCurve {
55 object_id: String,
57 expected_kind: CurveKind,
59 },
60 UnknownNodeIdRef {
62 object_id: String,
64 node_id: String,
66 },
67 UnknownNodeIndexRef {
69 object_id: String,
71 node_index: usize,
73 },
74 UnknownLinkIndexRef {
76 object_id: String,
78 link_index: usize,
80 },
81
82 LinkSelfLoop {
85 link_id: String,
87 },
88
89 NoReservoir,
92 NodeNotReachable {
94 node_id: String,
96 },
97
98 TankLevelOutOfRange {
101 node_id: String,
103 min_level: f64,
105 initial_level: f64,
107 max_level: f64,
109 },
110
111 PumpCurveNotDecreasing {
114 curve_id: String,
116 },
117 EfficiencyCurveYOutOfRange {
119 curve_id: String,
121 },
122 TankVolumeCurveYNotIncreasing {
124 curve_id: String,
126 },
127 GpvHeadlossCurveYDecreasing {
129 curve_id: String,
131 },
132
133 CurveXNotIncreasing {
136 curve_id: String,
138 },
139
140 PatternEmpty {
143 pattern_id: String,
145 },
146
147 RuleActionUnknownLink {
150 rule_priority: f64,
152 link_index: usize,
154 },
155
156 CurveTooFewPoints {
159 curve_id: String,
161 count: usize,
163 },
164
165 ControlUnknownLink {
168 control_index: usize,
170 link_index: usize,
172 },
173}
174
175impl std::fmt::Display for ValidationError {
176 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177 match self {
178 Self::LinkUnknownFromNode {
179 link_id,
180 node_index,
181 } => write!(
182 f,
183 "link '{link_id}' references unknown from-node index {node_index}"
184 ),
185 Self::LinkUnknownToNode {
186 link_id,
187 node_index,
188 } => write!(
189 f,
190 "link '{link_id}' references unknown to-node index {node_index}"
191 ),
192 Self::UnknownPatternRef {
193 object_id,
194 pattern_id,
195 } => write!(f, "'{object_id}' references unknown pattern '{pattern_id}'"),
196 Self::UnknownCurveRef {
197 object_id,
198 curve_id,
199 } => write!(f, "'{object_id}' references unknown curve '{curve_id}'"),
200 Self::WrongCurveKind {
201 object_id,
202 curve_id,
203 expected,
204 actual,
205 } => write!(
206 f,
207 "'{object_id}' expects {expected:?} curve but '{curve_id}' is {actual:?}"
208 ),
209 Self::MissingRequiredCurve {
210 object_id,
211 expected_kind,
212 } => write!(
213 f,
214 "'{object_id}' requires a {expected_kind:?} curve but none is assigned"
215 ),
216 Self::UnknownNodeIdRef { object_id, node_id } => {
217 write!(f, "'{object_id}' references unknown node '{node_id}'")
218 }
219 Self::UnknownNodeIndexRef {
220 object_id,
221 node_index,
222 } => write!(
223 f,
224 "'{object_id}' references unknown node index {node_index}"
225 ),
226 Self::UnknownLinkIndexRef {
227 object_id,
228 link_index,
229 } => write!(
230 f,
231 "'{object_id}' references unknown link index {link_index}"
232 ),
233 Self::LinkSelfLoop { link_id } => {
234 write!(f, "link '{link_id}' connects a node to itself")
235 }
236 Self::NoReservoir => write!(f, "network has no reservoir"),
237 Self::NodeNotReachable { node_id } => {
238 write!(f, "node '{node_id}' is not reachable from any reservoir")
239 }
240 Self::TankLevelOutOfRange {
241 node_id,
242 min_level,
243 initial_level: init_level,
244 max_level,
245 } => write!(
246 f,
247 "tank '{node_id}' init level {init_level} is outside [{min_level}, {max_level}]"
248 ),
249 Self::PumpCurveNotDecreasing { curve_id } => {
250 write!(f, "pump head curve '{curve_id}' is not strictly decreasing")
251 }
252 Self::EfficiencyCurveYOutOfRange { curve_id } => write!(
253 f,
254 "efficiency curve '{curve_id}' has y-values outside (0, 100]"
255 ),
256 Self::TankVolumeCurveYNotIncreasing { curve_id } => write!(
257 f,
258 "tank volume curve '{curve_id}' is not strictly increasing"
259 ),
260 Self::GpvHeadlossCurveYDecreasing { curve_id } => {
261 write!(f, "GPV headloss curve '{curve_id}' has decreasing y-values")
262 }
263 Self::CurveXNotIncreasing { curve_id } => {
264 write!(f, "curve '{curve_id}' has non-increasing x-values")
265 }
266 Self::PatternEmpty { pattern_id } => write!(f, "pattern '{pattern_id}' has no factors"),
267 Self::RuleActionUnknownLink {
268 rule_priority,
269 link_index,
270 } => write!(
271 f,
272 "rule (priority {rule_priority}) references unknown link index {link_index}"
273 ),
274 Self::CurveTooFewPoints { curve_id, count } => {
275 write!(f, "curve '{curve_id}' has {count} point(s), minimum is 2")
276 }
277 Self::ControlUnknownLink {
278 control_index,
279 link_index,
280 } => write!(
281 f,
282 "control {control_index} references unknown link index {link_index}"
283 ),
284 }
285 }
286}
287
288impl Network {
289 pub fn validate(&self) -> Result<(), Vec<ValidationError>> {
314 use std::collections::{HashMap, HashSet, VecDeque};
315
316 let mut errors: Vec<ValidationError> = Vec::new();
317
318 let node_count = self.nodes.len();
319 let link_count = self.links.len();
320
321 let pattern_ids: HashSet<&str> = self.patterns.iter().map(|p| p.id.as_str()).collect();
323 let node_ids: HashSet<&str> = self.nodes.iter().map(|n| n.base.id.as_str()).collect();
324 let curve_by_id: HashMap<&str, &Curve> =
325 self.curves.iter().map(|c| (c.id.as_str(), c)).collect();
326
327 macro_rules! chk_pattern {
329 ($obj:expr, $pat_id:expr) => {
330 if !pattern_ids.contains($pat_id.as_str()) {
331 errors.push(ValidationError::UnknownPatternRef {
332 object_id: $obj.to_string(),
333 pattern_id: $pat_id.clone(),
334 });
335 }
336 };
337 }
338
339 macro_rules! chk_curve {
341 ($obj:expr, $curve_id:expr, $expected:expr) => {
342 match curve_by_id.get($curve_id.as_str()) {
343 None => errors.push(ValidationError::UnknownCurveRef {
344 object_id: $obj.to_string(),
345 curve_id: $curve_id.clone(),
346 }),
347 Some(c) if c.kind != $expected => {
348 errors.push(ValidationError::WrongCurveKind {
349 object_id: $obj.to_string(),
350 curve_id: $curve_id.clone(),
351 expected: $expected,
352 actual: c.kind,
353 });
354 }
355 _ => {}
356 }
357 };
358 }
359
360 for link in &self.links {
362 if link.base.from_node < 1 || link.base.from_node > node_count {
363 errors.push(ValidationError::LinkUnknownFromNode {
364 link_id: link.base.id.clone(),
365 node_index: link.base.from_node,
366 });
367 }
368 if link.base.to_node < 1 || link.base.to_node > node_count {
369 errors.push(ValidationError::LinkUnknownToNode {
370 link_id: link.base.id.clone(),
371 node_index: link.base.to_node,
372 });
373 }
374 }
375
376 if let Some(ref pat_id) = self.options.default_pattern {
379 chk_pattern!("options", pat_id);
380 }
381 if let Some(ref pat_id) = self.options.energy_price_pattern {
382 chk_pattern!("options", pat_id);
383 }
384 if let Some(ref nid) = self.options.trace_node {
385 if !node_ids.contains(nid.as_str()) {
386 errors.push(ValidationError::UnknownNodeIdRef {
387 object_id: "options".to_string(),
388 node_id: nid.clone(),
389 });
390 }
391 }
392
393 for node in &self.nodes {
395 let oid = &node.base.id;
396 match &node.kind {
397 NodeKind::Junction(j) => {
398 for demand in &j.demands {
399 if let Some(ref pat_id) = demand.pattern {
400 chk_pattern!(oid, pat_id);
401 }
402 }
403 }
404 NodeKind::Reservoir(r) => {
405 if let Some(ref pat_id) = r.head_pattern {
406 chk_pattern!(oid, pat_id);
407 }
408 }
409 NodeKind::Tank(t) => {
410 if let Some(ref pat_id) = t.head_pattern {
411 chk_pattern!(oid, pat_id);
412 }
413 if let Some(ref curve_id) = t.volume_curve {
414 chk_curve!(oid, curve_id, CurveKind::TankVolume);
415 }
416 }
417 }
418 if let Some(ref src) = node.source {
419 if let Some(ref pat_id) = src.pattern {
420 chk_pattern!(oid, pat_id);
421 }
422 }
423 }
424
425 for link in &self.links {
427 let oid = &link.base.id;
428 match &link.kind {
429 LinkKind::Pump(p) => {
430 match p.curve_type {
431 PumpCurveType::ConstHp => {}
432 _ => match &p.head_curve {
433 None => errors.push(ValidationError::MissingRequiredCurve {
434 object_id: oid.clone(),
435 expected_kind: CurveKind::PumpHead,
436 }),
437 Some(curve_id) => {
438 chk_curve!(oid, curve_id, CurveKind::PumpHead);
439 }
440 },
441 }
442 if let Some(ref curve_id) = p.efficiency_curve {
443 chk_curve!(oid, curve_id, CurveKind::PumpEfficiency);
444 }
445 if let Some(ref pat_id) = p.speed_pattern {
446 chk_pattern!(oid, pat_id);
447 }
448 if let Some(ref pat_id) = p.price_pattern {
449 chk_pattern!(oid, pat_id);
450 }
451 }
452 LinkKind::Valve(v) if v.valve_type == ValveType::Gpv => match &v.curve {
453 None => errors.push(ValidationError::MissingRequiredCurve {
454 object_id: oid.clone(),
455 expected_kind: CurveKind::GpvHeadloss,
456 }),
457 Some(curve_id) => {
458 chk_curve!(oid, curve_id, CurveKind::GpvHeadloss);
459 }
460 },
461 LinkKind::Valve(v) if v.valve_type == ValveType::Pcv => match &v.curve {
462 None => errors.push(ValidationError::MissingRequiredCurve {
463 object_id: oid.clone(),
464 expected_kind: CurveKind::PcvLossRatio,
465 }),
466 Some(curve_id) => {
467 chk_curve!(oid, curve_id, CurveKind::PcvLossRatio);
468 }
469 },
470 _ => {}
471 }
472 }
473
474 for (i, ctrl) in self.controls.iter().enumerate() {
476 if ctrl.link < 1 || ctrl.link > link_count {
477 errors.push(ValidationError::ControlUnknownLink {
478 control_index: i,
479 link_index: ctrl.link,
480 });
481 }
482 if let Some(idx) = ctrl.trigger_node {
483 if idx < 1 || idx > node_count {
484 errors.push(ValidationError::UnknownNodeIndexRef {
485 object_id: format!("control[{i}]"),
486 node_index: idx,
487 });
488 }
489 }
490 }
491
492 for rule in &self.rules {
494 let oid = format!("rule[priority={}]", rule.priority);
495 for premise in &rule.premises {
496 match premise.object {
497 PremiseObject::Node(idx) => {
498 if idx < 1 || idx > node_count {
499 errors.push(ValidationError::UnknownNodeIndexRef {
500 object_id: oid.clone(),
501 node_index: idx,
502 });
503 }
504 }
505 PremiseObject::Link(idx) => {
506 if idx < 1 || idx > link_count {
507 errors.push(ValidationError::UnknownLinkIndexRef {
508 object_id: oid.clone(),
509 link_index: idx,
510 });
511 }
512 }
513 PremiseObject::Clock => {}
514 }
515 }
516 }
517
518 for link in &self.links {
520 if link.base.from_node == link.base.to_node {
521 errors.push(ValidationError::LinkSelfLoop {
522 link_id: link.base.id.clone(),
523 });
524 }
525 }
526
527 let mut adj: Vec<Vec<usize>> = vec![vec![]; node_count];
531 for link in &self.links {
532 let f = link.base.from_node;
533 let t = link.base.to_node;
534 if f >= 1 && f <= node_count && t >= 1 && t <= node_count {
535 adj[f - 1].push(t - 1);
536 adj[t - 1].push(f - 1);
537 }
538 }
539
540 let reservoir_indices: Vec<usize> = self
541 .nodes
542 .iter()
543 .enumerate()
544 .filter(|(_, n)| matches!(n.kind, NodeKind::Reservoir(_)))
545 .map(|(i, _)| i)
546 .collect();
547
548 let tank_indices: Vec<usize> = self
549 .nodes
550 .iter()
551 .enumerate()
552 .filter(|(_, n)| matches!(n.kind, NodeKind::Tank(_)))
553 .map(|(i, _)| i)
554 .collect();
555
556 let fixed_grade_indices: Vec<usize> = reservoir_indices
558 .iter()
559 .chain(tank_indices.iter())
560 .copied()
561 .collect();
562
563 if fixed_grade_indices.is_empty() {
564 errors.push(ValidationError::NoReservoir);
565 } else {
566 let mut visited = vec![false; node_count];
567 let mut queue: VecDeque<usize> = VecDeque::new();
568 for &r in &fixed_grade_indices {
569 visited[r] = true;
570 queue.push_back(r);
571 }
572 while let Some(u) = queue.pop_front() {
573 for &v in &adj[u] {
574 if !visited[v] {
575 visited[v] = true;
576 queue.push_back(v);
577 }
578 }
579 }
580 for (i, node) in self.nodes.iter().enumerate() {
581 if !visited[i] && matches!(node.kind, NodeKind::Junction(_) | NodeKind::Tank(_)) {
582 errors.push(ValidationError::NodeNotReachable {
583 node_id: node.base.id.clone(),
584 });
585 }
586 }
587 }
588
589 for node in &self.nodes {
591 if let NodeKind::Tank(t) = &node.kind {
592 if t.initial_level < t.min_level || t.initial_level > t.max_level {
593 errors.push(ValidationError::TankLevelOutOfRange {
594 node_id: node.base.id.clone(),
595 min_level: t.min_level,
596 initial_level: t.initial_level,
597 max_level: t.max_level,
598 });
599 }
600 }
601 }
602
603 for curve in &self.curves {
605 match curve.kind {
606 CurveKind::PumpHead => {
607 let ok = curve.points.windows(2).all(|w| w[1].y < w[0].y);
608 if !ok {
609 errors.push(ValidationError::PumpCurveNotDecreasing {
610 curve_id: curve.id.clone(),
611 });
612 }
613 }
614 CurveKind::PumpEfficiency => {
615 let ok = curve.points.iter().all(|p| p.y >= 0.0 && p.y <= 100.0);
616 if !ok {
617 errors.push(ValidationError::EfficiencyCurveYOutOfRange {
618 curve_id: curve.id.clone(),
619 });
620 }
621 }
622 CurveKind::TankVolume => {
623 let ok = curve.points.windows(2).all(|w| w[1].y > w[0].y);
624 if !ok {
625 errors.push(ValidationError::TankVolumeCurveYNotIncreasing {
626 curve_id: curve.id.clone(),
627 });
628 }
629 }
630 CurveKind::GpvHeadloss => {
631 let ok = curve.points.windows(2).all(|w| w[1].y >= w[0].y);
632 if !ok {
633 errors.push(ValidationError::GpvHeadlossCurveYDecreasing {
634 curve_id: curve.id.clone(),
635 });
636 }
637 }
638 _ => {}
639 }
640 }
641
642 for curve in &self.curves {
646 if curve.points.len() < 2
647 && curve.kind != CurveKind::Generic
648 && curve.kind != CurveKind::PumpEfficiency
649 {
650 errors.push(ValidationError::CurveTooFewPoints {
651 curve_id: curve.id.clone(),
652 count: curve.points.len(),
653 });
654 }
655 }
656
657 for curve in &self.curves {
659 let ok = curve.points.windows(2).all(|w| w[1].x > w[0].x);
660 if !ok {
661 errors.push(ValidationError::CurveXNotIncreasing {
662 curve_id: curve.id.clone(),
663 });
664 }
665 }
666
667 for pattern in &self.patterns {
669 if pattern.factors.is_empty() {
670 errors.push(ValidationError::PatternEmpty {
671 pattern_id: pattern.id.clone(),
672 });
673 }
674 }
675
676 for rule in &self.rules {
678 for action in rule.then_actions.iter().chain(rule.else_actions.iter()) {
679 if action.link < 1 || action.link > link_count {
680 errors.push(ValidationError::RuleActionUnknownLink {
681 rule_priority: rule.priority,
682 link_index: action.link,
683 });
684 }
685 }
686 }
687
688 if errors.is_empty() {
689 Ok(())
690 } else {
691 Err(errors)
692 }
693 }
694}
695
696#[cfg(test)]
699mod tests {
700 use super::*;
701 use std::collections::HashMap;
702
703 fn make_simple() -> Network {
705 Network {
706 title: vec![],
707 options: SimulationOptions::default(),
708 patterns: vec![],
709 curves: vec![],
710 nodes: vec![
711 Node {
712 base: NodeBase {
713 id: "R1".into(),
714 index: 1,
715 elevation: 100.0,
716 initial_quality: 0.0,
717 },
718 kind: NodeKind::Reservoir(Reservoir { head_pattern: None }),
719 source: None,
720 },
721 Node {
722 base: NodeBase {
723 id: "J1".into(),
724 index: 2,
725 elevation: 0.0,
726 initial_quality: 0.0,
727 },
728 kind: NodeKind::Junction(Junction {
729 demands: vec![DemandCategory {
730 base_demand: 0.01,
731 pattern: None,
732 name: None,
733 }],
734 emitter_coeff: 0.0,
735 emitter_exp: 0.5,
736 }),
737 source: None,
738 },
739 ],
740 links: vec![Link {
741 base: LinkBase {
742 id: "P1".into(),
743 index: 1,
744 from_node: 1,
745 to_node: 2,
746 initial_status: LinkStatus::Open,
747 initial_setting: Some(1.0),
748 },
749 kind: LinkKind::Pipe(Pipe {
750 length: 1000.0,
751 diameter: 0.3,
752 roughness: 100.0,
753 minor_loss: 0.0,
754 check_valve: false,
755 bulk_coeff: None,
756 wall_coeff: None,
757 leak_coeff_1: 0.0,
758 leak_coeff_2: 0.0,
759 }),
760 }],
761 controls: vec![],
762 rules: vec![],
763 pattern_index: HashMap::new(),
764 report: ReportOptions::default(),
765 coordinates: HashMap::new(),
766 vertices: HashMap::new(),
767 node_tags: HashMap::new(),
768 link_tags: HashMap::new(),
769 }
770 }
771
772 #[test]
773 fn valid_network_passes_validation() {
774 assert!(make_simple().validate().is_ok());
775 }
776
777 #[test]
780 fn link_unknown_from_node_detected() {
781 let mut net = make_simple();
782 net.links[0].base.from_node = 99;
783 let errs = net.validate().unwrap_err();
784 assert!(
785 errs.iter()
786 .any(|e| matches!(e, ValidationError::LinkUnknownFromNode { .. })),
787 "expected LinkUnknownFromNode"
788 );
789 }
790
791 #[test]
792 fn link_unknown_to_node_detected() {
793 let mut net = make_simple();
794 net.links[0].base.to_node = 99;
795 let errs = net.validate().unwrap_err();
796 assert!(
797 errs.iter()
798 .any(|e| matches!(e, ValidationError::LinkUnknownToNode { .. })),
799 "expected LinkUnknownToNode"
800 );
801 }
802
803 #[test]
806 fn unknown_pattern_ref_detected() {
807 let mut net = make_simple();
808 if let NodeKind::Junction(j) = &mut net.nodes[1].kind {
809 j.demands[0].pattern = Some("NO_SUCH_PAT".into());
810 }
811 let errs = net.validate().unwrap_err();
812 assert!(
813 errs.iter()
814 .any(|e| matches!(e, ValidationError::UnknownPatternRef { .. })),
815 "expected UnknownPatternRef"
816 );
817 }
818
819 #[test]
820 fn missing_required_pump_curve_detected() {
821 let mut net = make_simple();
822 net.nodes.push(Node {
824 base: NodeBase {
825 id: "J2".into(),
826 index: 3,
827 elevation: 0.0,
828 initial_quality: 0.0,
829 },
830 kind: NodeKind::Junction(Junction {
831 demands: vec![DemandCategory {
832 base_demand: 0.01,
833 pattern: None,
834 name: None,
835 }],
836 emitter_coeff: 0.0,
837 emitter_exp: 0.5,
838 }),
839 source: None,
840 });
841 net.links.push(Link {
842 base: LinkBase {
843 id: "PU1".into(),
844 index: 2,
845 from_node: 2,
846 to_node: 3,
847 initial_status: LinkStatus::Open,
848 initial_setting: Some(1.0),
849 },
850 kind: LinkKind::Pump(Pump {
851 curve_type: PumpCurveType::PowerFunction,
852 head_curve: None, power: None,
854 efficiency_curve: None,
855 default_efficiency: 0.75,
856 speed_pattern: None,
857 energy_price: None,
858 price_pattern: None,
859 }),
860 });
861 let errs = net.validate().unwrap_err();
862 assert!(
863 errs.iter()
864 .any(|e| matches!(e, ValidationError::MissingRequiredCurve { .. })),
865 "expected MissingRequiredCurve"
866 );
867 }
868
869 #[test]
872 fn link_self_loop_detected() {
873 let mut net = make_simple();
874 net.links[0].base.to_node = 1; let errs = net.validate().unwrap_err();
876 assert!(
877 errs.iter()
878 .any(|e| matches!(e, ValidationError::LinkSelfLoop { .. })),
879 "expected LinkSelfLoop"
880 );
881 }
882
883 #[test]
886 fn no_reservoir_detected() {
887 let mut net = make_simple();
888 net.nodes[0].kind = NodeKind::Junction(Junction {
889 demands: vec![DemandCategory {
890 base_demand: 0.0,
891 pattern: None,
892 name: None,
893 }],
894 emitter_coeff: 0.0,
895 emitter_exp: 0.5,
896 });
897 let errs = net.validate().unwrap_err();
898 assert!(
899 errs.iter()
900 .any(|e| matches!(e, ValidationError::NoReservoir)),
901 "expected NoReservoir"
902 );
903 }
904
905 #[test]
906 fn node_not_reachable_detected() {
907 let mut net = make_simple();
908 net.nodes.push(Node {
909 base: NodeBase {
910 id: "J2".into(),
911 index: 3,
912 elevation: 0.0,
913 initial_quality: 0.0,
914 },
915 kind: NodeKind::Junction(Junction {
916 demands: vec![DemandCategory {
917 base_demand: 0.01,
918 pattern: None,
919 name: None,
920 }],
921 emitter_coeff: 0.0,
922 emitter_exp: 0.5,
923 }),
924 source: None,
925 });
926 let errs = net.validate().unwrap_err();
928 assert!(
929 errs.iter().any(|e| matches!(
930 e,
931 ValidationError::NodeNotReachable { node_id } if node_id == "J2"
932 )),
933 "expected NodeNotReachable for J2"
934 );
935 }
936
937 #[test]
940 fn tank_level_out_of_range_detected() {
941 let mut net = make_simple();
942 net.nodes[1].kind = NodeKind::Tank(Tank {
943 min_level: 1.0,
944 max_level: 5.0,
945 initial_level: 0.5, diameter: 10.0,
947 min_volume: 0.0,
948 volume_curve: None,
949 mix_model: MixModel::Cstr,
950 mix_fraction: 1.0,
951 bulk_coeff: 0.0,
952 overflow: false,
953 head_pattern: None,
954 });
955 let errs = net.validate().unwrap_err();
956 assert!(
957 errs.iter()
958 .any(|e| matches!(e, ValidationError::TankLevelOutOfRange { .. })),
959 "expected TankLevelOutOfRange"
960 );
961 }
962
963 #[test]
966 fn pump_curve_not_decreasing_detected() {
967 let mut net = make_simple();
968 net.curves.push(Curve {
969 id: "HC1".into(),
970 kind: CurveKind::PumpHead,
971 points: vec![
972 CurvePoint { x: 0.0, y: 50.0 },
973 CurvePoint { x: 1.0, y: 60.0 }, CurvePoint { x: 2.0, y: 40.0 },
975 ],
976 });
977 let errs = net.validate().unwrap_err();
978 assert!(
979 errs.iter()
980 .any(|e| matches!(e, ValidationError::PumpCurveNotDecreasing { .. })),
981 "expected PumpCurveNotDecreasing"
982 );
983 }
984
985 #[test]
986 fn efficiency_curve_out_of_range_detected() {
987 let mut net = make_simple();
988 net.curves.push(Curve {
989 id: "EFF1".into(),
990 kind: CurveKind::PumpEfficiency,
991 points: vec![CurvePoint { x: 0.5, y: 150.0 }], });
993 let errs = net.validate().unwrap_err();
994 assert!(
995 errs.iter()
996 .any(|e| matches!(e, ValidationError::EfficiencyCurveYOutOfRange { .. })),
997 "expected EfficiencyCurveYOutOfRange"
998 );
999 }
1000
1001 #[test]
1002 fn tank_volume_curve_not_increasing_detected() {
1003 let mut net = make_simple();
1004 net.curves.push(Curve {
1005 id: "VOL1".into(),
1006 kind: CurveKind::TankVolume,
1007 points: vec![
1008 CurvePoint { x: 0.0, y: 100.0 },
1009 CurvePoint { x: 1.0, y: 80.0 }, ],
1011 });
1012 let errs = net.validate().unwrap_err();
1013 assert!(
1014 errs.iter()
1015 .any(|e| matches!(e, ValidationError::TankVolumeCurveYNotIncreasing { .. })),
1016 "expected TankVolumeCurveYNotIncreasing"
1017 );
1018 }
1019
1020 #[test]
1021 fn gpv_headloss_curve_decreasing_detected() {
1022 let mut net = make_simple();
1023 net.curves.push(Curve {
1024 id: "GPV1".into(),
1025 kind: CurveKind::GpvHeadloss,
1026 points: vec![
1027 CurvePoint { x: 0.0, y: 10.0 },
1028 CurvePoint { x: 1.0, y: 5.0 }, ],
1030 });
1031 let errs = net.validate().unwrap_err();
1032 assert!(
1033 errs.iter()
1034 .any(|e| matches!(e, ValidationError::GpvHeadlossCurveYDecreasing { .. })),
1035 "expected GpvHeadlossCurveYDecreasing"
1036 );
1037 }
1038
1039 #[test]
1042 fn curve_x_not_increasing_detected() {
1043 let mut net = make_simple();
1044 net.curves.push(Curve {
1045 id: "HC2".into(),
1046 kind: CurveKind::PumpHead,
1047 points: vec![
1048 CurvePoint { x: 2.0, y: 80.0 },
1049 CurvePoint { x: 1.0, y: 60.0 }, CurvePoint { x: 0.5, y: 40.0 },
1051 ],
1052 });
1053 let errs = net.validate().unwrap_err();
1054 assert!(
1055 errs.iter()
1056 .any(|e| matches!(e, ValidationError::CurveXNotIncreasing { .. })),
1057 "expected CurveXNotIncreasing"
1058 );
1059 }
1060
1061 #[test]
1064 fn pattern_empty_detected() {
1065 let mut net = make_simple();
1066 net.patterns.push(Pattern {
1067 id: "PAT1".into(),
1068 factors: vec![],
1069 });
1070 net.build_pattern_index();
1071 let errs = net.validate().unwrap_err();
1072 assert!(
1073 errs.iter()
1074 .any(|e| matches!(e, ValidationError::PatternEmpty { .. })),
1075 "expected PatternEmpty"
1076 );
1077 }
1078
1079 #[test]
1082 fn curve_too_few_points_detected() {
1083 let mut net = make_simple();
1084 net.curves.push(Curve {
1085 id: "HC_TINY".into(),
1086 kind: CurveKind::PumpHead,
1087 points: vec![CurvePoint { x: 1.0, y: 50.0 }], });
1089 let errs = net.validate().unwrap_err();
1090 assert!(
1091 errs.iter()
1092 .any(|e| matches!(e, ValidationError::CurveTooFewPoints { .. })),
1093 "expected CurveTooFewPoints"
1094 );
1095 }
1096
1097 #[test]
1100 fn control_unknown_link_detected() {
1101 let mut net = make_simple();
1102 net.controls.push(SimpleControl {
1103 link: 99,
1104 trigger_type: TriggerType::Timer,
1105 trigger_time: Some(3600.0),
1106 trigger_node: None,
1107 trigger_grade: None,
1108 action_status: Some(LinkStatus::Closed),
1109 action_setting: None,
1110 enabled: true,
1111 });
1112 let errs = net.validate().unwrap_err();
1113 assert!(
1114 errs.iter()
1115 .any(|e| matches!(e, ValidationError::ControlUnknownLink { .. })),
1116 "expected ControlUnknownLink"
1117 );
1118 }
1119
1120 #[test]
1123 fn multiple_errors_all_collected() {
1124 let mut net = make_simple();
1125 net.links[0].base.from_node = 99;
1126 net.links[0].base.to_node = 99;
1127 let errs = net.validate().unwrap_err();
1128 assert!(
1130 errs.len() >= 2,
1131 "expected ≥2 errors collected, got {}",
1132 errs.len()
1133 );
1134 }
1135}