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            &self.lines,
816            &self.hydros,
817            &self.thermals,
818            &self.pumping_stations,
819            &self.contracts,
820            &self.non_controllable_sources,
821            &bus_index,
822            &hydro_index,
823            &mut errors,
824        );
825
826        if !errors.is_empty() {
827            return Err(errors);
828        }
829
830        let cascade = CascadeTopology::build(&self.hydros);
831
832        if cascade.topological_order().len() < self.hydros.len() {
833            let in_topo: HashSet<EntityId> = cascade.topological_order().iter().copied().collect();
834            let mut cycle_ids: Vec<EntityId> = self
835                .hydros
836                .iter()
837                .map(|h| h.id)
838                .filter(|id| !in_topo.contains(id))
839                .collect();
840            cycle_ids.sort_by_key(|id| id.0);
841            errors.push(ValidationError::CascadeCycle { cycle_ids });
842        }
843
844        validate_filling_configs(&self.hydros, &mut errors);
845
846        if !errors.is_empty() {
847            return Err(errors);
848        }
849
850        let network = NetworkTopology::build(
851            &self.buses,
852            &self.lines,
853            &self.hydros,
854            &self.thermals,
855            &self.non_controllable_sources,
856            &self.contracts,
857            &self.pumping_stations,
858        );
859
860        let stage_index = build_stage_index(&self.stages);
861
862        Ok(System {
863            buses: self.buses,
864            lines: self.lines,
865            hydros: self.hydros,
866            thermals: self.thermals,
867            pumping_stations: self.pumping_stations,
868            contracts: self.contracts,
869            non_controllable_sources: self.non_controllable_sources,
870            bus_index,
871            line_index,
872            hydro_index,
873            thermal_index,
874            pumping_station_index,
875            contract_index,
876            non_controllable_source_index,
877            cascade,
878            network,
879            stages: self.stages,
880            policy_graph: self.policy_graph,
881            stage_index,
882            penalties: self.penalties,
883            bounds: self.bounds,
884            resolved_generic_bounds: self.resolved_generic_bounds,
885            resolved_load_factors: self.resolved_load_factors,
886            resolved_exchange_factors: self.resolved_exchange_factors,
887            resolved_ncs_bounds: self.resolved_ncs_bounds,
888            resolved_ncs_factors: self.resolved_ncs_factors,
889            inflow_models: self.inflow_models,
890            load_models: self.load_models,
891            ncs_models: self.ncs_models,
892            correlation: self.correlation,
893            initial_conditions: self.initial_conditions,
894            generic_constraints: self.generic_constraints,
895            scenario_source: self.scenario_source,
896        })
897    }
898}
899
900trait HasId {
901    fn entity_id(&self) -> EntityId;
902}
903
904impl HasId for Bus {
905    fn entity_id(&self) -> EntityId {
906        self.id
907    }
908}
909impl HasId for Line {
910    fn entity_id(&self) -> EntityId {
911        self.id
912    }
913}
914impl HasId for Hydro {
915    fn entity_id(&self) -> EntityId {
916        self.id
917    }
918}
919impl HasId for Thermal {
920    fn entity_id(&self) -> EntityId {
921        self.id
922    }
923}
924impl HasId for PumpingStation {
925    fn entity_id(&self) -> EntityId {
926        self.id
927    }
928}
929impl HasId for EnergyContract {
930    fn entity_id(&self) -> EntityId {
931        self.id
932    }
933}
934impl HasId for NonControllableSource {
935    fn entity_id(&self) -> EntityId {
936        self.id
937    }
938}
939
940fn build_index<T: HasId>(entities: &[T]) -> HashMap<EntityId, usize> {
941    let mut index = HashMap::with_capacity(entities.len());
942    for (i, entity) in entities.iter().enumerate() {
943        index.insert(entity.entity_id(), i);
944    }
945    index
946}
947
948/// Build a stage lookup index from the canonical-ordered stages vec.
949///
950/// Keys are `i32` stage IDs (which can be negative for pre-study stages).
951fn build_stage_index(stages: &[Stage]) -> HashMap<i32, usize> {
952    let mut index = HashMap::with_capacity(stages.len());
953    for (i, stage) in stages.iter().enumerate() {
954        index.insert(stage.id, i);
955    }
956    index
957}
958
959fn check_duplicates<T: HasId>(
960    entities: &[T],
961    entity_type: &'static str,
962    errors: &mut Vec<ValidationError>,
963) {
964    for window in entities.windows(2) {
965        if window[0].entity_id() == window[1].entity_id() {
966            errors.push(ValidationError::DuplicateId {
967                entity_type,
968                id: window[0].entity_id(),
969            });
970        }
971    }
972}
973
974/// Validate all cross-reference fields across entity collections.
975///
976/// Checks every entity field that references another entity by [`EntityId`]
977/// against the appropriate index. All invalid references are appended to
978/// `errors` — no short-circuiting on first error.
979///
980/// This function runs after duplicate checking passes and after indices are
981/// built, but before topology construction.
982#[allow(clippy::too_many_arguments)]
983fn validate_cross_references(
984    lines: &[Line],
985    hydros: &[Hydro],
986    thermals: &[Thermal],
987    pumping_stations: &[PumpingStation],
988    contracts: &[EnergyContract],
989    non_controllable_sources: &[NonControllableSource],
990    bus_index: &HashMap<EntityId, usize>,
991    hydro_index: &HashMap<EntityId, usize>,
992    errors: &mut Vec<ValidationError>,
993) {
994    validate_line_refs(lines, bus_index, errors);
995    validate_hydro_refs(hydros, bus_index, hydro_index, errors);
996    validate_thermal_refs(thermals, bus_index, errors);
997    validate_pumping_station_refs(pumping_stations, bus_index, hydro_index, errors);
998    validate_contract_refs(contracts, bus_index, errors);
999    validate_ncs_refs(non_controllable_sources, bus_index, errors);
1000}
1001
1002fn validate_line_refs(
1003    lines: &[Line],
1004    bus_index: &HashMap<EntityId, usize>,
1005    errors: &mut Vec<ValidationError>,
1006) {
1007    for line in lines {
1008        if !bus_index.contains_key(&line.source_bus_id) {
1009            errors.push(ValidationError::InvalidReference {
1010                source_entity_type: "Line",
1011                source_id: line.id,
1012                field_name: "source_bus_id",
1013                referenced_id: line.source_bus_id,
1014                expected_type: "Bus",
1015            });
1016        }
1017        if !bus_index.contains_key(&line.target_bus_id) {
1018            errors.push(ValidationError::InvalidReference {
1019                source_entity_type: "Line",
1020                source_id: line.id,
1021                field_name: "target_bus_id",
1022                referenced_id: line.target_bus_id,
1023                expected_type: "Bus",
1024            });
1025        }
1026    }
1027}
1028
1029fn validate_hydro_refs(
1030    hydros: &[Hydro],
1031    bus_index: &HashMap<EntityId, usize>,
1032    hydro_index: &HashMap<EntityId, usize>,
1033    errors: &mut Vec<ValidationError>,
1034) {
1035    for hydro in hydros {
1036        if !bus_index.contains_key(&hydro.bus_id) {
1037            errors.push(ValidationError::InvalidReference {
1038                source_entity_type: "Hydro",
1039                source_id: hydro.id,
1040                field_name: "bus_id",
1041                referenced_id: hydro.bus_id,
1042                expected_type: "Bus",
1043            });
1044        }
1045        if let Some(downstream_id) = hydro.downstream_id {
1046            if !hydro_index.contains_key(&downstream_id) {
1047                errors.push(ValidationError::InvalidReference {
1048                    source_entity_type: "Hydro",
1049                    source_id: hydro.id,
1050                    field_name: "downstream_id",
1051                    referenced_id: downstream_id,
1052                    expected_type: "Hydro",
1053                });
1054            }
1055        }
1056        if let Some(ref diversion) = hydro.diversion {
1057            if !hydro_index.contains_key(&diversion.downstream_id) {
1058                errors.push(ValidationError::InvalidReference {
1059                    source_entity_type: "Hydro",
1060                    source_id: hydro.id,
1061                    field_name: "diversion.downstream_id",
1062                    referenced_id: diversion.downstream_id,
1063                    expected_type: "Hydro",
1064                });
1065            }
1066        }
1067    }
1068}
1069
1070fn validate_thermal_refs(
1071    thermals: &[Thermal],
1072    bus_index: &HashMap<EntityId, usize>,
1073    errors: &mut Vec<ValidationError>,
1074) {
1075    for thermal in thermals {
1076        if !bus_index.contains_key(&thermal.bus_id) {
1077            errors.push(ValidationError::InvalidReference {
1078                source_entity_type: "Thermal",
1079                source_id: thermal.id,
1080                field_name: "bus_id",
1081                referenced_id: thermal.bus_id,
1082                expected_type: "Bus",
1083            });
1084        }
1085    }
1086}
1087
1088fn validate_pumping_station_refs(
1089    pumping_stations: &[PumpingStation],
1090    bus_index: &HashMap<EntityId, usize>,
1091    hydro_index: &HashMap<EntityId, usize>,
1092    errors: &mut Vec<ValidationError>,
1093) {
1094    for ps in pumping_stations {
1095        if !bus_index.contains_key(&ps.bus_id) {
1096            errors.push(ValidationError::InvalidReference {
1097                source_entity_type: "PumpingStation",
1098                source_id: ps.id,
1099                field_name: "bus_id",
1100                referenced_id: ps.bus_id,
1101                expected_type: "Bus",
1102            });
1103        }
1104        if !hydro_index.contains_key(&ps.source_hydro_id) {
1105            errors.push(ValidationError::InvalidReference {
1106                source_entity_type: "PumpingStation",
1107                source_id: ps.id,
1108                field_name: "source_hydro_id",
1109                referenced_id: ps.source_hydro_id,
1110                expected_type: "Hydro",
1111            });
1112        }
1113        if !hydro_index.contains_key(&ps.destination_hydro_id) {
1114            errors.push(ValidationError::InvalidReference {
1115                source_entity_type: "PumpingStation",
1116                source_id: ps.id,
1117                field_name: "destination_hydro_id",
1118                referenced_id: ps.destination_hydro_id,
1119                expected_type: "Hydro",
1120            });
1121        }
1122    }
1123}
1124
1125fn validate_contract_refs(
1126    contracts: &[EnergyContract],
1127    bus_index: &HashMap<EntityId, usize>,
1128    errors: &mut Vec<ValidationError>,
1129) {
1130    for contract in contracts {
1131        if !bus_index.contains_key(&contract.bus_id) {
1132            errors.push(ValidationError::InvalidReference {
1133                source_entity_type: "EnergyContract",
1134                source_id: contract.id,
1135                field_name: "bus_id",
1136                referenced_id: contract.bus_id,
1137                expected_type: "Bus",
1138            });
1139        }
1140    }
1141}
1142
1143fn validate_ncs_refs(
1144    non_controllable_sources: &[NonControllableSource],
1145    bus_index: &HashMap<EntityId, usize>,
1146    errors: &mut Vec<ValidationError>,
1147) {
1148    for ncs in non_controllable_sources {
1149        if !bus_index.contains_key(&ncs.bus_id) {
1150            errors.push(ValidationError::InvalidReference {
1151                source_entity_type: "NonControllableSource",
1152                source_id: ncs.id,
1153                field_name: "bus_id",
1154                referenced_id: ncs.bus_id,
1155                expected_type: "Bus",
1156            });
1157        }
1158    }
1159}
1160
1161/// Validate filling configurations for all hydros that have one.
1162///
1163/// For each hydro with `filling: Some(config)`:
1164/// - `filling_inflow_m3s` must be positive (> 0.0).
1165/// - `entry_stage_id` must be set (`Some`), since filling requires a known start stage.
1166///
1167/// All violations are appended to `errors` — no short-circuiting on first error.
1168fn validate_filling_configs(hydros: &[Hydro], errors: &mut Vec<ValidationError>) {
1169    for hydro in hydros {
1170        if let Some(filling) = &hydro.filling {
1171            if filling.filling_inflow_m3s.is_nan() || filling.filling_inflow_m3s <= 0.0 {
1172                errors.push(ValidationError::InvalidFillingConfig {
1173                    hydro_id: hydro.id,
1174                    reason: "filling_inflow_m3s must be positive".to_string(),
1175                });
1176            }
1177            if hydro.entry_stage_id.is_none() {
1178                errors.push(ValidationError::InvalidFillingConfig {
1179                    hydro_id: hydro.id,
1180                    reason: "filling requires entry_stage_id to be set".to_string(),
1181                });
1182            }
1183        }
1184    }
1185}
1186
1187#[cfg(test)]
1188mod tests {
1189    use super::*;
1190    use crate::entities::{ContractType, HydroGenerationModel, HydroPenalties, ThermalCostSegment};
1191
1192    fn make_bus(id: i32) -> Bus {
1193        Bus {
1194            id: EntityId(id),
1195            name: format!("bus-{id}"),
1196            deficit_segments: vec![],
1197            excess_cost: 0.0,
1198        }
1199    }
1200
1201    fn make_line(id: i32, source_bus_id: i32, target_bus_id: i32) -> Line {
1202        crate::Line {
1203            id: EntityId(id),
1204            name: format!("line-{id}"),
1205            source_bus_id: EntityId(source_bus_id),
1206            target_bus_id: EntityId(target_bus_id),
1207            entry_stage_id: None,
1208            exit_stage_id: None,
1209            direct_capacity_mw: 100.0,
1210            reverse_capacity_mw: 100.0,
1211            losses_percent: 0.0,
1212            exchange_cost: 0.0,
1213        }
1214    }
1215
1216    fn make_hydro_on_bus(id: i32, bus_id: i32) -> Hydro {
1217        let zero_penalties = HydroPenalties {
1218            spillage_cost: 0.0,
1219            diversion_cost: 0.0,
1220            fpha_turbined_cost: 0.0,
1221            storage_violation_below_cost: 0.0,
1222            filling_target_violation_cost: 0.0,
1223            turbined_violation_below_cost: 0.0,
1224            outflow_violation_below_cost: 0.0,
1225            outflow_violation_above_cost: 0.0,
1226            generation_violation_below_cost: 0.0,
1227            evaporation_violation_cost: 0.0,
1228            water_withdrawal_violation_cost: 0.0,
1229        };
1230        Hydro {
1231            id: EntityId(id),
1232            name: format!("hydro-{id}"),
1233            bus_id: EntityId(bus_id),
1234            downstream_id: None,
1235            entry_stage_id: None,
1236            exit_stage_id: None,
1237            min_storage_hm3: 0.0,
1238            max_storage_hm3: 1.0,
1239            min_outflow_m3s: 0.0,
1240            max_outflow_m3s: None,
1241            generation_model: HydroGenerationModel::ConstantProductivity {
1242                productivity_mw_per_m3s: 1.0,
1243            },
1244            min_turbined_m3s: 0.0,
1245            max_turbined_m3s: 1.0,
1246            min_generation_mw: 0.0,
1247            max_generation_mw: 1.0,
1248            tailrace: None,
1249            hydraulic_losses: None,
1250            efficiency: None,
1251            evaporation_coefficients_mm: None,
1252            evaporation_reference_volumes_hm3: None,
1253            diversion: None,
1254            filling: None,
1255            penalties: zero_penalties,
1256        }
1257    }
1258
1259    /// Creates a hydro on bus 0. Caller must supply `make_bus(0)`.
1260    fn make_hydro(id: i32) -> Hydro {
1261        make_hydro_on_bus(id, 0)
1262    }
1263
1264    fn make_thermal_on_bus(id: i32, bus_id: i32) -> Thermal {
1265        Thermal {
1266            id: EntityId(id),
1267            name: format!("thermal-{id}"),
1268            bus_id: EntityId(bus_id),
1269            entry_stage_id: None,
1270            exit_stage_id: None,
1271            cost_segments: vec![ThermalCostSegment {
1272                capacity_mw: 100.0,
1273                cost_per_mwh: 50.0,
1274            }],
1275            min_generation_mw: 0.0,
1276            max_generation_mw: 100.0,
1277            gnl_config: None,
1278        }
1279    }
1280
1281    /// Creates a thermal on bus 0. Caller must supply `make_bus(0)`.
1282    fn make_thermal(id: i32) -> Thermal {
1283        make_thermal_on_bus(id, 0)
1284    }
1285
1286    fn make_pumping_station_full(
1287        id: i32,
1288        bus_id: i32,
1289        source_hydro_id: i32,
1290        destination_hydro_id: i32,
1291    ) -> PumpingStation {
1292        PumpingStation {
1293            id: EntityId(id),
1294            name: format!("ps-{id}"),
1295            bus_id: EntityId(bus_id),
1296            source_hydro_id: EntityId(source_hydro_id),
1297            destination_hydro_id: EntityId(destination_hydro_id),
1298            entry_stage_id: None,
1299            exit_stage_id: None,
1300            consumption_mw_per_m3s: 0.5,
1301            min_flow_m3s: 0.0,
1302            max_flow_m3s: 10.0,
1303        }
1304    }
1305
1306    fn make_pumping_station(id: i32) -> PumpingStation {
1307        make_pumping_station_full(id, 0, 0, 1)
1308    }
1309
1310    fn make_contract_on_bus(id: i32, bus_id: i32) -> EnergyContract {
1311        EnergyContract {
1312            id: EntityId(id),
1313            name: format!("contract-{id}"),
1314            bus_id: EntityId(bus_id),
1315            contract_type: ContractType::Import,
1316            entry_stage_id: None,
1317            exit_stage_id: None,
1318            price_per_mwh: 0.0,
1319            min_mw: 0.0,
1320            max_mw: 100.0,
1321        }
1322    }
1323
1324    fn make_contract(id: i32) -> EnergyContract {
1325        make_contract_on_bus(id, 0)
1326    }
1327
1328    fn make_ncs_on_bus(id: i32, bus_id: i32) -> NonControllableSource {
1329        NonControllableSource {
1330            id: EntityId(id),
1331            name: format!("ncs-{id}"),
1332            bus_id: EntityId(bus_id),
1333            entry_stage_id: None,
1334            exit_stage_id: None,
1335            max_generation_mw: 50.0,
1336            curtailment_cost: 0.0,
1337        }
1338    }
1339
1340    fn make_ncs(id: i32) -> NonControllableSource {
1341        make_ncs_on_bus(id, 0)
1342    }
1343
1344    #[test]
1345    fn test_empty_system() {
1346        let system = SystemBuilder::new().build().expect("empty system is valid");
1347        assert_eq!(system.n_buses(), 0);
1348        assert_eq!(system.n_lines(), 0);
1349        assert_eq!(system.n_hydros(), 0);
1350        assert_eq!(system.n_thermals(), 0);
1351        assert_eq!(system.n_pumping_stations(), 0);
1352        assert_eq!(system.n_contracts(), 0);
1353        assert_eq!(system.n_non_controllable_sources(), 0);
1354        assert!(system.buses().is_empty());
1355        assert!(system.cascade().is_empty());
1356    }
1357
1358    #[test]
1359    fn test_canonical_ordering() {
1360        // Provide buses in reverse order: id=2, id=1, id=0
1361        let system = SystemBuilder::new()
1362            .buses(vec![make_bus(2), make_bus(1), make_bus(0)])
1363            .build()
1364            .expect("valid system");
1365
1366        assert_eq!(system.buses()[0].id, EntityId(0));
1367        assert_eq!(system.buses()[1].id, EntityId(1));
1368        assert_eq!(system.buses()[2].id, EntityId(2));
1369    }
1370
1371    #[test]
1372    fn test_lookup_by_id() {
1373        // Hydros reference bus id=0; supply it so cross-reference validation passes.
1374        let system = SystemBuilder::new()
1375            .buses(vec![make_bus(0)])
1376            .hydros(vec![make_hydro(10), make_hydro(5), make_hydro(20)])
1377            .build()
1378            .expect("valid system");
1379
1380        assert_eq!(system.hydro(EntityId(5)).map(|h| h.id), Some(EntityId(5)));
1381        assert_eq!(system.hydro(EntityId(10)).map(|h| h.id), Some(EntityId(10)));
1382        assert_eq!(system.hydro(EntityId(20)).map(|h| h.id), Some(EntityId(20)));
1383    }
1384
1385    #[test]
1386    fn test_lookup_missing_id() {
1387        // Hydros reference bus id=0; supply it so cross-reference validation passes.
1388        let system = SystemBuilder::new()
1389            .buses(vec![make_bus(0)])
1390            .hydros(vec![make_hydro(1), make_hydro(2)])
1391            .build()
1392            .expect("valid system");
1393
1394        assert!(system.hydro(EntityId(999)).is_none());
1395    }
1396
1397    #[test]
1398    fn test_count_queries() {
1399        let system = SystemBuilder::new()
1400            .buses(vec![make_bus(0), make_bus(1)])
1401            .lines(vec![make_line(0, 0, 1)])
1402            .hydros(vec![make_hydro(0), make_hydro(1), make_hydro(2)])
1403            .thermals(vec![make_thermal(0)])
1404            .pumping_stations(vec![make_pumping_station(0)])
1405            .contracts(vec![make_contract(0), make_contract(1)])
1406            .non_controllable_sources(vec![make_ncs(0)])
1407            .build()
1408            .expect("valid system");
1409
1410        assert_eq!(system.n_buses(), 2);
1411        assert_eq!(system.n_lines(), 1);
1412        assert_eq!(system.n_hydros(), 3);
1413        assert_eq!(system.n_thermals(), 1);
1414        assert_eq!(system.n_pumping_stations(), 1);
1415        assert_eq!(system.n_contracts(), 2);
1416        assert_eq!(system.n_non_controllable_sources(), 1);
1417    }
1418
1419    #[test]
1420    fn test_slice_accessors() {
1421        let system = SystemBuilder::new()
1422            .buses(vec![make_bus(0), make_bus(1), make_bus(2)])
1423            .build()
1424            .expect("valid system");
1425
1426        let buses = system.buses();
1427        assert_eq!(buses.len(), 3);
1428        assert_eq!(buses[0].id, EntityId(0));
1429        assert_eq!(buses[1].id, EntityId(1));
1430        assert_eq!(buses[2].id, EntityId(2));
1431    }
1432
1433    #[test]
1434    fn test_duplicate_id_error() {
1435        // Two buses with the same id=0 must yield an Err.
1436        let result = SystemBuilder::new()
1437            .buses(vec![make_bus(0), make_bus(0)])
1438            .build();
1439
1440        assert!(result.is_err());
1441        let errors = result.unwrap_err();
1442        assert!(!errors.is_empty());
1443        assert!(errors.iter().any(|e| matches!(
1444            e,
1445            ValidationError::DuplicateId {
1446                entity_type: "Bus",
1447                id: EntityId(0),
1448            }
1449        )));
1450    }
1451
1452    #[test]
1453    fn test_multiple_duplicate_errors() {
1454        // Duplicates in both buses (id=0) and thermals (id=5) must both be reported.
1455        let result = SystemBuilder::new()
1456            .buses(vec![make_bus(0), make_bus(0)])
1457            .thermals(vec![make_thermal(5), make_thermal(5)])
1458            .build();
1459
1460        assert!(result.is_err());
1461        let errors = result.unwrap_err();
1462
1463        let has_bus_dup = errors.iter().any(|e| {
1464            matches!(
1465                e,
1466                ValidationError::DuplicateId {
1467                    entity_type: "Bus",
1468                    ..
1469                }
1470            )
1471        });
1472        let has_thermal_dup = errors.iter().any(|e| {
1473            matches!(
1474                e,
1475                ValidationError::DuplicateId {
1476                    entity_type: "Thermal",
1477                    ..
1478                }
1479            )
1480        });
1481        assert!(has_bus_dup, "expected Bus duplicate error");
1482        assert!(has_thermal_dup, "expected Thermal duplicate error");
1483    }
1484
1485    #[test]
1486    fn test_send_sync() {
1487        fn require_send_sync<T: Send + Sync>(_: T) {}
1488        let system = SystemBuilder::new().build().expect("valid system");
1489        require_send_sync(system);
1490    }
1491
1492    #[test]
1493    fn test_cascade_accessible() {
1494        // Hydros reference bus id=0; supply it so cross-reference validation passes.
1495        let mut h0 = make_hydro_on_bus(0, 0);
1496        h0.downstream_id = Some(EntityId(1));
1497        let mut h1 = make_hydro_on_bus(1, 0);
1498        h1.downstream_id = Some(EntityId(2));
1499        let h2 = make_hydro_on_bus(2, 0);
1500
1501        let system = SystemBuilder::new()
1502            .buses(vec![make_bus(0)])
1503            .hydros(vec![h0, h1, h2])
1504            .build()
1505            .expect("valid system");
1506
1507        let order = system.cascade().topological_order();
1508        assert!(!order.is_empty(), "topological order must be non-empty");
1509        let pos_0 = order
1510            .iter()
1511            .position(|&id| id == EntityId(0))
1512            .expect("EntityId(0) must be in topological order");
1513        let pos_2 = order
1514            .iter()
1515            .position(|&id| id == EntityId(2))
1516            .expect("EntityId(2) must be in topological order");
1517        assert!(pos_0 < pos_2, "EntityId(0) must precede EntityId(2)");
1518    }
1519
1520    #[test]
1521    fn test_network_accessible() {
1522        let system = SystemBuilder::new()
1523            .buses(vec![make_bus(0), make_bus(1)])
1524            .lines(vec![make_line(0, 0, 1)])
1525            .build()
1526            .expect("valid system");
1527
1528        let connections = system.network().bus_lines(EntityId(0));
1529        assert!(!connections.is_empty(), "bus 0 must have connections");
1530        assert_eq!(connections[0].line_id, EntityId(0));
1531    }
1532
1533    #[test]
1534    fn test_all_entity_lookups() {
1535        // Provide all buses and hydros that the other entities reference.
1536        // - Buses 0 and 1 are needed by all entities (lines, hydros, thermals, etc.)
1537        // - Hydros 0 and 1 are needed by the pumping station (source/destination)
1538        // - Hydro 3 is the entity under test (lookup by id=3), on bus 0
1539        let system = SystemBuilder::new()
1540            .buses(vec![make_bus(0), make_bus(1)])
1541            .lines(vec![make_line(2, 0, 1)])
1542            .hydros(vec![
1543                make_hydro_on_bus(0, 0),
1544                make_hydro_on_bus(1, 0),
1545                make_hydro_on_bus(3, 0),
1546            ])
1547            .thermals(vec![make_thermal(4)])
1548            .pumping_stations(vec![make_pumping_station(5)])
1549            .contracts(vec![make_contract(6)])
1550            .non_controllable_sources(vec![make_ncs(7)])
1551            .build()
1552            .expect("valid system");
1553
1554        assert!(system.bus(EntityId(1)).is_some());
1555        assert!(system.line(EntityId(2)).is_some());
1556        assert!(system.hydro(EntityId(3)).is_some());
1557        assert!(system.thermal(EntityId(4)).is_some());
1558        assert!(system.pumping_station(EntityId(5)).is_some());
1559        assert!(system.contract(EntityId(6)).is_some());
1560        assert!(system.non_controllable_source(EntityId(7)).is_some());
1561
1562        assert!(system.bus(EntityId(999)).is_none());
1563        assert!(system.line(EntityId(999)).is_none());
1564        assert!(system.hydro(EntityId(999)).is_none());
1565        assert!(system.thermal(EntityId(999)).is_none());
1566        assert!(system.pumping_station(EntityId(999)).is_none());
1567        assert!(system.contract(EntityId(999)).is_none());
1568        assert!(system.non_controllable_source(EntityId(999)).is_none());
1569    }
1570
1571    #[test]
1572    fn test_default_builder() {
1573        let system = SystemBuilder::default()
1574            .build()
1575            .expect("default builder produces valid empty system");
1576        assert_eq!(system.n_buses(), 0);
1577    }
1578
1579    // ---- Cross-reference validation tests -----------------------------------
1580
1581    #[test]
1582    fn test_invalid_bus_reference_hydro() {
1583        // Hydro references bus id=99 which does not exist.
1584        let hydro = make_hydro_on_bus(1, 99);
1585        let result = SystemBuilder::new().hydros(vec![hydro]).build();
1586
1587        assert!(result.is_err(), "expected Err for missing bus reference");
1588        let errors = result.unwrap_err();
1589        assert!(
1590            errors.iter().any(|e| matches!(
1591                e,
1592                ValidationError::InvalidReference {
1593                    source_entity_type: "Hydro",
1594                    source_id: EntityId(1),
1595                    field_name: "bus_id",
1596                    referenced_id: EntityId(99),
1597                    expected_type: "Bus",
1598                }
1599            )),
1600            "expected InvalidReference for Hydro bus_id=99, got: {errors:?}"
1601        );
1602    }
1603
1604    #[test]
1605    fn test_invalid_downstream_reference() {
1606        // Hydro references downstream hydro id=50 which does not exist.
1607        let bus = make_bus(0);
1608        let mut hydro = make_hydro(1);
1609        hydro.downstream_id = Some(EntityId(50));
1610
1611        let result = SystemBuilder::new()
1612            .buses(vec![bus])
1613            .hydros(vec![hydro])
1614            .build();
1615
1616        assert!(
1617            result.is_err(),
1618            "expected Err for missing downstream reference"
1619        );
1620        let errors = result.unwrap_err();
1621        assert!(
1622            errors.iter().any(|e| matches!(
1623                e,
1624                ValidationError::InvalidReference {
1625                    source_entity_type: "Hydro",
1626                    source_id: EntityId(1),
1627                    field_name: "downstream_id",
1628                    referenced_id: EntityId(50),
1629                    expected_type: "Hydro",
1630                }
1631            )),
1632            "expected InvalidReference for Hydro downstream_id=50, got: {errors:?}"
1633        );
1634    }
1635
1636    #[test]
1637    fn test_invalid_pumping_station_hydro_refs() {
1638        // Pumping station references source hydro id=77 which does not exist.
1639        let bus = make_bus(0);
1640        let dest_hydro = make_hydro(1);
1641        let ps = make_pumping_station_full(10, 0, 77, 1);
1642
1643        let result = SystemBuilder::new()
1644            .buses(vec![bus])
1645            .hydros(vec![dest_hydro])
1646            .pumping_stations(vec![ps])
1647            .build();
1648
1649        assert!(
1650            result.is_err(),
1651            "expected Err for missing source_hydro_id reference"
1652        );
1653        let errors = result.unwrap_err();
1654        assert!(
1655            errors.iter().any(|e| matches!(
1656                e,
1657                ValidationError::InvalidReference {
1658                    source_entity_type: "PumpingStation",
1659                    source_id: EntityId(10),
1660                    field_name: "source_hydro_id",
1661                    referenced_id: EntityId(77),
1662                    expected_type: "Hydro",
1663                }
1664            )),
1665            "expected InvalidReference for PumpingStation source_hydro_id=77, got: {errors:?}"
1666        );
1667    }
1668
1669    #[test]
1670    fn test_multiple_invalid_references_collected() {
1671        // A line with bad source_bus_id AND a thermal with bad bus_id.
1672        // Both errors must be reported (no short-circuiting).
1673        let line = make_line(1, 99, 0);
1674        let thermal = make_thermal_on_bus(2, 88);
1675
1676        let result = SystemBuilder::new()
1677            .buses(vec![make_bus(0)])
1678            .lines(vec![line])
1679            .thermals(vec![thermal])
1680            .build();
1681
1682        assert!(
1683            result.is_err(),
1684            "expected Err for multiple invalid references"
1685        );
1686        let errors = result.unwrap_err();
1687
1688        let has_line_error = errors.iter().any(|e| {
1689            matches!(
1690                e,
1691                ValidationError::InvalidReference {
1692                    source_entity_type: "Line",
1693                    field_name: "source_bus_id",
1694                    referenced_id: EntityId(99),
1695                    ..
1696                }
1697            )
1698        });
1699        let has_thermal_error = errors.iter().any(|e| {
1700            matches!(
1701                e,
1702                ValidationError::InvalidReference {
1703                    source_entity_type: "Thermal",
1704                    field_name: "bus_id",
1705                    referenced_id: EntityId(88),
1706                    ..
1707                }
1708            )
1709        });
1710
1711        assert!(
1712            has_line_error,
1713            "expected Line source_bus_id=99 error, got: {errors:?}"
1714        );
1715        assert!(
1716            has_thermal_error,
1717            "expected Thermal bus_id=88 error, got: {errors:?}"
1718        );
1719        assert!(
1720            errors.len() >= 2,
1721            "expected at least 2 errors, got {}: {errors:?}",
1722            errors.len()
1723        );
1724    }
1725
1726    #[test]
1727    fn test_valid_cross_references_pass() {
1728        // All cross-references point to entities that exist — build must succeed.
1729        let bus_0 = make_bus(0);
1730        let bus_1 = make_bus(1);
1731        let h0 = make_hydro_on_bus(0, 0);
1732        let h1 = make_hydro_on_bus(1, 1);
1733        let mut h2 = make_hydro_on_bus(2, 0);
1734        h2.downstream_id = Some(EntityId(1));
1735        let line = make_line(10, 0, 1);
1736        let thermal = make_thermal_on_bus(20, 0);
1737        let ps = make_pumping_station_full(30, 0, 0, 1);
1738        let contract = make_contract_on_bus(40, 1);
1739        let ncs = make_ncs_on_bus(50, 0);
1740
1741        let result = SystemBuilder::new()
1742            .buses(vec![bus_0, bus_1])
1743            .lines(vec![line])
1744            .hydros(vec![h0, h1, h2])
1745            .thermals(vec![thermal])
1746            .pumping_stations(vec![ps])
1747            .contracts(vec![contract])
1748            .non_controllable_sources(vec![ncs])
1749            .build();
1750
1751        assert!(
1752            result.is_ok(),
1753            "expected Ok for all valid cross-references, got: {:?}",
1754            result.unwrap_err()
1755        );
1756        let system = result.unwrap_or_else(|_| unreachable!());
1757        assert_eq!(system.n_buses(), 2);
1758        assert_eq!(system.n_hydros(), 3);
1759        assert_eq!(system.n_lines(), 1);
1760        assert_eq!(system.n_thermals(), 1);
1761        assert_eq!(system.n_pumping_stations(), 1);
1762        assert_eq!(system.n_contracts(), 1);
1763        assert_eq!(system.n_non_controllable_sources(), 1);
1764    }
1765
1766    // ---- Cascade cycle detection tests --------------------------------------
1767
1768    #[test]
1769    fn test_cascade_cycle_detected() {
1770        // Three-node cycle: A(0)->B(1)->C(2)->A(0).
1771        // All three reference a common bus (bus 0).
1772        let bus = make_bus(0);
1773        let mut h0 = make_hydro(0);
1774        h0.downstream_id = Some(EntityId(1));
1775        let mut h1 = make_hydro(1);
1776        h1.downstream_id = Some(EntityId(2));
1777        let mut h2 = make_hydro(2);
1778        h2.downstream_id = Some(EntityId(0));
1779
1780        let result = SystemBuilder::new()
1781            .buses(vec![bus])
1782            .hydros(vec![h0, h1, h2])
1783            .build();
1784
1785        assert!(result.is_err(), "expected Err for 3-node cycle");
1786        let errors = result.unwrap_err();
1787        let cycle_error = errors
1788            .iter()
1789            .find(|e| matches!(e, ValidationError::CascadeCycle { .. }));
1790        assert!(
1791            cycle_error.is_some(),
1792            "expected CascadeCycle error, got: {errors:?}"
1793        );
1794        let ValidationError::CascadeCycle { cycle_ids } = cycle_error.unwrap() else {
1795            unreachable!()
1796        };
1797        assert_eq!(
1798            cycle_ids,
1799            &[EntityId(0), EntityId(1), EntityId(2)],
1800            "cycle_ids must be sorted ascending, got: {cycle_ids:?}"
1801        );
1802    }
1803
1804    #[test]
1805    fn test_cascade_self_loop_detected() {
1806        // Single hydro pointing to itself: A(0)->A(0).
1807        let bus = make_bus(0);
1808        let mut h0 = make_hydro(0);
1809        h0.downstream_id = Some(EntityId(0));
1810
1811        let result = SystemBuilder::new()
1812            .buses(vec![bus])
1813            .hydros(vec![h0])
1814            .build();
1815
1816        assert!(result.is_err(), "expected Err for self-loop");
1817        let errors = result.unwrap_err();
1818        let has_cycle = errors
1819            .iter()
1820            .any(|e| matches!(e, ValidationError::CascadeCycle { cycle_ids } if cycle_ids.contains(&EntityId(0))));
1821        assert!(
1822            has_cycle,
1823            "expected CascadeCycle containing EntityId(0), got: {errors:?}"
1824        );
1825    }
1826
1827    #[test]
1828    fn test_valid_acyclic_cascade_passes() {
1829        // Linear acyclic cascade A(0)->B(1)->C(2).
1830        // Verifies that a valid cascade produces Ok with correct topological_order length.
1831        let bus = make_bus(0);
1832        let mut h0 = make_hydro(0);
1833        h0.downstream_id = Some(EntityId(1));
1834        let mut h1 = make_hydro(1);
1835        h1.downstream_id = Some(EntityId(2));
1836        let h2 = make_hydro(2);
1837
1838        let result = SystemBuilder::new()
1839            .buses(vec![bus])
1840            .hydros(vec![h0, h1, h2])
1841            .build();
1842
1843        assert!(
1844            result.is_ok(),
1845            "expected Ok for acyclic cascade, got: {:?}",
1846            result.unwrap_err()
1847        );
1848        let system = result.unwrap_or_else(|_| unreachable!());
1849        assert_eq!(
1850            system.cascade().topological_order().len(),
1851            system.n_hydros(),
1852            "topological_order must contain all hydros"
1853        );
1854    }
1855
1856    // ---- Filling config validation tests ------------------------------------
1857
1858    #[test]
1859    fn test_filling_without_entry_stage() {
1860        // Filling config present but entry_stage_id is None.
1861        use crate::entities::FillingConfig;
1862        let bus = make_bus(0);
1863        let mut hydro = make_hydro(1);
1864        hydro.entry_stage_id = None;
1865        hydro.filling = Some(FillingConfig {
1866            start_stage_id: 10,
1867            filling_inflow_m3s: 100.0,
1868        });
1869
1870        let result = SystemBuilder::new()
1871            .buses(vec![bus])
1872            .hydros(vec![hydro])
1873            .build();
1874
1875        assert!(
1876            result.is_err(),
1877            "expected Err for filling without entry_stage_id"
1878        );
1879        let errors = result.unwrap_err();
1880        let has_error = errors.iter().any(|e| match e {
1881            ValidationError::InvalidFillingConfig { hydro_id, reason } => {
1882                *hydro_id == EntityId(1) && reason.contains("entry_stage_id")
1883            }
1884            _ => false,
1885        });
1886        assert!(
1887            has_error,
1888            "expected InvalidFillingConfig with entry_stage_id reason, got: {errors:?}"
1889        );
1890    }
1891
1892    #[test]
1893    fn test_filling_negative_inflow() {
1894        // Filling config with filling_inflow_m3s <= 0.0.
1895        use crate::entities::FillingConfig;
1896        let bus = make_bus(0);
1897        let mut hydro = make_hydro(1);
1898        hydro.entry_stage_id = Some(10);
1899        hydro.filling = Some(FillingConfig {
1900            start_stage_id: 10,
1901            filling_inflow_m3s: -5.0,
1902        });
1903
1904        let result = SystemBuilder::new()
1905            .buses(vec![bus])
1906            .hydros(vec![hydro])
1907            .build();
1908
1909        assert!(
1910            result.is_err(),
1911            "expected Err for negative filling_inflow_m3s"
1912        );
1913        let errors = result.unwrap_err();
1914        let has_error = errors.iter().any(|e| match e {
1915            ValidationError::InvalidFillingConfig { hydro_id, reason } => {
1916                *hydro_id == EntityId(1) && reason.contains("filling_inflow_m3s must be positive")
1917            }
1918            _ => false,
1919        });
1920        assert!(
1921            has_error,
1922            "expected InvalidFillingConfig with positive inflow reason, got: {errors:?}"
1923        );
1924    }
1925
1926    #[test]
1927    fn test_valid_filling_config_passes() {
1928        // Valid filling config: entry_stage_id set and filling_inflow_m3s positive.
1929        use crate::entities::FillingConfig;
1930        let bus = make_bus(0);
1931        let mut hydro = make_hydro(1);
1932        hydro.entry_stage_id = Some(10);
1933        hydro.filling = Some(FillingConfig {
1934            start_stage_id: 10,
1935            filling_inflow_m3s: 100.0,
1936        });
1937
1938        let result = SystemBuilder::new()
1939            .buses(vec![bus])
1940            .hydros(vec![hydro])
1941            .build();
1942
1943        assert!(
1944            result.is_ok(),
1945            "expected Ok for valid filling config, got: {:?}",
1946            result.unwrap_err()
1947        );
1948    }
1949
1950    #[test]
1951    fn test_cascade_cycle_and_invalid_filling_both_reported() {
1952        // Both a cascade cycle (A->A self-loop) AND an invalid filling config
1953        // must produce both error variants.
1954        use crate::entities::FillingConfig;
1955        let bus = make_bus(0);
1956
1957        // Hydro 0: self-loop (cycle)
1958        let mut h0 = make_hydro(0);
1959        h0.downstream_id = Some(EntityId(0));
1960
1961        // Hydro 1: valid cycle participant? No -- use a separate hydro with invalid filling.
1962        let mut h1 = make_hydro(1);
1963        h1.entry_stage_id = None; // no entry_stage_id
1964        h1.filling = Some(FillingConfig {
1965            start_stage_id: 5,
1966            filling_inflow_m3s: 50.0,
1967        });
1968
1969        let result = SystemBuilder::new()
1970            .buses(vec![bus])
1971            .hydros(vec![h0, h1])
1972            .build();
1973
1974        assert!(result.is_err(), "expected Err for cycle + invalid filling");
1975        let errors = result.unwrap_err();
1976        let has_cycle = errors
1977            .iter()
1978            .any(|e| matches!(e, ValidationError::CascadeCycle { .. }));
1979        let has_filling = errors
1980            .iter()
1981            .any(|e| matches!(e, ValidationError::InvalidFillingConfig { .. }));
1982        assert!(has_cycle, "expected CascadeCycle error, got: {errors:?}");
1983        assert!(
1984            has_filling,
1985            "expected InvalidFillingConfig error, got: {errors:?}"
1986        );
1987    }
1988
1989    #[cfg(feature = "serde")]
1990    #[test]
1991    fn test_system_serde_roundtrip() {
1992        // Build a system with a bus, a hydro, a line, and a thermal.
1993        let bus_a = make_bus(1);
1994        let bus_b = make_bus(2);
1995        let hydro = make_hydro_on_bus(10, 1);
1996        let thermal = make_thermal_on_bus(20, 2);
1997        let line = make_line(1, 1, 2);
1998
1999        let system = SystemBuilder::new()
2000            .buses(vec![bus_a, bus_b])
2001            .hydros(vec![hydro])
2002            .thermals(vec![thermal])
2003            .lines(vec![line])
2004            .build()
2005            .expect("valid system");
2006
2007        let json = serde_json::to_string(&system).unwrap();
2008
2009        // Deserialize and rebuild indices.
2010        let mut deserialized: System = serde_json::from_str(&json).unwrap();
2011        deserialized.rebuild_indices();
2012
2013        // Entity collections must match.
2014        assert_eq!(system.buses(), deserialized.buses());
2015        assert_eq!(system.hydros(), deserialized.hydros());
2016        assert_eq!(system.thermals(), deserialized.thermals());
2017        assert_eq!(system.lines(), deserialized.lines());
2018
2019        // O(1) lookup must work after index rebuild.
2020        assert_eq!(
2021            deserialized.bus(EntityId(1)).map(|b| b.id),
2022            Some(EntityId(1))
2023        );
2024        assert_eq!(
2025            deserialized.hydro(EntityId(10)).map(|h| h.id),
2026            Some(EntityId(10))
2027        );
2028        assert_eq!(
2029            deserialized.thermal(EntityId(20)).map(|t| t.id),
2030            Some(EntityId(20))
2031        );
2032        assert_eq!(
2033            deserialized.line(EntityId(1)).map(|l| l.id),
2034            Some(EntityId(1))
2035        );
2036    }
2037
2038    // ---- Extended System tests ----------------------------------------------
2039
2040    fn make_stage(id: i32) -> Stage {
2041        use crate::temporal::{
2042            Block, BlockMode, NoiseMethod, ScenarioSourceConfig, StageRiskConfig, StageStateConfig,
2043        };
2044        use chrono::NaiveDate;
2045        Stage {
2046            index: usize::try_from(id.max(0)).unwrap_or(0),
2047            id,
2048            start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
2049            end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
2050            season_id: Some(0),
2051            blocks: vec![Block {
2052                index: 0,
2053                name: "SINGLE".to_string(),
2054                duration_hours: 744.0,
2055            }],
2056            block_mode: BlockMode::Parallel,
2057            state_config: StageStateConfig {
2058                storage: true,
2059                inflow_lags: false,
2060            },
2061            risk_config: StageRiskConfig::Expectation,
2062            scenario_config: ScenarioSourceConfig {
2063                branching_factor: 50,
2064                noise_method: NoiseMethod::Saa,
2065            },
2066        }
2067    }
2068
2069    /// Verify that `SystemBuilder::new().build()` still works correctly.
2070    /// New fields must default to empty/default values.
2071    #[test]
2072    fn test_system_backward_compat() {
2073        let system = SystemBuilder::new().build().expect("empty system is valid");
2074        // Entity counts unchanged
2075        assert_eq!(system.n_buses(), 0);
2076        assert_eq!(system.n_hydros(), 0);
2077        // New fields default to empty
2078        assert_eq!(system.n_stages(), 0);
2079        assert!(system.stages().is_empty());
2080        assert!(system.initial_conditions().storage.is_empty());
2081        assert!(system.generic_constraints().is_empty());
2082        assert!(system.inflow_models().is_empty());
2083        assert!(system.load_models().is_empty());
2084        assert_eq!(system.penalties().n_stages(), 0);
2085        assert_eq!(system.bounds().n_stages(), 0);
2086        // Generic constraint bounds default to empty.
2087        assert!(!system.resolved_generic_bounds().is_active(0, 0));
2088        assert!(
2089            system
2090                .resolved_generic_bounds()
2091                .bounds_for_stage(0, 0)
2092                .is_empty()
2093        );
2094    }
2095
2096    /// Verify `System::resolved_generic_bounds()` accessor with a non-empty table.
2097    #[test]
2098    fn test_system_resolved_generic_bounds_accessor() {
2099        use crate::resolved::ResolvedGenericConstraintBounds;
2100        use std::collections::HashMap as StdHashMap;
2101
2102        let id_map: StdHashMap<i32, usize> = [(0, 0), (1, 1)].into_iter().collect();
2103        let rows = vec![(0i32, 0i32, None::<i32>, 100.0f64)];
2104        let table = ResolvedGenericConstraintBounds::new(&id_map, rows.into_iter());
2105
2106        let system = SystemBuilder::new()
2107            .resolved_generic_bounds(table)
2108            .build()
2109            .expect("valid system");
2110
2111        assert!(system.resolved_generic_bounds().is_active(0, 0));
2112        assert!(!system.resolved_generic_bounds().is_active(1, 0));
2113        let slice = system.resolved_generic_bounds().bounds_for_stage(0, 0);
2114        assert_eq!(slice.len(), 1);
2115        assert_eq!(slice[0], (None, 100.0));
2116    }
2117
2118    /// Build a System with 2 stages and verify `n_stages()` and `stage(id)` lookup.
2119    #[test]
2120    fn test_system_with_stages() {
2121        let s0 = make_stage(0);
2122        let s1 = make_stage(1);
2123
2124        let system = SystemBuilder::new()
2125            .stages(vec![s1.clone(), s0.clone()]) // supply in reverse order
2126            .build()
2127            .expect("valid system");
2128
2129        // Canonical ordering: id=0 comes before id=1
2130        assert_eq!(system.n_stages(), 2);
2131        assert_eq!(system.stages()[0].id, 0);
2132        assert_eq!(system.stages()[1].id, 1);
2133
2134        // O(1) lookup by stage id
2135        let found = system.stage(0).expect("stage 0 must be found");
2136        assert_eq!(found.id, s0.id);
2137
2138        let found1 = system.stage(1).expect("stage 1 must be found");
2139        assert_eq!(found1.id, s1.id);
2140
2141        // Missing stage returns None
2142        assert!(system.stage(99).is_none());
2143    }
2144
2145    /// Build a System with 3 stages having IDs 0, 1, 2 and verify `stage()` lookups.
2146    #[test]
2147    fn test_system_stage_lookup_by_id() {
2148        let stages: Vec<Stage> = [0i32, 1, 2].iter().map(|&id| make_stage(id)).collect();
2149
2150        let system = SystemBuilder::new()
2151            .stages(stages)
2152            .build()
2153            .expect("valid system");
2154
2155        assert_eq!(system.stage(1).map(|s| s.id), Some(1));
2156        assert!(system.stage(99).is_none());
2157    }
2158
2159    /// Build a System with `InitialConditions` containing 1 storage entry and verify accessor.
2160    #[test]
2161    fn test_system_with_initial_conditions() {
2162        let ic = InitialConditions {
2163            storage: vec![crate::HydroStorage {
2164                hydro_id: EntityId(0),
2165                value_hm3: 15_000.0,
2166            }],
2167            filling_storage: vec![],
2168            past_inflows: vec![],
2169        };
2170
2171        let system = SystemBuilder::new()
2172            .initial_conditions(ic)
2173            .build()
2174            .expect("valid system");
2175
2176        assert_eq!(system.initial_conditions().storage.len(), 1);
2177        assert_eq!(system.initial_conditions().storage[0].hydro_id, EntityId(0));
2178        assert!((system.initial_conditions().storage[0].value_hm3 - 15_000.0).abs() < f64::EPSILON);
2179    }
2180
2181    /// Verify serde round-trip of a System with stages and `policy_graph`,
2182    /// including that `stage_index` is correctly rebuilt after deserialization.
2183    #[cfg(feature = "serde")]
2184    #[test]
2185    fn test_system_serde_roundtrip_with_stages() {
2186        use crate::temporal::PolicyGraphType;
2187
2188        let stages = vec![make_stage(0), make_stage(1)];
2189        let policy_graph = PolicyGraph {
2190            graph_type: PolicyGraphType::FiniteHorizon,
2191            annual_discount_rate: 0.0,
2192            transitions: vec![],
2193            season_map: None,
2194        };
2195
2196        let system = SystemBuilder::new()
2197            .stages(stages)
2198            .policy_graph(policy_graph)
2199            .build()
2200            .expect("valid system");
2201
2202        let json = serde_json::to_string(&system).unwrap();
2203        let mut deserialized: System = serde_json::from_str(&json).unwrap();
2204
2205        // stage_index is skipped during serde; rebuild before querying
2206        deserialized.rebuild_indices();
2207
2208        // Collections must match after round-trip
2209        assert_eq!(system.n_stages(), deserialized.n_stages());
2210        assert_eq!(system.stages()[0].id, deserialized.stages()[0].id);
2211        assert_eq!(system.stages()[1].id, deserialized.stages()[1].id);
2212
2213        // O(1) lookup must work after index rebuild
2214        assert_eq!(deserialized.stage(0).map(|s| s.id), Some(0));
2215        assert_eq!(deserialized.stage(1).map(|s| s.id), Some(1));
2216        assert!(deserialized.stage(99).is_none());
2217
2218        // policy_graph fields must round-trip
2219        assert_eq!(
2220            deserialized.policy_graph().graph_type,
2221            system.policy_graph().graph_type
2222        );
2223    }
2224}