Skip to main content

hydra_engine_wds/model/
validation.rs

1use super::network::*;
2
3// ── §2.9 Validation ───────────────────────────────────────────────────────────
4
5/// A validation error produced by [`Network::validate`] (§2.9).
6///
7/// Each variant identifies the offending object by its string ID and the
8/// constraint violated — as required by §8.1.2.
9#[derive(Debug, Clone, PartialEq)]
10pub enum ValidationError {
11    // Check 1 — link node index out of range
12    /// `from_node` index of a link names a node that does not exist.
13    LinkUnknownFromNode {
14        /// String ID of the offending link.
15        link_id: String,
16        /// The out-of-range node index stored in `from_node`.
17        node_index: usize,
18    },
19    /// `to_node` index of a link names a node that does not exist.
20    LinkUnknownToNode {
21        /// String ID of the offending link.
22        link_id: String,
23        /// The out-of-range node index stored in `to_node`.
24        node_index: usize,
25    },
26
27    // Check 2 — ID cross-references
28    /// A pattern ID referenced by an object does not exist.
29    UnknownPatternRef {
30        /// String ID of the object that holds the reference.
31        object_id: String,
32        /// The unresolvable pattern string ID.
33        pattern_id: String,
34    },
35    /// A curve ID referenced by an object does not exist.
36    UnknownCurveRef {
37        /// String ID of the object that holds the reference.
38        object_id: String,
39        /// The unresolvable curve string ID.
40        curve_id: String,
41    },
42    /// A curve exists but has the wrong kind for the reference.
43    WrongCurveKind {
44        /// String ID of the object that holds the reference.
45        object_id: String,
46        /// String ID of the referenced curve.
47        curve_id: String,
48        /// The curve kind required by the reference.
49        expected: CurveKind,
50        /// The curve kind actually found.
51        actual: CurveKind,
52    },
53    /// A required curve reference (e.g. pump head curve) is absent.
54    MissingRequiredCurve {
55        /// String ID of the object that requires the curve.
56        object_id: String,
57        /// The curve kind that is missing.
58        expected_kind: CurveKind,
59    },
60    /// A node string ID referenced by an object does not exist.
61    UnknownNodeIdRef {
62        /// String ID of the object that holds the reference.
63        object_id: String,
64        /// The unresolvable node string ID.
65        node_id: String,
66    },
67    /// A node index in a control or rule premise is out of range.
68    UnknownNodeIndexRef {
69        /// String ID of the control or rule that holds the reference.
70        object_id: String,
71        /// The out-of-range node index.
72        node_index: usize,
73    },
74    /// A link index in a rule premise is out of range.
75    UnknownLinkIndexRef {
76        /// String ID of the rule that holds the reference.
77        object_id: String,
78        /// The out-of-range link index.
79        link_index: usize,
80    },
81
82    // Check 3
83    /// A link connects a node to itself.
84    LinkSelfLoop {
85        /// String ID of the self-looping link.
86        link_id: String,
87    },
88
89    // Check 4
90    /// The network has no reservoir (fixed-grade node).
91    NoReservoir,
92    /// A junction or tank not reachable from any reservoir.
93    NodeNotReachable {
94        /// String ID of the isolated node.
95        node_id: String,
96    },
97
98    // Check 5
99    /// A tank's `initial_level` is outside `[min_level, max_level]`.
100    TankLevelOutOfRange {
101        /// String ID of the tank node.
102        node_id: String,
103        /// Minimum level configured on the tank (m).
104        min_level: f64,
105        /// Initial level configured on the tank (m).
106        initial_level: f64,
107        /// Maximum level configured on the tank (m).
108        max_level: f64,
109    },
110
111    // Check 6
112    /// A `PUMP_HEAD` curve's y-values are not strictly decreasing.
113    PumpCurveNotDecreasing {
114        /// String ID of the offending curve.
115        curve_id: String,
116    },
117    /// A `PUMP_EFFICIENCY` curve's y-values are not all in `(0, 100]`.
118    EfficiencyCurveYOutOfRange {
119        /// String ID of the offending curve.
120        curve_id: String,
121    },
122    /// A `TANK_VOLUME` curve's y-values are not strictly increasing.
123    TankVolumeCurveYNotIncreasing {
124        /// String ID of the offending curve.
125        curve_id: String,
126    },
127    /// A `GPV_HEADLOSS` curve's y-values are not non-decreasing.
128    GpvHeadlossCurveYDecreasing {
129        /// String ID of the offending curve.
130        curve_id: String,
131    },
132
133    // Check 7
134    /// A curve's x-values are not strictly increasing.
135    CurveXNotIncreasing {
136        /// String ID of the offending curve.
137        curve_id: String,
138    },
139
140    // Check 8
141    /// A pattern contains no factors.
142    PatternEmpty {
143        /// String ID of the empty pattern.
144        pattern_id: String,
145    },
146
147    // Check 9
148    /// A rule action references a link index that is out of range.
149    RuleActionUnknownLink {
150        /// Priority of the rule containing the offending action.
151        rule_priority: f64,
152        /// The out-of-range link index stored in the action.
153        link_index: usize,
154    },
155
156    // Check 10
157    /// A curve has fewer than 2 data points (§2.3 requires length ≥ 2).
158    CurveTooFewPoints {
159        /// String ID of the offending curve.
160        curve_id: String,
161        /// Number of points actually present.
162        count: usize,
163    },
164
165    // Check 11
166    /// A simple control references a link index that is out of range.
167    ControlUnknownLink {
168        /// Zero-based position of the offending control in `Network::controls`.
169        control_index: usize,
170        /// The out-of-range 1-based link index stored in the control.
171        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    /// Validates the network against all topology and referential-integrity constraints.
290    ///
291    /// Returns `Ok(())` if every constraint is satisfied. Returns
292    /// `Err(errors)` with every violation found — never stops at the first
293    /// error, so the caller can report all problems at once. An invalid
294    /// network must not be used for simulation.
295    ///
296    /// The following constraints must all hold:
297    ///
298    /// 1. Every node index referenced by a link exists in the node table.
299    /// 2. Every curve, pattern, or node ID referenced by any object exists in
300    ///    the corresponding table.
301    /// 3. No link connects a node to itself (`from_node` ≠ `to_node`).
302    /// 4. The network contains at least one fixed-grade node (reservoir or
303    ///    tank). Every junction is reachable from at least one fixed-grade node
304    ///    via the link graph.
305    /// 5. For each tank: `min_level` ≤ `init_level` ≤ `max_level`.
306    /// 6. All `PUMP_HEAD` curves are strictly decreasing in *y*.
307    /// 7. All curves have strictly increasing *x*-values.
308    /// 8. All patterns have at least one factor.
309    /// 9. Every rule action that references a link references a valid link index.
310    /// 10. `wall_order` is 0 or 1; no other value is valid.
311    ///
312    /// Violations of any of the above are fatal — the simulation must not proceed.
313    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        // Lookup tables reused across checks.
322        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        // Helper: check a pattern ID reference produced by `object_id`.
328        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        // Helper: check a curve ID reference with an expected kind.
340        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        // ── Check 1: link node index bounds ───────────────────────────────────
361        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        // ── Check 2: ID cross-references ──────────────────────────────────────
377        // Global options.
378        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        // Nodes.
394        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        // Links.
426        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        // Simple control trigger node indices and link indices.
475        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        // Rule premise node/link indices.
493        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        // ── Check 3: no self-loops ─────────────────────────────────────────────
519        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        // ── Check 4: every junction/tank reachable from a reservoir ───────────
528        // Build undirected adjacency list (0-based). Guard on index validity to
529        // avoid out-of-bounds access when check 1 already found bad indices.
530        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        // Need at least one fixed-grade node (reservoir or tank).
557        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        // ── Check 5: tank level bounds ─────────────────────────────────────────
590        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        // ── Check 6: curve y-value invariants by kind ───────────────────────
604        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        // ── Check 10: all curves have ≥ 2 data points (§2.3) ─────────────────
643        // Generic (untagged) and PumpEfficiency curves are exempt: EPANET allows
644        // single-point efficiency curves (interpreted as constant efficiency).
645        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        // ── Check 7: all curves have strictly increasing x ────────────────────
658        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        // ── Check 8: all patterns non-empty ───────────────────────────────────
668        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        // ── Check 9: rule action link indices ─────────────────────────────────
677        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// ── Tests ─────────────────────────────────────────────────────────────────────
697
698#[cfg(test)]
699mod tests {
700    use super::*;
701    use std::collections::HashMap;
702
703    /// Minimal two-node (reservoir + junction) + one-pipe network.
704    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    // ── Check 1: link node index bounds ──────────────────────────────────────
778
779    #[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    // ── Check 2: ID cross-references ──────────────────────────────────────────
804
805    #[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        // Extra junction so the pump has a valid from/to.
823        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, // required but absent
853                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    // ── Check 3: no self-loops ────────────────────────────────────────────────
870
871    #[test]
872    fn link_self_loop_detected() {
873        let mut net = make_simple();
874        net.links[0].base.to_node = 1; // same as from_node
875        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    // ── Check 4: connectivity ─────────────────────────────────────────────────
884
885    #[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        // No link connecting J2 — it is unreachable.
927        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    // ── Check 5: tank level bounds ────────────────────────────────────────────
938
939    #[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, // below min_level
946            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    // ── Check 6: curve y-value invariants ────────────────────────────────────
964
965    #[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 }, // increasing — invalid
974                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 }], // >100 — invalid
992        });
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 }, // decreasing — invalid
1010            ],
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 }, // decreasing — invalid
1029            ],
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    // ── Check 7: curve x strictly increasing ─────────────────────────────────
1040
1041    #[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 }, // x decreasing — invalid
1050                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    // ── Check 8: patterns non-empty ───────────────────────────────────────────
1062
1063    #[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    // ── Check 10: curve must have ≥ 2 points ─────────────────────────────────
1080
1081    #[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 }], // only 1 point
1088        });
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    // ── Check 11: simple control link index ──────────────────────────────────
1098
1099    #[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    // ── Multi-error collection ────────────────────────────────────────────────
1121
1122    #[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        // Expect at least LinkUnknownFromNode + LinkUnknownToNode.
1129        assert!(
1130            errs.len() >= 2,
1131            "expected ≥2 errors collected, got {}",
1132            errs.len()
1133        );
1134    }
1135}