mod accessors;
pub(crate) mod methodology_config;
mod orchestration;
pub mod params;
pub(crate) mod scenario_libraries;
pub mod scenario_library_set;
pub mod stage_data;
pub mod stochastic_pipeline;
pub(crate) mod template_postprocess;
pub use params::{
ConstructionConfig, DEFAULT_FORWARD_PASSES, DEFAULT_MAX_ITERATIONS, DEFAULT_SEED, StudyParams,
};
pub use scenario_library_set::{PhaseLibraries, ScenarioLibraries};
pub use stage_data::StageData;
pub use stochastic_pipeline::{
PrepareStochasticResult, build_ncs_factor_entries, load_load_factors_for_stochastic,
prepare_stochastic,
};
use std::path::Path;
use cobre_core::{
EntityId, Stage, System,
scenario::{SamplingScheme, ScenarioSource},
};
use cobre_io::build_hydro_reference_volume_fractions;
use cobre_stochastic::{ExternalScenarioLibrary, HistoricalScenarioLibrary, StochasticContext};
use crate::{
config::{CutManagementConfig, EventParams},
cut::FutureCostFunction,
energy_conversion::{EnergyConversionSet, build_energy_conversion_set},
error::SddpError,
horizon_mode::HorizonMode,
hydro_models::{EvaporationModel, PrepareHydroModelsResult, ResolvedProductionModel},
indexer::StageIndexer,
lp_builder::build_stage_templates,
risk_measure::RiskMeasure,
simulation::EntityCounts,
stopping_rule::{StoppingRule, StoppingRuleSet},
};
#[derive(Debug)]
pub struct StudySetup {
pub stage_data: stage_data::StageData,
pub stochastic: StochasticContext,
pub fcf: FutureCostFunction,
pub(crate) initial_state: Vec<f64>,
pub hydro_models: PrepareHydroModelsResult,
pub(crate) ncs_entity_ids_per_stage: Vec<Vec<i32>>,
pub(crate) ncs_max_gen: Vec<f64>,
pub(crate) ncs_allow_curtailment: Vec<bool>,
pub scenario_libraries: ScenarioLibraries,
pub loop_params: crate::config::LoopParams,
pub simulation_config: crate::simulation::SimulationConfig,
pub policy_path: String,
pub(crate) cut_management: CutManagementConfig,
pub(crate) events: EventParams,
pub(crate) methodology: methodology_config::MethodologyConfig,
pub(crate) recent_observation_seed: crate::lag_transition::RecentObservationSeed,
pub(crate) downstream_par_order: usize,
pub(crate) energy_conversion: EnergyConversionSet,
pub(crate) hydro_min_storage_hm3: Vec<f64>,
#[allow(dead_code)]
pub(crate) resolved_parameters: crate::resolved_parameters::ResolvedParameters,
}
impl StudySetup {
pub fn new(
system: &System,
config: &cobre_io::Config,
stochastic: StochasticContext,
hydro_models: PrepareHydroModelsResult,
) -> Result<Self, SddpError> {
let params = StudyParams::from_config(config)?;
let sentinel_path = Path::new("config.json");
let training_source = config
.training_scenario_source(sentinel_path)
.map_err(|e| SddpError::Validation(e.to_string()))?;
let simulation_source = config
.simulation_scenario_source(sentinel_path)
.map_err(|e| SddpError::Validation(e.to_string()))?;
let config = params.into_construction_config();
Self::from_broadcast_params(
system,
stochastic,
config,
hydro_models,
&training_source,
&simulation_source,
)
}
#[allow(clippy::missing_panics_doc, clippy::too_many_lines)]
pub fn from_broadcast_params(
system: &System,
stochastic: StochasticContext,
config: ConstructionConfig,
hydro_models: PrepareHydroModelsResult,
training_source: &ScenarioSource,
simulation_source: &ScenarioSource,
) -> Result<Self, SddpError> {
let ConstructionConfig {
seed,
forward_passes,
stopping_rule_set,
n_scenarios,
io_channel_capacity,
policy_path,
inflow_method,
cut_selection,
cut_activity_tolerance,
basis_activity_window,
budget,
export_states,
scalar_parameters,
} = config;
let n_stages_pre = system.stages().iter().filter(|s| s.id >= 0).count();
let stage_to_season: Vec<i32> = system
.stages()
.iter()
.filter(|s| s.id >= 0)
.map(|s| i32::try_from(s.season_id.unwrap_or(0)).unwrap_or(0))
.collect();
let reference_volume_fractions = build_hydro_reference_volume_fractions(
Vec::new(),
0.65,
system.hydros(),
&stage_to_season,
)?;
let energy_conversion = build_energy_conversion_set(
system.hydros(),
n_stages_pre,
system.cascade(),
&reference_volume_fractions,
&std::collections::HashMap::<cobre_core::EntityId, Vec<cobre_io::HydroGeometryRow>>::new(),
Some(&hydro_models.productivity_override),
Some(&hydro_models.production),
)
.map_err(|e| SddpError::Validation(e.to_string()))?;
let resolved_parameters = crate::resolved_parameters::build_resolved_parameters(
&scalar_parameters,
&energy_conversion,
&hydro_models.productivity_override,
system.hydros(),
&stage_to_season,
n_stages_pre,
)
.map_err(|e| SddpError::Validation(e.to_string()))?;
let mut stage_templates = build_stage_templates(
system,
inflow_method,
stochastic.par(),
stochastic.normal(),
&hydro_models.production,
&hydro_models.evaporation,
&resolved_parameters,
)?;
let scaling_report =
template_postprocess::postprocess_templates(&mut stage_templates, system);
if stage_templates.templates.is_empty() {
return Err(SddpError::Validation(
"system has no study stages".to_string(),
));
}
let stage_templates_ref = &stage_templates.templates;
let n_blks_stage0 = system.stages().first().map_or(1, |s| s.blocks.len().max(1));
let has_inflow_penalty =
inflow_method.has_slack_columns() && stage_templates_ref[0].n_hydro > 0;
let n_hydros = system.hydros().len();
let mut fpha_hydro_indices: Vec<usize> = Vec::new();
let mut fpha_planes: Vec<usize> = Vec::new();
let mut evap_hydro_indices: Vec<usize> = Vec::new();
for h_idx in 0..n_hydros {
if let ResolvedProductionModel::Fpha { planes, .. } =
hydro_models.production.model(h_idx, 0)
{
fpha_hydro_indices.push(h_idx);
fpha_planes.push(planes.len());
}
if matches!(
hydro_models.evaporation.model(h_idx),
EvaporationModel::Linearized { .. }
) {
evap_hydro_indices.push(h_idx);
}
}
let max_deficit_segments = system
.buses()
.iter()
.map(|b| b.deficit_segments.len())
.max()
.unwrap_or(0);
let eq_counts = crate::indexer::EquipmentCounts {
hydro_count: stage_templates_ref[0].n_hydro,
max_par_order: stage_templates_ref[0].max_par_order,
n_thermals: system.thermals().len(),
n_lines: system.lines().len(),
n_buses: system.buses().len(),
n_blks: n_blks_stage0,
has_inflow_penalty,
max_deficit_segments,
};
let fpha_cfg = crate::indexer::FphaColumnLayout {
hydro_indices: fpha_hydro_indices,
planes_per_hydro: fpha_planes,
};
let evap_cfg = crate::indexer::EvapConfig {
hydro_indices: evap_hydro_indices,
};
let mut indexer =
StageIndexer::with_equipment_and_evaporation(&eq_counts, &fpha_cfg, &evap_cfg);
if !stage_templates.ncs_col_starts.is_empty() {
let ncs_start = stage_templates.ncs_col_starts[0];
let n_ncs_stage0 = stage_templates.n_ncs_per_stage[0];
indexer.ncs_generation = ncs_start..(ncs_start + n_ncs_stage0 * n_blks_stage0);
for (s, &start) in stage_templates.ncs_col_starts.iter().enumerate() {
debug_assert_eq!(
start, ncs_start,
"NCS column start differs at stage {s}: expected {ncs_start}, got {start}"
);
}
}
if indexer.max_par_order > 0 && stochastic.par().n_hydros() > 0 {
let par = stochastic.par();
let effective_lag_counts: Vec<usize> = (0..par.n_hydros())
.map(|h| par.effective_lag_count(h))
.collect();
indexer.set_nonzero_mask(&effective_lag_counts);
}
let initial_state = build_initial_state(system, &indexer);
let n_stages = stage_templates_ref.len();
let max_iterations = max_iterations_from_rules(&stopping_rule_set);
let fcf_capacity_iterations = max_iterations.saturating_add(1);
let fcf = FutureCostFunction::new(
n_stages,
indexer.n_state,
forward_passes,
fcf_capacity_iterations,
&vec![0; n_stages],
);
let horizon = HorizonMode::Finite {
num_stages: n_stages,
};
let risk_measures: Vec<RiskMeasure> = system
.stages()
.iter()
.filter(|s| s.id >= 0)
.map(|s| RiskMeasure::from(s.risk_config))
.collect();
let entity_counts = build_entity_counts(system);
let ncs_entity_ids_per_stage: Vec<Vec<i32>> = stage_templates
.active_ncs_indices
.iter()
.map(|stage_indices| {
stage_indices
.iter()
.map(|&sys_idx| entity_counts.non_controllable_ids[sys_idx])
.collect()
})
.collect();
let (ncs_max_gen, ncs_allow_curtailment): (Vec<f64>, Vec<bool>) = {
let stoch_ncs_ids = stochastic.ncs_entity_ids();
let mut max_v = Vec::with_capacity(stoch_ncs_ids.len());
let mut allow_v = Vec::with_capacity(stoch_ncs_ids.len());
for ncs_id in stoch_ncs_ids {
let ncs = system
.non_controllable_sources()
.iter()
.find(|n| n.id == *ncs_id)
.ok_or_else(|| {
SddpError::Validation(format!(
"stochastic NCS entity {ncs_id:?} not found in system non_controllable_sources"
))
})?;
max_v.push(ncs.max_generation_mw);
allow_v.push(ncs.allow_curtailment);
}
(max_v, allow_v)
};
let block_counts_per_stage: Vec<usize> = stage_templates
.block_hours_per_stage
.iter()
.map(Vec::len)
.collect();
let max_blocks = block_counts_per_stage.iter().copied().max().unwrap_or(0);
let inflow_scheme = training_source.inflow_scheme;
let load_scheme = training_source.load_scheme;
let ncs_scheme = training_source.ncs_scheme;
let sim_inflow_scheme = simulation_source.inflow_scheme;
let sim_load_scheme = simulation_source.load_scheme;
let sim_ncs_scheme = simulation_source.ncs_scheme;
let stages: Vec<Stage> = system
.stages()
.iter()
.filter(|s| s.id >= 0)
.cloned()
.collect();
let noop_season_map;
let season_map_ref = if let Some(sm) = system.policy_graph().season_map.as_ref() {
sm
} else {
noop_season_map = cobre_core::temporal::SeasonMap {
cycle_type: cobre_core::temporal::SeasonCycleType::Monthly,
seasons: Vec::new(),
};
&noop_season_map
};
let has_quarterly_stages = stages
.iter()
.any(|s| s.season_id.is_some_and(|id| id >= 12));
let downstream_par_order = if has_quarterly_stages {
stochastic.par().max_order()
} else {
0
};
let stage_lag_transitions = crate::lag_transition::precompute_stage_lag_transitions(
&stages,
season_map_ref,
downstream_par_order,
);
let noise_group_ids = crate::lag_transition::precompute_noise_groups(&stages);
let recent_observation_seed = if stages.is_empty() {
crate::lag_transition::RecentObservationSeed::zero(system.hydros().len())
} else {
crate::lag_transition::compute_recent_observation_seed(
&system.initial_conditions().recent_observations,
&stages[0],
season_map_ref,
system.hydros(),
)
};
let hydro_ids: Vec<EntityId> = system.hydros().iter().map(|h| h.id).collect();
let training_historical: Option<HistoricalScenarioLibrary> =
if inflow_scheme == SamplingScheme::Historical {
Some(scenario_libraries::build_historical_inflow_library(
system.inflow_history(),
&hydro_ids,
&stages,
stochastic.par(),
system.policy_graph().season_map.as_ref(),
&system.initial_conditions().past_inflows,
&stage_lag_transitions,
training_source.historical_years.as_ref(),
forward_passes,
)?)
} else {
None
};
let training_external_inflow: Option<ExternalScenarioLibrary> =
if inflow_scheme == SamplingScheme::External {
Some(scenario_libraries::build_external_inflow_library(
system.external_scenarios(),
&hydro_ids,
&stages,
stochastic.par(),
&system.initial_conditions().past_inflows,
&stage_lag_transitions,
forward_passes,
)?)
} else {
None
};
let training_external_load: Option<ExternalScenarioLibrary> =
if load_scheme == SamplingScheme::External {
Some(scenario_libraries::build_external_load_library(
system.external_load_scenarios(),
system.load_models(),
&stages,
forward_passes,
)?)
} else {
None
};
let training_external_ncs: Option<ExternalScenarioLibrary> =
if ncs_scheme == SamplingScheme::External {
Some(scenario_libraries::build_external_ncs_library(
system.external_ncs_scenarios(),
system.ncs_models(),
&stages,
forward_passes,
)?)
} else {
None
};
let simulation_historical: Option<HistoricalScenarioLibrary> = if sim_inflow_scheme
== SamplingScheme::Historical
&& sim_inflow_scheme != inflow_scheme
{
Some(scenario_libraries::build_historical_inflow_library(
system.inflow_history(),
&hydro_ids,
&stages,
stochastic.par(),
system.policy_graph().season_map.as_ref(),
&system.initial_conditions().past_inflows,
&stage_lag_transitions,
simulation_source.historical_years.as_ref(),
forward_passes,
)?)
} else {
None
};
let simulation_external_inflow: Option<ExternalScenarioLibrary> = if sim_inflow_scheme
== SamplingScheme::External
&& sim_inflow_scheme != inflow_scheme
{
Some(scenario_libraries::build_external_inflow_library(
system.external_scenarios(),
&hydro_ids,
&stages,
stochastic.par(),
&system.initial_conditions().past_inflows,
&stage_lag_transitions,
forward_passes,
)?)
} else {
None
};
let simulation_external_load: Option<ExternalScenarioLibrary> =
if sim_load_scheme == SamplingScheme::External && sim_load_scheme != load_scheme {
Some(scenario_libraries::build_external_load_library(
system.external_load_scenarios(),
system.load_models(),
&stages,
forward_passes,
)?)
} else {
None
};
let simulation_external_ncs: Option<ExternalScenarioLibrary> =
if sim_ncs_scheme == SamplingScheme::External && sim_ncs_scheme != ncs_scheme {
Some(scenario_libraries::build_external_ncs_library(
system.external_ncs_scenarios(),
system.ncs_models(),
&stages,
forward_passes,
)?)
} else {
None
};
let scenario_libraries = ScenarioLibraries {
training: PhaseLibraries {
inflow_scheme,
load_scheme,
ncs_scheme,
historical: training_historical,
external_inflow: training_external_inflow,
external_load: training_external_load,
external_ncs: training_external_ncs,
},
simulation: PhaseLibraries {
inflow_scheme: sim_inflow_scheme,
load_scheme: sim_load_scheme,
ncs_scheme: sim_ncs_scheme,
historical: simulation_historical,
external_inflow: simulation_external_inflow,
external_load: simulation_external_load,
external_ncs: simulation_external_ncs,
},
};
let hydro_min_storage_hm3: Vec<f64> =
system.hydros().iter().map(|h| h.min_storage_hm3).collect();
Ok(Self {
stage_data: stage_data::StageData {
stage_templates,
indexer,
stages,
entity_counts,
block_counts_per_stage,
stage_lag_transitions,
noise_group_ids,
scaling_report,
},
stochastic,
fcf,
initial_state,
hydro_models,
ncs_entity_ids_per_stage,
ncs_max_gen,
ncs_allow_curtailment,
scenario_libraries,
loop_params: crate::config::LoopParams {
seed,
forward_passes,
max_iterations,
start_iteration: 0,
max_blocks,
stopping_rules: stopping_rule_set,
},
simulation_config: crate::simulation::SimulationConfig {
n_scenarios,
io_channel_capacity,
basis_activity_window,
},
policy_path,
cut_management: CutManagementConfig {
cut_selection,
budget,
cut_activity_tolerance,
basis_activity_window,
warm_start_cuts: 0,
risk_measures,
},
events: EventParams { export_states },
methodology: methodology_config::MethodologyConfig {
horizon,
inflow_method,
},
recent_observation_seed,
downstream_par_order,
energy_conversion,
hydro_min_storage_hm3,
resolved_parameters,
})
}
}
fn max_iterations_from_rules(rules: &StoppingRuleSet) -> u64 {
rules
.rules
.iter()
.filter_map(|r| {
if let StoppingRule::IterationLimit { limit } = r {
Some(*limit)
} else {
None
}
})
.max()
.unwrap_or(DEFAULT_MAX_ITERATIONS)
}
fn build_entity_counts(system: &System) -> EntityCounts {
EntityCounts {
hydro_ids: system.hydros().iter().map(|h| h.id.0).collect(),
hydro_productivities: vec![0.0; system.hydros().len()],
thermal_ids: system.thermals().iter().map(|t| t.id.0).collect(),
line_ids: system.lines().iter().map(|l| l.id.0).collect(),
bus_ids: system.buses().iter().map(|b| b.id.0).collect(),
pumping_station_ids: system.pumping_stations().iter().map(|p| p.id.0).collect(),
contract_ids: system.contracts().iter().map(|c| c.id.0).collect(),
non_controllable_ids: system
.non_controllable_sources()
.iter()
.map(|n| n.id.0)
.collect(),
}
}
fn build_initial_state(system: &System, indexer: &StageIndexer) -> Vec<f64> {
let mut state = vec![0.0_f64; indexer.n_state];
let hydros = system.hydros();
let ic = system.initial_conditions();
for hs in &ic.storage {
if let Ok(idx) = hydros.binary_search_by_key(&hs.hydro_id.0, |h| h.id.0) {
state[idx] = hs.value_hm3;
}
}
if indexer.max_par_order > 0 {
let n_h = indexer.hydro_count;
for pi in &ic.past_inflows {
if let Ok(idx) = hydros.binary_search_by_key(&pi.hydro_id.0, |h| h.id.0) {
let n_lags = pi.values_m3s.len().min(indexer.max_par_order);
for lag in 0..n_lags {
let slot = indexer.inflow_lags.start + lag * n_h + idx;
state[slot] = pi.values_m3s[lag];
}
}
}
}
state
}
#[cfg(test)]
mod tests {
use super::StudySetup;
use crate::hydro_models::{
PrepareHydroModelsResult, ProductionModelSet, ResolvedProductionModel,
};
use crate::indexer::StageIndexer;
use cobre_core::{
BoundsCountsSpec, BoundsDefaults, BusStagePenalties, ContractStageBounds, HydroStageBounds,
HydroStagePenalties, LineStageBounds, LineStagePenalties, NcsStagePenalties,
PenaltiesCountsSpec, PenaltiesDefaults, PumpingStageBounds, ResolvedBounds,
ResolvedPenalties, ThermalStageBounds,
};
use cobre_core::{
EntityId, SystemBuilder,
entities::{
bus::{Bus, DeficitSegment},
hydro::{Hydro, HydroGenerationModel, HydroPenalties},
thermal::Thermal,
},
scenario::{InflowModel, LoadModel, SamplingScheme},
temporal::{
Block, BlockMode, NoiseMethod, ScenarioSourceConfig, Stage, StageRiskConfig,
StageStateConfig,
},
};
use cobre_io::config::{
Config, EstimationConfig, ExportsConfig, InflowNonNegativityConfig,
InflowNonNegativityMethod as CfgInflowMethod, ModelingConfig, PolicyConfig,
RawClassConfigEntry, RawScenarioSourceConfig, RowSelectionConfig,
SimulationConfig as IoSimulationConfig, StoppingRuleConfig, TrainingConfig,
TrainingSolverConfig, UpperBoundEvaluationConfig,
};
use cobre_stochastic::{ClassSchemes, OpeningTreeInputs, build_stochastic_context};
#[allow(
clippy::too_many_lines,
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::items_after_statements
)]
fn minimal_system(n_stages: usize) -> cobre_core::System {
use chrono::NaiveDate;
let bus = Bus {
id: EntityId(1),
name: "B1".to_string(),
deficit_segments: vec![DeficitSegment {
depth_mw: None,
cost_per_mwh: 500.0,
}],
excess_cost: 0.0,
};
let thermal = Thermal {
id: EntityId(2),
name: "T1".to_string(),
bus_id: EntityId(1),
min_generation_mw: 0.0,
max_generation_mw: 100.0,
cost_per_mwh: 50.0,
gnl_config: None,
entry_stage_id: None,
exit_stage_id: None,
};
let hydro = Hydro {
id: EntityId(3),
name: "H1".to_string(),
bus_id: EntityId(1),
downstream_id: None,
entry_stage_id: None,
exit_stage_id: None,
min_storage_hm3: 0.0,
max_storage_hm3: 200.0,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
generation_model: HydroGenerationModel::ConstantProductivity,
min_turbined_m3s: 0.0,
max_turbined_m3s: 100.0,
specific_productivity_mw_per_m3s_per_m: None,
min_generation_mw: 0.0,
max_generation_mw: 250.0,
tailrace: None,
hydraulic_losses: None,
efficiency: None,
evaporation_coefficients_mm: None,
evaporation_reference_volumes_hm3: None,
diversion: None,
filling: None,
penalties: HydroPenalties {
spillage_cost: 0.01,
diversion_cost: 0.0,
turbined_cost: 0.0,
storage_violation_below_cost: 0.0,
filling_target_violation_cost: 0.0,
turbined_violation_below_cost: 0.0,
outflow_violation_below_cost: 0.0,
outflow_violation_above_cost: 0.0,
generation_violation_below_cost: 0.0,
evaporation_violation_cost: 0.0,
water_withdrawal_violation_cost: 0.0,
water_withdrawal_violation_pos_cost: 0.0,
water_withdrawal_violation_neg_cost: 0.0,
evaporation_violation_pos_cost: 0.0,
evaporation_violation_neg_cost: 0.0,
inflow_nonnegativity_cost: 1000.0,
},
};
let stages: Vec<Stage> = (0..n_stages)
.map(|i| Stage {
index: i,
id: i as i32,
start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
season_id: None,
blocks: vec![Block {
index: 0,
name: "S".to_string(),
duration_hours: 744.0,
}],
block_mode: BlockMode::Parallel,
state_config: StageStateConfig {
storage: true,
inflow_lags: false,
},
risk_config: StageRiskConfig::Expectation,
scenario_config: ScenarioSourceConfig {
branching_factor: 1,
noise_method: NoiseMethod::Saa,
},
})
.collect();
let inflow_models: Vec<InflowModel> = (0..n_stages)
.map(|i| InflowModel {
hydro_id: EntityId(3),
stage_id: i as i32,
mean_m3s: 80.0,
std_m3s: 20.0,
ar_coefficients: vec![],
residual_std_ratio: 1.0,
annual: None,
})
.collect();
let load_models: Vec<LoadModel> = (0..n_stages)
.map(|i| LoadModel {
bus_id: EntityId(1),
stage_id: i as i32,
mean_mw: 100.0,
std_mw: 0.0,
})
.collect();
let n_st = n_stages.max(1);
fn default_hydro_bounds() -> HydroStageBounds {
HydroStageBounds {
min_storage_hm3: 0.0,
max_storage_hm3: 200.0,
min_turbined_m3s: 0.0,
max_turbined_m3s: 100.0,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
min_generation_mw: 0.0,
max_generation_mw: 250.0,
max_diversion_m3s: None,
filling_inflow_m3s: 0.0,
water_withdrawal_m3s: 0.0,
}
}
fn default_hydro_penalties() -> HydroStagePenalties {
HydroStagePenalties {
spillage_cost: 0.01,
diversion_cost: 0.0,
turbined_cost: 0.0,
storage_violation_below_cost: 500.0,
filling_target_violation_cost: 0.0,
turbined_violation_below_cost: 0.0,
outflow_violation_below_cost: 0.0,
outflow_violation_above_cost: 0.0,
generation_violation_below_cost: 0.0,
evaporation_violation_cost: 0.0,
water_withdrawal_violation_cost: 0.0,
water_withdrawal_violation_pos_cost: 0.0,
water_withdrawal_violation_neg_cost: 0.0,
evaporation_violation_pos_cost: 0.0,
evaporation_violation_neg_cost: 0.0,
inflow_nonnegativity_cost: 1000.0,
}
}
let bounds = ResolvedBounds::new(
&BoundsCountsSpec {
n_hydros: 1,
n_thermals: 1,
n_lines: 0,
n_pumping: 0,
n_contracts: 0,
n_stages: n_st,
},
&BoundsDefaults {
hydro: default_hydro_bounds(),
thermal: ThermalStageBounds {
min_generation_mw: 0.0,
max_generation_mw: 100.0,
cost_per_mwh: 0.0,
},
line: LineStageBounds {
direct_mw: 0.0,
reverse_mw: 0.0,
},
pumping: PumpingStageBounds {
min_flow_m3s: 0.0,
max_flow_m3s: 0.0,
},
contract: ContractStageBounds {
min_mw: 0.0,
max_mw: 0.0,
price_per_mwh: 0.0,
},
},
);
let penalties = ResolvedPenalties::new(
&PenaltiesCountsSpec {
n_hydros: 1,
n_buses: 1,
n_lines: 0,
n_ncs: 0,
n_stages: n_st,
},
&PenaltiesDefaults {
hydro: default_hydro_penalties(),
bus: BusStagePenalties { excess_cost: 0.0 },
line: LineStagePenalties { exchange_cost: 0.0 },
ncs: NcsStagePenalties {
curtailment_cost: 0.0,
},
},
);
SystemBuilder::new()
.buses(vec![bus])
.thermals(vec![thermal])
.hydros(vec![hydro])
.stages(stages)
.inflow_models(inflow_models)
.load_models(load_models)
.bounds(bounds)
.penalties(penalties)
.build()
.expect("minimal_system: valid")
}
#[allow(
clippy::too_many_lines,
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::items_after_statements
)]
fn minimal_fpha_misconfigured_system(n_stages: usize) -> cobre_core::System {
use chrono::NaiveDate;
let bus = Bus {
id: EntityId(1),
name: "B1".to_string(),
deficit_segments: vec![DeficitSegment {
depth_mw: None,
cost_per_mwh: 500.0,
}],
excess_cost: 0.0,
};
let thermal = Thermal {
id: EntityId(2),
name: "T1".to_string(),
bus_id: EntityId(1),
min_generation_mw: 0.0,
max_generation_mw: 100.0,
cost_per_mwh: 50.0,
gnl_config: None,
entry_stage_id: None,
exit_stage_id: None,
};
let hydro = Hydro {
id: EntityId(3),
name: "H_FPHA_BAD".to_string(),
bus_id: EntityId(1),
downstream_id: None,
entry_stage_id: None,
exit_stage_id: None,
min_storage_hm3: 0.0,
max_storage_hm3: 200.0,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
generation_model: HydroGenerationModel::Fpha,
min_turbined_m3s: 0.0,
max_turbined_m3s: 100.0,
specific_productivity_mw_per_m3s_per_m: None,
min_generation_mw: 0.0,
max_generation_mw: 250.0,
tailrace: None,
hydraulic_losses: None,
efficiency: None,
evaporation_coefficients_mm: None,
evaporation_reference_volumes_hm3: None,
diversion: None,
filling: None,
penalties: HydroPenalties {
spillage_cost: 0.01,
diversion_cost: 0.0,
turbined_cost: 0.0,
storage_violation_below_cost: 0.0,
filling_target_violation_cost: 0.0,
turbined_violation_below_cost: 0.0,
outflow_violation_below_cost: 0.0,
outflow_violation_above_cost: 0.0,
generation_violation_below_cost: 0.0,
evaporation_violation_cost: 0.0,
water_withdrawal_violation_cost: 0.0,
water_withdrawal_violation_pos_cost: 0.0,
water_withdrawal_violation_neg_cost: 0.0,
evaporation_violation_pos_cost: 0.0,
evaporation_violation_neg_cost: 0.0,
inflow_nonnegativity_cost: 1000.0,
},
};
let stages: Vec<Stage> = (0..n_stages)
.map(|i| Stage {
index: i,
id: i as i32,
start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
season_id: None,
blocks: vec![Block {
index: 0,
name: "S".to_string(),
duration_hours: 744.0,
}],
block_mode: BlockMode::Parallel,
state_config: StageStateConfig {
storage: true,
inflow_lags: false,
},
risk_config: StageRiskConfig::Expectation,
scenario_config: ScenarioSourceConfig {
branching_factor: 1,
noise_method: NoiseMethod::Saa,
},
})
.collect();
let inflow_models: Vec<InflowModel> = (0..n_stages)
.map(|i| InflowModel {
hydro_id: EntityId(3),
stage_id: i as i32,
mean_m3s: 80.0,
std_m3s: 20.0,
ar_coefficients: vec![],
residual_std_ratio: 1.0,
annual: None,
})
.collect();
let load_models: Vec<LoadModel> = (0..n_stages)
.map(|i| LoadModel {
bus_id: EntityId(1),
stage_id: i as i32,
mean_mw: 100.0,
std_mw: 0.0,
})
.collect();
let n_st = n_stages.max(1);
let bounds = ResolvedBounds::new(
&BoundsCountsSpec {
n_hydros: 1,
n_thermals: 1,
n_lines: 0,
n_pumping: 0,
n_contracts: 0,
n_stages: n_st,
},
&BoundsDefaults {
hydro: HydroStageBounds {
min_storage_hm3: 0.0,
max_storage_hm3: 200.0,
min_turbined_m3s: 0.0,
max_turbined_m3s: 100.0,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
min_generation_mw: 0.0,
max_generation_mw: 250.0,
max_diversion_m3s: None,
filling_inflow_m3s: 0.0,
water_withdrawal_m3s: 0.0,
},
thermal: ThermalStageBounds {
min_generation_mw: 0.0,
max_generation_mw: 100.0,
cost_per_mwh: 0.0,
},
line: LineStageBounds {
direct_mw: 0.0,
reverse_mw: 0.0,
},
pumping: PumpingStageBounds {
min_flow_m3s: 0.0,
max_flow_m3s: 0.0,
},
contract: ContractStageBounds {
min_mw: 0.0,
max_mw: 0.0,
price_per_mwh: 0.0,
},
},
);
let penalties = ResolvedPenalties::new(
&PenaltiesCountsSpec {
n_hydros: 1,
n_buses: 1,
n_lines: 0,
n_ncs: 0,
n_stages: n_st,
},
&PenaltiesDefaults {
hydro: HydroStagePenalties {
spillage_cost: 0.01,
diversion_cost: 0.0,
turbined_cost: 0.0,
storage_violation_below_cost: 500.0,
filling_target_violation_cost: 0.0,
turbined_violation_below_cost: 0.0,
outflow_violation_below_cost: 0.0,
outflow_violation_above_cost: 0.0,
generation_violation_below_cost: 0.0,
evaporation_violation_cost: 0.0,
water_withdrawal_violation_cost: 0.0,
water_withdrawal_violation_pos_cost: 0.0,
water_withdrawal_violation_neg_cost: 0.0,
evaporation_violation_pos_cost: 0.0,
evaporation_violation_neg_cost: 0.0,
inflow_nonnegativity_cost: 1000.0,
},
bus: BusStagePenalties { excess_cost: 0.0 },
line: LineStagePenalties { exchange_cost: 0.0 },
ncs: NcsStagePenalties {
curtailment_cost: 0.0,
},
},
);
SystemBuilder::new()
.buses(vec![bus])
.thermals(vec![thermal])
.hydros(vec![hydro])
.stages(stages)
.inflow_models(inflow_models)
.load_models(load_models)
.bounds(bounds)
.penalties(penalties)
.build()
.expect("minimal_fpha_misconfigured_system: valid")
}
fn minimal_config(forward_passes: u32, max_iterations: u32) -> Config {
Config {
schema: None,
modeling: ModelingConfig {
inflow_non_negativity: InflowNonNegativityConfig {
method: CfgInflowMethod::Penalty,
},
},
training: TrainingConfig {
enabled: true,
tree_seed: Some(42),
forward_passes: Some(forward_passes),
stopping_rules: Some(vec![StoppingRuleConfig::IterationLimit {
limit: max_iterations,
}]),
stopping_mode: "any".to_string(),
cut_selection: RowSelectionConfig::default(),
solver: TrainingSolverConfig::default(),
scenario_source: None,
},
upper_bound_evaluation: UpperBoundEvaluationConfig::default(),
policy: PolicyConfig::default(),
simulation: IoSimulationConfig::default(),
exports: ExportsConfig::default(),
estimation: EstimationConfig::default(),
energy: cobre_io::EnergyConfig::default(),
}
}
fn minimal_config_with_schemes(
forward_passes: u32,
max_iterations: u32,
inflow_scheme: Option<&str>,
load_scheme: Option<&str>,
ncs_scheme: Option<&str>,
) -> Config {
let needs_seed = inflow_scheme.is_some_and(|s| s != "in_sample")
|| load_scheme.is_some_and(|s| s != "in_sample")
|| ncs_scheme.is_some_and(|s| s != "in_sample");
let scenario_source = RawScenarioSourceConfig {
seed: if needs_seed { Some(42) } else { None },
historical_years: None,
inflow: inflow_scheme.map(|s| RawClassConfigEntry {
scheme: s.to_string(),
}),
load: load_scheme.map(|s| RawClassConfigEntry {
scheme: s.to_string(),
}),
ncs: ncs_scheme.map(|s| RawClassConfigEntry {
scheme: s.to_string(),
}),
};
let mut config = minimal_config(forward_passes, max_iterations);
config.training.scenario_source = Some(scenario_source);
config
}
#[test]
fn new_minimal_valid_system_returns_ok() {
let system = minimal_system(2);
let config = minimal_config(1, 10);
let stochastic = build_stochastic_context(
&system,
42,
None,
&[],
&[],
OpeningTreeInputs::default(),
ClassSchemes {
inflow: Some(SamplingScheme::InSample),
load: Some(SamplingScheme::InSample),
ncs: Some(SamplingScheme::InSample),
},
)
.expect("stochastic context");
let result = StudySetup::new(
&system,
&config,
stochastic,
PrepareHydroModelsResult::default_from_system(&system),
);
assert!(result.is_ok(), "expected Ok, got {result:?}");
let setup = result.unwrap();
assert!(!setup.stage_data.stage_templates.templates.is_empty());
}
#[test]
fn new_zero_stages_returns_validation_error() {
let system = minimal_system(0);
let config = minimal_config(1, 10);
let stochastic = build_stochastic_context(
&system,
42,
None,
&[],
&[],
OpeningTreeInputs::default(),
ClassSchemes {
inflow: Some(SamplingScheme::InSample),
load: Some(SamplingScheme::InSample),
ncs: Some(SamplingScheme::InSample),
},
)
.expect("stochastic context");
let result = StudySetup::new(
&system,
&config,
stochastic,
PrepareHydroModelsResult::default_from_system(&system),
);
assert!(result.is_err(), "expected Err, got Ok");
let err = result.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("no study stages"),
"error message should contain 'no study stages': {msg}"
);
}
#[test]
fn accessor_methods_return_expected_values() {
let n_stages = 3;
let system = minimal_system(n_stages);
let config = minimal_config(2, 50);
let stochastic = build_stochastic_context(
&system,
42,
None,
&[],
&[],
OpeningTreeInputs::default(),
ClassSchemes {
inflow: Some(SamplingScheme::InSample),
load: Some(SamplingScheme::InSample),
ncs: Some(SamplingScheme::InSample),
},
)
.expect("stochastic context");
let setup = StudySetup::new(
&system,
&config,
stochastic,
PrepareHydroModelsResult::default_from_system(&system),
)
.expect("setup");
assert_eq!(setup.stage_data.stage_templates.templates.len(), n_stages);
assert_eq!(setup.stage_data.stage_templates.base_rows.len(), n_stages);
assert_eq!(setup.loop_params.seed, 42);
assert_eq!(setup.loop_params.forward_passes, 2);
assert_eq!(setup.loop_params.max_iterations, 50);
assert_eq!(setup.simulation_config.n_scenarios, 0); assert_eq!(setup.policy_path, "./policy");
assert_eq!(setup.stage_data.block_counts_per_stage.len(), n_stages);
assert!(setup.loop_params.max_blocks > 0);
assert_eq!(setup.methodology.horizon.num_stages(), n_stages);
assert_eq!(setup.cut_management.risk_measures.len(), n_stages);
assert_eq!(setup.fcf.pools.len(), n_stages);
assert_eq!(setup.stage_data.entity_counts.hydro_ids.len(), 1);
assert_eq!(setup.stage_data.entity_counts.thermal_ids.len(), 1);
}
#[test]
fn fcf_mut_allows_cut_insertion() {
let system = minimal_system(2);
let config = minimal_config(1, 10);
let stochastic = build_stochastic_context(
&system,
42,
None,
&[],
&[],
OpeningTreeInputs::default(),
ClassSchemes {
inflow: Some(SamplingScheme::InSample),
load: Some(SamplingScheme::InSample),
ncs: Some(SamplingScheme::InSample),
},
)
.expect("stochastic context");
let mut setup = StudySetup::new(
&system,
&config,
stochastic,
PrepareHydroModelsResult::default_from_system(&system),
)
.expect("setup");
let n_state = setup.stage_data.indexer.n_state;
let coefficients = vec![1.0_f64; n_state];
setup.fcf.add_cut(0, 0, 0, 42.0, &coefficients);
assert_eq!(setup.fcf.total_active_cuts(), 1);
}
#[test]
fn inflow_method_reflects_config() {
use crate::InflowNonNegativityMethod;
let system = minimal_system(2);
let config = minimal_config(1, 10);
let stochastic = build_stochastic_context(
&system,
42,
None,
&[],
&[],
OpeningTreeInputs::default(),
ClassSchemes {
inflow: Some(SamplingScheme::InSample),
load: Some(SamplingScheme::InSample),
ncs: Some(SamplingScheme::InSample),
},
)
.expect("stochastic context");
let setup = StudySetup::new(
&system,
&config,
stochastic,
PrepareHydroModelsResult::default_from_system(&system),
)
.expect("setup");
assert!(
!matches!(
setup.methodology.inflow_method,
InflowNonNegativityMethod::None
),
"expected penalty or truncation method"
);
}
#[test]
fn cut_selection_none_when_disabled() {
let system = minimal_system(2);
let config = minimal_config(1, 10);
let stochastic = build_stochastic_context(
&system,
42,
None,
&[],
&[],
OpeningTreeInputs::default(),
ClassSchemes {
inflow: Some(SamplingScheme::InSample),
load: Some(SamplingScheme::InSample),
ncs: Some(SamplingScheme::InSample),
},
)
.expect("stochastic context");
let setup = StudySetup::new(
&system,
&config,
stochastic,
PrepareHydroModelsResult::default_from_system(&system),
)
.expect("setup");
assert!(
setup.cut_management.cut_selection.is_none(),
"cut_selection should be None when disabled"
);
}
#[test]
fn stage_ctx_fields_match_study_setup() {
let n_stages = 3;
let system = minimal_system(n_stages);
let config = minimal_config(2, 10);
let stochastic = build_stochastic_context(
&system,
42,
None,
&[],
&[],
OpeningTreeInputs::default(),
ClassSchemes {
inflow: Some(SamplingScheme::InSample),
load: Some(SamplingScheme::InSample),
ncs: Some(SamplingScheme::InSample),
},
)
.expect("stochastic context");
let setup = StudySetup::new(
&system,
&config,
stochastic,
PrepareHydroModelsResult::default_from_system(&system),
)
.expect("setup");
let ctx = setup.stage_ctx();
assert_eq!(
ctx.templates.len(),
setup.stage_data.stage_templates.templates.len(),
"templates length mismatch"
);
assert_eq!(
ctx.base_rows.len(),
setup.stage_data.stage_templates.base_rows.len(),
"base_rows length mismatch"
);
assert_eq!(
ctx.noise_scale.len(),
setup.stage_data.stage_templates.noise_scale.len(),
"noise_scale length mismatch"
);
assert_eq!(
ctx.n_hydros,
setup.stage_data.entity_counts.hydro_ids.len(),
"n_hydros mismatch"
);
assert_eq!(
ctx.block_counts_per_stage.len(),
setup.stage_data.block_counts_per_stage.len(),
"block_counts_per_stage length mismatch"
);
}
#[test]
fn training_ctx_fields_match_study_setup() {
let n_stages = 3;
let system = minimal_system(n_stages);
let config = minimal_config(2, 10);
let stochastic = build_stochastic_context(
&system,
42,
None,
&[],
&[],
OpeningTreeInputs::default(),
ClassSchemes {
inflow: Some(SamplingScheme::InSample),
load: Some(SamplingScheme::InSample),
ncs: Some(SamplingScheme::InSample),
},
)
.expect("stochastic context");
let setup = StudySetup::new(
&system,
&config,
stochastic,
PrepareHydroModelsResult::default_from_system(&system),
)
.expect("setup");
let ctx = setup.training_ctx();
assert_eq!(
ctx.horizon.num_stages(),
setup.methodology.horizon.num_stages(),
"horizon num_stages mismatch"
);
assert_eq!(
ctx.indexer.n_state, setup.stage_data.indexer.n_state,
"indexer n_state mismatch"
);
assert_eq!(
ctx.initial_state.len(),
setup.initial_state.len(),
"initial_state length mismatch"
);
}
#[test]
fn train_completes_within_iteration_limit() {
use cobre_comm::LocalBackend;
use cobre_solver::highs::HighsSolver;
let system = minimal_system(2);
let config = minimal_config(1, 3);
let stochastic = build_stochastic_context(
&system,
42,
None,
&[],
&[],
OpeningTreeInputs::default(),
ClassSchemes {
inflow: Some(SamplingScheme::InSample),
load: Some(SamplingScheme::InSample),
ncs: Some(SamplingScheme::InSample),
},
)
.expect("stochastic context");
let mut setup = StudySetup::new(
&system,
&config,
stochastic,
PrepareHydroModelsResult::default_from_system(&system),
)
.expect("setup");
let comm = LocalBackend;
let mut solver = HighsSolver::new().expect("solver");
let result = setup
.train(&mut solver, &comm, 1, HighsSolver::new, None, None)
.expect("train");
assert!(
result.result.iterations <= 3,
"expected iterations <= 3, got {}",
result.result.iterations
);
assert!(
result.result.iterations >= 1,
"expected at least 1 iteration, got {}",
result.result.iterations
);
}
#[test]
fn train_generates_cuts_in_fcf() {
use cobre_comm::LocalBackend;
use cobre_solver::highs::HighsSolver;
let system = minimal_system(2);
let config = minimal_config(1, 3);
let stochastic = build_stochastic_context(
&system,
42,
None,
&[],
&[],
OpeningTreeInputs::default(),
ClassSchemes {
inflow: Some(SamplingScheme::InSample),
load: Some(SamplingScheme::InSample),
ncs: Some(SamplingScheme::InSample),
},
)
.expect("stochastic context");
let mut setup = StudySetup::new(
&system,
&config,
stochastic,
PrepareHydroModelsResult::default_from_system(&system),
)
.expect("setup");
let comm = LocalBackend;
let mut solver = HighsSolver::new().expect("solver");
setup
.train(&mut solver, &comm, 1, HighsSolver::new, None, None)
.expect("train");
assert!(
setup.fcf.pools[0].populated_count > 0,
"expected at least one cut in FCF pool[0] after training"
);
}
#[test]
fn simulation_config_reflects_setup_fields() {
use cobre_io::config::SimulationConfig as IoSimulationConfig;
let mut config = minimal_config(1, 5);
config.simulation = IoSimulationConfig {
enabled: true,
num_scenarios: 50,
io_channel_capacity: 16,
..IoSimulationConfig::default()
};
let system = minimal_system(2);
let stochastic = build_stochastic_context(
&system,
42,
None,
&[],
&[],
OpeningTreeInputs::default(),
ClassSchemes {
inflow: Some(SamplingScheme::InSample),
load: Some(SamplingScheme::InSample),
ncs: Some(SamplingScheme::InSample),
},
)
.expect("stochastic context");
let setup = StudySetup::new(
&system,
&config,
stochastic,
PrepareHydroModelsResult::default_from_system(&system),
)
.expect("setup");
let sim_cfg = setup.simulation_config();
assert_eq!(sim_cfg.n_scenarios, setup.simulation_config.n_scenarios);
assert_eq!(
sim_cfg.io_channel_capacity,
setup.simulation_config.io_channel_capacity
);
}
#[test]
fn create_workspace_pool_returns_correct_size() {
use cobre_comm::LocalBackend;
use cobre_solver::highs::HighsSolver;
let system = minimal_system(2);
let config = minimal_config(1, 3);
let stochastic = build_stochastic_context(
&system,
42,
None,
&[],
&[],
OpeningTreeInputs::default(),
ClassSchemes {
inflow: Some(SamplingScheme::InSample),
load: Some(SamplingScheme::InSample),
ncs: Some(SamplingScheme::InSample),
},
)
.expect("stochastic context");
let setup = StudySetup::new(
&system,
&config,
stochastic,
PrepareHydroModelsResult::default_from_system(&system),
)
.expect("setup");
let comm = LocalBackend;
let pool = setup
.create_workspace_pool(&comm, 2, HighsSolver::new)
.expect("workspace pool");
assert_eq!(pool.workspaces.len(), 2);
}
#[test]
fn build_training_output_non_empty() {
use cobre_comm::LocalBackend;
use cobre_solver::highs::HighsSolver;
let system = minimal_system(2);
let config = minimal_config(1, 2);
let stochastic = build_stochastic_context(
&system,
42,
None,
&[],
&[],
OpeningTreeInputs::default(),
ClassSchemes {
inflow: Some(SamplingScheme::InSample),
load: Some(SamplingScheme::InSample),
ncs: Some(SamplingScheme::InSample),
},
)
.expect("stochastic context");
let mut setup = StudySetup::new(
&system,
&config,
stochastic,
PrepareHydroModelsResult::default_from_system(&system),
)
.expect("setup");
let comm = LocalBackend;
let mut solver = HighsSolver::new().expect("solver");
let (event_tx, event_rx) = std::sync::mpsc::channel();
let result = setup
.train(
&mut solver,
&comm,
1,
HighsSolver::new,
Some(event_tx),
None,
)
.expect("train");
let events: Vec<cobre_core::TrainingEvent> = event_rx.try_iter().collect();
let output = setup.build_training_output(&result.result, &events);
assert!(
!output.convergence_records.is_empty(),
"convergence_records must be non-empty after training"
);
}
#[test]
fn simulate_after_train_returns_nonempty_costs() {
use cobre_comm::LocalBackend;
use cobre_solver::highs::HighsSolver;
let mut config = minimal_config(1, 3);
config.simulation = cobre_io::config::SimulationConfig {
enabled: true,
num_scenarios: 3,
io_channel_capacity: 8,
..cobre_io::config::SimulationConfig::default()
};
let system = minimal_system(2);
let stochastic = build_stochastic_context(
&system,
42,
None,
&[],
&[],
OpeningTreeInputs::default(),
ClassSchemes {
inflow: Some(SamplingScheme::InSample),
load: Some(SamplingScheme::InSample),
ncs: Some(SamplingScheme::InSample),
},
)
.expect("stochastic context");
let mut setup = StudySetup::new(
&system,
&config,
stochastic,
PrepareHydroModelsResult::default_from_system(&system),
)
.expect("setup");
let comm = LocalBackend;
let mut solver = HighsSolver::new().expect("solver");
setup
.train(&mut solver, &comm, 1, HighsSolver::new, None, None)
.expect("train");
let mut pool = setup
.create_workspace_pool(&comm, 1, HighsSolver::new)
.expect("sim pool");
let io_capacity = setup.simulation_config.io_channel_capacity.max(1);
let (result_tx, result_rx) = std::sync::mpsc::sync_channel(io_capacity);
let drain_handle = std::thread::spawn(move || result_rx.into_iter().collect::<Vec<_>>());
let sim_result = setup
.simulate(&mut pool.workspaces, &comm, &result_tx, None, None, &[])
.expect("simulate");
drop(result_tx);
let _results = drain_handle.join().expect("drain thread");
assert!(
!sim_result.costs.is_empty(),
"simulate must return at least one cost entry"
);
assert_eq!(
sim_result.solver_stats.len(),
sim_result.costs.len(),
"one solver stats entry per scenario"
);
}
#[test]
fn study_params_from_config_defaults() {
use super::{DEFAULT_FORWARD_PASSES, DEFAULT_SEED, StudyParams};
use crate::stopping_rule::StoppingMode;
use cobre_io::config::{
Config, EstimationConfig, ExportsConfig, InflowNonNegativityConfig,
InflowNonNegativityMethod as CfgInflowMethod, ModelingConfig, PolicyConfig,
RowSelectionConfig, SimulationConfig as IoSimulationConfig, TrainingConfig,
TrainingSolverConfig, UpperBoundEvaluationConfig,
};
let config = Config {
schema: None,
modeling: ModelingConfig {
inflow_non_negativity: InflowNonNegativityConfig {
method: CfgInflowMethod::None,
},
},
training: TrainingConfig {
enabled: true,
tree_seed: None,
forward_passes: None,
stopping_rules: None,
stopping_mode: "any".to_string(),
cut_selection: RowSelectionConfig::default(),
solver: TrainingSolverConfig::default(),
scenario_source: None,
},
upper_bound_evaluation: UpperBoundEvaluationConfig::default(),
policy: PolicyConfig::default(),
simulation: IoSimulationConfig::default(),
exports: ExportsConfig::default(),
estimation: EstimationConfig::default(),
energy: cobre_io::EnergyConfig::default(),
};
let params = StudyParams::from_config(&config).expect("from_config");
assert_eq!(
params.seed, DEFAULT_SEED,
"seed should default to DEFAULT_SEED"
);
assert_eq!(
params.forward_passes, DEFAULT_FORWARD_PASSES,
"forward_passes should default to DEFAULT_FORWARD_PASSES"
);
assert_eq!(
params.stopping_rule_set.rules.len(),
1,
"expected exactly 1 default stopping rule"
);
assert!(
matches!(
params.stopping_rule_set.rules[0],
crate::stopping_rule::StoppingRule::IterationLimit { .. }
),
"default rule should be IterationLimit"
);
assert!(
matches!(params.stopping_rule_set.mode, StoppingMode::Any),
"default stopping mode should be Any"
);
assert_eq!(
params.n_scenarios, 0,
"n_scenarios should be 0 when simulation disabled"
);
assert!(
params.cut_selection.is_none(),
"cut_selection should be None by default"
);
}
#[test]
fn study_params_from_config_explicit() {
use super::StudyParams;
use crate::stopping_rule::{StoppingMode, StoppingRule};
use cobre_io::config::{
Config, EstimationConfig, ExportsConfig, InflowNonNegativityConfig,
InflowNonNegativityMethod as CfgInflowMethod, ModelingConfig, PolicyConfig,
RowSelectionConfig, SimulationConfig as IoSimulationConfig, StoppingRuleConfig,
TrainingConfig, TrainingSolverConfig, UpperBoundEvaluationConfig,
};
let config = Config {
schema: None,
modeling: ModelingConfig {
inflow_non_negativity: InflowNonNegativityConfig {
method: CfgInflowMethod::Penalty,
},
},
training: TrainingConfig {
enabled: true,
tree_seed: Some(1234),
forward_passes: Some(5),
stopping_rules: Some(vec![
StoppingRuleConfig::IterationLimit { limit: 50 },
StoppingRuleConfig::TimeLimit { seconds: 60.0 },
]),
stopping_mode: "all".to_string(),
cut_selection: RowSelectionConfig::default(),
solver: TrainingSolverConfig::default(),
scenario_source: None,
},
upper_bound_evaluation: UpperBoundEvaluationConfig::default(),
policy: PolicyConfig {
path: "./my_policy".to_string(),
..PolicyConfig::default()
},
simulation: IoSimulationConfig {
enabled: true,
num_scenarios: 200,
..IoSimulationConfig::default()
},
exports: ExportsConfig::default(),
estimation: EstimationConfig::default(),
energy: cobre_io::EnergyConfig::default(),
};
let params = StudyParams::from_config(&config).expect("from_config");
assert_eq!(params.seed, 1234, "seed mismatch");
assert_eq!(params.forward_passes, 5, "forward_passes mismatch");
assert_eq!(
params.stopping_rule_set.rules.len(),
2,
"stopping rule count mismatch"
);
assert!(
matches!(
params.stopping_rule_set.rules[0],
StoppingRule::IterationLimit { limit: 50 }
),
"first rule should be IterationLimit(50)"
);
assert!(
matches!(
params.stopping_rule_set.rules[1],
StoppingRule::TimeLimit { seconds } if (seconds - 60.0).abs() < 1e-9
),
"second rule should be TimeLimit(60.0)"
);
assert!(
matches!(params.stopping_rule_set.mode, StoppingMode::All),
"stopping mode should be All"
);
assert_eq!(params.n_scenarios, 200, "n_scenarios mismatch");
assert_eq!(params.policy_path, "./my_policy", "policy_path mismatch");
}
fn write_minimal_case_dir(root: &std::path::Path) {
use std::fs;
fs::create_dir_all(root.join("system")).unwrap();
fs::write(root.join("config.json"), b"{}").unwrap();
fs::write(root.join("penalties.json"), b"{}").unwrap();
fs::write(root.join("stages.json"), b"{}").unwrap();
fs::write(root.join("initial_conditions.json"), b"{}").unwrap();
fs::write(root.join("system/buses.json"), b"{}").unwrap();
fs::write(root.join("system/lines.json"), b"{}").unwrap();
fs::write(root.join("system/hydros.json"), b"{}").unwrap();
fs::write(root.join("system/thermals.json"), b"{}").unwrap();
}
fn minimal_prepare_config() -> cobre_io::Config {
use cobre_io::config::{
Config, EstimationConfig, ExportsConfig, InflowNonNegativityConfig,
InflowNonNegativityMethod as CfgInflowMethod, ModelingConfig, PolicyConfig,
RowSelectionConfig, SimulationConfig as IoSimulationConfig, TrainingConfig,
TrainingSolverConfig, UpperBoundEvaluationConfig,
};
Config {
schema: None,
modeling: ModelingConfig {
inflow_non_negativity: InflowNonNegativityConfig {
method: CfgInflowMethod::None,
},
},
training: TrainingConfig {
enabled: true,
tree_seed: None,
forward_passes: None,
stopping_rules: None,
stopping_mode: "any".to_string(),
cut_selection: RowSelectionConfig::default(),
solver: TrainingSolverConfig::default(),
scenario_source: None,
},
upper_bound_evaluation: UpperBoundEvaluationConfig::default(),
policy: PolicyConfig::default(),
simulation: IoSimulationConfig::default(),
exports: ExportsConfig::default(),
estimation: EstimationConfig::default(),
energy: cobre_io::EnergyConfig::default(),
}
}
#[test]
fn prepare_stochastic_no_history_no_tree_returns_none_report_and_generated_provenance() {
use super::prepare_stochastic;
use cobre_core::scenario::ScenarioSource;
use cobre_stochastic::provenance::ComponentProvenance;
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let root = dir.path();
write_minimal_case_dir(root);
let system = minimal_system(2);
let config = minimal_prepare_config();
let seed = 42_u64;
let source = ScenarioSource {
inflow_scheme: SamplingScheme::InSample,
load_scheme: SamplingScheme::InSample,
ncs_scheme: SamplingScheme::InSample,
seed: None,
historical_years: None,
};
let result = prepare_stochastic(system, root, &config, seed, &source)
.expect("prepare_stochastic should succeed with no optional files");
assert!(
result.estimation_report.is_none(),
"estimation_report must be None when no inflow_history.parquet is present"
);
assert_eq!(
result.stochastic.provenance().opening_tree,
ComponentProvenance::Generated,
"opening_tree provenance must be Generated when no user tree is supplied"
);
}
#[test]
fn prepare_stochastic_with_stats_file_present_skips_estimation() {
use super::prepare_stochastic;
use cobre_core::scenario::ScenarioSource;
use std::fs;
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let root = dir.path();
write_minimal_case_dir(root);
fs::create_dir_all(root.join("scenarios")).unwrap();
fs::write(root.join("scenarios/inflow_seasonal_stats.parquet"), b"").unwrap();
fs::write(root.join("scenarios/inflow_ar_coefficients.parquet"), b"").unwrap();
let system = minimal_system(2);
let config = minimal_prepare_config();
let seed = 42_u64;
let source = ScenarioSource {
inflow_scheme: SamplingScheme::InSample,
load_scheme: SamplingScheme::InSample,
ncs_scheme: SamplingScheme::InSample,
seed: None,
historical_years: None,
};
let result = prepare_stochastic(system, root, &config, seed, &source)
.expect("prepare_stochastic should succeed when stats file is present");
assert!(
result.estimation_report.is_none(),
"estimation_report must be None when inflow_seasonal_stats.parquet is present"
);
}
#[test]
fn prepare_stochastic_no_opening_tree_gives_non_user_supplied_provenance() {
use super::prepare_stochastic;
use cobre_core::scenario::ScenarioSource;
use cobre_stochastic::provenance::ComponentProvenance;
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let root = dir.path();
write_minimal_case_dir(root);
let system = minimal_system(2);
let config = minimal_prepare_config();
let source = ScenarioSource {
inflow_scheme: SamplingScheme::InSample,
load_scheme: SamplingScheme::InSample,
ncs_scheme: SamplingScheme::InSample,
seed: None,
historical_years: None,
};
let result = prepare_stochastic(system, root, &config, 0, &source)
.expect("prepare_stochastic must succeed with no opening tree file");
assert_ne!(
result.stochastic.provenance().opening_tree,
ComponentProvenance::UserSupplied,
"opening_tree provenance must not be UserSupplied when file is absent"
);
}
#[test]
#[allow(
clippy::too_many_lines,
clippy::cast_possible_truncation,
clippy::cast_possible_wrap
)]
fn test_prepare_stochastic_historical_residuals_noise_method() {
use super::prepare_stochastic;
use chrono::NaiveDate;
use cobre_core::{
scenario::{InflowHistoryRow, ScenarioSource},
system::SystemBuilder,
};
use tempfile::TempDir;
let n_stages = 2usize;
let bus = Bus {
id: EntityId(1),
name: "B1".to_string(),
deficit_segments: vec![DeficitSegment {
depth_mw: None,
cost_per_mwh: 500.0,
}],
excess_cost: 0.0,
};
let thermal = Thermal {
id: EntityId(2),
name: "T1".to_string(),
bus_id: EntityId(1),
min_generation_mw: 0.0,
max_generation_mw: 100.0,
cost_per_mwh: 50.0,
gnl_config: None,
entry_stage_id: None,
exit_stage_id: None,
};
let hydro = Hydro {
id: EntityId(3),
name: "H1".to_string(),
bus_id: EntityId(1),
downstream_id: None,
entry_stage_id: None,
exit_stage_id: None,
min_storage_hm3: 0.0,
max_storage_hm3: 200.0,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
generation_model: HydroGenerationModel::ConstantProductivity,
min_turbined_m3s: 0.0,
max_turbined_m3s: 100.0,
specific_productivity_mw_per_m3s_per_m: None,
min_generation_mw: 0.0,
max_generation_mw: 250.0,
tailrace: None,
hydraulic_losses: None,
efficiency: None,
evaporation_coefficients_mm: None,
evaporation_reference_volumes_hm3: None,
diversion: None,
filling: None,
penalties: HydroPenalties {
spillage_cost: 0.01,
diversion_cost: 0.0,
turbined_cost: 0.0,
storage_violation_below_cost: 0.0,
filling_target_violation_cost: 0.0,
turbined_violation_below_cost: 0.0,
outflow_violation_below_cost: 0.0,
outflow_violation_above_cost: 0.0,
generation_violation_below_cost: 0.0,
evaporation_violation_cost: 0.0,
water_withdrawal_violation_cost: 0.0,
water_withdrawal_violation_pos_cost: 0.0,
water_withdrawal_violation_neg_cost: 0.0,
evaporation_violation_pos_cost: 0.0,
evaporation_violation_neg_cost: 0.0,
inflow_nonnegativity_cost: 1000.0,
},
};
let stages: Vec<Stage> = (0..n_stages)
.map(|i| Stage {
index: i,
id: i as i32,
start_date: NaiveDate::from_ymd_opt(2024, (i as u32 % 12) + 1, 1).unwrap(),
end_date: NaiveDate::from_ymd_opt(2024, (i as u32 % 12) + 1, 28).unwrap(),
season_id: Some(i % 12),
blocks: vec![Block {
index: 0,
name: "S".to_string(),
duration_hours: 720.0,
}],
block_mode: BlockMode::Parallel,
state_config: StageStateConfig {
storage: true,
inflow_lags: false,
},
risk_config: StageRiskConfig::Expectation,
scenario_config: ScenarioSourceConfig {
branching_factor: 2,
noise_method: NoiseMethod::HistoricalResiduals,
},
})
.collect();
let inflow_models: Vec<InflowModel> = (0..n_stages)
.map(|i| InflowModel {
hydro_id: EntityId(3),
stage_id: i as i32,
mean_m3s: 80.0,
std_m3s: 20.0,
ar_coefficients: vec![],
residual_std_ratio: 1.0,
annual: None,
})
.collect();
let load_models: Vec<LoadModel> = (0..n_stages)
.map(|i| LoadModel {
bus_id: EntityId(1),
stage_id: i as i32,
mean_mw: 100.0,
std_mw: 0.0,
})
.collect();
let inflow_history: Vec<InflowHistoryRow> = (1990_i32..=1991)
.flat_map(|year| {
(1u32..=12).map(move |month| InflowHistoryRow {
hydro_id: EntityId(3),
date: NaiveDate::from_ymd_opt(year, month, 1).unwrap(),
value_m3s: 80.0 + f64::from(year - 1990) * 5.0,
})
})
.collect();
let n_st = n_stages.max(1);
let bounds = ResolvedBounds::new(
&BoundsCountsSpec {
n_hydros: 1,
n_thermals: 1,
n_lines: 0,
n_pumping: 0,
n_contracts: 0,
n_stages: n_st,
},
&BoundsDefaults {
hydro: HydroStageBounds {
min_storage_hm3: 0.0,
max_storage_hm3: 200.0,
min_turbined_m3s: 0.0,
max_turbined_m3s: 100.0,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
min_generation_mw: 0.0,
max_generation_mw: 250.0,
max_diversion_m3s: None,
filling_inflow_m3s: 0.0,
water_withdrawal_m3s: 0.0,
},
thermal: ThermalStageBounds {
min_generation_mw: 0.0,
max_generation_mw: 100.0,
cost_per_mwh: 0.0,
},
line: LineStageBounds {
direct_mw: 0.0,
reverse_mw: 0.0,
},
pumping: PumpingStageBounds {
min_flow_m3s: 0.0,
max_flow_m3s: 0.0,
},
contract: ContractStageBounds {
min_mw: 0.0,
max_mw: 0.0,
price_per_mwh: 0.0,
},
},
);
let penalties = ResolvedPenalties::new(
&PenaltiesCountsSpec {
n_hydros: 1,
n_buses: 1,
n_lines: 0,
n_ncs: 0,
n_stages: n_st,
},
&PenaltiesDefaults {
hydro: HydroStagePenalties {
spillage_cost: 0.01,
diversion_cost: 0.0,
turbined_cost: 0.0,
storage_violation_below_cost: 500.0,
filling_target_violation_cost: 0.0,
turbined_violation_below_cost: 0.0,
outflow_violation_below_cost: 0.0,
outflow_violation_above_cost: 0.0,
generation_violation_below_cost: 0.0,
evaporation_violation_cost: 0.0,
water_withdrawal_violation_cost: 0.0,
water_withdrawal_violation_pos_cost: 0.0,
water_withdrawal_violation_neg_cost: 0.0,
evaporation_violation_pos_cost: 0.0,
evaporation_violation_neg_cost: 0.0,
inflow_nonnegativity_cost: 1000.0,
},
bus: BusStagePenalties { excess_cost: 0.0 },
line: LineStagePenalties { exchange_cost: 0.0 },
ncs: NcsStagePenalties {
curtailment_cost: 0.0,
},
},
);
let system = SystemBuilder::new()
.buses(vec![bus])
.thermals(vec![thermal])
.hydros(vec![hydro])
.stages(stages)
.inflow_models(inflow_models)
.load_models(load_models)
.inflow_history(inflow_history)
.bounds(bounds)
.penalties(penalties)
.build()
.expect("test system: valid");
let dir = TempDir::new().unwrap();
let root = dir.path();
write_minimal_case_dir(root);
let config = minimal_prepare_config();
let source = ScenarioSource {
inflow_scheme: SamplingScheme::InSample,
load_scheme: SamplingScheme::InSample,
ncs_scheme: SamplingScheme::InSample,
seed: None,
historical_years: None,
};
let result = prepare_stochastic(system, root, &config, 42, &source)
.expect("prepare_stochastic must succeed with HistoricalResiduals noise method");
assert_eq!(
result.stochastic.opening_tree().n_stages(),
n_stages,
"opening_tree must have n_stages == {n_stages}"
);
}
#[test]
fn default_from_system_gives_constant_and_no_evaporation() {
use crate::hydro_models::{
EvaporationModel, ProductionModelSource, ResolvedProductionModel,
};
let system = minimal_system(2);
let result = PrepareHydroModelsResult::default_from_system(&system);
assert_eq!(
result.provenance.production_sources.len(),
system.hydros().len(),
"production_sources length must equal n_hydros"
);
for (_, source) in &result.provenance.production_sources {
assert_eq!(
*source,
ProductionModelSource::DefaultConstant,
"all hydros must use DefaultConstant"
);
}
assert_eq!(
result.provenance.evaporation_sources.len(),
system.hydros().len(),
"evaporation_sources length must equal n_hydros"
);
assert!(
!result.evaporation.has_evaporation(),
"default result must have no evaporation"
);
let model = result.production.model(0, 0);
assert!(
matches!(model, ResolvedProductionModel::ConstantProductivity { .. }),
"default production model must be ConstantProductivity"
);
let evap = result.evaporation.model(0);
assert!(
matches!(evap, EvaporationModel::None),
"default evaporation model must be None"
);
}
#[test]
fn hydro_models_accessor_returns_stored_result() {
use crate::hydro_models::ProductionModelSource;
let system = minimal_system(2);
let config = minimal_config(1, 5);
let stochastic = build_stochastic_context(
&system,
42,
None,
&[],
&[],
OpeningTreeInputs::default(),
ClassSchemes {
inflow: Some(SamplingScheme::InSample),
load: Some(SamplingScheme::InSample),
ncs: Some(SamplingScheme::InSample),
},
)
.expect("stochastic context");
let hydro_result = PrepareHydroModelsResult::default_from_system(&system);
let setup = StudySetup::new(&system, &config, stochastic, hydro_result).expect("setup");
let models = &setup.hydro_models;
assert_eq!(
models.provenance.production_sources.len(),
system.hydros().len(),
"hydro_models() must return the stored result (provenance length mismatch)"
);
for (_, source) in &models.provenance.production_sources {
assert_eq!(
*source,
ProductionModelSource::DefaultConstant,
"stored result must preserve DefaultConstant provenance"
);
}
}
#[test]
fn energy_conversion_accessor_returns_built_set() {
let system = minimal_system(2);
let config = minimal_config(1, 5);
let stochastic = build_stochastic_context(
&system,
42,
None,
&[],
&[],
OpeningTreeInputs::default(),
ClassSchemes {
inflow: Some(SamplingScheme::InSample),
load: Some(SamplingScheme::InSample),
ncs: Some(SamplingScheme::InSample),
},
)
.expect("stochastic context");
let n_study_stages = system.stages().iter().filter(|s| s.id >= 0).count();
let hydro_models_result = {
let mut result = PrepareHydroModelsResult::default_from_system(&system);
let pm = ProductionModelSet::new(
vec![vec![
ResolvedProductionModel::ConstantProductivity {
productivity: 2.5
};
n_study_stages
]],
1,
n_study_stages,
);
result.production = pm;
result
};
let setup =
StudySetup::new(&system, &config, stochastic, hydro_models_result).expect("setup");
let ec = setup.energy_conversion();
assert_eq!(ec.n_hydros(), system.hydros().len());
for s in 0..ec.n_stages() {
assert!(
(ec.accumulated_productivity(0, s) - 2.5).abs() < f64::EPSILON,
"stage {s}: expected ρ_acum=2.5, got {}",
ec.accumulated_productivity(0, s)
);
}
}
#[test]
fn study_setup_propagates_fpha_missing_equivalent_productivity() {
let system = minimal_fpha_misconfigured_system(2);
let config = minimal_config(1, 5);
let stochastic = build_stochastic_context(
&system,
42,
None,
&[],
&[],
OpeningTreeInputs::default(),
ClassSchemes {
inflow: Some(SamplingScheme::InSample),
load: Some(SamplingScheme::InSample),
ncs: Some(SamplingScheme::InSample),
},
)
.expect("stochastic context");
let err = StudySetup::new(
&system,
&config,
stochastic,
PrepareHydroModelsResult::default_from_system(&system),
)
.expect_err("setup must reject misconfigured FPHA hydro");
let msg = err.to_string();
assert!(
msg.contains("cannot derive ρ_eq"),
"error must come from FphaMissingEquivalentProductivity Display; got: {msg}"
);
assert!(
msg.contains("H_FPHA_BAD"),
"error must mention the offending hydro by name; got: {msg}"
);
}
fn indexer_for_lag_test(hydro_count: usize, max_par_order: usize) -> StageIndexer {
StageIndexer::new(hydro_count, max_par_order)
}
#[allow(
clippy::too_many_lines,
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::items_after_statements
)]
fn minimal_system_2_hydros_with_past_inflows(
n_stages: usize,
h1_past: Vec<f64>,
h2_past: Vec<f64>,
) -> cobre_core::System {
use chrono::NaiveDate;
let bus = Bus {
id: EntityId(1),
name: "B1".to_string(),
deficit_segments: vec![DeficitSegment {
depth_mw: None,
cost_per_mwh: 500.0,
}],
excess_cost: 0.0,
};
let make_hydro = |id: i32, name: &str| Hydro {
id: EntityId(id),
name: name.to_string(),
bus_id: EntityId(1),
downstream_id: None,
entry_stage_id: None,
exit_stage_id: None,
min_storage_hm3: 0.0,
max_storage_hm3: 200.0,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
generation_model: HydroGenerationModel::ConstantProductivity,
min_turbined_m3s: 0.0,
max_turbined_m3s: 100.0,
specific_productivity_mw_per_m3s_per_m: None,
min_generation_mw: 0.0,
max_generation_mw: 250.0,
tailrace: None,
hydraulic_losses: None,
efficiency: None,
evaporation_coefficients_mm: None,
evaporation_reference_volumes_hm3: None,
diversion: None,
filling: None,
penalties: HydroPenalties {
spillage_cost: 0.01,
diversion_cost: 0.0,
turbined_cost: 0.0,
storage_violation_below_cost: 0.0,
filling_target_violation_cost: 0.0,
turbined_violation_below_cost: 0.0,
outflow_violation_below_cost: 0.0,
outflow_violation_above_cost: 0.0,
generation_violation_below_cost: 0.0,
evaporation_violation_cost: 0.0,
water_withdrawal_violation_cost: 0.0,
water_withdrawal_violation_pos_cost: 0.0,
water_withdrawal_violation_neg_cost: 0.0,
evaporation_violation_pos_cost: 0.0,
evaporation_violation_neg_cost: 0.0,
inflow_nonnegativity_cost: 1000.0,
},
};
let n_st = n_stages.max(1);
let stages: Vec<Stage> = (0..n_stages)
.map(|i| Stage {
index: i,
id: i as i32,
start_date: NaiveDate::from_ymd_opt(2020, (i % 12 + 1) as u32, 1).unwrap(),
end_date: NaiveDate::from_ymd_opt(
if (i % 12 + 1) == 12 { 2021 } else { 2020 },
((i % 12 + 1) % 12 + 1) as u32,
1,
)
.unwrap(),
season_id: Some(i),
blocks: vec![Block {
index: 0,
name: "S".to_string(),
duration_hours: 744.0,
}],
block_mode: BlockMode::Parallel,
state_config: StageStateConfig {
storage: true,
inflow_lags: true,
},
risk_config: StageRiskConfig::Expectation,
scenario_config: ScenarioSourceConfig {
branching_factor: 1,
noise_method: NoiseMethod::Saa,
},
})
.collect();
let inflow_models: Vec<InflowModel> = (0..n_stages)
.flat_map(|i| {
[1_i32, 2].map(|hid| InflowModel {
hydro_id: EntityId(hid),
stage_id: i as i32,
mean_m3s: 80.0,
std_m3s: 20.0,
ar_coefficients: vec![0.5, 0.3],
residual_std_ratio: 0.8,
annual: None,
})
})
.collect();
let load_models: Vec<LoadModel> = (0..n_stages)
.map(|i| LoadModel {
bus_id: EntityId(1),
stage_id: i as i32,
mean_mw: 100.0,
std_mw: 0.0,
})
.collect();
fn default_hydro_bounds() -> HydroStageBounds {
HydroStageBounds {
min_storage_hm3: 0.0,
max_storage_hm3: 200.0,
min_turbined_m3s: 0.0,
max_turbined_m3s: 100.0,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
min_generation_mw: 0.0,
max_generation_mw: 250.0,
max_diversion_m3s: None,
filling_inflow_m3s: 0.0,
water_withdrawal_m3s: 0.0,
}
}
fn default_hydro_penalties() -> HydroStagePenalties {
HydroStagePenalties {
spillage_cost: 0.01,
diversion_cost: 0.0,
turbined_cost: 0.0,
storage_violation_below_cost: 500.0,
filling_target_violation_cost: 0.0,
turbined_violation_below_cost: 0.0,
outflow_violation_below_cost: 0.0,
outflow_violation_above_cost: 0.0,
generation_violation_below_cost: 0.0,
evaporation_violation_cost: 0.0,
water_withdrawal_violation_cost: 0.0,
water_withdrawal_violation_pos_cost: 0.0,
water_withdrawal_violation_neg_cost: 0.0,
evaporation_violation_pos_cost: 0.0,
evaporation_violation_neg_cost: 0.0,
inflow_nonnegativity_cost: 1000.0,
}
}
let bounds = ResolvedBounds::new(
&BoundsCountsSpec {
n_hydros: 2,
n_thermals: 0,
n_lines: 0,
n_pumping: 0,
n_contracts: 0,
n_stages: n_st,
},
&BoundsDefaults {
hydro: default_hydro_bounds(),
thermal: ThermalStageBounds {
min_generation_mw: 0.0,
max_generation_mw: 0.0,
cost_per_mwh: 0.0,
},
line: LineStageBounds {
direct_mw: 0.0,
reverse_mw: 0.0,
},
pumping: PumpingStageBounds {
min_flow_m3s: 0.0,
max_flow_m3s: 0.0,
},
contract: ContractStageBounds {
min_mw: 0.0,
max_mw: 0.0,
price_per_mwh: 0.0,
},
},
);
let penalties = ResolvedPenalties::new(
&PenaltiesCountsSpec {
n_hydros: 2,
n_buses: 1,
n_lines: 0,
n_ncs: 0,
n_stages: n_st,
},
&PenaltiesDefaults {
hydro: default_hydro_penalties(),
bus: BusStagePenalties { excess_cost: 0.0 },
line: LineStagePenalties { exchange_cost: 0.0 },
ncs: NcsStagePenalties {
curtailment_cost: 0.0,
},
},
);
let past_inflows = vec![
cobre_core::HydroPastInflows {
hydro_id: EntityId(1),
values_m3s: h1_past,
season_ids: None,
},
cobre_core::HydroPastInflows {
hydro_id: EntityId(2),
values_m3s: h2_past,
season_ids: None,
},
];
SystemBuilder::new()
.buses(vec![bus])
.thermals(vec![])
.hydros(vec![make_hydro(1, "H1"), make_hydro(2, "H2")])
.stages(stages)
.inflow_models(inflow_models)
.load_models(load_models)
.bounds(bounds)
.penalties(penalties)
.initial_conditions(cobre_core::InitialConditions {
storage: vec![],
filling_storage: vec![],
past_inflows,
recent_observations: vec![],
})
.build()
.expect("minimal_system_2_hydros_with_past_inflows: valid")
}
#[test]
fn build_initial_state_populates_lags_from_past_inflows() {
use super::build_initial_state;
let system =
minimal_system_2_hydros_with_past_inflows(1, vec![600.0, 500.0], vec![200.0, 100.0]);
let indexer = indexer_for_lag_test(2, 2);
let state = build_initial_state(&system, &indexer);
let s = indexer.inflow_lags.start;
assert!(
(state[s] - 600.0).abs() < 1e-10,
"lag0 hydro 0: expected 600.0, got {}",
state[s]
);
assert!(
(state[s + 1] - 200.0).abs() < 1e-10,
"lag0 hydro 1: expected 200.0, got {}",
state[s + 1]
);
assert!(
(state[s + 2] - 500.0).abs() < 1e-10,
"lag1 hydro 0: expected 500.0, got {}",
state[s + 2]
);
assert!(
(state[s + 3] - 100.0).abs() < 1e-10,
"lag1 hydro 1: expected 100.0, got {}",
state[s + 3]
);
assert_eq!(
state.len(),
indexer.n_state,
"state length must equal n_state"
);
}
#[test]
fn build_initial_state_empty_past_inflows_leaves_zero_lags() {
use super::build_initial_state;
let system = minimal_system(2);
let indexer = indexer_for_lag_test(1, 3);
let state = build_initial_state(&system, &indexer);
let s = indexer.inflow_lags.start;
for l in 0..3 {
assert!(
state[s + l].abs() < 1e-10,
"lag slot {l} should be 0.0 when past_inflows is empty, got {}",
state[s + l]
);
}
}
#[test]
fn build_initial_state_unknown_hydro_in_past_inflows_stays_zero() {
use super::build_initial_state;
let system = {
minimal_system(2)
};
let indexer = indexer_for_lag_test(1, 2);
let state = build_initial_state(&system, &indexer);
let s = indexer.inflow_lags.start;
assert!(
state[s].abs() < 1e-10,
"lag 0 should be 0.0 when past_inflows is absent, got {}",
state[s]
);
assert!(
state[s + 1].abs() < 1e-10,
"lag 1 should be 0.0 when past_inflows is absent, got {}",
state[s + 1]
);
}
#[test]
fn study_setup_initial_state_has_nonzero_lags_from_past_inflows() {
let system =
minimal_system_2_hydros_with_past_inflows(3, vec![600.0, 500.0], vec![200.0, 100.0]);
let config = minimal_config(1, 10);
let stochastic = build_stochastic_context(
&system,
42,
None,
&[],
&[],
OpeningTreeInputs::default(),
ClassSchemes {
inflow: Some(SamplingScheme::InSample),
load: Some(SamplingScheme::InSample),
ncs: Some(SamplingScheme::InSample),
},
)
.expect("stochastic context");
let setup = StudySetup::new(
&system,
&config,
stochastic,
PrepareHydroModelsResult::default_from_system(&system),
)
.expect("setup with past_inflows");
let state = &setup.initial_state;
let n_hydros = 2;
let lag_start = n_hydros;
assert!(
(state[lag_start] - 600.0).abs() < 1e-10,
"lag0 hydro 0 should be 600.0 via StudySetup, got {}",
state[lag_start]
);
assert!(
(state[lag_start + 1] - 200.0).abs() < 1e-10,
"lag0 hydro 1 should be 200.0 via StudySetup, got {}",
state[lag_start + 1]
);
assert!(
(state[lag_start + 2] - 500.0).abs() < 1e-10,
"lag1 hydro 0 should be 500.0 via StudySetup, got {}",
state[lag_start + 2]
);
assert!(
(state[lag_start + 3] - 100.0).abs() < 1e-10,
"lag1 hydro 1 should be 100.0 via StudySetup, got {}",
state[lag_start + 3]
);
}
#[test]
fn build_initial_state_no_lags_state_is_storage_only() {
use super::build_initial_state;
let system = minimal_system(2);
let indexer = indexer_for_lag_test(1, 0);
assert_eq!(indexer.n_state, 1);
assert!(
indexer.inflow_lags.is_empty(),
"inflow_lags range should be empty for L=0"
);
let state = build_initial_state(&system, &indexer);
assert_eq!(state.len(), 1, "state length must equal n_state=1");
}
#[test]
fn historical_library_none_for_insample() {
let system = minimal_system(2);
let config = minimal_config(1, 5);
let stochastic = build_stochastic_context(
&system,
42,
None,
&[],
&[],
OpeningTreeInputs::default(),
ClassSchemes {
inflow: Some(SamplingScheme::InSample),
load: Some(SamplingScheme::InSample),
ncs: Some(SamplingScheme::InSample),
},
)
.expect("stochastic context");
let setup = StudySetup::new(
&system,
&config,
stochastic,
PrepareHydroModelsResult::default_from_system(&system),
)
.expect("setup");
assert!(
setup.scenario_libraries.training.historical.is_none(),
"historical_library must be None for InSample scheme"
);
assert!(
setup.scenario_libraries.training.external_inflow.is_none(),
"external_inflow_library must be None for InSample scheme"
);
assert!(
setup.scenario_libraries.training.external_load.is_none(),
"external_load_library must be None for InSample load scheme"
);
assert!(
setup.scenario_libraries.training.external_ncs.is_none(),
"external_ncs_library must be None for InSample ncs scheme"
);
}
#[allow(
clippy::too_many_lines,
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_lossless
)]
fn system_with_historical_inflow(n_stages: usize) -> cobre_core::System {
use chrono::NaiveDate;
use cobre_core::{scenario::InflowHistoryRow, system::SystemBuilder};
fn default_hydro_bounds() -> HydroStageBounds {
HydroStageBounds {
min_storage_hm3: 0.0,
max_storage_hm3: 200.0,
min_turbined_m3s: 0.0,
max_turbined_m3s: 100.0,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
min_generation_mw: 0.0,
max_generation_mw: 250.0,
max_diversion_m3s: None,
filling_inflow_m3s: 0.0,
water_withdrawal_m3s: 0.0,
}
}
fn default_hydro_penalties() -> HydroStagePenalties {
HydroStagePenalties {
spillage_cost: 0.01,
diversion_cost: 0.0,
turbined_cost: 0.0,
storage_violation_below_cost: 500.0,
filling_target_violation_cost: 0.0,
turbined_violation_below_cost: 0.0,
outflow_violation_below_cost: 0.0,
outflow_violation_above_cost: 0.0,
generation_violation_below_cost: 0.0,
evaporation_violation_cost: 0.0,
water_withdrawal_violation_cost: 0.0,
water_withdrawal_violation_pos_cost: 0.0,
water_withdrawal_violation_neg_cost: 0.0,
evaporation_violation_pos_cost: 0.0,
evaporation_violation_neg_cost: 0.0,
inflow_nonnegativity_cost: 1000.0,
}
}
let bus = Bus {
id: EntityId(1),
name: "B1".to_string(),
deficit_segments: vec![DeficitSegment {
depth_mw: None,
cost_per_mwh: 500.0,
}],
excess_cost: 0.0,
};
let thermal = Thermal {
id: EntityId(2),
name: "T1".to_string(),
bus_id: EntityId(1),
min_generation_mw: 0.0,
max_generation_mw: 100.0,
cost_per_mwh: 50.0,
gnl_config: None,
entry_stage_id: None,
exit_stage_id: None,
};
let hydro = Hydro {
id: EntityId(3),
name: "H1".to_string(),
bus_id: EntityId(1),
downstream_id: None,
entry_stage_id: None,
exit_stage_id: None,
min_storage_hm3: 0.0,
max_storage_hm3: 200.0,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
generation_model: HydroGenerationModel::ConstantProductivity,
min_turbined_m3s: 0.0,
max_turbined_m3s: 100.0,
specific_productivity_mw_per_m3s_per_m: None,
min_generation_mw: 0.0,
max_generation_mw: 250.0,
tailrace: None,
hydraulic_losses: None,
efficiency: None,
evaporation_coefficients_mm: None,
evaporation_reference_volumes_hm3: None,
diversion: None,
filling: None,
penalties: HydroPenalties {
spillage_cost: 0.01,
diversion_cost: 0.0,
turbined_cost: 0.0,
storage_violation_below_cost: 0.0,
filling_target_violation_cost: 0.0,
turbined_violation_below_cost: 0.0,
outflow_violation_below_cost: 0.0,
outflow_violation_above_cost: 0.0,
generation_violation_below_cost: 0.0,
evaporation_violation_cost: 0.0,
water_withdrawal_violation_cost: 0.0,
water_withdrawal_violation_pos_cost: 0.0,
water_withdrawal_violation_neg_cost: 0.0,
evaporation_violation_pos_cost: 0.0,
evaporation_violation_neg_cost: 0.0,
inflow_nonnegativity_cost: 1000.0,
},
};
let stages: Vec<Stage> = (0..n_stages)
.map(|i| Stage {
index: i,
id: i as i32,
start_date: NaiveDate::from_ymd_opt(2024, (i as u32 % 12) + 1, 1).unwrap(),
end_date: NaiveDate::from_ymd_opt(2024, (i as u32 % 12) + 1, 28).unwrap(),
season_id: Some(i % 12),
blocks: vec![Block {
index: 0,
name: "S".to_string(),
duration_hours: 720.0,
}],
block_mode: BlockMode::Parallel,
state_config: StageStateConfig {
storage: true,
inflow_lags: false,
},
risk_config: StageRiskConfig::Expectation,
scenario_config: ScenarioSourceConfig {
branching_factor: 1,
noise_method: NoiseMethod::Saa,
},
})
.collect();
let inflow_models: Vec<InflowModel> = (0..n_stages)
.map(|i| InflowModel {
hydro_id: EntityId(3),
stage_id: i as i32,
mean_m3s: 80.0,
std_m3s: 20.0,
ar_coefficients: vec![],
residual_std_ratio: 1.0,
annual: None,
})
.collect();
let load_models: Vec<LoadModel> = (0..n_stages)
.map(|i| LoadModel {
bus_id: EntityId(1),
stage_id: i as i32,
mean_mw: 100.0,
std_mw: 0.0,
})
.collect();
let inflow_history: Vec<InflowHistoryRow> = (1990_i32..=1991)
.flat_map(|year| {
(1u32..=12).map(move |month| InflowHistoryRow {
hydro_id: EntityId(3),
date: NaiveDate::from_ymd_opt(year, month, 1).unwrap(),
value_m3s: 80.0 + f64::from(year - 1990) * 5.0,
})
})
.collect();
let n_st = n_stages.max(1);
let bounds = ResolvedBounds::new(
&BoundsCountsSpec {
n_hydros: 1,
n_thermals: 1,
n_lines: 0,
n_pumping: 0,
n_contracts: 0,
n_stages: n_st,
},
&BoundsDefaults {
hydro: default_hydro_bounds(),
thermal: ThermalStageBounds {
min_generation_mw: 0.0,
max_generation_mw: 100.0,
cost_per_mwh: 0.0,
},
line: LineStageBounds {
direct_mw: 0.0,
reverse_mw: 0.0,
},
pumping: PumpingStageBounds {
min_flow_m3s: 0.0,
max_flow_m3s: 0.0,
},
contract: ContractStageBounds {
min_mw: 0.0,
max_mw: 0.0,
price_per_mwh: 0.0,
},
},
);
let penalties = ResolvedPenalties::new(
&PenaltiesCountsSpec {
n_hydros: 1,
n_buses: 1,
n_lines: 0,
n_ncs: 0,
n_stages: n_st,
},
&PenaltiesDefaults {
hydro: default_hydro_penalties(),
bus: BusStagePenalties { excess_cost: 0.0 },
line: LineStagePenalties { exchange_cost: 0.0 },
ncs: NcsStagePenalties {
curtailment_cost: 0.0,
},
},
);
SystemBuilder::new()
.buses(vec![bus])
.thermals(vec![thermal])
.hydros(vec![hydro])
.stages(stages)
.inflow_models(inflow_models)
.load_models(load_models)
.inflow_history(inflow_history)
.bounds(bounds)
.penalties(penalties)
.build()
.expect("system_with_historical_inflow: valid")
}
#[test]
fn historical_library_built_when_scheme_is_historical() {
let system = system_with_historical_inflow(2);
let config = minimal_config_with_schemes(1, 5, Some("historical"), None, None);
let stochastic = build_stochastic_context(
&system,
42,
None,
&[],
&[],
OpeningTreeInputs::default(),
ClassSchemes {
inflow: Some(SamplingScheme::Historical),
load: Some(SamplingScheme::InSample),
ncs: Some(SamplingScheme::InSample),
},
)
.expect("stochastic context");
let setup = StudySetup::new(
&system,
&config,
stochastic,
PrepareHydroModelsResult::default_from_system(&system),
)
.expect("setup");
let lib = setup
.scenario_libraries
.training
.historical
.as_ref()
.expect("expected Some(HistoricalScenarioLibrary) for Historical scheme");
assert!(
lib.n_windows() > 0,
"expected at least one historical window, got 0"
);
assert_eq!(
lib.n_stages(),
2,
"expected n_stages == 2 matching the system's study stages"
);
assert_eq!(lib.n_hydros(), 1, "expected n_hydros == 1");
}
#[test]
#[allow(
clippy::too_many_lines,
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::cast_lossless
)]
fn external_inflow_library_built_when_scheme_is_external() {
use chrono::NaiveDate;
use cobre_core::scenario::ExternalScenarioRow;
use cobre_core::{scenario::InflowModel as CoreInflowModel, system::SystemBuilder};
let hydro_id = EntityId(3);
let mut external_rows: Vec<ExternalScenarioRow> = Vec::new();
for stage_id in 0i32..2 {
for scenario_id in 0i32..3 {
external_rows.push(ExternalScenarioRow {
stage_id,
scenario_id,
hydro_id,
value_m3s: 80.0 + scenario_id as f64 * 5.0,
});
}
}
let bus = Bus {
id: EntityId(1),
name: "B1".to_string(),
deficit_segments: vec![DeficitSegment {
depth_mw: None,
cost_per_mwh: 500.0,
}],
excess_cost: 0.0,
};
let thermal = Thermal {
id: EntityId(2),
name: "T1".to_string(),
bus_id: EntityId(1),
min_generation_mw: 0.0,
max_generation_mw: 100.0,
cost_per_mwh: 50.0,
gnl_config: None,
entry_stage_id: None,
exit_stage_id: None,
};
let hydro = Hydro {
id: EntityId(3),
name: "H1".to_string(),
bus_id: EntityId(1),
downstream_id: None,
entry_stage_id: None,
exit_stage_id: None,
min_storage_hm3: 0.0,
max_storage_hm3: 200.0,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
generation_model: HydroGenerationModel::ConstantProductivity,
min_turbined_m3s: 0.0,
max_turbined_m3s: 100.0,
specific_productivity_mw_per_m3s_per_m: None,
min_generation_mw: 0.0,
max_generation_mw: 250.0,
tailrace: None,
hydraulic_losses: None,
efficiency: None,
evaporation_coefficients_mm: None,
evaporation_reference_volumes_hm3: None,
diversion: None,
filling: None,
penalties: HydroPenalties {
spillage_cost: 0.01,
diversion_cost: 0.0,
turbined_cost: 0.0,
storage_violation_below_cost: 0.0,
filling_target_violation_cost: 0.0,
turbined_violation_below_cost: 0.0,
outflow_violation_below_cost: 0.0,
outflow_violation_above_cost: 0.0,
generation_violation_below_cost: 0.0,
evaporation_violation_cost: 0.0,
water_withdrawal_violation_cost: 0.0,
water_withdrawal_violation_pos_cost: 0.0,
water_withdrawal_violation_neg_cost: 0.0,
evaporation_violation_pos_cost: 0.0,
evaporation_violation_neg_cost: 0.0,
inflow_nonnegativity_cost: 1000.0,
},
};
let stages: Vec<Stage> = (0..2usize)
.map(|i| Stage {
index: i,
id: i as i32,
start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
season_id: None,
blocks: vec![Block {
index: 0,
name: "S".to_string(),
duration_hours: 744.0,
}],
block_mode: BlockMode::Parallel,
state_config: StageStateConfig {
storage: true,
inflow_lags: false,
},
risk_config: StageRiskConfig::Expectation,
scenario_config: ScenarioSourceConfig {
branching_factor: 1,
noise_method: NoiseMethod::Saa,
},
})
.collect();
let inflow_models: Vec<CoreInflowModel> = (0..2usize)
.map(|i| CoreInflowModel {
hydro_id: EntityId(3),
stage_id: i as i32,
mean_m3s: 80.0,
std_m3s: 20.0,
ar_coefficients: vec![],
residual_std_ratio: 1.0,
annual: None,
})
.collect();
let load_models: Vec<LoadModel> = (0..2usize)
.map(|i| LoadModel {
bus_id: EntityId(1),
stage_id: i as i32,
mean_mw: 100.0,
std_mw: 0.0,
})
.collect();
let bounds = ResolvedBounds::new(
&BoundsCountsSpec {
n_hydros: 1,
n_thermals: 1,
n_lines: 0,
n_pumping: 0,
n_contracts: 0,
n_stages: 2,
},
&BoundsDefaults {
hydro: HydroStageBounds {
min_storage_hm3: 0.0,
max_storage_hm3: 200.0,
min_turbined_m3s: 0.0,
max_turbined_m3s: 100.0,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
min_generation_mw: 0.0,
max_generation_mw: 250.0,
max_diversion_m3s: None,
filling_inflow_m3s: 0.0,
water_withdrawal_m3s: 0.0,
},
thermal: ThermalStageBounds {
min_generation_mw: 0.0,
max_generation_mw: 100.0,
cost_per_mwh: 0.0,
},
line: LineStageBounds {
direct_mw: 0.0,
reverse_mw: 0.0,
},
pumping: PumpingStageBounds {
min_flow_m3s: 0.0,
max_flow_m3s: 0.0,
},
contract: ContractStageBounds {
min_mw: 0.0,
max_mw: 0.0,
price_per_mwh: 0.0,
},
},
);
let penalties = ResolvedPenalties::new(
&PenaltiesCountsSpec {
n_hydros: 1,
n_buses: 1,
n_lines: 0,
n_ncs: 0,
n_stages: 2,
},
&PenaltiesDefaults {
hydro: HydroStagePenalties {
spillage_cost: 0.01,
diversion_cost: 0.0,
turbined_cost: 0.0,
storage_violation_below_cost: 500.0,
filling_target_violation_cost: 0.0,
turbined_violation_below_cost: 0.0,
outflow_violation_below_cost: 0.0,
outflow_violation_above_cost: 0.0,
generation_violation_below_cost: 0.0,
evaporation_violation_cost: 0.0,
water_withdrawal_violation_cost: 0.0,
water_withdrawal_violation_pos_cost: 0.0,
water_withdrawal_violation_neg_cost: 0.0,
evaporation_violation_pos_cost: 0.0,
evaporation_violation_neg_cost: 0.0,
inflow_nonnegativity_cost: 1000.0,
},
bus: BusStagePenalties { excess_cost: 0.0 },
line: LineStagePenalties { exchange_cost: 0.0 },
ncs: NcsStagePenalties {
curtailment_cost: 0.0,
},
},
);
let system = SystemBuilder::new()
.buses(vec![bus])
.thermals(vec![thermal])
.hydros(vec![hydro])
.stages(stages)
.inflow_models(inflow_models)
.load_models(load_models)
.external_scenarios(external_rows)
.bounds(bounds)
.penalties(penalties)
.build()
.expect("system with external inflow: valid");
let config = minimal_config_with_schemes(1, 5, Some("external"), None, None);
let stochastic = build_stochastic_context(
&system,
42,
None,
&[],
&[],
OpeningTreeInputs::default(),
ClassSchemes {
inflow: Some(SamplingScheme::External),
load: Some(SamplingScheme::InSample),
ncs: Some(SamplingScheme::InSample),
},
)
.expect("stochastic context");
let setup = StudySetup::new(
&system,
&config,
stochastic,
PrepareHydroModelsResult::default_from_system(&system),
)
.expect("setup");
let lib = setup
.scenario_libraries
.training
.external_inflow
.as_ref()
.expect("expected Some(ExternalScenarioLibrary) for External inflow scheme");
assert!(
lib.n_entities() > 0,
"expected n_entities > 0 in external inflow library"
);
assert_eq!(lib.n_stages(), 2);
assert_eq!(lib.n_scenarios(), 3);
assert_eq!(lib.entity_class(), "inflow");
}
#[test]
#[allow(
clippy::too_many_lines,
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::cast_lossless
)]
fn external_load_library_built_when_scheme_is_external() {
use chrono::NaiveDate;
use cobre_core::scenario::ExternalLoadRow;
use cobre_core::{scenario::InflowModel as CoreInflowModel, system::SystemBuilder};
let bus = Bus {
id: EntityId(1),
name: "B1".to_string(),
deficit_segments: vec![DeficitSegment {
depth_mw: None,
cost_per_mwh: 500.0,
}],
excess_cost: 0.0,
};
let thermal = Thermal {
id: EntityId(2),
name: "T1".to_string(),
bus_id: EntityId(1),
min_generation_mw: 0.0,
max_generation_mw: 100.0,
cost_per_mwh: 50.0,
gnl_config: None,
entry_stage_id: None,
exit_stage_id: None,
};
let hydro = Hydro {
id: EntityId(3),
name: "H1".to_string(),
bus_id: EntityId(1),
downstream_id: None,
entry_stage_id: None,
exit_stage_id: None,
min_storage_hm3: 0.0,
max_storage_hm3: 200.0,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
generation_model: HydroGenerationModel::ConstantProductivity,
min_turbined_m3s: 0.0,
max_turbined_m3s: 100.0,
specific_productivity_mw_per_m3s_per_m: None,
min_generation_mw: 0.0,
max_generation_mw: 250.0,
tailrace: None,
hydraulic_losses: None,
efficiency: None,
evaporation_coefficients_mm: None,
evaporation_reference_volumes_hm3: None,
diversion: None,
filling: None,
penalties: HydroPenalties {
spillage_cost: 0.01,
diversion_cost: 0.0,
turbined_cost: 0.0,
storage_violation_below_cost: 0.0,
filling_target_violation_cost: 0.0,
turbined_violation_below_cost: 0.0,
outflow_violation_below_cost: 0.0,
outflow_violation_above_cost: 0.0,
generation_violation_below_cost: 0.0,
evaporation_violation_cost: 0.0,
water_withdrawal_violation_cost: 0.0,
water_withdrawal_violation_pos_cost: 0.0,
water_withdrawal_violation_neg_cost: 0.0,
evaporation_violation_pos_cost: 0.0,
evaporation_violation_neg_cost: 0.0,
inflow_nonnegativity_cost: 1000.0,
},
};
let stages: Vec<Stage> = (0..2usize)
.map(|i| Stage {
index: i,
id: i as i32,
start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
season_id: None,
blocks: vec![Block {
index: 0,
name: "S".to_string(),
duration_hours: 744.0,
}],
block_mode: BlockMode::Parallel,
state_config: StageStateConfig {
storage: true,
inflow_lags: false,
},
risk_config: StageRiskConfig::Expectation,
scenario_config: ScenarioSourceConfig {
branching_factor: 1,
noise_method: NoiseMethod::Saa,
},
})
.collect();
let inflow_models: Vec<CoreInflowModel> = (0..2usize)
.map(|i| CoreInflowModel {
hydro_id: EntityId(3),
stage_id: i as i32,
mean_m3s: 80.0,
std_m3s: 20.0,
ar_coefficients: vec![],
residual_std_ratio: 1.0,
annual: None,
})
.collect();
let load_models: Vec<LoadModel> = (0..2usize)
.map(|i| LoadModel {
bus_id: EntityId(1),
stage_id: i as i32,
mean_mw: 100.0,
std_mw: 10.0,
})
.collect();
let mut external_load_rows: Vec<ExternalLoadRow> = Vec::new();
for stage_id in 0i32..2 {
for scenario_id in 0i32..3 {
external_load_rows.push(ExternalLoadRow {
stage_id,
scenario_id,
bus_id: EntityId(1),
value_mw: 90.0 + scenario_id as f64 * 10.0,
});
}
}
let bounds = ResolvedBounds::new(
&BoundsCountsSpec {
n_hydros: 1,
n_thermals: 1,
n_lines: 0,
n_pumping: 0,
n_contracts: 0,
n_stages: 2,
},
&BoundsDefaults {
hydro: HydroStageBounds {
min_storage_hm3: 0.0,
max_storage_hm3: 200.0,
min_turbined_m3s: 0.0,
max_turbined_m3s: 100.0,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
min_generation_mw: 0.0,
max_generation_mw: 250.0,
max_diversion_m3s: None,
filling_inflow_m3s: 0.0,
water_withdrawal_m3s: 0.0,
},
thermal: ThermalStageBounds {
min_generation_mw: 0.0,
max_generation_mw: 100.0,
cost_per_mwh: 0.0,
},
line: LineStageBounds {
direct_mw: 0.0,
reverse_mw: 0.0,
},
pumping: PumpingStageBounds {
min_flow_m3s: 0.0,
max_flow_m3s: 0.0,
},
contract: ContractStageBounds {
min_mw: 0.0,
max_mw: 0.0,
price_per_mwh: 0.0,
},
},
);
let penalties = ResolvedPenalties::new(
&PenaltiesCountsSpec {
n_hydros: 1,
n_buses: 1,
n_lines: 0,
n_ncs: 0,
n_stages: 2,
},
&PenaltiesDefaults {
hydro: HydroStagePenalties {
spillage_cost: 0.01,
diversion_cost: 0.0,
turbined_cost: 0.0,
storage_violation_below_cost: 500.0,
filling_target_violation_cost: 0.0,
turbined_violation_below_cost: 0.0,
outflow_violation_below_cost: 0.0,
outflow_violation_above_cost: 0.0,
generation_violation_below_cost: 0.0,
evaporation_violation_cost: 0.0,
water_withdrawal_violation_cost: 0.0,
water_withdrawal_violation_pos_cost: 0.0,
water_withdrawal_violation_neg_cost: 0.0,
evaporation_violation_pos_cost: 0.0,
evaporation_violation_neg_cost: 0.0,
inflow_nonnegativity_cost: 1000.0,
},
bus: BusStagePenalties { excess_cost: 0.0 },
line: LineStagePenalties { exchange_cost: 0.0 },
ncs: NcsStagePenalties {
curtailment_cost: 0.0,
},
},
);
let system = SystemBuilder::new()
.buses(vec![bus])
.thermals(vec![thermal])
.hydros(vec![hydro])
.stages(stages)
.inflow_models(inflow_models)
.load_models(load_models)
.external_load_scenarios(external_load_rows)
.bounds(bounds)
.penalties(penalties)
.build()
.expect("system with external load: valid");
let config = minimal_config_with_schemes(1, 5, None, Some("external"), None);
let stochastic = build_stochastic_context(
&system,
42,
None,
&[],
&[],
OpeningTreeInputs::default(),
ClassSchemes {
inflow: Some(SamplingScheme::InSample),
load: Some(SamplingScheme::External),
ncs: Some(SamplingScheme::InSample),
},
)
.expect("stochastic context");
let setup = StudySetup::new(
&system,
&config,
stochastic,
PrepareHydroModelsResult::default_from_system(&system),
)
.expect("setup");
let lib = setup
.scenario_libraries
.training
.external_load
.as_ref()
.expect("expected Some(ExternalScenarioLibrary) for External load scheme");
assert!(
lib.n_entities() > 0,
"expected n_entities > 0 in external load library"
);
assert_eq!(lib.n_stages(), 2);
assert_eq!(lib.n_scenarios(), 3);
assert_eq!(lib.entity_class(), "load");
}
#[test]
#[allow(
clippy::too_many_lines,
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::cast_lossless
)]
fn external_ncs_library_built_when_scheme_is_external() {
use chrono::NaiveDate;
use cobre_core::scenario::InflowModel as CoreInflowModel;
use cobre_core::{
NonControllableSource,
scenario::{ExternalNcsRow, NcsModel},
system::SystemBuilder,
};
let bus = Bus {
id: EntityId(1),
name: "B1".to_string(),
deficit_segments: vec![DeficitSegment {
depth_mw: None,
cost_per_mwh: 500.0,
}],
excess_cost: 0.0,
};
let thermal = Thermal {
id: EntityId(2),
name: "T1".to_string(),
bus_id: EntityId(1),
min_generation_mw: 0.0,
max_generation_mw: 100.0,
cost_per_mwh: 50.0,
gnl_config: None,
entry_stage_id: None,
exit_stage_id: None,
};
let hydro = Hydro {
id: EntityId(3),
name: "H1".to_string(),
bus_id: EntityId(1),
downstream_id: None,
entry_stage_id: None,
exit_stage_id: None,
min_storage_hm3: 0.0,
max_storage_hm3: 200.0,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
generation_model: HydroGenerationModel::ConstantProductivity,
min_turbined_m3s: 0.0,
max_turbined_m3s: 100.0,
specific_productivity_mw_per_m3s_per_m: None,
min_generation_mw: 0.0,
max_generation_mw: 250.0,
tailrace: None,
hydraulic_losses: None,
efficiency: None,
evaporation_coefficients_mm: None,
evaporation_reference_volumes_hm3: None,
diversion: None,
filling: None,
penalties: HydroPenalties {
spillage_cost: 0.01,
diversion_cost: 0.0,
turbined_cost: 0.0,
storage_violation_below_cost: 0.0,
filling_target_violation_cost: 0.0,
turbined_violation_below_cost: 0.0,
outflow_violation_below_cost: 0.0,
outflow_violation_above_cost: 0.0,
generation_violation_below_cost: 0.0,
evaporation_violation_cost: 0.0,
water_withdrawal_violation_cost: 0.0,
water_withdrawal_violation_pos_cost: 0.0,
water_withdrawal_violation_neg_cost: 0.0,
evaporation_violation_pos_cost: 0.0,
evaporation_violation_neg_cost: 0.0,
inflow_nonnegativity_cost: 1000.0,
},
};
let ncs_id = EntityId(4);
let ncs_source = NonControllableSource {
id: ncs_id,
name: "Wind1".to_string(),
bus_id: EntityId(1),
entry_stage_id: None,
exit_stage_id: None,
max_generation_mw: 100.0,
allow_curtailment: true,
curtailment_cost: 0.01,
};
let stages: Vec<Stage> = (0..2usize)
.map(|i| Stage {
index: i,
id: i as i32,
start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
season_id: None,
blocks: vec![Block {
index: 0,
name: "S".to_string(),
duration_hours: 744.0,
}],
block_mode: BlockMode::Parallel,
state_config: StageStateConfig {
storage: true,
inflow_lags: false,
},
risk_config: StageRiskConfig::Expectation,
scenario_config: ScenarioSourceConfig {
branching_factor: 1,
noise_method: NoiseMethod::Saa,
},
})
.collect();
let inflow_models: Vec<CoreInflowModel> = (0..2usize)
.map(|i| CoreInflowModel {
hydro_id: EntityId(3),
stage_id: i as i32,
mean_m3s: 80.0,
std_m3s: 20.0,
ar_coefficients: vec![],
residual_std_ratio: 1.0,
annual: None,
})
.collect();
let load_models: Vec<LoadModel> = (0..2usize)
.map(|i| LoadModel {
bus_id: EntityId(1),
stage_id: i as i32,
mean_mw: 100.0,
std_mw: 0.0,
})
.collect();
let ncs_models: Vec<NcsModel> = (0..2usize)
.map(|i| NcsModel {
ncs_id,
stage_id: i as i32,
mean: 0.8,
std: 0.1,
})
.collect();
let mut external_ncs_rows: Vec<ExternalNcsRow> = Vec::new();
for stage_id in 0i32..2 {
for scenario_id in 0i32..3 {
external_ncs_rows.push(ExternalNcsRow {
stage_id,
scenario_id,
ncs_id,
value: 0.7 + scenario_id as f64 * 0.1,
});
}
}
let bounds = ResolvedBounds::new(
&BoundsCountsSpec {
n_hydros: 1,
n_thermals: 1,
n_lines: 0,
n_pumping: 0,
n_contracts: 0,
n_stages: 2,
},
&BoundsDefaults {
hydro: HydroStageBounds {
min_storage_hm3: 0.0,
max_storage_hm3: 200.0,
min_turbined_m3s: 0.0,
max_turbined_m3s: 100.0,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
min_generation_mw: 0.0,
max_generation_mw: 250.0,
max_diversion_m3s: None,
filling_inflow_m3s: 0.0,
water_withdrawal_m3s: 0.0,
},
thermal: ThermalStageBounds {
min_generation_mw: 0.0,
max_generation_mw: 100.0,
cost_per_mwh: 0.0,
},
line: LineStageBounds {
direct_mw: 0.0,
reverse_mw: 0.0,
},
pumping: PumpingStageBounds {
min_flow_m3s: 0.0,
max_flow_m3s: 0.0,
},
contract: ContractStageBounds {
min_mw: 0.0,
max_mw: 0.0,
price_per_mwh: 0.0,
},
},
);
let penalties = ResolvedPenalties::new(
&PenaltiesCountsSpec {
n_hydros: 1,
n_buses: 1,
n_lines: 0,
n_ncs: 1,
n_stages: 2,
},
&PenaltiesDefaults {
hydro: HydroStagePenalties {
spillage_cost: 0.01,
diversion_cost: 0.0,
turbined_cost: 0.0,
storage_violation_below_cost: 500.0,
filling_target_violation_cost: 0.0,
turbined_violation_below_cost: 0.0,
outflow_violation_below_cost: 0.0,
outflow_violation_above_cost: 0.0,
generation_violation_below_cost: 0.0,
evaporation_violation_cost: 0.0,
water_withdrawal_violation_cost: 0.0,
water_withdrawal_violation_pos_cost: 0.0,
water_withdrawal_violation_neg_cost: 0.0,
evaporation_violation_pos_cost: 0.0,
evaporation_violation_neg_cost: 0.0,
inflow_nonnegativity_cost: 1000.0,
},
bus: BusStagePenalties { excess_cost: 0.0 },
line: LineStagePenalties { exchange_cost: 0.0 },
ncs: NcsStagePenalties {
curtailment_cost: 0.0,
},
},
);
let system = SystemBuilder::new()
.buses(vec![bus])
.thermals(vec![thermal])
.hydros(vec![hydro])
.non_controllable_sources(vec![ncs_source])
.stages(stages)
.inflow_models(inflow_models)
.load_models(load_models)
.ncs_models(ncs_models)
.external_ncs_scenarios(external_ncs_rows)
.bounds(bounds)
.penalties(penalties)
.build()
.expect("system with external NCS: valid");
let config = minimal_config_with_schemes(1, 5, None, None, Some("external"));
let stochastic = build_stochastic_context(
&system,
42,
None,
&[],
&[],
OpeningTreeInputs::default(),
ClassSchemes {
inflow: Some(SamplingScheme::InSample),
load: Some(SamplingScheme::InSample),
ncs: Some(SamplingScheme::External),
},
)
.expect("stochastic context");
let setup = StudySetup::new(
&system,
&config,
stochastic,
PrepareHydroModelsResult::default_from_system(&system),
)
.expect("setup");
let lib = setup
.scenario_libraries
.training
.external_ncs
.as_ref()
.expect("expected Some(ExternalScenarioLibrary) for External NCS scheme");
assert!(
lib.n_entities() > 0,
"expected n_entities > 0 in external NCS library"
);
assert_eq!(lib.n_stages(), 2);
assert_eq!(lib.n_scenarios(), 3);
assert_eq!(lib.entity_class(), "ncs");
}
#[test]
#[allow(
clippy::too_many_lines,
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::cast_lossless
)]
fn historical_library_fails_when_no_valid_windows() {
use cobre_core::system::SystemBuilder;
use chrono::NaiveDate;
use cobre_core::scenario::InflowModel;
let bus = Bus {
id: EntityId(1),
name: "B1".to_string(),
deficit_segments: vec![DeficitSegment {
depth_mw: None,
cost_per_mwh: 500.0,
}],
excess_cost: 0.0,
};
let thermal = Thermal {
id: EntityId(2),
name: "T1".to_string(),
bus_id: EntityId(1),
min_generation_mw: 0.0,
max_generation_mw: 100.0,
cost_per_mwh: 50.0,
gnl_config: None,
entry_stage_id: None,
exit_stage_id: None,
};
let hydro = Hydro {
id: EntityId(3),
name: "H1".to_string(),
bus_id: EntityId(1),
downstream_id: None,
entry_stage_id: None,
exit_stage_id: None,
min_storage_hm3: 0.0,
max_storage_hm3: 200.0,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
generation_model: HydroGenerationModel::ConstantProductivity,
min_turbined_m3s: 0.0,
max_turbined_m3s: 100.0,
specific_productivity_mw_per_m3s_per_m: None,
min_generation_mw: 0.0,
max_generation_mw: 250.0,
tailrace: None,
hydraulic_losses: None,
efficiency: None,
evaporation_coefficients_mm: None,
evaporation_reference_volumes_hm3: None,
diversion: None,
filling: None,
penalties: HydroPenalties {
spillage_cost: 0.01,
diversion_cost: 0.0,
turbined_cost: 0.0,
storage_violation_below_cost: 0.0,
filling_target_violation_cost: 0.0,
turbined_violation_below_cost: 0.0,
outflow_violation_below_cost: 0.0,
outflow_violation_above_cost: 0.0,
generation_violation_below_cost: 0.0,
evaporation_violation_cost: 0.0,
water_withdrawal_violation_cost: 0.0,
water_withdrawal_violation_pos_cost: 0.0,
water_withdrawal_violation_neg_cost: 0.0,
evaporation_violation_pos_cost: 0.0,
evaporation_violation_neg_cost: 0.0,
inflow_nonnegativity_cost: 1000.0,
},
};
let stages: Vec<Stage> = (0..2usize)
.map(|i| Stage {
index: i,
id: i as i32,
start_date: NaiveDate::from_ymd_opt(2024, (i as u32 % 12) + 1, 1).unwrap(),
end_date: NaiveDate::from_ymd_opt(2024, (i as u32 % 12) + 1, 28).unwrap(),
season_id: Some(i % 12),
blocks: vec![Block {
index: 0,
name: "S".to_string(),
duration_hours: 720.0,
}],
block_mode: BlockMode::Parallel,
state_config: StageStateConfig {
storage: true,
inflow_lags: false,
},
risk_config: StageRiskConfig::Expectation,
scenario_config: ScenarioSourceConfig {
branching_factor: 1,
noise_method: NoiseMethod::Saa,
},
})
.collect();
let inflow_models: Vec<InflowModel> = (0..2usize)
.map(|i| InflowModel {
hydro_id: EntityId(3),
stage_id: i as i32,
mean_m3s: 80.0,
std_m3s: 20.0,
ar_coefficients: vec![],
residual_std_ratio: 1.0,
annual: None,
})
.collect();
let load_models: Vec<LoadModel> = (0..2usize)
.map(|i| LoadModel {
bus_id: EntityId(1),
stage_id: i as i32,
mean_mw: 100.0,
std_mw: 0.0,
})
.collect();
let bounds = ResolvedBounds::new(
&BoundsCountsSpec {
n_hydros: 1,
n_thermals: 1,
n_lines: 0,
n_pumping: 0,
n_contracts: 0,
n_stages: 2,
},
&BoundsDefaults {
hydro: HydroStageBounds {
min_storage_hm3: 0.0,
max_storage_hm3: 200.0,
min_turbined_m3s: 0.0,
max_turbined_m3s: 100.0,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
min_generation_mw: 0.0,
max_generation_mw: 250.0,
max_diversion_m3s: None,
filling_inflow_m3s: 0.0,
water_withdrawal_m3s: 0.0,
},
thermal: ThermalStageBounds {
min_generation_mw: 0.0,
max_generation_mw: 100.0,
cost_per_mwh: 0.0,
},
line: LineStageBounds {
direct_mw: 0.0,
reverse_mw: 0.0,
},
pumping: PumpingStageBounds {
min_flow_m3s: 0.0,
max_flow_m3s: 0.0,
},
contract: ContractStageBounds {
min_mw: 0.0,
max_mw: 0.0,
price_per_mwh: 0.0,
},
},
);
let penalties = ResolvedPenalties::new(
&PenaltiesCountsSpec {
n_hydros: 1,
n_buses: 1,
n_lines: 0,
n_ncs: 0,
n_stages: 2,
},
&PenaltiesDefaults {
hydro: HydroStagePenalties {
spillage_cost: 0.01,
diversion_cost: 0.0,
turbined_cost: 0.0,
storage_violation_below_cost: 500.0,
filling_target_violation_cost: 0.0,
turbined_violation_below_cost: 0.0,
outflow_violation_below_cost: 0.0,
outflow_violation_above_cost: 0.0,
generation_violation_below_cost: 0.0,
evaporation_violation_cost: 0.0,
water_withdrawal_violation_cost: 0.0,
water_withdrawal_violation_pos_cost: 0.0,
water_withdrawal_violation_neg_cost: 0.0,
evaporation_violation_pos_cost: 0.0,
evaporation_violation_neg_cost: 0.0,
inflow_nonnegativity_cost: 1000.0,
},
bus: BusStagePenalties { excess_cost: 0.0 },
line: LineStagePenalties { exchange_cost: 0.0 },
ncs: NcsStagePenalties {
curtailment_cost: 0.0,
},
},
);
let system = SystemBuilder::new()
.buses(vec![bus])
.thermals(vec![thermal])
.hydros(vec![hydro])
.stages(stages)
.inflow_models(inflow_models)
.load_models(load_models)
.bounds(bounds)
.penalties(penalties)
.build()
.expect("system: valid");
let config = minimal_config_with_schemes(1, 5, Some("historical"), None, None);
let stochastic = build_stochastic_context(
&system,
42,
None,
&[],
&[],
OpeningTreeInputs::default(),
ClassSchemes {
inflow: Some(SamplingScheme::Historical),
load: Some(SamplingScheme::InSample),
ncs: Some(SamplingScheme::InSample),
},
)
.expect("stochastic context");
let result = StudySetup::new(
&system,
&config,
stochastic,
PrepareHydroModelsResult::default_from_system(&system),
);
assert!(result.is_err(), "expected Err when no historical data");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("window") || err_msg.contains("historical"),
"error should mention windows or historical, got: {err_msg}"
);
}
#[test]
fn test_simulate_uses_simulation_scheme() {
let system = minimal_system(2);
let mut config = minimal_config(1, 5);
config.simulation.scenario_source = Some(RawScenarioSourceConfig {
seed: Some(99),
historical_years: None,
inflow: Some(RawClassConfigEntry {
scheme: "out_of_sample".to_string(),
}),
load: None,
ncs: None,
});
let stochastic = build_stochastic_context(
&system,
42,
None,
&[],
&[],
OpeningTreeInputs::default(),
ClassSchemes {
inflow: Some(SamplingScheme::InSample),
load: Some(SamplingScheme::InSample),
ncs: Some(SamplingScheme::InSample),
},
)
.expect("stochastic context");
let setup = StudySetup::new(
&system,
&config,
stochastic,
PrepareHydroModelsResult::default_from_system(&system),
)
.expect("setup");
let train_ctx = setup.training_ctx();
assert_eq!(
train_ctx.inflow_scheme,
SamplingScheme::InSample,
"training context must use InSample inflow scheme"
);
let sim_ctx = setup.simulation_ctx();
assert_eq!(
sim_ctx.inflow_scheme,
SamplingScheme::OutOfSample,
"simulation context must use OutOfSample inflow scheme"
);
}
#[test]
fn test_sim_historical_library_built_when_sim_scheme_is_historical() {
let system = system_with_historical_inflow(2);
let mut config = minimal_config(1, 5);
config.simulation.scenario_source = Some(RawScenarioSourceConfig {
seed: Some(42),
historical_years: None,
inflow: Some(RawClassConfigEntry {
scheme: "historical".to_string(),
}),
load: None,
ncs: None,
});
let stochastic = build_stochastic_context(
&system,
42,
None,
&[],
&[],
OpeningTreeInputs::default(),
ClassSchemes {
inflow: Some(SamplingScheme::InSample),
load: Some(SamplingScheme::InSample),
ncs: Some(SamplingScheme::InSample),
},
)
.expect("stochastic context");
let setup = StudySetup::new(
&system,
&config,
stochastic,
PrepareHydroModelsResult::default_from_system(&system),
)
.expect("setup");
assert!(
setup.training_ctx().historical_library.is_none(),
"training context must NOT have a historical library when scheme is InSample"
);
assert!(
setup.simulation_ctx().historical_library.is_some(),
"simulation context must have a historical library when sim scheme is Historical"
);
}
}