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            water_withdrawal_violation_pos_cost: 0.0,
1236            water_withdrawal_violation_neg_cost: 0.0,
1237            evaporation_violation_pos_cost: 0.0,
1238            evaporation_violation_neg_cost: 0.0,
1239            inflow_nonnegativity_cost: 1000.0,
1240        };
1241        Hydro {
1242            id: EntityId(id),
1243            name: format!("hydro-{id}"),
1244            bus_id: EntityId(bus_id),
1245            downstream_id: None,
1246            entry_stage_id: None,
1247            exit_stage_id: None,
1248            min_storage_hm3: 0.0,
1249            max_storage_hm3: 1.0,
1250            min_outflow_m3s: 0.0,
1251            max_outflow_m3s: None,
1252            generation_model: HydroGenerationModel::ConstantProductivity {
1253                productivity_mw_per_m3s: 1.0,
1254            },
1255            min_turbined_m3s: 0.0,
1256            max_turbined_m3s: 1.0,
1257            min_generation_mw: 0.0,
1258            max_generation_mw: 1.0,
1259            tailrace: None,
1260            hydraulic_losses: None,
1261            efficiency: None,
1262            evaporation_coefficients_mm: None,
1263            evaporation_reference_volumes_hm3: None,
1264            diversion: None,
1265            filling: None,
1266            penalties: zero_penalties,
1267        }
1268    }
1269
1270    /// Creates a hydro on bus 0. Caller must supply `make_bus(0)`.
1271    fn make_hydro(id: i32) -> Hydro {
1272        make_hydro_on_bus(id, 0)
1273    }
1274
1275    fn make_thermal_on_bus(id: i32, bus_id: i32) -> Thermal {
1276        Thermal {
1277            id: EntityId(id),
1278            name: format!("thermal-{id}"),
1279            bus_id: EntityId(bus_id),
1280            entry_stage_id: None,
1281            exit_stage_id: None,
1282            cost_segments: vec![ThermalCostSegment {
1283                capacity_mw: 100.0,
1284                cost_per_mwh: 50.0,
1285            }],
1286            min_generation_mw: 0.0,
1287            max_generation_mw: 100.0,
1288            gnl_config: None,
1289        }
1290    }
1291
1292    /// Creates a thermal on bus 0. Caller must supply `make_bus(0)`.
1293    fn make_thermal(id: i32) -> Thermal {
1294        make_thermal_on_bus(id, 0)
1295    }
1296
1297    fn make_pumping_station_full(
1298        id: i32,
1299        bus_id: i32,
1300        source_hydro_id: i32,
1301        destination_hydro_id: i32,
1302    ) -> PumpingStation {
1303        PumpingStation {
1304            id: EntityId(id),
1305            name: format!("ps-{id}"),
1306            bus_id: EntityId(bus_id),
1307            source_hydro_id: EntityId(source_hydro_id),
1308            destination_hydro_id: EntityId(destination_hydro_id),
1309            entry_stage_id: None,
1310            exit_stage_id: None,
1311            consumption_mw_per_m3s: 0.5,
1312            min_flow_m3s: 0.0,
1313            max_flow_m3s: 10.0,
1314        }
1315    }
1316
1317    fn make_pumping_station(id: i32) -> PumpingStation {
1318        make_pumping_station_full(id, 0, 0, 1)
1319    }
1320
1321    fn make_contract_on_bus(id: i32, bus_id: i32) -> EnergyContract {
1322        EnergyContract {
1323            id: EntityId(id),
1324            name: format!("contract-{id}"),
1325            bus_id: EntityId(bus_id),
1326            contract_type: ContractType::Import,
1327            entry_stage_id: None,
1328            exit_stage_id: None,
1329            price_per_mwh: 0.0,
1330            min_mw: 0.0,
1331            max_mw: 100.0,
1332        }
1333    }
1334
1335    fn make_contract(id: i32) -> EnergyContract {
1336        make_contract_on_bus(id, 0)
1337    }
1338
1339    fn make_ncs_on_bus(id: i32, bus_id: i32) -> NonControllableSource {
1340        NonControllableSource {
1341            id: EntityId(id),
1342            name: format!("ncs-{id}"),
1343            bus_id: EntityId(bus_id),
1344            entry_stage_id: None,
1345            exit_stage_id: None,
1346            max_generation_mw: 50.0,
1347            curtailment_cost: 0.0,
1348        }
1349    }
1350
1351    fn make_ncs(id: i32) -> NonControllableSource {
1352        make_ncs_on_bus(id, 0)
1353    }
1354
1355    #[test]
1356    fn test_empty_system() {
1357        let system = SystemBuilder::new().build().expect("empty system is valid");
1358        assert_eq!(system.n_buses(), 0);
1359        assert_eq!(system.n_lines(), 0);
1360        assert_eq!(system.n_hydros(), 0);
1361        assert_eq!(system.n_thermals(), 0);
1362        assert_eq!(system.n_pumping_stations(), 0);
1363        assert_eq!(system.n_contracts(), 0);
1364        assert_eq!(system.n_non_controllable_sources(), 0);
1365        assert!(system.buses().is_empty());
1366        assert!(system.cascade().is_empty());
1367    }
1368
1369    #[test]
1370    fn test_canonical_ordering() {
1371        // Provide buses in reverse order: id=2, id=1, id=0
1372        let system = SystemBuilder::new()
1373            .buses(vec![make_bus(2), make_bus(1), make_bus(0)])
1374            .build()
1375            .expect("valid system");
1376
1377        assert_eq!(system.buses()[0].id, EntityId(0));
1378        assert_eq!(system.buses()[1].id, EntityId(1));
1379        assert_eq!(system.buses()[2].id, EntityId(2));
1380    }
1381
1382    #[test]
1383    fn test_lookup_by_id() {
1384        // Hydros reference bus id=0; supply it so cross-reference validation passes.
1385        let system = SystemBuilder::new()
1386            .buses(vec![make_bus(0)])
1387            .hydros(vec![make_hydro(10), make_hydro(5), make_hydro(20)])
1388            .build()
1389            .expect("valid system");
1390
1391        assert_eq!(system.hydro(EntityId(5)).map(|h| h.id), Some(EntityId(5)));
1392        assert_eq!(system.hydro(EntityId(10)).map(|h| h.id), Some(EntityId(10)));
1393        assert_eq!(system.hydro(EntityId(20)).map(|h| h.id), Some(EntityId(20)));
1394    }
1395
1396    #[test]
1397    fn test_lookup_missing_id() {
1398        // Hydros reference bus id=0; supply it so cross-reference validation passes.
1399        let system = SystemBuilder::new()
1400            .buses(vec![make_bus(0)])
1401            .hydros(vec![make_hydro(1), make_hydro(2)])
1402            .build()
1403            .expect("valid system");
1404
1405        assert!(system.hydro(EntityId(999)).is_none());
1406    }
1407
1408    #[test]
1409    fn test_count_queries() {
1410        let system = SystemBuilder::new()
1411            .buses(vec![make_bus(0), make_bus(1)])
1412            .lines(vec![make_line(0, 0, 1)])
1413            .hydros(vec![make_hydro(0), make_hydro(1), make_hydro(2)])
1414            .thermals(vec![make_thermal(0)])
1415            .pumping_stations(vec![make_pumping_station(0)])
1416            .contracts(vec![make_contract(0), make_contract(1)])
1417            .non_controllable_sources(vec![make_ncs(0)])
1418            .build()
1419            .expect("valid system");
1420
1421        assert_eq!(system.n_buses(), 2);
1422        assert_eq!(system.n_lines(), 1);
1423        assert_eq!(system.n_hydros(), 3);
1424        assert_eq!(system.n_thermals(), 1);
1425        assert_eq!(system.n_pumping_stations(), 1);
1426        assert_eq!(system.n_contracts(), 2);
1427        assert_eq!(system.n_non_controllable_sources(), 1);
1428    }
1429
1430    #[test]
1431    fn test_slice_accessors() {
1432        let system = SystemBuilder::new()
1433            .buses(vec![make_bus(0), make_bus(1), make_bus(2)])
1434            .build()
1435            .expect("valid system");
1436
1437        let buses = system.buses();
1438        assert_eq!(buses.len(), 3);
1439        assert_eq!(buses[0].id, EntityId(0));
1440        assert_eq!(buses[1].id, EntityId(1));
1441        assert_eq!(buses[2].id, EntityId(2));
1442    }
1443
1444    #[test]
1445    fn test_duplicate_id_error() {
1446        // Two buses with the same id=0 must yield an Err.
1447        let result = SystemBuilder::new()
1448            .buses(vec![make_bus(0), make_bus(0)])
1449            .build();
1450
1451        assert!(result.is_err());
1452        let errors = result.unwrap_err();
1453        assert!(!errors.is_empty());
1454        assert!(errors.iter().any(|e| matches!(
1455            e,
1456            ValidationError::DuplicateId {
1457                entity_type: "Bus",
1458                id: EntityId(0),
1459            }
1460        )));
1461    }
1462
1463    #[test]
1464    fn test_multiple_duplicate_errors() {
1465        // Duplicates in both buses (id=0) and thermals (id=5) must both be reported.
1466        let result = SystemBuilder::new()
1467            .buses(vec![make_bus(0), make_bus(0)])
1468            .thermals(vec![make_thermal(5), make_thermal(5)])
1469            .build();
1470
1471        assert!(result.is_err());
1472        let errors = result.unwrap_err();
1473
1474        let has_bus_dup = errors.iter().any(|e| {
1475            matches!(
1476                e,
1477                ValidationError::DuplicateId {
1478                    entity_type: "Bus",
1479                    ..
1480                }
1481            )
1482        });
1483        let has_thermal_dup = errors.iter().any(|e| {
1484            matches!(
1485                e,
1486                ValidationError::DuplicateId {
1487                    entity_type: "Thermal",
1488                    ..
1489                }
1490            )
1491        });
1492        assert!(has_bus_dup, "expected Bus duplicate error");
1493        assert!(has_thermal_dup, "expected Thermal duplicate error");
1494    }
1495
1496    #[test]
1497    fn test_send_sync() {
1498        fn require_send_sync<T: Send + Sync>(_: T) {}
1499        let system = SystemBuilder::new().build().expect("valid system");
1500        require_send_sync(system);
1501    }
1502
1503    #[test]
1504    fn test_cascade_accessible() {
1505        // Hydros reference bus id=0; supply it so cross-reference validation passes.
1506        let mut h0 = make_hydro_on_bus(0, 0);
1507        h0.downstream_id = Some(EntityId(1));
1508        let mut h1 = make_hydro_on_bus(1, 0);
1509        h1.downstream_id = Some(EntityId(2));
1510        let h2 = make_hydro_on_bus(2, 0);
1511
1512        let system = SystemBuilder::new()
1513            .buses(vec![make_bus(0)])
1514            .hydros(vec![h0, h1, h2])
1515            .build()
1516            .expect("valid system");
1517
1518        let order = system.cascade().topological_order();
1519        assert!(!order.is_empty(), "topological order must be non-empty");
1520        let pos_0 = order
1521            .iter()
1522            .position(|&id| id == EntityId(0))
1523            .expect("EntityId(0) must be in topological order");
1524        let pos_2 = order
1525            .iter()
1526            .position(|&id| id == EntityId(2))
1527            .expect("EntityId(2) must be in topological order");
1528        assert!(pos_0 < pos_2, "EntityId(0) must precede EntityId(2)");
1529    }
1530
1531    #[test]
1532    fn test_network_accessible() {
1533        let system = SystemBuilder::new()
1534            .buses(vec![make_bus(0), make_bus(1)])
1535            .lines(vec![make_line(0, 0, 1)])
1536            .build()
1537            .expect("valid system");
1538
1539        let connections = system.network().bus_lines(EntityId(0));
1540        assert!(!connections.is_empty(), "bus 0 must have connections");
1541        assert_eq!(connections[0].line_id, EntityId(0));
1542    }
1543
1544    #[test]
1545    fn test_all_entity_lookups() {
1546        // Provide all buses and hydros that the other entities reference.
1547        // - Buses 0 and 1 are needed by all entities (lines, hydros, thermals, etc.)
1548        // - Hydros 0 and 1 are needed by the pumping station (source/destination)
1549        // - Hydro 3 is the entity under test (lookup by id=3), on bus 0
1550        let system = SystemBuilder::new()
1551            .buses(vec![make_bus(0), make_bus(1)])
1552            .lines(vec![make_line(2, 0, 1)])
1553            .hydros(vec![
1554                make_hydro_on_bus(0, 0),
1555                make_hydro_on_bus(1, 0),
1556                make_hydro_on_bus(3, 0),
1557            ])
1558            .thermals(vec![make_thermal(4)])
1559            .pumping_stations(vec![make_pumping_station(5)])
1560            .contracts(vec![make_contract(6)])
1561            .non_controllable_sources(vec![make_ncs(7)])
1562            .build()
1563            .expect("valid system");
1564
1565        assert!(system.bus(EntityId(1)).is_some());
1566        assert!(system.line(EntityId(2)).is_some());
1567        assert!(system.hydro(EntityId(3)).is_some());
1568        assert!(system.thermal(EntityId(4)).is_some());
1569        assert!(system.pumping_station(EntityId(5)).is_some());
1570        assert!(system.contract(EntityId(6)).is_some());
1571        assert!(system.non_controllable_source(EntityId(7)).is_some());
1572
1573        assert!(system.bus(EntityId(999)).is_none());
1574        assert!(system.line(EntityId(999)).is_none());
1575        assert!(system.hydro(EntityId(999)).is_none());
1576        assert!(system.thermal(EntityId(999)).is_none());
1577        assert!(system.pumping_station(EntityId(999)).is_none());
1578        assert!(system.contract(EntityId(999)).is_none());
1579        assert!(system.non_controllable_source(EntityId(999)).is_none());
1580    }
1581
1582    #[test]
1583    fn test_default_builder() {
1584        let system = SystemBuilder::default()
1585            .build()
1586            .expect("default builder produces valid empty system");
1587        assert_eq!(system.n_buses(), 0);
1588    }
1589
1590    // ---- Cross-reference validation tests -----------------------------------
1591
1592    #[test]
1593    fn test_invalid_bus_reference_hydro() {
1594        // Hydro references bus id=99 which does not exist.
1595        let hydro = make_hydro_on_bus(1, 99);
1596        let result = SystemBuilder::new().hydros(vec![hydro]).build();
1597
1598        assert!(result.is_err(), "expected Err for missing bus reference");
1599        let errors = result.unwrap_err();
1600        assert!(
1601            errors.iter().any(|e| matches!(
1602                e,
1603                ValidationError::InvalidReference {
1604                    source_entity_type: "Hydro",
1605                    source_id: EntityId(1),
1606                    field_name: "bus_id",
1607                    referenced_id: EntityId(99),
1608                    expected_type: "Bus",
1609                }
1610            )),
1611            "expected InvalidReference for Hydro bus_id=99, got: {errors:?}"
1612        );
1613    }
1614
1615    #[test]
1616    fn test_invalid_downstream_reference() {
1617        // Hydro references downstream hydro id=50 which does not exist.
1618        let bus = make_bus(0);
1619        let mut hydro = make_hydro(1);
1620        hydro.downstream_id = Some(EntityId(50));
1621
1622        let result = SystemBuilder::new()
1623            .buses(vec![bus])
1624            .hydros(vec![hydro])
1625            .build();
1626
1627        assert!(
1628            result.is_err(),
1629            "expected Err for missing downstream reference"
1630        );
1631        let errors = result.unwrap_err();
1632        assert!(
1633            errors.iter().any(|e| matches!(
1634                e,
1635                ValidationError::InvalidReference {
1636                    source_entity_type: "Hydro",
1637                    source_id: EntityId(1),
1638                    field_name: "downstream_id",
1639                    referenced_id: EntityId(50),
1640                    expected_type: "Hydro",
1641                }
1642            )),
1643            "expected InvalidReference for Hydro downstream_id=50, got: {errors:?}"
1644        );
1645    }
1646
1647    #[test]
1648    fn test_invalid_pumping_station_hydro_refs() {
1649        // Pumping station references source hydro id=77 which does not exist.
1650        let bus = make_bus(0);
1651        let dest_hydro = make_hydro(1);
1652        let ps = make_pumping_station_full(10, 0, 77, 1);
1653
1654        let result = SystemBuilder::new()
1655            .buses(vec![bus])
1656            .hydros(vec![dest_hydro])
1657            .pumping_stations(vec![ps])
1658            .build();
1659
1660        assert!(
1661            result.is_err(),
1662            "expected Err for missing source_hydro_id reference"
1663        );
1664        let errors = result.unwrap_err();
1665        assert!(
1666            errors.iter().any(|e| matches!(
1667                e,
1668                ValidationError::InvalidReference {
1669                    source_entity_type: "PumpingStation",
1670                    source_id: EntityId(10),
1671                    field_name: "source_hydro_id",
1672                    referenced_id: EntityId(77),
1673                    expected_type: "Hydro",
1674                }
1675            )),
1676            "expected InvalidReference for PumpingStation source_hydro_id=77, got: {errors:?}"
1677        );
1678    }
1679
1680    #[test]
1681    fn test_multiple_invalid_references_collected() {
1682        // A line with bad source_bus_id AND a thermal with bad bus_id.
1683        // Both errors must be reported (no short-circuiting).
1684        let line = make_line(1, 99, 0);
1685        let thermal = make_thermal_on_bus(2, 88);
1686
1687        let result = SystemBuilder::new()
1688            .buses(vec![make_bus(0)])
1689            .lines(vec![line])
1690            .thermals(vec![thermal])
1691            .build();
1692
1693        assert!(
1694            result.is_err(),
1695            "expected Err for multiple invalid references"
1696        );
1697        let errors = result.unwrap_err();
1698
1699        let has_line_error = errors.iter().any(|e| {
1700            matches!(
1701                e,
1702                ValidationError::InvalidReference {
1703                    source_entity_type: "Line",
1704                    field_name: "source_bus_id",
1705                    referenced_id: EntityId(99),
1706                    ..
1707                }
1708            )
1709        });
1710        let has_thermal_error = errors.iter().any(|e| {
1711            matches!(
1712                e,
1713                ValidationError::InvalidReference {
1714                    source_entity_type: "Thermal",
1715                    field_name: "bus_id",
1716                    referenced_id: EntityId(88),
1717                    ..
1718                }
1719            )
1720        });
1721
1722        assert!(
1723            has_line_error,
1724            "expected Line source_bus_id=99 error, got: {errors:?}"
1725        );
1726        assert!(
1727            has_thermal_error,
1728            "expected Thermal bus_id=88 error, got: {errors:?}"
1729        );
1730        assert!(
1731            errors.len() >= 2,
1732            "expected at least 2 errors, got {}: {errors:?}",
1733            errors.len()
1734        );
1735    }
1736
1737    #[test]
1738    fn test_valid_cross_references_pass() {
1739        // All cross-references point to entities that exist — build must succeed.
1740        let bus_0 = make_bus(0);
1741        let bus_1 = make_bus(1);
1742        let h0 = make_hydro_on_bus(0, 0);
1743        let h1 = make_hydro_on_bus(1, 1);
1744        let mut h2 = make_hydro_on_bus(2, 0);
1745        h2.downstream_id = Some(EntityId(1));
1746        let line = make_line(10, 0, 1);
1747        let thermal = make_thermal_on_bus(20, 0);
1748        let ps = make_pumping_station_full(30, 0, 0, 1);
1749        let contract = make_contract_on_bus(40, 1);
1750        let ncs = make_ncs_on_bus(50, 0);
1751
1752        let result = SystemBuilder::new()
1753            .buses(vec![bus_0, bus_1])
1754            .lines(vec![line])
1755            .hydros(vec![h0, h1, h2])
1756            .thermals(vec![thermal])
1757            .pumping_stations(vec![ps])
1758            .contracts(vec![contract])
1759            .non_controllable_sources(vec![ncs])
1760            .build();
1761
1762        assert!(
1763            result.is_ok(),
1764            "expected Ok for all valid cross-references, got: {:?}",
1765            result.unwrap_err()
1766        );
1767        let system = result.unwrap_or_else(|_| unreachable!());
1768        assert_eq!(system.n_buses(), 2);
1769        assert_eq!(system.n_hydros(), 3);
1770        assert_eq!(system.n_lines(), 1);
1771        assert_eq!(system.n_thermals(), 1);
1772        assert_eq!(system.n_pumping_stations(), 1);
1773        assert_eq!(system.n_contracts(), 1);
1774        assert_eq!(system.n_non_controllable_sources(), 1);
1775    }
1776
1777    // ---- Cascade cycle detection tests --------------------------------------
1778
1779    #[test]
1780    fn test_cascade_cycle_detected() {
1781        // Three-node cycle: A(0)->B(1)->C(2)->A(0).
1782        // All three reference a common bus (bus 0).
1783        let bus = make_bus(0);
1784        let mut h0 = make_hydro(0);
1785        h0.downstream_id = Some(EntityId(1));
1786        let mut h1 = make_hydro(1);
1787        h1.downstream_id = Some(EntityId(2));
1788        let mut h2 = make_hydro(2);
1789        h2.downstream_id = Some(EntityId(0));
1790
1791        let result = SystemBuilder::new()
1792            .buses(vec![bus])
1793            .hydros(vec![h0, h1, h2])
1794            .build();
1795
1796        assert!(result.is_err(), "expected Err for 3-node cycle");
1797        let errors = result.unwrap_err();
1798        let cycle_error = errors
1799            .iter()
1800            .find(|e| matches!(e, ValidationError::CascadeCycle { .. }));
1801        assert!(
1802            cycle_error.is_some(),
1803            "expected CascadeCycle error, got: {errors:?}"
1804        );
1805        let ValidationError::CascadeCycle { cycle_ids } = cycle_error.unwrap() else {
1806            unreachable!()
1807        };
1808        assert_eq!(
1809            cycle_ids,
1810            &[EntityId(0), EntityId(1), EntityId(2)],
1811            "cycle_ids must be sorted ascending, got: {cycle_ids:?}"
1812        );
1813    }
1814
1815    #[test]
1816    fn test_cascade_self_loop_detected() {
1817        // Single hydro pointing to itself: A(0)->A(0).
1818        let bus = make_bus(0);
1819        let mut h0 = make_hydro(0);
1820        h0.downstream_id = Some(EntityId(0));
1821
1822        let result = SystemBuilder::new()
1823            .buses(vec![bus])
1824            .hydros(vec![h0])
1825            .build();
1826
1827        assert!(result.is_err(), "expected Err for self-loop");
1828        let errors = result.unwrap_err();
1829        let has_cycle = errors
1830            .iter()
1831            .any(|e| matches!(e, ValidationError::CascadeCycle { cycle_ids } if cycle_ids.contains(&EntityId(0))));
1832        assert!(
1833            has_cycle,
1834            "expected CascadeCycle containing EntityId(0), got: {errors:?}"
1835        );
1836    }
1837
1838    #[test]
1839    fn test_valid_acyclic_cascade_passes() {
1840        // Linear acyclic cascade A(0)->B(1)->C(2).
1841        // Verifies that a valid cascade produces Ok with correct topological_order length.
1842        let bus = make_bus(0);
1843        let mut h0 = make_hydro(0);
1844        h0.downstream_id = Some(EntityId(1));
1845        let mut h1 = make_hydro(1);
1846        h1.downstream_id = Some(EntityId(2));
1847        let h2 = make_hydro(2);
1848
1849        let result = SystemBuilder::new()
1850            .buses(vec![bus])
1851            .hydros(vec![h0, h1, h2])
1852            .build();
1853
1854        assert!(
1855            result.is_ok(),
1856            "expected Ok for acyclic cascade, got: {:?}",
1857            result.unwrap_err()
1858        );
1859        let system = result.unwrap_or_else(|_| unreachable!());
1860        assert_eq!(
1861            system.cascade().topological_order().len(),
1862            system.n_hydros(),
1863            "topological_order must contain all hydros"
1864        );
1865    }
1866
1867    // ---- Filling config validation tests ------------------------------------
1868
1869    #[test]
1870    fn test_filling_without_entry_stage() {
1871        // Filling config present but entry_stage_id is None.
1872        use crate::entities::FillingConfig;
1873        let bus = make_bus(0);
1874        let mut hydro = make_hydro(1);
1875        hydro.entry_stage_id = None;
1876        hydro.filling = Some(FillingConfig {
1877            start_stage_id: 10,
1878            filling_inflow_m3s: 100.0,
1879        });
1880
1881        let result = SystemBuilder::new()
1882            .buses(vec![bus])
1883            .hydros(vec![hydro])
1884            .build();
1885
1886        assert!(
1887            result.is_err(),
1888            "expected Err for filling without entry_stage_id"
1889        );
1890        let errors = result.unwrap_err();
1891        let has_error = errors.iter().any(|e| match e {
1892            ValidationError::InvalidFillingConfig { hydro_id, reason } => {
1893                *hydro_id == EntityId(1) && reason.contains("entry_stage_id")
1894            }
1895            _ => false,
1896        });
1897        assert!(
1898            has_error,
1899            "expected InvalidFillingConfig with entry_stage_id reason, got: {errors:?}"
1900        );
1901    }
1902
1903    #[test]
1904    fn test_filling_negative_inflow() {
1905        // Filling config with filling_inflow_m3s <= 0.0.
1906        use crate::entities::FillingConfig;
1907        let bus = make_bus(0);
1908        let mut hydro = make_hydro(1);
1909        hydro.entry_stage_id = Some(10);
1910        hydro.filling = Some(FillingConfig {
1911            start_stage_id: 10,
1912            filling_inflow_m3s: -5.0,
1913        });
1914
1915        let result = SystemBuilder::new()
1916            .buses(vec![bus])
1917            .hydros(vec![hydro])
1918            .build();
1919
1920        assert!(
1921            result.is_err(),
1922            "expected Err for negative filling_inflow_m3s"
1923        );
1924        let errors = result.unwrap_err();
1925        let has_error = errors.iter().any(|e| match e {
1926            ValidationError::InvalidFillingConfig { hydro_id, reason } => {
1927                *hydro_id == EntityId(1) && reason.contains("filling_inflow_m3s must be positive")
1928            }
1929            _ => false,
1930        });
1931        assert!(
1932            has_error,
1933            "expected InvalidFillingConfig with positive inflow reason, got: {errors:?}"
1934        );
1935    }
1936
1937    #[test]
1938    fn test_valid_filling_config_passes() {
1939        // Valid filling config: entry_stage_id set and filling_inflow_m3s positive.
1940        use crate::entities::FillingConfig;
1941        let bus = make_bus(0);
1942        let mut hydro = make_hydro(1);
1943        hydro.entry_stage_id = Some(10);
1944        hydro.filling = Some(FillingConfig {
1945            start_stage_id: 10,
1946            filling_inflow_m3s: 100.0,
1947        });
1948
1949        let result = SystemBuilder::new()
1950            .buses(vec![bus])
1951            .hydros(vec![hydro])
1952            .build();
1953
1954        assert!(
1955            result.is_ok(),
1956            "expected Ok for valid filling config, got: {:?}",
1957            result.unwrap_err()
1958        );
1959    }
1960
1961    #[test]
1962    fn test_cascade_cycle_and_invalid_filling_both_reported() {
1963        // Both a cascade cycle (A->A self-loop) AND an invalid filling config
1964        // must produce both error variants.
1965        use crate::entities::FillingConfig;
1966        let bus = make_bus(0);
1967
1968        // Hydro 0: self-loop (cycle)
1969        let mut h0 = make_hydro(0);
1970        h0.downstream_id = Some(EntityId(0));
1971
1972        // Hydro 1: valid cycle participant? No -- use a separate hydro with invalid filling.
1973        let mut h1 = make_hydro(1);
1974        h1.entry_stage_id = None; // no entry_stage_id
1975        h1.filling = Some(FillingConfig {
1976            start_stage_id: 5,
1977            filling_inflow_m3s: 50.0,
1978        });
1979
1980        let result = SystemBuilder::new()
1981            .buses(vec![bus])
1982            .hydros(vec![h0, h1])
1983            .build();
1984
1985        assert!(result.is_err(), "expected Err for cycle + invalid filling");
1986        let errors = result.unwrap_err();
1987        let has_cycle = errors
1988            .iter()
1989            .any(|e| matches!(e, ValidationError::CascadeCycle { .. }));
1990        let has_filling = errors
1991            .iter()
1992            .any(|e| matches!(e, ValidationError::InvalidFillingConfig { .. }));
1993        assert!(has_cycle, "expected CascadeCycle error, got: {errors:?}");
1994        assert!(
1995            has_filling,
1996            "expected InvalidFillingConfig error, got: {errors:?}"
1997        );
1998    }
1999
2000    #[cfg(feature = "serde")]
2001    #[test]
2002    fn test_system_serde_roundtrip() {
2003        // Build a system with a bus, a hydro, a line, and a thermal.
2004        let bus_a = make_bus(1);
2005        let bus_b = make_bus(2);
2006        let hydro = make_hydro_on_bus(10, 1);
2007        let thermal = make_thermal_on_bus(20, 2);
2008        let line = make_line(1, 1, 2);
2009
2010        let system = SystemBuilder::new()
2011            .buses(vec![bus_a, bus_b])
2012            .hydros(vec![hydro])
2013            .thermals(vec![thermal])
2014            .lines(vec![line])
2015            .build()
2016            .expect("valid system");
2017
2018        let json = serde_json::to_string(&system).unwrap();
2019
2020        // Deserialize and rebuild indices.
2021        let mut deserialized: System = serde_json::from_str(&json).unwrap();
2022        deserialized.rebuild_indices();
2023
2024        // Entity collections must match.
2025        assert_eq!(system.buses(), deserialized.buses());
2026        assert_eq!(system.hydros(), deserialized.hydros());
2027        assert_eq!(system.thermals(), deserialized.thermals());
2028        assert_eq!(system.lines(), deserialized.lines());
2029
2030        // O(1) lookup must work after index rebuild.
2031        assert_eq!(
2032            deserialized.bus(EntityId(1)).map(|b| b.id),
2033            Some(EntityId(1))
2034        );
2035        assert_eq!(
2036            deserialized.hydro(EntityId(10)).map(|h| h.id),
2037            Some(EntityId(10))
2038        );
2039        assert_eq!(
2040            deserialized.thermal(EntityId(20)).map(|t| t.id),
2041            Some(EntityId(20))
2042        );
2043        assert_eq!(
2044            deserialized.line(EntityId(1)).map(|l| l.id),
2045            Some(EntityId(1))
2046        );
2047    }
2048
2049    // ---- Extended System tests ----------------------------------------------
2050
2051    fn make_stage(id: i32) -> Stage {
2052        use crate::temporal::{
2053            Block, BlockMode, NoiseMethod, ScenarioSourceConfig, StageRiskConfig, StageStateConfig,
2054        };
2055        use chrono::NaiveDate;
2056        Stage {
2057            index: usize::try_from(id.max(0)).unwrap_or(0),
2058            id,
2059            start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
2060            end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
2061            season_id: Some(0),
2062            blocks: vec![Block {
2063                index: 0,
2064                name: "SINGLE".to_string(),
2065                duration_hours: 744.0,
2066            }],
2067            block_mode: BlockMode::Parallel,
2068            state_config: StageStateConfig {
2069                storage: true,
2070                inflow_lags: false,
2071            },
2072            risk_config: StageRiskConfig::Expectation,
2073            scenario_config: ScenarioSourceConfig {
2074                branching_factor: 50,
2075                noise_method: NoiseMethod::Saa,
2076            },
2077        }
2078    }
2079
2080    /// Verify that `SystemBuilder::new().build()` still works correctly.
2081    /// New fields must default to empty/default values.
2082    #[test]
2083    fn test_system_backward_compat() {
2084        let system = SystemBuilder::new().build().expect("empty system is valid");
2085        // Entity counts unchanged
2086        assert_eq!(system.n_buses(), 0);
2087        assert_eq!(system.n_hydros(), 0);
2088        // New fields default to empty
2089        assert_eq!(system.n_stages(), 0);
2090        assert!(system.stages().is_empty());
2091        assert!(system.initial_conditions().storage.is_empty());
2092        assert!(system.generic_constraints().is_empty());
2093        assert!(system.inflow_models().is_empty());
2094        assert!(system.load_models().is_empty());
2095        assert_eq!(system.penalties().n_stages(), 0);
2096        assert_eq!(system.bounds().n_stages(), 0);
2097        // Generic constraint bounds default to empty.
2098        assert!(!system.resolved_generic_bounds().is_active(0, 0));
2099        assert!(
2100            system
2101                .resolved_generic_bounds()
2102                .bounds_for_stage(0, 0)
2103                .is_empty()
2104        );
2105    }
2106
2107    /// Verify `System::resolved_generic_bounds()` accessor with a non-empty table.
2108    #[test]
2109    fn test_system_resolved_generic_bounds_accessor() {
2110        use crate::resolved::ResolvedGenericConstraintBounds;
2111        use std::collections::HashMap as StdHashMap;
2112
2113        let id_map: StdHashMap<i32, usize> = [(0, 0), (1, 1)].into_iter().collect();
2114        let rows = vec![(0i32, 0i32, None::<i32>, 100.0f64)];
2115        let table = ResolvedGenericConstraintBounds::new(&id_map, rows.into_iter());
2116
2117        let system = SystemBuilder::new()
2118            .resolved_generic_bounds(table)
2119            .build()
2120            .expect("valid system");
2121
2122        assert!(system.resolved_generic_bounds().is_active(0, 0));
2123        assert!(!system.resolved_generic_bounds().is_active(1, 0));
2124        let slice = system.resolved_generic_bounds().bounds_for_stage(0, 0);
2125        assert_eq!(slice.len(), 1);
2126        assert_eq!(slice[0], (None, 100.0));
2127    }
2128
2129    /// Build a System with 2 stages and verify `n_stages()` and `stage(id)` lookup.
2130    #[test]
2131    fn test_system_with_stages() {
2132        let s0 = make_stage(0);
2133        let s1 = make_stage(1);
2134
2135        let system = SystemBuilder::new()
2136            .stages(vec![s1.clone(), s0.clone()]) // supply in reverse order
2137            .build()
2138            .expect("valid system");
2139
2140        // Canonical ordering: id=0 comes before id=1
2141        assert_eq!(system.n_stages(), 2);
2142        assert_eq!(system.stages()[0].id, 0);
2143        assert_eq!(system.stages()[1].id, 1);
2144
2145        // O(1) lookup by stage id
2146        let found = system.stage(0).expect("stage 0 must be found");
2147        assert_eq!(found.id, s0.id);
2148
2149        let found1 = system.stage(1).expect("stage 1 must be found");
2150        assert_eq!(found1.id, s1.id);
2151
2152        // Missing stage returns None
2153        assert!(system.stage(99).is_none());
2154    }
2155
2156    /// Build a System with 3 stages having IDs 0, 1, 2 and verify `stage()` lookups.
2157    #[test]
2158    fn test_system_stage_lookup_by_id() {
2159        let stages: Vec<Stage> = [0i32, 1, 2].iter().map(|&id| make_stage(id)).collect();
2160
2161        let system = SystemBuilder::new()
2162            .stages(stages)
2163            .build()
2164            .expect("valid system");
2165
2166        assert_eq!(system.stage(1).map(|s| s.id), Some(1));
2167        assert!(system.stage(99).is_none());
2168    }
2169
2170    /// Build a System with `InitialConditions` containing 1 storage entry and verify accessor.
2171    #[test]
2172    fn test_system_with_initial_conditions() {
2173        let ic = InitialConditions {
2174            storage: vec![crate::HydroStorage {
2175                hydro_id: EntityId(0),
2176                value_hm3: 15_000.0,
2177            }],
2178            filling_storage: vec![],
2179            past_inflows: vec![],
2180        };
2181
2182        let system = SystemBuilder::new()
2183            .initial_conditions(ic)
2184            .build()
2185            .expect("valid system");
2186
2187        assert_eq!(system.initial_conditions().storage.len(), 1);
2188        assert_eq!(system.initial_conditions().storage[0].hydro_id, EntityId(0));
2189        assert!((system.initial_conditions().storage[0].value_hm3 - 15_000.0).abs() < f64::EPSILON);
2190    }
2191
2192    /// Verify serde round-trip of a System with stages and `policy_graph`,
2193    /// including that `stage_index` is correctly rebuilt after deserialization.
2194    #[cfg(feature = "serde")]
2195    #[test]
2196    fn test_system_serde_roundtrip_with_stages() {
2197        use crate::temporal::PolicyGraphType;
2198
2199        let stages = vec![make_stage(0), make_stage(1)];
2200        let policy_graph = PolicyGraph {
2201            graph_type: PolicyGraphType::FiniteHorizon,
2202            annual_discount_rate: 0.0,
2203            transitions: vec![],
2204            season_map: None,
2205        };
2206
2207        let system = SystemBuilder::new()
2208            .stages(stages)
2209            .policy_graph(policy_graph)
2210            .build()
2211            .expect("valid system");
2212
2213        let json = serde_json::to_string(&system).unwrap();
2214        let mut deserialized: System = serde_json::from_str(&json).unwrap();
2215
2216        // stage_index is skipped during serde; rebuild before querying
2217        deserialized.rebuild_indices();
2218
2219        // Collections must match after round-trip
2220        assert_eq!(system.n_stages(), deserialized.n_stages());
2221        assert_eq!(system.stages()[0].id, deserialized.stages()[0].id);
2222        assert_eq!(system.stages()[1].id, deserialized.stages()[1].id);
2223
2224        // O(1) lookup must work after index rebuild
2225        assert_eq!(deserialized.stage(0).map(|s| s.id), Some(0));
2226        assert_eq!(deserialized.stage(1).map(|s| s.id), Some(1));
2227        assert!(deserialized.stage(99).is_none());
2228
2229        // policy_graph fields must round-trip
2230        assert_eq!(
2231            deserialized.policy_graph().graph_type,
2232            system.policy_graph().graph_type
2233        );
2234    }
2235}