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