Skip to main content

cobre_core/system/
mod.rs

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