1use std::collections::HashMap;
31
32use crate::{
33 Bus, CascadeTopology, CorrelationModel, EnergyContract, EntityId, ExternalLoadRow,
34 ExternalNcsRow, ExternalScenarioRow, GenericConstraint, Hydro, InflowHistoryRow, InflowModel,
35 InitialConditions, Line, LoadModel, NcsModel, NetworkTopology, NonControllableSource,
36 PolicyGraph, PumpingStation, ResolvedBounds, ResolvedExchangeFactors,
37 ResolvedGenericConstraintBounds, ResolvedLoadFactors, ResolvedNcsBounds, ResolvedNcsFactors,
38 ResolvedPenalties, Stage, Thermal,
39};
40
41mod builder;
42mod validate;
43
44pub use builder::SystemBuilder;
45
46use validate::{build_index, build_stage_index};
47
48#[derive(Debug, PartialEq)]
78#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
79pub struct System {
80 buses: Vec<Bus>,
82 lines: Vec<Line>,
83 hydros: Vec<Hydro>,
84 thermals: Vec<Thermal>,
85 pumping_stations: Vec<PumpingStation>,
86 contracts: Vec<EnergyContract>,
87 non_controllable_sources: Vec<NonControllableSource>,
88
89 #[cfg_attr(feature = "serde", serde(skip))]
93 bus_index: HashMap<EntityId, usize>,
94 #[cfg_attr(feature = "serde", serde(skip))]
95 line_index: HashMap<EntityId, usize>,
96 #[cfg_attr(feature = "serde", serde(skip))]
97 hydro_index: HashMap<EntityId, usize>,
98 #[cfg_attr(feature = "serde", serde(skip))]
99 thermal_index: HashMap<EntityId, usize>,
100 #[cfg_attr(feature = "serde", serde(skip))]
101 pumping_station_index: HashMap<EntityId, usize>,
102 #[cfg_attr(feature = "serde", serde(skip))]
103 contract_index: HashMap<EntityId, usize>,
104 #[cfg_attr(feature = "serde", serde(skip))]
105 non_controllable_source_index: HashMap<EntityId, usize>,
106
107 cascade: CascadeTopology,
110 network: NetworkTopology,
112
113 stages: Vec<Stage>,
116 policy_graph: PolicyGraph,
118
119 #[cfg_attr(feature = "serde", serde(skip))]
123 stage_index: HashMap<i32, usize>,
124
125 penalties: ResolvedPenalties,
128 bounds: ResolvedBounds,
130 resolved_generic_bounds: ResolvedGenericConstraintBounds,
132 resolved_load_factors: ResolvedLoadFactors,
134 resolved_exchange_factors: ResolvedExchangeFactors,
136 resolved_ncs_bounds: ResolvedNcsBounds,
138 resolved_ncs_factors: ResolvedNcsFactors,
140
141 inflow_models: Vec<InflowModel>,
144 load_models: Vec<LoadModel>,
146 ncs_models: Vec<NcsModel>,
148 correlation: CorrelationModel,
150
151 initial_conditions: InitialConditions,
154 generic_constraints: Vec<GenericConstraint>,
156
157 inflow_history: Vec<InflowHistoryRow>,
162 external_scenarios: Vec<ExternalScenarioRow>,
166 external_load_scenarios: Vec<ExternalLoadRow>,
170 external_ncs_scenarios: Vec<ExternalNcsRow>,
174}
175
176const _: () = {
178 const fn assert_send_sync<T: Send + Sync>() {}
179 const fn check() {
180 assert_send_sync::<System>();
181 }
182 let _ = check;
183};
184
185impl System {
186 #[must_use]
188 pub fn buses(&self) -> &[Bus] {
189 &self.buses
190 }
191
192 #[must_use]
194 pub fn lines(&self) -> &[Line] {
195 &self.lines
196 }
197
198 #[must_use]
200 pub fn hydros(&self) -> &[Hydro] {
201 &self.hydros
202 }
203
204 #[must_use]
206 pub fn thermals(&self) -> &[Thermal] {
207 &self.thermals
208 }
209
210 #[must_use]
212 pub fn pumping_stations(&self) -> &[PumpingStation] {
213 &self.pumping_stations
214 }
215
216 #[must_use]
218 pub fn contracts(&self) -> &[EnergyContract] {
219 &self.contracts
220 }
221
222 #[must_use]
224 pub fn non_controllable_sources(&self) -> &[NonControllableSource] {
225 &self.non_controllable_sources
226 }
227
228 #[must_use]
230 pub fn n_buses(&self) -> usize {
231 self.buses.len()
232 }
233
234 #[must_use]
236 pub fn n_lines(&self) -> usize {
237 self.lines.len()
238 }
239
240 #[must_use]
242 pub fn n_hydros(&self) -> usize {
243 self.hydros.len()
244 }
245
246 #[must_use]
248 pub fn n_thermals(&self) -> usize {
249 self.thermals.len()
250 }
251
252 #[must_use]
254 pub fn n_pumping_stations(&self) -> usize {
255 self.pumping_stations.len()
256 }
257
258 #[must_use]
260 pub fn n_contracts(&self) -> usize {
261 self.contracts.len()
262 }
263
264 #[must_use]
266 pub fn n_non_controllable_sources(&self) -> usize {
267 self.non_controllable_sources.len()
268 }
269
270 #[must_use]
272 pub fn bus(&self, id: EntityId) -> Option<&Bus> {
273 self.bus_index.get(&id).map(|&i| &self.buses[i])
274 }
275
276 #[must_use]
278 pub fn line(&self, id: EntityId) -> Option<&Line> {
279 self.line_index.get(&id).map(|&i| &self.lines[i])
280 }
281
282 #[must_use]
284 pub fn hydro(&self, id: EntityId) -> Option<&Hydro> {
285 self.hydro_index.get(&id).map(|&i| &self.hydros[i])
286 }
287
288 #[must_use]
290 pub fn thermal(&self, id: EntityId) -> Option<&Thermal> {
291 self.thermal_index.get(&id).map(|&i| &self.thermals[i])
292 }
293
294 #[must_use]
296 pub fn pumping_station(&self, id: EntityId) -> Option<&PumpingStation> {
297 self.pumping_station_index
298 .get(&id)
299 .map(|&i| &self.pumping_stations[i])
300 }
301
302 #[must_use]
304 pub fn contract(&self, id: EntityId) -> Option<&EnergyContract> {
305 self.contract_index.get(&id).map(|&i| &self.contracts[i])
306 }
307
308 #[must_use]
310 pub fn non_controllable_source(&self, id: EntityId) -> Option<&NonControllableSource> {
311 self.non_controllable_source_index
312 .get(&id)
313 .map(|&i| &self.non_controllable_sources[i])
314 }
315
316 #[must_use]
318 pub fn cascade(&self) -> &CascadeTopology {
319 &self.cascade
320 }
321
322 #[must_use]
324 pub fn network(&self) -> &NetworkTopology {
325 &self.network
326 }
327
328 #[must_use]
330 pub fn stages(&self) -> &[Stage] {
331 &self.stages
332 }
333
334 #[must_use]
336 pub fn n_stages(&self) -> usize {
337 self.stages.len()
338 }
339
340 #[must_use]
345 pub fn stage(&self, id: i32) -> Option<&Stage> {
346 self.stage_index.get(&id).map(|&i| &self.stages[i])
347 }
348
349 #[must_use]
351 pub fn policy_graph(&self) -> &PolicyGraph {
352 &self.policy_graph
353 }
354
355 #[must_use]
357 pub fn penalties(&self) -> &ResolvedPenalties {
358 &self.penalties
359 }
360
361 #[must_use]
363 pub fn bounds(&self) -> &ResolvedBounds {
364 &self.bounds
365 }
366
367 #[must_use]
369 pub fn resolved_generic_bounds(&self) -> &ResolvedGenericConstraintBounds {
370 &self.resolved_generic_bounds
371 }
372
373 #[must_use]
375 pub fn resolved_load_factors(&self) -> &ResolvedLoadFactors {
376 &self.resolved_load_factors
377 }
378
379 #[must_use]
381 pub fn resolved_exchange_factors(&self) -> &ResolvedExchangeFactors {
382 &self.resolved_exchange_factors
383 }
384
385 #[must_use]
387 pub fn resolved_ncs_bounds(&self) -> &ResolvedNcsBounds {
388 &self.resolved_ncs_bounds
389 }
390
391 #[must_use]
393 pub fn resolved_ncs_factors(&self) -> &ResolvedNcsFactors {
394 &self.resolved_ncs_factors
395 }
396
397 #[must_use]
399 pub fn inflow_models(&self) -> &[InflowModel] {
400 &self.inflow_models
401 }
402
403 #[must_use]
405 pub fn load_models(&self) -> &[LoadModel] {
406 &self.load_models
407 }
408
409 #[must_use]
411 pub fn ncs_models(&self) -> &[NcsModel] {
412 &self.ncs_models
413 }
414
415 #[must_use]
417 pub fn correlation(&self) -> &CorrelationModel {
418 &self.correlation
419 }
420
421 #[must_use]
423 pub fn initial_conditions(&self) -> &InitialConditions {
424 &self.initial_conditions
425 }
426
427 #[must_use]
429 pub fn generic_constraints(&self) -> &[GenericConstraint] {
430 &self.generic_constraints
431 }
432
433 #[must_use]
438 pub fn inflow_history(&self) -> &[InflowHistoryRow] {
439 &self.inflow_history
440 }
441
442 #[must_use]
446 pub fn external_scenarios(&self) -> &[ExternalScenarioRow] {
447 &self.external_scenarios
448 }
449
450 #[must_use]
454 pub fn external_load_scenarios(&self) -> &[ExternalLoadRow] {
455 &self.external_load_scenarios
456 }
457
458 #[must_use]
462 pub fn external_ncs_scenarios(&self) -> &[ExternalNcsRow] {
463 &self.external_ncs_scenarios
464 }
465
466 #[must_use]
494 pub fn with_scenario_models(
495 mut self,
496 inflow_models: Vec<InflowModel>,
497 correlation: CorrelationModel,
498 ) -> Self {
499 self.inflow_models = inflow_models;
500 self.correlation = correlation;
501 self
502 }
503
504 pub fn rebuild_indices(&mut self) {
537 self.bus_index = build_index(&self.buses);
538 self.line_index = build_index(&self.lines);
539 self.hydro_index = build_index(&self.hydros);
540 self.thermal_index = build_index(&self.thermals);
541 self.pumping_station_index = build_index(&self.pumping_stations);
542 self.contract_index = build_index(&self.contracts);
543 self.non_controllable_source_index = build_index(&self.non_controllable_sources);
544 self.stage_index = build_stage_index(&self.stages);
545 }
546}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551 use crate::ValidationError;
552 use crate::entities::{ContractType, HydroGenerationModel, HydroPenalties};
553
554 fn make_bus(id: i32) -> Bus {
555 Bus {
556 id: EntityId(id),
557 name: format!("bus-{id}"),
558 deficit_segments: vec![],
559 excess_cost: 0.0,
560 }
561 }
562
563 fn make_line(id: i32, source_bus_id: i32, target_bus_id: i32) -> Line {
564 crate::Line {
565 id: EntityId(id),
566 name: format!("line-{id}"),
567 source_bus_id: EntityId(source_bus_id),
568 target_bus_id: EntityId(target_bus_id),
569 entry_stage_id: None,
570 exit_stage_id: None,
571 direct_capacity_mw: 100.0,
572 reverse_capacity_mw: 100.0,
573 losses_percent: 0.0,
574 exchange_cost: 0.0,
575 }
576 }
577
578 fn make_hydro_on_bus(id: i32, bus_id: i32) -> Hydro {
579 let zero_penalties = HydroPenalties {
580 spillage_cost: 0.0,
581 diversion_cost: 0.0,
582 turbined_cost: 0.0,
583 storage_violation_below_cost: 0.0,
584 filling_target_violation_cost: 0.0,
585 turbined_violation_below_cost: 0.0,
586 outflow_violation_below_cost: 0.0,
587 outflow_violation_above_cost: 0.0,
588 generation_violation_below_cost: 0.0,
589 evaporation_violation_cost: 0.0,
590 water_withdrawal_violation_cost: 0.0,
591 water_withdrawal_violation_pos_cost: 0.0,
592 water_withdrawal_violation_neg_cost: 0.0,
593 evaporation_violation_pos_cost: 0.0,
594 evaporation_violation_neg_cost: 0.0,
595 inflow_nonnegativity_cost: 1000.0,
596 };
597 Hydro {
598 id: EntityId(id),
599 name: format!("hydro-{id}"),
600 bus_id: EntityId(bus_id),
601 downstream_id: None,
602 entry_stage_id: None,
603 exit_stage_id: None,
604 min_storage_hm3: 0.0,
605 max_storage_hm3: 1.0,
606 min_outflow_m3s: 0.0,
607 max_outflow_m3s: None,
608 generation_model: HydroGenerationModel::ConstantProductivity,
609 min_turbined_m3s: 0.0,
610 max_turbined_m3s: 1.0,
611 specific_productivity_mw_per_m3s_per_m: None,
612 min_generation_mw: 0.0,
613 max_generation_mw: 1.0,
614 tailrace: None,
615 hydraulic_losses: None,
616 efficiency: None,
617 evaporation_coefficients_mm: None,
618 evaporation_reference_volumes_hm3: None,
619 diversion: None,
620 filling: None,
621 penalties: zero_penalties,
622 }
623 }
624
625 fn make_hydro(id: i32) -> Hydro {
627 make_hydro_on_bus(id, 0)
628 }
629
630 fn make_thermal_on_bus(id: i32, bus_id: i32) -> Thermal {
631 Thermal {
632 id: EntityId(id),
633 name: format!("thermal-{id}"),
634 bus_id: EntityId(bus_id),
635 entry_stage_id: None,
636 exit_stage_id: None,
637 cost_per_mwh: 50.0,
638 min_generation_mw: 0.0,
639 max_generation_mw: 100.0,
640 anticipated_config: None,
641 }
642 }
643
644 fn make_thermal(id: i32) -> Thermal {
646 make_thermal_on_bus(id, 0)
647 }
648
649 fn make_pumping_station_full(
650 id: i32,
651 bus_id: i32,
652 source_hydro_id: i32,
653 destination_hydro_id: i32,
654 ) -> PumpingStation {
655 PumpingStation {
656 id: EntityId(id),
657 name: format!("ps-{id}"),
658 bus_id: EntityId(bus_id),
659 source_hydro_id: EntityId(source_hydro_id),
660 destination_hydro_id: EntityId(destination_hydro_id),
661 entry_stage_id: None,
662 exit_stage_id: None,
663 consumption_mw_per_m3s: 0.5,
664 min_flow_m3s: 0.0,
665 max_flow_m3s: 10.0,
666 }
667 }
668
669 fn make_pumping_station(id: i32) -> PumpingStation {
670 make_pumping_station_full(id, 0, 0, 1)
671 }
672
673 fn make_contract_on_bus(id: i32, bus_id: i32) -> EnergyContract {
674 EnergyContract {
675 id: EntityId(id),
676 name: format!("contract-{id}"),
677 bus_id: EntityId(bus_id),
678 contract_type: ContractType::Import,
679 entry_stage_id: None,
680 exit_stage_id: None,
681 price_per_mwh: 0.0,
682 min_mw: 0.0,
683 max_mw: 100.0,
684 }
685 }
686
687 fn make_contract(id: i32) -> EnergyContract {
688 make_contract_on_bus(id, 0)
689 }
690
691 fn make_ncs_on_bus(id: i32, bus_id: i32) -> NonControllableSource {
692 NonControllableSource {
693 id: EntityId(id),
694 name: format!("ncs-{id}"),
695 bus_id: EntityId(bus_id),
696 entry_stage_id: None,
697 exit_stage_id: None,
698 max_generation_mw: 50.0,
699 allow_curtailment: true,
700 curtailment_cost: 0.0,
701 }
702 }
703
704 fn make_ncs(id: i32) -> NonControllableSource {
705 make_ncs_on_bus(id, 0)
706 }
707
708 #[test]
709 fn test_empty_system() {
710 let system = SystemBuilder::new().build().expect("empty system is valid");
711 assert_eq!(system.n_buses(), 0);
712 assert_eq!(system.n_lines(), 0);
713 assert_eq!(system.n_hydros(), 0);
714 assert_eq!(system.n_thermals(), 0);
715 assert_eq!(system.n_pumping_stations(), 0);
716 assert_eq!(system.n_contracts(), 0);
717 assert_eq!(system.n_non_controllable_sources(), 0);
718 assert!(system.buses().is_empty());
719 assert!(system.cascade().is_empty());
720 }
721
722 #[test]
723 fn test_canonical_ordering() {
724 let system = SystemBuilder::new()
726 .buses(vec![make_bus(2), make_bus(1), make_bus(0)])
727 .build()
728 .expect("valid system");
729
730 assert_eq!(system.buses()[0].id, EntityId(0));
731 assert_eq!(system.buses()[1].id, EntityId(1));
732 assert_eq!(system.buses()[2].id, EntityId(2));
733 }
734
735 #[test]
736 fn test_lookup_by_id() {
737 let system = SystemBuilder::new()
739 .buses(vec![make_bus(0)])
740 .hydros(vec![make_hydro(10), make_hydro(5), make_hydro(20)])
741 .build()
742 .expect("valid system");
743
744 assert_eq!(system.hydro(EntityId(5)).map(|h| h.id), Some(EntityId(5)));
745 assert_eq!(system.hydro(EntityId(10)).map(|h| h.id), Some(EntityId(10)));
746 assert_eq!(system.hydro(EntityId(20)).map(|h| h.id), Some(EntityId(20)));
747 }
748
749 #[test]
750 fn test_lookup_missing_id() {
751 let system = SystemBuilder::new()
753 .buses(vec![make_bus(0)])
754 .hydros(vec![make_hydro(1), make_hydro(2)])
755 .build()
756 .expect("valid system");
757
758 assert!(system.hydro(EntityId(999)).is_none());
759 }
760
761 #[test]
762 fn test_count_queries() {
763 let system = SystemBuilder::new()
764 .buses(vec![make_bus(0), make_bus(1)])
765 .lines(vec![make_line(0, 0, 1)])
766 .hydros(vec![make_hydro(0), make_hydro(1), make_hydro(2)])
767 .thermals(vec![make_thermal(0)])
768 .pumping_stations(vec![make_pumping_station(0)])
769 .contracts(vec![make_contract(0), make_contract(1)])
770 .non_controllable_sources(vec![make_ncs(0)])
771 .build()
772 .expect("valid system");
773
774 assert_eq!(system.n_buses(), 2);
775 assert_eq!(system.n_lines(), 1);
776 assert_eq!(system.n_hydros(), 3);
777 assert_eq!(system.n_thermals(), 1);
778 assert_eq!(system.n_pumping_stations(), 1);
779 assert_eq!(system.n_contracts(), 2);
780 assert_eq!(system.n_non_controllable_sources(), 1);
781 }
782
783 #[test]
784 fn test_slice_accessors() {
785 let system = SystemBuilder::new()
786 .buses(vec![make_bus(0), make_bus(1), make_bus(2)])
787 .build()
788 .expect("valid system");
789
790 let buses = system.buses();
791 assert_eq!(buses.len(), 3);
792 assert_eq!(buses[0].id, EntityId(0));
793 assert_eq!(buses[1].id, EntityId(1));
794 assert_eq!(buses[2].id, EntityId(2));
795 }
796
797 #[test]
798 fn test_duplicate_id_error() {
799 let result = SystemBuilder::new()
801 .buses(vec![make_bus(0), make_bus(0)])
802 .build();
803
804 assert!(result.is_err());
805 let errors = result.unwrap_err();
806 assert!(!errors.is_empty());
807 assert!(errors.iter().any(|e| matches!(
808 e,
809 ValidationError::DuplicateId {
810 entity_type: "Bus",
811 id: EntityId(0),
812 }
813 )));
814 }
815
816 #[test]
817 fn test_duplicate_stage_id_error() {
818 let result = SystemBuilder::new()
821 .stages(vec![make_stage(0), make_stage(0)])
822 .build();
823
824 assert!(result.is_err());
825 let errors = result.unwrap_err();
826 assert!(errors.iter().any(|e| matches!(
827 e,
828 ValidationError::DuplicateId {
829 entity_type: "Stage",
830 id: EntityId(0),
831 }
832 )));
833 }
834
835 #[test]
836 fn test_multiple_duplicate_errors() {
837 let result = SystemBuilder::new()
839 .buses(vec![make_bus(0), make_bus(0)])
840 .thermals(vec![make_thermal(5), make_thermal(5)])
841 .build();
842
843 assert!(result.is_err());
844 let errors = result.unwrap_err();
845
846 let has_bus_dup = errors.iter().any(|e| {
847 matches!(
848 e,
849 ValidationError::DuplicateId {
850 entity_type: "Bus",
851 ..
852 }
853 )
854 });
855 let has_thermal_dup = errors.iter().any(|e| {
856 matches!(
857 e,
858 ValidationError::DuplicateId {
859 entity_type: "Thermal",
860 ..
861 }
862 )
863 });
864 assert!(has_bus_dup, "expected Bus duplicate error");
865 assert!(has_thermal_dup, "expected Thermal duplicate error");
866 }
867
868 #[test]
869 fn test_send_sync() {
870 fn require_send_sync<T: Send + Sync>(_: T) {}
871 let system = SystemBuilder::new().build().expect("valid system");
872 require_send_sync(system);
873 }
874
875 #[test]
876 fn test_cascade_accessible() {
877 let mut h0 = make_hydro_on_bus(0, 0);
879 h0.downstream_id = Some(EntityId(1));
880 let mut h1 = make_hydro_on_bus(1, 0);
881 h1.downstream_id = Some(EntityId(2));
882 let h2 = make_hydro_on_bus(2, 0);
883
884 let system = SystemBuilder::new()
885 .buses(vec![make_bus(0)])
886 .hydros(vec![h0, h1, h2])
887 .build()
888 .expect("valid system");
889
890 let order = system.cascade().topological_order();
891 assert!(!order.is_empty(), "topological order must be non-empty");
892 let pos_0 = order
893 .iter()
894 .position(|&id| id == EntityId(0))
895 .expect("EntityId(0) must be in topological order");
896 let pos_2 = order
897 .iter()
898 .position(|&id| id == EntityId(2))
899 .expect("EntityId(2) must be in topological order");
900 assert!(pos_0 < pos_2, "EntityId(0) must precede EntityId(2)");
901 }
902
903 #[test]
904 fn test_network_accessible() {
905 let system = SystemBuilder::new()
906 .buses(vec![make_bus(0), make_bus(1)])
907 .lines(vec![make_line(0, 0, 1)])
908 .build()
909 .expect("valid system");
910
911 let connections = system.network().bus_lines(EntityId(0));
912 assert!(!connections.is_empty(), "bus 0 must have connections");
913 assert_eq!(connections[0].line_id, EntityId(0));
914 }
915
916 #[test]
917 fn test_all_entity_lookups() {
918 let system = SystemBuilder::new()
923 .buses(vec![make_bus(0), make_bus(1)])
924 .lines(vec![make_line(2, 0, 1)])
925 .hydros(vec![
926 make_hydro_on_bus(0, 0),
927 make_hydro_on_bus(1, 0),
928 make_hydro_on_bus(3, 0),
929 ])
930 .thermals(vec![make_thermal(4)])
931 .pumping_stations(vec![make_pumping_station(5)])
932 .contracts(vec![make_contract(6)])
933 .non_controllable_sources(vec![make_ncs(7)])
934 .build()
935 .expect("valid system");
936
937 assert!(system.bus(EntityId(1)).is_some());
938 assert!(system.line(EntityId(2)).is_some());
939 assert!(system.hydro(EntityId(3)).is_some());
940 assert!(system.thermal(EntityId(4)).is_some());
941 assert!(system.pumping_station(EntityId(5)).is_some());
942 assert!(system.contract(EntityId(6)).is_some());
943 assert!(system.non_controllable_source(EntityId(7)).is_some());
944
945 assert!(system.bus(EntityId(999)).is_none());
946 assert!(system.line(EntityId(999)).is_none());
947 assert!(system.hydro(EntityId(999)).is_none());
948 assert!(system.thermal(EntityId(999)).is_none());
949 assert!(system.pumping_station(EntityId(999)).is_none());
950 assert!(system.contract(EntityId(999)).is_none());
951 assert!(system.non_controllable_source(EntityId(999)).is_none());
952 }
953
954 #[test]
955 fn test_default_builder() {
956 let system = SystemBuilder::default()
957 .build()
958 .expect("default builder produces valid empty system");
959 assert_eq!(system.n_buses(), 0);
960 }
961
962 #[test]
965 fn test_invalid_bus_reference_hydro() {
966 let hydro = make_hydro_on_bus(1, 99);
968 let result = SystemBuilder::new().hydros(vec![hydro]).build();
969
970 assert!(result.is_err(), "expected Err for missing bus reference");
971 let errors = result.unwrap_err();
972 assert!(
973 errors.iter().any(|e| matches!(
974 e,
975 ValidationError::InvalidReference {
976 source_entity_type: "Hydro",
977 source_id: EntityId(1),
978 field_name: "bus_id",
979 referenced_id: EntityId(99),
980 expected_type: "Bus",
981 }
982 )),
983 "expected InvalidReference for Hydro bus_id=99, got: {errors:?}"
984 );
985 }
986
987 #[test]
988 fn test_invalid_downstream_reference() {
989 let bus = make_bus(0);
991 let mut hydro = make_hydro(1);
992 hydro.downstream_id = Some(EntityId(50));
993
994 let result = SystemBuilder::new()
995 .buses(vec![bus])
996 .hydros(vec![hydro])
997 .build();
998
999 assert!(
1000 result.is_err(),
1001 "expected Err for missing downstream reference"
1002 );
1003 let errors = result.unwrap_err();
1004 assert!(
1005 errors.iter().any(|e| matches!(
1006 e,
1007 ValidationError::InvalidReference {
1008 source_entity_type: "Hydro",
1009 source_id: EntityId(1),
1010 field_name: "downstream_id",
1011 referenced_id: EntityId(50),
1012 expected_type: "Hydro",
1013 }
1014 )),
1015 "expected InvalidReference for Hydro downstream_id=50, got: {errors:?}"
1016 );
1017 }
1018
1019 #[test]
1020 fn test_invalid_pumping_station_hydro_refs() {
1021 let bus = make_bus(0);
1023 let dest_hydro = make_hydro(1);
1024 let ps = make_pumping_station_full(10, 0, 77, 1);
1025
1026 let result = SystemBuilder::new()
1027 .buses(vec![bus])
1028 .hydros(vec![dest_hydro])
1029 .pumping_stations(vec![ps])
1030 .build();
1031
1032 assert!(
1033 result.is_err(),
1034 "expected Err for missing source_hydro_id reference"
1035 );
1036 let errors = result.unwrap_err();
1037 assert!(
1038 errors.iter().any(|e| matches!(
1039 e,
1040 ValidationError::InvalidReference {
1041 source_entity_type: "PumpingStation",
1042 source_id: EntityId(10),
1043 field_name: "source_hydro_id",
1044 referenced_id: EntityId(77),
1045 expected_type: "Hydro",
1046 }
1047 )),
1048 "expected InvalidReference for PumpingStation source_hydro_id=77, got: {errors:?}"
1049 );
1050 }
1051
1052 #[test]
1053 fn test_multiple_invalid_references_collected() {
1054 let line = make_line(1, 99, 0);
1057 let thermal = make_thermal_on_bus(2, 88);
1058
1059 let result = SystemBuilder::new()
1060 .buses(vec![make_bus(0)])
1061 .lines(vec![line])
1062 .thermals(vec![thermal])
1063 .build();
1064
1065 assert!(
1066 result.is_err(),
1067 "expected Err for multiple invalid references"
1068 );
1069 let errors = result.unwrap_err();
1070
1071 let has_line_error = errors.iter().any(|e| {
1072 matches!(
1073 e,
1074 ValidationError::InvalidReference {
1075 source_entity_type: "Line",
1076 field_name: "source_bus_id",
1077 referenced_id: EntityId(99),
1078 ..
1079 }
1080 )
1081 });
1082 let has_thermal_error = errors.iter().any(|e| {
1083 matches!(
1084 e,
1085 ValidationError::InvalidReference {
1086 source_entity_type: "Thermal",
1087 field_name: "bus_id",
1088 referenced_id: EntityId(88),
1089 ..
1090 }
1091 )
1092 });
1093
1094 assert!(
1095 has_line_error,
1096 "expected Line source_bus_id=99 error, got: {errors:?}"
1097 );
1098 assert!(
1099 has_thermal_error,
1100 "expected Thermal bus_id=88 error, got: {errors:?}"
1101 );
1102 assert!(
1103 errors.len() >= 2,
1104 "expected at least 2 errors, got {}: {errors:?}",
1105 errors.len()
1106 );
1107 }
1108
1109 #[test]
1110 fn test_valid_cross_references_pass() {
1111 let bus_0 = make_bus(0);
1113 let bus_1 = make_bus(1);
1114 let h0 = make_hydro_on_bus(0, 0);
1115 let h1 = make_hydro_on_bus(1, 1);
1116 let mut h2 = make_hydro_on_bus(2, 0);
1117 h2.downstream_id = Some(EntityId(1));
1118 let line = make_line(10, 0, 1);
1119 let thermal = make_thermal_on_bus(20, 0);
1120 let ps = make_pumping_station_full(30, 0, 0, 1);
1121 let contract = make_contract_on_bus(40, 1);
1122 let ncs = make_ncs_on_bus(50, 0);
1123
1124 let result = SystemBuilder::new()
1125 .buses(vec![bus_0, bus_1])
1126 .lines(vec![line])
1127 .hydros(vec![h0, h1, h2])
1128 .thermals(vec![thermal])
1129 .pumping_stations(vec![ps])
1130 .contracts(vec![contract])
1131 .non_controllable_sources(vec![ncs])
1132 .build();
1133
1134 assert!(
1135 result.is_ok(),
1136 "expected Ok for all valid cross-references, got: {:?}",
1137 result.unwrap_err()
1138 );
1139 let system = result.unwrap_or_else(|_| unreachable!());
1140 assert_eq!(system.n_buses(), 2);
1141 assert_eq!(system.n_hydros(), 3);
1142 assert_eq!(system.n_lines(), 1);
1143 assert_eq!(system.n_thermals(), 1);
1144 assert_eq!(system.n_pumping_stations(), 1);
1145 assert_eq!(system.n_contracts(), 1);
1146 assert_eq!(system.n_non_controllable_sources(), 1);
1147 }
1148
1149 #[test]
1152 fn test_cascade_cycle_detected() {
1153 let bus = make_bus(0);
1156 let mut h0 = make_hydro(0);
1157 h0.downstream_id = Some(EntityId(1));
1158 let mut h1 = make_hydro(1);
1159 h1.downstream_id = Some(EntityId(2));
1160 let mut h2 = make_hydro(2);
1161 h2.downstream_id = Some(EntityId(0));
1162
1163 let result = SystemBuilder::new()
1164 .buses(vec![bus])
1165 .hydros(vec![h0, h1, h2])
1166 .build();
1167
1168 assert!(result.is_err(), "expected Err for 3-node cycle");
1169 let errors = result.unwrap_err();
1170 let cycle_error = errors
1171 .iter()
1172 .find(|e| matches!(e, ValidationError::CascadeCycle { .. }));
1173 assert!(
1174 cycle_error.is_some(),
1175 "expected CascadeCycle error, got: {errors:?}"
1176 );
1177 let ValidationError::CascadeCycle { cycle_ids } = cycle_error.unwrap() else {
1178 unreachable!()
1179 };
1180 assert_eq!(
1181 cycle_ids,
1182 &[EntityId(0), EntityId(1), EntityId(2)],
1183 "cycle_ids must be sorted ascending, got: {cycle_ids:?}"
1184 );
1185 }
1186
1187 #[test]
1188 fn test_cascade_self_loop_detected() {
1189 let bus = make_bus(0);
1191 let mut h0 = make_hydro(0);
1192 h0.downstream_id = Some(EntityId(0));
1193
1194 let result = SystemBuilder::new()
1195 .buses(vec![bus])
1196 .hydros(vec![h0])
1197 .build();
1198
1199 assert!(result.is_err(), "expected Err for self-loop");
1200 let errors = result.unwrap_err();
1201 let has_cycle = errors
1202 .iter()
1203 .any(|e| matches!(e, ValidationError::CascadeCycle { cycle_ids } if cycle_ids.contains(&EntityId(0))));
1204 assert!(
1205 has_cycle,
1206 "expected CascadeCycle containing EntityId(0), got: {errors:?}"
1207 );
1208 }
1209
1210 #[test]
1211 fn test_valid_acyclic_cascade_passes() {
1212 let bus = make_bus(0);
1215 let mut h0 = make_hydro(0);
1216 h0.downstream_id = Some(EntityId(1));
1217 let mut h1 = make_hydro(1);
1218 h1.downstream_id = Some(EntityId(2));
1219 let h2 = make_hydro(2);
1220
1221 let result = SystemBuilder::new()
1222 .buses(vec![bus])
1223 .hydros(vec![h0, h1, h2])
1224 .build();
1225
1226 assert!(
1227 result.is_ok(),
1228 "expected Ok for acyclic cascade, got: {:?}",
1229 result.unwrap_err()
1230 );
1231 let system = result.unwrap_or_else(|_| unreachable!());
1232 assert_eq!(
1233 system.cascade().topological_order().len(),
1234 system.n_hydros(),
1235 "topological_order must contain all hydros"
1236 );
1237 }
1238
1239 #[test]
1242 fn test_filling_without_entry_stage() {
1243 use crate::entities::FillingConfig;
1245 let bus = make_bus(0);
1246 let mut hydro = make_hydro(1);
1247 hydro.entry_stage_id = None;
1248 hydro.filling = Some(FillingConfig {
1249 start_stage_id: 10,
1250 filling_inflow_m3s: 100.0,
1251 });
1252
1253 let result = SystemBuilder::new()
1254 .buses(vec![bus])
1255 .hydros(vec![hydro])
1256 .build();
1257
1258 assert!(
1259 result.is_err(),
1260 "expected Err for filling without entry_stage_id"
1261 );
1262 let errors = result.unwrap_err();
1263 let has_error = errors.iter().any(|e| match e {
1264 ValidationError::InvalidFillingConfig { hydro_id, reason } => {
1265 *hydro_id == EntityId(1) && reason.contains("entry_stage_id")
1266 }
1267 _ => false,
1268 });
1269 assert!(
1270 has_error,
1271 "expected InvalidFillingConfig with entry_stage_id reason, got: {errors:?}"
1272 );
1273 }
1274
1275 #[test]
1276 fn test_filling_negative_inflow() {
1277 use crate::entities::FillingConfig;
1279 let bus = make_bus(0);
1280 let mut hydro = make_hydro(1);
1281 hydro.entry_stage_id = Some(10);
1282 hydro.filling = Some(FillingConfig {
1283 start_stage_id: 10,
1284 filling_inflow_m3s: -5.0,
1285 });
1286
1287 let result = SystemBuilder::new()
1288 .buses(vec![bus])
1289 .hydros(vec![hydro])
1290 .build();
1291
1292 assert!(
1293 result.is_err(),
1294 "expected Err for negative filling_inflow_m3s"
1295 );
1296 let errors = result.unwrap_err();
1297 let has_error = errors.iter().any(|e| match e {
1298 ValidationError::InvalidFillingConfig { hydro_id, reason } => {
1299 *hydro_id == EntityId(1) && reason.contains("filling_inflow_m3s must be positive")
1300 }
1301 _ => false,
1302 });
1303 assert!(
1304 has_error,
1305 "expected InvalidFillingConfig with positive inflow reason, got: {errors:?}"
1306 );
1307 }
1308
1309 #[test]
1310 fn test_valid_filling_config_passes() {
1311 use crate::entities::FillingConfig;
1313 let bus = make_bus(0);
1314 let mut hydro = make_hydro(1);
1315 hydro.entry_stage_id = Some(10);
1316 hydro.filling = Some(FillingConfig {
1317 start_stage_id: 10,
1318 filling_inflow_m3s: 100.0,
1319 });
1320
1321 let result = SystemBuilder::new()
1322 .buses(vec![bus])
1323 .hydros(vec![hydro])
1324 .build();
1325
1326 assert!(
1327 result.is_ok(),
1328 "expected Ok for valid filling config, got: {:?}",
1329 result.unwrap_err()
1330 );
1331 }
1332
1333 #[test]
1334 fn test_cascade_cycle_and_invalid_filling_both_reported() {
1335 use crate::entities::FillingConfig;
1338 let bus = make_bus(0);
1339
1340 let mut h0 = make_hydro(0);
1342 h0.downstream_id = Some(EntityId(0));
1343
1344 let mut h1 = make_hydro(1);
1346 h1.entry_stage_id = None; h1.filling = Some(FillingConfig {
1348 start_stage_id: 5,
1349 filling_inflow_m3s: 50.0,
1350 });
1351
1352 let result = SystemBuilder::new()
1353 .buses(vec![bus])
1354 .hydros(vec![h0, h1])
1355 .build();
1356
1357 assert!(result.is_err(), "expected Err for cycle + invalid filling");
1358 let errors = result.unwrap_err();
1359 let has_cycle = errors
1360 .iter()
1361 .any(|e| matches!(e, ValidationError::CascadeCycle { .. }));
1362 let has_filling = errors
1363 .iter()
1364 .any(|e| matches!(e, ValidationError::InvalidFillingConfig { .. }));
1365 assert!(has_cycle, "expected CascadeCycle error, got: {errors:?}");
1366 assert!(
1367 has_filling,
1368 "expected InvalidFillingConfig error, got: {errors:?}"
1369 );
1370 }
1371
1372 #[cfg(feature = "serde")]
1373 #[test]
1374 fn test_system_serde_roundtrip() {
1375 let bus_a = make_bus(1);
1377 let bus_b = make_bus(2);
1378 let hydro = make_hydro_on_bus(10, 1);
1379 let thermal = make_thermal_on_bus(20, 2);
1380 let line = make_line(1, 1, 2);
1381
1382 let system = SystemBuilder::new()
1383 .buses(vec![bus_a, bus_b])
1384 .hydros(vec![hydro])
1385 .thermals(vec![thermal])
1386 .lines(vec![line])
1387 .build()
1388 .expect("valid system");
1389
1390 let json = serde_json::to_string(&system).unwrap();
1391
1392 let mut deserialized: System = serde_json::from_str(&json).unwrap();
1394 deserialized.rebuild_indices();
1395
1396 assert_eq!(system.buses(), deserialized.buses());
1398 assert_eq!(system.hydros(), deserialized.hydros());
1399 assert_eq!(system.thermals(), deserialized.thermals());
1400 assert_eq!(system.lines(), deserialized.lines());
1401
1402 assert_eq!(
1404 deserialized.bus(EntityId(1)).map(|b| b.id),
1405 Some(EntityId(1))
1406 );
1407 assert_eq!(
1408 deserialized.hydro(EntityId(10)).map(|h| h.id),
1409 Some(EntityId(10))
1410 );
1411 assert_eq!(
1412 deserialized.thermal(EntityId(20)).map(|t| t.id),
1413 Some(EntityId(20))
1414 );
1415 assert_eq!(
1416 deserialized.line(EntityId(1)).map(|l| l.id),
1417 Some(EntityId(1))
1418 );
1419 }
1420
1421 fn make_stage(id: i32) -> Stage {
1424 use crate::temporal::{
1425 Block, BlockMode, NoiseMethod, ScenarioSourceConfig, StageRiskConfig, StageStateConfig,
1426 };
1427 use chrono::NaiveDate;
1428 Stage {
1429 index: usize::try_from(id.max(0)).unwrap_or(0),
1430 id,
1431 start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1432 end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
1433 season_id: Some(0),
1434 blocks: vec![Block {
1435 index: 0,
1436 name: "SINGLE".to_string(),
1437 duration_hours: 744.0,
1438 }],
1439 block_mode: BlockMode::Parallel,
1440 state_config: StageStateConfig {
1441 storage: true,
1442 inflow_lags: false,
1443 },
1444 risk_config: StageRiskConfig::Expectation,
1445 scenario_config: ScenarioSourceConfig {
1446 branching_factor: 50,
1447 noise_method: NoiseMethod::Saa,
1448 },
1449 }
1450 }
1451
1452 #[test]
1455 fn test_system_backward_compat() {
1456 let system = SystemBuilder::new().build().expect("empty system is valid");
1457 assert_eq!(system.n_buses(), 0);
1459 assert_eq!(system.n_hydros(), 0);
1460 assert_eq!(system.n_stages(), 0);
1462 assert!(system.stages().is_empty());
1463 assert!(system.initial_conditions().storage.is_empty());
1464 assert!(system.generic_constraints().is_empty());
1465 assert!(system.inflow_models().is_empty());
1466 assert!(system.load_models().is_empty());
1467 assert_eq!(system.penalties().n_stages(), 0);
1468 assert_eq!(system.bounds().n_stages(), 0);
1469 assert!(!system.resolved_generic_bounds().is_active(0, 0));
1471 assert!(
1472 system
1473 .resolved_generic_bounds()
1474 .bounds_for_stage(0, 0)
1475 .is_empty()
1476 );
1477 }
1478
1479 #[test]
1481 fn test_system_resolved_generic_bounds_accessor() {
1482 use crate::resolved::ResolvedGenericConstraintBounds;
1483 use std::collections::HashMap as StdHashMap;
1484
1485 let id_map: StdHashMap<i32, usize> = [(0, 0), (1, 1)].into_iter().collect();
1486 let rows = vec![(0i32, 0i32, None::<i32>, 100.0f64)];
1487 let table = ResolvedGenericConstraintBounds::new(&id_map, rows.into_iter());
1488
1489 let system = SystemBuilder::new()
1490 .resolved_generic_bounds(table)
1491 .build()
1492 .expect("valid system");
1493
1494 assert!(system.resolved_generic_bounds().is_active(0, 0));
1495 assert!(!system.resolved_generic_bounds().is_active(1, 0));
1496 let slice = system.resolved_generic_bounds().bounds_for_stage(0, 0);
1497 assert_eq!(slice.len(), 1);
1498 assert_eq!(slice[0], (None, 100.0));
1499 }
1500
1501 #[test]
1503 fn test_system_with_stages() {
1504 let s0 = make_stage(0);
1505 let s1 = make_stage(1);
1506
1507 let system = SystemBuilder::new()
1508 .stages(vec![s1.clone(), s0.clone()]) .build()
1510 .expect("valid system");
1511
1512 assert_eq!(system.n_stages(), 2);
1514 assert_eq!(system.stages()[0].id, 0);
1515 assert_eq!(system.stages()[1].id, 1);
1516
1517 let found = system.stage(0).expect("stage 0 must be found");
1519 assert_eq!(found.id, s0.id);
1520
1521 let found1 = system.stage(1).expect("stage 1 must be found");
1522 assert_eq!(found1.id, s1.id);
1523
1524 assert!(system.stage(99).is_none());
1526 }
1527
1528 #[test]
1530 fn test_system_stage_lookup_by_id() {
1531 let stages: Vec<Stage> = [0i32, 1, 2].iter().map(|&id| make_stage(id)).collect();
1532
1533 let system = SystemBuilder::new()
1534 .stages(stages)
1535 .build()
1536 .expect("valid system");
1537
1538 assert_eq!(system.stage(1).map(|s| s.id), Some(1));
1539 assert!(system.stage(99).is_none());
1540 }
1541
1542 #[test]
1544 fn test_system_with_initial_conditions() {
1545 let ic = InitialConditions {
1546 storage: vec![crate::HydroStorage {
1547 hydro_id: EntityId(0),
1548 value_hm3: 15_000.0,
1549 }],
1550 filling_storage: vec![],
1551 past_inflows: vec![],
1552 past_anticipated_commitments: vec![],
1553 recent_observations: vec![],
1554 };
1555
1556 let system = SystemBuilder::new()
1557 .initial_conditions(ic)
1558 .build()
1559 .expect("valid system");
1560
1561 assert_eq!(system.initial_conditions().storage.len(), 1);
1562 assert_eq!(system.initial_conditions().storage[0].hydro_id, EntityId(0));
1563 assert!((system.initial_conditions().storage[0].value_hm3 - 15_000.0).abs() < f64::EPSILON);
1564 }
1565
1566 #[cfg(feature = "serde")]
1569 #[test]
1570 fn test_system_serde_roundtrip_with_stages() {
1571 use crate::temporal::PolicyGraphType;
1572
1573 let stages = vec![make_stage(0), make_stage(1)];
1574 let policy_graph = PolicyGraph {
1575 graph_type: PolicyGraphType::FiniteHorizon,
1576 annual_discount_rate: 0.0,
1577 transitions: vec![],
1578 season_map: None,
1579 };
1580
1581 let system = SystemBuilder::new()
1582 .stages(stages)
1583 .policy_graph(policy_graph)
1584 .build()
1585 .expect("valid system");
1586
1587 let json = serde_json::to_string(&system).unwrap();
1588 let mut deserialized: System = serde_json::from_str(&json).unwrap();
1589
1590 deserialized.rebuild_indices();
1592
1593 assert_eq!(system.n_stages(), deserialized.n_stages());
1595 assert_eq!(system.stages()[0].id, deserialized.stages()[0].id);
1596 assert_eq!(system.stages()[1].id, deserialized.stages()[1].id);
1597
1598 assert_eq!(deserialized.stage(0).map(|s| s.id), Some(0));
1600 assert_eq!(deserialized.stage(1).map(|s| s.id), Some(1));
1601 assert!(deserialized.stage(99).is_none());
1602
1603 assert_eq!(
1605 deserialized.policy_graph().graph_type,
1606 system.policy_graph().graph_type
1607 );
1608 }
1609
1610 #[test]
1615 fn test_system_inflow_history_defaults_empty() {
1616 let system = SystemBuilder::new().build().expect("valid system");
1617 assert!(
1618 system.inflow_history().is_empty(),
1619 "inflow_history must default to empty"
1620 );
1621 }
1622
1623 #[test]
1626 fn test_system_inflow_history_stores_rows() {
1627 use crate::scenario::InflowHistoryRow;
1628 use chrono::NaiveDate;
1629
1630 let row1 = InflowHistoryRow {
1631 hydro_id: EntityId(1),
1632 date: NaiveDate::from_ymd_opt(2000, 1, 1).expect("valid date"),
1633 value_m3s: 500.0,
1634 };
1635 let row2 = InflowHistoryRow {
1636 hydro_id: EntityId(1),
1637 date: NaiveDate::from_ymd_opt(2000, 2, 1).expect("valid date"),
1638 value_m3s: 420.0,
1639 };
1640
1641 let system = SystemBuilder::new()
1642 .inflow_history(vec![row1.clone(), row2.clone()])
1643 .build()
1644 .expect("valid system");
1645
1646 assert_eq!(system.inflow_history().len(), 2);
1647 assert_eq!(system.inflow_history()[0], row1);
1648 assert_eq!(system.inflow_history()[1], row2);
1649 }
1650
1651 #[test]
1654 fn test_system_external_scenarios_defaults_empty() {
1655 let system = SystemBuilder::new().build().expect("valid system");
1656 assert!(
1657 system.external_scenarios().is_empty(),
1658 "external_scenarios must default to empty"
1659 );
1660 }
1661
1662 #[test]
1665 fn test_system_external_scenarios_stores_rows() {
1666 use crate::scenario::ExternalScenarioRow;
1667
1668 let row = ExternalScenarioRow {
1669 stage_id: 0,
1670 scenario_id: 2,
1671 hydro_id: EntityId(5),
1672 value_m3s: 320.5,
1673 };
1674
1675 let system = SystemBuilder::new()
1676 .external_scenarios(vec![row.clone()])
1677 .build()
1678 .expect("valid system");
1679
1680 assert_eq!(system.external_scenarios().len(), 1);
1681 assert_eq!(system.external_scenarios()[0], row);
1682 }
1683}