1use 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#[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 #[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 cascade: CascadeTopology,
86 network: NetworkTopology,
88
89 stages: Vec<Stage>,
91 policy_graph: HorizonGraph,
93
94 #[cfg_attr(feature = "serde", serde(skip))]
95 stage_index: HashMap<i32, usize>,
96
97 penalties: ResolvedPenalties,
99 bounds: ResolvedBounds,
101 resolved_generic_bounds: ResolvedGenericConstraintBounds,
103 resolved_load_factors: ResolvedLoadFactors,
105 resolved_ncs_bounds: ResolvedNcsBounds,
107 resolved_ncs_factors: ResolvedNcsFactors,
109
110 inflow_models: Vec<InflowModel>,
112 load_models: Vec<LoadModel>,
114 ncs_models: Vec<NcsModel>,
116 correlation: CorrelationModel,
118
119 initial_conditions: InitialConditions,
121 generic_constraints: Vec<GenericConstraint>,
123
124 inflow_history: Vec<InflowHistoryRow>,
126 external_scenarios: Vec<ExternalScenarioRow>,
128 external_load_scenarios: Vec<ExternalLoadRow>,
130 external_ncs_scenarios: Vec<ExternalNcsRow>,
132
133 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#[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 #[must_use]
229 pub fn buses(&self) -> &[Bus] {
230 &self.buses
231 }
232
233 #[must_use]
235 pub fn lines(&self) -> &[Line] {
236 &self.lines
237 }
238
239 #[must_use]
241 pub fn hydros(&self) -> &[Hydro] {
242 &self.hydros
243 }
244
245 #[must_use]
247 pub fn thermals(&self) -> &[Thermal] {
248 &self.thermals
249 }
250
251 #[must_use]
253 pub fn pumping_stations(&self) -> &[PumpingStation] {
254 &self.pumping_stations
255 }
256
257 #[must_use]
259 pub fn contracts(&self) -> &[EnergyContract] {
260 &self.contracts
261 }
262
263 #[must_use]
265 pub fn non_controllable_sources(&self) -> &[NonControllableSource] {
266 &self.non_controllable_sources
267 }
268
269 #[must_use]
271 pub fn n_buses(&self) -> usize {
272 self.buses.len()
273 }
274
275 #[must_use]
277 pub fn n_lines(&self) -> usize {
278 self.lines.len()
279 }
280
281 #[must_use]
283 pub fn n_hydros(&self) -> usize {
284 self.hydros.len()
285 }
286
287 #[must_use]
289 pub fn n_thermals(&self) -> usize {
290 self.thermals.len()
291 }
292
293 #[must_use]
295 pub fn n_pumping_stations(&self) -> usize {
296 self.pumping_stations.len()
297 }
298
299 #[must_use]
301 pub fn n_contracts(&self) -> usize {
302 self.contracts.len()
303 }
304
305 #[must_use]
307 pub fn n_non_controllable_sources(&self) -> usize {
308 self.non_controllable_sources.len()
309 }
310
311 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
359 pub fn cascade(&self) -> &CascadeTopology {
360 &self.cascade
361 }
362
363 #[must_use]
365 pub fn network(&self) -> &NetworkTopology {
366 &self.network
367 }
368
369 #[must_use]
371 pub fn stages(&self) -> &[Stage] {
372 &self.stages
373 }
374
375 #[must_use]
377 pub fn n_stages(&self) -> usize {
378 self.stages.len()
379 }
380
381 #[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 #[must_use]
392 pub fn policy_graph(&self) -> &HorizonGraph {
393 &self.policy_graph
394 }
395
396 #[must_use]
398 pub fn penalties(&self) -> &ResolvedPenalties {
399 &self.penalties
400 }
401
402 #[must_use]
404 pub fn bounds(&self) -> &ResolvedBounds {
405 &self.bounds
406 }
407
408 #[must_use]
410 pub fn resolved_generic_bounds(&self) -> &ResolvedGenericConstraintBounds {
411 &self.resolved_generic_bounds
412 }
413
414 #[must_use]
416 pub fn resolved_load_factors(&self) -> &ResolvedLoadFactors {
417 &self.resolved_load_factors
418 }
419
420 #[must_use]
422 pub fn resolved_ncs_bounds(&self) -> &ResolvedNcsBounds {
423 &self.resolved_ncs_bounds
424 }
425
426 #[must_use]
428 pub fn resolved_ncs_factors(&self) -> &ResolvedNcsFactors {
429 &self.resolved_ncs_factors
430 }
431
432 #[must_use]
434 pub fn inflow_models(&self) -> &[InflowModel] {
435 &self.inflow_models
436 }
437
438 #[must_use]
440 pub fn load_models(&self) -> &[LoadModel] {
441 &self.load_models
442 }
443
444 #[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 #[must_use]
468 pub fn ncs_models(&self) -> &[NcsModel] {
469 &self.ncs_models
470 }
471
472 #[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 #[must_use]
492 pub fn correlation(&self) -> &CorrelationModel {
493 &self.correlation
494 }
495
496 #[must_use]
498 pub fn initial_conditions(&self) -> &InitialConditions {
499 &self.initial_conditions
500 }
501
502 #[must_use]
504 pub fn generic_constraints(&self) -> &[GenericConstraint] {
505 &self.generic_constraints
506 }
507
508 #[must_use]
513 pub fn inflow_history(&self) -> &[InflowHistoryRow] {
514 &self.inflow_history
515 }
516
517 #[must_use]
521 pub fn external_scenarios(&self) -> &[ExternalScenarioRow] {
522 &self.external_scenarios
523 }
524
525 #[must_use]
529 pub fn external_load_scenarios(&self) -> &[ExternalLoadRow] {
530 &self.external_load_scenarios
531 }
532
533 #[must_use]
537 pub fn external_ncs_scenarios(&self) -> &[ExternalNcsRow] {
538 &self.external_ncs_scenarios
539 }
540
541 #[must_use]
544 pub fn post_study_stages(&self) -> Option<&PostStudyStages> {
545 self.post_study_stages.as_ref()
546 }
547
548 #[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 #[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 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 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 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 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 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 #[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 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 #[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 #[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 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 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 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 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 assert_eq!(system, deserialized);
2138 }
2139
2140 #[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}