use std::ops::Range;
use cobre_comm::Communicator;
use cobre_solver::{RowBatch, SolverError, SolverInterface};
use cobre_stochastic::{OpeningTree, StochasticContext, evaluate_par_batch, solve_par_noise_batch};
use crate::{
FutureCostFunction, InflowNonNegativityMethod, PatchBuffer, RiskMeasure, SddpError,
StageIndexer,
forward::build_cut_row_batch_into,
lp_builder::COST_SCALE_FACTOR,
noise::{compute_effective_eta, transform_ncs_noise},
};
use cobre_solver::StageTemplate;
pub struct LbEvalSpec<'a> {
pub template: &'a StageTemplate,
pub base_row: usize,
pub noise_scale: &'a [f64],
pub n_hydros: usize,
pub opening_tree: &'a OpeningTree,
pub risk_measure: &'a RiskMeasure,
pub stochastic: Option<&'a StochasticContext>,
pub n_load_buses: usize,
pub ncs_max_gen: &'a [f64],
pub block_count: usize,
pub ncs_generation: Range<usize>,
pub inflow_method: &'a InflowNonNegativityMethod,
}
#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
pub fn evaluate_lower_bound<S: SolverInterface, C: Communicator>(
solver: &mut S,
fcf: &FutureCostFunction,
initial_state: &[f64],
indexer: &StageIndexer,
patch_buf: &mut PatchBuffer,
lb_cut_batch: &mut RowBatch,
spec: &LbEvalSpec<'_>,
comm: &C,
lb_cut_row_map: Option<&mut crate::cut::CutRowMap>,
) -> Result<f64, SddpError> {
let LbEvalSpec {
template,
base_row,
noise_scale,
n_hydros,
opening_tree,
risk_measure,
stochastic,
n_load_buses,
ncs_max_gen,
block_count,
ncs_generation,
inflow_method,
} = spec;
let (base_row, n_hydros, n_load_buses, block_count) =
(*base_row, *n_hydros, *n_load_buses, *block_count);
let mut lb = 0.0_f64;
if comm.rank() == 0 {
let n_openings = opening_tree.n_openings(0);
assert!(
n_openings > 0,
"evaluate_lower_bound: stage 0 must have at least one opening"
);
let mut objectives = Vec::with_capacity(n_openings);
let mut noise_buf = Vec::with_capacity(n_hydros);
let mut z_inflow_rhs_buf = Vec::with_capacity(n_hydros);
let mut ncs_col_upper_buf: Vec<f64> = Vec::new();
let mut ncs_col_indices_buf: Vec<usize> = Vec::new();
let mut ncs_col_lower_buf: Vec<f64> = Vec::new();
if let Some(stoch) = stochastic {
let n_stochastic_ncs = stoch.n_stochastic_ncs();
if n_stochastic_ncs > 0 && !ncs_generation.is_empty() {
for ncs_idx in 0..n_stochastic_ncs {
for blk in 0..block_count {
ncs_col_indices_buf
.push(ncs_generation.start + ncs_idx * block_count + blk);
ncs_col_lower_buf.push(0.0);
}
}
}
}
if let Some(row_map) = lb_cut_row_map {
if row_map.total_cut_rows() == 0 {
solver.load_model(template);
}
crate::forward::append_new_cuts_to_lp(
solver,
fcf,
0,
indexer,
&template.col_scale,
row_map,
lb_cut_batch,
);
} else {
build_cut_row_batch_into(lb_cut_batch, fcf, 0, indexer, &template.col_scale);
solver.load_model(template);
if lb_cut_batch.num_rows > 0 {
solver.add_rows(lb_cut_batch);
}
}
let needs_truncation = matches!(
inflow_method,
InflowNonNegativityMethod::Truncation
| InflowNonNegativityMethod::TruncationWithPenalty { .. }
);
let par_lp_opt = stochastic.map(StochasticContext::par);
let truncation_par = if needs_truncation {
par_lp_opt.filter(|p| p.n_stages() > 0 && p.n_hydros() == n_hydros)
} else {
None
};
let mut lag_matrix_buf = Vec::new();
let mut eta_floor_buf = Vec::new();
let mut par_inflow_buf = vec![0.0_f64; n_hydros];
let mut effective_eta_buf = Vec::with_capacity(n_hydros);
if let Some(par_lp) = truncation_par {
let max_order = indexer.max_par_order;
let lag_len = max_order * n_hydros;
lag_matrix_buf.resize(lag_len, 0.0);
for h in 0..n_hydros {
for l in 0..max_order {
lag_matrix_buf[l * n_hydros + h] =
initial_state[indexer.inflow_lags.start + l * n_hydros + h];
}
}
eta_floor_buf.resize(n_hydros, f64::NEG_INFINITY);
let zero_targets = vec![0.0_f64; n_hydros];
solve_par_noise_batch(
par_lp,
0,
&lag_matrix_buf,
&zero_targets,
&mut eta_floor_buf,
);
}
for opening_idx in 0..n_openings {
let raw_noise = opening_tree.opening(0, opening_idx);
noise_buf.clear();
z_inflow_rhs_buf.clear();
if let Some(par_lp) = truncation_par {
evaluate_par_batch(par_lp, 0, &lag_matrix_buf, raw_noise, &mut par_inflow_buf);
}
compute_effective_eta(
raw_noise,
n_hydros,
inflow_method,
&par_inflow_buf,
&eta_floor_buf,
&mut effective_eta_buf,
);
for (h, &eta_eff) in effective_eta_buf.iter().enumerate() {
noise_buf.push(template.row_lower[base_row + h] + noise_scale[h] * eta_eff);
if let Some(stoch) = stochastic {
let par_lp = stoch.par();
if par_lp.n_stages() > 0 && par_lp.n_hydros() == n_hydros {
let base = par_lp.deterministic_base(0, h);
let sigma = par_lp.sigma(0, h);
z_inflow_rhs_buf.push(base + sigma * eta_eff);
} else {
z_inflow_rhs_buf.push(0.0);
}
} else {
z_inflow_rhs_buf.push(0.0);
}
}
patch_buf.fill_forward_patches(
indexer,
initial_state,
&noise_buf,
base_row,
&template.row_scale,
);
patch_buf.fill_z_inflow_patches(
indexer.z_inflow_row_start,
&z_inflow_rhs_buf,
&template.row_scale,
);
let n_patches = patch_buf.forward_patch_count();
solver.set_row_bounds(
&patch_buf.indices[..n_patches],
&patch_buf.lower[..n_patches],
&patch_buf.upper[..n_patches],
);
if let Some(stoch) = stochastic {
let n_stochastic_ncs = stoch.n_stochastic_ncs();
if n_stochastic_ncs > 0 && !ncs_generation.is_empty() {
transform_ncs_noise(
raw_noise,
n_hydros,
n_load_buses,
stoch,
0,
block_count,
ncs_max_gen,
&mut ncs_col_upper_buf,
);
solver.set_col_bounds(
&ncs_col_indices_buf,
&ncs_col_lower_buf,
&ncs_col_upper_buf,
);
}
}
let view = solver.solve().map_err(|e| match e {
SolverError::Infeasible => SddpError::Infeasible {
stage: 0,
iteration: 0,
scenario: opening_idx,
},
other => SddpError::Solver(other),
})?;
objectives.push(view.objective);
}
#[allow(clippy::cast_precision_loss)]
let uniform_prob = 1.0_f64 / n_openings as f64;
lb = risk_measure.evaluate_risk(&objectives, &vec![uniform_prob; n_openings])
* COST_SCALE_FACTOR;
}
comm.broadcast(std::slice::from_mut(&mut lb), 0)
.map_err(SddpError::from)?;
Ok(lb)
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::float_cmp,
clippy::cast_precision_loss
)]
mod tests {
use super::{LbEvalSpec, evaluate_lower_bound};
use crate::{
FutureCostFunction, InflowNonNegativityMethod, PatchBuffer, RiskMeasure, SddpError,
StageIndexer,
};
use cobre_comm::{CommData, CommError, Communicator, ReduceOp};
use cobre_solver::{
Basis, RowBatch, SolverError, SolverInterface, SolverStatistics, StageTemplate,
};
use cobre_stochastic::OpeningTree;
fn empty_row_batch() -> RowBatch {
RowBatch {
num_rows: 0,
row_starts: Vec::new(),
col_indices: Vec::new(),
values: Vec::new(),
row_lower: Vec::new(),
row_upper: Vec::new(),
}
}
fn minimal_template() -> StageTemplate {
StageTemplate {
num_cols: 3,
num_rows: 1,
num_nz: 1,
col_starts: vec![0_i32, 0, 1, 1], row_indices: vec![0_i32],
values: vec![1.0],
col_lower: vec![0.0, 0.0, 0.0],
col_upper: vec![f64::INFINITY, f64::INFINITY, f64::INFINITY],
objective: vec![0.0, 0.0, 1.0], row_lower: vec![0.0],
row_upper: vec![0.0],
n_state: 1,
n_transfer: 0,
n_dual_relevant: 1,
n_hydro: 1,
max_par_order: 0,
col_scale: Vec::new(),
row_scale: Vec::new(),
}
}
fn simple_opening_tree(n_openings: usize) -> OpeningTree {
use chrono::NaiveDate;
use cobre_core::{
EntityId,
scenario::{CorrelationEntity, CorrelationGroup, CorrelationModel, CorrelationProfile},
temporal::{
Block, BlockMode, NoiseMethod, ScenarioSourceConfig, Stage, StageRiskConfig,
StageStateConfig,
},
};
use cobre_stochastic::correlation::resolve::DecomposedCorrelation;
use std::collections::BTreeMap;
let stage = Stage {
index: 0,
id: 0,
start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
season_id: Some(0),
blocks: vec![Block {
index: 0,
name: "S".to_string(),
duration_hours: 744.0,
}],
block_mode: BlockMode::Parallel,
state_config: StageStateConfig {
storage: true,
inflow_lags: false,
},
risk_config: StageRiskConfig::Expectation,
scenario_config: ScenarioSourceConfig {
branching_factor: n_openings,
noise_method: NoiseMethod::Saa,
},
};
let entity_id = EntityId(1);
let mut profiles = BTreeMap::new();
profiles.insert(
"default".to_string(),
CorrelationProfile {
groups: vec![CorrelationGroup {
name: "g1".to_string(),
entities: vec![CorrelationEntity {
entity_type: "inflow".to_string(),
id: entity_id,
}],
matrix: vec![vec![1.0]],
}],
},
);
let corr_model = CorrelationModel {
method: "cholesky".to_string(),
profiles,
schedule: vec![],
};
let mut decomposed = DecomposedCorrelation::build(&corr_model).unwrap();
let entity_order = vec![entity_id];
cobre_stochastic::tree::generate::generate_opening_tree(
42,
&[stage],
1, &mut decomposed,
&entity_order,
)
}
struct LocalComm;
impl Communicator for LocalComm {
fn allgatherv<T: CommData>(
&self,
_send: &[T],
_recv: &mut [T],
_counts: &[usize],
_displs: &[usize],
) -> Result<(), CommError> {
unreachable!("LocalComm allgatherv not used in lower_bound tests")
}
fn allreduce<T: CommData>(
&self,
_send: &[T],
_recv: &mut [T],
_op: ReduceOp,
) -> Result<(), CommError> {
unreachable!("LocalComm allreduce not used in lower_bound tests")
}
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
}
}
struct FailingBcastComm;
impl Communicator for FailingBcastComm {
fn allgatherv<T: CommData>(
&self,
_send: &[T],
_recv: &mut [T],
_counts: &[usize],
_displs: &[usize],
) -> Result<(), CommError> {
unreachable!()
}
fn allreduce<T: CommData>(
&self,
_send: &[T],
_recv: &mut [T],
_op: ReduceOp,
) -> Result<(), CommError> {
unreachable!()
}
fn broadcast<T: CommData>(&self, _buf: &mut [T], _root: usize) -> Result<(), CommError> {
Err(CommError::CollectiveFailed {
operation: "broadcast",
mpi_error_code: -1,
message: "test-induced broadcast failure".to_string(),
})
}
fn barrier(&self) -> Result<(), CommError> {
Ok(())
}
fn rank(&self) -> usize {
0
}
fn size(&self) -> usize {
1
}
}
struct MockSolver {
objectives: Vec<f64>,
call_count: usize,
infeasible_on_call: Option<usize>,
}
impl MockSolver {
fn with_objectives(objectives: Vec<f64>) -> Self {
Self {
objectives,
call_count: 0,
infeasible_on_call: None,
}
}
fn infeasible_on_first() -> Self {
Self {
objectives: vec![0.0],
call_count: 0,
infeasible_on_call: Some(0),
}
}
}
impl SolverInterface for MockSolver {
fn load_model(&mut self, _template: &StageTemplate) {}
fn add_rows(&mut self, _cuts: &RowBatch) {}
fn set_row_bounds(&mut self, _indices: &[usize], _lower: &[f64], _upper: &[f64]) {}
fn set_col_bounds(&mut self, _indices: &[usize], _lower: &[f64], _upper: &[f64]) {}
fn solve(&mut self) -> Result<cobre_solver::SolutionView<'_>, SolverError> {
let call = self.call_count;
self.call_count += 1;
if self.infeasible_on_call == Some(call) {
return Err(SolverError::Infeasible);
}
let obj = self.objectives[call % self.objectives.len()];
Ok(cobre_solver::SolutionView {
objective: obj,
primal: &[],
dual: &[],
reduced_costs: &[],
iterations: 0,
solve_time_seconds: 0.0,
})
}
fn reset(&mut self) {
self.call_count = 0;
}
fn get_basis(&mut self, _out: &mut Basis) {}
fn solve_with_basis(
&mut self,
_basis: &Basis,
) -> Result<cobre_solver::SolutionView<'_>, SolverError> {
self.solve()
}
fn statistics(&self) -> SolverStatistics {
SolverStatistics::default()
}
fn name(&self) -> &'static str {
"Mock"
}
}
fn make_fcf(n_stages: usize, n_state: usize) -> FutureCostFunction {
FutureCostFunction::new(n_stages, n_state, 2, 100, 0)
}
#[test]
fn one_opening_expectation_lb_equals_single_objective() {
let indexer = StageIndexer::new(1, 0); let template = minimal_template();
let fcf = make_fcf(2, indexer.n_state);
let initial_state = vec![0.0_f64; indexer.n_state];
let mut patch_buf = PatchBuffer::new(indexer.hydro_count, indexer.max_par_order, 0, 0);
let opening_tree = simple_opening_tree(1);
let rm = RiskMeasure::Expectation;
let comm = LocalComm;
let mut solver = MockSolver::with_objectives(vec![100.0]);
let spec = LbEvalSpec {
template: &template,
base_row: 1,
noise_scale: &[],
n_hydros: 0,
opening_tree: &opening_tree,
risk_measure: &rm,
stochastic: None,
n_load_buses: 0,
ncs_max_gen: &[],
block_count: 1,
ncs_generation: 0..0,
inflow_method: &InflowNonNegativityMethod::None,
};
let lb = evaluate_lower_bound(
&mut solver,
&fcf,
&initial_state,
&indexer,
&mut patch_buf,
&mut empty_row_batch(),
&spec,
&comm,
None,
)
.unwrap();
assert!(
(lb - 100_000.0).abs() < 1e-7,
"single opening expectation LB must equal objective 100.0 * COST_SCALE_FACTOR = 100_000.0, got {lb}"
);
}
#[test]
fn three_openings_expectation_lb_equals_mean() {
let indexer = StageIndexer::new(1, 0);
let template = minimal_template();
let fcf = make_fcf(2, indexer.n_state);
let initial_state = vec![0.0_f64; indexer.n_state];
let mut patch_buf = PatchBuffer::new(indexer.hydro_count, indexer.max_par_order, 0, 0);
let opening_tree = simple_opening_tree(3);
let rm = RiskMeasure::Expectation;
let comm = LocalComm;
let mut solver = MockSolver::with_objectives(vec![60.0, 80.0, 100.0]);
let spec = LbEvalSpec {
template: &template,
base_row: 1,
noise_scale: &[],
n_hydros: 0,
opening_tree: &opening_tree,
risk_measure: &rm,
stochastic: None,
n_load_buses: 0,
ncs_max_gen: &[],
block_count: 1,
ncs_generation: 0..0,
inflow_method: &InflowNonNegativityMethod::None,
};
let lb = evaluate_lower_bound(
&mut solver,
&fcf,
&initial_state,
&indexer,
&mut patch_buf,
&mut empty_row_batch(),
&spec,
&comm,
None,
)
.unwrap();
assert!(
(lb - 80_000.0).abs() < 1e-7,
"three openings expectation LB must equal 80_000.0, got {lb}"
);
}
#[test]
fn two_openings_pure_cvar_alpha_half_lb_equals_worst() {
let indexer = StageIndexer::new(1, 0);
let template = minimal_template();
let fcf = make_fcf(2, indexer.n_state);
let initial_state = vec![0.0_f64; indexer.n_state];
let mut patch_buf = PatchBuffer::new(indexer.hydro_count, indexer.max_par_order, 0, 0);
let opening_tree = simple_opening_tree(2);
let rm = RiskMeasure::CVaR {
alpha: 0.5,
lambda: 1.0,
};
let comm = LocalComm;
let mut solver = MockSolver::with_objectives(vec![50.0, 150.0]);
let spec = LbEvalSpec {
template: &template,
base_row: 1,
noise_scale: &[],
n_hydros: 0,
opening_tree: &opening_tree,
risk_measure: &rm,
stochastic: None,
n_load_buses: 0,
ncs_max_gen: &[],
block_count: 1,
ncs_generation: 0..0,
inflow_method: &InflowNonNegativityMethod::None,
};
let lb = evaluate_lower_bound(
&mut solver,
&fcf,
&initial_state,
&indexer,
&mut patch_buf,
&mut empty_row_batch(),
&spec,
&comm,
None,
)
.unwrap();
assert!(
(lb - 150_000.0).abs() < 1e-7,
"pure CVaR(0.5, 1.0) with 2 openings must equal 150_000.0, got {lb}"
);
}
#[test]
fn two_openings_cvar_alpha_one_equals_expectation() {
let indexer = StageIndexer::new(1, 0);
let template = minimal_template();
let fcf = make_fcf(2, indexer.n_state);
let initial_state = vec![0.0_f64; indexer.n_state];
let mut patch_buf = PatchBuffer::new(indexer.hydro_count, indexer.max_par_order, 0, 0);
let opening_tree = simple_opening_tree(2);
let rm = RiskMeasure::CVaR {
alpha: 1.0,
lambda: 1.0,
};
let comm = LocalComm;
let mut solver = MockSolver::with_objectives(vec![50.0, 150.0]);
let spec = LbEvalSpec {
template: &template,
base_row: 1,
noise_scale: &[],
n_hydros: 0,
opening_tree: &opening_tree,
risk_measure: &rm,
stochastic: None,
n_load_buses: 0,
ncs_max_gen: &[],
block_count: 1,
ncs_generation: 0..0,
inflow_method: &InflowNonNegativityMethod::None,
};
let lb = evaluate_lower_bound(
&mut solver,
&fcf,
&initial_state,
&indexer,
&mut patch_buf,
&mut empty_row_batch(),
&spec,
&comm,
None,
)
.unwrap();
assert!(
(lb - 100_000.0).abs() < 1e-7,
"CVaR(alpha=1, lambda=1) must equal expectation 100_000.0, got {lb}"
);
}
#[test]
fn infeasible_solve_maps_to_sddp_infeasible() {
let indexer = StageIndexer::new(1, 0);
let template = minimal_template();
let fcf = make_fcf(2, indexer.n_state);
let initial_state = vec![0.0_f64; indexer.n_state];
let mut patch_buf = PatchBuffer::new(indexer.hydro_count, indexer.max_par_order, 0, 0);
let opening_tree = simple_opening_tree(1);
let rm = RiskMeasure::Expectation;
let comm = LocalComm;
let mut solver = MockSolver::infeasible_on_first();
let spec = LbEvalSpec {
template: &template,
base_row: 1,
noise_scale: &[],
n_hydros: 0,
opening_tree: &opening_tree,
risk_measure: &rm,
stochastic: None,
n_load_buses: 0,
ncs_max_gen: &[],
block_count: 1,
ncs_generation: 0..0,
inflow_method: &InflowNonNegativityMethod::None,
};
let result = evaluate_lower_bound(
&mut solver,
&fcf,
&initial_state,
&indexer,
&mut patch_buf,
&mut empty_row_batch(),
&spec,
&comm,
None,
);
assert!(
matches!(result, Err(SddpError::Infeasible { stage: 0, .. })),
"infeasible solver must produce SddpError::Infeasible at stage 0, got {result:?}"
);
}
#[test]
fn broadcast_failure_maps_to_communication_error() {
let indexer = StageIndexer::new(1, 0);
let template = minimal_template();
let fcf = make_fcf(2, indexer.n_state);
let initial_state = vec![0.0_f64; indexer.n_state];
let mut patch_buf = PatchBuffer::new(indexer.hydro_count, indexer.max_par_order, 0, 0);
let opening_tree = simple_opening_tree(1);
let rm = RiskMeasure::Expectation;
let comm = FailingBcastComm;
let mut solver = MockSolver::with_objectives(vec![100.0]);
let spec = LbEvalSpec {
template: &template,
base_row: 1,
noise_scale: &[],
n_hydros: 0,
opening_tree: &opening_tree,
risk_measure: &rm,
stochastic: None,
n_load_buses: 0,
ncs_max_gen: &[],
block_count: 1,
ncs_generation: 0..0,
inflow_method: &InflowNonNegativityMethod::None,
};
let result = evaluate_lower_bound(
&mut solver,
&fcf,
&initial_state,
&indexer,
&mut patch_buf,
&mut empty_row_batch(),
&spec,
&comm,
None,
);
assert!(
matches!(result, Err(SddpError::Communication(_))),
"broadcast failure must produce SddpError::Communication, got {result:?}"
);
}
#[test]
fn integration_two_openings_local_backend_expectation() {
let indexer = StageIndexer::new(1, 0);
let template = minimal_template();
let fcf = make_fcf(2, indexer.n_state);
let initial_state = vec![50.0_f64]; let mut patch_buf = PatchBuffer::new(indexer.hydro_count, indexer.max_par_order, 0, 0);
let opening_tree = simple_opening_tree(2);
let rm = RiskMeasure::Expectation;
let comm = LocalComm;
let mut solver = MockSolver::with_objectives(vec![200.0, 300.0]);
let spec = LbEvalSpec {
template: &template,
base_row: 1,
noise_scale: &[],
n_hydros: 0,
opening_tree: &opening_tree,
risk_measure: &rm,
stochastic: None,
n_load_buses: 0,
ncs_max_gen: &[],
block_count: 1,
ncs_generation: 0..0,
inflow_method: &InflowNonNegativityMethod::None,
};
let lb = evaluate_lower_bound(
&mut solver,
&fcf,
&initial_state,
&indexer,
&mut patch_buf,
&mut empty_row_batch(),
&spec,
&comm,
None,
)
.unwrap();
assert!(
(lb - 250_000.0).abs() < 1e-7,
"integration round-trip must produce 250_000.0, got {lb}"
);
}
#[test]
fn integration_monotonicity_more_cuts_yields_higher_or_equal_lb() {
let indexer = StageIndexer::new(1, 0);
let template = minimal_template();
let fcf = make_fcf(2, indexer.n_state);
let initial_state = vec![0.0_f64];
let mut patch_buf = PatchBuffer::new(indexer.hydro_count, indexer.max_par_order, 0, 0);
let opening_tree = simple_opening_tree(2);
let rm = RiskMeasure::Expectation;
let comm = LocalComm;
let spec = LbEvalSpec {
template: &template,
base_row: 1,
noise_scale: &[],
n_hydros: 0,
opening_tree: &opening_tree,
risk_measure: &rm,
stochastic: None,
n_load_buses: 0,
ncs_max_gen: &[],
block_count: 1,
ncs_generation: 0..0,
inflow_method: &InflowNonNegativityMethod::None,
};
let mut solver1 = MockSolver::with_objectives(vec![50.0, 100.0]);
let lb1 = evaluate_lower_bound(
&mut solver1,
&fcf,
&initial_state,
&indexer,
&mut patch_buf,
&mut empty_row_batch(),
&spec,
&comm,
None,
)
.unwrap();
let mut solver2 = MockSolver::with_objectives(vec![80.0, 120.0]);
let lb2 = evaluate_lower_bound(
&mut solver2,
&fcf,
&initial_state,
&indexer,
&mut patch_buf,
&mut empty_row_batch(),
&spec,
&comm,
None,
)
.unwrap();
assert!(
lb2 >= lb1,
"second LB ({lb2}) must be >= first LB ({lb1}) when cuts are tighter"
);
}
#[test]
fn test_lb_none_method_unchanged() {
let indexer = StageIndexer::new(1, 0);
let template = minimal_template();
let fcf = make_fcf(2, indexer.n_state);
let initial_state = vec![0.0_f64; indexer.n_state];
let mut patch_buf = PatchBuffer::new(indexer.hydro_count, indexer.max_par_order, 0, 0);
let opening_tree = simple_opening_tree(2);
let rm = RiskMeasure::Expectation;
let comm = LocalComm;
let mut solver = MockSolver::with_objectives(vec![60.0, 80.0]);
let spec = LbEvalSpec {
template: &template,
base_row: 1,
noise_scale: &[],
n_hydros: 0,
opening_tree: &opening_tree,
risk_measure: &rm,
stochastic: None,
n_load_buses: 0,
ncs_max_gen: &[],
block_count: 1,
ncs_generation: 0..0,
inflow_method: &InflowNonNegativityMethod::None,
};
let lb = evaluate_lower_bound(
&mut solver,
&fcf,
&initial_state,
&indexer,
&mut patch_buf,
&mut empty_row_batch(),
&spec,
&comm,
None,
)
.unwrap();
assert!(
(lb - 70_000.0).abs() < 1e-7,
"None method must produce correct LB, got {lb}"
);
}
#[test]
fn test_lb_truncation_no_crash() {
let indexer = StageIndexer::new(1, 0);
let template = minimal_template();
let fcf = make_fcf(2, indexer.n_state);
let initial_state = vec![0.0_f64; indexer.n_state];
let mut patch_buf = PatchBuffer::new(indexer.hydro_count, indexer.max_par_order, 0, 0);
let opening_tree = simple_opening_tree(1);
let rm = RiskMeasure::Expectation;
let comm = LocalComm;
let mut solver = MockSolver::with_objectives(vec![100.0]);
let spec = LbEvalSpec {
template: &template,
base_row: 1,
noise_scale: &[],
n_hydros: 0,
opening_tree: &opening_tree,
risk_measure: &rm,
stochastic: None,
n_load_buses: 0,
ncs_max_gen: &[],
block_count: 1,
ncs_generation: 0..0,
inflow_method: &InflowNonNegativityMethod::Truncation,
};
let result = evaluate_lower_bound(
&mut solver,
&fcf,
&initial_state,
&indexer,
&mut patch_buf,
&mut empty_row_batch(),
&spec,
&comm,
None,
);
assert!(
result.is_ok(),
"Truncation method must not panic or fail, got {result:?}"
);
}
#[test]
fn test_lb_truncation_with_penalty_no_crash() {
let indexer = StageIndexer::new(1, 0);
let template = minimal_template();
let fcf = make_fcf(2, indexer.n_state);
let initial_state = vec![0.0_f64; indexer.n_state];
let mut patch_buf = PatchBuffer::new(indexer.hydro_count, indexer.max_par_order, 0, 0);
let opening_tree = simple_opening_tree(1);
let rm = RiskMeasure::Expectation;
let comm = LocalComm;
let mut solver = MockSolver::with_objectives(vec![100.0]);
let spec = LbEvalSpec {
template: &template,
base_row: 1,
noise_scale: &[],
n_hydros: 0,
opening_tree: &opening_tree,
risk_measure: &rm,
stochastic: None,
n_load_buses: 0,
ncs_max_gen: &[],
block_count: 1,
ncs_generation: 0..0,
inflow_method: &InflowNonNegativityMethod::TruncationWithPenalty { cost: 100.0 },
};
let result = evaluate_lower_bound(
&mut solver,
&fcf,
&initial_state,
&indexer,
&mut patch_buf,
&mut empty_row_batch(),
&spec,
&comm,
None,
);
assert!(
result.is_ok(),
"TruncationWithPenalty method must not panic or fail, got {result:?}"
);
}
}