Skip to main content

cobre_core/system/
mod.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//!
11//! # Submodule layout
12//!
13//! - `mod` (this file) — the `System` struct, its `Send`/`Sync` compile-time
14//!   assertion, and the read-only accessor methods.
15//! - `builder` — `SystemBuilder`, whose `build()` sorts entities into canonical
16//!   order, runs the construction-time validation pass, and assembles the
17//!   immutable `System`. As a child of `system`, it constructs `System` via a
18//!   direct struct literal over the ancestor-private fields without any field
19//!   promotion.
20//! - `validate` — the construction-time cross-reference, duplicate-id, and
21//!   filling-config validation helpers that `build()` drives, plus the `HasId`
22//!   keying trait and the `build_index` / `build_stage_index` lookup-index
23//!   builders.
24//!
25//! `SystemBuilder` is re-exported here so the curated `cobre_core::SystemBuilder`
26//! flat surface and the `cobre_core::system::SystemBuilder` module path both
27//! resolve to the same item; `System` is defined here and is already in the
28//! `system` namespace.
29
30use std::collections::HashMap;
31
32use crate::{
33    Bus, CascadeTopology, CorrelationModel, EnergyContract, EntityId, ExternalLoadRow,
34    ExternalNcsRow, ExternalScenarioRow, GenericConstraint, Hydro, InflowHistoryRow, InflowModel,
35    InitialConditions, Line, LoadModel, NcsModel, NetworkTopology, NonControllableSource,
36    PolicyGraph, PumpingStation, ResolvedBounds, ResolvedExchangeFactors,
37    ResolvedGenericConstraintBounds, ResolvedLoadFactors, ResolvedNcsBounds, ResolvedNcsFactors,
38    ResolvedPenalties, Stage, Thermal,
39};
40
41mod builder;
42mod validate;
43
44pub use builder::SystemBuilder;
45
46use validate::{build_index, build_stage_index};
47
48/// Top-level system representation.
49///
50/// Produced by `cobre-io::load_case()` or [`SystemBuilder`] in tests.
51/// Consumed by solvers and analysis tools via shared reference.
52/// Immutable and thread-safe after construction.
53///
54/// Entity collections are in canonical order (sorted by [`EntityId`]'s inner `i32`).
55/// Lookup indices provide O(1) access by [`EntityId`].
56///
57/// # Examples
58///
59/// ```
60/// use cobre_core::{Bus, DeficitSegment, EntityId, SystemBuilder};
61///
62/// let bus = Bus {
63///     id: EntityId(1),
64///     name: "Main Bus".to_string(),
65///     deficit_segments: vec![],
66///     excess_cost: 0.0,
67/// };
68///
69/// let system = SystemBuilder::new()
70///     .buses(vec![bus])
71///     .build()
72///     .expect("valid system");
73///
74/// assert_eq!(system.n_buses(), 1);
75/// assert!(system.bus(EntityId(1)).is_some());
76/// ```
77#[derive(Debug, PartialEq)]
78#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
79pub struct System {
80    // Entity collections (canonical ordering by ID)
81    buses: Vec<Bus>,
82    lines: Vec<Line>,
83    hydros: Vec<Hydro>,
84    thermals: Vec<Thermal>,
85    pumping_stations: Vec<PumpingStation>,
86    contracts: Vec<EnergyContract>,
87    non_controllable_sources: Vec<NonControllableSource>,
88
89    // O(1) lookup indices (entity ID -> position in collection) -- private.
90    // Per spec SS6.2: HashMap lookup indices are NOT serialized. After deserialization
91    // the caller must invoke `rebuild_indices()` to restore O(1) lookup capability.
92    #[cfg_attr(feature = "serde", serde(skip))]
93    bus_index: HashMap<EntityId, usize>,
94    #[cfg_attr(feature = "serde", serde(skip))]
95    line_index: HashMap<EntityId, usize>,
96    #[cfg_attr(feature = "serde", serde(skip))]
97    hydro_index: HashMap<EntityId, usize>,
98    #[cfg_attr(feature = "serde", serde(skip))]
99    thermal_index: HashMap<EntityId, usize>,
100    #[cfg_attr(feature = "serde", serde(skip))]
101    pumping_station_index: HashMap<EntityId, usize>,
102    #[cfg_attr(feature = "serde", serde(skip))]
103    contract_index: HashMap<EntityId, usize>,
104    #[cfg_attr(feature = "serde", serde(skip))]
105    non_controllable_source_index: HashMap<EntityId, usize>,
106
107    // Topology
108    /// Resolved hydro cascade graph.
109    cascade: CascadeTopology,
110    /// Resolved transmission network topology.
111    network: NetworkTopology,
112
113    // Temporal domain
114    /// Ordered list of stages (study + pre-study), sorted by `id` (canonical order).
115    stages: Vec<Stage>,
116    /// Policy graph defining stage transitions, horizon type, and discount rate.
117    policy_graph: PolicyGraph,
118
119    // Stage O(1) lookup index (stage ID -> position in stages vec).
120    // Stage IDs are `i32` (pre-study stages have negative IDs).
121    // Not serialized; rebuilt via `rebuild_indices()`.
122    #[cfg_attr(feature = "serde", serde(skip))]
123    stage_index: HashMap<i32, usize>,
124
125    // Resolved tables (populated by cobre-io after penalty/bound cascade)
126    /// Pre-resolved penalty values for all entities across all stages.
127    penalties: ResolvedPenalties,
128    /// Pre-resolved bound values for all entities across all stages.
129    bounds: ResolvedBounds,
130    /// Pre-resolved RHS bound table for user-defined generic linear constraints.
131    resolved_generic_bounds: ResolvedGenericConstraintBounds,
132    /// Pre-resolved per-block load scaling factors.
133    resolved_load_factors: ResolvedLoadFactors,
134    /// Pre-resolved per-block exchange capacity factors.
135    resolved_exchange_factors: ResolvedExchangeFactors,
136    /// Pre-resolved per-stage NCS available generation bounds.
137    resolved_ncs_bounds: ResolvedNcsBounds,
138    /// Pre-resolved per-block NCS generation scaling factors.
139    resolved_ncs_factors: ResolvedNcsFactors,
140
141    // Scenario pipeline data (raw parameters loaded by cobre-io)
142    /// PAR(p) inflow model parameters, one entry per (hydro, stage) pair.
143    inflow_models: Vec<InflowModel>,
144    /// Seasonal load statistics, one entry per (bus, stage) pair.
145    load_models: Vec<LoadModel>,
146    /// NCS availability noise model parameters, one entry per (ncs, stage) pair.
147    ncs_models: Vec<NcsModel>,
148    /// Correlation model for stochastic inflow/load generation.
149    correlation: CorrelationModel,
150
151    // Study state
152    /// Initial reservoir storage levels at the start of the study.
153    initial_conditions: InitialConditions,
154    /// User-defined generic linear constraints, sorted by `id`.
155    generic_constraints: Vec<GenericConstraint>,
156
157    // Raw scenario library data (H6 — historical and external sampling)
158    /// Raw historical inflow observations per (hydro, date) pair.
159    /// Sorted by `(hydro_id, date)` ascending. Empty when
160    /// `scenarios/inflow_history.parquet` is absent.
161    inflow_history: Vec<InflowHistoryRow>,
162    /// Raw external scenario rows per (stage, scenario, hydro) triple.
163    /// Sorted by `(stage_id, scenario_id, hydro_id)` ascending.
164    /// Empty when no external inflow scenario file is present.
165    external_scenarios: Vec<ExternalScenarioRow>,
166    /// Raw external load scenario rows per (stage, scenario, bus) triple.
167    /// Sorted by `(stage_id, scenario_id, bus_id)` ascending.
168    /// Empty when no external load scenario file is present.
169    external_load_scenarios: Vec<ExternalLoadRow>,
170    /// Raw external NCS scenario rows per (stage, scenario, ncs) triple.
171    /// Sorted by `(stage_id, scenario_id, ncs_id)` ascending.
172    /// Empty when no external NCS scenario file is present.
173    external_ncs_scenarios: Vec<ExternalNcsRow>,
174}
175
176// Compile-time check that System is Send + Sync.
177const _: () = {
178    const fn assert_send_sync<T: Send + Sync>() {}
179    const fn check() {
180        assert_send_sync::<System>();
181    }
182    let _ = check;
183};
184
185impl System {
186    /// Returns all buses in canonical ID order.
187    #[must_use]
188    pub fn buses(&self) -> &[Bus] {
189        &self.buses
190    }
191
192    /// Returns all lines in canonical ID order.
193    #[must_use]
194    pub fn lines(&self) -> &[Line] {
195        &self.lines
196    }
197
198    /// Returns all hydro plants in canonical ID order.
199    #[must_use]
200    pub fn hydros(&self) -> &[Hydro] {
201        &self.hydros
202    }
203
204    /// Returns all thermal plants in canonical ID order.
205    #[must_use]
206    pub fn thermals(&self) -> &[Thermal] {
207        &self.thermals
208    }
209
210    /// Returns all pumping stations in canonical ID order.
211    #[must_use]
212    pub fn pumping_stations(&self) -> &[PumpingStation] {
213        &self.pumping_stations
214    }
215
216    /// Returns all energy contracts in canonical ID order.
217    #[must_use]
218    pub fn contracts(&self) -> &[EnergyContract] {
219        &self.contracts
220    }
221
222    /// Returns all non-controllable sources in canonical ID order.
223    #[must_use]
224    pub fn non_controllable_sources(&self) -> &[NonControllableSource] {
225        &self.non_controllable_sources
226    }
227
228    /// Returns the number of buses in the system.
229    #[must_use]
230    pub fn n_buses(&self) -> usize {
231        self.buses.len()
232    }
233
234    /// Returns the number of lines in the system.
235    #[must_use]
236    pub fn n_lines(&self) -> usize {
237        self.lines.len()
238    }
239
240    /// Returns the number of hydro plants in the system.
241    #[must_use]
242    pub fn n_hydros(&self) -> usize {
243        self.hydros.len()
244    }
245
246    /// Returns the number of thermal plants in the system.
247    #[must_use]
248    pub fn n_thermals(&self) -> usize {
249        self.thermals.len()
250    }
251
252    /// Returns the number of pumping stations in the system.
253    #[must_use]
254    pub fn n_pumping_stations(&self) -> usize {
255        self.pumping_stations.len()
256    }
257
258    /// Returns the number of energy contracts in the system.
259    #[must_use]
260    pub fn n_contracts(&self) -> usize {
261        self.contracts.len()
262    }
263
264    /// Returns the number of non-controllable sources in the system.
265    #[must_use]
266    pub fn n_non_controllable_sources(&self) -> usize {
267        self.non_controllable_sources.len()
268    }
269
270    /// Returns the bus with the given ID, or `None` if not found.
271    #[must_use]
272    pub fn bus(&self, id: EntityId) -> Option<&Bus> {
273        self.bus_index.get(&id).map(|&i| &self.buses[i])
274    }
275
276    /// Returns the line with the given ID, or `None` if not found.
277    #[must_use]
278    pub fn line(&self, id: EntityId) -> Option<&Line> {
279        self.line_index.get(&id).map(|&i| &self.lines[i])
280    }
281
282    /// Returns the hydro plant with the given ID, or `None` if not found.
283    #[must_use]
284    pub fn hydro(&self, id: EntityId) -> Option<&Hydro> {
285        self.hydro_index.get(&id).map(|&i| &self.hydros[i])
286    }
287
288    /// Returns the thermal plant with the given ID, or `None` if not found.
289    #[must_use]
290    pub fn thermal(&self, id: EntityId) -> Option<&Thermal> {
291        self.thermal_index.get(&id).map(|&i| &self.thermals[i])
292    }
293
294    /// Returns the pumping station with the given ID, or `None` if not found.
295    #[must_use]
296    pub fn pumping_station(&self, id: EntityId) -> Option<&PumpingStation> {
297        self.pumping_station_index
298            .get(&id)
299            .map(|&i| &self.pumping_stations[i])
300    }
301
302    /// Returns the energy contract with the given ID, or `None` if not found.
303    #[must_use]
304    pub fn contract(&self, id: EntityId) -> Option<&EnergyContract> {
305        self.contract_index.get(&id).map(|&i| &self.contracts[i])
306    }
307
308    /// Returns the non-controllable source with the given ID, or `None` if not found.
309    #[must_use]
310    pub fn non_controllable_source(&self, id: EntityId) -> Option<&NonControllableSource> {
311        self.non_controllable_source_index
312            .get(&id)
313            .map(|&i| &self.non_controllable_sources[i])
314    }
315
316    /// Returns a reference to the hydro cascade topology.
317    #[must_use]
318    pub fn cascade(&self) -> &CascadeTopology {
319        &self.cascade
320    }
321
322    /// Returns a reference to the transmission network topology.
323    #[must_use]
324    pub fn network(&self) -> &NetworkTopology {
325        &self.network
326    }
327
328    /// Returns all stages in canonical ID order (study and pre-study stages).
329    #[must_use]
330    pub fn stages(&self) -> &[Stage] {
331        &self.stages
332    }
333
334    /// Returns the number of stages (study and pre-study) in the system.
335    #[must_use]
336    pub fn n_stages(&self) -> usize {
337        self.stages.len()
338    }
339
340    /// Returns the stage with the given stage ID, or `None` if not found.
341    ///
342    /// Stage IDs are `i32`. Study stages have non-negative IDs; pre-study
343    /// stages (used only for PAR model lag initialization) have negative IDs.
344    #[must_use]
345    pub fn stage(&self, id: i32) -> Option<&Stage> {
346        self.stage_index.get(&id).map(|&i| &self.stages[i])
347    }
348
349    /// Returns a reference to the policy graph.
350    #[must_use]
351    pub fn policy_graph(&self) -> &PolicyGraph {
352        &self.policy_graph
353    }
354
355    /// Returns a reference to the pre-resolved penalty table.
356    #[must_use]
357    pub fn penalties(&self) -> &ResolvedPenalties {
358        &self.penalties
359    }
360
361    /// Returns a reference to the pre-resolved bounds table.
362    #[must_use]
363    pub fn bounds(&self) -> &ResolvedBounds {
364        &self.bounds
365    }
366
367    /// Returns a reference to the pre-resolved generic constraint RHS bound table.
368    #[must_use]
369    pub fn resolved_generic_bounds(&self) -> &ResolvedGenericConstraintBounds {
370        &self.resolved_generic_bounds
371    }
372
373    /// Returns a reference to the pre-resolved per-block load scaling factors.
374    #[must_use]
375    pub fn resolved_load_factors(&self) -> &ResolvedLoadFactors {
376        &self.resolved_load_factors
377    }
378
379    /// Returns a reference to the pre-resolved per-block exchange capacity factors.
380    #[must_use]
381    pub fn resolved_exchange_factors(&self) -> &ResolvedExchangeFactors {
382        &self.resolved_exchange_factors
383    }
384
385    /// Returns a reference to the pre-resolved per-stage NCS available generation bounds.
386    #[must_use]
387    pub fn resolved_ncs_bounds(&self) -> &ResolvedNcsBounds {
388        &self.resolved_ncs_bounds
389    }
390
391    /// Returns a reference to the pre-resolved per-block NCS generation scaling factors.
392    #[must_use]
393    pub fn resolved_ncs_factors(&self) -> &ResolvedNcsFactors {
394        &self.resolved_ncs_factors
395    }
396
397    /// Returns all PAR(p) inflow models in canonical order (by hydro ID, then stage ID).
398    #[must_use]
399    pub fn inflow_models(&self) -> &[InflowModel] {
400        &self.inflow_models
401    }
402
403    /// Returns all load models in canonical order (by bus ID, then stage ID).
404    #[must_use]
405    pub fn load_models(&self) -> &[LoadModel] {
406        &self.load_models
407    }
408
409    /// Returns all NCS availability noise models in canonical order (by NCS ID, then stage ID).
410    #[must_use]
411    pub fn ncs_models(&self) -> &[NcsModel] {
412        &self.ncs_models
413    }
414
415    /// Returns a reference to the correlation model.
416    #[must_use]
417    pub fn correlation(&self) -> &CorrelationModel {
418        &self.correlation
419    }
420
421    /// Returns a reference to the initial conditions.
422    #[must_use]
423    pub fn initial_conditions(&self) -> &InitialConditions {
424        &self.initial_conditions
425    }
426
427    /// Returns all generic constraints in canonical ID order.
428    #[must_use]
429    pub fn generic_constraints(&self) -> &[GenericConstraint] {
430        &self.generic_constraints
431    }
432
433    /// Returns the raw historical inflow observations, sorted by `(hydro_id, date)`.
434    ///
435    /// Returns an empty slice when `scenarios/inflow_history.parquet` was absent
436    /// at case-load time.
437    #[must_use]
438    pub fn inflow_history(&self) -> &[InflowHistoryRow] {
439        &self.inflow_history
440    }
441
442    /// Returns the raw external inflow scenario rows, sorted by `(stage_id, scenario_id, hydro_id)`.
443    ///
444    /// Returns an empty slice when no external inflow scenario file was present at case-load time.
445    #[must_use]
446    pub fn external_scenarios(&self) -> &[ExternalScenarioRow] {
447        &self.external_scenarios
448    }
449
450    /// Returns the raw external load scenario rows, sorted by `(stage_id, scenario_id, bus_id)`.
451    ///
452    /// Returns an empty slice when no external load scenario file was present at case-load time.
453    #[must_use]
454    pub fn external_load_scenarios(&self) -> &[ExternalLoadRow] {
455        &self.external_load_scenarios
456    }
457
458    /// Returns the raw external NCS scenario rows, sorted by `(stage_id, scenario_id, ncs_id)`.
459    ///
460    /// Returns an empty slice when no external NCS scenario file was present at case-load time.
461    #[must_use]
462    pub fn external_ncs_scenarios(&self) -> &[ExternalNcsRow] {
463        &self.external_ncs_scenarios
464    }
465
466    /// Replace the scenario models and correlation on this `System`, returning a new
467    /// `System` with updated fields and all other fields preserved.
468    ///
469    /// This is the only supported way to update `inflow_models` and `correlation`
470    /// after a `System` has been constructed — the fields are not public outside
471    /// this crate. All entity collections, topology, stages, penalties, bounds, and
472    /// study state are preserved unchanged.
473    ///
474    /// # Examples
475    ///
476    /// ```
477    /// use cobre_core::{EntityId, SystemBuilder};
478    /// use cobre_core::scenario::{InflowModel, CorrelationModel};
479    ///
480    /// let system = SystemBuilder::new().build().expect("valid system");
481    /// let model = InflowModel {
482    ///     hydro_id: EntityId(1),
483    ///     stage_id: 0,
484    ///     mean_m3s: 100.0,
485    ///     std_m3s: 10.0,
486    ///     ar_coefficients: vec![],
487    ///     residual_std_ratio: 1.0,
488    ///     annual: None,
489    /// };
490    /// let updated = system.with_scenario_models(vec![model], CorrelationModel::default());
491    /// assert_eq!(updated.inflow_models().len(), 1);
492    /// ```
493    #[must_use]
494    pub fn with_scenario_models(
495        mut self,
496        inflow_models: Vec<InflowModel>,
497        correlation: CorrelationModel,
498    ) -> Self {
499        self.inflow_models = inflow_models;
500        self.correlation = correlation;
501        self
502    }
503
504    /// Rebuild all O(1) lookup indices from the entity collections.
505    ///
506    /// Required after deserialization: the `HashMap` lookup indices are not serialized
507    /// (per spec SS6.2 — they are derived from the entity collections). After
508    /// deserializing a `System` from JSON or any other format, call this method once
509    /// to restore O(1) access via [`bus`](Self::bus), [`hydro`](Self::hydro), etc.
510    ///
511    /// # Examples
512    ///
513    /// ```
514    /// # #[cfg(feature = "serde")]
515    /// # {
516    /// use cobre_core::{Bus, DeficitSegment, EntityId, SystemBuilder};
517    ///
518    /// let system = SystemBuilder::new()
519    ///     .buses(vec![Bus {
520    ///         id: EntityId(1),
521    ///         name: "A".to_string(),
522    ///         deficit_segments: vec![],
523    ///         excess_cost: 0.0,
524    ///     }])
525    ///     .build()
526    ///     .expect("valid system");
527    ///
528    /// let json = serde_json::to_string(&system).unwrap();
529    /// let mut deserialized: cobre_core::System = serde_json::from_str(&json).unwrap();
530    /// deserialized.rebuild_indices();
531    ///
532    /// // O(1) lookup now works after index rebuild.
533    /// assert!(deserialized.bus(EntityId(1)).is_some());
534    /// # }
535    /// ```
536    pub fn rebuild_indices(&mut self) {
537        self.bus_index = build_index(&self.buses);
538        self.line_index = build_index(&self.lines);
539        self.hydro_index = build_index(&self.hydros);
540        self.thermal_index = build_index(&self.thermals);
541        self.pumping_station_index = build_index(&self.pumping_stations);
542        self.contract_index = build_index(&self.contracts);
543        self.non_controllable_source_index = build_index(&self.non_controllable_sources);
544        self.stage_index = build_stage_index(&self.stages);
545    }
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551    use crate::ValidationError;
552    use crate::entities::{ContractType, HydroGenerationModel, HydroPenalties};
553
554    fn make_bus(id: i32) -> Bus {
555        Bus {
556            id: EntityId(id),
557            name: format!("bus-{id}"),
558            deficit_segments: vec![],
559            excess_cost: 0.0,
560        }
561    }
562
563    fn make_line(id: i32, source_bus_id: i32, target_bus_id: i32) -> Line {
564        crate::Line {
565            id: EntityId(id),
566            name: format!("line-{id}"),
567            source_bus_id: EntityId(source_bus_id),
568            target_bus_id: EntityId(target_bus_id),
569            entry_stage_id: None,
570            exit_stage_id: None,
571            direct_capacity_mw: 100.0,
572            reverse_capacity_mw: 100.0,
573            losses_percent: 0.0,
574            exchange_cost: 0.0,
575        }
576    }
577
578    fn make_hydro_on_bus(id: i32, bus_id: i32) -> Hydro {
579        let zero_penalties = HydroPenalties {
580            spillage_cost: 0.0,
581            diversion_cost: 0.0,
582            turbined_cost: 0.0,
583            storage_violation_below_cost: 0.0,
584            filling_target_violation_cost: 0.0,
585            turbined_violation_below_cost: 0.0,
586            outflow_violation_below_cost: 0.0,
587            outflow_violation_above_cost: 0.0,
588            generation_violation_below_cost: 0.0,
589            evaporation_violation_cost: 0.0,
590            water_withdrawal_violation_cost: 0.0,
591            water_withdrawal_violation_pos_cost: 0.0,
592            water_withdrawal_violation_neg_cost: 0.0,
593            evaporation_violation_pos_cost: 0.0,
594            evaporation_violation_neg_cost: 0.0,
595            inflow_nonnegativity_cost: 1000.0,
596        };
597        Hydro {
598            id: EntityId(id),
599            name: format!("hydro-{id}"),
600            bus_id: EntityId(bus_id),
601            downstream_id: None,
602            entry_stage_id: None,
603            exit_stage_id: None,
604            min_storage_hm3: 0.0,
605            max_storage_hm3: 1.0,
606            min_outflow_m3s: 0.0,
607            max_outflow_m3s: None,
608            generation_model: HydroGenerationModel::ConstantProductivity,
609            min_turbined_m3s: 0.0,
610            max_turbined_m3s: 1.0,
611            specific_productivity_mw_per_m3s_per_m: None,
612            min_generation_mw: 0.0,
613            max_generation_mw: 1.0,
614            tailrace: None,
615            hydraulic_losses: None,
616            efficiency: None,
617            evaporation_coefficients_mm: None,
618            evaporation_reference_volumes_hm3: None,
619            diversion: None,
620            filling: None,
621            penalties: zero_penalties,
622        }
623    }
624
625    /// Creates a hydro on bus 0. Caller must supply `make_bus(0)`.
626    fn make_hydro(id: i32) -> Hydro {
627        make_hydro_on_bus(id, 0)
628    }
629
630    fn make_thermal_on_bus(id: i32, bus_id: i32) -> Thermal {
631        Thermal {
632            id: EntityId(id),
633            name: format!("thermal-{id}"),
634            bus_id: EntityId(bus_id),
635            entry_stage_id: None,
636            exit_stage_id: None,
637            cost_per_mwh: 50.0,
638            min_generation_mw: 0.0,
639            max_generation_mw: 100.0,
640            anticipated_config: None,
641        }
642    }
643
644    /// Creates a thermal on bus 0. Caller must supply `make_bus(0)`.
645    fn make_thermal(id: i32) -> Thermal {
646        make_thermal_on_bus(id, 0)
647    }
648
649    fn make_pumping_station_full(
650        id: i32,
651        bus_id: i32,
652        source_hydro_id: i32,
653        destination_hydro_id: i32,
654    ) -> PumpingStation {
655        PumpingStation {
656            id: EntityId(id),
657            name: format!("ps-{id}"),
658            bus_id: EntityId(bus_id),
659            source_hydro_id: EntityId(source_hydro_id),
660            destination_hydro_id: EntityId(destination_hydro_id),
661            entry_stage_id: None,
662            exit_stage_id: None,
663            consumption_mw_per_m3s: 0.5,
664            min_flow_m3s: 0.0,
665            max_flow_m3s: 10.0,
666        }
667    }
668
669    fn make_pumping_station(id: i32) -> PumpingStation {
670        make_pumping_station_full(id, 0, 0, 1)
671    }
672
673    fn make_contract_on_bus(id: i32, bus_id: i32) -> EnergyContract {
674        EnergyContract {
675            id: EntityId(id),
676            name: format!("contract-{id}"),
677            bus_id: EntityId(bus_id),
678            contract_type: ContractType::Import,
679            entry_stage_id: None,
680            exit_stage_id: None,
681            price_per_mwh: 0.0,
682            min_mw: 0.0,
683            max_mw: 100.0,
684        }
685    }
686
687    fn make_contract(id: i32) -> EnergyContract {
688        make_contract_on_bus(id, 0)
689    }
690
691    fn make_ncs_on_bus(id: i32, bus_id: i32) -> NonControllableSource {
692        NonControllableSource {
693            id: EntityId(id),
694            name: format!("ncs-{id}"),
695            bus_id: EntityId(bus_id),
696            entry_stage_id: None,
697            exit_stage_id: None,
698            max_generation_mw: 50.0,
699            allow_curtailment: true,
700            curtailment_cost: 0.0,
701        }
702    }
703
704    fn make_ncs(id: i32) -> NonControllableSource {
705        make_ncs_on_bus(id, 0)
706    }
707
708    #[test]
709    fn test_empty_system() {
710        let system = SystemBuilder::new().build().expect("empty system is valid");
711        assert_eq!(system.n_buses(), 0);
712        assert_eq!(system.n_lines(), 0);
713        assert_eq!(system.n_hydros(), 0);
714        assert_eq!(system.n_thermals(), 0);
715        assert_eq!(system.n_pumping_stations(), 0);
716        assert_eq!(system.n_contracts(), 0);
717        assert_eq!(system.n_non_controllable_sources(), 0);
718        assert!(system.buses().is_empty());
719        assert!(system.cascade().is_empty());
720    }
721
722    #[test]
723    fn test_canonical_ordering() {
724        // Provide buses in reverse order: id=2, id=1, id=0
725        let system = SystemBuilder::new()
726            .buses(vec![make_bus(2), make_bus(1), make_bus(0)])
727            .build()
728            .expect("valid system");
729
730        assert_eq!(system.buses()[0].id, EntityId(0));
731        assert_eq!(system.buses()[1].id, EntityId(1));
732        assert_eq!(system.buses()[2].id, EntityId(2));
733    }
734
735    #[test]
736    fn test_lookup_by_id() {
737        // Hydros reference bus id=0; supply it so cross-reference validation passes.
738        let system = SystemBuilder::new()
739            .buses(vec![make_bus(0)])
740            .hydros(vec![make_hydro(10), make_hydro(5), make_hydro(20)])
741            .build()
742            .expect("valid system");
743
744        assert_eq!(system.hydro(EntityId(5)).map(|h| h.id), Some(EntityId(5)));
745        assert_eq!(system.hydro(EntityId(10)).map(|h| h.id), Some(EntityId(10)));
746        assert_eq!(system.hydro(EntityId(20)).map(|h| h.id), Some(EntityId(20)));
747    }
748
749    #[test]
750    fn test_lookup_missing_id() {
751        // Hydros reference bus id=0; supply it so cross-reference validation passes.
752        let system = SystemBuilder::new()
753            .buses(vec![make_bus(0)])
754            .hydros(vec![make_hydro(1), make_hydro(2)])
755            .build()
756            .expect("valid system");
757
758        assert!(system.hydro(EntityId(999)).is_none());
759    }
760
761    #[test]
762    fn test_count_queries() {
763        let system = SystemBuilder::new()
764            .buses(vec![make_bus(0), make_bus(1)])
765            .lines(vec![make_line(0, 0, 1)])
766            .hydros(vec![make_hydro(0), make_hydro(1), make_hydro(2)])
767            .thermals(vec![make_thermal(0)])
768            .pumping_stations(vec![make_pumping_station(0)])
769            .contracts(vec![make_contract(0), make_contract(1)])
770            .non_controllable_sources(vec![make_ncs(0)])
771            .build()
772            .expect("valid system");
773
774        assert_eq!(system.n_buses(), 2);
775        assert_eq!(system.n_lines(), 1);
776        assert_eq!(system.n_hydros(), 3);
777        assert_eq!(system.n_thermals(), 1);
778        assert_eq!(system.n_pumping_stations(), 1);
779        assert_eq!(system.n_contracts(), 2);
780        assert_eq!(system.n_non_controllable_sources(), 1);
781    }
782
783    #[test]
784    fn test_slice_accessors() {
785        let system = SystemBuilder::new()
786            .buses(vec![make_bus(0), make_bus(1), make_bus(2)])
787            .build()
788            .expect("valid system");
789
790        let buses = system.buses();
791        assert_eq!(buses.len(), 3);
792        assert_eq!(buses[0].id, EntityId(0));
793        assert_eq!(buses[1].id, EntityId(1));
794        assert_eq!(buses[2].id, EntityId(2));
795    }
796
797    #[test]
798    fn test_duplicate_id_error() {
799        // Two buses with the same id=0 must yield an Err.
800        let result = SystemBuilder::new()
801            .buses(vec![make_bus(0), make_bus(0)])
802            .build();
803
804        assert!(result.is_err());
805        let errors = result.unwrap_err();
806        assert!(!errors.is_empty());
807        assert!(errors.iter().any(|e| matches!(
808            e,
809            ValidationError::DuplicateId {
810                entity_type: "Bus",
811                id: EntityId(0),
812            }
813        )));
814    }
815
816    #[test]
817    fn test_duplicate_stage_id_error() {
818        // Two stages with the same id must yield an Err — build_stage_index
819        // would otherwise silently overwrite the colliding stage.
820        let result = SystemBuilder::new()
821            .stages(vec![make_stage(0), make_stage(0)])
822            .build();
823
824        assert!(result.is_err());
825        let errors = result.unwrap_err();
826        assert!(errors.iter().any(|e| matches!(
827            e,
828            ValidationError::DuplicateId {
829                entity_type: "Stage",
830                id: EntityId(0),
831            }
832        )));
833    }
834
835    #[test]
836    fn test_multiple_duplicate_errors() {
837        // Duplicates in both buses (id=0) and thermals (id=5) must both be reported.
838        let result = SystemBuilder::new()
839            .buses(vec![make_bus(0), make_bus(0)])
840            .thermals(vec![make_thermal(5), make_thermal(5)])
841            .build();
842
843        assert!(result.is_err());
844        let errors = result.unwrap_err();
845
846        let has_bus_dup = errors.iter().any(|e| {
847            matches!(
848                e,
849                ValidationError::DuplicateId {
850                    entity_type: "Bus",
851                    ..
852                }
853            )
854        });
855        let has_thermal_dup = errors.iter().any(|e| {
856            matches!(
857                e,
858                ValidationError::DuplicateId {
859                    entity_type: "Thermal",
860                    ..
861                }
862            )
863        });
864        assert!(has_bus_dup, "expected Bus duplicate error");
865        assert!(has_thermal_dup, "expected Thermal duplicate error");
866    }
867
868    #[test]
869    fn test_send_sync() {
870        fn require_send_sync<T: Send + Sync>(_: T) {}
871        let system = SystemBuilder::new().build().expect("valid system");
872        require_send_sync(system);
873    }
874
875    #[test]
876    fn test_cascade_accessible() {
877        // Hydros reference bus id=0; supply it so cross-reference validation passes.
878        let mut h0 = make_hydro_on_bus(0, 0);
879        h0.downstream_id = Some(EntityId(1));
880        let mut h1 = make_hydro_on_bus(1, 0);
881        h1.downstream_id = Some(EntityId(2));
882        let h2 = make_hydro_on_bus(2, 0);
883
884        let system = SystemBuilder::new()
885            .buses(vec![make_bus(0)])
886            .hydros(vec![h0, h1, h2])
887            .build()
888            .expect("valid system");
889
890        let order = system.cascade().topological_order();
891        assert!(!order.is_empty(), "topological order must be non-empty");
892        let pos_0 = order
893            .iter()
894            .position(|&id| id == EntityId(0))
895            .expect("EntityId(0) must be in topological order");
896        let pos_2 = order
897            .iter()
898            .position(|&id| id == EntityId(2))
899            .expect("EntityId(2) must be in topological order");
900        assert!(pos_0 < pos_2, "EntityId(0) must precede EntityId(2)");
901    }
902
903    #[test]
904    fn test_network_accessible() {
905        let system = SystemBuilder::new()
906            .buses(vec![make_bus(0), make_bus(1)])
907            .lines(vec![make_line(0, 0, 1)])
908            .build()
909            .expect("valid system");
910
911        let connections = system.network().bus_lines(EntityId(0));
912        assert!(!connections.is_empty(), "bus 0 must have connections");
913        assert_eq!(connections[0].line_id, EntityId(0));
914    }
915
916    #[test]
917    fn test_all_entity_lookups() {
918        // Provide all buses and hydros that the other entities reference.
919        // - Buses 0 and 1 are needed by all entities (lines, hydros, thermals, etc.)
920        // - Hydros 0 and 1 are needed by the pumping station (source/destination)
921        // - Hydro 3 is the entity under test (lookup by id=3), on bus 0
922        let system = SystemBuilder::new()
923            .buses(vec![make_bus(0), make_bus(1)])
924            .lines(vec![make_line(2, 0, 1)])
925            .hydros(vec![
926                make_hydro_on_bus(0, 0),
927                make_hydro_on_bus(1, 0),
928                make_hydro_on_bus(3, 0),
929            ])
930            .thermals(vec![make_thermal(4)])
931            .pumping_stations(vec![make_pumping_station(5)])
932            .contracts(vec![make_contract(6)])
933            .non_controllable_sources(vec![make_ncs(7)])
934            .build()
935            .expect("valid system");
936
937        assert!(system.bus(EntityId(1)).is_some());
938        assert!(system.line(EntityId(2)).is_some());
939        assert!(system.hydro(EntityId(3)).is_some());
940        assert!(system.thermal(EntityId(4)).is_some());
941        assert!(system.pumping_station(EntityId(5)).is_some());
942        assert!(system.contract(EntityId(6)).is_some());
943        assert!(system.non_controllable_source(EntityId(7)).is_some());
944
945        assert!(system.bus(EntityId(999)).is_none());
946        assert!(system.line(EntityId(999)).is_none());
947        assert!(system.hydro(EntityId(999)).is_none());
948        assert!(system.thermal(EntityId(999)).is_none());
949        assert!(system.pumping_station(EntityId(999)).is_none());
950        assert!(system.contract(EntityId(999)).is_none());
951        assert!(system.non_controllable_source(EntityId(999)).is_none());
952    }
953
954    #[test]
955    fn test_default_builder() {
956        let system = SystemBuilder::default()
957            .build()
958            .expect("default builder produces valid empty system");
959        assert_eq!(system.n_buses(), 0);
960    }
961
962    // ---- Cross-reference validation tests -----------------------------------
963
964    #[test]
965    fn test_invalid_bus_reference_hydro() {
966        // Hydro references bus id=99 which does not exist.
967        let hydro = make_hydro_on_bus(1, 99);
968        let result = SystemBuilder::new().hydros(vec![hydro]).build();
969
970        assert!(result.is_err(), "expected Err for missing bus reference");
971        let errors = result.unwrap_err();
972        assert!(
973            errors.iter().any(|e| matches!(
974                e,
975                ValidationError::InvalidReference {
976                    source_entity_type: "Hydro",
977                    source_id: EntityId(1),
978                    field_name: "bus_id",
979                    referenced_id: EntityId(99),
980                    expected_type: "Bus",
981                }
982            )),
983            "expected InvalidReference for Hydro bus_id=99, got: {errors:?}"
984        );
985    }
986
987    #[test]
988    fn test_invalid_downstream_reference() {
989        // Hydro references downstream hydro id=50 which does not exist.
990        let bus = make_bus(0);
991        let mut hydro = make_hydro(1);
992        hydro.downstream_id = Some(EntityId(50));
993
994        let result = SystemBuilder::new()
995            .buses(vec![bus])
996            .hydros(vec![hydro])
997            .build();
998
999        assert!(
1000            result.is_err(),
1001            "expected Err for missing downstream reference"
1002        );
1003        let errors = result.unwrap_err();
1004        assert!(
1005            errors.iter().any(|e| matches!(
1006                e,
1007                ValidationError::InvalidReference {
1008                    source_entity_type: "Hydro",
1009                    source_id: EntityId(1),
1010                    field_name: "downstream_id",
1011                    referenced_id: EntityId(50),
1012                    expected_type: "Hydro",
1013                }
1014            )),
1015            "expected InvalidReference for Hydro downstream_id=50, got: {errors:?}"
1016        );
1017    }
1018
1019    #[test]
1020    fn test_invalid_pumping_station_hydro_refs() {
1021        // Pumping station references source hydro id=77 which does not exist.
1022        let bus = make_bus(0);
1023        let dest_hydro = make_hydro(1);
1024        let ps = make_pumping_station_full(10, 0, 77, 1);
1025
1026        let result = SystemBuilder::new()
1027            .buses(vec![bus])
1028            .hydros(vec![dest_hydro])
1029            .pumping_stations(vec![ps])
1030            .build();
1031
1032        assert!(
1033            result.is_err(),
1034            "expected Err for missing source_hydro_id reference"
1035        );
1036        let errors = result.unwrap_err();
1037        assert!(
1038            errors.iter().any(|e| matches!(
1039                e,
1040                ValidationError::InvalidReference {
1041                    source_entity_type: "PumpingStation",
1042                    source_id: EntityId(10),
1043                    field_name: "source_hydro_id",
1044                    referenced_id: EntityId(77),
1045                    expected_type: "Hydro",
1046                }
1047            )),
1048            "expected InvalidReference for PumpingStation source_hydro_id=77, got: {errors:?}"
1049        );
1050    }
1051
1052    #[test]
1053    fn test_multiple_invalid_references_collected() {
1054        // A line with bad source_bus_id AND a thermal with bad bus_id.
1055        // Both errors must be reported (no short-circuiting).
1056        let line = make_line(1, 99, 0);
1057        let thermal = make_thermal_on_bus(2, 88);
1058
1059        let result = SystemBuilder::new()
1060            .buses(vec![make_bus(0)])
1061            .lines(vec![line])
1062            .thermals(vec![thermal])
1063            .build();
1064
1065        assert!(
1066            result.is_err(),
1067            "expected Err for multiple invalid references"
1068        );
1069        let errors = result.unwrap_err();
1070
1071        let has_line_error = errors.iter().any(|e| {
1072            matches!(
1073                e,
1074                ValidationError::InvalidReference {
1075                    source_entity_type: "Line",
1076                    field_name: "source_bus_id",
1077                    referenced_id: EntityId(99),
1078                    ..
1079                }
1080            )
1081        });
1082        let has_thermal_error = errors.iter().any(|e| {
1083            matches!(
1084                e,
1085                ValidationError::InvalidReference {
1086                    source_entity_type: "Thermal",
1087                    field_name: "bus_id",
1088                    referenced_id: EntityId(88),
1089                    ..
1090                }
1091            )
1092        });
1093
1094        assert!(
1095            has_line_error,
1096            "expected Line source_bus_id=99 error, got: {errors:?}"
1097        );
1098        assert!(
1099            has_thermal_error,
1100            "expected Thermal bus_id=88 error, got: {errors:?}"
1101        );
1102        assert!(
1103            errors.len() >= 2,
1104            "expected at least 2 errors, got {}: {errors:?}",
1105            errors.len()
1106        );
1107    }
1108
1109    #[test]
1110    fn test_valid_cross_references_pass() {
1111        // All cross-references point to entities that exist — build must succeed.
1112        let bus_0 = make_bus(0);
1113        let bus_1 = make_bus(1);
1114        let h0 = make_hydro_on_bus(0, 0);
1115        let h1 = make_hydro_on_bus(1, 1);
1116        let mut h2 = make_hydro_on_bus(2, 0);
1117        h2.downstream_id = Some(EntityId(1));
1118        let line = make_line(10, 0, 1);
1119        let thermal = make_thermal_on_bus(20, 0);
1120        let ps = make_pumping_station_full(30, 0, 0, 1);
1121        let contract = make_contract_on_bus(40, 1);
1122        let ncs = make_ncs_on_bus(50, 0);
1123
1124        let result = SystemBuilder::new()
1125            .buses(vec![bus_0, bus_1])
1126            .lines(vec![line])
1127            .hydros(vec![h0, h1, h2])
1128            .thermals(vec![thermal])
1129            .pumping_stations(vec![ps])
1130            .contracts(vec![contract])
1131            .non_controllable_sources(vec![ncs])
1132            .build();
1133
1134        assert!(
1135            result.is_ok(),
1136            "expected Ok for all valid cross-references, got: {:?}",
1137            result.unwrap_err()
1138        );
1139        let system = result.unwrap_or_else(|_| unreachable!());
1140        assert_eq!(system.n_buses(), 2);
1141        assert_eq!(system.n_hydros(), 3);
1142        assert_eq!(system.n_lines(), 1);
1143        assert_eq!(system.n_thermals(), 1);
1144        assert_eq!(system.n_pumping_stations(), 1);
1145        assert_eq!(system.n_contracts(), 1);
1146        assert_eq!(system.n_non_controllable_sources(), 1);
1147    }
1148
1149    // ---- Cascade cycle detection tests --------------------------------------
1150
1151    #[test]
1152    fn test_cascade_cycle_detected() {
1153        // Three-node cycle: A(0)->B(1)->C(2)->A(0).
1154        // All three reference a common bus (bus 0).
1155        let bus = make_bus(0);
1156        let mut h0 = make_hydro(0);
1157        h0.downstream_id = Some(EntityId(1));
1158        let mut h1 = make_hydro(1);
1159        h1.downstream_id = Some(EntityId(2));
1160        let mut h2 = make_hydro(2);
1161        h2.downstream_id = Some(EntityId(0));
1162
1163        let result = SystemBuilder::new()
1164            .buses(vec![bus])
1165            .hydros(vec![h0, h1, h2])
1166            .build();
1167
1168        assert!(result.is_err(), "expected Err for 3-node cycle");
1169        let errors = result.unwrap_err();
1170        let cycle_error = errors
1171            .iter()
1172            .find(|e| matches!(e, ValidationError::CascadeCycle { .. }));
1173        assert!(
1174            cycle_error.is_some(),
1175            "expected CascadeCycle error, got: {errors:?}"
1176        );
1177        let ValidationError::CascadeCycle { cycle_ids } = cycle_error.unwrap() else {
1178            unreachable!()
1179        };
1180        assert_eq!(
1181            cycle_ids,
1182            &[EntityId(0), EntityId(1), EntityId(2)],
1183            "cycle_ids must be sorted ascending, got: {cycle_ids:?}"
1184        );
1185    }
1186
1187    #[test]
1188    fn test_cascade_self_loop_detected() {
1189        // Single hydro pointing to itself: A(0)->A(0).
1190        let bus = make_bus(0);
1191        let mut h0 = make_hydro(0);
1192        h0.downstream_id = Some(EntityId(0));
1193
1194        let result = SystemBuilder::new()
1195            .buses(vec![bus])
1196            .hydros(vec![h0])
1197            .build();
1198
1199        assert!(result.is_err(), "expected Err for self-loop");
1200        let errors = result.unwrap_err();
1201        let has_cycle = errors
1202            .iter()
1203            .any(|e| matches!(e, ValidationError::CascadeCycle { cycle_ids } if cycle_ids.contains(&EntityId(0))));
1204        assert!(
1205            has_cycle,
1206            "expected CascadeCycle containing EntityId(0), got: {errors:?}"
1207        );
1208    }
1209
1210    #[test]
1211    fn test_valid_acyclic_cascade_passes() {
1212        // Linear acyclic cascade A(0)->B(1)->C(2).
1213        // Verifies that a valid cascade produces Ok with correct topological_order length.
1214        let bus = make_bus(0);
1215        let mut h0 = make_hydro(0);
1216        h0.downstream_id = Some(EntityId(1));
1217        let mut h1 = make_hydro(1);
1218        h1.downstream_id = Some(EntityId(2));
1219        let h2 = make_hydro(2);
1220
1221        let result = SystemBuilder::new()
1222            .buses(vec![bus])
1223            .hydros(vec![h0, h1, h2])
1224            .build();
1225
1226        assert!(
1227            result.is_ok(),
1228            "expected Ok for acyclic cascade, got: {:?}",
1229            result.unwrap_err()
1230        );
1231        let system = result.unwrap_or_else(|_| unreachable!());
1232        assert_eq!(
1233            system.cascade().topological_order().len(),
1234            system.n_hydros(),
1235            "topological_order must contain all hydros"
1236        );
1237    }
1238
1239    // ---- Filling config validation tests ------------------------------------
1240
1241    #[test]
1242    fn test_filling_without_entry_stage() {
1243        // Filling config present but entry_stage_id is None.
1244        use crate::entities::FillingConfig;
1245        let bus = make_bus(0);
1246        let mut hydro = make_hydro(1);
1247        hydro.entry_stage_id = None;
1248        hydro.filling = Some(FillingConfig {
1249            start_stage_id: 10,
1250            filling_inflow_m3s: 100.0,
1251        });
1252
1253        let result = SystemBuilder::new()
1254            .buses(vec![bus])
1255            .hydros(vec![hydro])
1256            .build();
1257
1258        assert!(
1259            result.is_err(),
1260            "expected Err for filling without entry_stage_id"
1261        );
1262        let errors = result.unwrap_err();
1263        let has_error = errors.iter().any(|e| match e {
1264            ValidationError::InvalidFillingConfig { hydro_id, reason } => {
1265                *hydro_id == EntityId(1) && reason.contains("entry_stage_id")
1266            }
1267            _ => false,
1268        });
1269        assert!(
1270            has_error,
1271            "expected InvalidFillingConfig with entry_stage_id reason, got: {errors:?}"
1272        );
1273    }
1274
1275    #[test]
1276    fn test_filling_negative_inflow() {
1277        // Filling config with filling_inflow_m3s <= 0.0.
1278        use crate::entities::FillingConfig;
1279        let bus = make_bus(0);
1280        let mut hydro = make_hydro(1);
1281        hydro.entry_stage_id = Some(10);
1282        hydro.filling = Some(FillingConfig {
1283            start_stage_id: 10,
1284            filling_inflow_m3s: -5.0,
1285        });
1286
1287        let result = SystemBuilder::new()
1288            .buses(vec![bus])
1289            .hydros(vec![hydro])
1290            .build();
1291
1292        assert!(
1293            result.is_err(),
1294            "expected Err for negative filling_inflow_m3s"
1295        );
1296        let errors = result.unwrap_err();
1297        let has_error = errors.iter().any(|e| match e {
1298            ValidationError::InvalidFillingConfig { hydro_id, reason } => {
1299                *hydro_id == EntityId(1) && reason.contains("filling_inflow_m3s must be positive")
1300            }
1301            _ => false,
1302        });
1303        assert!(
1304            has_error,
1305            "expected InvalidFillingConfig with positive inflow reason, got: {errors:?}"
1306        );
1307    }
1308
1309    #[test]
1310    fn test_valid_filling_config_passes() {
1311        // Valid filling config: entry_stage_id set and filling_inflow_m3s positive.
1312        use crate::entities::FillingConfig;
1313        let bus = make_bus(0);
1314        let mut hydro = make_hydro(1);
1315        hydro.entry_stage_id = Some(10);
1316        hydro.filling = Some(FillingConfig {
1317            start_stage_id: 10,
1318            filling_inflow_m3s: 100.0,
1319        });
1320
1321        let result = SystemBuilder::new()
1322            .buses(vec![bus])
1323            .hydros(vec![hydro])
1324            .build();
1325
1326        assert!(
1327            result.is_ok(),
1328            "expected Ok for valid filling config, got: {:?}",
1329            result.unwrap_err()
1330        );
1331    }
1332
1333    #[test]
1334    fn test_cascade_cycle_and_invalid_filling_both_reported() {
1335        // Both a cascade cycle (A->A self-loop) AND an invalid filling config
1336        // must produce both error variants.
1337        use crate::entities::FillingConfig;
1338        let bus = make_bus(0);
1339
1340        // Hydro 0: self-loop (cycle)
1341        let mut h0 = make_hydro(0);
1342        h0.downstream_id = Some(EntityId(0));
1343
1344        // Hydro 1: valid cycle participant? No -- use a separate hydro with invalid filling.
1345        let mut h1 = make_hydro(1);
1346        h1.entry_stage_id = None; // no entry_stage_id
1347        h1.filling = Some(FillingConfig {
1348            start_stage_id: 5,
1349            filling_inflow_m3s: 50.0,
1350        });
1351
1352        let result = SystemBuilder::new()
1353            .buses(vec![bus])
1354            .hydros(vec![h0, h1])
1355            .build();
1356
1357        assert!(result.is_err(), "expected Err for cycle + invalid filling");
1358        let errors = result.unwrap_err();
1359        let has_cycle = errors
1360            .iter()
1361            .any(|e| matches!(e, ValidationError::CascadeCycle { .. }));
1362        let has_filling = errors
1363            .iter()
1364            .any(|e| matches!(e, ValidationError::InvalidFillingConfig { .. }));
1365        assert!(has_cycle, "expected CascadeCycle error, got: {errors:?}");
1366        assert!(
1367            has_filling,
1368            "expected InvalidFillingConfig error, got: {errors:?}"
1369        );
1370    }
1371
1372    #[cfg(feature = "serde")]
1373    #[test]
1374    fn test_system_serde_roundtrip() {
1375        // Build a system with a bus, a hydro, a line, and a thermal.
1376        let bus_a = make_bus(1);
1377        let bus_b = make_bus(2);
1378        let hydro = make_hydro_on_bus(10, 1);
1379        let thermal = make_thermal_on_bus(20, 2);
1380        let line = make_line(1, 1, 2);
1381
1382        let system = SystemBuilder::new()
1383            .buses(vec![bus_a, bus_b])
1384            .hydros(vec![hydro])
1385            .thermals(vec![thermal])
1386            .lines(vec![line])
1387            .build()
1388            .expect("valid system");
1389
1390        let json = serde_json::to_string(&system).unwrap();
1391
1392        // Deserialize and rebuild indices.
1393        let mut deserialized: System = serde_json::from_str(&json).unwrap();
1394        deserialized.rebuild_indices();
1395
1396        // Entity collections must match.
1397        assert_eq!(system.buses(), deserialized.buses());
1398        assert_eq!(system.hydros(), deserialized.hydros());
1399        assert_eq!(system.thermals(), deserialized.thermals());
1400        assert_eq!(system.lines(), deserialized.lines());
1401
1402        // O(1) lookup must work after index rebuild.
1403        assert_eq!(
1404            deserialized.bus(EntityId(1)).map(|b| b.id),
1405            Some(EntityId(1))
1406        );
1407        assert_eq!(
1408            deserialized.hydro(EntityId(10)).map(|h| h.id),
1409            Some(EntityId(10))
1410        );
1411        assert_eq!(
1412            deserialized.thermal(EntityId(20)).map(|t| t.id),
1413            Some(EntityId(20))
1414        );
1415        assert_eq!(
1416            deserialized.line(EntityId(1)).map(|l| l.id),
1417            Some(EntityId(1))
1418        );
1419    }
1420
1421    // ---- Extended System tests ----------------------------------------------
1422
1423    fn make_stage(id: i32) -> Stage {
1424        use crate::temporal::{
1425            Block, BlockMode, NoiseMethod, ScenarioSourceConfig, StageRiskConfig, StageStateConfig,
1426        };
1427        use chrono::NaiveDate;
1428        Stage {
1429            index: usize::try_from(id.max(0)).unwrap_or(0),
1430            id,
1431            start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1432            end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
1433            season_id: Some(0),
1434            blocks: vec![Block {
1435                index: 0,
1436                name: "SINGLE".to_string(),
1437                duration_hours: 744.0,
1438            }],
1439            block_mode: BlockMode::Parallel,
1440            state_config: StageStateConfig {
1441                storage: true,
1442                inflow_lags: false,
1443            },
1444            risk_config: StageRiskConfig::Expectation,
1445            scenario_config: ScenarioSourceConfig {
1446                branching_factor: 50,
1447                noise_method: NoiseMethod::Saa,
1448            },
1449        }
1450    }
1451
1452    /// Verify that `SystemBuilder::new().build()` still works correctly.
1453    /// New fields must default to empty/default values.
1454    #[test]
1455    fn test_system_backward_compat() {
1456        let system = SystemBuilder::new().build().expect("empty system is valid");
1457        // Entity counts unchanged
1458        assert_eq!(system.n_buses(), 0);
1459        assert_eq!(system.n_hydros(), 0);
1460        // New fields default to empty
1461        assert_eq!(system.n_stages(), 0);
1462        assert!(system.stages().is_empty());
1463        assert!(system.initial_conditions().storage.is_empty());
1464        assert!(system.generic_constraints().is_empty());
1465        assert!(system.inflow_models().is_empty());
1466        assert!(system.load_models().is_empty());
1467        assert_eq!(system.penalties().n_stages(), 0);
1468        assert_eq!(system.bounds().n_stages(), 0);
1469        // Generic constraint bounds default to empty.
1470        assert!(!system.resolved_generic_bounds().is_active(0, 0));
1471        assert!(
1472            system
1473                .resolved_generic_bounds()
1474                .bounds_for_stage(0, 0)
1475                .is_empty()
1476        );
1477    }
1478
1479    /// Verify `System::resolved_generic_bounds()` accessor with a non-empty table.
1480    #[test]
1481    fn test_system_resolved_generic_bounds_accessor() {
1482        use crate::resolved::ResolvedGenericConstraintBounds;
1483        use std::collections::HashMap as StdHashMap;
1484
1485        let id_map: StdHashMap<i32, usize> = [(0, 0), (1, 1)].into_iter().collect();
1486        let rows = vec![(0i32, 0i32, None::<i32>, 100.0f64)];
1487        let table = ResolvedGenericConstraintBounds::new(&id_map, rows.into_iter());
1488
1489        let system = SystemBuilder::new()
1490            .resolved_generic_bounds(table)
1491            .build()
1492            .expect("valid system");
1493
1494        assert!(system.resolved_generic_bounds().is_active(0, 0));
1495        assert!(!system.resolved_generic_bounds().is_active(1, 0));
1496        let slice = system.resolved_generic_bounds().bounds_for_stage(0, 0);
1497        assert_eq!(slice.len(), 1);
1498        assert_eq!(slice[0], (None, 100.0));
1499    }
1500
1501    /// Build a System with 2 stages and verify `n_stages()` and `stage(id)` lookup.
1502    #[test]
1503    fn test_system_with_stages() {
1504        let s0 = make_stage(0);
1505        let s1 = make_stage(1);
1506
1507        let system = SystemBuilder::new()
1508            .stages(vec![s1.clone(), s0.clone()]) // supply in reverse order
1509            .build()
1510            .expect("valid system");
1511
1512        // Canonical ordering: id=0 comes before id=1
1513        assert_eq!(system.n_stages(), 2);
1514        assert_eq!(system.stages()[0].id, 0);
1515        assert_eq!(system.stages()[1].id, 1);
1516
1517        // O(1) lookup by stage id
1518        let found = system.stage(0).expect("stage 0 must be found");
1519        assert_eq!(found.id, s0.id);
1520
1521        let found1 = system.stage(1).expect("stage 1 must be found");
1522        assert_eq!(found1.id, s1.id);
1523
1524        // Missing stage returns None
1525        assert!(system.stage(99).is_none());
1526    }
1527
1528    /// Build a System with 3 stages having IDs 0, 1, 2 and verify `stage()` lookups.
1529    #[test]
1530    fn test_system_stage_lookup_by_id() {
1531        let stages: Vec<Stage> = [0i32, 1, 2].iter().map(|&id| make_stage(id)).collect();
1532
1533        let system = SystemBuilder::new()
1534            .stages(stages)
1535            .build()
1536            .expect("valid system");
1537
1538        assert_eq!(system.stage(1).map(|s| s.id), Some(1));
1539        assert!(system.stage(99).is_none());
1540    }
1541
1542    /// Build a System with `InitialConditions` containing 1 storage entry and verify accessor.
1543    #[test]
1544    fn test_system_with_initial_conditions() {
1545        let ic = InitialConditions {
1546            storage: vec![crate::HydroStorage {
1547                hydro_id: EntityId(0),
1548                value_hm3: 15_000.0,
1549            }],
1550            filling_storage: vec![],
1551            past_inflows: vec![],
1552            past_anticipated_commitments: vec![],
1553            recent_observations: vec![],
1554        };
1555
1556        let system = SystemBuilder::new()
1557            .initial_conditions(ic)
1558            .build()
1559            .expect("valid system");
1560
1561        assert_eq!(system.initial_conditions().storage.len(), 1);
1562        assert_eq!(system.initial_conditions().storage[0].hydro_id, EntityId(0));
1563        assert!((system.initial_conditions().storage[0].value_hm3 - 15_000.0).abs() < f64::EPSILON);
1564    }
1565
1566    /// Verify serde round-trip of a System with stages and `policy_graph`,
1567    /// including that `stage_index` is correctly rebuilt after deserialization.
1568    #[cfg(feature = "serde")]
1569    #[test]
1570    fn test_system_serde_roundtrip_with_stages() {
1571        use crate::temporal::PolicyGraphType;
1572
1573        let stages = vec![make_stage(0), make_stage(1)];
1574        let policy_graph = PolicyGraph {
1575            graph_type: PolicyGraphType::FiniteHorizon,
1576            annual_discount_rate: 0.0,
1577            transitions: vec![],
1578            season_map: None,
1579        };
1580
1581        let system = SystemBuilder::new()
1582            .stages(stages)
1583            .policy_graph(policy_graph)
1584            .build()
1585            .expect("valid system");
1586
1587        let json = serde_json::to_string(&system).unwrap();
1588        let mut deserialized: System = serde_json::from_str(&json).unwrap();
1589
1590        // stage_index is skipped during serde; rebuild before querying
1591        deserialized.rebuild_indices();
1592
1593        // Collections must match after round-trip
1594        assert_eq!(system.n_stages(), deserialized.n_stages());
1595        assert_eq!(system.stages()[0].id, deserialized.stages()[0].id);
1596        assert_eq!(system.stages()[1].id, deserialized.stages()[1].id);
1597
1598        // O(1) lookup must work after index rebuild
1599        assert_eq!(deserialized.stage(0).map(|s| s.id), Some(0));
1600        assert_eq!(deserialized.stage(1).map(|s| s.id), Some(1));
1601        assert!(deserialized.stage(99).is_none());
1602
1603        // policy_graph fields must round-trip
1604        assert_eq!(
1605            deserialized.policy_graph().graph_type,
1606            system.policy_graph().graph_type
1607        );
1608    }
1609
1610    // ---- inflow_history and external_scenarios field tests ------------------
1611
1612    /// Given a `SystemBuilder` with no `.inflow_history()` call, `inflow_history()`
1613    /// must return an empty slice.
1614    #[test]
1615    fn test_system_inflow_history_defaults_empty() {
1616        let system = SystemBuilder::new().build().expect("valid system");
1617        assert!(
1618            system.inflow_history().is_empty(),
1619            "inflow_history must default to empty"
1620        );
1621    }
1622
1623    /// Given a `SystemBuilder` with `.inflow_history(rows)`, the system must store
1624    /// and return the same rows via `inflow_history()`.
1625    #[test]
1626    fn test_system_inflow_history_stores_rows() {
1627        use crate::scenario::InflowHistoryRow;
1628        use chrono::NaiveDate;
1629
1630        let row1 = InflowHistoryRow {
1631            hydro_id: EntityId(1),
1632            date: NaiveDate::from_ymd_opt(2000, 1, 1).expect("valid date"),
1633            value_m3s: 500.0,
1634        };
1635        let row2 = InflowHistoryRow {
1636            hydro_id: EntityId(1),
1637            date: NaiveDate::from_ymd_opt(2000, 2, 1).expect("valid date"),
1638            value_m3s: 420.0,
1639        };
1640
1641        let system = SystemBuilder::new()
1642            .inflow_history(vec![row1.clone(), row2.clone()])
1643            .build()
1644            .expect("valid system");
1645
1646        assert_eq!(system.inflow_history().len(), 2);
1647        assert_eq!(system.inflow_history()[0], row1);
1648        assert_eq!(system.inflow_history()[1], row2);
1649    }
1650
1651    /// Given a `SystemBuilder` with no `.external_scenarios()` call,
1652    /// `external_scenarios()` must return an empty slice.
1653    #[test]
1654    fn test_system_external_scenarios_defaults_empty() {
1655        let system = SystemBuilder::new().build().expect("valid system");
1656        assert!(
1657            system.external_scenarios().is_empty(),
1658            "external_scenarios must default to empty"
1659        );
1660    }
1661
1662    /// Given a `SystemBuilder` with `.external_scenarios(rows)`, the system must
1663    /// store and return the same rows via `external_scenarios()`.
1664    #[test]
1665    fn test_system_external_scenarios_stores_rows() {
1666        use crate::scenario::ExternalScenarioRow;
1667
1668        let row = ExternalScenarioRow {
1669            stage_id: 0,
1670            scenario_id: 2,
1671            hydro_id: EntityId(5),
1672            value_m3s: 320.5,
1673        };
1674
1675        let system = SystemBuilder::new()
1676            .external_scenarios(vec![row.clone()])
1677            .build()
1678            .expect("valid system");
1679
1680        assert_eq!(system.external_scenarios().len(), 1);
1681        assert_eq!(system.external_scenarios()[0], row);
1682    }
1683}