#![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::prepare_stochastic,
};
use cobre_solver::SolverInterface;
use cobre_solver::highs::HighsSolver;
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 run_deterministic_with_solver(case_dir: &Path) -> (cobre_sddp::TrainingResult, HighsSolver) {
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).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 = HighsSolver::new().expect("HighsSolver::new must succeed");
let outcome = setup
.train(&mut solver, &comm, 1, HighsSolver::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).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 = HighsSolver::new().expect("HighsSolver::new must succeed");
let outcome = setup
.train(&mut solver, &comm, 1, HighsSolver::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).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 = HighsSolver::new().expect("HighsSolver::new must succeed");
let outcome = setup
.train(&mut solver, &comm, 1, HighsSolver::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(1, HighsSolver::new)
.expect("simulation workspace pool must build");
let io_capacity = setup.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.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})"
);
}
pub const D02_EXPECTED_COST: f64 = 23_635_000.0 / 9.0;
#[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
);
}
#[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 = 4_171_000.0 / 3.0;
#[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_263_443_883.0 / 657.0;
#[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
);
}
#[test]
fn d05_fpha_constant_head() {
let case_dir = Path::new("../../examples/deterministic/d05-fpha-constant-head");
let result = run_deterministic(case_dir);
assert_cost(result.final_lb, D02_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;
#[test]
fn d06_fpha_variable_head() {
let case_dir = Path::new("../../examples/deterministic/d06-fpha-variable-head");
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)"
);
}
#[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
);
}
pub const D08_EXPECTED_COST: f64 = 94_644_561_875.0 / 36_009.0;
#[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;
#[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;
#[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_320_000.0 / 657.0;
#[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
);
}
#[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_rejections, 0,
"D11: expected 0 basis rejections, got {}",
stats.basis_rejections
);
}
#[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).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 = HighsSolver::new().expect("HighsSolver::new must succeed");
let outcome = setup
.train(&mut solver, &comm, 1, HighsSolver::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],
domination_count: meta.domination_count as u32,
}
})
.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 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: 0,
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(1, HighsSolver::new)
.expect("simulation workspace pool must build");
let io_capacity = setup.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.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");
}
#[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
);
}
#[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
);
}
#[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
);
}
#[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_756_250.0, 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
);
assert!(
stats.add_rows_count > 0,
"add_rows_count should be positive when cuts exist"
);
}
#[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_lb_add_rows_exceeds_load_model() {
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-add-rows");
let stats = solver.statistics();
if result.iterations > 1 {
assert!(
stats.add_rows_count > stats.load_model_count,
"with incremental LB, add_rows_count ({}) should exceed \
load_model_count ({}) when iterations ({}) > 1",
stats.add_rows_count,
stats.load_model_count,
result.iterations
);
}
}
#[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
);
}
#[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_332_425.292_764_49;
#[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_444.444_444_48;
#[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_111.111_111_1;
#[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_666.666_666_66;
#[test]
#[ignore = "requires external case data at ~/git/cobre-bridge/example/convertido2/"]
fn d20_convertido2_truncation_feasibility() {
let case_dir = Path::new(env!("HOME")).join("git/cobre-bridge/example/convertido2");
if !case_dir.exists() {
eprintln!("SKIP: convertido2 case not found at {}", case_dir.display());
return;
}
let result = run_deterministic(&case_dir);
assert!(
result.final_lb > 0.0,
"D20-convertido2: lower bound must be positive, got {}",
result.final_lb
);
assert!(
result.iterations >= 1,
"D20-convertido2: expected at least 1 iteration, got {}",
result.iterations
);
}
#[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_945_000.0 / 9.0;
#[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_611_454.584_787_283;
#[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,
);
}
#[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 = 47_721_588.894_912_5;
#[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
);
}
#[test]
fn d26_estimated_par2_order_selection() {
use cobre_sddp::setup::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
);
}