use std::collections::HashMap;
use std::ops::Range;
use cobre_core::ConstraintSense;
use cobre_core::EntityId;
use crate::StageIndexer;
use crate::lp_builder::{COST_SCALE_FACTOR, GenericConstraintRowEntry};
use crate::simulation::types::{
ScenarioCategoryCosts, SimulationBusResult, SimulationContractResult, SimulationCostResult,
SimulationExchangeResult, SimulationGenericViolationResult, 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],
pub generic_constraint_entries: &'a [GenericConstraintRowEntry],
pub ncs_col_start: usize,
pub n_ncs: usize,
pub ncs_entity_ids: &'a [i32],
pub ncs_col_upper: &'a [f64],
pub diversion_upstream: &'a HashMap<EntityId, Vec<usize>>,
pub hydro_productivities: &'a [f64],
pub col_scale: &'a [f64],
pub row_scale: &'a [f64],
}
impl StageExtractionSpec<'_> {
#[inline]
fn col_scale_factor(&self, col: usize) -> f64 {
if col < self.col_scale.len() {
let d = self.col_scale[col];
if d == 0.0 { 1.0 } else { d }
} else {
1.0
}
}
}
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 withdrawal_violation = if indexer.has_withdrawal {
view.primal[indexer.withdrawal_slack.start + h]
} else {
0.0
};
let water_value = view
.dual
.get(indexer.water_balance.start + h)
.copied()
.unwrap_or(0.0)
* COST_SCALE_FACTOR;
let is_fpha = indexer.fpha_hydro_indices.contains(&h);
let productivity_mw_per_m3s = if is_fpha {
None
} else {
Some(spec.hydro_productivities[h])
};
let (evaporation_m3s, evaporation_violation_m3s) =
if let Some(local_evap_idx) = indexer.evap_hydro_indices.iter().position(|&e| e == h) {
let ei = &indexer.evap_indices[local_evap_idx];
let q_ev = view.primal[ei.q_ev_col];
let violation = view.primal[ei.f_evap_plus_col] + view.primal[ei.f_evap_minus_col];
(Some(q_ev), violation)
} else {
(Some(0.0), 0.0)
};
SimulationHydroResult {
stage_id,
block_id: None,
hydro_id,
turbined_m3s: 0.0,
spillage_m3s: 0.0,
evaporation_m3s,
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,
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,
inflow_nonnegativity_slack_m3s: inflow_slack,
water_withdrawal_violation_m3s: withdrawal_violation,
}
}
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 inflow_slack = if indexer.has_inflow_penalty {
view.primal[indexer.inflow_slack.start + h]
} else {
0.0
};
let withdrawal_violation = if indexer.has_withdrawal {
view.primal[indexer.withdrawal_slack.start + h]
} else {
0.0
};
let water_value = view
.dual
.get(indexer.water_balance.start + h)
.copied()
.unwrap_or(0.0)
* COST_SCALE_FACTOR;
let fpha_local: Option<usize> = indexer.fpha_hydro_indices.iter().position(|&e| e == h);
let productivity_mw_per_m3s = if fpha_local.is_some() {
None
} else {
Some(spec.hydro_productivities[h])
};
let local_evap: Option<usize> = indexer.evap_hydro_indices.iter().position(|&e| e == h);
let (evaporation_m3s, evaporation_violation_m3s) = if let Some(lei) = local_evap {
let ei = &indexer.evap_indices[lei];
let q_ev = view.primal[ei.q_ev_col];
let violation = view.primal[ei.f_evap_plus_col] + view.primal[ei.f_evap_minus_col];
(Some(q_ev), violation)
} else {
(Some(0.0), 0.0)
};
let hydro_entity_id = EntityId(hydro_id);
let div_sources = spec.diversion_upstream.get(&hydro_entity_id);
(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];
let diverted_outflow = if indexer.diversion.is_empty() {
0.0
} else {
view.primal[indexer.diversion.start + h * n_blks + b]
};
let diverted_inflow = if let Some(sources) = div_sources {
let mut total = 0.0;
for &d_idx in sources {
total += view.primal[indexer.diversion.start + d_idx * n_blks + b];
}
total
} else {
0.0
};
let generation_mw = if let Some(local_fpha_idx) = fpha_local {
view.primal[indexer.generation.start + local_fpha_idx * n_blks + b]
} else {
turbined * spec.hydro_productivities[h]
};
#[allow(clippy::cast_possible_truncation)]
SimulationHydroResult {
stage_id,
block_id: Some(b as u32),
hydro_id,
turbined_m3s: turbined,
spillage_m3s: spillage,
evaporation_m3s,
diverted_inflow_m3s: Some(diverted_inflow),
diverted_outflow_m3s: Some(diverted_outflow),
incremental_inflow_m3s: incremental_inflow,
inflow_m3s: incremental_inflow,
storage_initial_hm3: storage_initial,
storage_final_hm3: storage_final,
generation_mw,
productivity_mw_per_m3s,
spillage_cost: spillage * view.objective_coeffs[s_col] / spec.col_scale_factor(s_col)
* COST_SCALE_FACTOR,
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,
inflow_nonnegativity_slack_m3s: inflow_slack,
water_withdrawal_violation_m3s: withdrawal_violation,
}
})
}
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]
/ spec.col_scale_factor(col)
* COST_SCALE_FACTOR,
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]
/ spec.col_scale_factor(fwd_col)
+ rev * view.objective_coeffs[rev_col]
/ spec.col_scale_factor(rev_col))
* COST_SCALE_FACTOR,
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 {
let max_segs = indexer.max_deficit_segments;
spec.entity_counts
.bus_ids
.iter()
.enumerate()
.flat_map(move |(bus_idx, &bus_id)| {
(0..n_blks).map(move |b| {
let deficit_mw: f64 = (0..max_segs)
.map(|s| {
let col = indexer.deficit.start
+ bus_idx * max_segs * n_blks
+ s * n_blks
+ b;
view.primal[col]
})
.sum();
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,
excess_mw: view.primal[excess_col],
spot_price: if hrs > 0.0 {
raw_dual * COST_SCALE_FACTOR / 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 (generic_violations, generic_violation_cost) =
extract_generic_violations(view, spec, stage_id);
let (non_controllables, ncs_curtailment_cost) = extract_non_controllables(view, spec, stage_id);
let costs = vec![compute_cost_result(
view,
spec.indexer,
spec.col_scale,
generic_violation_cost,
ncs_curtailment_cost,
stage_id,
)];
let (inflow_lags, pumping_stations, contracts) = 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,
}
}
#[allow(clippy::too_many_lines)]
fn compute_cost_result(
view: &SolutionView<'_>,
indexer: &StageIndexer,
col_scale: &[f64],
generic_violation_cost: f64,
ncs_curtailment_cost: f64,
stage_id: u32,
) -> SimulationCostResult {
let scale_factor = |col: usize| -> f64 {
if col < col_scale.len() {
let d = col_scale[col];
if d == 0.0 { 1.0 } else { d }
} else {
1.0
}
};
let col_cost = |col: usize| view.primal[col] * view.objective_coeffs[col] / scale_factor(col);
let range_sum = |r: std::ops::Range<usize>| -> f64 { r.map(col_cost).sum() };
let future_cost = view.primal[indexer.theta] * COST_SCALE_FACTOR;
let immediate_cost = (view.objective - view.primal[indexer.theta]) * COST_SCALE_FACTOR;
let thermal_cost = if indexer.thermal.is_empty() {
0.0
} else {
range_sum(indexer.thermal.clone()) * COST_SCALE_FACTOR
};
let spillage_cost = if indexer.spillage.is_empty() {
0.0
} else {
range_sum(indexer.spillage.clone()) * COST_SCALE_FACTOR
};
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::<f64>()
* COST_SCALE_FACTOR
};
let deficit_cost = if indexer.deficit.is_empty() {
0.0
} else {
range_sum(indexer.deficit.clone()) * COST_SCALE_FACTOR
};
let excess_cost = if indexer.excess.is_empty() {
0.0
} else {
range_sum(indexer.excess.clone()) * COST_SCALE_FACTOR
};
let fpha_turbined_cost = if indexer.generation.is_empty() {
0.0
} else {
let n_blks = indexer.n_blks;
indexer
.fpha_hydro_indices
.iter()
.enumerate()
.flat_map(|(local_fpha_idx, _sys_h)| {
(0..n_blks).map(move |b| indexer.generation.start + local_fpha_idx * n_blks + b)
})
.map(col_cost)
.sum::<f64>()
* COST_SCALE_FACTOR
};
let inflow_penalty_cost = if indexer.inflow_slack.is_empty() {
0.0
} else {
range_sum(indexer.inflow_slack.clone()) * COST_SCALE_FACTOR
};
let evap_violation_cost: f64 = indexer
.evap_indices
.iter()
.map(|ei| col_cost(ei.f_evap_plus_col) + col_cost(ei.f_evap_minus_col))
.sum::<f64>()
* COST_SCALE_FACTOR;
let withdrawal_violation_cost = if indexer.withdrawal_slack.is_empty() {
0.0
} else {
range_sum(indexer.withdrawal_slack.clone()) * COST_SCALE_FACTOR
};
let hydro_violation_cost = evap_violation_cost + withdrawal_violation_cost;
let diversion_cost = if indexer.diversion.is_empty() {
0.0
} else {
range_sum(indexer.diversion.clone()) * COST_SCALE_FACTOR
};
SimulationCostResult {
stage_id,
block_id: None,
total_cost: view.objective * COST_SCALE_FACTOR,
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,
inflow_penalty_cost,
generic_violation_cost,
spillage_cost: spillage_cost + diversion_cost,
fpha_turbined_cost,
curtailment_cost: ncs_curtailment_cost,
exchange_cost,
pumping_cost: 0.0,
}
}
fn extract_generic_violations(
view: &SolutionView<'_>,
spec: &StageExtractionSpec<'_>,
stage_id: u32,
) -> (Vec<SimulationGenericViolationResult>, f64) {
let entries = spec.generic_constraint_entries;
if entries.is_empty() {
return (Vec::new(), 0.0);
}
let indexer = spec.indexer;
let gc_row_start = indexer.generic_constraint_rows.start;
let mut results = Vec::with_capacity(entries.len());
let mut total_cost = 0.0;
for (entry_idx, entry) in entries.iter().enumerate() {
let row_idx = gc_row_start + entry_idx;
let _dual_value = if row_idx < view.dual.len() {
view.dual[row_idx]
} else {
0.0
};
let block_hours = spec
.block_hours
.get(entry.block_idx)
.copied()
.unwrap_or(0.0);
let (slack_value, slack_cost) = if entry.slack_enabled {
match entry.sense {
ConstraintSense::Equal => {
let s_plus = entry.slack_plus_col.map_or(0.0, |col| view.primal[col]);
let s_minus = entry.slack_minus_col.map_or(0.0, |col| view.primal[col]);
let net = s_plus - s_minus;
let cost = (s_plus + s_minus) * entry.slack_penalty * block_hours;
(net, cost)
}
ConstraintSense::LessEqual | ConstraintSense::GreaterEqual => {
let s = entry.slack_plus_col.map_or(0.0, |col| view.primal[col]);
let cost = s * entry.slack_penalty * block_hours;
(s, cost)
}
}
} else {
(0.0, 0.0)
};
total_cost += slack_cost;
results.push(SimulationGenericViolationResult {
stage_id,
#[allow(clippy::cast_possible_truncation)]
block_id: Some(entry.block_idx as u32),
constraint_id: entry.entity_id,
slack_value,
slack_cost,
});
}
(results, total_cost)
}
fn extract_non_controllables(
view: &SolutionView<'_>,
spec: &StageExtractionSpec<'_>,
stage_id: u32,
) -> (Vec<SimulationNonControllableResult>, f64) {
let n_ncs = spec.n_ncs;
if n_ncs == 0 {
return (Vec::new(), 0.0);
}
let n_blks = spec.indexer.n_blks;
let col_start = spec.ncs_col_start;
let mut results = Vec::with_capacity(n_ncs * n_blks);
let mut total_curtailment_cost = 0.0;
for (local_idx, &ncs_id) in spec.ncs_entity_ids.iter().enumerate() {
for blk in 0..n_blks {
let col = col_start + local_idx * n_blks + blk;
let generation_mw = view.primal[col];
let col_upper_offset = local_idx * n_blks + blk;
debug_assert!(
col_upper_offset < spec.ncs_col_upper.len(),
"NCS col_upper out of bounds: offset {col_upper_offset}, len {}",
spec.ncs_col_upper.len()
);
let available_mw = spec.ncs_col_upper[col_upper_offset];
let curtailment_mw = available_mw - generation_mw;
let col_cost = -(curtailment_mw * view.objective_coeffs[col]
/ spec.col_scale_factor(col))
* COST_SCALE_FACTOR;
total_curtailment_cost += col_cost;
#[allow(clippy::cast_possible_truncation)]
results.push(SimulationNonControllableResult {
stage_id,
block_id: Some(blk as u32),
non_controllable_id: ncs_id,
generation_mw,
available_mw,
curtailment_mw,
curtailment_cost: col_cost,
operative_state_code: 1,
});
}
}
(results, total_curtailment_cost)
}
fn extract_stub_collections(
view: &SolutionView<'_>,
spec: &StageExtractionSpec<'_>,
stage_id: u32,
) -> (
Vec<SimulationInflowLagResult>,
Vec<SimulationPumpingResult>,
Vec<SimulationContractResult>,
) {
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();
(inflow_lags, pumping_stations, contracts)
}
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, clippy::too_many_lines)]
mod tests {
use std::collections::HashMap;
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],
0.0, 0.0, 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: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[1.0, 1.0],
col_scale: &[],
row_scale: &[],
},
3,
);
assert_eq!(result.costs.len(), 1);
assert_eq!(result.costs[0].stage_id, 3);
assert_eq!(result.costs[0].future_cost, 999_500.0);
}
#[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: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[1.0, 1.0],
col_scale: &[],
row_scale: &[],
},
0,
);
let cost = &result.costs[0];
assert_eq!(cost.future_cost, theta_val * 1000.0);
assert_eq!(cost.immediate_cost, (objective - theta_val) * 1000.0);
assert_eq!(cost.total_cost, objective * 1000.0);
}
#[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: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[1.0, 1.0],
col_scale: &[],
row_scale: &[],
},
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: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[1.0, 1.0],
col_scale: &[],
row_scale: &[],
},
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, 0.0, 0.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: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[1.0, 1.0],
col_scale: &[],
row_scale: &[],
},
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: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[1.0, 1.0],
col_scale: &[],
row_scale: &[],
},
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: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[1.0, 1.0],
col_scale: &[],
row_scale: &[],
},
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(
&crate::indexer::EquipmentCounts {
hydro_count: 2,
max_par_order: 1,
n_thermals: 1,
n_lines: 1,
n_buses: 1,
n_blks: 1,
has_inflow_penalty: false,
max_deficit_segments: 1,
},
&crate::indexer::FphaConfig {
hydro_indices: vec![],
planes_per_hydro: vec![],
},
);
assert_eq!(indexer.theta, 8);
assert_eq!(indexer.turbine, 9..11);
assert_eq!(indexer.spillage, 11..13);
assert_eq!(indexer.diversion, 13..15);
assert_eq!(indexer.thermal, 15..16);
assert_eq!(indexer.line_fwd, 16..17);
assert_eq!(indexer.line_rev, 17..18);
assert_eq!(indexer.deficit, 18..19);
assert_eq!(indexer.excess, 19..20);
let n_cols = indexer.withdrawal_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[6] = 90.0; primal[7] = 180.0; primal[8] = 500.0; primal[9] = 30.0; primal[10] = 40.0; primal[11] = 5.0; primal[12] = 0.0; primal[15] = 80.0; primal[16] = 15.0; primal[17] = 0.0; primal[18] = 10.0; primal[19] = 2.0;
let mut obj = vec![0.0_f64; n_cols];
obj[8] = 1.0; obj[11] = 0.1; obj[15] = 50.0; obj[16] = 5.0; obj[18] = 1000.0; obj[19] = 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; 9];
dual[6] = -120.0; dual[7] = -95.0; dual[8] = 108_000.0;
let mut row_lower = vec![0.0_f64; 9]; row_lower[8] = 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,
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[1.0, 1.0],
col_scale: &[],
row_scale: &[],
},
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 - 500.0).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 - 4_000_000.0).abs() < 1e-9); 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_000.0).abs() < 1e-9); 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_000.0).abs() < 1e-9);
assert!((result.hydros[0].water_value_per_hm3 - (-120_000.0)).abs() < 1e-9);
assert!((result.hydros[1].water_value_per_hm3 - (-95_000.0)).abs() < 1e-9);
let cost = &result.costs[0];
assert!((cost.thermal_cost - 4_000_000.0).abs() < 1e-9); assert!((cost.spillage_cost - 500.0).abs() < 1e-12); assert!((cost.deficit_cost - 10_000_000.0).abs() < 1e-9); assert!((cost.excess_cost - 100_000.0).abs() < 1e-9); assert!((cost.exchange_cost - 75_000.0).abs() < 1e-9); }
#[test]
fn extract_optional_entity_types_are_empty_when_absent() {
let indexer = StageIndexer::new(1, 0);
let primal = vec![50.0, 0.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: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[1.0],
col_scale: &[],
row_scale: &[],
},
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(
&crate::indexer::EquipmentCounts {
hydro_count: 2,
max_par_order: 1,
n_thermals: 1,
n_lines: 1,
n_buses: 1,
n_blks: 1,
has_inflow_penalty: true,
max_deficit_segments: 1,
},
&crate::indexer::FphaConfig {
hydro_indices: vec![],
planes_per_hydro: vec![],
},
);
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.withdrawal_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: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[1.0, 1.0],
col_scale: &[],
row_scale: &[],
},
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(
&crate::indexer::EquipmentCounts {
hydro_count: 2,
max_par_order: 1,
n_thermals: 1,
n_lines: 1,
n_buses: 1,
n_blks: 1,
has_inflow_penalty: false,
max_deficit_segments: 1,
},
&crate::indexer::FphaConfig {
hydro_indices: vec![],
planes_per_hydro: vec![],
},
);
assert!(
!indexer.has_inflow_penalty,
"has_inflow_penalty must be false"
);
let n_cols = indexer.withdrawal_slack.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: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[1.0, 1.0],
col_scale: &[],
row_scale: &[],
},
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(
&crate::indexer::EquipmentCounts {
hydro_count: 2,
max_par_order: 1,
n_thermals: 0,
n_lines: 0,
n_buses: 0,
n_blks: 0,
has_inflow_penalty: true,
max_deficit_segments: 1,
},
&crate::indexer::FphaConfig {
hydro_indices: vec![],
planes_per_hydro: vec![],
},
);
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.withdrawal_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: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[1.0, 1.0],
col_scale: &[],
row_scale: &[],
},
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);
}
fn make_indexer_2h_1fpha_1blk() -> StageIndexer {
StageIndexer::with_equipment(
&crate::indexer::EquipmentCounts {
hydro_count: 2,
max_par_order: 0,
n_thermals: 0,
n_lines: 0,
n_buses: 0,
n_blks: 1,
has_inflow_penalty: false,
max_deficit_segments: 1,
},
&crate::indexer::FphaConfig {
hydro_indices: vec![0],
planes_per_hydro: vec![2],
},
)
}
#[test]
fn fpha_generation_read_from_lp_column() {
let indexer = make_indexer_2h_1fpha_1blk();
assert_eq!(indexer.generation.start, 13, "generation starts at 13");
assert_eq!(indexer.fpha_hydro_indices, vec![0]);
let n_cols = indexer.withdrawal_slack.end;
let mut primal = vec![0.0_f64; n_cols];
primal[0] = 50.0; primal[1] = 80.0; primal[4] = 45.0; primal[5] = 75.0; primal[6] = 0.0; primal[7] = 20.0; primal[8] = 30.0; primal[9] = 0.0; primal[10] = 0.0; primal[13] = 75.0;
let obj = vec![0.0_f64; n_cols];
let dual = vec![0.0_f64; 2];
let row_lower = vec![0.0_f64; 1];
let counts = EntityCounts {
hydro_ids: vec![1, 2],
hydro_productivities: vec![0.0, 1.5], 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: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[0.0, 1.5],
col_scale: &[],
row_scale: &[],
},
0,
);
assert_eq!(result.hydros.len(), 2);
assert!(
(result.hydros[0].generation_mw - 75.0).abs() < 1e-12,
"FPHA generation_mw should be 75.0, got {}",
result.hydros[0].generation_mw
);
assert!(
(result.hydros[1].generation_mw - 45.0).abs() < 1e-12,
"constant-productivity generation_mw should be 45.0, got {}",
result.hydros[1].generation_mw
);
}
#[test]
fn fpha_productivity_is_none() {
let indexer = make_indexer_2h_1fpha_1blk();
let n_cols = indexer.withdrawal_slack.end;
let primal = vec![0.0_f64; n_cols];
let obj = vec![0.0_f64; n_cols];
let dual = vec![0.0_f64; 2];
let row_lower = vec![0.0_f64; 1];
let counts = EntityCounts {
hydro_ids: vec![1, 2],
hydro_productivities: vec![0.0, 1.5],
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: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[0.0, 1.5],
col_scale: &[],
row_scale: &[],
},
0,
);
assert_eq!(
result.hydros[0].productivity_mw_per_m3s, None,
"FPHA hydro must have productivity_mw_per_m3s == None"
);
assert_eq!(
result.hydros[1].productivity_mw_per_m3s,
Some(1.5),
"constant-productivity hydro must have Some(1.5)"
);
}
fn make_indexer_1h_evap_1blk() -> StageIndexer {
StageIndexer::with_equipment_and_evaporation(
&crate::indexer::EquipmentCounts {
hydro_count: 1,
max_par_order: 0,
n_thermals: 0,
n_lines: 0,
n_buses: 0,
n_blks: 1,
has_inflow_penalty: false,
max_deficit_segments: 1,
},
&crate::indexer::FphaConfig {
hydro_indices: vec![],
planes_per_hydro: vec![],
},
&crate::indexer::EvapConfig {
hydro_indices: vec![0],
},
)
}
#[test]
fn evaporation_read_from_lp_column() {
let indexer = make_indexer_1h_evap_1blk();
assert_eq!(indexer.evap_hydro_indices, vec![0]);
let ei = &indexer.evap_indices[0];
assert_eq!(ei.q_ev_col, 7);
assert_eq!(ei.f_evap_plus_col, 8);
assert_eq!(ei.f_evap_minus_col, 9);
let n_cols = indexer.withdrawal_slack.end;
let mut primal = vec![0.0_f64; n_cols];
primal[0] = 200.0; primal[2] = 190.0; primal[3] = 0.0; primal[4] = 10.0; primal[5] = 0.0; primal[7] = 3.5;
let obj = vec![0.0_f64; n_cols];
let dual = vec![0.0_f64; 1];
let row_lower = vec![0.0_f64; 1];
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: 0.0,
objective_coeffs: &obj,
row_lower: &row_lower,
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[1.0],
col_scale: &[],
row_scale: &[],
},
0,
);
assert_eq!(result.hydros.len(), 1);
assert_eq!(
result.hydros[0].evaporation_m3s,
Some(3.5),
"evaporation_m3s should be Some(3.5)"
);
assert!(
result.hydros[0].evaporation_violation_m3s.abs() < 1e-12,
"evaporation_violation_m3s should be 0.0"
);
}
#[test]
fn evaporation_violation_is_sum_of_slacks() {
let indexer = make_indexer_1h_evap_1blk();
let n_cols = indexer.withdrawal_slack.end;
let mut primal = vec![0.0_f64; n_cols];
primal[0] = 200.0;
primal[2] = 190.0; primal[7] = 2.0; primal[8] = 0.5; primal[9] = 0.0;
let obj = vec![0.0_f64; n_cols];
let dual = vec![0.0_f64; 1];
let row_lower = vec![0.0_f64; 1];
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: 0.0,
objective_coeffs: &obj,
row_lower: &row_lower,
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[1.0],
col_scale: &[],
row_scale: &[],
},
0,
);
assert!(
(result.hydros[0].evaporation_violation_m3s - 0.5).abs() < 1e-12,
"evaporation_violation_m3s should be 0.5, got {}",
result.hydros[0].evaporation_violation_m3s
);
}
#[test]
fn fpha_turbined_cost_in_compute_cost_result() {
let indexer = make_indexer_2h_1fpha_1blk();
let n_cols = indexer.withdrawal_slack.end;
let mut primal = vec![0.0_f64; n_cols];
primal[6] = 500.0;
primal[13] = 30.0;
let mut obj = vec![0.0_f64; n_cols];
obj[13] = 0.01;
let dual = vec![0.0_f64; 2];
let row_lower = vec![0.0_f64; 1];
let counts = EntityCounts {
hydro_ids: vec![1, 2],
hydro_productivities: vec![0.0, 1.5],
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: 500.3, objective_coeffs: &obj,
row_lower: &row_lower,
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[0.0, 1.5],
col_scale: &[],
row_scale: &[],
},
0,
);
let cost = &result.costs[0];
assert!(
(cost.fpha_turbined_cost - 300.0).abs() < 1e-9,
"fpha_turbined_cost should be 300.0 (30.0 * 0.01 * COST_SCALE_FACTOR), got {}",
cost.fpha_turbined_cost
);
}
#[test]
fn cost_breakdown_sums_to_immediate_identity_scale() {
let indexer = make_indexer_2h_1fpha_1blk();
let n_cols = indexer.withdrawal_slack.end;
let mut primal = vec![0.0_f64; n_cols];
primal[6] = 500.0; primal[13] = 30.0;
let mut obj = vec![0.0_f64; n_cols];
obj[13] = 0.01; let objective_val = 500.3_f64;
let dual = vec![0.0_f64; 2];
let row_lower = vec![0.0_f64; 1];
let counts = EntityCounts {
hydro_ids: vec![1, 2],
hydro_productivities: vec![0.0, 1.5],
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: objective_val,
objective_coeffs: &obj,
row_lower: &row_lower,
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[0.0, 1.5],
col_scale: &[],
row_scale: &[],
},
0,
);
let cost = &result.costs[0];
assert!(
(cost.immediate_cost - 300.0).abs() < 1e-6,
"immediate_cost should be 300.0, got {}",
cost.immediate_cost
);
let component_sum = cost.thermal_cost
+ cost.deficit_cost
+ cost.excess_cost
+ cost.exchange_cost
+ cost.spillage_cost
+ cost.generic_violation_cost
+ cost.fpha_turbined_cost
+ cost.curtailment_cost;
assert!(
(component_sum - cost.immediate_cost).abs() < 1e-6,
"per-component cost sum ({component_sum}) must equal immediate_cost ({})",
cost.immediate_cost
);
}
#[test]
fn cost_unscaled_by_col_scale() {
let indexer = make_indexer_2h_1fpha_1blk();
let n_cols = indexer.withdrawal_slack.end;
let mut primal = vec![0.0_f64; n_cols];
primal[6] = 500.0; primal[13] = 30.0;
let mut obj = vec![0.0_f64; n_cols];
obj[13] = 0.01;
let mut col_scale = vec![1.0_f64; n_cols];
col_scale[13] = 2.0;
let dual = vec![0.0_f64; 2];
let row_lower = vec![0.0_f64; 1];
let counts = EntityCounts {
hydro_ids: vec![1, 2],
hydro_productivities: vec![0.0, 1.5],
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: 500.3, objective_coeffs: &obj,
row_lower: &row_lower,
},
&StageExtractionSpec {
indexer: &indexer,
entity_counts: &counts,
inflow_m3s_per_hydro: &[],
block_hours: &[],
generic_constraint_entries: &[],
ncs_col_start: 0,
n_ncs: 0,
ncs_entity_ids: &[],
ncs_col_upper: &[],
diversion_upstream: &HashMap::new(),
hydro_productivities: &[0.0, 1.5],
col_scale: &col_scale,
row_scale: &[],
},
0,
);
let cost = &result.costs[0];
assert!(
(cost.fpha_turbined_cost - 150.0).abs() < 1e-6,
"fpha_turbined_cost should be 150.0 (unscaled by col_scale=2.0), got {}",
cost.fpha_turbined_cost
);
}
}