#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::float_cmp,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::doc_markdown,
clippy::too_many_lines
)]
use std::path::Path;
use std::sync::mpsc;
use cobre_comm::{CommData, CommError, Communicator, ReduceOp};
use cobre_core::scenario::ScenarioSource;
use cobre_io::{
PolicyCheckpointMetadata, PolicyCutRecord, StageCutsPayload, write_policy_checkpoint,
};
use cobre_sddp::{
StudySetup, aggregate_simulation,
hydro_models::prepare_hydro_models,
setup::{StudyParams, prepare_stochastic},
};
use cobre_solver::ActiveSolver;
use cobre_solver::SolverInterface;
mod common;
struct StubComm;
impl Communicator for StubComm {
fn allgatherv<T: CommData>(
&self,
send: &[T],
recv: &mut [T],
_counts: &[usize],
_displs: &[usize],
) -> Result<(), CommError> {
recv[..send.len()].clone_from_slice(send);
Ok(())
}
fn allreduce<T: CommData>(
&self,
send: &[T],
recv: &mut [T],
_op: ReduceOp,
) -> Result<(), CommError> {
recv.clone_from_slice(send);
Ok(())
}
fn broadcast<T: CommData>(&self, _buf: &mut [T], _root: usize) -> Result<(), CommError> {
Ok(())
}
fn barrier(&self) -> Result<(), CommError> {
Ok(())
}
fn rank(&self) -> usize {
0
}
fn size(&self) -> usize {
1
}
fn abort(&self, error_code: i32) -> ! {
std::process::exit(error_code)
}
}
fn build_setup_for_case(
_case_dir: &Path,
config: &cobre_io::Config,
system: &cobre_core::System,
stochastic: cobre_stochastic::StochasticContext,
hydro_models: cobre_sddp::PrepareHydroModelsResult,
) -> StudySetup {
let sentinel = std::path::Path::new("config.json");
let training_source = config
.training_scenario_source(sentinel)
.expect("training_scenario_source must parse");
let simulation_source = config
.simulation_scenario_source(sentinel)
.expect("simulation_scenario_source must parse");
let params = StudyParams::from_config(config).expect("StudyParams::from_config must succeed");
let construction = params.into_construction_config();
StudySetup::from_broadcast_params(
system,
stochastic,
construction,
hydro_models,
&training_source,
&simulation_source,
)
.expect("StudySetup::from_broadcast_params must build")
}
fn run_deterministic_with_solver(case_dir: &Path) -> (cobre_sddp::TrainingResult, ActiveSolver) {
let config_path = case_dir.join("config.json");
let config = cobre_io::parse_config(&config_path).expect("config must parse");
let system = cobre_io::load_case(case_dir).expect("load_case must succeed");
let prepare_result =
prepare_stochastic(system, case_dir, &config, 42, &ScenarioSource::default())
.expect("prepare_stochastic must succeed");
let system = prepare_result.system;
let stochastic = prepare_result.stochastic;
let hydro_models =
prepare_hydro_models(&system, case_dir, false).expect("prepare_hydro_models must succeed");
let mut setup = build_setup_for_case(case_dir, &config, &system, stochastic, hydro_models);
let comm = StubComm;
let mut solver = ActiveSolver::new().expect("ActiveSolver::new must succeed");
let outcome = setup
.train(&mut solver, &comm, 1, ActiveSolver::new, None, None)
.expect("train must return Ok");
assert!(outcome.error.is_none(), "expected no training error");
(outcome.result, solver)
}
fn run_deterministic(case_dir: &Path) -> cobre_sddp::TrainingResult {
let config_path = case_dir.join("config.json");
let config = cobre_io::parse_config(&config_path).expect("config must parse");
let system = cobre_io::load_case(case_dir).expect("load_case must succeed");
let prepare_result =
prepare_stochastic(system, case_dir, &config, 42, &ScenarioSource::default())
.expect("prepare_stochastic must succeed");
let system = prepare_result.system;
let stochastic = prepare_result.stochastic;
let hydro_models =
prepare_hydro_models(&system, case_dir, false).expect("prepare_hydro_models must succeed");
let mut setup = build_setup_for_case(case_dir, &config, &system, stochastic, hydro_models);
let comm = StubComm;
let mut solver = ActiveSolver::new().expect("ActiveSolver::new must succeed");
let outcome = setup
.train(&mut solver, &comm, 1, ActiveSolver::new, None, None)
.expect("train must return Ok");
assert!(outcome.error.is_none(), "expected no training error");
outcome.result
}
fn run_with_simulation(
case_dir: &Path,
) -> (
cobre_sddp::TrainingResult,
Vec<cobre_sddp::SimulationScenarioResult>,
cobre_sddp::SimulationSummary,
) {
let config_path = case_dir.join("config.json");
let config = cobre_io::parse_config(&config_path).expect("config must parse");
let system = cobre_io::load_case(case_dir).expect("load_case must succeed");
let pr = prepare_stochastic(system, case_dir, &config, 42, &ScenarioSource::default())
.expect("prepare_stochastic must succeed");
let system = pr.system;
let stochastic = pr.stochastic;
let hydro_models =
prepare_hydro_models(&system, case_dir, false).expect("prepare_hydro_models must succeed");
let mut config_with_sim = config.clone();
config_with_sim.simulation.enabled = true;
config_with_sim.simulation.num_scenarios = 1;
let mut setup = build_setup_for_case(
case_dir,
&config_with_sim,
&system,
stochastic,
hydro_models,
);
let comm = StubComm;
let mut solver = ActiveSolver::new().expect("ActiveSolver::new must succeed");
let outcome = setup
.train(&mut solver, &comm, 1, ActiveSolver::new, None, None)
.expect("train must return Ok");
assert!(outcome.error.is_none(), "expected no training error");
let result = outcome.result;
let mut pool = setup
.create_workspace_pool(&comm, 1, ActiveSolver::new)
.expect("simulation workspace pool must build");
let io_capacity = setup.simulation_config.io_channel_capacity.max(1);
let (result_tx, result_rx) = mpsc::sync_channel(io_capacity);
let drain_handle = std::thread::spawn(move || result_rx.into_iter().collect::<Vec<_>>());
let local_costs = setup
.simulate(
&mut pool.workspaces,
&comm,
&result_tx,
None,
result.baked_templates.as_deref(),
&result.basis_cache,
)
.expect("simulate must return Ok");
drop(result_tx);
let scenario_results = drain_handle.join().expect("drain thread must not panic");
let sim_config = setup.simulation_config();
let summary = aggregate_simulation(&local_costs.costs, sim_config, &comm)
.expect("aggregate_simulation must succeed");
(result, scenario_results, summary)
}
fn assert_cost(actual: f64, expected: f64, tolerance: f64, case_name: &str) {
let diff = (actual - expected).abs();
assert!(
diff <= tolerance,
"{case_name}: expected cost {expected}, got {actual} (diff={diff} > tolerance={tolerance})"
);
}
fn write_energy_productivity_override(
dest: &std::path::Path,
hydro_id: i32,
equivalent_productivity_mw_per_m3s: f64,
) {
use arrow::array::{Float64Array, Int32Array};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use parquet::arrow::ArrowWriter;
use std::sync::Arc;
let schema = Arc::new(Schema::new(vec![
Field::new("hydro_id", DataType::Int32, false),
Field::new("stage_id", DataType::Int32, true),
Field::new(
"equivalent_productivity_mw_per_m3s",
DataType::Float64,
true,
),
Field::new("reference_volume_hm3", DataType::Float64, true),
Field::new("reference_outflow_m3s", DataType::Float64, true),
Field::new(
"specific_productivity_mw_per_m3s_per_m",
DataType::Float64,
true,
),
]));
let batch = RecordBatch::try_new(
Arc::clone(&schema),
vec![
Arc::new(Int32Array::from(vec![hydro_id])),
Arc::new(Int32Array::from(vec![None::<i32>])),
Arc::new(Float64Array::from(vec![equivalent_productivity_mw_per_m3s])),
Arc::new(Float64Array::from(vec![None::<f64>])),
Arc::new(Float64Array::from(vec![None::<f64>])),
Arc::new(Float64Array::from(vec![None::<f64>])),
],
)
.expect("valid RecordBatch for hydro_energy_productivity override");
let file = std::fs::File::create(dest).expect("create hydro_energy_productivity.parquet");
let mut writer = ArrowWriter::try_new(file, schema, None).expect("ArrowWriter for override");
writer.write(&batch).expect("write override batch");
writer.close().expect("close override writer");
}
pub const D02_EXPECTED_COST: f64 = 23_640_785.0 / 9.0;
pub const D05_EXPECTED_COST: f64 = 23_635_000.0 / 9.0;
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "slow: run with --features slow-tests"
)]
#[test]
fn d01_thermal_dispatch() {
let case_dir = Path::new("../../examples/deterministic/d01-thermal-dispatch");
let result = run_deterministic(case_dir);
assert_cost(result.final_lb, 182_500.0, 1e-6, "D01");
assert!(
result.iterations <= 10,
"D01: iterations={}",
result.iterations
);
assert!(
result.final_gap.abs() < 1e-6,
"D01: gap={:.2e}",
result.final_gap
);
}
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "slow: run with --features slow-tests"
)]
#[test]
fn d02_single_hydro() {
let case_dir = Path::new("../../examples/deterministic/d02-single-hydro");
let result = run_deterministic(case_dir);
assert_cost(result.final_lb, D02_EXPECTED_COST, 1e-4, "D02");
assert!(
result.iterations <= 10,
"D02: iterations={}",
result.iterations
);
assert!(
result.final_gap.abs() < 1e-6,
"D02: gap={:.2e}",
result.final_gap
);
}
pub const D03_EXPECTED_COST: f64 = 1_391_697.766_666_667_3;
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "slow: run with --features slow-tests"
)]
#[test]
fn d03_two_hydro_cascade() {
let case_dir = Path::new("../../examples/deterministic/d03-two-hydro-cascade");
let result = run_deterministic(case_dir);
assert_cost(result.final_lb, D03_EXPECTED_COST, 1e-4, "D03");
assert!(
result.iterations <= 10,
"D03: iterations={}",
result.iterations
);
assert!(
result.final_gap.abs() < 1e-6,
"D03: gap={:.2e}",
result.final_gap
);
}
pub const D04_EXPECTED_COST: f64 = 5_264_062_704.0 / 657.0;
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "slow: run with --features slow-tests"
)]
#[test]
fn d04_transmission() {
let case_dir = Path::new("../../examples/deterministic/d04-transmission");
let result = run_deterministic(case_dir);
assert_cost(result.final_lb, D04_EXPECTED_COST, 1e-4, "D04");
assert!(
result.iterations <= 10,
"D04: iterations={}",
result.iterations
);
assert!(
result.final_gap.abs() < 1e-6,
"D04: gap={:.2e}",
result.final_gap
);
}
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "slow: run with --features slow-tests"
)]
#[test]
fn d05_fpha_constant_head() {
let case_dir = Path::new("../../examples/deterministic/d05-fpha-constant-head");
write_energy_productivity_override(
&case_dir.join("system/hydro_energy_productivity.parquet"),
0, 1.0, );
let result = run_deterministic(case_dir);
assert_cost(result.final_lb, D05_EXPECTED_COST, 1e-6, "D05");
assert!(
result.iterations <= 10,
"D05: iterations={}",
result.iterations
);
assert!(
result.final_gap.abs() < 1e-6,
"D05: gap={:.2e}",
result.final_gap
);
}
pub const D06_EXPECTED_COST: f64 = 732_952_154.0 / 225.0;
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "slow: run with --features slow-tests"
)]
#[test]
fn d06_fpha_variable_head() {
let case_dir = Path::new("../../examples/deterministic/d06-fpha-variable-head");
write_energy_productivity_override(
&case_dir.join("system/hydro_energy_productivity.parquet"),
0, 1.0, );
let result = run_deterministic(case_dir);
assert_cost(result.final_lb, D06_EXPECTED_COST, 1e-4, "D06");
assert!(
result.iterations <= 10,
"D06: iterations={}",
result.iterations
);
assert!(
result.final_gap.abs() < 1e-6,
"D06: gap={:.2e}",
result.final_gap
);
assert!(
(result.final_lb - D02_EXPECTED_COST).abs() > 1.0,
"D06: cost must differ from D02 (variable head changes economics)"
);
}
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "slow: run with --features slow-tests"
)]
#[test]
fn d07_fpha_computed() {
let case_dir = Path::new("../../examples/deterministic/d07-fpha-computed");
let result = run_deterministic(case_dir);
assert!(
result.final_gap.abs() < 1e-6,
"D07: gap={:.2e}",
result.final_gap
);
assert!(
result.iterations <= 10,
"D07: iterations={}",
result.iterations
);
assert!(
result.final_lb > 0.0,
"D07: final_lb={} must be positive",
result.final_lb
);
}
const ENERGY_FACTOR: f64 = 1.0e6 / 3600.0;
const D02_RHO_EQ: f64 = 1.0;
const D02_RHO_ACUM: f64 = 1.0;
const D02_H0_V_INIT: f64 = 100.0;
const D02_H0_STAGE0_INFLOW: f64 = 40.0;
const D02_H0_STAGE1_INFLOW: f64 = 10.0;
const D03_H0_RHO_EQ: f64 = 1.0;
const D03_H0_RHO_ACUM: f64 = 2.0;
const D03_H1_RHO_EQ: f64 = 1.0;
const D03_H1_RHO_ACUM: f64 = 1.0;
const D03_H0_V_INIT: f64 = 80.0;
const D03_H1_V_INIT: f64 = 50.0;
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "slow: run with --features slow-tests"
)]
#[test]
fn d_case_energy_outputs() {
const TOL: f64 = 1e-6;
const V_MIN: f64 = 0.0;
{
let case_dir = Path::new("../../examples/deterministic/d02-single-hydro");
let (_result, scenario_results, _summary) = run_with_simulation(case_dir);
assert_eq!(scenario_results.len(), 1, "D02: expected 1 scenario");
let scenario = &scenario_results[0];
assert_eq!(scenario.stages.len(), 2, "D02: expected 2 stages");
for stage_result in &scenario.stages {
let stage = stage_result.stage_id as usize;
assert_eq!(
stage_result.hydros.len(),
1,
"D02 stage {stage}: expected 1 hydro result"
);
let h = &stage_result.hydros[0];
let diff_rho_eq = (h.equivalent_productivity_mw_per_m3s - D02_RHO_EQ).abs();
assert!(
diff_rho_eq <= TOL,
"D02 stage {stage} H0: equivalent_productivity_mw_per_m3s = {} (expected {D02_RHO_EQ}, diff = {diff_rho_eq})",
h.equivalent_productivity_mw_per_m3s,
);
let diff_rho_acum = (h.accumulated_productivity_mw_per_m3s - D02_RHO_ACUM).abs();
assert!(
diff_rho_acum <= TOL,
"D02 stage {stage} H0: accumulated_productivity_mw_per_m3s = {} (expected {D02_RHO_ACUM}, diff = {diff_rho_acum})",
h.accumulated_productivity_mw_per_m3s,
);
let expected_energy = h.accumulated_productivity_mw_per_m3s * h.incremental_inflow_m3s;
let diff_energy = (h.incremental_inflow_energy_mw - expected_energy).abs();
assert!(
diff_energy <= TOL,
"D02 stage {stage} H0: incremental_inflow_energy_mw = {} (ρ_acum × inflow = {expected_energy}, diff = {diff_energy})",
h.incremental_inflow_energy_mw,
);
let expected_earm = (h.storage_initial_hm3 - V_MIN)
* h.accumulated_productivity_mw_per_m3s
* ENERGY_FACTOR;
let diff_earm = (h.stored_energy_initial_mwh - expected_earm).abs();
assert!(
diff_earm <= TOL,
"D02 stage {stage} H0: stored_energy_initial_mwh = {} (expected {expected_earm}, diff = {diff_earm})",
h.stored_energy_initial_mwh,
);
}
let h0_stage0 = scenario.stages[0]
.hydros
.iter()
.find(|h| h.hydro_id == 0)
.expect("D02: H0 missing from stage 0");
let diff_inflow0 =
(h0_stage0.incremental_inflow_energy_mw - D02_RHO_ACUM * D02_H0_STAGE0_INFLOW).abs();
assert!(
diff_inflow0 <= TOL,
"D02 stage 0 H0: incremental_inflow_energy_mw = {} (expected {}, diff = {diff_inflow0})",
h0_stage0.incremental_inflow_energy_mw,
D02_RHO_ACUM * D02_H0_STAGE0_INFLOW,
);
let expected_earm0 = (D02_H0_V_INIT - V_MIN) * D02_RHO_ACUM * ENERGY_FACTOR;
let diff_earm0 = (h0_stage0.stored_energy_initial_mwh - expected_earm0).abs();
assert!(
diff_earm0 <= TOL,
"D02 stage 0 H0: stored_energy_initial_mwh = {} (expected {expected_earm0}, diff = {diff_earm0})",
h0_stage0.stored_energy_initial_mwh,
);
let h0_stage1 = scenario.stages[1]
.hydros
.iter()
.find(|h| h.hydro_id == 0)
.expect("D02: H0 missing from stage 1");
let diff_inflow1 =
(h0_stage1.incremental_inflow_energy_mw - D02_RHO_ACUM * D02_H0_STAGE1_INFLOW).abs();
assert!(
diff_inflow1 <= TOL,
"D02 stage 1 H0: incremental_inflow_energy_mw = {} (expected {}, diff = {diff_inflow1})",
h0_stage1.incremental_inflow_energy_mw,
D02_RHO_ACUM * D02_H0_STAGE1_INFLOW,
);
}
{
let case_dir = Path::new("../../examples/deterministic/d03-two-hydro-cascade");
let (_result, scenario_results, _summary) = run_with_simulation(case_dir);
assert_eq!(scenario_results.len(), 1, "D03: expected 1 scenario");
let scenario = &scenario_results[0];
assert_eq!(scenario.stages.len(), 3, "D03: expected 3 stages");
for stage_result in &scenario.stages {
let stage = stage_result.stage_id as usize;
assert_eq!(
stage_result.hydros.len(),
2,
"D03 stage {stage}: expected 2 hydro results"
);
for h in &stage_result.hydros {
let (expected_rho_eq, expected_rho_acum) = if h.hydro_id == 0 {
(D03_H0_RHO_EQ, D03_H0_RHO_ACUM)
} else {
(D03_H1_RHO_EQ, D03_H1_RHO_ACUM)
};
let diff_rho_eq = (h.equivalent_productivity_mw_per_m3s - expected_rho_eq).abs();
assert!(
diff_rho_eq <= TOL,
"D03 stage {stage} H{}: equivalent_productivity_mw_per_m3s = {} (expected {expected_rho_eq}, diff = {diff_rho_eq})",
h.hydro_id,
h.equivalent_productivity_mw_per_m3s,
);
let diff_rho_acum =
(h.accumulated_productivity_mw_per_m3s - expected_rho_acum).abs();
assert!(
diff_rho_acum <= TOL,
"D03 stage {stage} H{}: accumulated_productivity_mw_per_m3s = {} (expected {expected_rho_acum}, diff = {diff_rho_acum})",
h.hydro_id,
h.accumulated_productivity_mw_per_m3s,
);
let expected_energy =
h.accumulated_productivity_mw_per_m3s * h.incremental_inflow_m3s;
let diff_energy = (h.incremental_inflow_energy_mw - expected_energy).abs();
assert!(
diff_energy <= TOL,
"D03 stage {stage} H{}: incremental_inflow_energy_mw = {} (ρ_acum × inflow = {expected_energy}, diff = {diff_energy})",
h.hydro_id,
h.incremental_inflow_energy_mw,
);
let expected_earm = (h.storage_initial_hm3 - V_MIN)
* h.accumulated_productivity_mw_per_m3s
* ENERGY_FACTOR;
let diff_earm = (h.stored_energy_initial_mwh - expected_earm).abs();
assert!(
diff_earm <= TOL,
"D03 stage {stage} H{}: stored_energy_initial_mwh = {} (expected {expected_earm}, diff = {diff_earm})",
h.hydro_id,
h.stored_energy_initial_mwh,
);
}
}
let h0_s0 = scenario.stages[0]
.hydros
.iter()
.find(|h| h.hydro_id == 0)
.expect("D03: H0 missing from stage 0");
let h1_s0 = scenario.stages[0]
.hydros
.iter()
.find(|h| h.hydro_id == 1)
.expect("D03: H1 missing from stage 0");
let expected_earm_h0 = (D03_H0_V_INIT - V_MIN) * D03_H0_RHO_ACUM * ENERGY_FACTOR;
let diff_earm_h0 = (h0_s0.stored_energy_initial_mwh - expected_earm_h0).abs();
assert!(
diff_earm_h0 <= TOL,
"D03 stage 0 H0: stored_energy_initial_mwh = {} (expected {expected_earm_h0}, diff = {diff_earm_h0})",
h0_s0.stored_energy_initial_mwh,
);
let expected_earm_h1 = (D03_H1_V_INIT - V_MIN) * D03_H1_RHO_ACUM * ENERGY_FACTOR;
let diff_earm_h1 = (h1_s0.stored_energy_initial_mwh - expected_earm_h1).abs();
assert!(
diff_earm_h1 <= TOL,
"D03 stage 0 H1: stored_energy_initial_mwh = {} (expected {expected_earm_h1}, diff = {diff_earm_h1})",
h1_s0.stored_energy_initial_mwh,
);
}
}
pub const D08_EXPECTED_COST: f64 = 94_644_561_875.0 / 36_009.0;
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "slow: run with --features slow-tests"
)]
#[test]
fn d08_evaporation() {
let case_dir = Path::new("../../examples/deterministic/d08-evaporation");
let result = run_deterministic(case_dir);
assert_cost(result.final_lb, D08_EXPECTED_COST, 1e-4, "D08");
assert!(
result.iterations <= 10,
"D08: iterations={}",
result.iterations
);
assert!(
result.final_gap.abs() < 1e-6,
"D08: gap={:.2e}",
result.final_gap
);
assert!(
result.final_lb > D02_EXPECTED_COST,
"D08: cost {:.6} must exceed D02 cost {:.6}",
result.final_lb,
D02_EXPECTED_COST
);
}
pub const D09_EXPECTED_COST: f64 = 80_738_000.0;
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "slow: run with --features slow-tests"
)]
#[test]
fn d09_multi_deficit() {
let case_dir = Path::new("../../examples/deterministic/d09-multi-deficit");
let result = run_deterministic(case_dir);
assert_cost(result.final_lb, D09_EXPECTED_COST, 1e-6, "D09");
assert!(
result.iterations <= 10,
"D09: iterations={}",
result.iterations
);
assert!(
result.final_gap.abs() < 1e-6,
"D09: gap={:.2e}",
result.final_gap
);
}
pub const D10_EXPECTED_COST: f64 = 28_562_500.0 / 9.0;
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "slow: run with --features slow-tests"
)]
#[test]
fn d10_inflow_nonnegativity() {
let case_dir = Path::new("../../examples/deterministic/d10-inflow-nonnegativity");
let result = run_deterministic(case_dir);
assert_cost(result.final_lb, D10_EXPECTED_COST, 1e-4, "D10");
assert!(
result.iterations <= 10,
"D10: iterations={}",
result.iterations
);
assert!(
result.final_gap.abs() < 1e-6,
"D10: gap={:.2e}",
result.final_gap
);
assert!(
result.final_lb > D02_EXPECTED_COST,
"D10: cost {:.6} must exceed D02 cost {:.6}",
result.final_lb,
D02_EXPECTED_COST
);
}
pub const D11_WATER_WITHDRAWAL_EXPECTED_COST: f64 = 3_930_694_344.0 / 657.0;
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "slow: run with --features slow-tests"
)]
#[test]
fn d11_water_withdrawal() {
let case_dir = Path::new("../../examples/deterministic/d11-water-withdrawal");
let result = run_deterministic(case_dir);
assert_cost(
result.final_lb,
D11_WATER_WITHDRAWAL_EXPECTED_COST,
1e-4,
"D11",
);
assert!(
result.iterations <= 10,
"D11: iterations={}",
result.iterations
);
assert!(
result.final_gap.abs() < 1e-6,
"D11: gap={:.2e}",
result.final_gap
);
assert!(
result.final_lb > D02_EXPECTED_COST,
"D11: cost {:.6} must exceed D02 cost {:.6} (withdrawal increases thermal dispatch)",
result.final_lb,
D02_EXPECTED_COST
);
}
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "slow: run with --features slow-tests"
)]
#[test]
fn d11_warm_start_verification() {
let case_dir = Path::new("../../examples/deterministic/d02-single-hydro");
let (result, solver) = run_deterministic_with_solver(case_dir);
assert_cost(result.final_lb, D02_EXPECTED_COST, 1e-4, "D11");
assert!(
result.final_gap.abs() < 1e-6,
"D11: gap={:.2e}",
result.final_gap
);
let stats = solver.statistics();
assert_eq!(
stats.basis_consistency_failures, 0,
"D11: expected 0 basis rejections, got {}",
stats.basis_consistency_failures
);
}
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "slow: run with --features slow-tests"
)]
#[test]
fn d12_checkpoint_round_trip() {
let case_dir = Path::new("../../examples/deterministic/d02-single-hydro");
let config_path = case_dir.join("config.json");
let config = cobre_io::parse_config(&config_path).expect("config must parse");
let system = cobre_io::load_case(case_dir).expect("load_case must succeed");
let pr = prepare_stochastic(system, case_dir, &config, 42, &ScenarioSource::default())
.expect("prepare_stochastic must succeed");
let system = pr.system;
let stochastic = pr.stochastic;
let hydro_models =
prepare_hydro_models(&system, case_dir, false).expect("prepare_hydro_models must succeed");
let mut config_with_sim = config.clone();
config_with_sim.simulation.enabled = true;
config_with_sim.simulation.num_scenarios = 1;
let mut setup = StudySetup::new(&system, &config_with_sim, stochastic, hydro_models)
.expect("StudySetup must build");
let comm = StubComm;
let mut solver = ActiveSolver::new().expect("ActiveSolver::new must succeed");
let outcome = setup
.train(&mut solver, &comm, 1, ActiveSolver::new, None, None)
.expect("train must return Ok");
assert!(outcome.error.is_none(), "expected no training error");
let result = outcome.result;
assert_cost(result.final_lb, D02_EXPECTED_COST, 1e-4, "D12-train");
assert!(
result.final_gap.abs() < 1e-6,
"D12: gap={:.2e}",
result.final_gap
);
let _training_output = setup.build_training_output(&result, &[]);
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let policy_dir = tmp.path().join("policy");
let fcf = &setup.fcf;
let cut_records_per_stage: Vec<Vec<PolicyCutRecord<'_>>> = fcf
.pools
.iter()
.map(|pool| {
(0..pool.populated_count)
.map(|slot| {
let meta = &pool.metadata[slot];
PolicyCutRecord {
cut_id: slot as u64,
slot_index: slot as u32,
iteration: meta.iteration_generated as u32,
forward_pass_index: meta.forward_pass_index,
intercept: pool.intercepts[slot],
coefficients: &pool.coefficients
[slot * pool.state_dimension..(slot + 1) * pool.state_dimension],
is_active: pool.active[slot],
}
})
.collect()
})
.collect();
let active_indices_per_stage: Vec<Vec<u32>> = fcf
.pools
.iter()
.map(|pool| {
(0..pool.populated_count)
.filter(|&slot| pool.active[slot])
.map(|slot| slot as u32)
.collect()
})
.collect();
let stage_cuts_payloads: Vec<StageCutsPayload<'_>> = fcf
.pools
.iter()
.enumerate()
.map(|(stage_idx, pool)| StageCutsPayload {
stage_id: stage_idx as u32,
state_dimension: pool.state_dimension as u32,
capacity: pool.capacity as u32,
warm_start_count: pool.warm_start_count,
cuts: &cut_records_per_stage[stage_idx],
active_cut_indices: &active_indices_per_stage[stage_idx],
populated_count: pool.populated_count as u32,
})
.collect();
let n_stages = fcf.pools.len();
let warm_start_counts: Vec<u32> = fcf.pools.iter().map(|p| p.warm_start_count).collect();
let policy_metadata = PolicyCheckpointMetadata {
cobre_version: env!("CARGO_PKG_VERSION").to_string(),
created_at: "2026-03-16T00:00:00Z".to_string(),
completed_iterations: result.iterations as u32,
final_lower_bound: result.final_lb,
best_upper_bound: Some(result.final_ub),
state_dimension: fcf.state_dimension as u32,
num_stages: n_stages as u32,
max_iterations: 100,
forward_passes: 1,
warm_start_cuts: warm_start_counts.iter().copied().max().unwrap_or(0),
warm_start_counts,
rng_seed: 42,
total_visited_states: 0,
};
write_policy_checkpoint(
&policy_dir,
&stage_cuts_payloads,
&[],
&policy_metadata,
&[],
)
.expect("write_policy_checkpoint must succeed");
let checkpoint =
cobre_io::read_policy_checkpoint(&policy_dir).expect("read_policy_checkpoint must succeed");
assert_eq!(
checkpoint.metadata.num_stages, 2,
"D12: checkpoint must have 2 stages"
);
assert_eq!(
checkpoint.metadata.state_dimension, 1,
"D12: checkpoint must have state_dimension == 1 (one hydro = one storage state)"
);
assert!(
!checkpoint.stage_cuts.is_empty(),
"D12: checkpoint must contain at least one stage_cuts entry"
);
let metadata_path = policy_dir.join("metadata.json");
assert!(metadata_path.is_file(), "D12: metadata.json must exist");
let stage_bin_path = policy_dir.join("cuts/stage_000.bin");
assert!(
stage_bin_path.is_file(),
"D12: cuts/stage_000.bin must exist"
);
let mut pool = setup
.create_workspace_pool(&comm, 1, ActiveSolver::new)
.expect("simulation workspace pool must build");
let io_capacity = setup.simulation_config.io_channel_capacity.max(1);
let (result_tx, result_rx) = mpsc::sync_channel(io_capacity);
let drain_handle = std::thread::spawn(move || result_rx.into_iter().collect::<Vec<_>>());
let local_costs = setup
.simulate(
&mut pool.workspaces,
&comm,
&result_tx,
None,
result.baked_templates.as_deref(),
&result.basis_cache,
)
.expect("simulate must return Ok");
drop(result_tx);
let _scenario_results = drain_handle.join().expect("drain thread must not panic");
let sim_config = setup.simulation_config();
let summary = aggregate_simulation(&local_costs.costs, sim_config, &comm)
.expect("aggregate_simulation must succeed");
assert_eq!(
summary.n_scenarios, 1,
"D12: simulation must produce exactly 1 scenario"
);
assert_cost(summary.mean_cost, D02_EXPECTED_COST, 1e-2, "D12-sim");
}
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "slow: run with --features slow-tests"
)]
#[test]
fn d13_generic_constraint() {
use arrow::array::{Float64Array, Int32Array};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use parquet::arrow::ArrowWriter;
use std::sync::Arc;
let case_dir = Path::new("../../examples/deterministic/d13-generic-constraint");
let constraints_dir = case_dir.join("constraints");
std::fs::create_dir_all(&constraints_dir).expect("create constraints dir");
let schema = Arc::new(Schema::new(vec![
Field::new("constraint_id", DataType::Int32, false),
Field::new("stage_id", DataType::Int32, false),
Field::new("block_id", DataType::Int32, true),
Field::new("bound", DataType::Float64, false),
]));
let batch = RecordBatch::try_new(
Arc::clone(&schema),
vec![
Arc::new(Int32Array::from(vec![1, 1])), Arc::new(Int32Array::from(vec![0, 1])), Arc::new(Int32Array::new_null(2)), Arc::new(Float64Array::from(vec![10.0, 10.0])), ],
)
.expect("RecordBatch");
let bounds_path = constraints_dir.join("generic_constraint_bounds.parquet");
let file = std::fs::File::create(&bounds_path).expect("create parquet file");
let mut writer = ArrowWriter::try_new(file, schema, None).expect("ArrowWriter");
writer.write(&batch).expect("write batch");
writer.close().expect("close writer");
let result = run_deterministic(case_dir);
assert_cost(result.final_lb, 15_330_000.0, 1e-2, "D13");
assert!(
result.iterations <= 10,
"D13: iterations={}",
result.iterations
);
assert!(
result.final_gap.abs() < 1e-4,
"D13: gap={:.2e}",
result.final_gap
);
}
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "slow: run with --features slow-tests"
)]
#[test]
fn d14_block_factors() {
use arrow::array::{Float64Array, Int32Array};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use parquet::arrow::ArrowWriter;
use std::sync::Arc;
let case_dir = Path::new("../../examples/deterministic/d14-block-factors");
let scenarios_dir = case_dir.join("scenarios");
std::fs::create_dir_all(&scenarios_dir).expect("create scenarios dir");
let load_schema = Arc::new(Schema::new(vec![
Field::new("bus_id", DataType::Int32, false),
Field::new("stage_id", DataType::Int32, false),
Field::new("mean_mw", DataType::Float64, false),
Field::new("std_mw", DataType::Float64, false),
]));
let load_batch = RecordBatch::try_new(
Arc::clone(&load_schema),
vec![
Arc::new(Int32Array::from(vec![0, 0])),
Arc::new(Int32Array::from(vec![0, 1])),
Arc::new(Float64Array::from(vec![20.0, 20.0])),
Arc::new(Float64Array::from(vec![0.0, 0.0])),
],
)
.expect("load RecordBatch");
let load_path = scenarios_dir.join("load_seasonal_stats.parquet");
let file = std::fs::File::create(&load_path).expect("create load parquet");
let mut writer = ArrowWriter::try_new(file, load_schema, None).expect("ArrowWriter");
writer.write(&load_batch).expect("write load batch");
writer.close().expect("close load writer");
let inflow_schema = Arc::new(Schema::new(vec![
Field::new("hydro_id", DataType::Int32, false),
Field::new("stage_id", DataType::Int32, false),
Field::new("mean_m3s", DataType::Float64, false),
Field::new("std_m3s", DataType::Float64, false),
]));
let inflow_batch = RecordBatch::new_empty(Arc::clone(&inflow_schema));
let inflow_path = scenarios_dir.join("inflow_seasonal_stats.parquet");
let file = std::fs::File::create(&inflow_path).expect("create inflow parquet");
let mut writer = ArrowWriter::try_new(file, inflow_schema, None).expect("ArrowWriter");
writer.write(&inflow_batch).expect("write inflow batch");
writer.close().expect("close inflow writer");
let result = run_deterministic(case_dir);
assert_cost(result.final_lb, 176_900.0, 1e-4, "D14");
assert!(
result.iterations <= 10,
"D14: iterations={}",
result.iterations
);
assert!(
result.final_gap.abs() < 1e-6,
"D14: gap={:.2e}",
result.final_gap
);
}
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "slow: run with --features slow-tests"
)]
#[test]
fn d15_non_controllable_source() {
use arrow::array::{Float64Array, Int32Array};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use parquet::arrow::ArrowWriter;
use std::sync::Arc;
let case_dir = Path::new("../../examples/deterministic/d15-non-controllable-source");
let scenarios_dir = case_dir.join("scenarios");
std::fs::create_dir_all(&scenarios_dir).expect("create scenarios dir");
let load_schema = Arc::new(Schema::new(vec![
Field::new("bus_id", DataType::Int32, false),
Field::new("stage_id", DataType::Int32, false),
Field::new("mean_mw", DataType::Float64, false),
Field::new("std_mw", DataType::Float64, false),
]));
let load_batch = RecordBatch::try_new(
Arc::clone(&load_schema),
vec![
Arc::new(Int32Array::from(vec![0, 0])),
Arc::new(Int32Array::from(vec![0, 1])),
Arc::new(Float64Array::from(vec![80.0, 80.0])),
Arc::new(Float64Array::from(vec![0.0, 0.0])),
],
)
.expect("load RecordBatch");
let load_path = scenarios_dir.join("load_seasonal_stats.parquet");
let file = std::fs::File::create(&load_path).expect("create load parquet");
let mut writer = ArrowWriter::try_new(file, load_schema, None).expect("ArrowWriter");
writer.write(&load_batch).expect("write load batch");
writer.close().expect("close load writer");
let inflow_schema = Arc::new(Schema::new(vec![
Field::new("hydro_id", DataType::Int32, false),
Field::new("stage_id", DataType::Int32, false),
Field::new("mean_m3s", DataType::Float64, false),
Field::new("std_m3s", DataType::Float64, false),
]));
let inflow_batch = RecordBatch::new_empty(Arc::clone(&inflow_schema));
let inflow_path = scenarios_dir.join("inflow_seasonal_stats.parquet");
let file = std::fs::File::create(&inflow_path).expect("create inflow parquet");
let mut writer = ArrowWriter::try_new(file, inflow_schema, None).expect("ArrowWriter");
writer.write(&inflow_batch).expect("write inflow batch");
writer.close().expect("close inflow writer");
let ncs_schema = Arc::new(Schema::new(vec![
Field::new("ncs_id", DataType::Int32, false),
Field::new("stage_id", DataType::Int32, false),
Field::new("mean", DataType::Float64, false),
Field::new("std", DataType::Float64, false),
]));
let ncs_batch = RecordBatch::try_new(
Arc::clone(&ncs_schema),
vec![
Arc::new(Int32Array::from(vec![0, 0])),
Arc::new(Int32Array::from(vec![0, 1])),
Arc::new(Float64Array::from(vec![0.5, 0.5])),
Arc::new(Float64Array::from(vec![0.0, 0.0])),
],
)
.expect("non_controllable_stats RecordBatch");
let ncs_path = scenarios_dir.join("non_controllable_stats.parquet");
let file = std::fs::File::create(&ncs_path).expect("create non_controllable_stats parquet");
let mut writer = ArrowWriter::try_new(file, ncs_schema, None).expect("ArrowWriter");
writer
.write(&ncs_batch)
.expect("write non_controllable_stats batch");
writer.close().expect("close non_controllable_stats writer");
let result = run_deterministic(case_dir);
assert_cost(result.final_lb, 437_927.0, 1e-2, "D15");
assert!(
result.iterations <= 10,
"D15: iterations={}",
result.iterations
);
assert!(
result.final_gap.abs() < 1e-4,
"D15: gap={:.2e}",
result.final_gap
);
}
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "slow: run with --features slow-tests"
)]
#[test]
fn d16_par1_lag_shift() {
let case_dir = Path::new("../../examples/deterministic/d16-par1-lag-shift");
let result = run_deterministic(case_dir);
assert!(
result.final_lb > 0.0,
"D16: lower bound must be positive, got {}",
result.final_lb
);
assert_cost(result.final_lb, 7_759_078.749_993_78, 1.0, "D16");
}
#[test]
fn model_persistence_regression_d01() {
use cobre_solver::SolverInterface;
let case_dir = Path::new("../../examples/deterministic/d01-thermal-dispatch");
let (result, solver) = run_deterministic_with_solver(case_dir);
assert_cost(result.final_lb, 182_500.0, 1e-6, "D01-persistence");
let stats = solver.statistics();
let n_stages = 2_u64;
let forward_passes = 2_u64;
let iterations = result.iterations;
let without_persistence_forward = n_stages * forward_passes * iterations;
let with_persistence_forward = n_stages * iterations;
assert!(
stats.load_model_count < without_persistence_forward,
"model persistence regression: load_model_count ({}) should be < {} (per-scenario forward-only count), \
expected ~{} for persisted forward",
stats.load_model_count,
without_persistence_forward,
with_persistence_forward
);
}
#[test]
fn incremental_lb_reduces_load_model_count() {
use cobre_solver::SolverInterface;
let case_dir = Path::new("../../examples/deterministic/d03-two-hydro-cascade");
let (result, solver) = run_deterministic_with_solver(case_dir);
assert_cost(result.final_lb, D03_EXPECTED_COST, 1e-4, "D03-incremental");
let stats = solver.statistics();
let n_stages = 3_u64;
let iterations = result.iterations;
let non_incremental_lb = iterations; let forward_count = n_stages * iterations;
let backward_count = (n_stages - 1) * iterations;
let total_without_incremental = forward_count + backward_count + non_incremental_lb;
assert!(
stats.load_model_count < total_without_incremental,
"incremental LB should reduce load_model_count: got {} >= {} (non-incremental total), \
iterations={}, n_stages={}",
stats.load_model_count,
total_without_incremental,
iterations,
n_stages
);
let expected_savings = iterations.saturating_sub(1);
let actual_savings = total_without_incremental - stats.load_model_count;
assert!(
actual_savings >= expected_savings,
"LB incremental savings should be >= {} (iterations - 1), got {} savings \
(total_without={}, actual={})",
expected_savings,
actual_savings,
total_without_incremental,
stats.load_model_count
);
}
#[test]
fn incremental_bit_for_bit_d01_trace() {
let case_dir = Path::new("../../examples/deterministic/d01-thermal-dispatch");
let (result, _solver) = run_deterministic_with_solver(case_dir);
assert_cost(result.final_lb, 182_500.0, 1e-6, "D01-trace");
assert!(
result.final_gap.abs() < 1e-6,
"D01-trace: gap={:.2e} should be < 1e-6",
result.final_gap
);
}
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "slow: run with --features slow-tests"
)]
#[test]
fn d19_multi_hydro_par_truncation() {
let case_dir = Path::new("../../examples/deterministic/d19-multi-hydro-par");
let result = run_deterministic(case_dir);
assert!(
result.final_lb > 0.0,
"D19: lower bound must be positive, got {}",
result.final_lb
);
assert_cost(result.final_lb, D19_EXPECTED_COST, 1.0, "D19");
}
pub const D19_EXPECTED_COST: f64 = 1_334_655.175_543_562_7;
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "slow: run with --features slow-tests"
)]
#[test]
fn d20_operational_violations() {
let case_dir = Path::new("../../examples/deterministic/d20-operational-violations");
let (result, scenario_results, summary) = run_with_simulation(case_dir);
assert!(
result.iterations <= 20,
"D20: iterations={} (expected <= 20)",
result.iterations
);
assert!(
result.final_gap.abs() < 1e-6,
"D20: gap={:.2e} (expected < 1e-6)",
result.final_gap
);
assert_cost(result.final_lb, D20_EXPECTED_COST, 1e-2, "D20");
assert_eq!(summary.n_scenarios, 1);
assert_cost(summary.mean_cost, D20_EXPECTED_COST, 1e-2, "D20-sim");
assert_eq!(scenario_results.len(), 1);
let scenario = &scenario_results[0];
assert_eq!(scenario.stages.len(), 2);
let mut found_outflow_below = false;
let mut found_turbine_below = false;
for stage_result in &scenario.stages {
for hydro_result in &stage_result.hydros {
if hydro_result.outflow_slack_below_m3s > 1e-10 {
found_outflow_below = true;
}
if hydro_result.turbined_slack_m3s > 1e-10 {
found_turbine_below = true;
}
}
}
assert!(
found_outflow_below,
"D20: expected non-zero outflow_slack_below_m3s"
);
assert!(
found_turbine_below,
"D20: expected non-zero turbined_slack_m3s"
);
}
pub const D20_EXPECTED_COST: f64 = 195_744_837.222_222_24;
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "slow: run with --features slow-tests"
)]
#[test]
fn d21_min_outflow_regression() {
use arrow::array::{Float64Array, Int32Array};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use parquet::arrow::ArrowWriter;
use std::sync::Arc;
let case_dir = Path::new("../../examples/deterministic/d21-min-outflow-regression");
let scenarios_dir = case_dir.join("scenarios");
std::fs::create_dir_all(&scenarios_dir).expect("create scenarios dir");
let load_schema = Arc::new(Schema::new(vec![
Field::new("bus_id", DataType::Int32, false),
Field::new("stage_id", DataType::Int32, false),
Field::new("mean_mw", DataType::Float64, false),
Field::new("std_mw", DataType::Float64, false),
]));
let load_batch = RecordBatch::try_new(
Arc::clone(&load_schema),
vec![
Arc::new(Int32Array::from(vec![0, 0])),
Arc::new(Int32Array::from(vec![0, 1])),
Arc::new(Float64Array::from(vec![20.0, 20.0])),
Arc::new(Float64Array::from(vec![0.0, 0.0])),
],
)
.expect("load RecordBatch");
let file = std::fs::File::create(scenarios_dir.join("load_seasonal_stats.parquet"))
.expect("create load parquet");
let mut writer = ArrowWriter::try_new(file, load_schema, None).expect("ArrowWriter");
writer.write(&load_batch).expect("write load batch");
writer.close().expect("close load writer");
let inflow_schema = Arc::new(Schema::new(vec![
Field::new("hydro_id", DataType::Int32, false),
Field::new("stage_id", DataType::Int32, false),
Field::new("mean_m3s", DataType::Float64, false),
Field::new("std_m3s", DataType::Float64, false),
]));
let inflow_batch = RecordBatch::try_new(
Arc::clone(&inflow_schema),
vec![
Arc::new(Int32Array::from(vec![0, 0])),
Arc::new(Int32Array::from(vec![0, 1])),
Arc::new(Float64Array::from(vec![10.0, 10.0])),
Arc::new(Float64Array::from(vec![0.0, 0.0])),
],
)
.expect("inflow RecordBatch");
let file = std::fs::File::create(scenarios_dir.join("inflow_seasonal_stats.parquet"))
.expect("create inflow parquet");
let mut writer = ArrowWriter::try_new(file, inflow_schema, None).expect("ArrowWriter");
writer.write(&inflow_batch).expect("write inflow batch");
writer.close().expect("close inflow writer");
let (result, scenario_results, summary) = run_with_simulation(case_dir);
assert!(
result.iterations <= 20,
"D21: iterations={} (expected <= 20)",
result.iterations
);
assert!(
result.final_gap.abs() < 1e-6,
"D21: gap={:.2e} (expected < 1e-6)",
result.final_gap
);
assert_cost(result.final_lb, D21_EXPECTED_COST, 1e-2, "D21");
assert_eq!(summary.n_scenarios, 1);
assert_cost(
summary.mean_cost,
result.final_lb,
1e-2,
"D21-sim-vs-training",
);
assert_eq!(scenario_results.len(), 1);
let scenario = &scenario_results[0];
assert_eq!(scenario.stages.len(), 2);
let found_outflow_below = scenario
.stages
.iter()
.flat_map(|s| &s.hydros)
.any(|h| h.outflow_slack_below_m3s > 1e-10);
assert!(
found_outflow_below,
"D21: expected non-zero outflow_slack_below_m3s"
);
let penalty = 5000.0_f64;
let hours = 730.0_f64;
let mut total_hydro_violation_cost = 0.0;
for (s, stage_result) in scenario.stages.iter().enumerate() {
assert_eq!(stage_result.hydros.len(), 1);
assert_eq!(stage_result.costs.len(), 1);
let slack_m3s = stage_result.hydros[0].outflow_slack_below_m3s;
let stage_violation_cost = stage_result.costs[0].hydro_violation_cost;
total_hydro_violation_cost += stage_violation_cost;
if slack_m3s > 1e-10 {
let expected_cost = slack_m3s * penalty * hours;
let cost_diff = (stage_violation_cost - expected_cost).abs();
assert!(
cost_diff < 1e-2,
"D21 stage {s}: hydro_violation_cost={stage_violation_cost}, \
expected={expected_cost}, diff={cost_diff}"
);
}
}
assert!(
total_hydro_violation_cost > 0.0,
"D21: hydro_violation_cost must be positive"
);
for (s, stage_result) in scenario.stages.iter().enumerate() {
let cost = &stage_result.costs[0];
let component_sum = cost.outflow_violation_below_cost
+ cost.outflow_violation_above_cost
+ cost.turbined_violation_cost
+ cost.generation_violation_cost
+ cost.evaporation_violation_cost
+ cost.withdrawal_violation_cost;
assert!(
(cost.hydro_violation_cost - component_sum).abs() < 1e-6,
"D21 stage {s}: sum invariant failed: hydro_violation_cost={}, component_sum={}",
cost.hydro_violation_cost,
component_sum
);
assert!(
cost.outflow_violation_above_cost.abs() < 1e-10,
"D21 stage {s}: outflow_above should be 0, got {}",
cost.outflow_violation_above_cost
);
assert!(
cost.turbined_violation_cost.abs() < 1e-10,
"D21 stage {s}: turbined should be 0, got {}",
cost.turbined_violation_cost
);
assert!(
cost.generation_violation_cost.abs() < 1e-10,
"D21 stage {s}: generation should be 0, got {}",
cost.generation_violation_cost
);
assert!(
cost.evaporation_violation_cost.abs() < 1e-10,
"D21 stage {s}: evaporation should be 0, got {}",
cost.evaporation_violation_cost
);
assert!(
cost.withdrawal_violation_cost.abs() < 1e-10,
"D21 stage {s}: withdrawal should be 0, got {}",
cost.withdrawal_violation_cost
);
let slack_m3s = stage_result.hydros[0].outflow_slack_below_m3s;
if slack_m3s > 1e-10 {
let expected_below_cost = slack_m3s * penalty * hours;
assert!(
(cost.outflow_violation_below_cost - expected_below_cost).abs() < 1e-2,
"D21 stage {s}: outflow_violation_below_cost={}, expected={}",
cost.outflow_violation_below_cost,
expected_below_cost
);
}
}
let found_below_cost = scenario
.stages
.iter()
.flat_map(|s| &s.costs)
.any(|c| c.outflow_violation_below_cost > 1e-10);
assert!(
found_below_cost,
"D21: expected non-zero outflow_violation_below_cost in at least one stage"
);
}
pub const D21_EXPECTED_COST: f64 = 285_716_271.0;
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "slow: run with --features slow-tests"
)]
#[test]
fn d22_per_block_min_outflow() {
use arrow::array::{Float64Array, Int32Array};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use parquet::arrow::ArrowWriter;
use std::sync::Arc;
let case_dir = Path::new("../../examples/deterministic/d22-per-block-min-outflow");
let scenarios_dir = case_dir.join("scenarios");
std::fs::create_dir_all(&scenarios_dir).expect("create scenarios dir");
let load_schema = Arc::new(Schema::new(vec![
Field::new("bus_id", DataType::Int32, false),
Field::new("stage_id", DataType::Int32, false),
Field::new("mean_mw", DataType::Float64, false),
Field::new("std_mw", DataType::Float64, false),
]));
let load_batch = RecordBatch::try_new(
Arc::clone(&load_schema),
vec![
Arc::new(Int32Array::from(vec![0, 0])),
Arc::new(Int32Array::from(vec![0, 1])),
Arc::new(Float64Array::from(vec![20.0, 20.0])),
Arc::new(Float64Array::from(vec![0.0, 0.0])),
],
)
.expect("load RecordBatch");
let file = std::fs::File::create(scenarios_dir.join("load_seasonal_stats.parquet"))
.expect("create load parquet");
let mut writer = ArrowWriter::try_new(file, load_schema, None).expect("ArrowWriter");
writer.write(&load_batch).expect("write load batch");
writer.close().expect("close load writer");
let inflow_schema = Arc::new(Schema::new(vec![
Field::new("hydro_id", DataType::Int32, false),
Field::new("stage_id", DataType::Int32, false),
Field::new("mean_m3s", DataType::Float64, false),
Field::new("std_m3s", DataType::Float64, false),
]));
let inflow_batch = RecordBatch::try_new(
Arc::clone(&inflow_schema),
vec![
Arc::new(Int32Array::from(vec![0, 0])),
Arc::new(Int32Array::from(vec![0, 1])),
Arc::new(Float64Array::from(vec![10.0, 10.0])),
Arc::new(Float64Array::from(vec![0.0, 0.0])),
],
)
.expect("inflow RecordBatch");
let file = std::fs::File::create(scenarios_dir.join("inflow_seasonal_stats.parquet"))
.expect("create inflow parquet");
let mut writer = ArrowWriter::try_new(file, inflow_schema, None).expect("ArrowWriter");
writer.write(&inflow_batch).expect("write inflow batch");
writer.close().expect("close inflow writer");
let (result, scenario_results, summary) = run_with_simulation(case_dir);
assert!(
result.iterations <= 20,
"D22: iterations={} (expected <= 20)",
result.iterations
);
assert!(
result.final_gap.abs() < 1e-6,
"D22: gap={:.2e} (expected < 1e-6)",
result.final_gap
);
assert_cost(result.final_lb, D22_EXPECTED_COST, 1e-2, "D22");
assert_eq!(summary.n_scenarios, 1);
assert_cost(
summary.mean_cost,
result.final_lb,
1e-2,
"D22-sim-vs-training",
);
let scenario = &scenario_results[0];
let block_hours = [200.0_f64, 300.0, 230.0];
let penalty = 5000.0_f64;
for (s, stage_result) in scenario.stages.iter().enumerate() {
assert_eq!(
stage_result.hydros.len(),
3,
"D22 stage {s}: expected 3 per-block hydro rows"
);
for (b, hr) in stage_result.hydros.iter().enumerate() {
assert!(
hr.outflow_slack_below_m3s > 1e-6,
"D22 stage {s} block {b}: outflow_slack_below_m3s should be > 0, got {}",
hr.outflow_slack_below_m3s
);
}
assert_eq!(stage_result.costs.len(), 1);
let total_violation_cost = stage_result.costs[0].hydro_violation_cost;
let expected_total: f64 = stage_result
.hydros
.iter()
.enumerate()
.map(|(b, hr)| hr.outflow_slack_below_m3s * penalty * block_hours[b])
.sum();
assert!(
(total_violation_cost - expected_total).abs() < 1e-2,
"D22 stage {s}: hydro_violation_cost={total_violation_cost}, expected={expected_total}"
);
}
}
pub const D22_EXPECTED_COST: f64 = 140_376_826.555_555_58;
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "slow: run with --features slow-tests"
)]
#[test]
fn d23_bidirectional_withdrawal() {
use arrow::array::{Float64Array, Int32Array};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use parquet::arrow::ArrowWriter;
use std::sync::Arc;
let case_dir = Path::new("../../examples/deterministic/d23-bidirectional-withdrawal");
let scenarios_dir = case_dir.join("scenarios");
std::fs::create_dir_all(&scenarios_dir).expect("create scenarios dir");
let load_schema = Arc::new(Schema::new(vec![
Field::new("bus_id", DataType::Int32, false),
Field::new("stage_id", DataType::Int32, false),
Field::new("mean_mw", DataType::Float64, false),
Field::new("std_mw", DataType::Float64, false),
]));
let load_batch = RecordBatch::try_new(
Arc::clone(&load_schema),
vec![
Arc::new(Int32Array::from(vec![0, 0])),
Arc::new(Int32Array::from(vec![0, 1])),
Arc::new(Float64Array::from(vec![80.0, 80.0])),
Arc::new(Float64Array::from(vec![0.0, 0.0])),
],
)
.expect("load RecordBatch");
let file = std::fs::File::create(scenarios_dir.join("load_seasonal_stats.parquet"))
.expect("create load parquet");
let mut writer = ArrowWriter::try_new(file, load_schema, None).expect("ArrowWriter");
writer.write(&load_batch).expect("write load batch");
writer.close().expect("close load writer");
let inflow_schema = Arc::new(Schema::new(vec![
Field::new("hydro_id", DataType::Int32, false),
Field::new("stage_id", DataType::Int32, false),
Field::new("mean_m3s", DataType::Float64, false),
Field::new("std_m3s", DataType::Float64, false),
]));
let inflow_batch = RecordBatch::try_new(
Arc::clone(&inflow_schema),
vec![
Arc::new(Int32Array::from(vec![0, 0])),
Arc::new(Int32Array::from(vec![0, 1])),
Arc::new(Float64Array::from(vec![50.0, 50.0])),
Arc::new(Float64Array::from(vec![0.0, 0.0])),
],
)
.expect("inflow RecordBatch");
let file = std::fs::File::create(scenarios_dir.join("inflow_seasonal_stats.parquet"))
.expect("create inflow parquet");
let mut writer = ArrowWriter::try_new(file, inflow_schema, None).expect("ArrowWriter");
writer.write(&inflow_batch).expect("write inflow batch");
writer.close().expect("close inflow writer");
let constraints_dir = case_dir.join("constraints");
std::fs::create_dir_all(&constraints_dir).expect("create constraints dir");
let bounds_schema = Arc::new(Schema::new(vec![
Field::new("hydro_id", DataType::Int32, false),
Field::new("stage_id", DataType::Int32, false),
Field::new("water_withdrawal_m3s", DataType::Float64, false),
]));
let bounds_batch = RecordBatch::try_new(
Arc::clone(&bounds_schema),
vec![
Arc::new(Int32Array::from(vec![0, 0])),
Arc::new(Int32Array::from(vec![0, 1])),
Arc::new(Float64Array::from(vec![5.0, 5.0])),
],
)
.expect("bounds RecordBatch");
let file = std::fs::File::create(constraints_dir.join("hydro_bounds.parquet"))
.expect("create bounds parquet");
let mut writer = ArrowWriter::try_new(file, bounds_schema, None).expect("ArrowWriter");
writer.write(&bounds_batch).expect("write bounds batch");
writer.close().expect("close bounds writer");
let (result, scenario_results, _summary) = run_with_simulation(case_dir);
assert!(
result.iterations <= 20,
"D23: iterations={} (expected <= 20)",
result.iterations
);
assert!(
result.final_gap.abs() < 1e-6,
"D23: gap={:.2e} (expected < 1e-6)",
result.final_gap
);
assert_eq!(scenario_results.len(), 1);
let scenario = &scenario_results[0];
assert_eq!(scenario.stages.len(), 2);
let mut found_ww_pos = false;
for stage_result in &scenario.stages {
for hydro_result in &stage_result.hydros {
if hydro_result.water_withdrawal_violation_pos_m3s > 1e-10 {
found_ww_pos = true;
}
assert!(
hydro_result.water_withdrawal_violation_neg_m3s < 1e-10,
"D23: unexpected under-withdrawal violation: {}",
hydro_result.water_withdrawal_violation_neg_m3s
);
}
}
assert!(
found_ww_pos,
"D23: expected non-zero water_withdrawal_violation_pos_m3s (over-withdrawal)"
);
let kappa = 730.0 * 3600.0 / 1e6; let ww_target = 5.0;
let inflow = 50.0;
for (s, stage_result) in scenario.stages.iter().enumerate() {
assert_eq!(stage_result.hydros.len(), 1);
let h = &stage_result.hydros[0];
let net_flow = inflow - ww_target + h.water_withdrawal_violation_neg_m3s
- h.water_withdrawal_violation_pos_m3s
- h.turbined_m3s
- h.spillage_m3s;
let expected_v_out = h.storage_initial_hm3 + kappa * net_flow;
let diff = (h.storage_final_hm3 - expected_v_out).abs();
assert!(
diff < 1e-6,
"D23 stage {s}: water balance mismatch: V_out={}, expected={expected_v_out}, diff={diff}",
h.storage_final_hm3
);
}
}
fn assert_bus_balance(stage: &cobre_sddp::SimulationStageResult, tolerance: f64, label: &str) {
let mut block_ids: Vec<u32> = stage.buses.iter().filter_map(|b| b.block_id).collect();
block_ids.sort_unstable();
block_ids.dedup();
for &block_id in &block_ids {
let hydro_gen: f64 = stage
.hydros
.iter()
.filter(|h| h.block_id == Some(block_id))
.map(|h| h.generation_mw)
.sum();
let thermal_gen: f64 = stage
.thermals
.iter()
.filter(|t| t.block_id == Some(block_id))
.map(|t| t.generation_mw)
.sum();
let ncs_gen: f64 = stage
.non_controllables
.iter()
.filter(|n| n.block_id == Some(block_id))
.map(|n| n.generation_mw)
.sum();
let deficit: f64 = stage
.buses
.iter()
.filter(|b| b.block_id == Some(block_id))
.map(|b| b.deficit_mw)
.sum();
let excess: f64 = stage
.buses
.iter()
.filter(|b| b.block_id == Some(block_id))
.map(|b| b.excess_mw)
.sum();
let net_exchange: f64 = stage
.exchanges
.iter()
.filter(|e| e.block_id == Some(block_id))
.map(|e| e.direct_flow_mw - e.reverse_flow_mw)
.sum();
let load: f64 = stage
.buses
.iter()
.filter(|b| b.block_id == Some(block_id))
.map(|b| b.load_mw)
.sum();
let supply = hydro_gen + thermal_gen + ncs_gen + deficit - excess + net_exchange;
let mismatch = (supply - load).abs();
assert!(
mismatch < tolerance,
"{label} stage {} block {block_id}: bus balance mismatch: \
supply={supply:.6} (hydro={hydro_gen:.6} + thermal={thermal_gen:.6} \
+ ncs={ncs_gen:.6} + deficit={deficit:.6} - excess={excess:.6} \
+ exchange={net_exchange:.6}) vs load={load:.6}, diff={mismatch:.2e}",
stage.stage_id
);
}
}
pub const D24_EXPECTED_COST: f64 = 23_950_785.0 / 9.0;
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "slow: run with --features slow-tests"
)]
#[test]
fn d24_productivity_override() {
use arrow::array::{Float64Array, Int32Array};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use parquet::arrow::ArrowWriter;
use std::sync::Arc;
let case_dir = Path::new("../../examples/deterministic/d24-productivity-override");
let scenarios_dir = case_dir.join("scenarios");
std::fs::create_dir_all(&scenarios_dir).expect("create scenarios dir");
let inflow_schema = Arc::new(Schema::new(vec![
Field::new("hydro_id", DataType::Int32, false),
Field::new("stage_id", DataType::Int32, false),
Field::new("mean_m3s", DataType::Float64, false),
Field::new("std_m3s", DataType::Float64, false),
]));
let inflow_batch = RecordBatch::try_new(
Arc::clone(&inflow_schema),
vec![
Arc::new(Int32Array::from(vec![0, 0])),
Arc::new(Int32Array::from(vec![0, 1])),
Arc::new(Float64Array::from(vec![40.0, 10.0])),
Arc::new(Float64Array::from(vec![0.0, 0.0])),
],
)
.expect("inflow RecordBatch");
let file = std::fs::File::create(scenarios_dir.join("inflow_seasonal_stats.parquet"))
.expect("create inflow parquet");
let mut writer = ArrowWriter::try_new(file, inflow_schema, None).expect("ArrowWriter");
writer.write(&inflow_batch).expect("write inflow batch");
writer.close().expect("close inflow writer");
let load_schema = Arc::new(Schema::new(vec![
Field::new("bus_id", DataType::Int32, false),
Field::new("stage_id", DataType::Int32, false),
Field::new("mean_mw", DataType::Float64, false),
Field::new("std_mw", DataType::Float64, false),
]));
let load_batch = RecordBatch::try_new(
Arc::clone(&load_schema),
vec![
Arc::new(Int32Array::from(vec![0, 0])),
Arc::new(Int32Array::from(vec![0, 1])),
Arc::new(Float64Array::from(vec![80.0, 80.0])),
Arc::new(Float64Array::from(vec![0.0, 0.0])),
],
)
.expect("load RecordBatch");
let file = std::fs::File::create(scenarios_dir.join("load_seasonal_stats.parquet"))
.expect("create load parquet");
let mut writer = ArrowWriter::try_new(file, load_schema, None).expect("ArrowWriter");
writer.write(&load_batch).expect("write load batch");
writer.close().expect("close load writer");
let (result, scenario_results, _summary) = run_with_simulation(case_dir);
assert_cost(result.final_lb, D24_EXPECTED_COST, 1e-4, "D24");
assert!(
result.iterations <= 10,
"D24: iterations={}",
result.iterations
);
assert!(
result.final_gap.abs() < 1e-6,
"D24: gap={:.2e}",
result.final_gap
);
assert!(
(result.final_lb - D02_EXPECTED_COST).abs() > 1.0,
"D24: cost must differ from D02 (per-stage overrides change economics)"
);
assert_eq!(
scenario_results.len(),
1,
"D24: expected 1 simulation scenario"
);
for stage in &scenario_results[0].stages {
assert_bus_balance(stage, 1e-3, "D24");
}
}
const D25_EXPECTED_COST: f64 = 2_612_094.703_543_594_6;
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "slow: run with --features slow-tests"
)]
#[test]
fn d25_discount_rate() {
let case_dir = Path::new("../../examples/deterministic/d25-discount-rate");
let result = run_deterministic(case_dir);
assert_cost(result.final_lb, D25_EXPECTED_COST, 1e-4, "D25");
assert!(
result.iterations <= 10,
"D25: iterations={}",
result.iterations
);
assert!(
result.final_gap.abs() < 1e-6,
"D25: gap={:.2e}",
result.final_gap
);
assert!(
result.final_lb < D02_EXPECTED_COST,
"D25: discounted LB ({}) must be < undiscounted D02 LB ({})",
result.final_lb,
D02_EXPECTED_COST,
);
}
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "slow: run with --features slow-tests"
)]
#[test]
fn d25_simulation_discount_factors() {
let case_dir = Path::new("../../examples/deterministic/d25-discount-rate");
let (result, scenarios, _summary) = run_with_simulation(case_dir);
assert_cost(result.final_lb, D25_EXPECTED_COST, 1e-4, "D25-sim");
assert_eq!(scenarios.len(), 1, "D25: expected 1 simulation scenario");
let stages = &scenarios[0].stages;
assert_eq!(stages.len(), 2, "D25: expected 2 stages");
let df0 = stages[0].costs[0].discount_factor;
assert!(
(df0 - 1.0).abs() < 1e-12,
"D25: stage 0 discount_factor expected 1.0, got {df0}"
);
let d0 = 1.0_f64 / 1.12_f64.powf(31.0 / 365.25);
let df1 = stages[1].costs[0].discount_factor;
assert!(
(df1 - d0).abs() < 1e-10,
"D25: stage 1 discount_factor expected {d0}, got {df1}"
);
}
pub const D26_EXPECTED_COST: f64 = 50_625_314.970_196_81;
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "slow: run with --features slow-tests"
)]
#[test]
fn d26_estimated_par2() {
let case_dir = Path::new("../../examples/deterministic/d26-estimated-par2");
let result = run_deterministic(case_dir);
assert!(
result.final_lb > 0.0,
"D26: lower bound must be positive, got {}",
result.final_lb
);
assert_cost(result.final_lb, D26_EXPECTED_COST, 1.0, "D26");
assert!(
result.iterations <= 100,
"D26: must converge within 100 iterations, got {}",
result.iterations
);
}
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "slow: run with --features slow-tests"
)]
#[test]
fn d26_estimated_par2_order_selection() {
use cobre_sddp::prepare_stochastic;
let case_dir = Path::new("../../examples/deterministic/d26-estimated-par2");
let config_path = case_dir.join("config.json");
let config = cobre_io::parse_config(&config_path).expect("config must parse");
let system = cobre_io::load_case(case_dir).expect("load_case must succeed");
let prepare_result =
prepare_stochastic(system, case_dir, &config, 42, &ScenarioSource::default())
.expect("prepare_stochastic must succeed");
let report = prepare_result
.estimation_report
.expect("estimation report must be Some");
assert_eq!(report.entries.len(), 1, "expected 1 hydro entry");
let (hydro_id, entry) = report.entries.iter().next().expect("entry exists");
assert_eq!(
entry.selected_order, 2,
"expected AR order 2 for hydro {hydro_id}, got {}",
entry.selected_order
);
}
pub const D27_EXPECTED_COST: f64 = 10_950_000.0;
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "slow: run with --features slow-tests"
)]
#[test]
fn d27_per_stage_thermal_cost() {
let case_dir = Path::new("../../examples/deterministic/d27-per-stage-thermal-cost");
let result = run_deterministic(case_dir);
assert_cost(result.final_lb, D27_EXPECTED_COST, 1e-4, "D27");
assert!(
result.iterations <= 10,
"D27: iterations={}",
result.iterations
);
assert!(
result.final_gap.abs() < 1e-6,
"D27: gap={:.2e}",
result.final_gap
);
let uniform_baseline = 9_052_000.0_f64;
assert!(
result.final_lb > uniform_baseline,
"D27: per-stage cost override must increase total cost vs uniform baseline \
({} > {})",
result.final_lb,
uniform_baseline
);
}
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "slow: run with --features slow-tests"
)]
#[test]
fn d28_decomp_weekly_monthly_loads_and_trains() {
let case_dir = Path::new("../../examples/deterministic/d28-decomp-weekly-monthly");
let result = run_deterministic(case_dir);
assert!(
result.iterations > 0,
"D28: must complete at least 1 iteration"
);
}
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "slow: run with --features slow-tests"
)]
#[test]
fn d29_weekly_par_noise_sharing() {
let case_dir = Path::new("../../examples/deterministic/d29-weekly-par-noise-sharing");
let config_path = case_dir.join("config.json");
let config = cobre_io::parse_config(&config_path).expect("config must parse");
let training_source = config
.training_scenario_source(&config_path)
.expect("training_scenario_source must parse");
let system = cobre_io::load_case(case_dir).expect("load_case must succeed");
let pr = prepare_stochastic(system, case_dir, &config, 42, &training_source)
.expect("prepare_stochastic must succeed");
let system = pr.system;
let stochastic = pr.stochastic;
let hydro_models =
prepare_hydro_models(&system, case_dir, false).expect("prepare_hydro_models must succeed");
let mut setup =
StudySetup::new(&system, &config, stochastic, hydro_models).expect("StudySetup must build");
let groups = &setup.stage_data.noise_group_ids;
assert_eq!(groups.len(), 4, "expected 4 study stages");
assert!(
groups.iter().all(|&g| g == groups[0]),
"all weekly stages in the same month must share the same group ID, got {groups:?}"
);
let comm = StubComm;
let mut solver = ActiveSolver::new().expect("ActiveSolver::new must succeed");
let outcome = setup
.train(&mut solver, &comm, 1, ActiveSolver::new, None, None)
.expect("train must return Ok");
assert!(
outcome.error.is_none(),
"D29: expected no training error, got: {:?}",
outcome.error
);
let result = outcome.result;
assert!(
result.iterations > 0,
"D29: must complete at least 1 iteration"
);
assert!(
result.final_lb > 0.0,
"D29: lower bound must be positive, got {}",
result.final_lb
);
let mut pool = setup
.create_workspace_pool(&comm, 1, ActiveSolver::new)
.expect("simulation workspace pool must build");
let io_capacity = setup.simulation_config.io_channel_capacity.max(1);
let (result_tx, result_rx) = mpsc::sync_channel(io_capacity);
let drain_handle = std::thread::spawn(move || result_rx.into_iter().collect::<Vec<_>>());
let _local_costs = setup
.simulate(
&mut pool.workspaces,
&comm,
&result_tx,
None,
result.baked_templates.as_deref(),
&result.basis_cache,
)
.expect("simulation must succeed");
drop(result_tx);
let scenario_results = drain_handle.join().expect("drain thread must not panic");
assert_eq!(
scenario_results.len(),
1,
"D29: expected 1 simulation scenario result"
);
}
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "slow: run with --features slow-tests"
)]
#[test]
fn d30_multi_resolution_loads_and_trains() {
let case_dir = Path::new("../../examples/deterministic/d30-multi-resolution-monthly-quarterly");
let config_path = case_dir.join("config.json");
let config = cobre_io::parse_config(&config_path).expect("config must parse");
let training_source = config
.training_scenario_source(&config_path)
.expect("training_scenario_source must parse");
let system = cobre_io::load_case(case_dir).expect("load_case must succeed");
let pr = prepare_stochastic(system, case_dir, &config, 42, &training_source)
.expect("prepare_stochastic must succeed");
let system = pr.system;
let stochastic = pr.stochastic;
let hydro_models =
prepare_hydro_models(&system, case_dir, false).expect("prepare_hydro_models must succeed");
let mut setup =
StudySetup::new(&system, &config, stochastic, hydro_models).expect("StudySetup must build");
let comm = StubComm;
let mut solver = ActiveSolver::new().expect("ActiveSolver::new must succeed");
let outcome = setup
.train(&mut solver, &comm, 1, ActiveSolver::new, None, None)
.expect("train must return Ok");
assert!(
outcome.error.is_none(),
"D30: expected no training error, got: {:?}",
outcome.error
);
let result = outcome.result;
assert!(
result.iterations > 0,
"D30: must complete at least 1 iteration"
);
assert!(
result.final_lb > 0.0,
"D30: lower bound must be positive, got {}",
result.final_lb
);
}
#[test]
fn baked_vs_fallback_simulation_costs_are_identical() {
let case_dir = Path::new("../../examples/deterministic/d01-thermal-dispatch");
let config_path = case_dir.join("config.json");
let config = cobre_io::parse_config(&config_path).expect("config must parse");
let system = cobre_io::load_case(case_dir).expect("load_case must succeed");
let pr = prepare_stochastic(
system,
case_dir,
&config,
42,
&cobre_core::scenario::ScenarioSource::default(),
)
.expect("prepare_stochastic must succeed");
let system = pr.system;
let stochastic = pr.stochastic;
let hydro_models =
prepare_hydro_models(&system, case_dir, false).expect("prepare_hydro_models must succeed");
let mut config_with_sim = config.clone();
config_with_sim.simulation.enabled = true;
config_with_sim.simulation.num_scenarios = 4;
let mut setup = StudySetup::new(&system, &config_with_sim, stochastic, hydro_models)
.expect("StudySetup must build");
let comm = StubComm;
let mut solver = ActiveSolver::new().expect("ActiveSolver::new must succeed");
let outcome = setup
.train(&mut solver, &comm, 1, ActiveSolver::new, None, None)
.expect("train must return Ok");
assert!(outcome.error.is_none(), "expected no training error");
let training_result = outcome.result;
assert!(
training_result.baked_templates.is_some(),
"D01 training must produce baked templates (requires >= 2 iterations)"
);
let mut pool = setup
.create_workspace_pool(&comm, 1, ActiveSolver::new)
.expect("simulation workspace pool must build");
let io_capacity = setup.simulation_config.io_channel_capacity.max(1);
let (tx_baked, rx_baked) = mpsc::sync_channel(io_capacity);
let drain_baked = std::thread::spawn(move || rx_baked.into_iter().collect::<Vec<_>>());
let baked_run = setup
.simulate(
&mut pool.workspaces,
&comm,
&tx_baked,
None,
training_result.baked_templates.as_deref(),
&training_result.basis_cache,
)
.expect("baked-path simulate must return Ok");
drop(tx_baked);
drop(drain_baked.join().expect("drain thread must not panic"));
let (tx_fallback, rx_fallback) = mpsc::sync_channel(io_capacity);
let drain_fallback = std::thread::spawn(move || rx_fallback.into_iter().collect::<Vec<_>>());
let fallback_run = setup
.simulate(
&mut pool.workspaces,
&comm,
&tx_fallback,
None,
None, &training_result.basis_cache,
)
.expect("fallback-path simulate must return Ok");
drop(tx_fallback);
drop(drain_fallback.join().expect("drain thread must not panic"));
assert_eq!(
baked_run.costs.len(),
fallback_run.costs.len(),
"both runs must return the same number of scenarios"
);
for ((b_id, b_cost, _), (f_id, f_cost, _)) in
baked_run.costs.iter().zip(fallback_run.costs.iter())
{
assert_eq!(b_id, f_id, "scenario IDs must match between runs");
let rel_err = if b_cost.abs() > 1e-10 {
(b_cost - f_cost).abs() / b_cost.abs()
} else {
(b_cost - f_cost).abs()
};
assert!(
rel_err < 1e-12,
"scenario {b_id}: baked cost {b_cost} != fallback cost {f_cost} (rel_err={rel_err})"
);
}
}