Skip to main content

cobre_core/
system.rs

1//! Top-level system struct and builder.
2//!
3//! The `System` struct is the top-level in-memory representation of a fully loaded,
4//! validated, and resolved case. It is produced by `cobre-io::load_case()` and consumed
5//! by solvers and analysis tools (e.g., optimization, simulation, power flow).
6//!
7//! All entity collections in `System` are stored in canonical ID-sorted order to ensure
8//! declaration-order invariance: results are bit-for-bit identical regardless of input
9//! entity ordering. See the design principles spec for details.
10
11use std::collections::{HashMap, HashSet};
12
13use crate::{
14    Bus, CascadeTopology, CorrelationModel, EnergyContract, EntityId, GenericConstraint, Hydro,
15    InflowModel, InitialConditions, Line, LoadModel, NetworkTopology, NonControllableSource,
16    PolicyGraph, PumpingStation, ResolvedBounds, ResolvedPenalties, ScenarioSource, Stage, Thermal,
17    ValidationError,
18};
19
20/// Top-level system representation.
21///
22/// Produced by `cobre-io::load_case()` or [`SystemBuilder`] in tests.
23/// Consumed by solvers and analysis tools via shared reference.
24/// Immutable and thread-safe after construction.
25///
26/// Entity collections are in canonical order (sorted by [`EntityId`]'s inner `i32`).
27/// Lookup indices provide O(1) access by [`EntityId`].
28///
29/// # Examples
30///
31/// ```
32/// use cobre_core::{Bus, DeficitSegment, EntityId, SystemBuilder};
33///
34/// let bus = Bus {
35///     id: EntityId(1),
36///     name: "Main Bus".to_string(),
37///     deficit_segments: vec![],
38///     excess_cost: 0.0,
39/// };
40///
41/// let system = SystemBuilder::new()
42///     .buses(vec![bus])
43///     .build()
44///     .expect("valid system");
45///
46/// assert_eq!(system.n_buses(), 1);
47/// assert!(system.bus(EntityId(1)).is_some());
48/// ```
49#[derive(Debug, PartialEq)]
50#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
51pub struct System {
52    // Entity collections (canonical ordering by ID)
53    buses: Vec<Bus>,
54    lines: Vec<Line>,
55    hydros: Vec<Hydro>,
56    thermals: Vec<Thermal>,
57    pumping_stations: Vec<PumpingStation>,
58    contracts: Vec<EnergyContract>,
59    non_controllable_sources: Vec<NonControllableSource>,
60
61    // O(1) lookup indices (entity ID -> position in collection) -- private.
62    // Per spec SS6.2: HashMap lookup indices are NOT serialized. After deserialization
63    // the caller must invoke `rebuild_indices()` to restore O(1) lookup capability.
64    #[cfg_attr(feature = "serde", serde(skip))]
65    bus_index: HashMap<EntityId, usize>,
66    #[cfg_attr(feature = "serde", serde(skip))]
67    line_index: HashMap<EntityId, usize>,
68    #[cfg_attr(feature = "serde", serde(skip))]
69    hydro_index: HashMap<EntityId, usize>,
70    #[cfg_attr(feature = "serde", serde(skip))]
71    thermal_index: HashMap<EntityId, usize>,
72    #[cfg_attr(feature = "serde", serde(skip))]
73    pumping_station_index: HashMap<EntityId, usize>,
74    #[cfg_attr(feature = "serde", serde(skip))]
75    contract_index: HashMap<EntityId, usize>,
76    #[cfg_attr(feature = "serde", serde(skip))]
77    non_controllable_source_index: HashMap<EntityId, usize>,
78
79    // Topology
80    /// Resolved hydro cascade graph.
81    cascade: CascadeTopology,
82    /// Resolved transmission network topology.
83    network: NetworkTopology,
84
85    // Temporal domain
86    /// Ordered list of stages (study + pre-study), sorted by `id` (canonical order).
87    stages: Vec<Stage>,
88    /// Policy graph defining stage transitions, horizon type, and discount rate.
89    policy_graph: PolicyGraph,
90
91    // Stage O(1) lookup index (stage ID -> position in stages vec).
92    // Stage IDs are `i32` (pre-study stages have negative IDs).
93    // Not serialized; rebuilt via `rebuild_indices()`.
94    #[cfg_attr(feature = "serde", serde(skip))]
95    stage_index: HashMap<i32, usize>,
96
97    // Resolved tables (populated by cobre-io after penalty/bound cascade)
98    /// Pre-resolved penalty values for all entities across all stages.
99    penalties: ResolvedPenalties,
100    /// Pre-resolved bound values for all entities across all stages.
101    bounds: ResolvedBounds,
102
103    // Scenario pipeline data (raw parameters loaded by cobre-io)
104    /// PAR(p) inflow model parameters, one entry per (hydro, stage) pair.
105    inflow_models: Vec<InflowModel>,
106    /// Seasonal load statistics, one entry per (bus, stage) pair.
107    load_models: Vec<LoadModel>,
108    /// Correlation model for stochastic inflow/load generation.
109    correlation: CorrelationModel,
110
111    // Study state
112    /// Initial reservoir storage levels at the start of the study.
113    initial_conditions: InitialConditions,
114    /// User-defined generic linear constraints, sorted by `id`.
115    generic_constraints: Vec<GenericConstraint>,
116    /// Top-level scenario source configuration (sampling scheme, seed).
117    scenario_source: ScenarioSource,
118}
119
120// Compile-time check that System is Send + Sync.
121const _: () = {
122    const fn assert_send_sync<T: Send + Sync>() {}
123    const fn check() {
124        assert_send_sync::<System>();
125    }
126    let _ = check;
127};
128
129impl System {
130    /// Returns all buses in canonical ID order.
131    #[must_use]
132    pub fn buses(&self) -> &[Bus] {
133        &self.buses
134    }
135
136    /// Returns all lines in canonical ID order.
137    #[must_use]
138    pub fn lines(&self) -> &[Line] {
139        &self.lines
140    }
141
142    /// Returns all hydro plants in canonical ID order.
143    #[must_use]
144    pub fn hydros(&self) -> &[Hydro] {
145        &self.hydros
146    }
147
148    /// Returns all thermal plants in canonical ID order.
149    #[must_use]
150    pub fn thermals(&self) -> &[Thermal] {
151        &self.thermals
152    }
153
154    /// Returns all pumping stations in canonical ID order.
155    #[must_use]
156    pub fn pumping_stations(&self) -> &[PumpingStation] {
157        &self.pumping_stations
158    }
159
160    /// Returns all energy contracts in canonical ID order.
161    #[must_use]
162    pub fn contracts(&self) -> &[EnergyContract] {
163        &self.contracts
164    }
165
166    /// Returns all non-controllable sources in canonical ID order.
167    #[must_use]
168    pub fn non_controllable_sources(&self) -> &[NonControllableSource] {
169        &self.non_controllable_sources
170    }
171
172    /// Returns the number of buses in the system.
173    #[must_use]
174    pub fn n_buses(&self) -> usize {
175        self.buses.len()
176    }
177
178    /// Returns the number of lines in the system.
179    #[must_use]
180    pub fn n_lines(&self) -> usize {
181        self.lines.len()
182    }
183
184    /// Returns the number of hydro plants in the system.
185    #[must_use]
186    pub fn n_hydros(&self) -> usize {
187        self.hydros.len()
188    }
189
190    /// Returns the number of thermal plants in the system.
191    #[must_use]
192    pub fn n_thermals(&self) -> usize {
193        self.thermals.len()
194    }
195
196    /// Returns the number of pumping stations in the system.
197    #[must_use]
198    pub fn n_pumping_stations(&self) -> usize {
199        self.pumping_stations.len()
200    }
201
202    /// Returns the number of energy contracts in the system.
203    #[must_use]
204    pub fn n_contracts(&self) -> usize {
205        self.contracts.len()
206    }
207
208    /// Returns the number of non-controllable sources in the system.
209    #[must_use]
210    pub fn n_non_controllable_sources(&self) -> usize {
211        self.non_controllable_sources.len()
212    }
213
214    /// Returns the bus with the given ID, or `None` if not found.
215    #[must_use]
216    pub fn bus(&self, id: EntityId) -> Option<&Bus> {
217        self.bus_index.get(&id).map(|&i| &self.buses[i])
218    }
219
220    /// Returns the line with the given ID, or `None` if not found.
221    #[must_use]
222    pub fn line(&self, id: EntityId) -> Option<&Line> {
223        self.line_index.get(&id).map(|&i| &self.lines[i])
224    }
225
226    /// Returns the hydro plant with the given ID, or `None` if not found.
227    #[must_use]
228    pub fn hydro(&self, id: EntityId) -> Option<&Hydro> {
229        self.hydro_index.get(&id).map(|&i| &self.hydros[i])
230    }
231
232    /// Returns the thermal plant with the given ID, or `None` if not found.
233    #[must_use]
234    pub fn thermal(&self, id: EntityId) -> Option<&Thermal> {
235        self.thermal_index.get(&id).map(|&i| &self.thermals[i])
236    }
237
238    /// Returns the pumping station with the given ID, or `None` if not found.
239    #[must_use]
240    pub fn pumping_station(&self, id: EntityId) -> Option<&PumpingStation> {
241        self.pumping_station_index
242            .get(&id)
243            .map(|&i| &self.pumping_stations[i])
244    }
245
246    /// Returns the energy contract with the given ID, or `None` if not found.
247    #[must_use]
248    pub fn contract(&self, id: EntityId) -> Option<&EnergyContract> {
249        self.contract_index.get(&id).map(|&i| &self.contracts[i])
250    }
251
252    /// Returns the non-controllable source with the given ID, or `None` if not found.
253    #[must_use]
254    pub fn non_controllable_source(&self, id: EntityId) -> Option<&NonControllableSource> {
255        self.non_controllable_source_index
256            .get(&id)
257            .map(|&i| &self.non_controllable_sources[i])
258    }
259
260    /// Returns a reference to the hydro cascade topology.
261    #[must_use]
262    pub fn cascade(&self) -> &CascadeTopology {
263        &self.cascade
264    }
265
266    /// Returns a reference to the transmission network topology.
267    #[must_use]
268    pub fn network(&self) -> &NetworkTopology {
269        &self.network
270    }
271
272    /// Returns all stages in canonical ID order (study and pre-study stages).
273    #[must_use]
274    pub fn stages(&self) -> &[Stage] {
275        &self.stages
276    }
277
278    /// Returns the number of stages (study and pre-study) in the system.
279    #[must_use]
280    pub fn n_stages(&self) -> usize {
281        self.stages.len()
282    }
283
284    /// Returns the stage with the given stage ID, or `None` if not found.
285    ///
286    /// Stage IDs are `i32`. Study stages have non-negative IDs; pre-study
287    /// stages (used only for PAR model lag initialization) have negative IDs.
288    #[must_use]
289    pub fn stage(&self, id: i32) -> Option<&Stage> {
290        self.stage_index.get(&id).map(|&i| &self.stages[i])
291    }
292
293    /// Returns a reference to the policy graph.
294    #[must_use]
295    pub fn policy_graph(&self) -> &PolicyGraph {
296        &self.policy_graph
297    }
298
299    /// Returns a reference to the pre-resolved penalty table.
300    #[must_use]
301    pub fn penalties(&self) -> &ResolvedPenalties {
302        &self.penalties
303    }
304
305    /// Returns a reference to the pre-resolved bounds table.
306    #[must_use]
307    pub fn bounds(&self) -> &ResolvedBounds {
308        &self.bounds
309    }
310
311    /// Returns all PAR(p) inflow models in canonical order (by hydro ID, then stage ID).
312    #[must_use]
313    pub fn inflow_models(&self) -> &[InflowModel] {
314        &self.inflow_models
315    }
316
317    /// Returns all load models in canonical order (by bus ID, then stage ID).
318    #[must_use]
319    pub fn load_models(&self) -> &[LoadModel] {
320        &self.load_models
321    }
322
323    /// Returns a reference to the correlation model.
324    #[must_use]
325    pub fn correlation(&self) -> &CorrelationModel {
326        &self.correlation
327    }
328
329    /// Returns a reference to the initial conditions.
330    #[must_use]
331    pub fn initial_conditions(&self) -> &InitialConditions {
332        &self.initial_conditions
333    }
334
335    /// Returns all generic constraints in canonical ID order.
336    #[must_use]
337    pub fn generic_constraints(&self) -> &[GenericConstraint] {
338        &self.generic_constraints
339    }
340
341    /// Returns a reference to the scenario source configuration.
342    #[must_use]
343    pub fn scenario_source(&self) -> &ScenarioSource {
344        &self.scenario_source
345    }
346
347    /// Rebuild all O(1) lookup indices from the entity collections.
348    ///
349    /// Required after deserialization: the `HashMap` lookup indices are not serialized
350    /// (per spec SS6.2 — they are derived from the entity collections). After
351    /// deserializing a `System` from JSON or any other format, call this method once
352    /// to restore O(1) access via [`bus`](Self::bus), [`hydro`](Self::hydro), etc.
353    ///
354    /// # Examples
355    ///
356    /// ```
357    /// # #[cfg(feature = "serde")]
358    /// # {
359    /// use cobre_core::{Bus, DeficitSegment, EntityId, SystemBuilder};
360    ///
361    /// let system = SystemBuilder::new()
362    ///     .buses(vec![Bus {
363    ///         id: EntityId(1),
364    ///         name: "A".to_string(),
365    ///         deficit_segments: vec![],
366    ///         excess_cost: 0.0,
367    ///     }])
368    ///     .build()
369    ///     .expect("valid system");
370    ///
371    /// let json = serde_json::to_string(&system).unwrap();
372    /// let mut deserialized: cobre_core::System = serde_json::from_str(&json).unwrap();
373    /// deserialized.rebuild_indices();
374    ///
375    /// // O(1) lookup now works after index rebuild.
376    /// assert!(deserialized.bus(EntityId(1)).is_some());
377    /// # }
378    /// ```
379    pub fn rebuild_indices(&mut self) {
380        self.bus_index = build_index(&self.buses);
381        self.line_index = build_index(&self.lines);
382        self.hydro_index = build_index(&self.hydros);
383        self.thermal_index = build_index(&self.thermals);
384        self.pumping_station_index = build_index(&self.pumping_stations);
385        self.contract_index = build_index(&self.contracts);
386        self.non_controllable_source_index = build_index(&self.non_controllable_sources);
387        self.stage_index = build_stage_index(&self.stages);
388    }
389}
390
391/// Builder for constructing a validated, immutable [`System`].
392///
393/// Accepts entity collections, sorts entities by ID, checks for duplicate IDs,
394/// builds topology, and returns the [`System`]. All entity collections default to
395/// empty; only supply the collections your test case requires.
396///
397/// # Examples
398///
399/// ```
400/// use cobre_core::{Bus, DeficitSegment, EntityId, SystemBuilder};
401///
402/// let system = SystemBuilder::new()
403///     .buses(vec![
404///         Bus { id: EntityId(2), name: "B".to_string(), deficit_segments: vec![], excess_cost: 0.0 },
405///         Bus { id: EntityId(1), name: "A".to_string(), deficit_segments: vec![], excess_cost: 0.0 },
406///     ])
407///     .build()
408///     .expect("valid system");
409///
410/// // Canonical ordering: id=1 comes before id=2.
411/// assert_eq!(system.buses()[0].id, EntityId(1));
412/// assert_eq!(system.buses()[1].id, EntityId(2));
413/// ```
414pub struct SystemBuilder {
415    buses: Vec<Bus>,
416    lines: Vec<Line>,
417    hydros: Vec<Hydro>,
418    thermals: Vec<Thermal>,
419    pumping_stations: Vec<PumpingStation>,
420    contracts: Vec<EnergyContract>,
421    non_controllable_sources: Vec<NonControllableSource>,
422    // New fields from tickets 004-007
423    stages: Vec<Stage>,
424    policy_graph: PolicyGraph,
425    penalties: ResolvedPenalties,
426    bounds: ResolvedBounds,
427    inflow_models: Vec<InflowModel>,
428    load_models: Vec<LoadModel>,
429    correlation: CorrelationModel,
430    initial_conditions: InitialConditions,
431    generic_constraints: Vec<GenericConstraint>,
432    scenario_source: ScenarioSource,
433}
434
435impl Default for SystemBuilder {
436    fn default() -> Self {
437        Self::new()
438    }
439}
440
441impl SystemBuilder {
442    /// Create a new empty builder. All entity collections start empty.
443    ///
444    /// New fields default to empty/default values so that
445    /// pre-existing tests continue to work without modification.
446    #[must_use]
447    pub fn new() -> Self {
448        Self {
449            buses: Vec::new(),
450            lines: Vec::new(),
451            hydros: Vec::new(),
452            thermals: Vec::new(),
453            pumping_stations: Vec::new(),
454            contracts: Vec::new(),
455            non_controllable_sources: Vec::new(),
456            stages: Vec::new(),
457            policy_graph: PolicyGraph::default(),
458            penalties: ResolvedPenalties::empty(),
459            bounds: ResolvedBounds::empty(),
460            inflow_models: Vec::new(),
461            load_models: Vec::new(),
462            correlation: CorrelationModel::default(),
463            initial_conditions: InitialConditions::default(),
464            generic_constraints: Vec::new(),
465            scenario_source: ScenarioSource::default(),
466        }
467    }
468
469    /// Set the bus collection.
470    #[must_use]
471    pub fn buses(mut self, buses: Vec<Bus>) -> Self {
472        self.buses = buses;
473        self
474    }
475
476    /// Set the line collection.
477    #[must_use]
478    pub fn lines(mut self, lines: Vec<Line>) -> Self {
479        self.lines = lines;
480        self
481    }
482
483    /// Set the hydro plant collection.
484    #[must_use]
485    pub fn hydros(mut self, hydros: Vec<Hydro>) -> Self {
486        self.hydros = hydros;
487        self
488    }
489
490    /// Set the thermal plant collection.
491    #[must_use]
492    pub fn thermals(mut self, thermals: Vec<Thermal>) -> Self {
493        self.thermals = thermals;
494        self
495    }
496
497    /// Set the pumping station collection.
498    #[must_use]
499    pub fn pumping_stations(mut self, stations: Vec<PumpingStation>) -> Self {
500        self.pumping_stations = stations;
501        self
502    }
503
504    /// Set the energy contract collection.
505    #[must_use]
506    pub fn contracts(mut self, contracts: Vec<EnergyContract>) -> Self {
507        self.contracts = contracts;
508        self
509    }
510
511    /// Set the non-controllable source collection.
512    #[must_use]
513    pub fn non_controllable_sources(mut self, sources: Vec<NonControllableSource>) -> Self {
514        self.non_controllable_sources = sources;
515        self
516    }
517
518    /// Set the stage collection (study and pre-study stages).
519    ///
520    /// Stages are sorted by `id` in [`build`](Self::build) to canonical order.
521    #[must_use]
522    pub fn stages(mut self, stages: Vec<Stage>) -> Self {
523        self.stages = stages;
524        self
525    }
526
527    /// Set the policy graph.
528    #[must_use]
529    pub fn policy_graph(mut self, policy_graph: PolicyGraph) -> Self {
530        self.policy_graph = policy_graph;
531        self
532    }
533
534    /// Set the pre-resolved penalty table.
535    ///
536    /// Populated by `cobre-io` after the three-tier penalty cascade is applied.
537    #[must_use]
538    pub fn penalties(mut self, penalties: ResolvedPenalties) -> Self {
539        self.penalties = penalties;
540        self
541    }
542
543    /// Set the pre-resolved bounds table.
544    ///
545    /// Populated by `cobre-io` after base bounds are overlaid with stage overrides.
546    #[must_use]
547    pub fn bounds(mut self, bounds: ResolvedBounds) -> Self {
548        self.bounds = bounds;
549        self
550    }
551
552    /// Set the PAR(p) inflow model collection.
553    #[must_use]
554    pub fn inflow_models(mut self, inflow_models: Vec<InflowModel>) -> Self {
555        self.inflow_models = inflow_models;
556        self
557    }
558
559    /// Set the load model collection.
560    #[must_use]
561    pub fn load_models(mut self, load_models: Vec<LoadModel>) -> Self {
562        self.load_models = load_models;
563        self
564    }
565
566    /// Set the correlation model.
567    #[must_use]
568    pub fn correlation(mut self, correlation: CorrelationModel) -> Self {
569        self.correlation = correlation;
570        self
571    }
572
573    /// Set the initial conditions.
574    #[must_use]
575    pub fn initial_conditions(mut self, initial_conditions: InitialConditions) -> Self {
576        self.initial_conditions = initial_conditions;
577        self
578    }
579
580    /// Set the generic constraint collection.
581    ///
582    /// Constraints are sorted by `id` in [`build`](Self::build) to canonical order.
583    #[must_use]
584    pub fn generic_constraints(mut self, generic_constraints: Vec<GenericConstraint>) -> Self {
585        self.generic_constraints = generic_constraints;
586        self
587    }
588
589    /// Set the scenario source configuration.
590    #[must_use]
591    pub fn scenario_source(mut self, scenario_source: ScenarioSource) -> Self {
592        self.scenario_source = scenario_source;
593        self
594    }
595
596    /// Build the [`System`].
597    ///
598    /// Sorts all entity collections by [`EntityId`] (canonical ordering).
599    /// Checks for duplicate IDs within each collection.
600    /// Validates all cross-reference fields (e.g., `bus_id`, `downstream_id`) against
601    /// the appropriate index to ensure every referenced entity exists.
602    /// Builds [`CascadeTopology`] and [`NetworkTopology`].
603    /// Validates the cascade graph for cycles and checks hydro filling configurations.
604    /// Constructs lookup indices.
605    ///
606    /// Returns `Err` with a list of all validation errors found across all collections.
607    /// All invalid references across all entity types are collected before returning —
608    /// no short-circuiting on first error.
609    ///
610    /// # Errors
611    ///
612    /// Returns `Err(Vec<ValidationError>)` if:
613    /// - Duplicate IDs are detected in any entity collection.
614    /// - Any cross-reference field refers to an entity ID that does not exist.
615    /// - The hydro cascade graph contains a cycle.
616    /// - Any hydro filling configuration is invalid (non-positive inflow or missing
617    ///   `entry_stage_id`).
618    ///
619    /// All errors across all collections are reported together.
620    pub fn build(mut self) -> Result<System, Vec<ValidationError>> {
621        self.buses.sort_by_key(|e| e.id.0);
622        self.lines.sort_by_key(|e| e.id.0);
623        self.hydros.sort_by_key(|e| e.id.0);
624        self.thermals.sort_by_key(|e| e.id.0);
625        self.pumping_stations.sort_by_key(|e| e.id.0);
626        self.contracts.sort_by_key(|e| e.id.0);
627        self.non_controllable_sources.sort_by_key(|e| e.id.0);
628        self.stages.sort_by_key(|s| s.id);
629        self.generic_constraints.sort_by_key(|c| c.id.0);
630
631        let mut errors: Vec<ValidationError> = Vec::new();
632        check_duplicates(&self.buses, "Bus", &mut errors);
633        check_duplicates(&self.lines, "Line", &mut errors);
634        check_duplicates(&self.hydros, "Hydro", &mut errors);
635        check_duplicates(&self.thermals, "Thermal", &mut errors);
636        check_duplicates(&self.pumping_stations, "PumpingStation", &mut errors);
637        check_duplicates(&self.contracts, "EnergyContract", &mut errors);
638        check_duplicates(
639            &self.non_controllable_sources,
640            "NonControllableSource",
641            &mut errors,
642        );
643
644        if !errors.is_empty() {
645            return Err(errors);
646        }
647
648        let bus_index = build_index(&self.buses);
649        let line_index = build_index(&self.lines);
650        let hydro_index = build_index(&self.hydros);
651        let thermal_index = build_index(&self.thermals);
652        let pumping_station_index = build_index(&self.pumping_stations);
653        let contract_index = build_index(&self.contracts);
654        let non_controllable_source_index = build_index(&self.non_controllable_sources);
655
656        validate_cross_references(
657            &self.lines,
658            &self.hydros,
659            &self.thermals,
660            &self.pumping_stations,
661            &self.contracts,
662            &self.non_controllable_sources,
663            &bus_index,
664            &hydro_index,
665            &mut errors,
666        );
667
668        if !errors.is_empty() {
669            return Err(errors);
670        }
671
672        let cascade = CascadeTopology::build(&self.hydros);
673
674        if cascade.topological_order().len() < self.hydros.len() {
675            let in_topo: HashSet<EntityId> = cascade.topological_order().iter().copied().collect();
676            let mut cycle_ids: Vec<EntityId> = self
677                .hydros
678                .iter()
679                .map(|h| h.id)
680                .filter(|id| !in_topo.contains(id))
681                .collect();
682            cycle_ids.sort_by_key(|id| id.0);
683            errors.push(ValidationError::CascadeCycle { cycle_ids });
684        }
685
686        validate_filling_configs(&self.hydros, &mut errors);
687
688        if !errors.is_empty() {
689            return Err(errors);
690        }
691
692        let network = NetworkTopology::build(
693            &self.buses,
694            &self.lines,
695            &self.hydros,
696            &self.thermals,
697            &self.non_controllable_sources,
698            &self.contracts,
699            &self.pumping_stations,
700        );
701
702        let stage_index = build_stage_index(&self.stages);
703
704        Ok(System {
705            buses: self.buses,
706            lines: self.lines,
707            hydros: self.hydros,
708            thermals: self.thermals,
709            pumping_stations: self.pumping_stations,
710            contracts: self.contracts,
711            non_controllable_sources: self.non_controllable_sources,
712            bus_index,
713            line_index,
714            hydro_index,
715            thermal_index,
716            pumping_station_index,
717            contract_index,
718            non_controllable_source_index,
719            cascade,
720            network,
721            stages: self.stages,
722            policy_graph: self.policy_graph,
723            stage_index,
724            penalties: self.penalties,
725            bounds: self.bounds,
726            inflow_models: self.inflow_models,
727            load_models: self.load_models,
728            correlation: self.correlation,
729            initial_conditions: self.initial_conditions,
730            generic_constraints: self.generic_constraints,
731            scenario_source: self.scenario_source,
732        })
733    }
734}
735
736trait HasId {
737    fn entity_id(&self) -> EntityId;
738}
739
740impl HasId for Bus {
741    fn entity_id(&self) -> EntityId {
742        self.id
743    }
744}
745impl HasId for Line {
746    fn entity_id(&self) -> EntityId {
747        self.id
748    }
749}
750impl HasId for Hydro {
751    fn entity_id(&self) -> EntityId {
752        self.id
753    }
754}
755impl HasId for Thermal {
756    fn entity_id(&self) -> EntityId {
757        self.id
758    }
759}
760impl HasId for PumpingStation {
761    fn entity_id(&self) -> EntityId {
762        self.id
763    }
764}
765impl HasId for EnergyContract {
766    fn entity_id(&self) -> EntityId {
767        self.id
768    }
769}
770impl HasId for NonControllableSource {
771    fn entity_id(&self) -> EntityId {
772        self.id
773    }
774}
775
776fn build_index<T: HasId>(entities: &[T]) -> HashMap<EntityId, usize> {
777    let mut index = HashMap::with_capacity(entities.len());
778    for (i, entity) in entities.iter().enumerate() {
779        index.insert(entity.entity_id(), i);
780    }
781    index
782}
783
784/// Build a stage lookup index from the canonical-ordered stages vec.
785///
786/// Keys are `i32` stage IDs (which can be negative for pre-study stages).
787fn build_stage_index(stages: &[Stage]) -> HashMap<i32, usize> {
788    let mut index = HashMap::with_capacity(stages.len());
789    for (i, stage) in stages.iter().enumerate() {
790        index.insert(stage.id, i);
791    }
792    index
793}
794
795fn check_duplicates<T: HasId>(
796    entities: &[T],
797    entity_type: &'static str,
798    errors: &mut Vec<ValidationError>,
799) {
800    for window in entities.windows(2) {
801        if window[0].entity_id() == window[1].entity_id() {
802            errors.push(ValidationError::DuplicateId {
803                entity_type,
804                id: window[0].entity_id(),
805            });
806        }
807    }
808}
809
810/// Validate all cross-reference fields across entity collections.
811///
812/// Checks every entity field that references another entity by [`EntityId`]
813/// against the appropriate index. All invalid references are appended to
814/// `errors` — no short-circuiting on first error.
815///
816/// This function runs after duplicate checking passes and after indices are
817/// built, but before topology construction.
818#[allow(clippy::too_many_arguments)]
819fn validate_cross_references(
820    lines: &[Line],
821    hydros: &[Hydro],
822    thermals: &[Thermal],
823    pumping_stations: &[PumpingStation],
824    contracts: &[EnergyContract],
825    non_controllable_sources: &[NonControllableSource],
826    bus_index: &HashMap<EntityId, usize>,
827    hydro_index: &HashMap<EntityId, usize>,
828    errors: &mut Vec<ValidationError>,
829) {
830    validate_line_refs(lines, bus_index, errors);
831    validate_hydro_refs(hydros, bus_index, hydro_index, errors);
832    validate_thermal_refs(thermals, bus_index, errors);
833    validate_pumping_station_refs(pumping_stations, bus_index, hydro_index, errors);
834    validate_contract_refs(contracts, bus_index, errors);
835    validate_ncs_refs(non_controllable_sources, bus_index, errors);
836}
837
838fn validate_line_refs(
839    lines: &[Line],
840    bus_index: &HashMap<EntityId, usize>,
841    errors: &mut Vec<ValidationError>,
842) {
843    for line in lines {
844        if !bus_index.contains_key(&line.source_bus_id) {
845            errors.push(ValidationError::InvalidReference {
846                source_entity_type: "Line",
847                source_id: line.id,
848                field_name: "source_bus_id",
849                referenced_id: line.source_bus_id,
850                expected_type: "Bus",
851            });
852        }
853        if !bus_index.contains_key(&line.target_bus_id) {
854            errors.push(ValidationError::InvalidReference {
855                source_entity_type: "Line",
856                source_id: line.id,
857                field_name: "target_bus_id",
858                referenced_id: line.target_bus_id,
859                expected_type: "Bus",
860            });
861        }
862    }
863}
864
865fn validate_hydro_refs(
866    hydros: &[Hydro],
867    bus_index: &HashMap<EntityId, usize>,
868    hydro_index: &HashMap<EntityId, usize>,
869    errors: &mut Vec<ValidationError>,
870) {
871    for hydro in hydros {
872        if !bus_index.contains_key(&hydro.bus_id) {
873            errors.push(ValidationError::InvalidReference {
874                source_entity_type: "Hydro",
875                source_id: hydro.id,
876                field_name: "bus_id",
877                referenced_id: hydro.bus_id,
878                expected_type: "Bus",
879            });
880        }
881        if let Some(downstream_id) = hydro.downstream_id {
882            if !hydro_index.contains_key(&downstream_id) {
883                errors.push(ValidationError::InvalidReference {
884                    source_entity_type: "Hydro",
885                    source_id: hydro.id,
886                    field_name: "downstream_id",
887                    referenced_id: downstream_id,
888                    expected_type: "Hydro",
889                });
890            }
891        }
892        if let Some(ref diversion) = hydro.diversion {
893            if !hydro_index.contains_key(&diversion.downstream_id) {
894                errors.push(ValidationError::InvalidReference {
895                    source_entity_type: "Hydro",
896                    source_id: hydro.id,
897                    field_name: "diversion.downstream_id",
898                    referenced_id: diversion.downstream_id,
899                    expected_type: "Hydro",
900                });
901            }
902        }
903    }
904}
905
906fn validate_thermal_refs(
907    thermals: &[Thermal],
908    bus_index: &HashMap<EntityId, usize>,
909    errors: &mut Vec<ValidationError>,
910) {
911    for thermal in thermals {
912        if !bus_index.contains_key(&thermal.bus_id) {
913            errors.push(ValidationError::InvalidReference {
914                source_entity_type: "Thermal",
915                source_id: thermal.id,
916                field_name: "bus_id",
917                referenced_id: thermal.bus_id,
918                expected_type: "Bus",
919            });
920        }
921    }
922}
923
924fn validate_pumping_station_refs(
925    pumping_stations: &[PumpingStation],
926    bus_index: &HashMap<EntityId, usize>,
927    hydro_index: &HashMap<EntityId, usize>,
928    errors: &mut Vec<ValidationError>,
929) {
930    for ps in pumping_stations {
931        if !bus_index.contains_key(&ps.bus_id) {
932            errors.push(ValidationError::InvalidReference {
933                source_entity_type: "PumpingStation",
934                source_id: ps.id,
935                field_name: "bus_id",
936                referenced_id: ps.bus_id,
937                expected_type: "Bus",
938            });
939        }
940        if !hydro_index.contains_key(&ps.source_hydro_id) {
941            errors.push(ValidationError::InvalidReference {
942                source_entity_type: "PumpingStation",
943                source_id: ps.id,
944                field_name: "source_hydro_id",
945                referenced_id: ps.source_hydro_id,
946                expected_type: "Hydro",
947            });
948        }
949        if !hydro_index.contains_key(&ps.destination_hydro_id) {
950            errors.push(ValidationError::InvalidReference {
951                source_entity_type: "PumpingStation",
952                source_id: ps.id,
953                field_name: "destination_hydro_id",
954                referenced_id: ps.destination_hydro_id,
955                expected_type: "Hydro",
956            });
957        }
958    }
959}
960
961fn validate_contract_refs(
962    contracts: &[EnergyContract],
963    bus_index: &HashMap<EntityId, usize>,
964    errors: &mut Vec<ValidationError>,
965) {
966    for contract in contracts {
967        if !bus_index.contains_key(&contract.bus_id) {
968            errors.push(ValidationError::InvalidReference {
969                source_entity_type: "EnergyContract",
970                source_id: contract.id,
971                field_name: "bus_id",
972                referenced_id: contract.bus_id,
973                expected_type: "Bus",
974            });
975        }
976    }
977}
978
979fn validate_ncs_refs(
980    non_controllable_sources: &[NonControllableSource],
981    bus_index: &HashMap<EntityId, usize>,
982    errors: &mut Vec<ValidationError>,
983) {
984    for ncs in non_controllable_sources {
985        if !bus_index.contains_key(&ncs.bus_id) {
986            errors.push(ValidationError::InvalidReference {
987                source_entity_type: "NonControllableSource",
988                source_id: ncs.id,
989                field_name: "bus_id",
990                referenced_id: ncs.bus_id,
991                expected_type: "Bus",
992            });
993        }
994    }
995}
996
997/// Validate filling configurations for all hydros that have one.
998///
999/// For each hydro with `filling: Some(config)`:
1000/// - `filling_inflow_m3s` must be positive (> 0.0).
1001/// - `entry_stage_id` must be set (`Some`), since filling requires a known start stage.
1002///
1003/// All violations are appended to `errors` — no short-circuiting on first error.
1004fn validate_filling_configs(hydros: &[Hydro], errors: &mut Vec<ValidationError>) {
1005    for hydro in hydros {
1006        if let Some(filling) = &hydro.filling {
1007            if filling.filling_inflow_m3s.is_nan() || filling.filling_inflow_m3s <= 0.0 {
1008                errors.push(ValidationError::InvalidFillingConfig {
1009                    hydro_id: hydro.id,
1010                    reason: "filling_inflow_m3s must be positive".to_string(),
1011                });
1012            }
1013            if hydro.entry_stage_id.is_none() {
1014                errors.push(ValidationError::InvalidFillingConfig {
1015                    hydro_id: hydro.id,
1016                    reason: "filling requires entry_stage_id to be set".to_string(),
1017                });
1018            }
1019        }
1020    }
1021}
1022
1023#[cfg(test)]
1024mod tests {
1025    use super::*;
1026    use crate::entities::{ContractType, HydroGenerationModel, HydroPenalties, ThermalCostSegment};
1027
1028    fn make_bus(id: i32) -> Bus {
1029        Bus {
1030            id: EntityId(id),
1031            name: format!("bus-{id}"),
1032            deficit_segments: vec![],
1033            excess_cost: 0.0,
1034        }
1035    }
1036
1037    fn make_line(id: i32, source_bus_id: i32, target_bus_id: i32) -> Line {
1038        crate::Line {
1039            id: EntityId(id),
1040            name: format!("line-{id}"),
1041            source_bus_id: EntityId(source_bus_id),
1042            target_bus_id: EntityId(target_bus_id),
1043            entry_stage_id: None,
1044            exit_stage_id: None,
1045            direct_capacity_mw: 100.0,
1046            reverse_capacity_mw: 100.0,
1047            losses_percent: 0.0,
1048            exchange_cost: 0.0,
1049        }
1050    }
1051
1052    fn make_hydro_on_bus(id: i32, bus_id: i32) -> Hydro {
1053        let zero_penalties = HydroPenalties {
1054            spillage_cost: 0.0,
1055            diversion_cost: 0.0,
1056            fpha_turbined_cost: 0.0,
1057            storage_violation_below_cost: 0.0,
1058            filling_target_violation_cost: 0.0,
1059            turbined_violation_below_cost: 0.0,
1060            outflow_violation_below_cost: 0.0,
1061            outflow_violation_above_cost: 0.0,
1062            generation_violation_below_cost: 0.0,
1063            evaporation_violation_cost: 0.0,
1064            water_withdrawal_violation_cost: 0.0,
1065        };
1066        Hydro {
1067            id: EntityId(id),
1068            name: format!("hydro-{id}"),
1069            bus_id: EntityId(bus_id),
1070            downstream_id: None,
1071            entry_stage_id: None,
1072            exit_stage_id: None,
1073            min_storage_hm3: 0.0,
1074            max_storage_hm3: 1.0,
1075            min_outflow_m3s: 0.0,
1076            max_outflow_m3s: None,
1077            generation_model: HydroGenerationModel::ConstantProductivity {
1078                productivity_mw_per_m3s: 1.0,
1079            },
1080            min_turbined_m3s: 0.0,
1081            max_turbined_m3s: 1.0,
1082            min_generation_mw: 0.0,
1083            max_generation_mw: 1.0,
1084            tailrace: None,
1085            hydraulic_losses: None,
1086            efficiency: None,
1087            evaporation_coefficients_mm: None,
1088            diversion: None,
1089            filling: None,
1090            penalties: zero_penalties,
1091        }
1092    }
1093
1094    /// Creates a hydro on bus 0. Caller must supply `make_bus(0)`.
1095    fn make_hydro(id: i32) -> Hydro {
1096        make_hydro_on_bus(id, 0)
1097    }
1098
1099    fn make_thermal_on_bus(id: i32, bus_id: i32) -> Thermal {
1100        Thermal {
1101            id: EntityId(id),
1102            name: format!("thermal-{id}"),
1103            bus_id: EntityId(bus_id),
1104            entry_stage_id: None,
1105            exit_stage_id: None,
1106            cost_segments: vec![ThermalCostSegment {
1107                capacity_mw: 100.0,
1108                cost_per_mwh: 50.0,
1109            }],
1110            min_generation_mw: 0.0,
1111            max_generation_mw: 100.0,
1112            gnl_config: None,
1113        }
1114    }
1115
1116    /// Creates a thermal on bus 0. Caller must supply `make_bus(0)`.
1117    fn make_thermal(id: i32) -> Thermal {
1118        make_thermal_on_bus(id, 0)
1119    }
1120
1121    fn make_pumping_station_full(
1122        id: i32,
1123        bus_id: i32,
1124        source_hydro_id: i32,
1125        destination_hydro_id: i32,
1126    ) -> PumpingStation {
1127        PumpingStation {
1128            id: EntityId(id),
1129            name: format!("ps-{id}"),
1130            bus_id: EntityId(bus_id),
1131            source_hydro_id: EntityId(source_hydro_id),
1132            destination_hydro_id: EntityId(destination_hydro_id),
1133            entry_stage_id: None,
1134            exit_stage_id: None,
1135            consumption_mw_per_m3s: 0.5,
1136            min_flow_m3s: 0.0,
1137            max_flow_m3s: 10.0,
1138        }
1139    }
1140
1141    fn make_pumping_station(id: i32) -> PumpingStation {
1142        make_pumping_station_full(id, 0, 0, 1)
1143    }
1144
1145    fn make_contract_on_bus(id: i32, bus_id: i32) -> EnergyContract {
1146        EnergyContract {
1147            id: EntityId(id),
1148            name: format!("contract-{id}"),
1149            bus_id: EntityId(bus_id),
1150            contract_type: ContractType::Import,
1151            entry_stage_id: None,
1152            exit_stage_id: None,
1153            price_per_mwh: 0.0,
1154            min_mw: 0.0,
1155            max_mw: 100.0,
1156        }
1157    }
1158
1159    fn make_contract(id: i32) -> EnergyContract {
1160        make_contract_on_bus(id, 0)
1161    }
1162
1163    fn make_ncs_on_bus(id: i32, bus_id: i32) -> NonControllableSource {
1164        NonControllableSource {
1165            id: EntityId(id),
1166            name: format!("ncs-{id}"),
1167            bus_id: EntityId(bus_id),
1168            entry_stage_id: None,
1169            exit_stage_id: None,
1170            max_generation_mw: 50.0,
1171            curtailment_cost: 0.0,
1172        }
1173    }
1174
1175    fn make_ncs(id: i32) -> NonControllableSource {
1176        make_ncs_on_bus(id, 0)
1177    }
1178
1179    #[test]
1180    fn test_empty_system() {
1181        let system = SystemBuilder::new().build().expect("empty system is valid");
1182        assert_eq!(system.n_buses(), 0);
1183        assert_eq!(system.n_lines(), 0);
1184        assert_eq!(system.n_hydros(), 0);
1185        assert_eq!(system.n_thermals(), 0);
1186        assert_eq!(system.n_pumping_stations(), 0);
1187        assert_eq!(system.n_contracts(), 0);
1188        assert_eq!(system.n_non_controllable_sources(), 0);
1189        assert!(system.buses().is_empty());
1190        assert!(system.cascade().is_empty());
1191    }
1192
1193    #[test]
1194    fn test_canonical_ordering() {
1195        // Provide buses in reverse order: id=2, id=1, id=0
1196        let system = SystemBuilder::new()
1197            .buses(vec![make_bus(2), make_bus(1), make_bus(0)])
1198            .build()
1199            .expect("valid system");
1200
1201        assert_eq!(system.buses()[0].id, EntityId(0));
1202        assert_eq!(system.buses()[1].id, EntityId(1));
1203        assert_eq!(system.buses()[2].id, EntityId(2));
1204    }
1205
1206    #[test]
1207    fn test_lookup_by_id() {
1208        // Hydros reference bus id=0; supply it so cross-reference validation passes.
1209        let system = SystemBuilder::new()
1210            .buses(vec![make_bus(0)])
1211            .hydros(vec![make_hydro(10), make_hydro(5), make_hydro(20)])
1212            .build()
1213            .expect("valid system");
1214
1215        assert_eq!(system.hydro(EntityId(5)).map(|h| h.id), Some(EntityId(5)));
1216        assert_eq!(system.hydro(EntityId(10)).map(|h| h.id), Some(EntityId(10)));
1217        assert_eq!(system.hydro(EntityId(20)).map(|h| h.id), Some(EntityId(20)));
1218    }
1219
1220    #[test]
1221    fn test_lookup_missing_id() {
1222        // Hydros reference bus id=0; supply it so cross-reference validation passes.
1223        let system = SystemBuilder::new()
1224            .buses(vec![make_bus(0)])
1225            .hydros(vec![make_hydro(1), make_hydro(2)])
1226            .build()
1227            .expect("valid system");
1228
1229        assert!(system.hydro(EntityId(999)).is_none());
1230    }
1231
1232    #[test]
1233    fn test_count_queries() {
1234        let system = SystemBuilder::new()
1235            .buses(vec![make_bus(0), make_bus(1)])
1236            .lines(vec![make_line(0, 0, 1)])
1237            .hydros(vec![make_hydro(0), make_hydro(1), make_hydro(2)])
1238            .thermals(vec![make_thermal(0)])
1239            .pumping_stations(vec![make_pumping_station(0)])
1240            .contracts(vec![make_contract(0), make_contract(1)])
1241            .non_controllable_sources(vec![make_ncs(0)])
1242            .build()
1243            .expect("valid system");
1244
1245        assert_eq!(system.n_buses(), 2);
1246        assert_eq!(system.n_lines(), 1);
1247        assert_eq!(system.n_hydros(), 3);
1248        assert_eq!(system.n_thermals(), 1);
1249        assert_eq!(system.n_pumping_stations(), 1);
1250        assert_eq!(system.n_contracts(), 2);
1251        assert_eq!(system.n_non_controllable_sources(), 1);
1252    }
1253
1254    #[test]
1255    fn test_slice_accessors() {
1256        let system = SystemBuilder::new()
1257            .buses(vec![make_bus(0), make_bus(1), make_bus(2)])
1258            .build()
1259            .expect("valid system");
1260
1261        let buses = system.buses();
1262        assert_eq!(buses.len(), 3);
1263        assert_eq!(buses[0].id, EntityId(0));
1264        assert_eq!(buses[1].id, EntityId(1));
1265        assert_eq!(buses[2].id, EntityId(2));
1266    }
1267
1268    #[test]
1269    fn test_duplicate_id_error() {
1270        // Two buses with the same id=0 must yield an Err.
1271        let result = SystemBuilder::new()
1272            .buses(vec![make_bus(0), make_bus(0)])
1273            .build();
1274
1275        assert!(result.is_err());
1276        let errors = result.unwrap_err();
1277        assert!(!errors.is_empty());
1278        assert!(errors.iter().any(|e| matches!(
1279            e,
1280            ValidationError::DuplicateId {
1281                entity_type: "Bus",
1282                id: EntityId(0),
1283            }
1284        )));
1285    }
1286
1287    #[test]
1288    fn test_multiple_duplicate_errors() {
1289        // Duplicates in both buses (id=0) and thermals (id=5) must both be reported.
1290        let result = SystemBuilder::new()
1291            .buses(vec![make_bus(0), make_bus(0)])
1292            .thermals(vec![make_thermal(5), make_thermal(5)])
1293            .build();
1294
1295        assert!(result.is_err());
1296        let errors = result.unwrap_err();
1297
1298        let has_bus_dup = errors.iter().any(|e| {
1299            matches!(
1300                e,
1301                ValidationError::DuplicateId {
1302                    entity_type: "Bus",
1303                    ..
1304                }
1305            )
1306        });
1307        let has_thermal_dup = errors.iter().any(|e| {
1308            matches!(
1309                e,
1310                ValidationError::DuplicateId {
1311                    entity_type: "Thermal",
1312                    ..
1313                }
1314            )
1315        });
1316        assert!(has_bus_dup, "expected Bus duplicate error");
1317        assert!(has_thermal_dup, "expected Thermal duplicate error");
1318    }
1319
1320    #[test]
1321    fn test_send_sync() {
1322        fn require_send_sync<T: Send + Sync>(_: T) {}
1323        let system = SystemBuilder::new().build().expect("valid system");
1324        require_send_sync(system);
1325    }
1326
1327    #[test]
1328    fn test_cascade_accessible() {
1329        // Hydros reference bus id=0; supply it so cross-reference validation passes.
1330        let mut h0 = make_hydro_on_bus(0, 0);
1331        h0.downstream_id = Some(EntityId(1));
1332        let mut h1 = make_hydro_on_bus(1, 0);
1333        h1.downstream_id = Some(EntityId(2));
1334        let h2 = make_hydro_on_bus(2, 0);
1335
1336        let system = SystemBuilder::new()
1337            .buses(vec![make_bus(0)])
1338            .hydros(vec![h0, h1, h2])
1339            .build()
1340            .expect("valid system");
1341
1342        let order = system.cascade().topological_order();
1343        assert!(!order.is_empty(), "topological order must be non-empty");
1344        let pos_0 = order
1345            .iter()
1346            .position(|&id| id == EntityId(0))
1347            .expect("EntityId(0) must be in topological order");
1348        let pos_2 = order
1349            .iter()
1350            .position(|&id| id == EntityId(2))
1351            .expect("EntityId(2) must be in topological order");
1352        assert!(pos_0 < pos_2, "EntityId(0) must precede EntityId(2)");
1353    }
1354
1355    #[test]
1356    fn test_network_accessible() {
1357        let system = SystemBuilder::new()
1358            .buses(vec![make_bus(0), make_bus(1)])
1359            .lines(vec![make_line(0, 0, 1)])
1360            .build()
1361            .expect("valid system");
1362
1363        let connections = system.network().bus_lines(EntityId(0));
1364        assert!(!connections.is_empty(), "bus 0 must have connections");
1365        assert_eq!(connections[0].line_id, EntityId(0));
1366    }
1367
1368    #[test]
1369    fn test_all_entity_lookups() {
1370        // Provide all buses and hydros that the other entities reference.
1371        // - Buses 0 and 1 are needed by all entities (lines, hydros, thermals, etc.)
1372        // - Hydros 0 and 1 are needed by the pumping station (source/destination)
1373        // - Hydro 3 is the entity under test (lookup by id=3), on bus 0
1374        let system = SystemBuilder::new()
1375            .buses(vec![make_bus(0), make_bus(1)])
1376            .lines(vec![make_line(2, 0, 1)])
1377            .hydros(vec![
1378                make_hydro_on_bus(0, 0),
1379                make_hydro_on_bus(1, 0),
1380                make_hydro_on_bus(3, 0),
1381            ])
1382            .thermals(vec![make_thermal(4)])
1383            .pumping_stations(vec![make_pumping_station(5)])
1384            .contracts(vec![make_contract(6)])
1385            .non_controllable_sources(vec![make_ncs(7)])
1386            .build()
1387            .expect("valid system");
1388
1389        assert!(system.bus(EntityId(1)).is_some());
1390        assert!(system.line(EntityId(2)).is_some());
1391        assert!(system.hydro(EntityId(3)).is_some());
1392        assert!(system.thermal(EntityId(4)).is_some());
1393        assert!(system.pumping_station(EntityId(5)).is_some());
1394        assert!(system.contract(EntityId(6)).is_some());
1395        assert!(system.non_controllable_source(EntityId(7)).is_some());
1396
1397        assert!(system.bus(EntityId(999)).is_none());
1398        assert!(system.line(EntityId(999)).is_none());
1399        assert!(system.hydro(EntityId(999)).is_none());
1400        assert!(system.thermal(EntityId(999)).is_none());
1401        assert!(system.pumping_station(EntityId(999)).is_none());
1402        assert!(system.contract(EntityId(999)).is_none());
1403        assert!(system.non_controllable_source(EntityId(999)).is_none());
1404    }
1405
1406    #[test]
1407    fn test_default_builder() {
1408        let system = SystemBuilder::default()
1409            .build()
1410            .expect("default builder produces valid empty system");
1411        assert_eq!(system.n_buses(), 0);
1412    }
1413
1414    // ---- Cross-reference validation tests -----------------------------------
1415
1416    #[test]
1417    fn test_invalid_bus_reference_hydro() {
1418        // Hydro references bus id=99 which does not exist.
1419        let hydro = make_hydro_on_bus(1, 99);
1420        let result = SystemBuilder::new().hydros(vec![hydro]).build();
1421
1422        assert!(result.is_err(), "expected Err for missing bus reference");
1423        let errors = result.unwrap_err();
1424        assert!(
1425            errors.iter().any(|e| matches!(
1426                e,
1427                ValidationError::InvalidReference {
1428                    source_entity_type: "Hydro",
1429                    source_id: EntityId(1),
1430                    field_name: "bus_id",
1431                    referenced_id: EntityId(99),
1432                    expected_type: "Bus",
1433                }
1434            )),
1435            "expected InvalidReference for Hydro bus_id=99, got: {errors:?}"
1436        );
1437    }
1438
1439    #[test]
1440    fn test_invalid_downstream_reference() {
1441        // Hydro references downstream hydro id=50 which does not exist.
1442        let bus = make_bus(0);
1443        let mut hydro = make_hydro(1);
1444        hydro.downstream_id = Some(EntityId(50));
1445
1446        let result = SystemBuilder::new()
1447            .buses(vec![bus])
1448            .hydros(vec![hydro])
1449            .build();
1450
1451        assert!(
1452            result.is_err(),
1453            "expected Err for missing downstream reference"
1454        );
1455        let errors = result.unwrap_err();
1456        assert!(
1457            errors.iter().any(|e| matches!(
1458                e,
1459                ValidationError::InvalidReference {
1460                    source_entity_type: "Hydro",
1461                    source_id: EntityId(1),
1462                    field_name: "downstream_id",
1463                    referenced_id: EntityId(50),
1464                    expected_type: "Hydro",
1465                }
1466            )),
1467            "expected InvalidReference for Hydro downstream_id=50, got: {errors:?}"
1468        );
1469    }
1470
1471    #[test]
1472    fn test_invalid_pumping_station_hydro_refs() {
1473        // Pumping station references source hydro id=77 which does not exist.
1474        let bus = make_bus(0);
1475        let dest_hydro = make_hydro(1);
1476        let ps = make_pumping_station_full(10, 0, 77, 1);
1477
1478        let result = SystemBuilder::new()
1479            .buses(vec![bus])
1480            .hydros(vec![dest_hydro])
1481            .pumping_stations(vec![ps])
1482            .build();
1483
1484        assert!(
1485            result.is_err(),
1486            "expected Err for missing source_hydro_id reference"
1487        );
1488        let errors = result.unwrap_err();
1489        assert!(
1490            errors.iter().any(|e| matches!(
1491                e,
1492                ValidationError::InvalidReference {
1493                    source_entity_type: "PumpingStation",
1494                    source_id: EntityId(10),
1495                    field_name: "source_hydro_id",
1496                    referenced_id: EntityId(77),
1497                    expected_type: "Hydro",
1498                }
1499            )),
1500            "expected InvalidReference for PumpingStation source_hydro_id=77, got: {errors:?}"
1501        );
1502    }
1503
1504    #[test]
1505    fn test_multiple_invalid_references_collected() {
1506        // A line with bad source_bus_id AND a thermal with bad bus_id.
1507        // Both errors must be reported (no short-circuiting).
1508        let line = make_line(1, 99, 0);
1509        let thermal = make_thermal_on_bus(2, 88);
1510
1511        let result = SystemBuilder::new()
1512            .buses(vec![make_bus(0)])
1513            .lines(vec![line])
1514            .thermals(vec![thermal])
1515            .build();
1516
1517        assert!(
1518            result.is_err(),
1519            "expected Err for multiple invalid references"
1520        );
1521        let errors = result.unwrap_err();
1522
1523        let has_line_error = errors.iter().any(|e| {
1524            matches!(
1525                e,
1526                ValidationError::InvalidReference {
1527                    source_entity_type: "Line",
1528                    field_name: "source_bus_id",
1529                    referenced_id: EntityId(99),
1530                    ..
1531                }
1532            )
1533        });
1534        let has_thermal_error = errors.iter().any(|e| {
1535            matches!(
1536                e,
1537                ValidationError::InvalidReference {
1538                    source_entity_type: "Thermal",
1539                    field_name: "bus_id",
1540                    referenced_id: EntityId(88),
1541                    ..
1542                }
1543            )
1544        });
1545
1546        assert!(
1547            has_line_error,
1548            "expected Line source_bus_id=99 error, got: {errors:?}"
1549        );
1550        assert!(
1551            has_thermal_error,
1552            "expected Thermal bus_id=88 error, got: {errors:?}"
1553        );
1554        assert!(
1555            errors.len() >= 2,
1556            "expected at least 2 errors, got {}: {errors:?}",
1557            errors.len()
1558        );
1559    }
1560
1561    #[test]
1562    fn test_valid_cross_references_pass() {
1563        // All cross-references point to entities that exist — build must succeed.
1564        let bus_0 = make_bus(0);
1565        let bus_1 = make_bus(1);
1566        let h0 = make_hydro_on_bus(0, 0);
1567        let h1 = make_hydro_on_bus(1, 1);
1568        let mut h2 = make_hydro_on_bus(2, 0);
1569        h2.downstream_id = Some(EntityId(1));
1570        let line = make_line(10, 0, 1);
1571        let thermal = make_thermal_on_bus(20, 0);
1572        let ps = make_pumping_station_full(30, 0, 0, 1);
1573        let contract = make_contract_on_bus(40, 1);
1574        let ncs = make_ncs_on_bus(50, 0);
1575
1576        let result = SystemBuilder::new()
1577            .buses(vec![bus_0, bus_1])
1578            .lines(vec![line])
1579            .hydros(vec![h0, h1, h2])
1580            .thermals(vec![thermal])
1581            .pumping_stations(vec![ps])
1582            .contracts(vec![contract])
1583            .non_controllable_sources(vec![ncs])
1584            .build();
1585
1586        assert!(
1587            result.is_ok(),
1588            "expected Ok for all valid cross-references, got: {:?}",
1589            result.unwrap_err()
1590        );
1591        let system = result.unwrap_or_else(|_| unreachable!());
1592        assert_eq!(system.n_buses(), 2);
1593        assert_eq!(system.n_hydros(), 3);
1594        assert_eq!(system.n_lines(), 1);
1595        assert_eq!(system.n_thermals(), 1);
1596        assert_eq!(system.n_pumping_stations(), 1);
1597        assert_eq!(system.n_contracts(), 1);
1598        assert_eq!(system.n_non_controllable_sources(), 1);
1599    }
1600
1601    // ---- Cascade cycle detection tests --------------------------------------
1602
1603    #[test]
1604    fn test_cascade_cycle_detected() {
1605        // Three-node cycle: A(0)->B(1)->C(2)->A(0).
1606        // All three reference a common bus (bus 0).
1607        let bus = make_bus(0);
1608        let mut h0 = make_hydro(0);
1609        h0.downstream_id = Some(EntityId(1));
1610        let mut h1 = make_hydro(1);
1611        h1.downstream_id = Some(EntityId(2));
1612        let mut h2 = make_hydro(2);
1613        h2.downstream_id = Some(EntityId(0));
1614
1615        let result = SystemBuilder::new()
1616            .buses(vec![bus])
1617            .hydros(vec![h0, h1, h2])
1618            .build();
1619
1620        assert!(result.is_err(), "expected Err for 3-node cycle");
1621        let errors = result.unwrap_err();
1622        let cycle_error = errors
1623            .iter()
1624            .find(|e| matches!(e, ValidationError::CascadeCycle { .. }));
1625        assert!(
1626            cycle_error.is_some(),
1627            "expected CascadeCycle error, got: {errors:?}"
1628        );
1629        let ValidationError::CascadeCycle { cycle_ids } = cycle_error.unwrap() else {
1630            unreachable!()
1631        };
1632        assert_eq!(
1633            cycle_ids,
1634            &[EntityId(0), EntityId(1), EntityId(2)],
1635            "cycle_ids must be sorted ascending, got: {cycle_ids:?}"
1636        );
1637    }
1638
1639    #[test]
1640    fn test_cascade_self_loop_detected() {
1641        // Single hydro pointing to itself: A(0)->A(0).
1642        let bus = make_bus(0);
1643        let mut h0 = make_hydro(0);
1644        h0.downstream_id = Some(EntityId(0));
1645
1646        let result = SystemBuilder::new()
1647            .buses(vec![bus])
1648            .hydros(vec![h0])
1649            .build();
1650
1651        assert!(result.is_err(), "expected Err for self-loop");
1652        let errors = result.unwrap_err();
1653        let has_cycle = errors
1654            .iter()
1655            .any(|e| matches!(e, ValidationError::CascadeCycle { cycle_ids } if cycle_ids.contains(&EntityId(0))));
1656        assert!(
1657            has_cycle,
1658            "expected CascadeCycle containing EntityId(0), got: {errors:?}"
1659        );
1660    }
1661
1662    #[test]
1663    fn test_valid_acyclic_cascade_passes() {
1664        // Linear acyclic cascade A(0)->B(1)->C(2).
1665        // Verifies that a valid cascade produces Ok with correct topological_order length.
1666        let bus = make_bus(0);
1667        let mut h0 = make_hydro(0);
1668        h0.downstream_id = Some(EntityId(1));
1669        let mut h1 = make_hydro(1);
1670        h1.downstream_id = Some(EntityId(2));
1671        let h2 = make_hydro(2);
1672
1673        let result = SystemBuilder::new()
1674            .buses(vec![bus])
1675            .hydros(vec![h0, h1, h2])
1676            .build();
1677
1678        assert!(
1679            result.is_ok(),
1680            "expected Ok for acyclic cascade, got: {:?}",
1681            result.unwrap_err()
1682        );
1683        let system = result.unwrap_or_else(|_| unreachable!());
1684        assert_eq!(
1685            system.cascade().topological_order().len(),
1686            system.n_hydros(),
1687            "topological_order must contain all hydros"
1688        );
1689    }
1690
1691    // ---- Filling config validation tests ------------------------------------
1692
1693    #[test]
1694    fn test_filling_without_entry_stage() {
1695        // Filling config present but entry_stage_id is None.
1696        use crate::entities::FillingConfig;
1697        let bus = make_bus(0);
1698        let mut hydro = make_hydro(1);
1699        hydro.entry_stage_id = None;
1700        hydro.filling = Some(FillingConfig {
1701            start_stage_id: 10,
1702            filling_inflow_m3s: 100.0,
1703        });
1704
1705        let result = SystemBuilder::new()
1706            .buses(vec![bus])
1707            .hydros(vec![hydro])
1708            .build();
1709
1710        assert!(
1711            result.is_err(),
1712            "expected Err for filling without entry_stage_id"
1713        );
1714        let errors = result.unwrap_err();
1715        let has_error = errors.iter().any(|e| match e {
1716            ValidationError::InvalidFillingConfig { hydro_id, reason } => {
1717                *hydro_id == EntityId(1) && reason.contains("entry_stage_id")
1718            }
1719            _ => false,
1720        });
1721        assert!(
1722            has_error,
1723            "expected InvalidFillingConfig with entry_stage_id reason, got: {errors:?}"
1724        );
1725    }
1726
1727    #[test]
1728    fn test_filling_negative_inflow() {
1729        // Filling config with filling_inflow_m3s <= 0.0.
1730        use crate::entities::FillingConfig;
1731        let bus = make_bus(0);
1732        let mut hydro = make_hydro(1);
1733        hydro.entry_stage_id = Some(10);
1734        hydro.filling = Some(FillingConfig {
1735            start_stage_id: 10,
1736            filling_inflow_m3s: -5.0,
1737        });
1738
1739        let result = SystemBuilder::new()
1740            .buses(vec![bus])
1741            .hydros(vec![hydro])
1742            .build();
1743
1744        assert!(
1745            result.is_err(),
1746            "expected Err for negative filling_inflow_m3s"
1747        );
1748        let errors = result.unwrap_err();
1749        let has_error = errors.iter().any(|e| match e {
1750            ValidationError::InvalidFillingConfig { hydro_id, reason } => {
1751                *hydro_id == EntityId(1) && reason.contains("filling_inflow_m3s must be positive")
1752            }
1753            _ => false,
1754        });
1755        assert!(
1756            has_error,
1757            "expected InvalidFillingConfig with positive inflow reason, got: {errors:?}"
1758        );
1759    }
1760
1761    #[test]
1762    fn test_valid_filling_config_passes() {
1763        // Valid filling config: entry_stage_id set and filling_inflow_m3s positive.
1764        use crate::entities::FillingConfig;
1765        let bus = make_bus(0);
1766        let mut hydro = make_hydro(1);
1767        hydro.entry_stage_id = Some(10);
1768        hydro.filling = Some(FillingConfig {
1769            start_stage_id: 10,
1770            filling_inflow_m3s: 100.0,
1771        });
1772
1773        let result = SystemBuilder::new()
1774            .buses(vec![bus])
1775            .hydros(vec![hydro])
1776            .build();
1777
1778        assert!(
1779            result.is_ok(),
1780            "expected Ok for valid filling config, got: {:?}",
1781            result.unwrap_err()
1782        );
1783    }
1784
1785    #[test]
1786    fn test_cascade_cycle_and_invalid_filling_both_reported() {
1787        // Both a cascade cycle (A->A self-loop) AND an invalid filling config
1788        // must produce both error variants.
1789        use crate::entities::FillingConfig;
1790        let bus = make_bus(0);
1791
1792        // Hydro 0: self-loop (cycle)
1793        let mut h0 = make_hydro(0);
1794        h0.downstream_id = Some(EntityId(0));
1795
1796        // Hydro 1: valid cycle participant? No -- use a separate hydro with invalid filling.
1797        let mut h1 = make_hydro(1);
1798        h1.entry_stage_id = None; // no entry_stage_id
1799        h1.filling = Some(FillingConfig {
1800            start_stage_id: 5,
1801            filling_inflow_m3s: 50.0,
1802        });
1803
1804        let result = SystemBuilder::new()
1805            .buses(vec![bus])
1806            .hydros(vec![h0, h1])
1807            .build();
1808
1809        assert!(result.is_err(), "expected Err for cycle + invalid filling");
1810        let errors = result.unwrap_err();
1811        let has_cycle = errors
1812            .iter()
1813            .any(|e| matches!(e, ValidationError::CascadeCycle { .. }));
1814        let has_filling = errors
1815            .iter()
1816            .any(|e| matches!(e, ValidationError::InvalidFillingConfig { .. }));
1817        assert!(has_cycle, "expected CascadeCycle error, got: {errors:?}");
1818        assert!(
1819            has_filling,
1820            "expected InvalidFillingConfig error, got: {errors:?}"
1821        );
1822    }
1823
1824    #[cfg(feature = "serde")]
1825    #[test]
1826    fn test_system_serde_roundtrip() {
1827        // Build a system with a bus, a hydro, a line, and a thermal.
1828        let bus_a = make_bus(1);
1829        let bus_b = make_bus(2);
1830        let hydro = make_hydro_on_bus(10, 1);
1831        let thermal = make_thermal_on_bus(20, 2);
1832        let line = make_line(1, 1, 2);
1833
1834        let system = SystemBuilder::new()
1835            .buses(vec![bus_a, bus_b])
1836            .hydros(vec![hydro])
1837            .thermals(vec![thermal])
1838            .lines(vec![line])
1839            .build()
1840            .expect("valid system");
1841
1842        let json = serde_json::to_string(&system).unwrap();
1843
1844        // Deserialize and rebuild indices.
1845        let mut deserialized: System = serde_json::from_str(&json).unwrap();
1846        deserialized.rebuild_indices();
1847
1848        // Entity collections must match.
1849        assert_eq!(system.buses(), deserialized.buses());
1850        assert_eq!(system.hydros(), deserialized.hydros());
1851        assert_eq!(system.thermals(), deserialized.thermals());
1852        assert_eq!(system.lines(), deserialized.lines());
1853
1854        // O(1) lookup must work after index rebuild.
1855        assert_eq!(
1856            deserialized.bus(EntityId(1)).map(|b| b.id),
1857            Some(EntityId(1))
1858        );
1859        assert_eq!(
1860            deserialized.hydro(EntityId(10)).map(|h| h.id),
1861            Some(EntityId(10))
1862        );
1863        assert_eq!(
1864            deserialized.thermal(EntityId(20)).map(|t| t.id),
1865            Some(EntityId(20))
1866        );
1867        assert_eq!(
1868            deserialized.line(EntityId(1)).map(|l| l.id),
1869            Some(EntityId(1))
1870        );
1871    }
1872
1873    // ---- Extended System tests ----------------------------------------------
1874
1875    fn make_stage(id: i32) -> Stage {
1876        use crate::temporal::{
1877            Block, BlockMode, NoiseMethod, ScenarioSourceConfig, StageRiskConfig, StageStateConfig,
1878        };
1879        use chrono::NaiveDate;
1880        Stage {
1881            index: usize::try_from(id.max(0)).unwrap_or(0),
1882            id,
1883            start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1884            end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
1885            season_id: Some(0),
1886            blocks: vec![Block {
1887                index: 0,
1888                name: "SINGLE".to_string(),
1889                duration_hours: 744.0,
1890            }],
1891            block_mode: BlockMode::Parallel,
1892            state_config: StageStateConfig {
1893                storage: true,
1894                inflow_lags: false,
1895            },
1896            risk_config: StageRiskConfig::Expectation,
1897            scenario_config: ScenarioSourceConfig {
1898                branching_factor: 50,
1899                noise_method: NoiseMethod::Saa,
1900            },
1901        }
1902    }
1903
1904    /// Verify that `SystemBuilder::new().build()` still works correctly.
1905    /// New fields must default to empty/default values.
1906    #[test]
1907    fn test_system_backward_compat() {
1908        let system = SystemBuilder::new().build().expect("empty system is valid");
1909        // Entity counts unchanged
1910        assert_eq!(system.n_buses(), 0);
1911        assert_eq!(system.n_hydros(), 0);
1912        // New fields default to empty
1913        assert_eq!(system.n_stages(), 0);
1914        assert!(system.stages().is_empty());
1915        assert!(system.initial_conditions().storage.is_empty());
1916        assert!(system.generic_constraints().is_empty());
1917        assert!(system.inflow_models().is_empty());
1918        assert!(system.load_models().is_empty());
1919        assert_eq!(system.penalties().n_stages(), 0);
1920        assert_eq!(system.bounds().n_stages(), 0);
1921    }
1922
1923    /// Build a System with 2 stages and verify `n_stages()` and `stage(id)` lookup.
1924    #[test]
1925    fn test_system_with_stages() {
1926        let s0 = make_stage(0);
1927        let s1 = make_stage(1);
1928
1929        let system = SystemBuilder::new()
1930            .stages(vec![s1.clone(), s0.clone()]) // supply in reverse order
1931            .build()
1932            .expect("valid system");
1933
1934        // Canonical ordering: id=0 comes before id=1
1935        assert_eq!(system.n_stages(), 2);
1936        assert_eq!(system.stages()[0].id, 0);
1937        assert_eq!(system.stages()[1].id, 1);
1938
1939        // O(1) lookup by stage id
1940        let found = system.stage(0).expect("stage 0 must be found");
1941        assert_eq!(found.id, s0.id);
1942
1943        let found1 = system.stage(1).expect("stage 1 must be found");
1944        assert_eq!(found1.id, s1.id);
1945
1946        // Missing stage returns None
1947        assert!(system.stage(99).is_none());
1948    }
1949
1950    /// Build a System with 3 stages having IDs 0, 1, 2 and verify `stage()` lookups.
1951    #[test]
1952    fn test_system_stage_lookup_by_id() {
1953        let stages: Vec<Stage> = [0i32, 1, 2].iter().map(|&id| make_stage(id)).collect();
1954
1955        let system = SystemBuilder::new()
1956            .stages(stages)
1957            .build()
1958            .expect("valid system");
1959
1960        assert_eq!(system.stage(1).map(|s| s.id), Some(1));
1961        assert!(system.stage(99).is_none());
1962    }
1963
1964    /// Build a System with `InitialConditions` containing 1 storage entry and verify accessor.
1965    #[test]
1966    fn test_system_with_initial_conditions() {
1967        let ic = InitialConditions {
1968            storage: vec![crate::HydroStorage {
1969                hydro_id: EntityId(0),
1970                value_hm3: 15_000.0,
1971            }],
1972            filling_storage: vec![],
1973        };
1974
1975        let system = SystemBuilder::new()
1976            .initial_conditions(ic)
1977            .build()
1978            .expect("valid system");
1979
1980        assert_eq!(system.initial_conditions().storage.len(), 1);
1981        assert_eq!(system.initial_conditions().storage[0].hydro_id, EntityId(0));
1982        assert!((system.initial_conditions().storage[0].value_hm3 - 15_000.0).abs() < f64::EPSILON);
1983    }
1984
1985    /// Verify serde round-trip of a System with stages and `policy_graph`,
1986    /// including that `stage_index` is correctly rebuilt after deserialization.
1987    #[cfg(feature = "serde")]
1988    #[test]
1989    fn test_system_serde_roundtrip_with_stages() {
1990        use crate::temporal::PolicyGraphType;
1991
1992        let stages = vec![make_stage(0), make_stage(1)];
1993        let policy_graph = PolicyGraph {
1994            graph_type: PolicyGraphType::FiniteHorizon,
1995            annual_discount_rate: 0.0,
1996            transitions: vec![],
1997            season_map: None,
1998        };
1999
2000        let system = SystemBuilder::new()
2001            .stages(stages)
2002            .policy_graph(policy_graph)
2003            .build()
2004            .expect("valid system");
2005
2006        let json = serde_json::to_string(&system).unwrap();
2007        let mut deserialized: System = serde_json::from_str(&json).unwrap();
2008
2009        // stage_index is skipped during serde; rebuild before querying
2010        deserialized.rebuild_indices();
2011
2012        // Collections must match after round-trip
2013        assert_eq!(system.n_stages(), deserialized.n_stages());
2014        assert_eq!(system.stages()[0].id, deserialized.stages()[0].id);
2015        assert_eq!(system.stages()[1].id, deserialized.stages()[1].id);
2016
2017        // O(1) lookup must work after index rebuild
2018        assert_eq!(deserialized.stage(0).map(|s| s.id), Some(0));
2019        assert_eq!(deserialized.stage(1).map(|s| s.id), Some(1));
2020        assert!(deserialized.stage(99).is_none());
2021
2022        // policy_graph fields must round-trip
2023        assert_eq!(
2024            deserialized.policy_graph().graph_type,
2025            system.policy_graph().graph_type
2026        );
2027    }
2028}