use std::ops::Range;
use crate::StageIndexer;
use crate::simulation::types::{
ScenarioCategoryCosts, SimulationBusResult, SimulationContractResult, SimulationCostResult,
SimulationExchangeResult, SimulationHydroResult, SimulationInflowLagResult,
SimulationNonControllableResult, SimulationPumpingResult, SimulationStageResult,
SimulationThermalResult,
};
#[derive(Debug, Clone)]
pub struct EntityCounts {
pub hydro_ids: Vec<i32>,
pub thermal_ids: Vec<i32>,
pub line_ids: Vec<i32>,
pub bus_ids: Vec<i32>,
pub hydro_productivities: Vec<f64>,
pub pumping_station_ids: Vec<i32>,
pub contract_ids: Vec<i32>,
pub non_controllable_ids: Vec<i32>,
}
#[must_use]
pub fn assign_scenarios(n_scenarios: u32, rank: usize, world_size: usize) -> Range<u32> {
debug_assert!(world_size > 0, "world_size must be > 0");
let n = n_scenarios as usize;
let r = world_size;
let fat_count = n % r;
let fat_size = n / r + 1; let lean_size = n / r;
let start: usize = if rank < fat_count {
rank * fat_size
} else {
fat_count * fat_size + (rank - fat_count) * lean_size
};
let size = if rank < fat_count {
fat_size
} else {
lean_size
};
let end = start + size;
#[allow(clippy::cast_possible_truncation)]
{
(start as u32)..(end as u32)
}
}
pub struct SolutionView<'a> {
pub primal: &'a [f64],
pub dual: &'a [f64],
pub objective: f64,
pub objective_coeffs: &'a [f64],
pub row_lower: &'a [f64],
}
pub struct StageExtractionSpec<'a> {
pub indexer: &'a StageIndexer,
pub entity_counts: &'a EntityCounts,
pub inflow_m3s_per_hydro: &'a [f64],
pub block_hours: &'a [f64],
}
fn extract_hydro_no_turbine(
view: &SolutionView<'_>,
spec: &StageExtractionSpec<'_>,
h: usize,
hydro_id: i32,
stage_id: u32,
) -> SimulationHydroResult {
let indexer = spec.indexer;
let incremental_inflow = if h < spec.inflow_m3s_per_hydro.len() {
spec.inflow_m3s_per_hydro[h]
} else if indexer.max_par_order > 0 {
view.primal[indexer.inflow_lags.start + h * indexer.max_par_order]
} else {
0.0
};
let inflow_slack = if indexer.has_inflow_penalty {
view.primal[indexer.inflow_slack.start + h]
} else {
0.0
};
let water_value = view.dual.get(indexer.n_state + h).copied().unwrap_or(0.0);
SimulationHydroResult {
stage_id,
block_id: None,
hydro_id,
turbined_m3s: 0.0,
spillage_m3s: 0.0,
evaporation_m3s: Some(0.0),
diverted_inflow_m3s: Some(0.0),
diverted_outflow_m3s: Some(0.0),
incremental_inflow_m3s: incremental_inflow,
inflow_m3s: incremental_inflow,
storage_initial_hm3: view.primal[indexer.storage_in.start + h],
storage_final_hm3: view.primal[indexer.storage.start + h],
generation_mw: 0.0,
productivity_mw_per_m3s: Some(spec.entity_counts.hydro_productivities[h]),
spillage_cost: 0.0,
water_value_per_hm3: water_value,
storage_binding_code: 0,
operative_state_code: 1,
turbined_slack_m3s: 0.0,
outflow_slack_below_m3s: 0.0,
outflow_slack_above_m3s: 0.0,
generation_slack_mw: 0.0,
storage_violation_below_hm3: 0.0,
filling_target_violation_hm3: 0.0,
evaporation_violation_m3s: 0.0,
inflow_nonnegativity_slack_m3s: inflow_slack,
}
}
fn extract_hydro_per_block<'a>(
view: &'a SolutionView<'a>,
spec: &'a StageExtractionSpec<'a>,
h: usize,
hydro_id: i32,
stage_id: u32,
) -> impl Iterator<Item = SimulationHydroResult> + 'a {
let indexer = spec.indexer;
let n_blks = indexer.n_blks;
let storage_final = view.primal[indexer.storage.start + h];
let storage_initial = view.primal[indexer.storage_in.start + h];
let incremental_inflow = if h < spec.inflow_m3s_per_hydro.len() {
spec.inflow_m3s_per_hydro[h]
} else if indexer.max_par_order > 0 {
view.primal[indexer.inflow_lags.start + h * indexer.max_par_order]
} else {
0.0
};
let productivity = spec.entity_counts.hydro_productivities[h];
let inflow_slack = if indexer.has_inflow_penalty {
view.primal[indexer.inflow_slack.start + h]
} else {
0.0
};
let water_value = view.dual.get(indexer.n_state + h).copied().unwrap_or(0.0);
(0..n_blks).map(move |b| {
let t_col = indexer.turbine.start + h * n_blks + b;
let s_col = indexer.spillage.start + h * n_blks + b;
let turbined = view.primal[t_col];
let spillage = view.primal[s_col];
#[allow(clippy::cast_possible_truncation)]
SimulationHydroResult {
stage_id,
block_id: Some(b as u32),
hydro_id,
turbined_m3s: turbined,
spillage_m3s: spillage,
evaporation_m3s: Some(0.0),
diverted_inflow_m3s: Some(0.0),
diverted_outflow_m3s: Some(0.0),
incremental_inflow_m3s: incremental_inflow,
inflow_m3s: incremental_inflow,
storage_initial_hm3: storage_initial,
storage_final_hm3: storage_final,
generation_mw: turbined * productivity,
productivity_mw_per_m3s: Some(productivity),
spillage_cost: spillage * view.objective_coeffs[s_col],
water_value_per_hm3: water_value,
storage_binding_code: 0,
operative_state_code: 1,
turbined_slack_m3s: 0.0,
outflow_slack_below_m3s: 0.0,
outflow_slack_above_m3s: 0.0,
generation_slack_mw: 0.0,
storage_violation_below_hm3: 0.0,
filling_target_violation_hm3: 0.0,
evaporation_violation_m3s: 0.0,
inflow_nonnegativity_slack_m3s: inflow_slack,
}
})
}
fn extract_hydros(
view: &SolutionView<'_>,
spec: &StageExtractionSpec<'_>,
stage_id: u32,
) -> Vec<SimulationHydroResult> {
let indexer = spec.indexer;
if indexer.turbine.is_empty() || indexer.n_blks == 0 {
spec.entity_counts
.hydro_ids
.iter()
.enumerate()
.map(|(h, &hydro_id)| extract_hydro_no_turbine(view, spec, h, hydro_id, stage_id))
.collect()
} else {
spec.entity_counts
.hydro_ids
.iter()
.enumerate()
.flat_map(|(h, &hydro_id)| extract_hydro_per_block(view, spec, h, hydro_id, stage_id))
.collect()
}
}
fn extract_thermals(
view: &SolutionView<'_>,
spec: &StageExtractionSpec<'_>,
stage_id: u32,
) -> Vec<SimulationThermalResult> {
let indexer = spec.indexer;
let n_blks = indexer.n_blks;
if indexer.thermal.is_empty() || n_blks == 0 {
spec.entity_counts
.thermal_ids
.iter()
.map(|&thermal_id| SimulationThermalResult {
stage_id,
block_id: None,
thermal_id,
generation_mw: 0.0,
generation_cost: 0.0,
is_gnl: false,
gnl_committed_mw: None,
gnl_decision_mw: None,
operative_state_code: 1,
})
.collect()
} else {
spec.entity_counts
.thermal_ids
.iter()
.enumerate()
.flat_map(|(t, &thermal_id)| {
(0..n_blks).map(move |b| {
let col = indexer.thermal.start + t * n_blks + b;
let gen_mw = view.primal[col];
#[allow(clippy::cast_possible_truncation)]
SimulationThermalResult {
stage_id,
block_id: Some(b as u32),
thermal_id,
generation_mw: gen_mw,
generation_cost: gen_mw * view.objective_coeffs[col],
is_gnl: false,
gnl_committed_mw: None,
gnl_decision_mw: None,
operative_state_code: 1,
}
})
})
.collect()
}
}
fn extract_exchanges(
view: &SolutionView<'_>,
spec: &StageExtractionSpec<'_>,
stage_id: u32,
) -> Vec<SimulationExchangeResult> {
let indexer = spec.indexer;
let n_blks = indexer.n_blks;
if indexer.line_fwd.is_empty() || n_blks == 0 {
spec.entity_counts
.line_ids
.iter()
.map(|&line_id| SimulationExchangeResult {
stage_id,
block_id: None,
line_id,
direct_flow_mw: 0.0,
reverse_flow_mw: 0.0,
exchange_cost: 0.0,
operative_state_code: 1,
})
.collect()
} else {
spec.entity_counts
.line_ids
.iter()
.enumerate()
.flat_map(|(l, &line_id)| {
(0..n_blks).map(move |b| {
let fwd_col = indexer.line_fwd.start + l * n_blks + b;
let rev_col = indexer.line_rev.start + l * n_blks + b;
let fwd = view.primal[fwd_col];
let rev = view.primal[rev_col];
#[allow(clippy::cast_possible_truncation)]
SimulationExchangeResult {
stage_id,
block_id: Some(b as u32),
line_id,
direct_flow_mw: fwd,
reverse_flow_mw: rev,
exchange_cost: fwd * view.objective_coeffs[fwd_col]
+ rev * view.objective_coeffs[rev_col],
operative_state_code: 2,
}
})
})
.collect()
}
}
fn extract_buses(
view: &SolutionView<'_>,
spec: &StageExtractionSpec<'_>,
stage_id: u32,
) -> Vec<SimulationBusResult> {
let indexer = spec.indexer;
let n_blks = indexer.n_blks;
if indexer.deficit.is_empty() || n_blks == 0 {
spec.entity_counts
.bus_ids
.iter()
.map(|&bus_id| SimulationBusResult {
stage_id,
block_id: None,
bus_id,
load_mw: 0.0,
deficit_mw: 0.0,
excess_mw: 0.0,
spot_price: 0.0,
})
.collect()
} else {
spec.entity_counts
.bus_ids
.iter()
.enumerate()
.flat_map(|(bus_idx, &bus_id)| {
(0..n_blks).map(move |b| {
let deficit_col = indexer.deficit.start + bus_idx * n_blks + b;
let excess_col = indexer.excess.start + bus_idx * n_blks + b;
let load_row = indexer.load_balance.start + bus_idx * n_blks + b;
let raw_dual = view.dual.get(load_row).copied().unwrap_or(0.0);
let hrs = spec.block_hours.get(b).copied().unwrap_or(0.0);
#[allow(clippy::cast_possible_truncation)]
SimulationBusResult {
stage_id,
block_id: Some(b as u32),
bus_id,
load_mw: view.row_lower[load_row],
deficit_mw: view.primal[deficit_col],
excess_mw: view.primal[excess_col],
spot_price: if hrs > 0.0 { raw_dual / hrs } else { 0.0 },
}
})
})
.collect()
}
}
#[must_use]
pub fn extract_stage_result(
view: &SolutionView<'_>,
spec: &StageExtractionSpec<'_>,
stage_id: u32,
) -> SimulationStageResult {
let indexer = spec.indexer;
debug_assert!(
view.primal.len() > indexer.theta,
"primal vector too short: len={}, need > theta={}",
view.primal.len(),
indexer.theta
);
debug_assert!(
spec.entity_counts.hydro_ids.len() == indexer.hydro_count,
"hydro_ids length {} does not match indexer.hydro_count {}",
spec.entity_counts.hydro_ids.len(),
indexer.hydro_count
);
debug_assert!(
indexer.excess.is_empty() || view.objective_coeffs.len() >= indexer.excess.end,
"objective_coeffs too short: len={}, need >= excess.end={}",
view.objective_coeffs.len(),
indexer.excess.end
);
debug_assert!(
spec.entity_counts.hydro_productivities.len() == indexer.hydro_count,
"hydro_productivities length {} does not match indexer.hydro_count {}",
spec.entity_counts.hydro_productivities.len(),
indexer.hydro_count
);
debug_assert!(
indexer.load_balance.is_empty() || view.row_lower.len() >= indexer.load_balance.end,
"row_lower too short: len={}, need >= load_balance.end={}",
view.row_lower.len(),
indexer.load_balance.end
);
let costs = vec![compute_cost_result(view, spec.indexer, stage_id)];
let (inflow_lags, pumping_stations, contracts, non_controllables) =
extract_stub_collections(view, spec, stage_id);
SimulationStageResult {
stage_id,
costs,
hydros: extract_hydros(view, spec, stage_id),
thermals: extract_thermals(view, spec, stage_id),
exchanges: extract_exchanges(view, spec, stage_id),
buses: extract_buses(view, spec, stage_id),
pumping_stations,
contracts,
non_controllables,
inflow_lags,
generic_violations: vec![],
}
}
fn compute_cost_result(
view: &SolutionView<'_>,
indexer: &StageIndexer,
stage_id: u32,
) -> SimulationCostResult {
let col_cost = |col: usize| view.primal[col] * view.objective_coeffs[col];
let range_sum = |r: std::ops::Range<usize>| -> f64 { r.map(col_cost).sum() };
let future_cost = view.primal[indexer.theta];
let immediate_cost = view.objective - future_cost;
let thermal_cost = if indexer.thermal.is_empty() {
0.0
} else {
range_sum(indexer.thermal.clone())
};
let spillage_cost = if indexer.spillage.is_empty() {
0.0
} else {
range_sum(indexer.spillage.clone())
};
let exchange_cost = if indexer.line_fwd.is_empty() {
0.0
} else {
indexer
.line_fwd
.clone()
.chain(indexer.line_rev.clone())
.map(col_cost)
.sum()
};
let deficit_cost = if indexer.deficit.is_empty() {
0.0
} else {
range_sum(indexer.deficit.clone())
};
let excess_cost = if indexer.excess.is_empty() {
0.0
} else {
range_sum(indexer.excess.clone())
};
SimulationCostResult {
stage_id,
block_id: None,
total_cost: view.objective,
immediate_cost,
future_cost,
discount_factor: 1.0,
thermal_cost,
contract_cost: 0.0,
deficit_cost,
excess_cost,
storage_violation_cost: 0.0,
filling_target_cost: 0.0,
hydro_violation_cost: 0.0,
inflow_penalty_cost: 0.0,
generic_violation_cost: 0.0,
spillage_cost,
fpha_turbined_cost: 0.0,
curtailment_cost: 0.0,
exchange_cost,
pumping_cost: 0.0,
}
}
#[allow(clippy::type_complexity)]
fn extract_stub_collections(
view: &SolutionView<'_>,
spec: &StageExtractionSpec<'_>,
stage_id: u32,
) -> (
Vec<SimulationInflowLagResult>,
Vec<SimulationPumpingResult>,
Vec<SimulationContractResult>,
Vec<SimulationNonControllableResult>,
) {
let indexer = spec.indexer;
let inflow_lags: Vec<SimulationInflowLagResult> = spec
.entity_counts
.hydro_ids
.iter()
.enumerate()
.flat_map(|(h, &hydro_id)| {
(0..indexer.max_par_order).map(move |l| {
#[allow(clippy::cast_possible_truncation)]
SimulationInflowLagResult {
stage_id,
hydro_id,
lag_index: l as u32,
inflow_m3s: view.primal
[indexer.inflow_lags.start + h * indexer.max_par_order + l],
}
})
})
.collect();
let pumping_stations: Vec<SimulationPumpingResult> = spec
.entity_counts
.pumping_station_ids
.iter()
.map(|&pumping_station_id| SimulationPumpingResult {
stage_id,
block_id: None,
pumping_station_id,
pumped_flow_m3s: 0.0,
power_consumption_mw: 0.0,
pumping_cost: 0.0,
operative_state_code: 1,
})
.collect();
let contracts: Vec<SimulationContractResult> = spec
.entity_counts
.contract_ids
.iter()
.map(|&contract_id| SimulationContractResult {
stage_id,
block_id: None,
contract_id,
power_mw: 0.0,
price_per_mwh: 0.0,
total_cost: 0.0,
operative_state_code: 1,
})
.collect();
let non_controllables: Vec<SimulationNonControllableResult> = spec
.entity_counts
.non_controllable_ids
.iter()
.map(|&non_controllable_id| SimulationNonControllableResult {
stage_id,
block_id: None,
non_controllable_id,
generation_mw: 0.0,
available_mw: 0.0,
curtailment_mw: 0.0,
curtailment_cost: 0.0,
operative_state_code: 1,
})
.collect();
(inflow_lags, pumping_stations, contracts, non_controllables)
}
pub fn accumulate_category_costs(cost: &SimulationCostResult, accum: &mut ScenarioCategoryCosts) {
accum.resource_cost += cost.thermal_cost + cost.contract_cost;
accum.recourse_cost += cost.deficit_cost + cost.excess_cost;
accum.violation_cost += cost.storage_violation_cost
+ cost.filling_target_cost
+ cost.hydro_violation_cost
+ cost.inflow_penalty_cost
+ cost.generic_violation_cost;
accum.regularization_cost +=
cost.spillage_cost + cost.fpha_turbined_cost + cost.curtailment_cost + cost.exchange_cost;
accum.imputed_cost += cost.pumping_cost;
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod tests {
use super::{
EntityCounts, SolutionView, StageExtractionSpec, accumulate_category_costs,
assign_scenarios, extract_stage_result,
};
use crate::StageIndexer;
use crate::simulation::types::{ScenarioCategoryCosts, SimulationCostResult};
#[test]
fn assign_scenarios_uneven_rank0() {
assert_eq!(assign_scenarios(10, 0, 3), 0..4);
}
#[test]
fn assign_scenarios_uneven_rank2() {
assert_eq!(assign_scenarios(10, 2, 3), 7..10);
}
#[test]
fn assign_scenarios_single_rank() {
assert_eq!(assign_scenarios(7, 0, 1), 0..7);
}
#[test]
fn assign_scenarios_uneven_rank1() {
assert_eq!(assign_scenarios(10, 1, 3), 4..7);
}
#[test]
fn assign_scenarios_exact_division() {
assert_eq!(assign_scenarios(9, 0, 3), 0..3);
assert_eq!(assign_scenarios(9, 1, 3), 3..6);
assert_eq!(assign_scenarios(9, 2, 3), 6..9);
}
#[test]
fn assign_scenarios_zero_scenarios() {
assert_eq!(assign_scenarios(0, 0, 1), 0..0);
assert_eq!(assign_scenarios(0, 0, 4), 0..0);
assert_eq!(assign_scenarios(0, 3, 4), 0..0);
}
#[test]
fn assign_scenarios_more_ranks_than_scenarios() {
assert_eq!(assign_scenarios(2, 0, 5), 0..1);
assert_eq!(assign_scenarios(2, 1, 5), 1..2);
assert_eq!(assign_scenarios(2, 2, 5), 2..2);
assert_eq!(assign_scenarios(2, 3, 5), 2..2);
assert_eq!(assign_scenarios(2, 4, 5), 2..2);
}
#[test]
fn assign_scenarios_sum_equals_n_scenarios() {
for (n, world_size) in [(0_u32, 1_usize), (1, 1), (10, 3), (9, 3), (2, 5), (100, 7)] {
let total: u32 = (0..world_size)
.map(|rank| {
let r = assign_scenarios(n, rank, world_size);
r.end - r.start
})
.sum();
assert_eq!(
total, n,
"total assigned {total} != n_scenarios {n} for world_size={world_size}"
);
}
}
fn make_entity_counts_2_hydros() -> EntityCounts {
EntityCounts {
hydro_ids: vec![10, 20],
hydro_productivities: vec![1.0, 1.0],
thermal_ids: vec![1],
line_ids: vec![5],
bus_ids: vec![100],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
}
}
fn make_primal_2_1(
storage: [f64; 2],
lags: [f64; 2],
storage_in: [f64; 2],
theta: f64,
) -> Vec<f64> {
vec![
storage[0],
storage[1],
lags[0],
lags[1],
storage_in[0],
storage_in[1],
theta,
]
}
#[test]
fn extract_costs_has_one_entry_matching_stage_id() {
let indexer = StageIndexer::new(2, 1);
let primal = make_primal_2_1([100.0, 200.0], [50.0, 60.0], [90.0, 180.0], 999.5);
let dual = vec![0.0; 4];
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: 1500.0,
objective_coeffs: &[],
row_lower: &[],
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &make_entity_counts_2_hydros(),
inflow_m3s_per_hydro: &[],
block_hours: &[],
},
3,
);
assert_eq!(result.costs.len(), 1);
assert_eq!(result.costs[0].stage_id, 3);
assert_eq!(result.costs[0].future_cost, 999.5); }
#[test]
fn extract_cost_splits_objective_correctly() {
let indexer = StageIndexer::new(2, 1);
let theta_val = 300.0;
let objective = 800.0;
let primal = make_primal_2_1([0.0; 2], [0.0; 2], [0.0; 2], theta_val);
let dual = vec![0.0; 4];
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective,
objective_coeffs: &[],
row_lower: &[],
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &make_entity_counts_2_hydros(),
inflow_m3s_per_hydro: &[],
block_hours: &[],
},
0,
);
let cost = &result.costs[0];
assert_eq!(cost.future_cost, theta_val);
assert_eq!(cost.immediate_cost, objective - theta_val);
assert_eq!(cost.total_cost, objective);
}
#[test]
fn extract_hydro_storage_values_from_primal() {
let indexer = StageIndexer::new(2, 1);
let primal = make_primal_2_1([100.0, 200.0], [50.0, 60.0], [90.0, 180.0], 999.5);
let dual = vec![0.0; 4];
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: 1500.0,
objective_coeffs: &[],
row_lower: &[],
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &make_entity_counts_2_hydros(),
inflow_m3s_per_hydro: &[],
block_hours: &[],
},
0,
);
assert_eq!(result.hydros.len(), 2);
assert_eq!(result.hydros[0].hydro_id, 10);
assert_eq!(result.hydros[0].storage_initial_hm3, 90.0);
assert_eq!(result.hydros[0].storage_final_hm3, 100.0);
assert_eq!(result.hydros[1].hydro_id, 20);
assert_eq!(result.hydros[1].storage_initial_hm3, 180.0);
assert_eq!(result.hydros[1].storage_final_hm3, 200.0);
}
#[test]
fn extract_inflow_lag_values_from_primal() {
let indexer = StageIndexer::new(2, 1);
let primal = make_primal_2_1([100.0, 200.0], [50.0, 60.0], [90.0, 180.0], 999.5);
let dual = vec![0.0; 4];
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: 1500.0,
objective_coeffs: &[],
row_lower: &[],
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &make_entity_counts_2_hydros(),
inflow_m3s_per_hydro: &[],
block_hours: &[],
},
0,
);
assert_eq!(result.inflow_lags.len(), 2); assert_eq!(result.inflow_lags[0].hydro_id, 10);
assert_eq!(result.inflow_lags[0].lag_index, 0);
assert_eq!(result.inflow_lags[0].inflow_m3s, 50.0);
assert_eq!(result.inflow_lags[1].hydro_id, 20);
assert_eq!(result.inflow_lags[1].lag_index, 0);
assert_eq!(result.inflow_lags[1].inflow_m3s, 60.0);
}
#[test]
fn extract_no_lags_when_max_par_order_zero() {
let indexer = StageIndexer::new(2, 0);
let primal = vec![100.0, 200.0, 90.0, 180.0, 500.0];
let dual = vec![];
let counts = EntityCounts {
hydro_ids: vec![10, 20],
hydro_productivities: vec![1.0, 1.0],
thermal_ids: vec![],
line_ids: vec![],
bus_ids: vec![],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: 600.0,
objective_coeffs: &[],
row_lower: &[],
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[],
},
2,
);
assert!(result.inflow_lags.is_empty());
assert_eq!(result.hydros[0].incremental_inflow_m3s, 0.0);
}
#[test]
fn extract_stage_id_propagates_to_all_results() {
let indexer = StageIndexer::new(2, 1);
let primal = make_primal_2_1([100.0, 200.0], [50.0, 60.0], [90.0, 180.0], 10.0);
let dual = vec![0.0; 4];
let stage_id = 7_u32;
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: 110.0,
objective_coeffs: &[],
row_lower: &[],
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &make_entity_counts_2_hydros(),
inflow_m3s_per_hydro: &[],
block_hours: &[],
},
stage_id,
);
assert_eq!(result.stage_id, stage_id);
assert_eq!(result.costs[0].stage_id, stage_id);
assert!(result.hydros.iter().all(|h| h.stage_id == stage_id));
assert!(result.thermals.iter().all(|t| t.stage_id == stage_id));
assert!(result.exchanges.iter().all(|e| e.stage_id == stage_id));
assert!(result.buses.iter().all(|b| b.stage_id == stage_id));
assert!(result.inflow_lags.iter().all(|l| l.stage_id == stage_id));
}
#[test]
fn extract_equipment_zero_when_indexer_has_no_equipment_ranges() {
let indexer = StageIndexer::new(2, 1);
let primal = make_primal_2_1([0.0; 2], [0.0; 2], [0.0; 2], 0.0);
let dual = vec![0.0; 4];
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: 0.0,
objective_coeffs: &[],
row_lower: &[],
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &make_entity_counts_2_hydros(),
inflow_m3s_per_hydro: &[],
block_hours: &[],
},
0,
);
assert_eq!(result.thermals.len(), 1);
assert_eq!(result.thermals[0].generation_mw, 0.0);
assert_eq!(result.thermals[0].generation_cost, 0.0);
assert_eq!(result.thermals[0].block_id, None);
assert_eq!(result.exchanges.len(), 1);
assert_eq!(result.exchanges[0].direct_flow_mw, 0.0);
assert_eq!(result.exchanges[0].block_id, None);
assert_eq!(result.buses.len(), 1);
assert_eq!(result.buses[0].deficit_mw, 0.0);
assert_eq!(result.buses[0].spot_price, 0.0);
assert_eq!(result.buses[0].block_id, None);
}
#[test]
fn extract_equipment_reads_primal_when_with_equipment() {
let indexer = StageIndexer::with_equipment(2, 1, 1, 1, 1, 1, false);
assert_eq!(indexer.theta, 6);
assert_eq!(indexer.turbine, 7..9);
assert_eq!(indexer.spillage, 9..11);
assert_eq!(indexer.thermal, 11..12);
assert_eq!(indexer.line_fwd, 12..13);
assert_eq!(indexer.line_rev, 13..14);
assert_eq!(indexer.deficit, 14..15);
assert_eq!(indexer.excess, 15..16);
let mut primal = vec![0.0_f64; 16];
primal[0] = 100.0; primal[1] = 200.0; primal[2] = 50.0; primal[3] = 60.0; primal[4] = 90.0; primal[5] = 180.0; primal[6] = 500.0; primal[7] = 30.0; primal[8] = 40.0; primal[9] = 5.0; primal[10] = 0.0; primal[11] = 80.0; primal[12] = 15.0; primal[13] = 0.0; primal[14] = 10.0; primal[15] = 2.0;
let mut obj = vec![0.0_f64; 16];
obj[6] = 1.0; obj[9] = 0.1; obj[11] = 50.0; obj[12] = 5.0; obj[14] = 1000.0; obj[15] = 50.0;
let counts = EntityCounts {
hydro_ids: vec![10, 20],
hydro_productivities: vec![1.0, 1.0],
thermal_ids: vec![1],
line_ids: vec![5],
bus_ids: vec![100],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let mut dual = vec![0.0_f64; 7];
dual[4] = -120.0; dual[5] = -95.0; dual[6] = 108_000.0;
let mut row_lower = vec![0.0_f64; 7]; row_lower[6] = 75.0; let block_hours = [720.0_f64]; let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: 600.0,
objective_coeffs: &obj,
row_lower: &row_lower,
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &block_hours,
},
0,
);
assert_eq!(result.hydros.len(), 2); assert_eq!(result.hydros[0].block_id, Some(0));
assert_eq!(result.hydros[0].turbined_m3s, 30.0);
assert_eq!(result.hydros[0].spillage_m3s, 5.0);
assert!((result.hydros[0].spillage_cost - 0.5).abs() < 1e-12);
assert_eq!(result.hydros[0].generation_mw, 30.0); assert_eq!(result.hydros[1].generation_mw, 40.0);
assert_eq!(result.hydros[1].block_id, Some(0));
assert_eq!(result.hydros[1].turbined_m3s, 40.0);
assert_eq!(result.hydros[1].spillage_m3s, 0.0);
assert_eq!(result.thermals.len(), 1);
assert_eq!(result.thermals[0].generation_mw, 80.0);
assert!((result.thermals[0].generation_cost - 4000.0).abs() < 1e-12); assert_eq!(result.thermals[0].block_id, Some(0));
assert_eq!(result.exchanges.len(), 1);
assert_eq!(result.exchanges[0].direct_flow_mw, 15.0);
assert_eq!(result.exchanges[0].reverse_flow_mw, 0.0);
assert!((result.exchanges[0].exchange_cost - 75.0).abs() < 1e-12); assert_eq!(result.exchanges[0].block_id, Some(0));
assert_eq!(result.buses.len(), 1);
assert_eq!(result.buses[0].load_mw, 75.0); assert_eq!(result.buses[0].deficit_mw, 10.0);
assert_eq!(result.buses[0].excess_mw, 2.0);
assert_eq!(result.buses[0].block_id, Some(0));
assert!((result.buses[0].spot_price - 150.0).abs() < 1e-12);
assert!((result.hydros[0].water_value_per_hm3 - (-120.0)).abs() < 1e-12);
assert!((result.hydros[1].water_value_per_hm3 - (-95.0)).abs() < 1e-12);
let cost = &result.costs[0];
assert!((cost.thermal_cost - 4000.0).abs() < 1e-12); assert!((cost.spillage_cost - 0.5).abs() < 1e-12); assert!((cost.deficit_cost - 10_000.0).abs() < 1e-12); assert!((cost.excess_cost - 100.0).abs() < 1e-12); assert!((cost.exchange_cost - 75.0).abs() < 1e-12); }
#[test]
fn extract_optional_entity_types_are_empty_when_absent() {
let indexer = StageIndexer::new(1, 0);
let primal = vec![50.0, 40.0, 200.0]; let dual = vec![];
let counts = EntityCounts {
hydro_ids: vec![1],
hydro_productivities: vec![1.0],
thermal_ids: vec![],
line_ids: vec![],
bus_ids: vec![],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: 250.0,
objective_coeffs: &[],
row_lower: &[],
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[],
},
0,
);
assert!(result.pumping_stations.is_empty());
assert!(result.contracts.is_empty());
assert!(result.non_controllables.is_empty());
assert!(result.generic_violations.is_empty());
}
#[allow(clippy::too_many_arguments)]
fn make_cost(
thermal: f64,
contract: f64,
deficit: f64,
excess: f64,
storage_violation: f64,
filling: f64,
hydro_violation: f64,
inflow_penalty: f64,
generic_violation: f64,
spillage: f64,
fpha: f64,
curtailment: f64,
exchange: f64,
pumping: f64,
) -> SimulationCostResult {
SimulationCostResult {
stage_id: 0,
block_id: None,
total_cost: 0.0,
immediate_cost: 0.0,
future_cost: 0.0,
discount_factor: 1.0,
thermal_cost: thermal,
contract_cost: contract,
deficit_cost: deficit,
excess_cost: excess,
storage_violation_cost: storage_violation,
filling_target_cost: filling,
hydro_violation_cost: hydro_violation,
inflow_penalty_cost: inflow_penalty,
generic_violation_cost: generic_violation,
spillage_cost: spillage,
fpha_turbined_cost: fpha,
curtailment_cost: curtailment,
exchange_cost: exchange,
pumping_cost: pumping,
}
}
fn zero_accum() -> ScenarioCategoryCosts {
ScenarioCategoryCosts {
resource_cost: 0.0,
recourse_cost: 0.0,
violation_cost: 0.0,
regularization_cost: 0.0,
imputed_cost: 0.0,
}
}
#[test]
fn accumulate_single_stage_all_categories() {
let cost = make_cost(
400.0, 100.0, 50.0, 10.0, 20.0, 30.0, 5.0, 3.0, 2.0, 1.0, 4.0, 7.0, 8.0, 60.0, );
let mut accum = zero_accum();
accumulate_category_costs(&cost, &mut accum);
assert_eq!(accum.resource_cost, 500.0);
assert_eq!(accum.recourse_cost, 60.0);
assert_eq!(accum.violation_cost, 60.0);
assert_eq!(accum.regularization_cost, 20.0);
assert_eq!(accum.imputed_cost, 60.0);
}
#[test]
fn accumulate_two_consecutive_stages_sums_correctly() {
let cost1 = make_cost(
100.0, 0.0, 10.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 5.0, );
let cost2 = make_cost(
200.0, 50.0, 20.0, 5.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 10.0, );
let mut accum = zero_accum();
accumulate_category_costs(&cost1, &mut accum);
accumulate_category_costs(&cost2, &mut accum);
assert_eq!(accum.resource_cost, 100.0 + 200.0 + 50.0);
assert_eq!(accum.recourse_cost, 10.0 + 20.0 + 5.0);
assert_eq!(accum.violation_cost, 0.0);
assert_eq!(accum.regularization_cost, 0.0);
assert_eq!(accum.imputed_cost, 5.0 + 10.0);
}
#[test]
fn accumulate_all_zeros_leaves_accum_unchanged() {
let cost = make_cost(
0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
);
let mut accum = ScenarioCategoryCosts {
resource_cost: 1.0,
recourse_cost: 2.0,
violation_cost: 3.0,
regularization_cost: 4.0,
imputed_cost: 5.0,
};
accumulate_category_costs(&cost, &mut accum);
assert_eq!(accum.resource_cost, 1.0);
assert_eq!(accum.recourse_cost, 2.0);
assert_eq!(accum.violation_cost, 3.0);
assert_eq!(accum.regularization_cost, 4.0);
assert_eq!(accum.imputed_cost, 5.0);
}
#[test]
fn accumulate_violation_all_five_components() {
let cost = make_cost(
0.0, 0.0, 0.0, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 0.0, 0.0, 0.0, 0.0, 0.0, );
let mut accum = zero_accum();
accumulate_category_costs(&cost, &mut accum);
assert_eq!(accum.violation_cost, 15.0);
}
#[test]
fn accumulate_regularization_all_four_components() {
let cost = make_cost(
0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 2.0, 3.0, 4.0, 5.0, 0.0, );
let mut accum = zero_accum();
accumulate_category_costs(&cost, &mut accum);
assert_eq!(accum.regularization_cost, 14.0);
}
#[test]
fn test_slack_extraction_with_penalty_active() {
let indexer = StageIndexer::with_equipment(2, 1, 1, 1, 1, 1, true);
assert!(
indexer.has_inflow_penalty,
"has_inflow_penalty must be true"
);
assert!(
!indexer.inflow_slack.is_empty(),
"inflow_slack must be non-empty"
);
let n_cols = indexer.inflow_slack.end;
let mut primal = vec![0.0_f64; n_cols];
primal[0] = 100.0; primal[1] = 200.0; primal[2] = 50.0; primal[3] = 60.0; primal[4] = 90.0; primal[5] = 180.0; primal[6] = 500.0;
primal[indexer.inflow_slack.start] = 7.5; primal[indexer.inflow_slack.start + 1] = 0.0;
let obj = vec![0.0_f64; n_cols];
let dual = vec![0.0_f64; 4];
let row_lower = vec![0.0_f64; indexer.load_balance.end.max(1)];
let counts = EntityCounts {
hydro_ids: vec![10, 20],
hydro_productivities: vec![1.0, 1.0],
thermal_ids: vec![1],
line_ids: vec![5],
bus_ids: vec![100],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: 500.0,
objective_coeffs: &obj,
row_lower: &row_lower,
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[],
},
0,
);
assert_eq!(result.hydros.len(), 2);
assert!(
(result.hydros[0].inflow_nonnegativity_slack_m3s - 7.5).abs() < 1e-12,
"hydro 0 slack should be 7.5, got {}",
result.hydros[0].inflow_nonnegativity_slack_m3s
);
assert_eq!(
result.hydros[1].inflow_nonnegativity_slack_m3s, 0.0,
"hydro 1 slack should be 0.0"
);
}
#[test]
fn test_slack_extraction_without_penalty_is_zero() {
let indexer = StageIndexer::with_equipment(2, 1, 1, 1, 1, 1, false);
assert!(
!indexer.has_inflow_penalty,
"has_inflow_penalty must be false"
);
let n_cols = indexer.excess.end; let primal = vec![1.0_f64; n_cols]; let obj = vec![0.0_f64; n_cols];
let dual = vec![0.0_f64; 4];
let row_lower = vec![0.0_f64; indexer.load_balance.end.max(1)];
let counts = EntityCounts {
hydro_ids: vec![10, 20],
hydro_productivities: vec![1.0, 1.0],
thermal_ids: vec![1],
line_ids: vec![5],
bus_ids: vec![100],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: 1.0,
objective_coeffs: &obj,
row_lower: &row_lower,
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[],
},
0,
);
for (h, hr) in result.hydros.iter().enumerate() {
assert_eq!(
hr.inflow_nonnegativity_slack_m3s, 0.0,
"hydro {h} slack must be 0.0 when penalty is inactive"
);
}
}
#[test]
fn test_slack_extraction_fallback_path_with_penalty() {
let indexer = StageIndexer::with_equipment(2, 1, 0, 0, 0, 0, true);
assert!(
indexer.turbine.is_empty(),
"turbine must be empty to trigger fallback"
);
assert!(
indexer.has_inflow_penalty,
"has_inflow_penalty must be true"
);
let n_cols = indexer.inflow_slack.end;
let mut primal = vec![0.0_f64; n_cols];
primal[0] = 150.0; primal[1] = 250.0; primal[2] = 55.0; primal[3] = 65.0; primal[4] = 140.0; primal[5] = 240.0; primal[6] = 0.0; primal[indexer.inflow_slack.start] = 3.0; primal[indexer.inflow_slack.start + 1] = 0.0;
let obj = vec![0.0_f64; n_cols];
let dual = vec![0.0_f64; 4];
let row_lower = vec![0.0_f64; 1];
let counts = EntityCounts {
hydro_ids: vec![10, 20],
hydro_productivities: vec![1.0, 1.0],
thermal_ids: vec![],
line_ids: vec![],
bus_ids: vec![],
pumping_station_ids: vec![],
contract_ids: vec![],
non_controllable_ids: vec![],
};
let result = extract_stage_result(
&SolutionView {
primal: &primal,
dual: &dual,
objective: 0.0,
objective_coeffs: &obj,
row_lower: &row_lower,
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[],
},
0,
);
assert_eq!(result.hydros.len(), 2);
assert!(
(result.hydros[0].inflow_nonnegativity_slack_m3s - 3.0).abs() < 1e-12,
"hydro 0 fallback slack should be 3.0, got {}",
result.hydros[0].inflow_nonnegativity_slack_m3s
);
assert_eq!(result.hydros[1].inflow_nonnegativity_slack_m3s, 0.0);
}
}