#![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_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).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).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 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).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],
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 {
version: "1.0.0".to_string(),
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,
config_hash: "d12-config-hash".to_string(),
system_hash: "d12-system-hash".to_string(),
max_iterations: 100,
forward_passes: 1,
warm_start_cuts: 0,
rng_seed: 42,
};
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, 5_475_000.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
);
}