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