1mod accessors;
31pub(crate) mod methodology_config;
32mod orchestration;
33pub mod params;
34pub(crate) mod scenario_libraries;
35pub mod scenario_library_set;
36pub mod stage_data;
37pub mod stochastic_pipeline;
38pub(crate) mod template_postprocess;
39
40pub use params::{
41 ConstructionConfig, DEFAULT_FORWARD_PASSES, DEFAULT_MAX_ITERATIONS, DEFAULT_SEED, StudyParams,
42};
43pub use scenario_library_set::{PhaseLibraries, ScenarioLibraries};
44pub use stage_data::StageData;
45pub use stochastic_pipeline::{
46 PrepareStochasticResult, build_ncs_factor_entries, load_load_factors_for_stochastic,
47 prepare_stochastic,
48};
49
50use std::path::Path;
51
52use cobre_core::{
53 EntityId, Stage, System,
54 scenario::{SamplingScheme, ScenarioSource},
55};
56use cobre_io::build_hydro_reference_volumes_resolved;
57use cobre_stochastic::{
58 ExternalScenarioLibrary, HistoricalScenarioLibrary, StochasticContext, SweepDirection,
59};
60
61use crate::{
62 config::{CutManagementConfig, EventParams},
63 cut::FutureCostFunction,
64 energy_conversion::{EnergyConversionSet, build_energy_conversion_set},
65 error::SddpError,
66 horizon_mode::HorizonMode,
67 hydro_models::{EvaporationModel, PrepareHydroModelsResult, ResolvedProductionModel},
68 indexer::StageIndexer,
69 lp_builder::build_stage_templates,
70 risk_measure::RiskMeasure,
71 simulation::EntityCounts,
72 stopping_rule::{StoppingRule, StoppingRuleSet},
73 workspace::CapturedBasis,
74};
75
76#[derive(Debug)]
89pub struct StudySetup {
90 pub stage_data: stage_data::StageData,
93
94 pub stochastic: StochasticContext,
96 pub fcf: FutureCostFunction,
98 pub(crate) initial_state: Vec<f64>,
99
100 pub hydro_models: PrepareHydroModelsResult,
102
103 pub(crate) ncs_entity_ids_per_stage: Vec<Vec<i32>>,
104 pub(crate) ncs_max_gen: Vec<f64>,
106 pub(crate) ncs_allow_curtailment: Vec<bool>,
112
113 pub scenario_libraries: ScenarioLibraries,
119
120 pub loop_params: crate::config::LoopParams,
126
127 pub simulation_config: crate::simulation::SimulationConfig,
129
130 pub policy_path: String,
132
133 pub(crate) cut_management: CutManagementConfig,
139
140 pub(crate) events: EventParams,
147
148 pub(crate) methodology: methodology_config::MethodologyConfig,
153
154 pub(crate) recent_observation_seed: crate::lag_transition::RecentObservationSeed,
164
165 pub(crate) downstream_par_order: usize,
172
173 pub(crate) energy_conversion: EnergyConversionSet,
180
181 pub(crate) hydro_min_storage_hm3: Vec<f64>,
186
187 pub(crate) warm_start_basis_cache: Option<Vec<Option<CapturedBasis>>>,
200
201 pub(crate) noise_key_diag: Option<crate::noise_key_diag::NoiseKeyDiag>,
209}
210
211impl StudySetup {
212 pub fn new(
227 system: &System,
228 config: &cobre_io::Config,
229 stochastic: StochasticContext,
230 hydro_models: PrepareHydroModelsResult,
231 ) -> Result<Self, SddpError> {
232 let params = StudyParams::from_config(config)?;
233 let sentinel_path = Path::new("config.json");
237 let training_source = config
238 .training_scenario_source(sentinel_path)
239 .map_err(|e| SddpError::Validation(e.to_string()))?;
240 let simulation_source = config
241 .simulation_scenario_source(sentinel_path)
242 .map_err(|e| SddpError::Validation(e.to_string()))?;
243 let config = params.into_construction_config();
244 Self::from_broadcast_params(
245 system,
246 stochastic,
247 config,
248 hydro_models,
249 &training_source,
250 &simulation_source,
251 )
252 }
253
254 pub fn from_broadcast_params(
281 system: &System,
282 mut stochastic: StochasticContext,
283 config: ConstructionConfig,
284 hydro_models: PrepareHydroModelsResult,
285 training_source: &ScenarioSource,
286 simulation_source: &ScenarioSource,
287 ) -> Result<Self, SddpError> {
288 let ConstructionConfig {
289 seed,
290 forward_passes,
291 stopping_rule_set,
292 n_scenarios,
293 io_channel_capacity,
294 policy_path,
295 inflow_method,
296 cut_selection,
297 cut_activity_tolerance,
298 budget,
299 export_states,
300 scalar_parameters,
301 } = config;
302
303 let solve_order_keys = crate::noise_key_diag::build_noise_key_table(system, &stochastic)?;
316 stochastic
317 .set_solve_order(&solve_order_keys, SweepDirection::Descending)
318 .map_err(|e| SddpError::Validation(e.to_string()))?;
319
320 let EnergyAndTemplates {
321 energy_conversion,
322 stage_templates,
323 scaling_report,
324 } = build_energy_and_templates(
325 system,
326 inflow_method,
327 &stochastic,
328 &hydro_models,
329 &scalar_parameters,
330 )?;
331
332 let stage_templates_ref = &stage_templates.templates;
333
334 let indexer = build_wired_indexer(
335 system,
336 &stage_templates,
337 inflow_method,
338 &hydro_models,
339 &stochastic,
340 );
341
342 let initial_state = build_initial_state(system, &indexer);
343
344 let n_stages = stage_templates_ref.len();
345 let max_iterations = max_iterations_from_rules(&stopping_rule_set);
346 let fcf_capacity_iterations = max_iterations.saturating_add(1);
347 let fcf = FutureCostFunction::new(
348 n_stages,
349 indexer.n_state,
350 forward_passes,
351 fcf_capacity_iterations,
352 &vec![0; n_stages],
353 );
354
355 let horizon = HorizonMode::Finite {
356 num_stages: n_stages,
357 };
358 horizon.validate()?;
365
366 let risk_measures: Vec<RiskMeasure> = system
367 .stages()
368 .iter()
369 .filter(|s| s.id >= 0)
370 .map(|s| RiskMeasure::from(s.risk_config))
371 .collect();
372
373 let NcsEntityData {
374 entity_counts,
375 ncs_entity_ids_per_stage,
376 ncs_max_gen,
377 ncs_allow_curtailment,
378 } = build_ncs_entity_data(system, &stage_templates, &stochastic)?;
379
380 let block_counts_per_stage: Vec<usize> = stage_templates
381 .block_hours_per_stage
382 .iter()
383 .map(Vec::len)
384 .collect();
385 let max_blocks = block_counts_per_stage.iter().copied().max().unwrap_or(0);
386
387 let stages: Vec<Stage> = system
388 .stages()
389 .iter()
390 .filter(|s| s.id >= 0)
391 .cloned()
392 .collect();
393
394 let LagData {
395 stage_lag_transitions,
396 noise_group_ids,
397 recent_observation_seed,
398 downstream_par_order,
399 } = precompute_lag_data(system, &stages, &stochastic);
400
401 let hydro_ids: Vec<EntityId> = system.hydros().iter().map(|h| h.id).collect();
402
403 let scenario_libraries = build_scenario_libraries(
404 system,
405 &stages,
406 &hydro_ids,
407 &stochastic,
408 &stage_lag_transitions,
409 training_source,
410 simulation_source,
411 forward_passes,
412 )?;
413
414 let hydro_min_storage_hm3: Vec<f64> =
415 system.hydros().iter().map(|h| h.min_storage_hm3).collect();
416
417 let noise_key_diag =
423 crate::noise_key_diag::NoiseKeyDiag::from_keys_if_enabled(&solve_order_keys);
424
425 Ok(Self {
426 stage_data: stage_data::StageData {
427 stage_templates,
428 indexer,
429 stages,
430 entity_counts,
431 block_counts_per_stage,
432 stage_lag_transitions,
433 noise_group_ids,
434 scaling_report,
435 },
436 stochastic,
437 fcf,
438 initial_state,
439 hydro_models,
440 ncs_entity_ids_per_stage,
441 ncs_max_gen,
442 ncs_allow_curtailment,
443 scenario_libraries,
444 loop_params: crate::config::LoopParams {
445 seed,
446 forward_passes,
447 max_iterations,
448 start_iteration: 0,
449 max_blocks,
450 stopping_rules: stopping_rule_set,
451 },
452 simulation_config: crate::simulation::SimulationConfig {
453 n_scenarios,
454 io_channel_capacity,
455 },
456 policy_path,
457 cut_management: CutManagementConfig {
458 cut_selection,
459 budget,
460 cut_activity_tolerance,
461 warm_start_cuts: 0,
462 risk_measures,
463 },
464 events: EventParams { export_states },
465 methodology: methodology_config::MethodologyConfig {
466 horizon,
467 inflow_method,
468 },
469 recent_observation_seed,
470 downstream_par_order,
471 energy_conversion,
472 hydro_min_storage_hm3,
473 warm_start_basis_cache: None,
474 noise_key_diag,
475 })
476 }
477}
478
479struct NcsEntityData {
485 entity_counts: EntityCounts,
486 ncs_entity_ids_per_stage: Vec<Vec<i32>>,
487 ncs_max_gen: Vec<f64>,
488 ncs_allow_curtailment: Vec<bool>,
489}
490
491fn build_ncs_entity_data(
503 system: &System,
504 stage_templates: &crate::lp_builder::StageTemplates,
505 stochastic: &StochasticContext,
506) -> Result<NcsEntityData, SddpError> {
507 let entity_counts = build_entity_counts(system);
508
509 let ncs_entity_ids_per_stage: Vec<Vec<i32>> = stage_templates
510 .active_ncs_indices
511 .iter()
512 .map(|stage_indices| {
513 stage_indices
514 .iter()
515 .map(|&sys_idx| entity_counts.non_controllable_ids[sys_idx])
516 .collect()
517 })
518 .collect();
519
520 let (ncs_max_gen, ncs_allow_curtailment): (Vec<f64>, Vec<bool>) = {
521 let stoch_ncs_ids = stochastic.ncs_entity_ids();
522 let mut max_v = Vec::with_capacity(stoch_ncs_ids.len());
523 let mut allow_v = Vec::with_capacity(stoch_ncs_ids.len());
524 for ncs_id in stoch_ncs_ids {
525 let ncs = system
526 .non_controllable_sources()
527 .iter()
528 .find(|n| n.id == *ncs_id)
529 .ok_or_else(|| {
530 SddpError::Validation(format!(
531 "stochastic NCS entity {ncs_id:?} not found in system non_controllable_sources"
532 ))
533 })?;
534 max_v.push(ncs.max_generation_mw);
535 allow_v.push(ncs.allow_curtailment);
536 }
537 (max_v, allow_v)
538 };
539
540 Ok(NcsEntityData {
541 entity_counts,
542 ncs_entity_ids_per_stage,
543 ncs_max_gen,
544 ncs_allow_curtailment,
545 })
546}
547
548struct EnergyAndTemplates {
550 energy_conversion: EnergyConversionSet,
551 stage_templates: crate::lp_builder::StageTemplates,
552 scaling_report: crate::scaling_report::ScalingReport,
553}
554
555fn build_energy_and_templates(
571 system: &System,
572 inflow_method: crate::InflowNonNegativityMethod,
573 stochastic: &StochasticContext,
574 hydro_models: &PrepareHydroModelsResult,
575 scalar_parameters: &[cobre_core::ScalarParameter],
576) -> Result<EnergyAndTemplates, SddpError> {
577 let n_stages_pre = system.stages().iter().filter(|s| s.id >= 0).count();
578 let stage_to_season: Vec<i32> = system
579 .stages()
580 .iter()
581 .filter(|s| s.id >= 0)
582 .map(|s| i32::try_from(s.season_id.unwrap_or(0)).unwrap_or(0))
583 .collect();
584 let reference_volume_fractions =
591 build_hydro_reference_volumes_resolved(&hydro_models.reference_volumes_hm3, 0.0);
592 let energy_conversion = build_energy_conversion_set(
593 system.hydros(),
594 n_stages_pre,
595 system.cascade(),
596 &reference_volume_fractions,
597 &hydro_models.vha_geometry_by_hydro,
602 Some(&hydro_models.productivity_override),
603 Some(&hydro_models.production),
604 )
605 .map_err(|e| SddpError::Validation(e.to_string()))?;
606 let resolved_parameters = crate::resolved_parameters::build_resolved_parameters(
607 scalar_parameters,
608 &energy_conversion,
609 &hydro_models.productivity_override,
610 system.hydros(),
611 &stage_to_season,
612 n_stages_pre,
613 )
614 .map_err(|e| SddpError::Validation(e.to_string()))?;
615
616 let mut stage_templates = build_stage_templates(
617 system,
618 inflow_method,
619 stochastic.par(),
620 stochastic.normal(),
621 &hydro_models.production,
622 &hydro_models.evaporation,
623 &resolved_parameters,
624 )?;
625
626 let scaling_report = template_postprocess::postprocess_templates(&mut stage_templates, system);
627
628 if stage_templates.templates.is_empty() {
629 return Err(SddpError::Validation(
630 "system has no study stages".to_string(),
631 ));
632 }
633
634 Ok(EnergyAndTemplates {
635 energy_conversion,
636 stage_templates,
637 scaling_report,
638 })
639}
640
641fn build_wired_indexer(
649 system: &System,
650 stage_templates: &crate::lp_builder::StageTemplates,
651 inflow_method: crate::InflowNonNegativityMethod,
652 hydro_models: &PrepareHydroModelsResult,
653 stochastic: &StochasticContext,
654) -> StageIndexer {
655 let stage_templates_ref = &stage_templates.templates;
656 let n_blks_stage0 = system.stages().first().map_or(1, |s| s.blocks.len().max(1));
657 let has_inflow_penalty =
658 inflow_method.has_slack_columns() && stage_templates_ref[0].n_hydro > 0;
659
660 let n_hydros = system.hydros().len();
662 let mut fpha_hydro_indices: Vec<usize> = Vec::new();
663 let mut fpha_planes: Vec<usize> = Vec::new();
664 let mut evap_hydro_indices: Vec<usize> = Vec::new();
665 for h_idx in 0..n_hydros {
666 if let ResolvedProductionModel::Fpha { planes, .. } =
667 hydro_models.production.model(h_idx, 0)
668 {
669 fpha_hydro_indices.push(h_idx);
670 fpha_planes.push(planes.len());
671 }
672 if matches!(
673 hydro_models.evaporation.model(h_idx),
674 EvaporationModel::Linearized { .. }
675 ) {
676 evap_hydro_indices.push(h_idx);
677 }
678 }
679
680 let max_deficit_segments = system
681 .buses()
682 .iter()
683 .map(|b| b.deficit_segments.len())
684 .max()
685 .unwrap_or(0);
686
687 let mut anticipated_thermal_indices: Vec<usize> = Vec::new();
688 let mut anticipated_lead_stages: Vec<usize> = Vec::new();
689 for (t_idx, thermal) in system.thermals().iter().enumerate() {
690 if let Some(cfg) = thermal.anticipated_config.as_ref() {
691 anticipated_thermal_indices.push(t_idx);
692 anticipated_lead_stages.push(usize::try_from(cfg.lead_stages).unwrap_or(usize::MAX));
693 }
694 }
695 let n_anticipated = anticipated_thermal_indices.len();
696 let k_max: usize = anticipated_lead_stages.iter().copied().max().unwrap_or(0);
697 let eq_counts = crate::indexer::EquipmentCounts {
698 hydro_count: stage_templates_ref[0].n_hydro,
699 max_par_order: stage_templates_ref[0].max_par_order,
700 n_thermals: system.thermals().len(),
701 n_lines: system.lines().len(),
702 n_buses: system.buses().len(),
703 n_blks: n_blks_stage0,
704 has_inflow_penalty,
705 max_deficit_segments,
706 n_anticipated,
707 k_max,
708 anticipated_lead_stages,
709 anticipated_thermal_indices,
710 };
711 let fpha_cfg = crate::indexer::FphaColumnLayout {
712 hydro_indices: fpha_hydro_indices,
713 planes_per_hydro: fpha_planes,
714 };
715 let evap_cfg = crate::indexer::EvapConfig {
716 hydro_indices: evap_hydro_indices,
717 };
718 let mut indexer =
719 StageIndexer::with_equipment_and_evaporation(&eq_counts, &fpha_cfg, &evap_cfg);
720
721 if !stage_templates.ncs_col_starts.is_empty() {
723 let ncs_start = stage_templates.ncs_col_starts[0];
724 let n_ncs_stage0 = stage_templates.n_ncs_per_stage[0];
725 indexer.ncs_generation = ncs_start..(ncs_start + n_ncs_stage0 * n_blks_stage0);
726
727 for (s, &start) in stage_templates.ncs_col_starts.iter().enumerate() {
728 debug_assert_eq!(
729 start, ncs_start,
730 "NCS column start differs at stage {s}: expected {ncs_start}, got {start}"
731 );
732 }
733 }
734
735 {
749 let par = stochastic.par();
750 let effective_lag_counts: Vec<usize> = if indexer.max_par_order > 0 {
751 (0..par.n_hydros())
752 .map(|h| par.effective_lag_count(h))
753 .collect()
754 } else {
755 vec![0; indexer.hydro_count]
756 };
757 let anticipated_k: Vec<usize> = indexer.anticipated_lead_stages.clone();
760 indexer.set_nonzero_mask(&effective_lag_counts, &anticipated_k);
761 }
762 indexer.finalize_state_column_map();
765
766 indexer
767}
768
769struct LagData {
771 stage_lag_transitions: Vec<cobre_core::temporal::StageLagTransition>,
772 noise_group_ids: Vec<u32>,
773 recent_observation_seed: crate::lag_transition::RecentObservationSeed,
774 downstream_par_order: usize,
775}
776
777fn precompute_lag_data(
786 system: &System,
787 stages: &[Stage],
788 stochastic: &StochasticContext,
789) -> LagData {
790 let noop_season_map;
791 let season_map_ref = if let Some(sm) = system.policy_graph().season_map.as_ref() {
792 sm
793 } else {
794 noop_season_map = cobre_core::temporal::SeasonMap {
796 cycle_type: cobre_core::temporal::SeasonCycleType::Monthly,
797 seasons: Vec::new(),
798 };
799 &noop_season_map
800 };
801 let has_quarterly_stages = stages
806 .iter()
807 .any(|s| s.season_id.is_some_and(|id| id >= 12));
808 let downstream_par_order = if has_quarterly_stages {
809 stochastic.par().max_order()
810 } else {
811 0
812 };
813 let stage_lag_transitions = crate::lag_transition::precompute_stage_lag_transitions(
814 stages,
815 season_map_ref,
816 downstream_par_order,
817 );
818 let noise_group_ids = crate::lag_transition::precompute_noise_groups(stages);
819
820 let recent_observation_seed = if stages.is_empty() {
824 crate::lag_transition::RecentObservationSeed::zero(system.hydros().len())
825 } else {
826 crate::lag_transition::compute_recent_observation_seed(
827 &system.initial_conditions().recent_observations,
828 &stages[0],
829 season_map_ref,
830 system.hydros(),
831 )
832 };
833
834 LagData {
835 stage_lag_transitions,
836 noise_group_ids,
837 recent_observation_seed,
838 downstream_par_order,
839 }
840}
841
842#[allow(clippy::too_many_arguments)]
858fn build_scenario_libraries(
859 system: &System,
860 stages: &[Stage],
861 hydro_ids: &[EntityId],
862 stochastic: &StochasticContext,
863 stage_lag_transitions: &[cobre_core::temporal::StageLagTransition],
864 training_source: &ScenarioSource,
865 simulation_source: &ScenarioSource,
866 forward_passes: u32,
867) -> Result<ScenarioLibraries, SddpError> {
868 let inflow_scheme = training_source.inflow_scheme;
869 let load_scheme = training_source.load_scheme;
870 let ncs_scheme = training_source.ncs_scheme;
871 let sim_inflow_scheme = simulation_source.inflow_scheme;
872 let sim_load_scheme = simulation_source.load_scheme;
873 let sim_ncs_scheme = simulation_source.ncs_scheme;
874
875 let training_historical: Option<HistoricalScenarioLibrary> =
877 if inflow_scheme == SamplingScheme::Historical {
878 Some(scenario_libraries::build_historical_inflow_library(
879 system.inflow_history(),
880 hydro_ids,
881 stages,
882 stochastic.par(),
883 system.policy_graph().season_map.as_ref(),
884 &system.initial_conditions().past_inflows,
885 stage_lag_transitions,
886 training_source.historical_years.as_ref(),
887 forward_passes,
888 )?)
889 } else {
890 None
891 };
892
893 let training_external_inflow: Option<ExternalScenarioLibrary> =
894 if inflow_scheme == SamplingScheme::External {
895 Some(scenario_libraries::build_external_inflow_library(
896 system.external_scenarios(),
897 hydro_ids,
898 stages,
899 stochastic.par(),
900 &system.initial_conditions().past_inflows,
901 stage_lag_transitions,
902 forward_passes,
903 )?)
904 } else {
905 None
906 };
907
908 let training_external_load: Option<ExternalScenarioLibrary> =
909 if load_scheme == SamplingScheme::External {
910 Some(scenario_libraries::build_external_load_library(
911 system.external_load_scenarios(),
912 system.load_models(),
913 stages,
914 forward_passes,
915 )?)
916 } else {
917 None
918 };
919
920 let training_external_ncs: Option<ExternalScenarioLibrary> =
921 if ncs_scheme == SamplingScheme::External {
922 Some(scenario_libraries::build_external_ncs_library(
923 system.external_ncs_scenarios(),
924 system.ncs_models(),
925 stages,
926 forward_passes,
927 )?)
928 } else {
929 None
930 };
931
932 let simulation_historical: Option<HistoricalScenarioLibrary> =
938 if sim_inflow_scheme == SamplingScheme::Historical && sim_inflow_scheme != inflow_scheme {
939 Some(scenario_libraries::build_historical_inflow_library(
940 system.inflow_history(),
941 hydro_ids,
942 stages,
943 stochastic.par(),
944 system.policy_graph().season_map.as_ref(),
945 &system.initial_conditions().past_inflows,
946 stage_lag_transitions,
947 simulation_source.historical_years.as_ref(),
948 forward_passes,
949 )?)
950 } else {
951 None
952 };
953
954 let simulation_external_inflow: Option<ExternalScenarioLibrary> =
955 if sim_inflow_scheme == SamplingScheme::External && sim_inflow_scheme != inflow_scheme {
956 Some(scenario_libraries::build_external_inflow_library(
957 system.external_scenarios(),
958 hydro_ids,
959 stages,
960 stochastic.par(),
961 &system.initial_conditions().past_inflows,
962 stage_lag_transitions,
963 forward_passes,
964 )?)
965 } else {
966 None
967 };
968
969 let simulation_external_load: Option<ExternalScenarioLibrary> =
970 if sim_load_scheme == SamplingScheme::External && sim_load_scheme != load_scheme {
971 Some(scenario_libraries::build_external_load_library(
972 system.external_load_scenarios(),
973 system.load_models(),
974 stages,
975 forward_passes,
976 )?)
977 } else {
978 None
979 };
980
981 let simulation_external_ncs: Option<ExternalScenarioLibrary> =
982 if sim_ncs_scheme == SamplingScheme::External && sim_ncs_scheme != ncs_scheme {
983 Some(scenario_libraries::build_external_ncs_library(
984 system.external_ncs_scenarios(),
985 system.ncs_models(),
986 stages,
987 forward_passes,
988 )?)
989 } else {
990 None
991 };
992
993 Ok(ScenarioLibraries {
994 training: PhaseLibraries {
995 inflow_scheme,
996 load_scheme,
997 ncs_scheme,
998 historical: training_historical,
999 external_inflow: training_external_inflow,
1000 external_load: training_external_load,
1001 external_ncs: training_external_ncs,
1002 },
1003 simulation: PhaseLibraries {
1004 inflow_scheme: sim_inflow_scheme,
1005 load_scheme: sim_load_scheme,
1006 ncs_scheme: sim_ncs_scheme,
1007 historical: simulation_historical,
1008 external_inflow: simulation_external_inflow,
1009 external_load: simulation_external_load,
1010 external_ncs: simulation_external_ncs,
1011 },
1012 })
1013}
1014
1015fn max_iterations_from_rules(rules: &StoppingRuleSet) -> u64 {
1020 rules
1021 .rules
1022 .iter()
1023 .filter_map(|r| {
1024 if let StoppingRule::IterationLimit { limit } = r {
1025 Some(*limit)
1026 } else {
1027 None
1028 }
1029 })
1030 .max()
1031 .unwrap_or(DEFAULT_MAX_ITERATIONS)
1032}
1033
1034fn build_entity_counts(system: &System) -> EntityCounts {
1039 EntityCounts {
1040 hydro_ids: system.hydros().iter().map(|h| h.id.0).collect(),
1041 hydro_productivities: vec![0.0; system.hydros().len()],
1042 thermal_ids: system.thermals().iter().map(|t| t.id.0).collect(),
1043 line_ids: system.lines().iter().map(|l| l.id.0).collect(),
1044 bus_ids: system.buses().iter().map(|b| b.id.0).collect(),
1045 pumping_station_ids: system.pumping_stations().iter().map(|p| p.id.0).collect(),
1046 contract_ids: system.contracts().iter().map(|c| c.id.0).collect(),
1047 non_controllable_ids: system
1048 .non_controllable_sources()
1049 .iter()
1050 .map(|n| n.id.0)
1051 .collect(),
1052 }
1053}
1054
1055fn build_initial_state(system: &System, indexer: &StageIndexer) -> Vec<f64> {
1072 let mut state = vec![0.0_f64; indexer.n_state];
1073 let hydros = system.hydros();
1074 let ic = system.initial_conditions();
1075
1076 for hs in &ic.storage {
1077 if let Ok(idx) = hydros.binary_search_by_key(&hs.hydro_id.0, |h| h.id.0) {
1079 state[idx] = hs.value_hm3;
1080 }
1081 }
1082
1083 if indexer.max_par_order > 0 {
1084 let n_h = indexer.hydro_count;
1085 for pi in &ic.past_inflows {
1086 if let Ok(idx) = hydros.binary_search_by_key(&pi.hydro_id.0, |h| h.id.0) {
1087 let n_lags = pi.values_m3s.len().min(indexer.max_par_order);
1088 for lag in 0..n_lags {
1089 let slot = indexer.inflow_lags.start + lag * n_h + idx;
1090 state[slot] = pi.values_m3s[lag];
1091 }
1092 }
1093 }
1094 }
1095
1096 if indexer.n_anticipated > 0 && indexer.k_max > 0 {
1105 debug_assert_eq!(
1106 indexer.anticipated_thermal_indices.len(),
1107 indexer.n_anticipated,
1108 "anticipated_thermal_indices length must equal n_anticipated",
1109 );
1110 let thermals = system.thermals();
1111 let n_ant = indexer.n_anticipated;
1112 let ant_start = indexer.anticipated_state.start;
1113 for history in &ic.past_anticipated_commitments {
1114 let Ok(global_idx) = thermals.binary_search_by_key(&history.thermal_id.0, |t| t.id.0)
1118 else {
1119 continue;
1123 };
1124 let Some(local_idx) = indexer
1127 .anticipated_thermal_indices
1128 .iter()
1129 .position(|&g| g == global_idx)
1130 else {
1131 continue;
1134 };
1135 let k_i = indexer.anticipated_lead_stages[local_idx];
1139 let n_slots = history.values_mw.len().min(k_i);
1140 for slot in 0..n_slots {
1141 let off = ant_start + slot * n_ant + local_idx;
1142 state[off] = history.values_mw[slot];
1143 }
1144 #[allow(clippy::float_cmp)]
1147 for slot in k_i..indexer.k_max {
1148 let off = ant_start + slot * n_ant + local_idx;
1149 debug_assert_eq!(
1150 state[off], 0.0,
1151 "padding slot must be zero: plant local_idx={local_idx}, slot={slot}, K_i={k_i}, k_max={}",
1152 indexer.k_max
1153 );
1154 }
1155 }
1156 }
1157
1158 state
1159}
1160
1161#[cfg(test)]
1166mod tests {
1167 use super::StudySetup;
1168 use crate::hydro_models::{
1169 PrepareHydroModelsResult, ProductionModelSet, ResolvedProductionModel,
1170 };
1171 use crate::indexer::StageIndexer;
1172
1173 use cobre_core::{
1174 BoundsCountsSpec, BoundsDefaults, BusStagePenalties, ContractStageBounds, HydroStageBounds,
1175 HydroStagePenalties, LineStageBounds, LineStagePenalties, NcsStagePenalties,
1176 PenaltiesCountsSpec, PenaltiesDefaults, PumpingStageBounds, ResolvedBounds,
1177 ResolvedPenalties, ThermalStageBounds,
1178 };
1179 use cobre_core::{
1180 EntityId, SystemBuilder,
1181 entities::{
1182 bus::{Bus, DeficitSegment},
1183 hydro::{Hydro, HydroGenerationModel, HydroPenalties},
1184 thermal::{AnticipatedConfig, Thermal},
1185 },
1186 scenario::{InflowModel, LoadModel, SamplingScheme},
1187 temporal::{
1188 Block, BlockMode, NoiseMethod, ScenarioSourceConfig, Stage, StageRiskConfig,
1189 StageStateConfig,
1190 },
1191 };
1192 use cobre_io::config::{
1193 Config, EstimationConfig, ExportsConfig, InflowNonNegativityConfig,
1194 InflowNonNegativityMethod as CfgInflowMethod, ModelingConfig, PolicyConfig,
1195 RawClassConfigEntry, RawScenarioSourceConfig, RowSelectionConfig,
1196 SimulationConfig as IoSimulationConfig, StoppingRuleConfig, TrainingConfig,
1197 TrainingSolverConfig, UpperBoundEvaluationConfig,
1198 };
1199 use cobre_stochastic::{ClassSchemes, OpeningTreeInputs, build_stochastic_context};
1200
1201 #[allow(
1205 clippy::too_many_lines,
1206 clippy::cast_possible_truncation,
1207 clippy::cast_possible_wrap,
1208 clippy::items_after_statements
1209 )]
1210 fn minimal_system(n_stages: usize) -> cobre_core::System {
1211 use chrono::NaiveDate;
1212
1213 let bus = Bus {
1214 id: EntityId(1),
1215 name: "B1".to_string(),
1216 deficit_segments: vec![DeficitSegment {
1217 depth_mw: None,
1218 cost_per_mwh: 500.0,
1219 }],
1220 excess_cost: 0.0,
1221 };
1222
1223 let thermal = Thermal {
1224 id: EntityId(2),
1225 name: "T1".to_string(),
1226 bus_id: EntityId(1),
1227 min_generation_mw: 0.0,
1228 max_generation_mw: 100.0,
1229 cost_per_mwh: 50.0,
1230 anticipated_config: None,
1231 entry_stage_id: None,
1232 exit_stage_id: None,
1233 };
1234
1235 let hydro = Hydro {
1236 id: EntityId(3),
1237 name: "H1".to_string(),
1238 bus_id: EntityId(1),
1239 downstream_id: None,
1240 entry_stage_id: None,
1241 exit_stage_id: None,
1242 min_storage_hm3: 0.0,
1243 max_storage_hm3: 200.0,
1244 min_outflow_m3s: 0.0,
1245 max_outflow_m3s: None,
1246 generation_model: HydroGenerationModel::ConstantProductivity,
1247 min_turbined_m3s: 0.0,
1248 max_turbined_m3s: 100.0,
1249 specific_productivity_mw_per_m3s_per_m: None,
1250 min_generation_mw: 0.0,
1251 max_generation_mw: 250.0,
1252 tailrace: None,
1253 hydraulic_losses: None,
1254 efficiency: None,
1255 evaporation_coefficients_mm: None,
1256 evaporation_reference_volumes_hm3: None,
1257 diversion: None,
1258 filling: None,
1259 penalties: HydroPenalties {
1260 spillage_cost: 0.01,
1261 diversion_cost: 0.0,
1262 turbined_cost: 0.0,
1263 storage_violation_below_cost: 0.0,
1264 filling_target_violation_cost: 0.0,
1265 turbined_violation_below_cost: 0.0,
1266 outflow_violation_below_cost: 0.0,
1267 outflow_violation_above_cost: 0.0,
1268 generation_violation_below_cost: 0.0,
1269 evaporation_violation_cost: 0.0,
1270 water_withdrawal_violation_cost: 0.0,
1271 water_withdrawal_violation_pos_cost: 0.0,
1272 water_withdrawal_violation_neg_cost: 0.0,
1273 evaporation_violation_pos_cost: 0.0,
1274 evaporation_violation_neg_cost: 0.0,
1275 inflow_nonnegativity_cost: 1000.0,
1276 },
1277 };
1278
1279 let stages: Vec<Stage> = (0..n_stages)
1280 .map(|i| Stage {
1281 index: i,
1282 id: i as i32,
1283 start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1284 end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
1285 season_id: None,
1286 blocks: vec![Block {
1287 index: 0,
1288 name: "S".to_string(),
1289 duration_hours: 744.0,
1290 }],
1291 block_mode: BlockMode::Parallel,
1292 state_config: StageStateConfig {
1293 storage: true,
1294 inflow_lags: false,
1295 },
1296 risk_config: StageRiskConfig::Expectation,
1297 scenario_config: ScenarioSourceConfig {
1298 branching_factor: 1,
1299 noise_method: NoiseMethod::Saa,
1300 },
1301 })
1302 .collect();
1303
1304 let inflow_models: Vec<InflowModel> = (0..n_stages)
1305 .map(|i| InflowModel {
1306 hydro_id: EntityId(3),
1307 stage_id: i as i32,
1308 mean_m3s: 80.0,
1309 std_m3s: 20.0,
1310 ar_coefficients: vec![],
1311 residual_std_ratio: 1.0,
1312 annual: None,
1313 })
1314 .collect();
1315
1316 let load_models: Vec<LoadModel> = (0..n_stages)
1317 .map(|i| LoadModel {
1318 bus_id: EntityId(1),
1319 stage_id: i as i32,
1320 mean_mw: 100.0,
1321 std_mw: 0.0,
1322 })
1323 .collect();
1324
1325 let n_st = n_stages.max(1);
1326
1327 fn default_hydro_bounds() -> HydroStageBounds {
1328 HydroStageBounds {
1329 min_storage_hm3: 0.0,
1330 max_storage_hm3: 200.0,
1331 min_turbined_m3s: 0.0,
1332 max_turbined_m3s: 100.0,
1333 min_outflow_m3s: 0.0,
1334 max_outflow_m3s: None,
1335 min_generation_mw: 0.0,
1336 max_generation_mw: 250.0,
1337 max_diversion_m3s: None,
1338 filling_inflow_m3s: 0.0,
1339 water_withdrawal_m3s: 0.0,
1340 }
1341 }
1342
1343 fn default_hydro_penalties() -> HydroStagePenalties {
1344 HydroStagePenalties {
1345 spillage_cost: 0.01,
1346 diversion_cost: 0.0,
1347 turbined_cost: 0.0,
1348 storage_violation_below_cost: 500.0,
1349 filling_target_violation_cost: 0.0,
1350 turbined_violation_below_cost: 0.0,
1351 outflow_violation_below_cost: 0.0,
1352 outflow_violation_above_cost: 0.0,
1353 generation_violation_below_cost: 0.0,
1354 evaporation_violation_cost: 0.0,
1355 water_withdrawal_violation_cost: 0.0,
1356 water_withdrawal_violation_pos_cost: 0.0,
1357 water_withdrawal_violation_neg_cost: 0.0,
1358 evaporation_violation_pos_cost: 0.0,
1359 evaporation_violation_neg_cost: 0.0,
1360 inflow_nonnegativity_cost: 1000.0,
1361 }
1362 }
1363
1364 let bounds = ResolvedBounds::new(
1365 &BoundsCountsSpec {
1366 n_hydros: 1,
1367 n_thermals: 1,
1369 n_lines: 0,
1371 n_pumping: 0,
1373 n_contracts: 0,
1375 n_stages: n_st,
1377 k_max: 0,
1378 },
1379 &BoundsDefaults {
1380 hydro: default_hydro_bounds(),
1381 thermal: ThermalStageBounds {
1382 min_generation_mw: 0.0,
1383 max_generation_mw: 100.0,
1384 cost_per_mwh: 0.0,
1385 },
1386 line: LineStageBounds {
1387 direct_mw: 0.0,
1388 reverse_mw: 0.0,
1389 },
1390 pumping: PumpingStageBounds {
1391 min_flow_m3s: 0.0,
1392 max_flow_m3s: 0.0,
1393 },
1394 contract: ContractStageBounds {
1395 min_mw: 0.0,
1396 max_mw: 0.0,
1397 price_per_mwh: 0.0,
1398 },
1399 },
1400 );
1401
1402 let penalties = ResolvedPenalties::new(
1403 &PenaltiesCountsSpec {
1404 n_hydros: 1,
1405 n_buses: 1,
1407 n_lines: 0,
1409 n_ncs: 0,
1411 n_stages: n_st,
1413 },
1414 &PenaltiesDefaults {
1415 hydro: default_hydro_penalties(),
1416 bus: BusStagePenalties { excess_cost: 0.0 },
1417 line: LineStagePenalties { exchange_cost: 0.0 },
1418 ncs: NcsStagePenalties {
1419 curtailment_cost: 0.0,
1420 },
1421 },
1422 );
1423
1424 SystemBuilder::new()
1425 .buses(vec![bus])
1426 .thermals(vec![thermal])
1427 .hydros(vec![hydro])
1428 .stages(stages)
1429 .inflow_models(inflow_models)
1430 .load_models(load_models)
1431 .bounds(bounds)
1432 .penalties(penalties)
1433 .build()
1434 .expect("minimal_system: valid")
1435 }
1436
1437 #[allow(
1441 clippy::too_many_lines,
1442 clippy::cast_possible_truncation,
1443 clippy::cast_possible_wrap,
1444 clippy::items_after_statements
1445 )]
1446 fn minimal_fpha_misconfigured_system(n_stages: usize) -> cobre_core::System {
1447 use chrono::NaiveDate;
1448
1449 let bus = Bus {
1450 id: EntityId(1),
1451 name: "B1".to_string(),
1452 deficit_segments: vec![DeficitSegment {
1453 depth_mw: None,
1454 cost_per_mwh: 500.0,
1455 }],
1456 excess_cost: 0.0,
1457 };
1458
1459 let thermal = Thermal {
1460 id: EntityId(2),
1461 name: "T1".to_string(),
1462 bus_id: EntityId(1),
1463 min_generation_mw: 0.0,
1464 max_generation_mw: 100.0,
1465 cost_per_mwh: 50.0,
1466 anticipated_config: None,
1467 entry_stage_id: None,
1468 exit_stage_id: None,
1469 };
1470
1471 let hydro = Hydro {
1472 id: EntityId(3),
1473 name: "H_FPHA_BAD".to_string(),
1474 bus_id: EntityId(1),
1475 downstream_id: None,
1476 entry_stage_id: None,
1477 exit_stage_id: None,
1478 min_storage_hm3: 0.0,
1479 max_storage_hm3: 200.0,
1480 min_outflow_m3s: 0.0,
1481 max_outflow_m3s: None,
1482 generation_model: HydroGenerationModel::Fpha,
1483 min_turbined_m3s: 0.0,
1484 max_turbined_m3s: 100.0,
1485 specific_productivity_mw_per_m3s_per_m: None,
1486 min_generation_mw: 0.0,
1487 max_generation_mw: 250.0,
1488 tailrace: None,
1489 hydraulic_losses: None,
1490 efficiency: None,
1491 evaporation_coefficients_mm: None,
1492 evaporation_reference_volumes_hm3: None,
1493 diversion: None,
1494 filling: None,
1495 penalties: HydroPenalties {
1496 spillage_cost: 0.01,
1497 diversion_cost: 0.0,
1498 turbined_cost: 0.0,
1499 storage_violation_below_cost: 0.0,
1500 filling_target_violation_cost: 0.0,
1501 turbined_violation_below_cost: 0.0,
1502 outflow_violation_below_cost: 0.0,
1503 outflow_violation_above_cost: 0.0,
1504 generation_violation_below_cost: 0.0,
1505 evaporation_violation_cost: 0.0,
1506 water_withdrawal_violation_cost: 0.0,
1507 water_withdrawal_violation_pos_cost: 0.0,
1508 water_withdrawal_violation_neg_cost: 0.0,
1509 evaporation_violation_pos_cost: 0.0,
1510 evaporation_violation_neg_cost: 0.0,
1511 inflow_nonnegativity_cost: 1000.0,
1512 },
1513 };
1514
1515 let stages: Vec<Stage> = (0..n_stages)
1516 .map(|i| Stage {
1517 index: i,
1518 id: i as i32,
1519 start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1520 end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
1521 season_id: None,
1522 blocks: vec![Block {
1523 index: 0,
1524 name: "S".to_string(),
1525 duration_hours: 744.0,
1526 }],
1527 block_mode: BlockMode::Parallel,
1528 state_config: StageStateConfig {
1529 storage: true,
1530 inflow_lags: false,
1531 },
1532 risk_config: StageRiskConfig::Expectation,
1533 scenario_config: ScenarioSourceConfig {
1534 branching_factor: 1,
1535 noise_method: NoiseMethod::Saa,
1536 },
1537 })
1538 .collect();
1539
1540 let inflow_models: Vec<InflowModel> = (0..n_stages)
1541 .map(|i| InflowModel {
1542 hydro_id: EntityId(3),
1543 stage_id: i as i32,
1544 mean_m3s: 80.0,
1545 std_m3s: 20.0,
1546 ar_coefficients: vec![],
1547 residual_std_ratio: 1.0,
1548 annual: None,
1549 })
1550 .collect();
1551
1552 let load_models: Vec<LoadModel> = (0..n_stages)
1553 .map(|i| LoadModel {
1554 bus_id: EntityId(1),
1555 stage_id: i as i32,
1556 mean_mw: 100.0,
1557 std_mw: 0.0,
1558 })
1559 .collect();
1560
1561 let n_st = n_stages.max(1);
1562
1563 let bounds = ResolvedBounds::new(
1564 &BoundsCountsSpec {
1565 n_hydros: 1,
1566 n_thermals: 1,
1567 n_lines: 0,
1568 n_pumping: 0,
1569 n_contracts: 0,
1570 n_stages: n_st,
1571 k_max: 0,
1572 },
1573 &BoundsDefaults {
1574 hydro: HydroStageBounds {
1575 min_storage_hm3: 0.0,
1576 max_storage_hm3: 200.0,
1577 min_turbined_m3s: 0.0,
1578 max_turbined_m3s: 100.0,
1579 min_outflow_m3s: 0.0,
1580 max_outflow_m3s: None,
1581 min_generation_mw: 0.0,
1582 max_generation_mw: 250.0,
1583 max_diversion_m3s: None,
1584 filling_inflow_m3s: 0.0,
1585 water_withdrawal_m3s: 0.0,
1586 },
1587 thermal: ThermalStageBounds {
1588 min_generation_mw: 0.0,
1589 max_generation_mw: 100.0,
1590 cost_per_mwh: 0.0,
1591 },
1592 line: LineStageBounds {
1593 direct_mw: 0.0,
1594 reverse_mw: 0.0,
1595 },
1596 pumping: PumpingStageBounds {
1597 min_flow_m3s: 0.0,
1598 max_flow_m3s: 0.0,
1599 },
1600 contract: ContractStageBounds {
1601 min_mw: 0.0,
1602 max_mw: 0.0,
1603 price_per_mwh: 0.0,
1604 },
1605 },
1606 );
1607
1608 let penalties = ResolvedPenalties::new(
1609 &PenaltiesCountsSpec {
1610 n_hydros: 1,
1611 n_buses: 1,
1612 n_lines: 0,
1613 n_ncs: 0,
1614 n_stages: n_st,
1615 },
1616 &PenaltiesDefaults {
1617 hydro: HydroStagePenalties {
1618 spillage_cost: 0.01,
1619 diversion_cost: 0.0,
1620 turbined_cost: 0.0,
1621 storage_violation_below_cost: 500.0,
1622 filling_target_violation_cost: 0.0,
1623 turbined_violation_below_cost: 0.0,
1624 outflow_violation_below_cost: 0.0,
1625 outflow_violation_above_cost: 0.0,
1626 generation_violation_below_cost: 0.0,
1627 evaporation_violation_cost: 0.0,
1628 water_withdrawal_violation_cost: 0.0,
1629 water_withdrawal_violation_pos_cost: 0.0,
1630 water_withdrawal_violation_neg_cost: 0.0,
1631 evaporation_violation_pos_cost: 0.0,
1632 evaporation_violation_neg_cost: 0.0,
1633 inflow_nonnegativity_cost: 1000.0,
1634 },
1635 bus: BusStagePenalties { excess_cost: 0.0 },
1636 line: LineStagePenalties { exchange_cost: 0.0 },
1637 ncs: NcsStagePenalties {
1638 curtailment_cost: 0.0,
1639 },
1640 },
1641 );
1642
1643 SystemBuilder::new()
1644 .buses(vec![bus])
1645 .thermals(vec![thermal])
1646 .hydros(vec![hydro])
1647 .stages(stages)
1648 .inflow_models(inflow_models)
1649 .load_models(load_models)
1650 .bounds(bounds)
1651 .penalties(penalties)
1652 .build()
1653 .expect("minimal_fpha_misconfigured_system: valid")
1654 }
1655
1656 fn minimal_config(forward_passes: u32, max_iterations: u32) -> Config {
1658 Config {
1659 schema: None,
1660 modeling: ModelingConfig {
1661 inflow_non_negativity: InflowNonNegativityConfig {
1662 method: CfgInflowMethod::Penalty,
1663 },
1664 },
1665 training: TrainingConfig {
1666 enabled: true,
1667 tree_seed: Some(42),
1668 forward_passes: Some(forward_passes),
1669 stopping_rules: Some(vec![StoppingRuleConfig::IterationLimit {
1670 limit: max_iterations,
1671 }]),
1672 stopping_mode: "any".to_string(),
1673 cut_selection: RowSelectionConfig::default(),
1674 solver: TrainingSolverConfig::default(),
1675 scenario_source: None,
1676 },
1677 upper_bound_evaluation: UpperBoundEvaluationConfig::default(),
1678 policy: PolicyConfig::default(),
1679 simulation: IoSimulationConfig::default(),
1680 exports: ExportsConfig::default(),
1681 estimation: EstimationConfig::default(),
1682 }
1683 }
1684
1685 fn minimal_config_with_schemes(
1691 forward_passes: u32,
1692 max_iterations: u32,
1693 inflow_scheme: Option<&str>,
1694 load_scheme: Option<&str>,
1695 ncs_scheme: Option<&str>,
1696 ) -> Config {
1697 let needs_seed = inflow_scheme.is_some_and(|s| s != "in_sample")
1699 || load_scheme.is_some_and(|s| s != "in_sample")
1700 || ncs_scheme.is_some_and(|s| s != "in_sample");
1701 let scenario_source = RawScenarioSourceConfig {
1702 seed: if needs_seed { Some(42) } else { None },
1703 historical_years: None,
1704 inflow: inflow_scheme.map(|s| RawClassConfigEntry {
1705 scheme: s.to_string(),
1706 }),
1707 load: load_scheme.map(|s| RawClassConfigEntry {
1708 scheme: s.to_string(),
1709 }),
1710 ncs: ncs_scheme.map(|s| RawClassConfigEntry {
1711 scheme: s.to_string(),
1712 }),
1713 };
1714 let mut config = minimal_config(forward_passes, max_iterations);
1715 config.training.scenario_source = Some(scenario_source);
1716 config
1717 }
1718
1719 #[test]
1723 fn new_minimal_valid_system_returns_ok() {
1724 let system = minimal_system(2);
1725 let config = minimal_config(1, 10);
1726 let stochastic = build_stochastic_context(
1727 &system,
1728 42,
1729 None,
1730 &[],
1731 &[],
1732 OpeningTreeInputs::default(),
1733 ClassSchemes {
1734 inflow: Some(SamplingScheme::InSample),
1735 load: Some(SamplingScheme::InSample),
1736 ncs: Some(SamplingScheme::InSample),
1737 },
1738 )
1739 .expect("stochastic context");
1740
1741 let result = StudySetup::new(
1742 &system,
1743 &config,
1744 stochastic,
1745 PrepareHydroModelsResult::default_from_system(&system),
1746 );
1747 assert!(result.is_ok(), "expected Ok, got {result:?}");
1748 let setup = result.unwrap();
1749 assert!(!setup.stage_data.stage_templates.templates.is_empty());
1750 }
1751
1752 #[test]
1755 fn new_zero_stages_returns_validation_error() {
1756 let system = minimal_system(0);
1757 let config = minimal_config(1, 10);
1758 let stochastic = build_stochastic_context(
1759 &system,
1760 42,
1761 None,
1762 &[],
1763 &[],
1764 OpeningTreeInputs::default(),
1765 ClassSchemes {
1766 inflow: Some(SamplingScheme::InSample),
1767 load: Some(SamplingScheme::InSample),
1768 ncs: Some(SamplingScheme::InSample),
1769 },
1770 )
1771 .expect("stochastic context");
1772
1773 let result = StudySetup::new(
1774 &system,
1775 &config,
1776 stochastic,
1777 PrepareHydroModelsResult::default_from_system(&system),
1778 );
1779 assert!(result.is_err(), "expected Err, got Ok");
1780 let err = result.unwrap_err();
1781 let msg = err.to_string();
1782 assert!(
1783 msg.contains("no study stages"),
1784 "error message should contain 'no study stages': {msg}"
1785 );
1786 }
1787
1788 #[test]
1790 fn accessor_methods_return_expected_values() {
1791 let n_stages = 3;
1792 let system = minimal_system(n_stages);
1793 let config = minimal_config(2, 50);
1794 let stochastic = build_stochastic_context(
1795 &system,
1796 42,
1797 None,
1798 &[],
1799 &[],
1800 OpeningTreeInputs::default(),
1801 ClassSchemes {
1802 inflow: Some(SamplingScheme::InSample),
1803 load: Some(SamplingScheme::InSample),
1804 ncs: Some(SamplingScheme::InSample),
1805 },
1806 )
1807 .expect("stochastic context");
1808
1809 let setup = StudySetup::new(
1810 &system,
1811 &config,
1812 stochastic,
1813 PrepareHydroModelsResult::default_from_system(&system),
1814 )
1815 .expect("setup");
1816
1817 assert_eq!(setup.stage_data.stage_templates.templates.len(), n_stages);
1819 assert_eq!(setup.stage_data.stage_templates.base_rows.len(), n_stages);
1820
1821 assert_eq!(setup.loop_params.seed, 42);
1823 assert_eq!(setup.loop_params.forward_passes, 2);
1824 assert_eq!(setup.loop_params.max_iterations, 50);
1825 assert_eq!(setup.simulation_config.n_scenarios, 0); assert_eq!(setup.policy_path, "./policy");
1827
1828 assert_eq!(setup.stage_data.block_counts_per_stage.len(), n_stages);
1830 assert!(setup.loop_params.max_blocks > 0);
1831
1832 assert_eq!(setup.methodology.horizon.num_stages(), n_stages);
1834
1835 assert_eq!(setup.cut_management.risk_measures.len(), n_stages);
1837
1838 assert_eq!(setup.fcf.pools.len(), n_stages);
1840
1841 assert_eq!(setup.stage_data.entity_counts.hydro_ids.len(), 1);
1843 assert_eq!(setup.stage_data.entity_counts.thermal_ids.len(), 1);
1844 }
1845
1846 #[test]
1848 fn fcf_mut_allows_cut_insertion() {
1849 let system = minimal_system(2);
1850 let config = minimal_config(1, 10);
1851 let stochastic = build_stochastic_context(
1852 &system,
1853 42,
1854 None,
1855 &[],
1856 &[],
1857 OpeningTreeInputs::default(),
1858 ClassSchemes {
1859 inflow: Some(SamplingScheme::InSample),
1860 load: Some(SamplingScheme::InSample),
1861 ncs: Some(SamplingScheme::InSample),
1862 },
1863 )
1864 .expect("stochastic context");
1865
1866 let mut setup = StudySetup::new(
1867 &system,
1868 &config,
1869 stochastic,
1870 PrepareHydroModelsResult::default_from_system(&system),
1871 )
1872 .expect("setup");
1873
1874 let n_state = setup.stage_data.indexer.n_state;
1875 let coefficients = vec![1.0_f64; n_state];
1876 setup.fcf.add_cut(0, 0, 0, 42.0, &coefficients);
1877 assert_eq!(setup.fcf.total_active_cuts(), 1);
1878 }
1879
1880 #[test]
1882 fn inflow_method_reflects_config() {
1883 use crate::InflowNonNegativityMethod;
1884
1885 let system = minimal_system(2);
1886 let config = minimal_config(1, 10);
1887 let stochastic = build_stochastic_context(
1888 &system,
1889 42,
1890 None,
1891 &[],
1892 &[],
1893 OpeningTreeInputs::default(),
1894 ClassSchemes {
1895 inflow: Some(SamplingScheme::InSample),
1896 load: Some(SamplingScheme::InSample),
1897 ncs: Some(SamplingScheme::InSample),
1898 },
1899 )
1900 .expect("stochastic context");
1901
1902 let setup = StudySetup::new(
1903 &system,
1904 &config,
1905 stochastic,
1906 PrepareHydroModelsResult::default_from_system(&system),
1907 )
1908 .expect("setup");
1909
1910 assert!(
1912 !matches!(
1913 setup.methodology.inflow_method,
1914 InflowNonNegativityMethod::None
1915 ),
1916 "expected penalty or truncation method"
1917 );
1918 }
1919
1920 #[test]
1922 fn cut_selection_none_when_disabled() {
1923 let system = minimal_system(2);
1924 let config = minimal_config(1, 10);
1925 let stochastic = build_stochastic_context(
1926 &system,
1927 42,
1928 None,
1929 &[],
1930 &[],
1931 OpeningTreeInputs::default(),
1932 ClassSchemes {
1933 inflow: Some(SamplingScheme::InSample),
1934 load: Some(SamplingScheme::InSample),
1935 ncs: Some(SamplingScheme::InSample),
1936 },
1937 )
1938 .expect("stochastic context");
1939
1940 let setup = StudySetup::new(
1941 &system,
1942 &config,
1943 stochastic,
1944 PrepareHydroModelsResult::default_from_system(&system),
1945 )
1946 .expect("setup");
1947
1948 assert!(
1949 setup.cut_management.cut_selection.is_none(),
1950 "cut_selection should be None when disabled"
1951 );
1952 }
1953
1954 #[test]
1955 fn stage_ctx_fields_match_study_setup() {
1956 let n_stages = 3;
1957 let system = minimal_system(n_stages);
1958 let config = minimal_config(2, 10);
1959 let stochastic = build_stochastic_context(
1960 &system,
1961 42,
1962 None,
1963 &[],
1964 &[],
1965 OpeningTreeInputs::default(),
1966 ClassSchemes {
1967 inflow: Some(SamplingScheme::InSample),
1968 load: Some(SamplingScheme::InSample),
1969 ncs: Some(SamplingScheme::InSample),
1970 },
1971 )
1972 .expect("stochastic context");
1973
1974 let setup = StudySetup::new(
1975 &system,
1976 &config,
1977 stochastic,
1978 PrepareHydroModelsResult::default_from_system(&system),
1979 )
1980 .expect("setup");
1981 let ctx = setup.stage_ctx();
1982
1983 assert_eq!(
1984 ctx.templates.len(),
1985 setup.stage_data.stage_templates.templates.len(),
1986 "templates length mismatch"
1987 );
1988 assert_eq!(
1989 ctx.base_rows.len(),
1990 setup.stage_data.stage_templates.base_rows.len(),
1991 "base_rows length mismatch"
1992 );
1993 assert_eq!(
1994 ctx.noise_scale.len(),
1995 setup.stage_data.stage_templates.noise_scale.len(),
1996 "noise_scale length mismatch"
1997 );
1998 assert_eq!(
1999 ctx.n_hydros,
2000 setup.stage_data.entity_counts.hydro_ids.len(),
2001 "n_hydros mismatch"
2002 );
2003 assert_eq!(
2004 ctx.block_counts_per_stage.len(),
2005 setup.stage_data.block_counts_per_stage.len(),
2006 "block_counts_per_stage length mismatch"
2007 );
2008 }
2009
2010 #[test]
2011 fn training_ctx_fields_match_study_setup() {
2012 let n_stages = 3;
2013 let system = minimal_system(n_stages);
2014 let config = minimal_config(2, 10);
2015 let stochastic = build_stochastic_context(
2016 &system,
2017 42,
2018 None,
2019 &[],
2020 &[],
2021 OpeningTreeInputs::default(),
2022 ClassSchemes {
2023 inflow: Some(SamplingScheme::InSample),
2024 load: Some(SamplingScheme::InSample),
2025 ncs: Some(SamplingScheme::InSample),
2026 },
2027 )
2028 .expect("stochastic context");
2029
2030 let setup = StudySetup::new(
2031 &system,
2032 &config,
2033 stochastic,
2034 PrepareHydroModelsResult::default_from_system(&system),
2035 )
2036 .expect("setup");
2037 let ctx = setup.training_ctx();
2038
2039 assert_eq!(
2040 ctx.horizon.num_stages(),
2041 setup.methodology.horizon.num_stages(),
2042 "horizon num_stages mismatch"
2043 );
2044 assert_eq!(
2045 ctx.indexer.n_state, setup.stage_data.indexer.n_state,
2046 "indexer n_state mismatch"
2047 );
2048 assert_eq!(
2049 ctx.initial_state.len(),
2050 setup.initial_state.len(),
2051 "initial_state length mismatch"
2052 );
2053 }
2054
2055 #[test]
2056 fn simulation_ctx_propagates_dynamic_dcs_from_setup() {
2057 let n_stages = 3;
2058 let system = minimal_system(n_stages);
2059 let mut config = minimal_config(2, 10);
2060 config.training.cut_selection = RowSelectionConfig {
2063 selection: Some(cobre_io::config::SelectionMethod::Dynamic {
2064 start_iteration: 2,
2065 seed_window: 5,
2066 candidate_recency: None,
2067 max_added_per_round: 10,
2068 violation_tolerance: 1e-10,
2069 }),
2070 ..RowSelectionConfig::default()
2071 };
2072 let stochastic = build_stochastic_context(
2073 &system,
2074 42,
2075 None,
2076 &[],
2077 &[],
2078 OpeningTreeInputs::default(),
2079 ClassSchemes {
2080 inflow: Some(SamplingScheme::InSample),
2081 load: Some(SamplingScheme::InSample),
2082 ncs: Some(SamplingScheme::InSample),
2083 },
2084 )
2085 .expect("stochastic context");
2086
2087 let setup = StudySetup::new(
2088 &system,
2089 &config,
2090 stochastic,
2091 PrepareHydroModelsResult::default_from_system(&system),
2092 )
2093 .expect("setup");
2094 let ctx = setup.simulation_ctx();
2095
2096 let expected = crate::dcs::DcsParams {
2099 k1: None,
2100 k2: 5,
2101 nadic: 10,
2102 epsilon_viol: 1e-10,
2103 start_iteration: 2,
2104 max_inner_iterations: crate::dcs::DcsParams::default().max_inner_iterations,
2105 };
2106 assert_eq!(
2107 ctx.dcs,
2108 Some(expected),
2109 "simulation_ctx().dcs must carry the configured dynamic DcsParams, got {:?}",
2110 ctx.dcs
2111 );
2112 }
2113
2114 #[test]
2118 fn train_completes_within_iteration_limit() {
2119 use cobre_comm::LocalBackend;
2120 use cobre_solver::ActiveSolver;
2121
2122 let system = minimal_system(2);
2123 let config = minimal_config(1, 3);
2124 let stochastic = build_stochastic_context(
2125 &system,
2126 42,
2127 None,
2128 &[],
2129 &[],
2130 OpeningTreeInputs::default(),
2131 ClassSchemes {
2132 inflow: Some(SamplingScheme::InSample),
2133 load: Some(SamplingScheme::InSample),
2134 ncs: Some(SamplingScheme::InSample),
2135 },
2136 )
2137 .expect("stochastic context");
2138
2139 let mut setup = StudySetup::new(
2140 &system,
2141 &config,
2142 stochastic,
2143 PrepareHydroModelsResult::default_from_system(&system),
2144 )
2145 .expect("setup");
2146 let comm = LocalBackend;
2147 let mut solver = ActiveSolver::new().expect("solver");
2148
2149 let result = setup
2150 .train(&mut solver, &comm, 1, ActiveSolver::new, None, None)
2151 .expect("train");
2152
2153 assert!(
2154 result.result.iterations <= 3,
2155 "expected iterations <= 3, got {}",
2156 result.result.iterations
2157 );
2158 assert!(
2159 result.result.iterations >= 1,
2160 "expected at least 1 iteration, got {}",
2161 result.result.iterations
2162 );
2163 }
2164
2165 #[test]
2168 fn train_generates_cuts_in_fcf() {
2169 use cobre_comm::LocalBackend;
2170 use cobre_solver::ActiveSolver;
2171
2172 let system = minimal_system(2);
2173 let config = minimal_config(1, 3);
2174 let stochastic = build_stochastic_context(
2175 &system,
2176 42,
2177 None,
2178 &[],
2179 &[],
2180 OpeningTreeInputs::default(),
2181 ClassSchemes {
2182 inflow: Some(SamplingScheme::InSample),
2183 load: Some(SamplingScheme::InSample),
2184 ncs: Some(SamplingScheme::InSample),
2185 },
2186 )
2187 .expect("stochastic context");
2188
2189 let mut setup = StudySetup::new(
2190 &system,
2191 &config,
2192 stochastic,
2193 PrepareHydroModelsResult::default_from_system(&system),
2194 )
2195 .expect("setup");
2196 let comm = LocalBackend;
2197 let mut solver = ActiveSolver::new().expect("solver");
2198
2199 setup
2200 .train(&mut solver, &comm, 1, ActiveSolver::new, None, None)
2201 .expect("train");
2202
2203 assert!(
2204 setup.fcf.pools[0].populated_count > 0,
2205 "expected at least one cut in FCF pool[0] after training"
2206 );
2207 }
2208
2209 #[test]
2212 fn simulation_config_reflects_setup_fields() {
2213 use cobre_io::config::SimulationConfig as IoSimulationConfig;
2214
2215 let mut config = minimal_config(1, 5);
2217 config.simulation = IoSimulationConfig {
2218 enabled: true,
2219 num_scenarios: 50,
2220 io_channel_capacity: 16,
2221 ..IoSimulationConfig::default()
2222 };
2223
2224 let system = minimal_system(2);
2225 let stochastic = build_stochastic_context(
2226 &system,
2227 42,
2228 None,
2229 &[],
2230 &[],
2231 OpeningTreeInputs::default(),
2232 ClassSchemes {
2233 inflow: Some(SamplingScheme::InSample),
2234 load: Some(SamplingScheme::InSample),
2235 ncs: Some(SamplingScheme::InSample),
2236 },
2237 )
2238 .expect("stochastic context");
2239
2240 let setup = StudySetup::new(
2241 &system,
2242 &config,
2243 stochastic,
2244 PrepareHydroModelsResult::default_from_system(&system),
2245 )
2246 .expect("setup");
2247
2248 let sim_cfg = setup.simulation_config();
2249 assert_eq!(sim_cfg.n_scenarios, setup.simulation_config.n_scenarios);
2250 assert_eq!(
2251 sim_cfg.io_channel_capacity,
2252 setup.simulation_config.io_channel_capacity
2253 );
2254 }
2255
2256 #[test]
2259 fn create_workspace_pool_returns_correct_size() {
2260 use cobre_comm::LocalBackend;
2261 use cobre_solver::ActiveSolver;
2262
2263 let system = minimal_system(2);
2264 let config = minimal_config(1, 3);
2265 let stochastic = build_stochastic_context(
2266 &system,
2267 42,
2268 None,
2269 &[],
2270 &[],
2271 OpeningTreeInputs::default(),
2272 ClassSchemes {
2273 inflow: Some(SamplingScheme::InSample),
2274 load: Some(SamplingScheme::InSample),
2275 ncs: Some(SamplingScheme::InSample),
2276 },
2277 )
2278 .expect("stochastic context");
2279
2280 let setup = StudySetup::new(
2281 &system,
2282 &config,
2283 stochastic,
2284 PrepareHydroModelsResult::default_from_system(&system),
2285 )
2286 .expect("setup");
2287
2288 let comm = LocalBackend;
2289 let pool = setup
2290 .create_workspace_pool(&comm, 2, ActiveSolver::new)
2291 .expect("workspace pool");
2292
2293 assert_eq!(pool.workspaces.len(), 2);
2294 }
2295
2296 #[test]
2300 fn build_training_output_non_empty() {
2301 use cobre_comm::LocalBackend;
2302 use cobre_solver::ActiveSolver;
2303
2304 let system = minimal_system(2);
2305 let config = minimal_config(1, 2);
2306 let stochastic = build_stochastic_context(
2307 &system,
2308 42,
2309 None,
2310 &[],
2311 &[],
2312 OpeningTreeInputs::default(),
2313 ClassSchemes {
2314 inflow: Some(SamplingScheme::InSample),
2315 load: Some(SamplingScheme::InSample),
2316 ncs: Some(SamplingScheme::InSample),
2317 },
2318 )
2319 .expect("stochastic context");
2320
2321 let mut setup = StudySetup::new(
2322 &system,
2323 &config,
2324 stochastic,
2325 PrepareHydroModelsResult::default_from_system(&system),
2326 )
2327 .expect("setup");
2328 let comm = LocalBackend;
2329 let mut solver = ActiveSolver::new().expect("solver");
2330
2331 let (event_tx, event_rx) = std::sync::mpsc::channel();
2333 let result = setup
2334 .train(
2335 &mut solver,
2336 &comm,
2337 1,
2338 ActiveSolver::new,
2339 Some(event_tx),
2340 None,
2341 )
2342 .expect("train");
2343
2344 let events: Vec<cobre_core::TrainingEvent> = event_rx.try_iter().collect();
2345
2346 let output = setup.build_training_output(&result.result, &events);
2347 assert!(
2348 !output.convergence_records.is_empty(),
2349 "convergence_records must be non-empty after training"
2350 );
2351 }
2352
2353 #[test]
2356 fn simulate_after_train_returns_nonempty_costs() {
2357 use cobre_comm::LocalBackend;
2358 use cobre_solver::ActiveSolver;
2359
2360 let mut config = minimal_config(1, 3);
2362 config.simulation = cobre_io::config::SimulationConfig {
2363 enabled: true,
2364 num_scenarios: 3,
2365 io_channel_capacity: 8,
2366 ..cobre_io::config::SimulationConfig::default()
2367 };
2368
2369 let system = minimal_system(2);
2370 let stochastic = build_stochastic_context(
2371 &system,
2372 42,
2373 None,
2374 &[],
2375 &[],
2376 OpeningTreeInputs::default(),
2377 ClassSchemes {
2378 inflow: Some(SamplingScheme::InSample),
2379 load: Some(SamplingScheme::InSample),
2380 ncs: Some(SamplingScheme::InSample),
2381 },
2382 )
2383 .expect("stochastic context");
2384
2385 let mut setup = StudySetup::new(
2386 &system,
2387 &config,
2388 stochastic,
2389 PrepareHydroModelsResult::default_from_system(&system),
2390 )
2391 .expect("setup");
2392
2393 let comm = LocalBackend;
2395 let mut solver = ActiveSolver::new().expect("solver");
2396 setup
2397 .train(&mut solver, &comm, 1, ActiveSolver::new, None, None)
2398 .expect("train");
2399
2400 let mut pool = setup
2402 .create_workspace_pool(&comm, 1, ActiveSolver::new)
2403 .expect("sim pool");
2404
2405 let io_capacity = setup.simulation_config.io_channel_capacity.max(1);
2407 let (result_tx, result_rx) = std::sync::mpsc::sync_channel(io_capacity);
2408 let drain_handle = std::thread::spawn(move || result_rx.into_iter().collect::<Vec<_>>());
2409
2410 let sim_result = setup
2411 .simulate(&mut pool.workspaces, &comm, &result_tx, None, None, &[])
2412 .expect("simulate");
2413
2414 drop(result_tx);
2416 let _results = drain_handle.join().expect("drain thread");
2417
2418 assert!(
2419 !sim_result.costs.is_empty(),
2420 "simulate must return at least one cost entry"
2421 );
2422 assert_eq!(
2423 sim_result.solver_stats.len(),
2424 sim_result.costs.len(),
2425 "one solver stats entry per scenario"
2426 );
2427 }
2428
2429 #[test]
2432 fn study_params_from_config_defaults() {
2433 use super::{DEFAULT_FORWARD_PASSES, DEFAULT_SEED, StudyParams};
2434 use crate::stopping_rule::StoppingMode;
2435 use cobre_io::config::{
2436 Config, EstimationConfig, ExportsConfig, InflowNonNegativityConfig,
2437 InflowNonNegativityMethod as CfgInflowMethod, ModelingConfig, PolicyConfig,
2438 RowSelectionConfig, SimulationConfig as IoSimulationConfig, TrainingConfig,
2439 TrainingSolverConfig, UpperBoundEvaluationConfig,
2440 };
2441
2442 let config = Config {
2443 schema: None,
2444 modeling: ModelingConfig {
2445 inflow_non_negativity: InflowNonNegativityConfig {
2446 method: CfgInflowMethod::None,
2447 },
2448 },
2449 training: TrainingConfig {
2450 enabled: true,
2451 tree_seed: None,
2452 forward_passes: None,
2453 stopping_rules: None,
2454 stopping_mode: "any".to_string(),
2455 cut_selection: RowSelectionConfig::default(),
2456 solver: TrainingSolverConfig::default(),
2457 scenario_source: None,
2458 },
2459 upper_bound_evaluation: UpperBoundEvaluationConfig::default(),
2460 policy: PolicyConfig::default(),
2461 simulation: IoSimulationConfig::default(),
2462 exports: ExportsConfig::default(),
2463 estimation: EstimationConfig::default(),
2464 };
2465
2466 let params = StudyParams::from_config(&config).expect("from_config");
2467
2468 assert_eq!(
2469 params.seed, DEFAULT_SEED,
2470 "seed should default to DEFAULT_SEED"
2471 );
2472 assert_eq!(
2473 params.forward_passes, DEFAULT_FORWARD_PASSES,
2474 "forward_passes should default to DEFAULT_FORWARD_PASSES"
2475 );
2476 assert_eq!(
2478 params.stopping_rule_set.rules.len(),
2479 1,
2480 "expected exactly 1 default stopping rule"
2481 );
2482 assert!(
2483 matches!(
2484 params.stopping_rule_set.rules[0],
2485 crate::stopping_rule::StoppingRule::IterationLimit { .. }
2486 ),
2487 "default rule should be IterationLimit"
2488 );
2489 assert!(
2490 matches!(params.stopping_rule_set.mode, StoppingMode::Any),
2491 "default stopping mode should be Any"
2492 );
2493 assert_eq!(
2495 params.n_scenarios, 0,
2496 "n_scenarios should be 0 when simulation disabled"
2497 );
2498 assert!(
2499 params.cut_selection.is_none(),
2500 "cut_selection should be None by default"
2501 );
2502 }
2503
2504 #[test]
2507 fn study_params_from_config_explicit() {
2508 use super::StudyParams;
2509 use crate::stopping_rule::{StoppingMode, StoppingRule};
2510 use cobre_io::config::{
2511 Config, EstimationConfig, ExportsConfig, InflowNonNegativityConfig,
2512 InflowNonNegativityMethod as CfgInflowMethod, ModelingConfig, PolicyConfig,
2513 RowSelectionConfig, SimulationConfig as IoSimulationConfig, StoppingRuleConfig,
2514 TrainingConfig, TrainingSolverConfig, UpperBoundEvaluationConfig,
2515 };
2516
2517 let config = Config {
2518 schema: None,
2519 modeling: ModelingConfig {
2520 inflow_non_negativity: InflowNonNegativityConfig {
2521 method: CfgInflowMethod::Penalty,
2522 },
2523 },
2524 training: TrainingConfig {
2525 enabled: true,
2526 tree_seed: Some(1234),
2527 forward_passes: Some(5),
2528 stopping_rules: Some(vec![
2529 StoppingRuleConfig::IterationLimit { limit: 50 },
2530 StoppingRuleConfig::TimeLimit { seconds: 60.0 },
2531 ]),
2532 stopping_mode: "all".to_string(),
2533 cut_selection: RowSelectionConfig::default(),
2534 solver: TrainingSolverConfig::default(),
2535 scenario_source: None,
2536 },
2537 upper_bound_evaluation: UpperBoundEvaluationConfig::default(),
2538 policy: PolicyConfig {
2539 path: "./my_policy".to_string(),
2540 ..PolicyConfig::default()
2541 },
2542 simulation: IoSimulationConfig {
2543 enabled: true,
2544 num_scenarios: 200,
2545 ..IoSimulationConfig::default()
2546 },
2547 exports: ExportsConfig::default(),
2548 estimation: EstimationConfig::default(),
2549 };
2550
2551 let params = StudyParams::from_config(&config).expect("from_config");
2552
2553 assert_eq!(params.seed, 1234, "seed mismatch");
2555 assert_eq!(params.forward_passes, 5, "forward_passes mismatch");
2556 assert_eq!(
2558 params.stopping_rule_set.rules.len(),
2559 2,
2560 "stopping rule count mismatch"
2561 );
2562 assert!(
2563 matches!(
2564 params.stopping_rule_set.rules[0],
2565 StoppingRule::IterationLimit { limit: 50 }
2566 ),
2567 "first rule should be IterationLimit(50)"
2568 );
2569 assert!(
2570 matches!(
2571 params.stopping_rule_set.rules[1],
2572 StoppingRule::TimeLimit { seconds } if (seconds - 60.0).abs() < 1e-9
2573 ),
2574 "second rule should be TimeLimit(60.0)"
2575 );
2576 assert!(
2577 matches!(params.stopping_rule_set.mode, StoppingMode::All),
2578 "stopping mode should be All"
2579 );
2580 assert_eq!(params.n_scenarios, 200, "n_scenarios mismatch");
2581 assert_eq!(params.policy_path, "./my_policy", "policy_path mismatch");
2582 }
2583
2584 fn write_minimal_case_dir(root: &std::path::Path) {
2588 use std::fs;
2589
2590 fs::create_dir_all(root.join("system")).unwrap();
2591 fs::write(root.join("config.json"), b"{}").unwrap();
2592 fs::write(root.join("penalties.json"), b"{}").unwrap();
2593 fs::write(root.join("stages.json"), b"{}").unwrap();
2594 fs::write(root.join("initial_conditions.json"), b"{}").unwrap();
2595 fs::write(root.join("system/buses.json"), b"{}").unwrap();
2596 fs::write(root.join("system/lines.json"), b"{}").unwrap();
2597 fs::write(root.join("system/hydros.json"), b"{}").unwrap();
2598 fs::write(root.join("system/thermals.json"), b"{}").unwrap();
2599 }
2600
2601 fn minimal_prepare_config() -> cobre_io::Config {
2603 use cobre_io::config::{
2604 Config, EstimationConfig, ExportsConfig, InflowNonNegativityConfig,
2605 InflowNonNegativityMethod as CfgInflowMethod, ModelingConfig, PolicyConfig,
2606 RowSelectionConfig, SimulationConfig as IoSimulationConfig, TrainingConfig,
2607 TrainingSolverConfig, UpperBoundEvaluationConfig,
2608 };
2609
2610 Config {
2611 schema: None,
2612 modeling: ModelingConfig {
2613 inflow_non_negativity: InflowNonNegativityConfig {
2614 method: CfgInflowMethod::None,
2615 },
2616 },
2617 training: TrainingConfig {
2618 enabled: true,
2619 tree_seed: None,
2620 forward_passes: None,
2621 stopping_rules: None,
2622 stopping_mode: "any".to_string(),
2623 cut_selection: RowSelectionConfig::default(),
2624 solver: TrainingSolverConfig::default(),
2625 scenario_source: None,
2626 },
2627 upper_bound_evaluation: UpperBoundEvaluationConfig::default(),
2628 policy: PolicyConfig::default(),
2629 simulation: IoSimulationConfig::default(),
2630 exports: ExportsConfig::default(),
2631 estimation: EstimationConfig::default(),
2632 }
2633 }
2634
2635 #[test]
2639 fn prepare_stochastic_no_history_no_tree_returns_none_report_and_generated_provenance() {
2640 use super::prepare_stochastic;
2641 use cobre_core::scenario::ScenarioSource;
2642 use cobre_stochastic::provenance::ComponentProvenance;
2643 use tempfile::TempDir;
2644
2645 let dir = TempDir::new().unwrap();
2646 let root = dir.path();
2647 write_minimal_case_dir(root);
2648
2649 let system = minimal_system(2);
2650 let config = minimal_prepare_config();
2651 let seed = 42_u64;
2652
2653 let source = ScenarioSource {
2654 inflow_scheme: SamplingScheme::InSample,
2655 load_scheme: SamplingScheme::InSample,
2656 ncs_scheme: SamplingScheme::InSample,
2657 seed: None,
2658 historical_years: None,
2659 };
2660 let result = prepare_stochastic(system, root, &config, seed, &source)
2661 .expect("prepare_stochastic should succeed with no optional files");
2662
2663 assert!(
2664 result.estimation_report.is_none(),
2665 "estimation_report must be None when no inflow_history.parquet is present"
2666 );
2667 assert_eq!(
2668 result.stochastic.provenance().opening_tree,
2669 ComponentProvenance::Generated,
2670 "opening_tree provenance must be Generated when no user tree is supplied"
2671 );
2672 }
2673
2674 #[test]
2678 fn prepare_stochastic_with_stats_file_present_skips_estimation() {
2679 use super::prepare_stochastic;
2680 use cobre_core::scenario::ScenarioSource;
2681 use std::fs;
2682 use tempfile::TempDir;
2683
2684 let dir = TempDir::new().unwrap();
2685 let root = dir.path();
2686 write_minimal_case_dir(root);
2687
2688 fs::create_dir_all(root.join("scenarios")).unwrap();
2694 fs::write(root.join("scenarios/inflow_seasonal_stats.parquet"), b"").unwrap();
2695 fs::write(root.join("scenarios/inflow_ar_coefficients.parquet"), b"").unwrap();
2696
2697 let system = minimal_system(2);
2698 let config = minimal_prepare_config();
2699 let seed = 42_u64;
2700
2701 let source = ScenarioSource {
2702 inflow_scheme: SamplingScheme::InSample,
2703 load_scheme: SamplingScheme::InSample,
2704 ncs_scheme: SamplingScheme::InSample,
2705 seed: None,
2706 historical_years: None,
2707 };
2708 let result = prepare_stochastic(system, root, &config, seed, &source)
2709 .expect("prepare_stochastic should succeed when stats file is present");
2710
2711 assert!(
2712 result.estimation_report.is_none(),
2713 "estimation_report must be None when inflow_seasonal_stats.parquet is present"
2714 );
2715 }
2716
2717 #[test]
2723 fn prepare_stochastic_no_opening_tree_gives_non_user_supplied_provenance() {
2724 use super::prepare_stochastic;
2725 use cobre_core::scenario::ScenarioSource;
2726 use cobre_stochastic::provenance::ComponentProvenance;
2727 use tempfile::TempDir;
2728
2729 let dir = TempDir::new().unwrap();
2730 let root = dir.path();
2731 write_minimal_case_dir(root);
2732
2733 let system = minimal_system(2);
2734 let config = minimal_prepare_config();
2735
2736 let source = ScenarioSource {
2737 inflow_scheme: SamplingScheme::InSample,
2738 load_scheme: SamplingScheme::InSample,
2739 ncs_scheme: SamplingScheme::InSample,
2740 seed: None,
2741 historical_years: None,
2742 };
2743 let result = prepare_stochastic(system, root, &config, 0, &source)
2744 .expect("prepare_stochastic must succeed with no opening tree file");
2745
2746 assert_ne!(
2747 result.stochastic.provenance().opening_tree,
2748 ComponentProvenance::UserSupplied,
2749 "opening_tree provenance must not be UserSupplied when file is absent"
2750 );
2751 }
2752
2753 #[test]
2758 #[allow(
2759 clippy::too_many_lines,
2760 clippy::cast_possible_truncation,
2761 clippy::cast_possible_wrap
2762 )]
2763 fn test_prepare_stochastic_historical_residuals_noise_method() {
2764 use super::prepare_stochastic;
2765 use chrono::NaiveDate;
2766 use cobre_core::{
2767 scenario::{InflowHistoryRow, ScenarioSource},
2768 system::SystemBuilder,
2769 };
2770 use tempfile::TempDir;
2771
2772 let n_stages = 2usize;
2776
2777 let bus = Bus {
2778 id: EntityId(1),
2779 name: "B1".to_string(),
2780 deficit_segments: vec![DeficitSegment {
2781 depth_mw: None,
2782 cost_per_mwh: 500.0,
2783 }],
2784 excess_cost: 0.0,
2785 };
2786 let thermal = Thermal {
2787 id: EntityId(2),
2788 name: "T1".to_string(),
2789 bus_id: EntityId(1),
2790 min_generation_mw: 0.0,
2791 max_generation_mw: 100.0,
2792 cost_per_mwh: 50.0,
2793 anticipated_config: None,
2794 entry_stage_id: None,
2795 exit_stage_id: None,
2796 };
2797 let hydro = Hydro {
2798 id: EntityId(3),
2799 name: "H1".to_string(),
2800 bus_id: EntityId(1),
2801 downstream_id: None,
2802 entry_stage_id: None,
2803 exit_stage_id: None,
2804 min_storage_hm3: 0.0,
2805 max_storage_hm3: 200.0,
2806 min_outflow_m3s: 0.0,
2807 max_outflow_m3s: None,
2808 generation_model: HydroGenerationModel::ConstantProductivity,
2809 min_turbined_m3s: 0.0,
2810 max_turbined_m3s: 100.0,
2811 specific_productivity_mw_per_m3s_per_m: None,
2812 min_generation_mw: 0.0,
2813 max_generation_mw: 250.0,
2814 tailrace: None,
2815 hydraulic_losses: None,
2816 efficiency: None,
2817 evaporation_coefficients_mm: None,
2818 evaporation_reference_volumes_hm3: None,
2819 diversion: None,
2820 filling: None,
2821 penalties: HydroPenalties {
2822 spillage_cost: 0.01,
2823 diversion_cost: 0.0,
2824 turbined_cost: 0.0,
2825 storage_violation_below_cost: 0.0,
2826 filling_target_violation_cost: 0.0,
2827 turbined_violation_below_cost: 0.0,
2828 outflow_violation_below_cost: 0.0,
2829 outflow_violation_above_cost: 0.0,
2830 generation_violation_below_cost: 0.0,
2831 evaporation_violation_cost: 0.0,
2832 water_withdrawal_violation_cost: 0.0,
2833 water_withdrawal_violation_pos_cost: 0.0,
2834 water_withdrawal_violation_neg_cost: 0.0,
2835 evaporation_violation_pos_cost: 0.0,
2836 evaporation_violation_neg_cost: 0.0,
2837 inflow_nonnegativity_cost: 1000.0,
2838 },
2839 };
2840
2841 let stages: Vec<Stage> = (0..n_stages)
2844 .map(|i| Stage {
2845 index: i,
2846 id: i as i32,
2847 start_date: NaiveDate::from_ymd_opt(2024, (i as u32 % 12) + 1, 1).unwrap(),
2848 end_date: NaiveDate::from_ymd_opt(2024, (i as u32 % 12) + 1, 28).unwrap(),
2849 season_id: Some(i % 12),
2850 blocks: vec![Block {
2851 index: 0,
2852 name: "S".to_string(),
2853 duration_hours: 720.0,
2854 }],
2855 block_mode: BlockMode::Parallel,
2856 state_config: StageStateConfig {
2857 storage: true,
2858 inflow_lags: false,
2859 },
2860 risk_config: StageRiskConfig::Expectation,
2861 scenario_config: ScenarioSourceConfig {
2862 branching_factor: 2,
2863 noise_method: NoiseMethod::HistoricalResiduals,
2864 },
2865 })
2866 .collect();
2867
2868 let inflow_models: Vec<InflowModel> = (0..n_stages)
2869 .map(|i| InflowModel {
2870 hydro_id: EntityId(3),
2871 stage_id: i as i32,
2872 mean_m3s: 80.0,
2873 std_m3s: 20.0,
2874 ar_coefficients: vec![],
2875 residual_std_ratio: 1.0,
2876 annual: None,
2877 })
2878 .collect();
2879
2880 let load_models: Vec<LoadModel> = (0..n_stages)
2881 .map(|i| LoadModel {
2882 bus_id: EntityId(1),
2883 stage_id: i as i32,
2884 mean_mw: 100.0,
2885 std_mw: 0.0,
2886 })
2887 .collect();
2888
2889 let inflow_history: Vec<InflowHistoryRow> = (1990_i32..=1991)
2891 .flat_map(|year| {
2892 (1u32..=12).map(move |month| InflowHistoryRow {
2893 hydro_id: EntityId(3),
2894 date: NaiveDate::from_ymd_opt(year, month, 1).unwrap(),
2895 value_m3s: 80.0 + f64::from(year - 1990) * 5.0,
2896 })
2897 })
2898 .collect();
2899
2900 let n_st = n_stages.max(1);
2901 let bounds = ResolvedBounds::new(
2902 &BoundsCountsSpec {
2903 n_hydros: 1,
2904 n_thermals: 1,
2905 n_lines: 0,
2906 n_pumping: 0,
2907 n_contracts: 0,
2908 n_stages: n_st,
2909 k_max: 0,
2910 },
2911 &BoundsDefaults {
2912 hydro: HydroStageBounds {
2913 min_storage_hm3: 0.0,
2914 max_storage_hm3: 200.0,
2915 min_turbined_m3s: 0.0,
2916 max_turbined_m3s: 100.0,
2917 min_outflow_m3s: 0.0,
2918 max_outflow_m3s: None,
2919 min_generation_mw: 0.0,
2920 max_generation_mw: 250.0,
2921 max_diversion_m3s: None,
2922 filling_inflow_m3s: 0.0,
2923 water_withdrawal_m3s: 0.0,
2924 },
2925 thermal: ThermalStageBounds {
2926 min_generation_mw: 0.0,
2927 max_generation_mw: 100.0,
2928 cost_per_mwh: 0.0,
2929 },
2930 line: LineStageBounds {
2931 direct_mw: 0.0,
2932 reverse_mw: 0.0,
2933 },
2934 pumping: PumpingStageBounds {
2935 min_flow_m3s: 0.0,
2936 max_flow_m3s: 0.0,
2937 },
2938 contract: ContractStageBounds {
2939 min_mw: 0.0,
2940 max_mw: 0.0,
2941 price_per_mwh: 0.0,
2942 },
2943 },
2944 );
2945 let penalties = ResolvedPenalties::new(
2946 &PenaltiesCountsSpec {
2947 n_hydros: 1,
2948 n_buses: 1,
2949 n_lines: 0,
2950 n_ncs: 0,
2951 n_stages: n_st,
2952 },
2953 &PenaltiesDefaults {
2954 hydro: HydroStagePenalties {
2955 spillage_cost: 0.01,
2956 diversion_cost: 0.0,
2957 turbined_cost: 0.0,
2958 storage_violation_below_cost: 500.0,
2959 filling_target_violation_cost: 0.0,
2960 turbined_violation_below_cost: 0.0,
2961 outflow_violation_below_cost: 0.0,
2962 outflow_violation_above_cost: 0.0,
2963 generation_violation_below_cost: 0.0,
2964 evaporation_violation_cost: 0.0,
2965 water_withdrawal_violation_cost: 0.0,
2966 water_withdrawal_violation_pos_cost: 0.0,
2967 water_withdrawal_violation_neg_cost: 0.0,
2968 evaporation_violation_pos_cost: 0.0,
2969 evaporation_violation_neg_cost: 0.0,
2970 inflow_nonnegativity_cost: 1000.0,
2971 },
2972 bus: BusStagePenalties { excess_cost: 0.0 },
2973 line: LineStagePenalties { exchange_cost: 0.0 },
2974 ncs: NcsStagePenalties {
2975 curtailment_cost: 0.0,
2976 },
2977 },
2978 );
2979
2980 let system = SystemBuilder::new()
2981 .buses(vec![bus])
2982 .thermals(vec![thermal])
2983 .hydros(vec![hydro])
2984 .stages(stages)
2985 .inflow_models(inflow_models)
2986 .load_models(load_models)
2987 .inflow_history(inflow_history)
2988 .bounds(bounds)
2989 .penalties(penalties)
2990 .build()
2991 .expect("test system: valid");
2992
2993 let dir = TempDir::new().unwrap();
2994 let root = dir.path();
2995 write_minimal_case_dir(root);
2996
2997 let config = minimal_prepare_config();
2998 let source = ScenarioSource {
2999 inflow_scheme: SamplingScheme::InSample,
3000 load_scheme: SamplingScheme::InSample,
3001 ncs_scheme: SamplingScheme::InSample,
3002 seed: None,
3003 historical_years: None,
3004 };
3005 let result = prepare_stochastic(system, root, &config, 42, &source)
3006 .expect("prepare_stochastic must succeed with HistoricalResiduals noise method");
3007
3008 assert_eq!(
3009 result.stochastic.opening_tree().n_stages(),
3010 n_stages,
3011 "opening_tree must have n_stages == {n_stages}"
3012 );
3013 }
3014
3015 #[test]
3018 fn default_from_system_gives_constant_and_no_evaporation() {
3019 use crate::hydro_models::{
3020 EvaporationModel, ProductionModelSource, ResolvedProductionModel,
3021 };
3022
3023 let system = minimal_system(2);
3024 let result = PrepareHydroModelsResult::default_from_system(&system);
3025
3026 assert_eq!(
3027 result.provenance.production_sources.len(),
3028 system.hydros().len(),
3029 "production_sources length must equal n_hydros"
3030 );
3031 for (_, source) in &result.provenance.production_sources {
3032 assert_eq!(
3033 *source,
3034 ProductionModelSource::DefaultConstant,
3035 "all hydros must use DefaultConstant"
3036 );
3037 }
3038
3039 assert_eq!(
3040 result.provenance.evaporation_sources.len(),
3041 system.hydros().len(),
3042 "evaporation_sources length must equal n_hydros"
3043 );
3044 assert!(
3045 !result.evaporation.has_evaporation(),
3046 "default result must have no evaporation"
3047 );
3048
3049 let model = result.production.model(0, 0);
3051 assert!(
3052 matches!(model, ResolvedProductionModel::ConstantProductivity { .. }),
3053 "default production model must be ConstantProductivity"
3054 );
3055
3056 let evap = result.evaporation.model(0);
3058 assert!(
3059 matches!(evap, EvaporationModel::None),
3060 "default evaporation model must be None"
3061 );
3062 }
3063
3064 #[test]
3066 fn hydro_models_accessor_returns_stored_result() {
3067 use crate::hydro_models::ProductionModelSource;
3068
3069 let system = minimal_system(2);
3070 let config = minimal_config(1, 5);
3071 let stochastic = build_stochastic_context(
3072 &system,
3073 42,
3074 None,
3075 &[],
3076 &[],
3077 OpeningTreeInputs::default(),
3078 ClassSchemes {
3079 inflow: Some(SamplingScheme::InSample),
3080 load: Some(SamplingScheme::InSample),
3081 ncs: Some(SamplingScheme::InSample),
3082 },
3083 )
3084 .expect("stochastic context");
3085 let hydro_result = PrepareHydroModelsResult::default_from_system(&system);
3086
3087 let setup = StudySetup::new(&system, &config, stochastic, hydro_result).expect("setup");
3088
3089 let models = &setup.hydro_models;
3090 assert_eq!(
3091 models.provenance.production_sources.len(),
3092 system.hydros().len(),
3093 "hydro_models() must return the stored result (provenance length mismatch)"
3094 );
3095 for (_, source) in &models.provenance.production_sources {
3096 assert_eq!(
3097 *source,
3098 ProductionModelSource::DefaultConstant,
3099 "stored result must preserve DefaultConstant provenance"
3100 );
3101 }
3102 }
3103
3104 #[test]
3109 fn energy_conversion_accessor_returns_built_set() {
3110 let system = minimal_system(2);
3111 let config = minimal_config(1, 5);
3112 let stochastic = build_stochastic_context(
3113 &system,
3114 42,
3115 None,
3116 &[],
3117 &[],
3118 OpeningTreeInputs::default(),
3119 ClassSchemes {
3120 inflow: Some(SamplingScheme::InSample),
3121 load: Some(SamplingScheme::InSample),
3122 ncs: Some(SamplingScheme::InSample),
3123 },
3124 )
3125 .expect("stochastic context");
3126
3127 let n_study_stages = system.stages().iter().filter(|s| s.id >= 0).count();
3131 let hydro_models_result = {
3132 let mut result = PrepareHydroModelsResult::default_from_system(&system);
3133 let pm = ProductionModelSet::new(
3134 vec![vec![
3135 ResolvedProductionModel::ConstantProductivity {
3136 productivity: 2.5
3137 };
3138 n_study_stages
3139 ]],
3140 1,
3141 n_study_stages,
3142 );
3143 result.production = pm;
3144 result
3145 };
3146
3147 let setup =
3148 StudySetup::new(&system, &config, stochastic, hydro_models_result).expect("setup");
3149
3150 let ec = setup.energy_conversion();
3151 assert_eq!(ec.n_hydros(), system.hydros().len());
3152 for s in 0..ec.n_stages() {
3155 assert!(
3156 (ec.accumulated_productivity(0, s) - 2.5).abs() < f64::EPSILON,
3157 "stage {s}: expected ρ_acum=2.5, got {}",
3158 ec.accumulated_productivity(0, s)
3159 );
3160 }
3161 }
3162
3163 #[test]
3168 fn study_setup_propagates_fpha_missing_equivalent_productivity() {
3169 let system = minimal_fpha_misconfigured_system(2);
3170 let config = minimal_config(1, 5);
3171 let stochastic = build_stochastic_context(
3172 &system,
3173 42,
3174 None,
3175 &[],
3176 &[],
3177 OpeningTreeInputs::default(),
3178 ClassSchemes {
3179 inflow: Some(SamplingScheme::InSample),
3180 load: Some(SamplingScheme::InSample),
3181 ncs: Some(SamplingScheme::InSample),
3182 },
3183 )
3184 .expect("stochastic context");
3185
3186 let err = StudySetup::new(
3187 &system,
3188 &config,
3189 stochastic,
3190 PrepareHydroModelsResult::default_from_system(&system),
3191 )
3192 .expect_err("setup must reject misconfigured FPHA hydro");
3193
3194 let msg = err.to_string();
3195 assert!(
3196 msg.contains("cannot derive ρ_eq"),
3197 "error must come from FphaMissingEquivalentProductivity Display; got: {msg}"
3198 );
3199 assert!(
3200 msg.contains("H_FPHA_BAD"),
3201 "error must mention the offending hydro by name; got: {msg}"
3202 );
3203 }
3204
3205 fn indexer_for_lag_test(hydro_count: usize, max_par_order: usize) -> StageIndexer {
3207 StageIndexer::new(hydro_count, max_par_order)
3208 }
3209
3210 #[allow(
3216 clippy::too_many_lines,
3217 clippy::cast_possible_truncation,
3218 clippy::cast_possible_wrap,
3219 clippy::items_after_statements
3220 )]
3221 fn minimal_system_2_hydros_with_past_inflows(
3222 n_stages: usize,
3223 h1_past: Vec<f64>,
3224 h2_past: Vec<f64>,
3225 ) -> cobre_core::System {
3226 use chrono::NaiveDate;
3227
3228 let bus = Bus {
3229 id: EntityId(1),
3230 name: "B1".to_string(),
3231 deficit_segments: vec![DeficitSegment {
3232 depth_mw: None,
3233 cost_per_mwh: 500.0,
3234 }],
3235 excess_cost: 0.0,
3236 };
3237
3238 let make_hydro = |id: i32, name: &str| Hydro {
3239 id: EntityId(id),
3240 name: name.to_string(),
3241 bus_id: EntityId(1),
3242 downstream_id: None,
3243 entry_stage_id: None,
3244 exit_stage_id: None,
3245 min_storage_hm3: 0.0,
3246 max_storage_hm3: 200.0,
3247 min_outflow_m3s: 0.0,
3248 max_outflow_m3s: None,
3249 generation_model: HydroGenerationModel::ConstantProductivity,
3250 min_turbined_m3s: 0.0,
3251 max_turbined_m3s: 100.0,
3252 specific_productivity_mw_per_m3s_per_m: None,
3253 min_generation_mw: 0.0,
3254 max_generation_mw: 250.0,
3255 tailrace: None,
3256 hydraulic_losses: None,
3257 efficiency: None,
3258 evaporation_coefficients_mm: None,
3259 evaporation_reference_volumes_hm3: None,
3260 diversion: None,
3261 filling: None,
3262 penalties: HydroPenalties {
3263 spillage_cost: 0.01,
3264 diversion_cost: 0.0,
3265 turbined_cost: 0.0,
3266 storage_violation_below_cost: 0.0,
3267 filling_target_violation_cost: 0.0,
3268 turbined_violation_below_cost: 0.0,
3269 outflow_violation_below_cost: 0.0,
3270 outflow_violation_above_cost: 0.0,
3271 generation_violation_below_cost: 0.0,
3272 evaporation_violation_cost: 0.0,
3273 water_withdrawal_violation_cost: 0.0,
3274 water_withdrawal_violation_pos_cost: 0.0,
3275 water_withdrawal_violation_neg_cost: 0.0,
3276 evaporation_violation_pos_cost: 0.0,
3277 evaporation_violation_neg_cost: 0.0,
3278 inflow_nonnegativity_cost: 1000.0,
3279 },
3280 };
3281
3282 let n_st = n_stages.max(1);
3283 let stages: Vec<Stage> = (0..n_stages)
3284 .map(|i| Stage {
3285 index: i,
3286 id: i as i32,
3287 start_date: NaiveDate::from_ymd_opt(2020, (i % 12 + 1) as u32, 1).unwrap(),
3288 end_date: NaiveDate::from_ymd_opt(
3289 if (i % 12 + 1) == 12 { 2021 } else { 2020 },
3290 ((i % 12 + 1) % 12 + 1) as u32,
3291 1,
3292 )
3293 .unwrap(),
3294 season_id: Some(i),
3295 blocks: vec![Block {
3296 index: 0,
3297 name: "S".to_string(),
3298 duration_hours: 744.0,
3299 }],
3300 block_mode: BlockMode::Parallel,
3301 state_config: StageStateConfig {
3302 storage: true,
3303 inflow_lags: true,
3304 },
3305 risk_config: StageRiskConfig::Expectation,
3306 scenario_config: ScenarioSourceConfig {
3307 branching_factor: 1,
3308 noise_method: NoiseMethod::Saa,
3309 },
3310 })
3311 .collect();
3312
3313 let inflow_models: Vec<InflowModel> = (0..n_stages)
3314 .flat_map(|i| {
3315 [1_i32, 2].map(|hid| InflowModel {
3316 hydro_id: EntityId(hid),
3317 stage_id: i as i32,
3318 mean_m3s: 80.0,
3319 std_m3s: 20.0,
3320 ar_coefficients: vec![0.5, 0.3],
3321 residual_std_ratio: 0.8,
3322 annual: None,
3323 })
3324 })
3325 .collect();
3326
3327 let load_models: Vec<LoadModel> = (0..n_stages)
3328 .map(|i| LoadModel {
3329 bus_id: EntityId(1),
3330 stage_id: i as i32,
3331 mean_mw: 100.0,
3332 std_mw: 0.0,
3333 })
3334 .collect();
3335
3336 fn default_hydro_bounds() -> HydroStageBounds {
3337 HydroStageBounds {
3338 min_storage_hm3: 0.0,
3339 max_storage_hm3: 200.0,
3340 min_turbined_m3s: 0.0,
3341 max_turbined_m3s: 100.0,
3342 min_outflow_m3s: 0.0,
3343 max_outflow_m3s: None,
3344 min_generation_mw: 0.0,
3345 max_generation_mw: 250.0,
3346 max_diversion_m3s: None,
3347 filling_inflow_m3s: 0.0,
3348 water_withdrawal_m3s: 0.0,
3349 }
3350 }
3351
3352 fn default_hydro_penalties() -> HydroStagePenalties {
3353 HydroStagePenalties {
3354 spillage_cost: 0.01,
3355 diversion_cost: 0.0,
3356 turbined_cost: 0.0,
3357 storage_violation_below_cost: 500.0,
3358 filling_target_violation_cost: 0.0,
3359 turbined_violation_below_cost: 0.0,
3360 outflow_violation_below_cost: 0.0,
3361 outflow_violation_above_cost: 0.0,
3362 generation_violation_below_cost: 0.0,
3363 evaporation_violation_cost: 0.0,
3364 water_withdrawal_violation_cost: 0.0,
3365 water_withdrawal_violation_pos_cost: 0.0,
3366 water_withdrawal_violation_neg_cost: 0.0,
3367 evaporation_violation_pos_cost: 0.0,
3368 evaporation_violation_neg_cost: 0.0,
3369 inflow_nonnegativity_cost: 1000.0,
3370 }
3371 }
3372
3373 let bounds = ResolvedBounds::new(
3374 &BoundsCountsSpec {
3375 n_hydros: 2,
3376 n_thermals: 0,
3377 n_lines: 0,
3378 n_pumping: 0,
3379 n_contracts: 0,
3380 n_stages: n_st,
3381 k_max: 0,
3382 },
3383 &BoundsDefaults {
3384 hydro: default_hydro_bounds(),
3385 thermal: ThermalStageBounds {
3386 min_generation_mw: 0.0,
3387 max_generation_mw: 0.0,
3388 cost_per_mwh: 0.0,
3389 },
3390 line: LineStageBounds {
3391 direct_mw: 0.0,
3392 reverse_mw: 0.0,
3393 },
3394 pumping: PumpingStageBounds {
3395 min_flow_m3s: 0.0,
3396 max_flow_m3s: 0.0,
3397 },
3398 contract: ContractStageBounds {
3399 min_mw: 0.0,
3400 max_mw: 0.0,
3401 price_per_mwh: 0.0,
3402 },
3403 },
3404 );
3405
3406 let penalties = ResolvedPenalties::new(
3407 &PenaltiesCountsSpec {
3408 n_hydros: 2,
3409 n_buses: 1,
3410 n_lines: 0,
3411 n_ncs: 0,
3412 n_stages: n_st,
3413 },
3414 &PenaltiesDefaults {
3415 hydro: default_hydro_penalties(),
3416 bus: BusStagePenalties { excess_cost: 0.0 },
3417 line: LineStagePenalties { exchange_cost: 0.0 },
3418 ncs: NcsStagePenalties {
3419 curtailment_cost: 0.0,
3420 },
3421 },
3422 );
3423
3424 let past_inflows = vec![
3425 cobre_core::HydroPastInflows {
3426 hydro_id: EntityId(1),
3427 values_m3s: h1_past,
3428 season_ids: None,
3429 },
3430 cobre_core::HydroPastInflows {
3431 hydro_id: EntityId(2),
3432 values_m3s: h2_past,
3433 season_ids: None,
3434 },
3435 ];
3436
3437 SystemBuilder::new()
3438 .buses(vec![bus])
3439 .thermals(vec![])
3440 .hydros(vec![make_hydro(1, "H1"), make_hydro(2, "H2")])
3441 .stages(stages)
3442 .inflow_models(inflow_models)
3443 .load_models(load_models)
3444 .bounds(bounds)
3445 .penalties(penalties)
3446 .initial_conditions(cobre_core::InitialConditions {
3447 storage: vec![],
3448 filling_storage: vec![],
3449 past_inflows,
3450 past_anticipated_commitments: vec![],
3451 recent_observations: vec![],
3452 })
3453 .build()
3454 .expect("minimal_system_2_hydros_with_past_inflows: valid")
3455 }
3456
3457 #[test]
3463 fn build_initial_state_populates_lags_from_past_inflows() {
3464 use super::build_initial_state;
3465
3466 let system =
3467 minimal_system_2_hydros_with_past_inflows(1, vec![600.0, 500.0], vec![200.0, 100.0]);
3468 let indexer = indexer_for_lag_test(2, 2);
3469
3470 let state = build_initial_state(&system, &indexer);
3471
3472 let s = indexer.inflow_lags.start;
3477 assert!(
3478 (state[s] - 600.0).abs() < 1e-10,
3479 "lag0 hydro 0: expected 600.0, got {}",
3480 state[s]
3481 );
3482 assert!(
3483 (state[s + 1] - 200.0).abs() < 1e-10,
3484 "lag0 hydro 1: expected 200.0, got {}",
3485 state[s + 1]
3486 );
3487 assert!(
3488 (state[s + 2] - 500.0).abs() < 1e-10,
3489 "lag1 hydro 0: expected 500.0, got {}",
3490 state[s + 2]
3491 );
3492 assert!(
3493 (state[s + 3] - 100.0).abs() < 1e-10,
3494 "lag1 hydro 1: expected 100.0, got {}",
3495 state[s + 3]
3496 );
3497 assert_eq!(
3498 state.len(),
3499 indexer.n_state,
3500 "state length must equal n_state"
3501 );
3502 }
3503
3504 #[test]
3506 fn build_initial_state_empty_past_inflows_leaves_zero_lags() {
3507 use super::build_initial_state;
3508
3509 let system = minimal_system(2);
3510 let indexer = indexer_for_lag_test(1, 3);
3511
3512 let state = build_initial_state(&system, &indexer);
3513
3514 let s = indexer.inflow_lags.start;
3515 for l in 0..3 {
3516 assert!(
3517 state[s + l].abs() < 1e-10,
3518 "lag slot {l} should be 0.0 when past_inflows is empty, got {}",
3519 state[s + l]
3520 );
3521 }
3522 }
3523
3524 #[test]
3527 fn build_initial_state_unknown_hydro_in_past_inflows_stays_zero() {
3528 use super::build_initial_state;
3529
3530 let system = {
3533 minimal_system(2)
3538 };
3539 let indexer = indexer_for_lag_test(1, 2);
3540
3541 let state = build_initial_state(&system, &indexer);
3542
3543 let s = indexer.inflow_lags.start;
3544 assert!(
3545 state[s].abs() < 1e-10,
3546 "lag 0 should be 0.0 when past_inflows is absent, got {}",
3547 state[s]
3548 );
3549 assert!(
3550 state[s + 1].abs() < 1e-10,
3551 "lag 1 should be 0.0 when past_inflows is absent, got {}",
3552 state[s + 1]
3553 );
3554 }
3555
3556 #[test]
3559 fn study_setup_initial_state_has_nonzero_lags_from_past_inflows() {
3560 let system =
3561 minimal_system_2_hydros_with_past_inflows(3, vec![600.0, 500.0], vec![200.0, 100.0]);
3562 let config = minimal_config(1, 10);
3563 let stochastic = build_stochastic_context(
3564 &system,
3565 42,
3566 None,
3567 &[],
3568 &[],
3569 OpeningTreeInputs::default(),
3570 ClassSchemes {
3571 inflow: Some(SamplingScheme::InSample),
3572 load: Some(SamplingScheme::InSample),
3573 ncs: Some(SamplingScheme::InSample),
3574 },
3575 )
3576 .expect("stochastic context");
3577
3578 let setup = StudySetup::new(
3579 &system,
3580 &config,
3581 stochastic,
3582 PrepareHydroModelsResult::default_from_system(&system),
3583 )
3584 .expect("setup with past_inflows");
3585
3586 let state = &setup.initial_state;
3587
3588 let n_hydros = 2;
3593 let lag_start = n_hydros;
3594 assert!(
3595 (state[lag_start] - 600.0).abs() < 1e-10,
3596 "lag0 hydro 0 should be 600.0 via StudySetup, got {}",
3597 state[lag_start]
3598 );
3599 assert!(
3600 (state[lag_start + 1] - 200.0).abs() < 1e-10,
3601 "lag0 hydro 1 should be 200.0 via StudySetup, got {}",
3602 state[lag_start + 1]
3603 );
3604 assert!(
3605 (state[lag_start + 2] - 500.0).abs() < 1e-10,
3606 "lag1 hydro 0 should be 500.0 via StudySetup, got {}",
3607 state[lag_start + 2]
3608 );
3609 assert!(
3610 (state[lag_start + 3] - 100.0).abs() < 1e-10,
3611 "lag1 hydro 1 should be 100.0 via StudySetup, got {}",
3612 state[lag_start + 3]
3613 );
3614 }
3615
3616 #[test]
3618 fn build_initial_state_no_lags_state_is_storage_only() {
3619 use super::build_initial_state;
3620
3621 let system = minimal_system(2);
3622 let indexer = indexer_for_lag_test(1, 0);
3623
3624 assert_eq!(indexer.n_state, 1);
3626 assert!(
3627 indexer.inflow_lags.is_empty(),
3628 "inflow_lags range should be empty for L=0"
3629 );
3630
3631 let state = build_initial_state(&system, &indexer);
3632
3633 assert_eq!(state.len(), 1, "state length must equal n_state=1");
3634 }
3635
3636 fn indexer_with_anticipated(
3645 n_anticipated: usize,
3646 k_values: &[usize], thermal_indices: &[usize], ) -> StageIndexer {
3649 use crate::indexer::{EquipmentCounts, EvapConfig, FphaColumnLayout};
3650
3651 let k_max = k_values.iter().copied().max().unwrap_or(0);
3652 StageIndexer::with_equipment_and_evaporation(
3653 &EquipmentCounts {
3654 hydro_count: 1,
3655 max_par_order: 0,
3656 n_thermals: n_anticipated, n_lines: 0,
3658 n_buses: 1,
3659 n_blks: 1,
3660 has_inflow_penalty: false,
3661 max_deficit_segments: 1,
3662 n_anticipated,
3663 k_max,
3664 anticipated_lead_stages: k_values.to_vec(),
3665 anticipated_thermal_indices: thermal_indices.to_vec(),
3666 },
3667 &FphaColumnLayout {
3668 hydro_indices: vec![],
3669 planes_per_hydro: vec![],
3670 },
3671 &EvapConfig {
3672 hydro_indices: vec![],
3673 },
3674 )
3675 }
3676
3677 #[allow(
3683 clippy::too_many_lines,
3684 clippy::cast_possible_truncation,
3685 clippy::cast_possible_wrap,
3686 clippy::items_after_statements
3687 )]
3688 fn system_with_anticipated_thermals(
3689 k_values: &[u32],
3690 past_commits: Vec<cobre_core::AnticipatedCommitmentHistory>,
3691 ) -> cobre_core::System {
3692 use chrono::NaiveDate;
3693
3694 let bus = Bus {
3695 id: EntityId(1),
3696 name: "B1".to_string(),
3697 deficit_segments: vec![DeficitSegment {
3698 depth_mw: None,
3699 cost_per_mwh: 500.0,
3700 }],
3701 excess_cost: 0.0,
3702 };
3703
3704 let thermals: Vec<Thermal> = k_values
3707 .iter()
3708 .enumerate()
3709 .map(|(i, &k)| Thermal {
3710 id: EntityId(10 + i as i32),
3711 name: format!("AT{i}"),
3712 bus_id: EntityId(1),
3713 min_generation_mw: 0.0,
3714 max_generation_mw: 100.0,
3715 cost_per_mwh: 50.0,
3716 anticipated_config: Some(AnticipatedConfig { lead_stages: k }),
3717 entry_stage_id: None,
3718 exit_stage_id: None,
3719 })
3720 .collect();
3721
3722 let hydro = Hydro {
3723 id: EntityId(3),
3724 name: "H1".to_string(),
3725 bus_id: EntityId(1),
3726 downstream_id: None,
3727 entry_stage_id: None,
3728 exit_stage_id: None,
3729 min_storage_hm3: 0.0,
3730 max_storage_hm3: 200.0,
3731 min_outflow_m3s: 0.0,
3732 max_outflow_m3s: None,
3733 generation_model: HydroGenerationModel::ConstantProductivity,
3734 min_turbined_m3s: 0.0,
3735 max_turbined_m3s: 100.0,
3736 specific_productivity_mw_per_m3s_per_m: None,
3737 min_generation_mw: 0.0,
3738 max_generation_mw: 250.0,
3739 tailrace: None,
3740 hydraulic_losses: None,
3741 efficiency: None,
3742 evaporation_coefficients_mm: None,
3743 evaporation_reference_volumes_hm3: None,
3744 diversion: None,
3745 filling: None,
3746 penalties: HydroPenalties {
3747 spillage_cost: 0.01,
3748 diversion_cost: 0.0,
3749 turbined_cost: 0.0,
3750 storage_violation_below_cost: 0.0,
3751 filling_target_violation_cost: 0.0,
3752 turbined_violation_below_cost: 0.0,
3753 outflow_violation_below_cost: 0.0,
3754 outflow_violation_above_cost: 0.0,
3755 generation_violation_below_cost: 0.0,
3756 evaporation_violation_cost: 0.0,
3757 water_withdrawal_violation_cost: 0.0,
3758 water_withdrawal_violation_pos_cost: 0.0,
3759 water_withdrawal_violation_neg_cost: 0.0,
3760 evaporation_violation_pos_cost: 0.0,
3761 evaporation_violation_neg_cost: 0.0,
3762 inflow_nonnegativity_cost: 1000.0,
3763 },
3764 };
3765
3766 let n_stages = 2_usize;
3767 let stages: Vec<Stage> = (0..n_stages)
3768 .map(|i| Stage {
3769 index: i,
3770 id: i as i32,
3771 start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
3772 end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
3773 season_id: None,
3774 blocks: vec![Block {
3775 index: 0,
3776 name: "S".to_string(),
3777 duration_hours: 744.0,
3778 }],
3779 block_mode: BlockMode::Parallel,
3780 state_config: StageStateConfig {
3781 storage: true,
3782 inflow_lags: false,
3783 },
3784 risk_config: StageRiskConfig::Expectation,
3785 scenario_config: ScenarioSourceConfig {
3786 branching_factor: 1,
3787 noise_method: NoiseMethod::Saa,
3788 },
3789 })
3790 .collect();
3791
3792 let inflow_models: Vec<InflowModel> = (0..n_stages)
3793 .map(|i| InflowModel {
3794 hydro_id: EntityId(3),
3795 stage_id: i as i32,
3796 mean_m3s: 80.0,
3797 std_m3s: 20.0,
3798 ar_coefficients: vec![],
3799 residual_std_ratio: 1.0,
3800 annual: None,
3801 })
3802 .collect();
3803
3804 let load_models: Vec<LoadModel> = (0..n_stages)
3805 .map(|i| LoadModel {
3806 bus_id: EntityId(1),
3807 stage_id: i as i32,
3808 mean_mw: 100.0,
3809 std_mw: 0.0,
3810 })
3811 .collect();
3812
3813 let k_max_bounds = k_values.iter().copied().max().unwrap_or(0) as usize;
3814 let n_thermals = k_values.len();
3815
3816 fn default_hydro_bounds() -> HydroStageBounds {
3817 HydroStageBounds {
3818 min_storage_hm3: 0.0,
3819 max_storage_hm3: 200.0,
3820 min_turbined_m3s: 0.0,
3821 max_turbined_m3s: 100.0,
3822 min_outflow_m3s: 0.0,
3823 max_outflow_m3s: None,
3824 min_generation_mw: 0.0,
3825 max_generation_mw: 250.0,
3826 max_diversion_m3s: None,
3827 filling_inflow_m3s: 0.0,
3828 water_withdrawal_m3s: 0.0,
3829 }
3830 }
3831
3832 fn default_hydro_penalties() -> HydroStagePenalties {
3833 HydroStagePenalties {
3834 spillage_cost: 0.01,
3835 diversion_cost: 0.0,
3836 turbined_cost: 0.0,
3837 storage_violation_below_cost: 500.0,
3838 filling_target_violation_cost: 0.0,
3839 turbined_violation_below_cost: 0.0,
3840 outflow_violation_below_cost: 0.0,
3841 outflow_violation_above_cost: 0.0,
3842 generation_violation_below_cost: 0.0,
3843 evaporation_violation_cost: 0.0,
3844 water_withdrawal_violation_cost: 0.0,
3845 water_withdrawal_violation_pos_cost: 0.0,
3846 water_withdrawal_violation_neg_cost: 0.0,
3847 evaporation_violation_pos_cost: 0.0,
3848 evaporation_violation_neg_cost: 0.0,
3849 inflow_nonnegativity_cost: 1000.0,
3850 }
3851 }
3852
3853 let bounds = ResolvedBounds::new(
3854 &BoundsCountsSpec {
3855 n_hydros: 1,
3856 n_thermals,
3857 n_lines: 0,
3858 n_pumping: 0,
3859 n_contracts: 0,
3860 n_stages,
3861 k_max: k_max_bounds,
3862 },
3863 &BoundsDefaults {
3864 hydro: default_hydro_bounds(),
3865 thermal: ThermalStageBounds {
3866 min_generation_mw: 0.0,
3867 max_generation_mw: 100.0,
3868 cost_per_mwh: 0.0,
3869 },
3870 line: LineStageBounds {
3871 direct_mw: 0.0,
3872 reverse_mw: 0.0,
3873 },
3874 pumping: PumpingStageBounds {
3875 min_flow_m3s: 0.0,
3876 max_flow_m3s: 0.0,
3877 },
3878 contract: ContractStageBounds {
3879 min_mw: 0.0,
3880 max_mw: 0.0,
3881 price_per_mwh: 0.0,
3882 },
3883 },
3884 );
3885
3886 let penalties = ResolvedPenalties::new(
3887 &PenaltiesCountsSpec {
3888 n_hydros: 1,
3889 n_buses: 1,
3890 n_lines: 0,
3891 n_ncs: 0,
3892 n_stages,
3893 },
3894 &PenaltiesDefaults {
3895 hydro: default_hydro_penalties(),
3896 bus: BusStagePenalties { excess_cost: 0.0 },
3897 line: LineStagePenalties { exchange_cost: 0.0 },
3898 ncs: NcsStagePenalties {
3899 curtailment_cost: 0.0,
3900 },
3901 },
3902 );
3903
3904 SystemBuilder::new()
3905 .buses(vec![bus])
3906 .thermals(thermals)
3907 .hydros(vec![hydro])
3908 .stages(stages)
3909 .inflow_models(inflow_models)
3910 .load_models(load_models)
3911 .bounds(bounds)
3912 .penalties(penalties)
3913 .initial_conditions(cobre_core::InitialConditions {
3914 storage: vec![],
3915 filling_storage: vec![],
3916 past_inflows: vec![],
3917 past_anticipated_commitments: past_commits,
3918 recent_observations: vec![],
3919 })
3920 .build()
3921 .expect("system_with_anticipated_thermals: valid")
3922 }
3923
3924 #[test]
3929 fn build_initial_state_no_anticipated_state_unchanged() {
3930 use super::build_initial_state;
3931
3932 let system = minimal_system(2);
3933 let indexer = indexer_for_lag_test(1, 0);
3934
3935 assert_eq!(indexer.n_anticipated, 0);
3937 assert!(indexer.anticipated_state.is_empty());
3938
3939 let state = build_initial_state(&system, &indexer);
3940
3941 assert_eq!(
3942 state.len(),
3943 indexer.n_state,
3944 "state length must equal n_state"
3945 );
3946 assert!(
3948 state.iter().all(|&v| v == 0.0),
3949 "all state slots must be 0.0 when no anticipated thermals and no ICs set"
3950 );
3951 }
3952
3953 #[test]
3960 fn build_initial_state_single_anticipated_thermal_k2() {
3961 use super::build_initial_state;
3962 use cobre_core::AnticipatedCommitmentHistory;
3963
3964 let past_commits = vec![AnticipatedCommitmentHistory {
3967 thermal_id: EntityId(10),
3968 values_mw: vec![50.0, 75.0],
3969 }];
3970 let system = system_with_anticipated_thermals(&[2], past_commits);
3971
3972 let indexer = indexer_with_anticipated(1, &[2], &[0]);
3974
3975 let state = build_initial_state(&system, &indexer);
3976
3977 assert_eq!(
3978 state.len(),
3979 indexer.n_state,
3980 "state length must equal n_state"
3981 );
3982 let ant_start = indexer.anticipated_state.start;
3983 assert!(
3984 (state[ant_start] - 50.0).abs() < 1e-10,
3985 "slot 0 expected 50.0, got {}",
3986 state[ant_start]
3987 );
3988 assert!(
3989 (state[ant_start + 1] - 75.0).abs() < 1e-10,
3990 "slot 1 expected 75.0, got {}",
3991 state[ant_start + 1]
3992 );
3993 }
3994
3995 #[test]
4006 fn build_initial_state_two_anticipated_thermals_mixed_k() {
4007 use super::build_initial_state;
4008 use cobre_core::AnticipatedCommitmentHistory;
4009
4010 let past_commits = vec![
4013 AnticipatedCommitmentHistory {
4014 thermal_id: EntityId(10),
4015 values_mw: vec![10.0, 20.0],
4016 },
4017 AnticipatedCommitmentHistory {
4018 thermal_id: EntityId(11),
4019 values_mw: vec![100.0, 200.0, 300.0],
4020 },
4021 ];
4022 let system = system_with_anticipated_thermals(&[2, 3], past_commits);
4023
4024 let indexer = indexer_with_anticipated(2, &[2, 3], &[0, 1]);
4029
4030 let state = build_initial_state(&system, &indexer);
4031
4032 assert_eq!(
4033 state.len(),
4034 indexer.n_state,
4035 "state length must equal n_state"
4036 );
4037 let s = indexer.anticipated_state.start;
4041
4042 assert!(
4043 (state[s] - 10.0).abs() < 1e-10,
4044 "slot 0 plant 0: expected 10.0, got {}",
4045 state[s]
4046 );
4047 assert!(
4048 (state[s + 1] - 100.0).abs() < 1e-10,
4049 "slot 0 plant 1: expected 100.0, got {}",
4050 state[s + 1]
4051 );
4052 assert!(
4053 (state[s + 2] - 20.0).abs() < 1e-10,
4054 "slot 1 plant 0: expected 20.0, got {}",
4055 state[s + 2]
4056 );
4057 assert!(
4058 (state[s + 3] - 200.0).abs() < 1e-10,
4059 "slot 1 plant 1: expected 200.0, got {}",
4060 state[s + 3]
4061 );
4062 assert!(
4063 state[s + 4].abs() < 1e-10,
4064 "slot 2 plant 0 (K_0=2 padding): expected 0.0, got {}",
4065 state[s + 4]
4066 );
4067 assert!(
4068 (state[s + 5] - 300.0).abs() < 1e-10,
4069 "slot 2 plant 1: expected 300.0, got {}",
4070 state[s + 5]
4071 );
4072 }
4073
4074 #[test]
4077 fn build_initial_state_empty_past_commitments_leaves_zeros() {
4078 use super::build_initial_state;
4079
4080 let system = system_with_anticipated_thermals(&[2], vec![]);
4081
4082 let indexer = indexer_with_anticipated(1, &[2], &[0]);
4083
4084 let state = build_initial_state(&system, &indexer);
4085
4086 assert_eq!(
4087 state.len(),
4088 indexer.n_state,
4089 "state length must equal n_state"
4090 );
4091 let ant_start = indexer.anticipated_state.start;
4092 let ant_end = indexer.anticipated_state.end;
4093 for (i, &v) in state[ant_start..ant_end].iter().enumerate() {
4094 assert!(
4095 v.abs() < 1e-10,
4096 "anticipated_state slot {i} expected 0.0, got {v}"
4097 );
4098 }
4099 }
4100
4101 #[test]
4105 fn build_initial_state_unknown_thermal_id_silently_skipped() {
4106 use super::build_initial_state;
4107 use cobre_core::AnticipatedCommitmentHistory;
4108
4109 let past_commits = vec![AnticipatedCommitmentHistory {
4112 thermal_id: EntityId(99999),
4113 values_mw: vec![42.0, 43.0],
4114 }];
4115 let system = system_with_anticipated_thermals(&[2], past_commits);
4116
4117 let indexer = indexer_with_anticipated(1, &[2], &[0]);
4118
4119 let state = build_initial_state(&system, &indexer);
4120
4121 assert_eq!(
4122 state.len(),
4123 indexer.n_state,
4124 "state length must equal n_state"
4125 );
4126 let ant_start = indexer.anticipated_state.start;
4127 let ant_end = indexer.anticipated_state.end;
4128 for (i, &v) in state[ant_start..ant_end].iter().enumerate() {
4129 assert!(
4130 v.abs() < 1e-10,
4131 "anticipated_state slot {i} expected 0.0 for unknown ID, got {v}"
4132 );
4133 }
4134 }
4135
4136 #[test]
4153 fn build_initial_state_anticipated_seed_padding_slot_stays_zero() {
4154 use super::build_initial_state;
4155 use cobre_core::AnticipatedCommitmentHistory;
4156
4157 let past_commits = vec![
4158 AnticipatedCommitmentHistory {
4159 thermal_id: EntityId(10),
4160 values_mw: vec![100.0],
4161 },
4162 AnticipatedCommitmentHistory {
4163 thermal_id: EntityId(11),
4164 values_mw: vec![50.0, 75.0],
4165 },
4166 ];
4167 let system = system_with_anticipated_thermals(&[1, 2], past_commits);
4168 let indexer = indexer_with_anticipated(2, &[1, 2], &[0, 1]);
4170
4171 let state = build_initial_state(&system, &indexer);
4172
4173 assert_eq!(
4174 state.len(),
4175 indexer.n_state,
4176 "state length must equal n_state"
4177 );
4178 let s = indexer.anticipated_state.start;
4179 let n_ant = indexer.n_anticipated;
4180 assert_eq!(n_ant, 2);
4181 assert_eq!(indexer.k_max, 2);
4182
4183 assert!(
4185 (state[s] - 100.0).abs() < 1e-10,
4186 "slot 0 plant 0 expected 100.0, got {}",
4187 state[s]
4188 );
4189 assert!(
4191 (state[s + 1] - 50.0).abs() < 1e-10,
4192 "slot 0 plant 1 expected 50.0, got {}",
4193 state[s + 1]
4194 );
4195 assert!(
4198 state[s + 2].abs() < 1e-10,
4199 "padding slot 1 plant 0 expected 0.0, got {}",
4200 state[s + 2]
4201 );
4202 assert!(
4204 (state[s + 3] - 75.0).abs() < 1e-10,
4205 "slot 1 plant 1 expected 75.0, got {}",
4206 state[s + 3]
4207 );
4208 }
4209
4210 #[test]
4213 fn historical_library_none_for_insample() {
4214 let system = minimal_system(2);
4215 let config = minimal_config(1, 5);
4216 let stochastic = build_stochastic_context(
4217 &system,
4218 42,
4219 None,
4220 &[],
4221 &[],
4222 OpeningTreeInputs::default(),
4223 ClassSchemes {
4224 inflow: Some(SamplingScheme::InSample),
4225 load: Some(SamplingScheme::InSample),
4226 ncs: Some(SamplingScheme::InSample),
4227 },
4228 )
4229 .expect("stochastic context");
4230
4231 let setup = StudySetup::new(
4232 &system,
4233 &config,
4234 stochastic,
4235 PrepareHydroModelsResult::default_from_system(&system),
4236 )
4237 .expect("setup");
4238
4239 assert!(
4240 setup.scenario_libraries.training.historical.is_none(),
4241 "historical_library must be None for InSample scheme"
4242 );
4243 assert!(
4244 setup.scenario_libraries.training.external_inflow.is_none(),
4245 "external_inflow_library must be None for InSample scheme"
4246 );
4247 assert!(
4248 setup.scenario_libraries.training.external_load.is_none(),
4249 "external_load_library must be None for InSample load scheme"
4250 );
4251 assert!(
4252 setup.scenario_libraries.training.external_ncs.is_none(),
4253 "external_ncs_library must be None for InSample ncs scheme"
4254 );
4255 }
4256
4257 #[allow(
4266 clippy::too_many_lines,
4267 clippy::cast_possible_truncation,
4268 clippy::cast_possible_wrap,
4269 clippy::cast_lossless
4270 )]
4271 fn system_with_historical_inflow(n_stages: usize) -> cobre_core::System {
4272 use chrono::NaiveDate;
4273 use cobre_core::{scenario::InflowHistoryRow, system::SystemBuilder};
4274
4275 fn default_hydro_bounds() -> HydroStageBounds {
4276 HydroStageBounds {
4277 min_storage_hm3: 0.0,
4278 max_storage_hm3: 200.0,
4279 min_turbined_m3s: 0.0,
4280 max_turbined_m3s: 100.0,
4281 min_outflow_m3s: 0.0,
4282 max_outflow_m3s: None,
4283 min_generation_mw: 0.0,
4284 max_generation_mw: 250.0,
4285 max_diversion_m3s: None,
4286 filling_inflow_m3s: 0.0,
4287 water_withdrawal_m3s: 0.0,
4288 }
4289 }
4290
4291 fn default_hydro_penalties() -> HydroStagePenalties {
4292 HydroStagePenalties {
4293 spillage_cost: 0.01,
4294 diversion_cost: 0.0,
4295 turbined_cost: 0.0,
4296 storage_violation_below_cost: 500.0,
4297 filling_target_violation_cost: 0.0,
4298 turbined_violation_below_cost: 0.0,
4299 outflow_violation_below_cost: 0.0,
4300 outflow_violation_above_cost: 0.0,
4301 generation_violation_below_cost: 0.0,
4302 evaporation_violation_cost: 0.0,
4303 water_withdrawal_violation_cost: 0.0,
4304 water_withdrawal_violation_pos_cost: 0.0,
4305 water_withdrawal_violation_neg_cost: 0.0,
4306 evaporation_violation_pos_cost: 0.0,
4307 evaporation_violation_neg_cost: 0.0,
4308 inflow_nonnegativity_cost: 1000.0,
4309 }
4310 }
4311
4312 let bus = Bus {
4313 id: EntityId(1),
4314 name: "B1".to_string(),
4315 deficit_segments: vec![DeficitSegment {
4316 depth_mw: None,
4317 cost_per_mwh: 500.0,
4318 }],
4319 excess_cost: 0.0,
4320 };
4321
4322 let thermal = Thermal {
4323 id: EntityId(2),
4324 name: "T1".to_string(),
4325 bus_id: EntityId(1),
4326 min_generation_mw: 0.0,
4327 max_generation_mw: 100.0,
4328 cost_per_mwh: 50.0,
4329 anticipated_config: None,
4330 entry_stage_id: None,
4331 exit_stage_id: None,
4332 };
4333
4334 let hydro = Hydro {
4335 id: EntityId(3),
4336 name: "H1".to_string(),
4337 bus_id: EntityId(1),
4338 downstream_id: None,
4339 entry_stage_id: None,
4340 exit_stage_id: None,
4341 min_storage_hm3: 0.0,
4342 max_storage_hm3: 200.0,
4343 min_outflow_m3s: 0.0,
4344 max_outflow_m3s: None,
4345 generation_model: HydroGenerationModel::ConstantProductivity,
4346 min_turbined_m3s: 0.0,
4347 max_turbined_m3s: 100.0,
4348 specific_productivity_mw_per_m3s_per_m: None,
4349 min_generation_mw: 0.0,
4350 max_generation_mw: 250.0,
4351 tailrace: None,
4352 hydraulic_losses: None,
4353 efficiency: None,
4354 evaporation_coefficients_mm: None,
4355 evaporation_reference_volumes_hm3: None,
4356 diversion: None,
4357 filling: None,
4358 penalties: HydroPenalties {
4359 spillage_cost: 0.01,
4360 diversion_cost: 0.0,
4361 turbined_cost: 0.0,
4362 storage_violation_below_cost: 0.0,
4363 filling_target_violation_cost: 0.0,
4364 turbined_violation_below_cost: 0.0,
4365 outflow_violation_below_cost: 0.0,
4366 outflow_violation_above_cost: 0.0,
4367 generation_violation_below_cost: 0.0,
4368 evaporation_violation_cost: 0.0,
4369 water_withdrawal_violation_cost: 0.0,
4370 water_withdrawal_violation_pos_cost: 0.0,
4371 water_withdrawal_violation_neg_cost: 0.0,
4372 evaporation_violation_pos_cost: 0.0,
4373 evaporation_violation_neg_cost: 0.0,
4374 inflow_nonnegativity_cost: 1000.0,
4375 },
4376 };
4377
4378 let stages: Vec<Stage> = (0..n_stages)
4380 .map(|i| Stage {
4381 index: i,
4382 id: i as i32,
4383 start_date: NaiveDate::from_ymd_opt(2024, (i as u32 % 12) + 1, 1).unwrap(),
4384 end_date: NaiveDate::from_ymd_opt(2024, (i as u32 % 12) + 1, 28).unwrap(),
4385 season_id: Some(i % 12),
4386 blocks: vec![Block {
4387 index: 0,
4388 name: "S".to_string(),
4389 duration_hours: 720.0,
4390 }],
4391 block_mode: BlockMode::Parallel,
4392 state_config: StageStateConfig {
4393 storage: true,
4394 inflow_lags: false,
4395 },
4396 risk_config: StageRiskConfig::Expectation,
4397 scenario_config: ScenarioSourceConfig {
4398 branching_factor: 1,
4399 noise_method: NoiseMethod::Saa,
4400 },
4401 })
4402 .collect();
4403
4404 let inflow_models: Vec<InflowModel> = (0..n_stages)
4405 .map(|i| InflowModel {
4406 hydro_id: EntityId(3),
4407 stage_id: i as i32,
4408 mean_m3s: 80.0,
4409 std_m3s: 20.0,
4410 ar_coefficients: vec![],
4411 residual_std_ratio: 1.0,
4412 annual: None,
4413 })
4414 .collect();
4415
4416 let load_models: Vec<LoadModel> = (0..n_stages)
4417 .map(|i| LoadModel {
4418 bus_id: EntityId(1),
4419 stage_id: i as i32,
4420 mean_mw: 100.0,
4421 std_mw: 0.0,
4422 })
4423 .collect();
4424
4425 let inflow_history: Vec<InflowHistoryRow> = (1990_i32..=1991)
4429 .flat_map(|year| {
4430 (1u32..=12).map(move |month| InflowHistoryRow {
4431 hydro_id: EntityId(3),
4432 date: NaiveDate::from_ymd_opt(year, month, 1).unwrap(),
4433 value_m3s: 80.0 + f64::from(year - 1990) * 5.0,
4434 })
4435 })
4436 .collect();
4437
4438 let n_st = n_stages.max(1);
4439
4440 let bounds = ResolvedBounds::new(
4441 &BoundsCountsSpec {
4442 n_hydros: 1,
4443 n_thermals: 1,
4444 n_lines: 0,
4445 n_pumping: 0,
4446 n_contracts: 0,
4447 n_stages: n_st,
4448 k_max: 0,
4449 },
4450 &BoundsDefaults {
4451 hydro: default_hydro_bounds(),
4452 thermal: ThermalStageBounds {
4453 min_generation_mw: 0.0,
4454 max_generation_mw: 100.0,
4455 cost_per_mwh: 0.0,
4456 },
4457 line: LineStageBounds {
4458 direct_mw: 0.0,
4459 reverse_mw: 0.0,
4460 },
4461 pumping: PumpingStageBounds {
4462 min_flow_m3s: 0.0,
4463 max_flow_m3s: 0.0,
4464 },
4465 contract: ContractStageBounds {
4466 min_mw: 0.0,
4467 max_mw: 0.0,
4468 price_per_mwh: 0.0,
4469 },
4470 },
4471 );
4472
4473 let penalties = ResolvedPenalties::new(
4474 &PenaltiesCountsSpec {
4475 n_hydros: 1,
4476 n_buses: 1,
4477 n_lines: 0,
4478 n_ncs: 0,
4479 n_stages: n_st,
4480 },
4481 &PenaltiesDefaults {
4482 hydro: default_hydro_penalties(),
4483 bus: BusStagePenalties { excess_cost: 0.0 },
4484 line: LineStagePenalties { exchange_cost: 0.0 },
4485 ncs: NcsStagePenalties {
4486 curtailment_cost: 0.0,
4487 },
4488 },
4489 );
4490
4491 SystemBuilder::new()
4492 .buses(vec![bus])
4493 .thermals(vec![thermal])
4494 .hydros(vec![hydro])
4495 .stages(stages)
4496 .inflow_models(inflow_models)
4497 .load_models(load_models)
4498 .inflow_history(inflow_history)
4499 .bounds(bounds)
4500 .penalties(penalties)
4501 .build()
4502 .expect("system_with_historical_inflow: valid")
4503 }
4504
4505 #[test]
4509 fn historical_library_built_when_scheme_is_historical() {
4510 let system = system_with_historical_inflow(2);
4511 let config = minimal_config_with_schemes(1, 5, Some("historical"), None, None);
4512 let stochastic = build_stochastic_context(
4513 &system,
4514 42,
4515 None,
4516 &[],
4517 &[],
4518 OpeningTreeInputs::default(),
4519 ClassSchemes {
4520 inflow: Some(SamplingScheme::Historical),
4521 load: Some(SamplingScheme::InSample),
4522 ncs: Some(SamplingScheme::InSample),
4523 },
4524 )
4525 .expect("stochastic context");
4526
4527 let setup = StudySetup::new(
4528 &system,
4529 &config,
4530 stochastic,
4531 PrepareHydroModelsResult::default_from_system(&system),
4532 )
4533 .expect("setup");
4534
4535 let lib = setup
4536 .scenario_libraries
4537 .training
4538 .historical
4539 .as_ref()
4540 .expect("expected Some(HistoricalScenarioLibrary) for Historical scheme");
4541 assert!(
4542 lib.n_windows() > 0,
4543 "expected at least one historical window, got 0"
4544 );
4545 assert_eq!(
4546 lib.n_stages(),
4547 2,
4548 "expected n_stages == 2 matching the system's study stages"
4549 );
4550 assert_eq!(lib.n_hydros(), 1, "expected n_hydros == 1");
4551 }
4552
4553 #[test]
4557 #[allow(
4558 clippy::too_many_lines,
4559 clippy::cast_possible_truncation,
4560 clippy::cast_possible_wrap,
4561 clippy::cast_precision_loss,
4562 clippy::cast_lossless
4563 )]
4564 fn external_inflow_library_built_when_scheme_is_external() {
4565 use chrono::NaiveDate;
4566 use cobre_core::scenario::ExternalScenarioRow;
4567 use cobre_core::{scenario::InflowModel as CoreInflowModel, system::SystemBuilder};
4568
4569 let hydro_id = EntityId(3);
4572 let mut external_rows: Vec<ExternalScenarioRow> = Vec::new();
4573 for stage_id in 0i32..2 {
4574 for scenario_id in 0i32..3 {
4575 external_rows.push(ExternalScenarioRow {
4576 stage_id,
4577 scenario_id,
4578 hydro_id,
4579 value_m3s: 80.0 + scenario_id as f64 * 5.0,
4580 });
4581 }
4582 }
4583
4584 let bus = Bus {
4589 id: EntityId(1),
4590 name: "B1".to_string(),
4591 deficit_segments: vec![DeficitSegment {
4592 depth_mw: None,
4593 cost_per_mwh: 500.0,
4594 }],
4595 excess_cost: 0.0,
4596 };
4597 let thermal = Thermal {
4598 id: EntityId(2),
4599 name: "T1".to_string(),
4600 bus_id: EntityId(1),
4601 min_generation_mw: 0.0,
4602 max_generation_mw: 100.0,
4603 cost_per_mwh: 50.0,
4604 anticipated_config: None,
4605 entry_stage_id: None,
4606 exit_stage_id: None,
4607 };
4608 let hydro = Hydro {
4609 id: EntityId(3),
4610 name: "H1".to_string(),
4611 bus_id: EntityId(1),
4612 downstream_id: None,
4613 entry_stage_id: None,
4614 exit_stage_id: None,
4615 min_storage_hm3: 0.0,
4616 max_storage_hm3: 200.0,
4617 min_outflow_m3s: 0.0,
4618 max_outflow_m3s: None,
4619 generation_model: HydroGenerationModel::ConstantProductivity,
4620 min_turbined_m3s: 0.0,
4621 max_turbined_m3s: 100.0,
4622 specific_productivity_mw_per_m3s_per_m: None,
4623 min_generation_mw: 0.0,
4624 max_generation_mw: 250.0,
4625 tailrace: None,
4626 hydraulic_losses: None,
4627 efficiency: None,
4628 evaporation_coefficients_mm: None,
4629 evaporation_reference_volumes_hm3: None,
4630 diversion: None,
4631 filling: None,
4632 penalties: HydroPenalties {
4633 spillage_cost: 0.01,
4634 diversion_cost: 0.0,
4635 turbined_cost: 0.0,
4636 storage_violation_below_cost: 0.0,
4637 filling_target_violation_cost: 0.0,
4638 turbined_violation_below_cost: 0.0,
4639 outflow_violation_below_cost: 0.0,
4640 outflow_violation_above_cost: 0.0,
4641 generation_violation_below_cost: 0.0,
4642 evaporation_violation_cost: 0.0,
4643 water_withdrawal_violation_cost: 0.0,
4644 water_withdrawal_violation_pos_cost: 0.0,
4645 water_withdrawal_violation_neg_cost: 0.0,
4646 evaporation_violation_pos_cost: 0.0,
4647 evaporation_violation_neg_cost: 0.0,
4648 inflow_nonnegativity_cost: 1000.0,
4649 },
4650 };
4651 let stages: Vec<Stage> = (0..2usize)
4652 .map(|i| Stage {
4653 index: i,
4654 id: i as i32,
4655 start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
4656 end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
4657 season_id: None,
4658 blocks: vec![Block {
4659 index: 0,
4660 name: "S".to_string(),
4661 duration_hours: 744.0,
4662 }],
4663 block_mode: BlockMode::Parallel,
4664 state_config: StageStateConfig {
4665 storage: true,
4666 inflow_lags: false,
4667 },
4668 risk_config: StageRiskConfig::Expectation,
4669 scenario_config: ScenarioSourceConfig {
4670 branching_factor: 1,
4671 noise_method: NoiseMethod::Saa,
4672 },
4673 })
4674 .collect();
4675
4676 let inflow_models: Vec<CoreInflowModel> = (0..2usize)
4677 .map(|i| CoreInflowModel {
4678 hydro_id: EntityId(3),
4679 stage_id: i as i32,
4680 mean_m3s: 80.0,
4681 std_m3s: 20.0,
4682 ar_coefficients: vec![],
4683 residual_std_ratio: 1.0,
4684 annual: None,
4685 })
4686 .collect();
4687
4688 let load_models: Vec<LoadModel> = (0..2usize)
4689 .map(|i| LoadModel {
4690 bus_id: EntityId(1),
4691 stage_id: i as i32,
4692 mean_mw: 100.0,
4693 std_mw: 0.0,
4694 })
4695 .collect();
4696
4697 let bounds = ResolvedBounds::new(
4698 &BoundsCountsSpec {
4699 n_hydros: 1,
4700 n_thermals: 1,
4701 n_lines: 0,
4702 n_pumping: 0,
4703 n_contracts: 0,
4704 n_stages: 2,
4705 k_max: 0,
4706 },
4707 &BoundsDefaults {
4708 hydro: HydroStageBounds {
4709 min_storage_hm3: 0.0,
4710 max_storage_hm3: 200.0,
4711 min_turbined_m3s: 0.0,
4712 max_turbined_m3s: 100.0,
4713 min_outflow_m3s: 0.0,
4714 max_outflow_m3s: None,
4715 min_generation_mw: 0.0,
4716 max_generation_mw: 250.0,
4717 max_diversion_m3s: None,
4718 filling_inflow_m3s: 0.0,
4719 water_withdrawal_m3s: 0.0,
4720 },
4721 thermal: ThermalStageBounds {
4722 min_generation_mw: 0.0,
4723 max_generation_mw: 100.0,
4724 cost_per_mwh: 0.0,
4725 },
4726 line: LineStageBounds {
4727 direct_mw: 0.0,
4728 reverse_mw: 0.0,
4729 },
4730 pumping: PumpingStageBounds {
4731 min_flow_m3s: 0.0,
4732 max_flow_m3s: 0.0,
4733 },
4734 contract: ContractStageBounds {
4735 min_mw: 0.0,
4736 max_mw: 0.0,
4737 price_per_mwh: 0.0,
4738 },
4739 },
4740 );
4741 let penalties = ResolvedPenalties::new(
4742 &PenaltiesCountsSpec {
4743 n_hydros: 1,
4744 n_buses: 1,
4745 n_lines: 0,
4746 n_ncs: 0,
4747 n_stages: 2,
4748 },
4749 &PenaltiesDefaults {
4750 hydro: HydroStagePenalties {
4751 spillage_cost: 0.01,
4752 diversion_cost: 0.0,
4753 turbined_cost: 0.0,
4754 storage_violation_below_cost: 500.0,
4755 filling_target_violation_cost: 0.0,
4756 turbined_violation_below_cost: 0.0,
4757 outflow_violation_below_cost: 0.0,
4758 outflow_violation_above_cost: 0.0,
4759 generation_violation_below_cost: 0.0,
4760 evaporation_violation_cost: 0.0,
4761 water_withdrawal_violation_cost: 0.0,
4762 water_withdrawal_violation_pos_cost: 0.0,
4763 water_withdrawal_violation_neg_cost: 0.0,
4764 evaporation_violation_pos_cost: 0.0,
4765 evaporation_violation_neg_cost: 0.0,
4766 inflow_nonnegativity_cost: 1000.0,
4767 },
4768 bus: BusStagePenalties { excess_cost: 0.0 },
4769 line: LineStagePenalties { exchange_cost: 0.0 },
4770 ncs: NcsStagePenalties {
4771 curtailment_cost: 0.0,
4772 },
4773 },
4774 );
4775
4776 let system = SystemBuilder::new()
4777 .buses(vec![bus])
4778 .thermals(vec![thermal])
4779 .hydros(vec![hydro])
4780 .stages(stages)
4781 .inflow_models(inflow_models)
4782 .load_models(load_models)
4783 .external_scenarios(external_rows)
4784 .bounds(bounds)
4785 .penalties(penalties)
4786 .build()
4787 .expect("system with external inflow: valid");
4788
4789 let config = minimal_config_with_schemes(1, 5, Some("external"), None, None);
4790 let stochastic = build_stochastic_context(
4791 &system,
4792 42,
4793 None,
4794 &[],
4795 &[],
4796 OpeningTreeInputs::default(),
4797 ClassSchemes {
4798 inflow: Some(SamplingScheme::External),
4799 load: Some(SamplingScheme::InSample),
4800 ncs: Some(SamplingScheme::InSample),
4801 },
4802 )
4803 .expect("stochastic context");
4804
4805 let setup = StudySetup::new(
4806 &system,
4807 &config,
4808 stochastic,
4809 PrepareHydroModelsResult::default_from_system(&system),
4810 )
4811 .expect("setup");
4812
4813 let lib = setup
4814 .scenario_libraries
4815 .training
4816 .external_inflow
4817 .as_ref()
4818 .expect("expected Some(ExternalScenarioLibrary) for External inflow scheme");
4819 assert!(
4820 lib.n_entities() > 0,
4821 "expected n_entities > 0 in external inflow library"
4822 );
4823 assert_eq!(lib.n_stages(), 2);
4824 assert_eq!(lib.n_scenarios(), 3);
4825 assert_eq!(lib.entity_class(), "inflow");
4826 }
4827
4828 #[test]
4832 #[allow(
4833 clippy::too_many_lines,
4834 clippy::cast_possible_truncation,
4835 clippy::cast_possible_wrap,
4836 clippy::cast_precision_loss,
4837 clippy::cast_lossless
4838 )]
4839 fn external_load_library_built_when_scheme_is_external() {
4840 use chrono::NaiveDate;
4841 use cobre_core::scenario::ExternalLoadRow;
4842 use cobre_core::{scenario::InflowModel as CoreInflowModel, system::SystemBuilder};
4843
4844 let bus = Bus {
4845 id: EntityId(1),
4846 name: "B1".to_string(),
4847 deficit_segments: vec![DeficitSegment {
4848 depth_mw: None,
4849 cost_per_mwh: 500.0,
4850 }],
4851 excess_cost: 0.0,
4852 };
4853 let thermal = Thermal {
4854 id: EntityId(2),
4855 name: "T1".to_string(),
4856 bus_id: EntityId(1),
4857 min_generation_mw: 0.0,
4858 max_generation_mw: 100.0,
4859 cost_per_mwh: 50.0,
4860 anticipated_config: None,
4861 entry_stage_id: None,
4862 exit_stage_id: None,
4863 };
4864 let hydro = Hydro {
4865 id: EntityId(3),
4866 name: "H1".to_string(),
4867 bus_id: EntityId(1),
4868 downstream_id: None,
4869 entry_stage_id: None,
4870 exit_stage_id: None,
4871 min_storage_hm3: 0.0,
4872 max_storage_hm3: 200.0,
4873 min_outflow_m3s: 0.0,
4874 max_outflow_m3s: None,
4875 generation_model: HydroGenerationModel::ConstantProductivity,
4876 min_turbined_m3s: 0.0,
4877 max_turbined_m3s: 100.0,
4878 specific_productivity_mw_per_m3s_per_m: None,
4879 min_generation_mw: 0.0,
4880 max_generation_mw: 250.0,
4881 tailrace: None,
4882 hydraulic_losses: None,
4883 efficiency: None,
4884 evaporation_coefficients_mm: None,
4885 evaporation_reference_volumes_hm3: None,
4886 diversion: None,
4887 filling: None,
4888 penalties: HydroPenalties {
4889 spillage_cost: 0.01,
4890 diversion_cost: 0.0,
4891 turbined_cost: 0.0,
4892 storage_violation_below_cost: 0.0,
4893 filling_target_violation_cost: 0.0,
4894 turbined_violation_below_cost: 0.0,
4895 outflow_violation_below_cost: 0.0,
4896 outflow_violation_above_cost: 0.0,
4897 generation_violation_below_cost: 0.0,
4898 evaporation_violation_cost: 0.0,
4899 water_withdrawal_violation_cost: 0.0,
4900 water_withdrawal_violation_pos_cost: 0.0,
4901 water_withdrawal_violation_neg_cost: 0.0,
4902 evaporation_violation_pos_cost: 0.0,
4903 evaporation_violation_neg_cost: 0.0,
4904 inflow_nonnegativity_cost: 1000.0,
4905 },
4906 };
4907
4908 let stages: Vec<Stage> = (0..2usize)
4909 .map(|i| Stage {
4910 index: i,
4911 id: i as i32,
4912 start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
4913 end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
4914 season_id: None,
4915 blocks: vec![Block {
4916 index: 0,
4917 name: "S".to_string(),
4918 duration_hours: 744.0,
4919 }],
4920 block_mode: BlockMode::Parallel,
4921 state_config: StageStateConfig {
4922 storage: true,
4923 inflow_lags: false,
4924 },
4925 risk_config: StageRiskConfig::Expectation,
4926 scenario_config: ScenarioSourceConfig {
4927 branching_factor: 1,
4928 noise_method: NoiseMethod::Saa,
4929 },
4930 })
4931 .collect();
4932
4933 let inflow_models: Vec<CoreInflowModel> = (0..2usize)
4934 .map(|i| CoreInflowModel {
4935 hydro_id: EntityId(3),
4936 stage_id: i as i32,
4937 mean_m3s: 80.0,
4938 std_m3s: 20.0,
4939 ar_coefficients: vec![],
4940 residual_std_ratio: 1.0,
4941 annual: None,
4942 })
4943 .collect();
4944
4945 let load_models: Vec<LoadModel> = (0..2usize)
4946 .map(|i| LoadModel {
4947 bus_id: EntityId(1),
4948 stage_id: i as i32,
4949 mean_mw: 100.0,
4950 std_mw: 10.0,
4951 })
4952 .collect();
4953
4954 let mut external_load_rows: Vec<ExternalLoadRow> = Vec::new();
4956 for stage_id in 0i32..2 {
4957 for scenario_id in 0i32..3 {
4958 external_load_rows.push(ExternalLoadRow {
4959 stage_id,
4960 scenario_id,
4961 bus_id: EntityId(1),
4962 value_mw: 90.0 + scenario_id as f64 * 10.0,
4963 });
4964 }
4965 }
4966
4967 let bounds = ResolvedBounds::new(
4968 &BoundsCountsSpec {
4969 n_hydros: 1,
4970 n_thermals: 1,
4971 n_lines: 0,
4972 n_pumping: 0,
4973 n_contracts: 0,
4974 n_stages: 2,
4975 k_max: 0,
4976 },
4977 &BoundsDefaults {
4978 hydro: HydroStageBounds {
4979 min_storage_hm3: 0.0,
4980 max_storage_hm3: 200.0,
4981 min_turbined_m3s: 0.0,
4982 max_turbined_m3s: 100.0,
4983 min_outflow_m3s: 0.0,
4984 max_outflow_m3s: None,
4985 min_generation_mw: 0.0,
4986 max_generation_mw: 250.0,
4987 max_diversion_m3s: None,
4988 filling_inflow_m3s: 0.0,
4989 water_withdrawal_m3s: 0.0,
4990 },
4991 thermal: ThermalStageBounds {
4992 min_generation_mw: 0.0,
4993 max_generation_mw: 100.0,
4994 cost_per_mwh: 0.0,
4995 },
4996 line: LineStageBounds {
4997 direct_mw: 0.0,
4998 reverse_mw: 0.0,
4999 },
5000 pumping: PumpingStageBounds {
5001 min_flow_m3s: 0.0,
5002 max_flow_m3s: 0.0,
5003 },
5004 contract: ContractStageBounds {
5005 min_mw: 0.0,
5006 max_mw: 0.0,
5007 price_per_mwh: 0.0,
5008 },
5009 },
5010 );
5011 let penalties = ResolvedPenalties::new(
5012 &PenaltiesCountsSpec {
5013 n_hydros: 1,
5014 n_buses: 1,
5015 n_lines: 0,
5016 n_ncs: 0,
5017 n_stages: 2,
5018 },
5019 &PenaltiesDefaults {
5020 hydro: HydroStagePenalties {
5021 spillage_cost: 0.01,
5022 diversion_cost: 0.0,
5023 turbined_cost: 0.0,
5024 storage_violation_below_cost: 500.0,
5025 filling_target_violation_cost: 0.0,
5026 turbined_violation_below_cost: 0.0,
5027 outflow_violation_below_cost: 0.0,
5028 outflow_violation_above_cost: 0.0,
5029 generation_violation_below_cost: 0.0,
5030 evaporation_violation_cost: 0.0,
5031 water_withdrawal_violation_cost: 0.0,
5032 water_withdrawal_violation_pos_cost: 0.0,
5033 water_withdrawal_violation_neg_cost: 0.0,
5034 evaporation_violation_pos_cost: 0.0,
5035 evaporation_violation_neg_cost: 0.0,
5036 inflow_nonnegativity_cost: 1000.0,
5037 },
5038 bus: BusStagePenalties { excess_cost: 0.0 },
5039 line: LineStagePenalties { exchange_cost: 0.0 },
5040 ncs: NcsStagePenalties {
5041 curtailment_cost: 0.0,
5042 },
5043 },
5044 );
5045
5046 let system = SystemBuilder::new()
5047 .buses(vec![bus])
5048 .thermals(vec![thermal])
5049 .hydros(vec![hydro])
5050 .stages(stages)
5051 .inflow_models(inflow_models)
5052 .load_models(load_models)
5053 .external_load_scenarios(external_load_rows)
5054 .bounds(bounds)
5055 .penalties(penalties)
5056 .build()
5057 .expect("system with external load: valid");
5058
5059 let config = minimal_config_with_schemes(1, 5, None, Some("external"), None);
5060 let stochastic = build_stochastic_context(
5061 &system,
5062 42,
5063 None,
5064 &[],
5065 &[],
5066 OpeningTreeInputs::default(),
5067 ClassSchemes {
5068 inflow: Some(SamplingScheme::InSample),
5069 load: Some(SamplingScheme::External),
5070 ncs: Some(SamplingScheme::InSample),
5071 },
5072 )
5073 .expect("stochastic context");
5074
5075 let setup = StudySetup::new(
5076 &system,
5077 &config,
5078 stochastic,
5079 PrepareHydroModelsResult::default_from_system(&system),
5080 )
5081 .expect("setup");
5082
5083 let lib = setup
5084 .scenario_libraries
5085 .training
5086 .external_load
5087 .as_ref()
5088 .expect("expected Some(ExternalScenarioLibrary) for External load scheme");
5089 assert!(
5090 lib.n_entities() > 0,
5091 "expected n_entities > 0 in external load library"
5092 );
5093 assert_eq!(lib.n_stages(), 2);
5094 assert_eq!(lib.n_scenarios(), 3);
5095 assert_eq!(lib.entity_class(), "load");
5096 }
5097
5098 #[test]
5102 #[allow(
5103 clippy::too_many_lines,
5104 clippy::cast_possible_truncation,
5105 clippy::cast_possible_wrap,
5106 clippy::cast_precision_loss,
5107 clippy::cast_lossless
5108 )]
5109 fn external_ncs_library_built_when_scheme_is_external() {
5110 use chrono::NaiveDate;
5111 use cobre_core::scenario::InflowModel as CoreInflowModel;
5112 use cobre_core::{
5113 NonControllableSource,
5114 scenario::{ExternalNcsRow, NcsModel},
5115 system::SystemBuilder,
5116 };
5117
5118 let bus = Bus {
5119 id: EntityId(1),
5120 name: "B1".to_string(),
5121 deficit_segments: vec![DeficitSegment {
5122 depth_mw: None,
5123 cost_per_mwh: 500.0,
5124 }],
5125 excess_cost: 0.0,
5126 };
5127 let thermal = Thermal {
5128 id: EntityId(2),
5129 name: "T1".to_string(),
5130 bus_id: EntityId(1),
5131 min_generation_mw: 0.0,
5132 max_generation_mw: 100.0,
5133 cost_per_mwh: 50.0,
5134 anticipated_config: None,
5135 entry_stage_id: None,
5136 exit_stage_id: None,
5137 };
5138 let hydro = Hydro {
5139 id: EntityId(3),
5140 name: "H1".to_string(),
5141 bus_id: EntityId(1),
5142 downstream_id: None,
5143 entry_stage_id: None,
5144 exit_stage_id: None,
5145 min_storage_hm3: 0.0,
5146 max_storage_hm3: 200.0,
5147 min_outflow_m3s: 0.0,
5148 max_outflow_m3s: None,
5149 generation_model: HydroGenerationModel::ConstantProductivity,
5150 min_turbined_m3s: 0.0,
5151 max_turbined_m3s: 100.0,
5152 specific_productivity_mw_per_m3s_per_m: None,
5153 min_generation_mw: 0.0,
5154 max_generation_mw: 250.0,
5155 tailrace: None,
5156 hydraulic_losses: None,
5157 efficiency: None,
5158 evaporation_coefficients_mm: None,
5159 evaporation_reference_volumes_hm3: None,
5160 diversion: None,
5161 filling: None,
5162 penalties: HydroPenalties {
5163 spillage_cost: 0.01,
5164 diversion_cost: 0.0,
5165 turbined_cost: 0.0,
5166 storage_violation_below_cost: 0.0,
5167 filling_target_violation_cost: 0.0,
5168 turbined_violation_below_cost: 0.0,
5169 outflow_violation_below_cost: 0.0,
5170 outflow_violation_above_cost: 0.0,
5171 generation_violation_below_cost: 0.0,
5172 evaporation_violation_cost: 0.0,
5173 water_withdrawal_violation_cost: 0.0,
5174 water_withdrawal_violation_pos_cost: 0.0,
5175 water_withdrawal_violation_neg_cost: 0.0,
5176 evaporation_violation_pos_cost: 0.0,
5177 evaporation_violation_neg_cost: 0.0,
5178 inflow_nonnegativity_cost: 1000.0,
5179 },
5180 };
5181
5182 let ncs_id = EntityId(4);
5184 let ncs_source = NonControllableSource {
5185 id: ncs_id,
5186 name: "Wind1".to_string(),
5187 bus_id: EntityId(1),
5188 entry_stage_id: None,
5189 exit_stage_id: None,
5190 max_generation_mw: 100.0,
5191 allow_curtailment: true,
5192 curtailment_cost: 0.01,
5193 };
5194
5195 let stages: Vec<Stage> = (0..2usize)
5196 .map(|i| Stage {
5197 index: i,
5198 id: i as i32,
5199 start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
5200 end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
5201 season_id: None,
5202 blocks: vec![Block {
5203 index: 0,
5204 name: "S".to_string(),
5205 duration_hours: 744.0,
5206 }],
5207 block_mode: BlockMode::Parallel,
5208 state_config: StageStateConfig {
5209 storage: true,
5210 inflow_lags: false,
5211 },
5212 risk_config: StageRiskConfig::Expectation,
5213 scenario_config: ScenarioSourceConfig {
5214 branching_factor: 1,
5215 noise_method: NoiseMethod::Saa,
5216 },
5217 })
5218 .collect();
5219
5220 let inflow_models: Vec<CoreInflowModel> = (0..2usize)
5221 .map(|i| CoreInflowModel {
5222 hydro_id: EntityId(3),
5223 stage_id: i as i32,
5224 mean_m3s: 80.0,
5225 std_m3s: 20.0,
5226 ar_coefficients: vec![],
5227 residual_std_ratio: 1.0,
5228 annual: None,
5229 })
5230 .collect();
5231
5232 let load_models: Vec<LoadModel> = (0..2usize)
5233 .map(|i| LoadModel {
5234 bus_id: EntityId(1),
5235 stage_id: i as i32,
5236 mean_mw: 100.0,
5237 std_mw: 0.0,
5238 })
5239 .collect();
5240
5241 let ncs_models: Vec<NcsModel> = (0..2usize)
5243 .map(|i| NcsModel {
5244 ncs_id,
5245 stage_id: i as i32,
5246 mean: 0.8,
5247 std: 0.1,
5248 })
5249 .collect();
5250
5251 let mut external_ncs_rows: Vec<ExternalNcsRow> = Vec::new();
5253 for stage_id in 0i32..2 {
5254 for scenario_id in 0i32..3 {
5255 external_ncs_rows.push(ExternalNcsRow {
5256 stage_id,
5257 scenario_id,
5258 ncs_id,
5259 value: 0.7 + scenario_id as f64 * 0.1,
5260 });
5261 }
5262 }
5263
5264 let bounds = ResolvedBounds::new(
5265 &BoundsCountsSpec {
5266 n_hydros: 1,
5267 n_thermals: 1,
5268 n_lines: 0,
5269 n_pumping: 0,
5270 n_contracts: 0,
5271 n_stages: 2,
5272 k_max: 0,
5273 },
5274 &BoundsDefaults {
5275 hydro: HydroStageBounds {
5276 min_storage_hm3: 0.0,
5277 max_storage_hm3: 200.0,
5278 min_turbined_m3s: 0.0,
5279 max_turbined_m3s: 100.0,
5280 min_outflow_m3s: 0.0,
5281 max_outflow_m3s: None,
5282 min_generation_mw: 0.0,
5283 max_generation_mw: 250.0,
5284 max_diversion_m3s: None,
5285 filling_inflow_m3s: 0.0,
5286 water_withdrawal_m3s: 0.0,
5287 },
5288 thermal: ThermalStageBounds {
5289 min_generation_mw: 0.0,
5290 max_generation_mw: 100.0,
5291 cost_per_mwh: 0.0,
5292 },
5293 line: LineStageBounds {
5294 direct_mw: 0.0,
5295 reverse_mw: 0.0,
5296 },
5297 pumping: PumpingStageBounds {
5298 min_flow_m3s: 0.0,
5299 max_flow_m3s: 0.0,
5300 },
5301 contract: ContractStageBounds {
5302 min_mw: 0.0,
5303 max_mw: 0.0,
5304 price_per_mwh: 0.0,
5305 },
5306 },
5307 );
5308 let penalties = ResolvedPenalties::new(
5309 &PenaltiesCountsSpec {
5310 n_hydros: 1,
5311 n_buses: 1,
5312 n_lines: 0,
5313 n_ncs: 1,
5314 n_stages: 2,
5315 },
5316 &PenaltiesDefaults {
5317 hydro: HydroStagePenalties {
5318 spillage_cost: 0.01,
5319 diversion_cost: 0.0,
5320 turbined_cost: 0.0,
5321 storage_violation_below_cost: 500.0,
5322 filling_target_violation_cost: 0.0,
5323 turbined_violation_below_cost: 0.0,
5324 outflow_violation_below_cost: 0.0,
5325 outflow_violation_above_cost: 0.0,
5326 generation_violation_below_cost: 0.0,
5327 evaporation_violation_cost: 0.0,
5328 water_withdrawal_violation_cost: 0.0,
5329 water_withdrawal_violation_pos_cost: 0.0,
5330 water_withdrawal_violation_neg_cost: 0.0,
5331 evaporation_violation_pos_cost: 0.0,
5332 evaporation_violation_neg_cost: 0.0,
5333 inflow_nonnegativity_cost: 1000.0,
5334 },
5335 bus: BusStagePenalties { excess_cost: 0.0 },
5336 line: LineStagePenalties { exchange_cost: 0.0 },
5337 ncs: NcsStagePenalties {
5338 curtailment_cost: 0.0,
5339 },
5340 },
5341 );
5342
5343 let system = SystemBuilder::new()
5344 .buses(vec![bus])
5345 .thermals(vec![thermal])
5346 .hydros(vec![hydro])
5347 .non_controllable_sources(vec![ncs_source])
5348 .stages(stages)
5349 .inflow_models(inflow_models)
5350 .load_models(load_models)
5351 .ncs_models(ncs_models)
5352 .external_ncs_scenarios(external_ncs_rows)
5353 .bounds(bounds)
5354 .penalties(penalties)
5355 .build()
5356 .expect("system with external NCS: valid");
5357
5358 let config = minimal_config_with_schemes(1, 5, None, None, Some("external"));
5359 let stochastic = build_stochastic_context(
5360 &system,
5361 42,
5362 None,
5363 &[],
5364 &[],
5365 OpeningTreeInputs::default(),
5366 ClassSchemes {
5367 inflow: Some(SamplingScheme::InSample),
5368 load: Some(SamplingScheme::InSample),
5369 ncs: Some(SamplingScheme::External),
5370 },
5371 )
5372 .expect("stochastic context");
5373
5374 let setup = StudySetup::new(
5375 &system,
5376 &config,
5377 stochastic,
5378 PrepareHydroModelsResult::default_from_system(&system),
5379 )
5380 .expect("setup");
5381
5382 let lib = setup
5383 .scenario_libraries
5384 .training
5385 .external_ncs
5386 .as_ref()
5387 .expect("expected Some(ExternalScenarioLibrary) for External NCS scheme");
5388 assert!(
5389 lib.n_entities() > 0,
5390 "expected n_entities > 0 in external NCS library"
5391 );
5392 assert_eq!(lib.n_stages(), 2);
5393 assert_eq!(lib.n_scenarios(), 3);
5394 assert_eq!(lib.entity_class(), "ncs");
5395 }
5396
5397 #[test]
5401 #[allow(
5402 clippy::too_many_lines,
5403 clippy::cast_possible_truncation,
5404 clippy::cast_possible_wrap,
5405 clippy::cast_precision_loss,
5406 clippy::cast_lossless
5407 )]
5408 fn historical_library_fails_when_no_valid_windows() {
5409 use cobre_core::system::SystemBuilder;
5413
5414 use chrono::NaiveDate;
5418 use cobre_core::scenario::InflowModel;
5419
5420 let bus = Bus {
5421 id: EntityId(1),
5422 name: "B1".to_string(),
5423 deficit_segments: vec![DeficitSegment {
5424 depth_mw: None,
5425 cost_per_mwh: 500.0,
5426 }],
5427 excess_cost: 0.0,
5428 };
5429 let thermal = Thermal {
5430 id: EntityId(2),
5431 name: "T1".to_string(),
5432 bus_id: EntityId(1),
5433 min_generation_mw: 0.0,
5434 max_generation_mw: 100.0,
5435 cost_per_mwh: 50.0,
5436 anticipated_config: None,
5437 entry_stage_id: None,
5438 exit_stage_id: None,
5439 };
5440 let hydro = Hydro {
5441 id: EntityId(3),
5442 name: "H1".to_string(),
5443 bus_id: EntityId(1),
5444 downstream_id: None,
5445 entry_stage_id: None,
5446 exit_stage_id: None,
5447 min_storage_hm3: 0.0,
5448 max_storage_hm3: 200.0,
5449 min_outflow_m3s: 0.0,
5450 max_outflow_m3s: None,
5451 generation_model: HydroGenerationModel::ConstantProductivity,
5452 min_turbined_m3s: 0.0,
5453 max_turbined_m3s: 100.0,
5454 specific_productivity_mw_per_m3s_per_m: None,
5455 min_generation_mw: 0.0,
5456 max_generation_mw: 250.0,
5457 tailrace: None,
5458 hydraulic_losses: None,
5459 efficiency: None,
5460 evaporation_coefficients_mm: None,
5461 evaporation_reference_volumes_hm3: None,
5462 diversion: None,
5463 filling: None,
5464 penalties: HydroPenalties {
5465 spillage_cost: 0.01,
5466 diversion_cost: 0.0,
5467 turbined_cost: 0.0,
5468 storage_violation_below_cost: 0.0,
5469 filling_target_violation_cost: 0.0,
5470 turbined_violation_below_cost: 0.0,
5471 outflow_violation_below_cost: 0.0,
5472 outflow_violation_above_cost: 0.0,
5473 generation_violation_below_cost: 0.0,
5474 evaporation_violation_cost: 0.0,
5475 water_withdrawal_violation_cost: 0.0,
5476 water_withdrawal_violation_pos_cost: 0.0,
5477 water_withdrawal_violation_neg_cost: 0.0,
5478 evaporation_violation_pos_cost: 0.0,
5479 evaporation_violation_neg_cost: 0.0,
5480 inflow_nonnegativity_cost: 1000.0,
5481 },
5482 };
5483
5484 let stages: Vec<Stage> = (0..2usize)
5485 .map(|i| Stage {
5486 index: i,
5487 id: i as i32,
5488 start_date: NaiveDate::from_ymd_opt(2024, (i as u32 % 12) + 1, 1).unwrap(),
5489 end_date: NaiveDate::from_ymd_opt(2024, (i as u32 % 12) + 1, 28).unwrap(),
5490 season_id: Some(i % 12),
5491 blocks: vec![Block {
5492 index: 0,
5493 name: "S".to_string(),
5494 duration_hours: 720.0,
5495 }],
5496 block_mode: BlockMode::Parallel,
5497 state_config: StageStateConfig {
5498 storage: true,
5499 inflow_lags: false,
5500 },
5501 risk_config: StageRiskConfig::Expectation,
5502 scenario_config: ScenarioSourceConfig {
5503 branching_factor: 1,
5504 noise_method: NoiseMethod::Saa,
5505 },
5506 })
5507 .collect();
5508
5509 let inflow_models: Vec<InflowModel> = (0..2usize)
5510 .map(|i| InflowModel {
5511 hydro_id: EntityId(3),
5512 stage_id: i as i32,
5513 mean_m3s: 80.0,
5514 std_m3s: 20.0,
5515 ar_coefficients: vec![],
5516 residual_std_ratio: 1.0,
5517 annual: None,
5518 })
5519 .collect();
5520
5521 let load_models: Vec<LoadModel> = (0..2usize)
5522 .map(|i| LoadModel {
5523 bus_id: EntityId(1),
5524 stage_id: i as i32,
5525 mean_mw: 100.0,
5526 std_mw: 0.0,
5527 })
5528 .collect();
5529
5530 let bounds = ResolvedBounds::new(
5531 &BoundsCountsSpec {
5532 n_hydros: 1,
5533 n_thermals: 1,
5534 n_lines: 0,
5535 n_pumping: 0,
5536 n_contracts: 0,
5537 n_stages: 2,
5538 k_max: 0,
5539 },
5540 &BoundsDefaults {
5541 hydro: HydroStageBounds {
5542 min_storage_hm3: 0.0,
5543 max_storage_hm3: 200.0,
5544 min_turbined_m3s: 0.0,
5545 max_turbined_m3s: 100.0,
5546 min_outflow_m3s: 0.0,
5547 max_outflow_m3s: None,
5548 min_generation_mw: 0.0,
5549 max_generation_mw: 250.0,
5550 max_diversion_m3s: None,
5551 filling_inflow_m3s: 0.0,
5552 water_withdrawal_m3s: 0.0,
5553 },
5554 thermal: ThermalStageBounds {
5555 min_generation_mw: 0.0,
5556 max_generation_mw: 100.0,
5557 cost_per_mwh: 0.0,
5558 },
5559 line: LineStageBounds {
5560 direct_mw: 0.0,
5561 reverse_mw: 0.0,
5562 },
5563 pumping: PumpingStageBounds {
5564 min_flow_m3s: 0.0,
5565 max_flow_m3s: 0.0,
5566 },
5567 contract: ContractStageBounds {
5568 min_mw: 0.0,
5569 max_mw: 0.0,
5570 price_per_mwh: 0.0,
5571 },
5572 },
5573 );
5574 let penalties = ResolvedPenalties::new(
5575 &PenaltiesCountsSpec {
5576 n_hydros: 1,
5577 n_buses: 1,
5578 n_lines: 0,
5579 n_ncs: 0,
5580 n_stages: 2,
5581 },
5582 &PenaltiesDefaults {
5583 hydro: HydroStagePenalties {
5584 spillage_cost: 0.01,
5585 diversion_cost: 0.0,
5586 turbined_cost: 0.0,
5587 storage_violation_below_cost: 500.0,
5588 filling_target_violation_cost: 0.0,
5589 turbined_violation_below_cost: 0.0,
5590 outflow_violation_below_cost: 0.0,
5591 outflow_violation_above_cost: 0.0,
5592 generation_violation_below_cost: 0.0,
5593 evaporation_violation_cost: 0.0,
5594 water_withdrawal_violation_cost: 0.0,
5595 water_withdrawal_violation_pos_cost: 0.0,
5596 water_withdrawal_violation_neg_cost: 0.0,
5597 evaporation_violation_pos_cost: 0.0,
5598 evaporation_violation_neg_cost: 0.0,
5599 inflow_nonnegativity_cost: 1000.0,
5600 },
5601 bus: BusStagePenalties { excess_cost: 0.0 },
5602 line: LineStagePenalties { exchange_cost: 0.0 },
5603 ncs: NcsStagePenalties {
5604 curtailment_cost: 0.0,
5605 },
5606 },
5607 );
5608
5609 let system = SystemBuilder::new()
5611 .buses(vec![bus])
5612 .thermals(vec![thermal])
5613 .hydros(vec![hydro])
5614 .stages(stages)
5615 .inflow_models(inflow_models)
5616 .load_models(load_models)
5617 .bounds(bounds)
5618 .penalties(penalties)
5619 .build()
5620 .expect("system: valid");
5621
5622 let config = minimal_config_with_schemes(1, 5, Some("historical"), None, None);
5623 let stochastic = build_stochastic_context(
5624 &system,
5625 42,
5626 None,
5627 &[],
5628 &[],
5629 OpeningTreeInputs::default(),
5630 ClassSchemes {
5631 inflow: Some(SamplingScheme::Historical),
5632 load: Some(SamplingScheme::InSample),
5633 ncs: Some(SamplingScheme::InSample),
5634 },
5635 )
5636 .expect("stochastic context");
5637
5638 let result = StudySetup::new(
5639 &system,
5640 &config,
5641 stochastic,
5642 PrepareHydroModelsResult::default_from_system(&system),
5643 );
5644
5645 assert!(result.is_err(), "expected Err when no historical data");
5646 let err_msg = result.unwrap_err().to_string();
5647 assert!(
5648 err_msg.contains("window") || err_msg.contains("historical"),
5649 "error should mention windows or historical, got: {err_msg}"
5650 );
5651 }
5652
5653 #[test]
5658 fn test_simulate_uses_simulation_scheme() {
5659 let system = minimal_system(2);
5660
5661 let mut config = minimal_config(1, 5);
5663 config.simulation.scenario_source = Some(RawScenarioSourceConfig {
5664 seed: Some(99),
5665 historical_years: None,
5666 inflow: Some(RawClassConfigEntry {
5667 scheme: "out_of_sample".to_string(),
5668 }),
5669 load: None,
5670 ncs: None,
5671 });
5672
5673 let stochastic = build_stochastic_context(
5674 &system,
5675 42,
5676 None,
5677 &[],
5678 &[],
5679 OpeningTreeInputs::default(),
5680 ClassSchemes {
5681 inflow: Some(SamplingScheme::InSample),
5682 load: Some(SamplingScheme::InSample),
5683 ncs: Some(SamplingScheme::InSample),
5684 },
5685 )
5686 .expect("stochastic context");
5687
5688 let setup = StudySetup::new(
5689 &system,
5690 &config,
5691 stochastic,
5692 PrepareHydroModelsResult::default_from_system(&system),
5693 )
5694 .expect("setup");
5695
5696 let train_ctx = setup.training_ctx();
5697 assert_eq!(
5698 train_ctx.inflow_scheme,
5699 SamplingScheme::InSample,
5700 "training context must use InSample inflow scheme"
5701 );
5702
5703 let sim_ctx = setup.simulation_ctx();
5704 assert_eq!(
5705 sim_ctx.inflow_scheme,
5706 SamplingScheme::OutOfSample,
5707 "simulation context must use OutOfSample inflow scheme"
5708 );
5709 }
5710
5711 #[test]
5716 fn test_sim_historical_library_built_when_sim_scheme_is_historical() {
5717 let system = system_with_historical_inflow(2);
5718
5719 let mut config = minimal_config(1, 5);
5721 config.simulation.scenario_source = Some(RawScenarioSourceConfig {
5722 seed: Some(42),
5723 historical_years: None,
5724 inflow: Some(RawClassConfigEntry {
5725 scheme: "historical".to_string(),
5726 }),
5727 load: None,
5728 ncs: None,
5729 });
5730
5731 let stochastic = build_stochastic_context(
5733 &system,
5734 42,
5735 None,
5736 &[],
5737 &[],
5738 OpeningTreeInputs::default(),
5739 ClassSchemes {
5740 inflow: Some(SamplingScheme::InSample),
5741 load: Some(SamplingScheme::InSample),
5742 ncs: Some(SamplingScheme::InSample),
5743 },
5744 )
5745 .expect("stochastic context");
5746
5747 let setup = StudySetup::new(
5748 &system,
5749 &config,
5750 stochastic,
5751 PrepareHydroModelsResult::default_from_system(&system),
5752 )
5753 .expect("setup");
5754
5755 assert!(
5756 setup.training_ctx().historical_library.is_none(),
5757 "training context must NOT have a historical library when scheme is InSample"
5758 );
5759 assert!(
5760 setup.simulation_ctx().historical_library.is_some(),
5761 "simulation context must have a historical library when sim scheme is Historical"
5762 );
5763 }
5764
5765 #[allow(
5771 clippy::too_many_lines,
5772 clippy::cast_possible_truncation,
5773 clippy::cast_possible_wrap,
5774 clippy::items_after_statements
5775 )]
5776 fn minimal_system_with_anticipated_lead_stages(
5777 n_stages: usize,
5778 lead_stages: u32,
5779 ) -> cobre_core::System {
5780 use chrono::NaiveDate;
5781
5782 let bus = Bus {
5783 id: EntityId(1),
5784 name: "B1".to_string(),
5785 deficit_segments: vec![DeficitSegment {
5786 depth_mw: None,
5787 cost_per_mwh: 500.0,
5788 }],
5789 excess_cost: 0.0,
5790 };
5791
5792 let thermal = Thermal {
5793 id: EntityId(2),
5794 name: "T1".to_string(),
5795 bus_id: EntityId(1),
5796 min_generation_mw: 0.0,
5797 max_generation_mw: 100.0,
5798 cost_per_mwh: 50.0,
5799 anticipated_config: Some(AnticipatedConfig { lead_stages }),
5800 entry_stage_id: None,
5801 exit_stage_id: None,
5802 };
5803
5804 let hydro = Hydro {
5805 id: EntityId(3),
5806 name: "H1".to_string(),
5807 bus_id: EntityId(1),
5808 downstream_id: None,
5809 entry_stage_id: None,
5810 exit_stage_id: None,
5811 min_storage_hm3: 0.0,
5812 max_storage_hm3: 200.0,
5813 min_outflow_m3s: 0.0,
5814 max_outflow_m3s: None,
5815 generation_model: HydroGenerationModel::ConstantProductivity,
5816 min_turbined_m3s: 0.0,
5817 max_turbined_m3s: 100.0,
5818 specific_productivity_mw_per_m3s_per_m: None,
5819 min_generation_mw: 0.0,
5820 max_generation_mw: 250.0,
5821 tailrace: None,
5822 hydraulic_losses: None,
5823 efficiency: None,
5824 evaporation_coefficients_mm: None,
5825 evaporation_reference_volumes_hm3: None,
5826 diversion: None,
5827 filling: None,
5828 penalties: HydroPenalties {
5829 spillage_cost: 0.01,
5830 diversion_cost: 0.0,
5831 turbined_cost: 0.0,
5832 storage_violation_below_cost: 0.0,
5833 filling_target_violation_cost: 0.0,
5834 turbined_violation_below_cost: 0.0,
5835 outflow_violation_below_cost: 0.0,
5836 outflow_violation_above_cost: 0.0,
5837 generation_violation_below_cost: 0.0,
5838 evaporation_violation_cost: 0.0,
5839 water_withdrawal_violation_cost: 0.0,
5840 water_withdrawal_violation_pos_cost: 0.0,
5841 water_withdrawal_violation_neg_cost: 0.0,
5842 evaporation_violation_pos_cost: 0.0,
5843 evaporation_violation_neg_cost: 0.0,
5844 inflow_nonnegativity_cost: 1000.0,
5845 },
5846 };
5847
5848 let stages: Vec<Stage> = (0..n_stages)
5849 .map(|i| Stage {
5850 index: i,
5851 id: i as i32,
5852 start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
5853 end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
5854 season_id: None,
5855 blocks: vec![Block {
5856 index: 0,
5857 name: "S".to_string(),
5858 duration_hours: 744.0,
5859 }],
5860 block_mode: BlockMode::Parallel,
5861 state_config: StageStateConfig {
5862 storage: true,
5863 inflow_lags: false,
5864 },
5865 risk_config: StageRiskConfig::Expectation,
5866 scenario_config: ScenarioSourceConfig {
5867 branching_factor: 1,
5868 noise_method: NoiseMethod::Saa,
5869 },
5870 })
5871 .collect();
5872
5873 let inflow_models: Vec<InflowModel> = (0..n_stages)
5874 .map(|i| InflowModel {
5875 hydro_id: EntityId(3),
5876 stage_id: i as i32,
5877 mean_m3s: 80.0,
5878 std_m3s: 20.0,
5879 ar_coefficients: vec![],
5880 residual_std_ratio: 1.0,
5881 annual: None,
5882 })
5883 .collect();
5884
5885 let load_models: Vec<LoadModel> = (0..n_stages)
5886 .map(|i| LoadModel {
5887 bus_id: EntityId(1),
5888 stage_id: i as i32,
5889 mean_mw: 100.0,
5890 std_mw: 0.0,
5891 })
5892 .collect();
5893
5894 let n_st = n_stages.max(1);
5895 let k_max_bounds = lead_stages as usize;
5896
5897 fn default_hydro_bounds() -> HydroStageBounds {
5898 HydroStageBounds {
5899 min_storage_hm3: 0.0,
5900 max_storage_hm3: 200.0,
5901 min_turbined_m3s: 0.0,
5902 max_turbined_m3s: 100.0,
5903 min_outflow_m3s: 0.0,
5904 max_outflow_m3s: None,
5905 min_generation_mw: 0.0,
5906 max_generation_mw: 250.0,
5907 max_diversion_m3s: None,
5908 filling_inflow_m3s: 0.0,
5909 water_withdrawal_m3s: 0.0,
5910 }
5911 }
5912
5913 fn default_hydro_penalties() -> HydroStagePenalties {
5914 HydroStagePenalties {
5915 spillage_cost: 0.01,
5916 diversion_cost: 0.0,
5917 turbined_cost: 0.0,
5918 storage_violation_below_cost: 500.0,
5919 filling_target_violation_cost: 0.0,
5920 turbined_violation_below_cost: 0.0,
5921 outflow_violation_below_cost: 0.0,
5922 outflow_violation_above_cost: 0.0,
5923 generation_violation_below_cost: 0.0,
5924 evaporation_violation_cost: 0.0,
5925 water_withdrawal_violation_cost: 0.0,
5926 water_withdrawal_violation_pos_cost: 0.0,
5927 water_withdrawal_violation_neg_cost: 0.0,
5928 evaporation_violation_pos_cost: 0.0,
5929 evaporation_violation_neg_cost: 0.0,
5930 inflow_nonnegativity_cost: 1000.0,
5931 }
5932 }
5933
5934 let bounds = ResolvedBounds::new(
5935 &BoundsCountsSpec {
5936 n_hydros: 1,
5937 n_thermals: 1,
5938 n_lines: 0,
5939 n_pumping: 0,
5940 n_contracts: 0,
5941 n_stages: n_st,
5942 k_max: k_max_bounds,
5943 },
5944 &BoundsDefaults {
5945 hydro: default_hydro_bounds(),
5946 thermal: ThermalStageBounds {
5947 min_generation_mw: 0.0,
5948 max_generation_mw: 100.0,
5949 cost_per_mwh: 0.0,
5950 },
5951 line: LineStageBounds {
5952 direct_mw: 0.0,
5953 reverse_mw: 0.0,
5954 },
5955 pumping: PumpingStageBounds {
5956 min_flow_m3s: 0.0,
5957 max_flow_m3s: 0.0,
5958 },
5959 contract: ContractStageBounds {
5960 min_mw: 0.0,
5961 max_mw: 0.0,
5962 price_per_mwh: 0.0,
5963 },
5964 },
5965 );
5966
5967 let penalties = ResolvedPenalties::new(
5968 &PenaltiesCountsSpec {
5969 n_hydros: 1,
5970 n_buses: 1,
5971 n_lines: 0,
5972 n_ncs: 0,
5973 n_stages: n_st,
5974 },
5975 &PenaltiesDefaults {
5976 hydro: default_hydro_penalties(),
5977 bus: BusStagePenalties { excess_cost: 0.0 },
5978 line: LineStagePenalties { exchange_cost: 0.0 },
5979 ncs: NcsStagePenalties {
5980 curtailment_cost: 0.0,
5981 },
5982 },
5983 );
5984
5985 SystemBuilder::new()
5986 .buses(vec![bus])
5987 .thermals(vec![thermal])
5988 .hydros(vec![hydro])
5989 .stages(stages)
5990 .inflow_models(inflow_models)
5991 .load_models(load_models)
5992 .bounds(bounds)
5993 .penalties(penalties)
5994 .build()
5995 .expect("minimal_system_with_anticipated_lead_stages: valid")
5996 }
5997
5998 #[test]
6002 fn setup_wires_anticipated_metadata_into_indexer() {
6003 let system = minimal_system_with_anticipated_lead_stages(2, 2);
6004 let config = minimal_config(1, 10);
6005 let stochastic = build_stochastic_context(
6006 &system,
6007 42,
6008 None,
6009 &[],
6010 &[],
6011 OpeningTreeInputs::default(),
6012 ClassSchemes {
6013 inflow: Some(SamplingScheme::InSample),
6014 load: Some(SamplingScheme::InSample),
6015 ncs: Some(SamplingScheme::InSample),
6016 },
6017 )
6018 .expect("stochastic context");
6019
6020 let setup = StudySetup::new(
6021 &system,
6022 &config,
6023 stochastic,
6024 PrepareHydroModelsResult::default_from_system(&system),
6025 )
6026 .expect("setup");
6027
6028 assert_eq!(
6029 setup.stage_data.indexer.n_anticipated, 1,
6030 "expected n_anticipated == 1"
6031 );
6032 assert_eq!(setup.stage_data.indexer.k_max, 2, "expected k_max == 2");
6033 assert_eq!(
6034 setup.stage_data.indexer.anticipated_lead_stages,
6035 vec![2],
6036 "expected anticipated_lead_stages == [2]"
6037 );
6038 }
6039}