use std::collections::HashMap;
use cobre_core::{ComputedParameter, EntityId, Hydro, ParameterKind, ScalarParameter};
use thiserror::Error;
use crate::energy_conversion::{EnergyConversionSet, HydroEnergyProductivityOverride};
#[derive(Debug, Error, PartialEq)]
pub enum ResolvedParametersError {
#[error("parameter '{name}': PerStage vector has {got} values but n_stages = {expected}")]
PerStageLengthMismatch {
name: String,
expected: usize,
got: usize,
},
#[error(
"parameter '{name}': no seasonal value for season_id={season_id} (needed by stage {stage_idx})"
)]
MissingSeason {
name: String,
season_id: i32,
stage_idx: usize,
},
#[error("parameter '{name}': computed variant references unknown hydro_id={hydro_id:?}")]
UnknownHydro {
name: String,
hydro_id: EntityId,
},
#[error(
"parameter '{name}': no specific productivity (`ρ_esp`) available for hydro_id={hydro_id:?}"
)]
MissingSpecificProductivity {
name: String,
hydro_id: EntityId,
},
}
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
pub struct ResolvedParameters {
pub per_param: Vec<Vec<f64>>,
pub id_to_slot: Vec<(i32, usize)>,
}
impl ResolvedParameters {
#[must_use]
pub fn get(&self, id: EntityId, stage_idx: usize) -> f64 {
if let Ok(pos) = self.id_to_slot.binary_search_by_key(&id.0, |(k, _)| *k) {
let slot = self.id_to_slot[pos].1;
let row = &self.per_param[slot];
if stage_idx < row.len() {
row[stage_idx]
} else {
debug_assert!(
false,
"ResolvedParameters miss: id={id:?}, stage={stage_idx} (row len={})",
row.len()
);
0.0
}
} else {
debug_assert!(
false,
"ResolvedParameters miss: id={id:?}, stage={stage_idx}"
);
0.0
}
}
}
pub fn build_resolved_parameters(
parameters: &[ScalarParameter],
energy_conversion: &EnergyConversionSet,
override_table: &HydroEnergyProductivityOverride,
hydros: &[Hydro],
stage_to_season: &[i32],
n_stages: usize,
) -> Result<ResolvedParameters, ResolvedParametersError> {
let hydro_index: HashMap<EntityId, usize> = hydros
.iter()
.enumerate()
.map(|(idx, h)| (h.id, idx))
.collect();
let mut per_param: Vec<Vec<f64>> = Vec::with_capacity(parameters.len());
let mut id_to_slot: Vec<(i32, usize)> = Vec::with_capacity(parameters.len());
for (slot, param) in parameters.iter().enumerate() {
let values = resolve_kind(
¶m.kind,
¶m.name,
n_stages,
stage_to_season,
energy_conversion,
override_table,
hydros,
&hydro_index,
)?;
per_param.push(values);
id_to_slot.push((param.id.0, slot));
}
id_to_slot.sort_by_key(|(k, _)| *k);
debug_assert!(
id_to_slot.windows(2).all(|w| w[0].0 != w[1].0),
"duplicate EntityId.0 values in parameters: {:?}",
id_to_slot
.windows(2)
.filter(|w| w[0].0 == w[1].0)
.map(|w| w[0].0)
.collect::<Vec<_>>()
);
Ok(ResolvedParameters {
per_param,
id_to_slot,
})
}
#[allow(clippy::too_many_arguments)]
fn resolve_kind(
kind: &ParameterKind,
name: &str,
n_stages: usize,
stage_to_season: &[i32],
energy_conversion: &EnergyConversionSet,
override_table: &HydroEnergyProductivityOverride,
hydros: &[Hydro],
hydro_index: &HashMap<EntityId, usize>,
) -> Result<Vec<f64>, ResolvedParametersError> {
match kind {
ParameterKind::Constant { value: c } => Ok(vec![*c; n_stages]),
ParameterKind::PerStage { values: v } => {
if v.len() != n_stages {
return Err(ResolvedParametersError::PerStageLengthMismatch {
name: name.to_string(),
expected: n_stages,
got: v.len(),
});
}
Ok(v.clone())
}
ParameterKind::Seasonal { values: pairs } => {
let mut values = Vec::with_capacity(n_stages);
for (t, &season_id) in stage_to_season.iter().enumerate() {
match pairs.binary_search_by_key(&season_id, |(k, _)| *k) {
Ok(pos) => values.push(pairs[pos].1),
Err(_) => {
return Err(ResolvedParametersError::MissingSeason {
name: name.to_string(),
season_id,
stage_idx: t,
});
}
}
}
Ok(values)
}
ParameterKind::Computed { computed_spec: cp } => resolve_computed(
*cp,
name,
n_stages,
stage_to_season,
energy_conversion,
override_table,
hydros,
hydro_index,
),
}
}
#[allow(clippy::too_many_arguments)]
fn resolve_computed(
cp: ComputedParameter,
name: &str,
n_stages: usize,
_stage_to_season: &[i32],
energy_conversion: &EnergyConversionSet,
override_table: &HydroEnergyProductivityOverride,
hydros: &[Hydro],
hydro_index: &HashMap<EntityId, usize>,
) -> Result<Vec<f64>, ResolvedParametersError> {
let hydro_id = match cp {
ComputedParameter::EquivalentProductivity { hydro_id }
| ComputedParameter::AccumulatedProductivity { hydro_id }
| ComputedParameter::ReferenceVolume { hydro_id }
| ComputedParameter::ReferenceTurbine { hydro_id }
| ComputedParameter::MinStorage { hydro_id }
| ComputedParameter::MaxStorage { hydro_id }
| ComputedParameter::SpecificProductivity { hydro_id } => hydro_id,
};
let hydro_idx = hydro_index.get(&hydro_id).copied().ok_or_else(|| {
ResolvedParametersError::UnknownHydro {
name: name.to_string(),
hydro_id,
}
})?;
let hydro = &hydros[hydro_idx];
match cp {
ComputedParameter::MinStorage { .. } => {
return Ok(vec![hydro.min_storage_hm3; n_stages]);
}
ComputedParameter::MaxStorage { .. } => {
return Ok(vec![hydro.max_storage_hm3; n_stages]);
}
_ => {}
}
let mut values = Vec::with_capacity(n_stages);
for t in 0..n_stages {
let value = match cp {
ComputedParameter::EquivalentProductivity { .. } => {
energy_conversion
.conversion(hydro_idx, t)
.equivalent_productivity_mw_per_m3s
}
ComputedParameter::AccumulatedProductivity { .. } => {
energy_conversion.accumulated_productivity(hydro_idx, t)
}
ComputedParameter::ReferenceVolume { .. } => {
override_table.reference_volume(hydro_id, t).unwrap_or(
energy_conversion
.conversion(hydro_idx, t)
.reference_volume_hm3,
)
}
ComputedParameter::ReferenceTurbine { .. } => {
override_table.reference_outflow(hydro_id, t).unwrap_or(
energy_conversion
.conversion(hydro_idx, t)
.reference_outflow_m3s,
)
}
ComputedParameter::MinStorage { .. } => {
hydro.min_storage_hm3
}
ComputedParameter::MaxStorage { .. } => {
hydro.max_storage_hm3
}
ComputedParameter::SpecificProductivity { .. } => override_table
.specific_productivity(hydro_id, t)
.or(hydro.specific_productivity_mw_per_m3s_per_m)
.ok_or_else(|| ResolvedParametersError::MissingSpecificProductivity {
name: name.to_string(),
hydro_id,
})?,
};
values.push(value);
}
Ok(values)
}
pub const RESOLVED_PARAMETERS_WIRE_VERSION: u32 = 1;
#[derive(serde::Serialize, serde::Deserialize)]
struct ResolvedParametersWireEnvelope {
version: u32,
payload: ResolvedParameters,
}
pub fn serialize_resolved_parameters(
table: &ResolvedParameters,
) -> Result<Vec<u8>, crate::error::SddpError> {
let envelope = ResolvedParametersWireEnvelope {
version: RESOLVED_PARAMETERS_WIRE_VERSION,
payload: table.clone(),
};
postcard::to_allocvec(&envelope).map_err(|e| {
crate::error::SddpError::Validation(format!("postcard resolved_parameters: {e}"))
})
}
pub fn deserialize_resolved_parameters(
bytes: &[u8],
) -> Result<ResolvedParameters, crate::error::SddpError> {
let envelope: ResolvedParametersWireEnvelope = postcard::from_bytes(bytes).map_err(|e| {
crate::error::SddpError::Validation(format!("postcard resolved_parameters: {e}"))
})?;
if envelope.version != RESOLVED_PARAMETERS_WIRE_VERSION {
return Err(crate::error::SddpError::WireVersionMismatch {
encoded: envelope.version,
expected: RESOLVED_PARAMETERS_WIRE_VERSION,
});
}
Ok(envelope.payload)
}
#[cfg(test)]
#[allow(clippy::cast_precision_loss)]
mod tests {
use cobre_core::{
ComputedParameter, EntityId, ParameterKind, ScalarParameter,
entities::hydro::{HydroGenerationModel, HydroPenalties},
};
use super::*;
use crate::energy_conversion::{EnergyConversion, EnergyConversionSet};
fn make_hydro(
id: i32,
min_storage: f64,
max_storage: f64,
specific_productivity: Option<f64>,
) -> Hydro {
Hydro {
id: EntityId(id),
name: format!("h{id}"),
bus_id: EntityId(1),
downstream_id: None,
entry_stage_id: None,
exit_stage_id: None,
generation_model: HydroGenerationModel::ConstantProductivity,
min_storage_hm3: min_storage,
max_storage_hm3: max_storage,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
min_turbined_m3s: 0.0,
max_turbined_m3s: 500.0,
specific_productivity_mw_per_m3s_per_m: specific_productivity,
min_generation_mw: 0.0,
max_generation_mw: 100.0,
tailrace: None,
hydraulic_losses: None,
efficiency: None,
evaporation_coefficients_mm: None,
evaporation_reference_volumes_hm3: None,
diversion: None,
filling: None,
penalties: HydroPenalties {
spillage_cost: 0.0,
diversion_cost: 0.0,
turbined_cost: 0.0,
storage_violation_below_cost: 0.0,
filling_target_violation_cost: 0.0,
turbined_violation_below_cost: 0.0,
outflow_violation_below_cost: 0.0,
outflow_violation_above_cost: 0.0,
generation_violation_below_cost: 0.0,
evaporation_violation_cost: 0.0,
water_withdrawal_violation_cost: 0.0,
water_withdrawal_violation_pos_cost: 0.0,
water_withdrawal_violation_neg_cost: 0.0,
evaporation_violation_pos_cost: 0.0,
evaporation_violation_neg_cost: 0.0,
inflow_nonnegativity_cost: 1000.0,
},
}
}
fn make_energy_conversion(n_hydros: usize, n_stages: usize) -> EnergyConversionSet {
let per_hydro_stage: Vec<Vec<EnergyConversion>> = (0..n_hydros)
.map(|h| {
(0..n_stages)
.map(|t| EnergyConversion {
equivalent_productivity_mw_per_m3s: 0.42 + h as f64 + t as f64 * 0.01,
reference_volume_hm3: 1000.0 + h as f64 * 100.0 + t as f64,
reference_outflow_m3s: 500.0 + h as f64 * 50.0 + t as f64 * 0.1,
})
.collect()
})
.collect();
let accumulated: Vec<Vec<f64>> = (0..n_hydros)
.map(|h| {
(0..n_stages)
.map(|t| 0.90 + h as f64 * 0.05 + t as f64 * 0.001)
.collect()
})
.collect();
EnergyConversionSet::new(per_hydro_stage, accumulated, n_hydros, n_stages)
}
fn make_setup_inputs(
n_stages: usize,
) -> (
Vec<Hydro>,
EnergyConversionSet,
HydroEnergyProductivityOverride,
Vec<i32>,
) {
let hydros = vec![
make_hydro(0, 50.0, 2000.0, Some(0.0085)),
make_hydro(1, 100.0, 5000.0, Some(0.0090)),
];
let energy_conversion = make_energy_conversion(2, n_stages);
let override_table = HydroEnergyProductivityOverride::default();
let stage_to_season: Vec<i32> = (0..n_stages).map(|t| (t % 4) as i32).collect();
(hydros, energy_conversion, override_table, stage_to_season)
}
fn make_param(id: i32, kind: ParameterKind) -> ScalarParameter {
ScalarParameter {
id: EntityId(id),
name: format!("param_{id}"),
kind,
}
}
#[test]
fn constant_kind_fills_all_stages() {
let params = vec![make_param(0, ParameterKind::Constant { value: 3.6 })];
let ec = EnergyConversionSet::new(vec![], vec![], 0, 4);
let overrides = HydroEnergyProductivityOverride::default();
let stage_to_season = vec![0i32; 4];
let table =
build_resolved_parameters(¶ms, &ec, &overrides, &[], &stage_to_season, 4).unwrap();
assert!((table.get(EntityId(0), 0) - 3.6).abs() < 1e-12);
assert!((table.get(EntityId(0), 1) - 3.6).abs() < 1e-12);
assert!((table.get(EntityId(0), 2) - 3.6).abs() < 1e-12);
assert!((table.get(EntityId(0), 3) - 3.6).abs() < 1e-12);
}
#[test]
fn per_stage_kind_length_mismatch_errors() {
let params = vec![make_param(
0,
ParameterKind::PerStage {
values: vec![1.0, 2.0],
},
)];
let ec = EnergyConversionSet::new(vec![], vec![], 0, 3);
let overrides = HydroEnergyProductivityOverride::default();
let stage_to_season = vec![0i32; 3];
let result = build_resolved_parameters(¶ms, &ec, &overrides, &[], &stage_to_season, 3);
assert!(matches!(
result,
Err(ResolvedParametersError::PerStageLengthMismatch {
expected: 3,
got: 2,
..
})
));
}
#[test]
fn seasonal_kind_maps_stage_to_value() {
let params = vec![make_param(
0,
ParameterKind::Seasonal {
values: vec![(0, 0.5), (1, 1.5)],
},
)];
let ec = EnergyConversionSet::new(vec![], vec![], 0, 3);
let overrides = HydroEnergyProductivityOverride::default();
let stage_to_season = vec![0i32, 1, 0];
let table =
build_resolved_parameters(¶ms, &ec, &overrides, &[], &stage_to_season, 3).unwrap();
assert!((table.get(EntityId(0), 0) - 0.5).abs() < 1e-12);
assert!((table.get(EntityId(0), 1) - 1.5).abs() < 1e-12);
assert!((table.get(EntityId(0), 2) - 0.5).abs() < 1e-12);
}
#[test]
fn computed_equivalent_productivity_reads_energy_conversion() {
let n_stages = 4;
let (hydros, energy_conversion, override_table, stage_to_season) =
make_setup_inputs(n_stages);
let params = vec![make_param(
0,
ParameterKind::Computed {
computed_spec: ComputedParameter::EquivalentProductivity {
hydro_id: EntityId(0),
},
},
)];
let table = build_resolved_parameters(
¶ms,
&energy_conversion,
&override_table,
&hydros,
&stage_to_season,
n_stages,
)
.unwrap();
let expected = energy_conversion
.conversion(0, 0)
.equivalent_productivity_mw_per_m3s;
assert!((table.get(EntityId(0), 0) - expected).abs() < 1e-12);
for t in 0..n_stages {
let exp_t = energy_conversion
.conversion(0, t)
.equivalent_productivity_mw_per_m3s;
assert!(
(table.get(EntityId(0), t) - exp_t).abs() < 1e-12,
"stage {t}: expected {exp_t}, got {}",
table.get(EntityId(0), t)
);
}
}
#[test]
fn computed_specific_productivity_missing_returns_error() {
let n_stages = 2;
let hydros = vec![make_hydro(0, 50.0, 2000.0, None)];
let energy_conversion = make_energy_conversion(1, n_stages);
let override_table = HydroEnergyProductivityOverride::default();
let stage_to_season = vec![0i32; n_stages];
let params = vec![make_param(
0,
ParameterKind::Computed {
computed_spec: ComputedParameter::SpecificProductivity {
hydro_id: EntityId(0),
},
},
)];
let result = build_resolved_parameters(
¶ms,
&energy_conversion,
&override_table,
&hydros,
&stage_to_season,
n_stages,
);
assert!(matches!(
result,
Err(ResolvedParametersError::MissingSpecificProductivity {
hydro_id: EntityId(0),
..
})
));
}
#[test]
fn declaration_order_invariance() {
let n_stages = 3;
let (hydros, energy_conversion, override_table, stage_to_season) =
make_setup_inputs(n_stages);
let param_a = make_param(10, ParameterKind::Constant { value: 1.0 });
let param_b = make_param(
20,
ParameterKind::PerStage {
values: vec![2.0, 3.0, 4.0],
},
);
let param_c = make_param(
30,
ParameterKind::Computed {
computed_spec: ComputedParameter::AccumulatedProductivity {
hydro_id: EntityId(0),
},
},
);
let params_abc = vec![param_a.clone(), param_b.clone(), param_c.clone()];
let params_cab = vec![param_c.clone(), param_a.clone(), param_b.clone()];
let table_abc = build_resolved_parameters(
¶ms_abc,
&energy_conversion,
&override_table,
&hydros,
&stage_to_season,
n_stages,
)
.unwrap();
let table_cab = build_resolved_parameters(
¶ms_cab,
&energy_conversion,
&override_table,
&hydros,
&stage_to_season,
n_stages,
)
.unwrap();
for id in [EntityId(10), EntityId(20), EntityId(30)] {
for t in 0..n_stages {
assert_eq!(
table_abc.get(id, t).to_bits(),
table_cab.get(id, t).to_bits(),
"bit mismatch for id={id:?}, stage={t}"
);
}
}
}
#[test]
fn unknown_hydro_in_computed_returns_error() {
let n_stages = 2;
let (hydros, energy_conversion, override_table, stage_to_season) =
make_setup_inputs(n_stages);
let params = vec![make_param(
0,
ParameterKind::Computed {
computed_spec: ComputedParameter::MinStorage {
hydro_id: EntityId(99),
},
},
)];
let result = build_resolved_parameters(
¶ms,
&energy_conversion,
&override_table,
&hydros,
&stage_to_season,
n_stages,
);
assert!(matches!(
result,
Err(ResolvedParametersError::UnknownHydro {
hydro_id: EntityId(99),
..
})
));
}
#[test]
fn min_max_storage_use_stage_invariant_hydro_field() {
let n_stages = 5;
let (hydros, energy_conversion, override_table, stage_to_season) =
make_setup_inputs(n_stages);
let params = vec![
make_param(
0,
ParameterKind::Computed {
computed_spec: ComputedParameter::MinStorage {
hydro_id: EntityId(0),
},
},
),
make_param(
1,
ParameterKind::Computed {
computed_spec: ComputedParameter::MaxStorage {
hydro_id: EntityId(1),
},
},
),
];
let table = build_resolved_parameters(
¶ms,
&energy_conversion,
&override_table,
&hydros,
&stage_to_season,
n_stages,
)
.unwrap();
for t in 0..n_stages {
assert!(
(table.get(EntityId(0), t) - 50.0).abs() < 1e-12,
"min_storage mismatch at stage {t}"
);
}
for t in 0..n_stages {
assert!(
(table.get(EntityId(1), t) - 5000.0).abs() < 1e-12,
"max_storage mismatch at stage {t}"
);
}
}
#[test]
fn empty_parameters_n_stages_zero_is_ok() {
let ec = EnergyConversionSet::new(vec![], vec![], 0, 0);
let overrides = HydroEnergyProductivityOverride::default();
let table = build_resolved_parameters(&[], &ec, &overrides, &[], &[], 0).unwrap();
let _ = table;
}
fn broadcast_fixture() -> ResolvedParameters {
let n_stages = 4;
let (hydros, energy_conversion, override_table, stage_to_season) =
make_setup_inputs(n_stages);
let params = vec![
make_param(10, ParameterKind::Constant { value: 3.6 }),
make_param(
20,
ParameterKind::PerStage {
values: vec![1.0, 2.0, 3.0, 4.0],
},
),
make_param(
30,
ParameterKind::Computed {
computed_spec: ComputedParameter::EquivalentProductivity {
hydro_id: EntityId(0),
},
},
),
];
build_resolved_parameters(
¶ms,
&energy_conversion,
&override_table,
&hydros,
&stage_to_season,
n_stages,
)
.unwrap()
}
#[test]
fn round_trip_populated_resolved_parameters() {
let original = broadcast_fixture();
let bytes = super::serialize_resolved_parameters(&original).unwrap();
assert!(!bytes.is_empty());
let restored = super::deserialize_resolved_parameters(&bytes).unwrap();
for id in [EntityId(10), EntityId(20), EntityId(30)] {
for t in 0..4_usize {
assert_eq!(
original.get(id, t).to_bits(),
restored.get(id, t).to_bits(),
"bit mismatch for id={id:?}, stage={t}"
);
}
}
}
#[test]
fn serialize_resolved_parameters_is_deterministic() {
let table = broadcast_fixture();
let bytes_a = super::serialize_resolved_parameters(&table).unwrap();
let bytes_b = super::serialize_resolved_parameters(&table).unwrap();
assert_eq!(bytes_a, bytes_b, "postcard encoding must be deterministic");
}
#[test]
fn deserialize_resolved_parameters_rejects_corrupted_bytes() {
let result = super::deserialize_resolved_parameters(&[0xFF, 0xFE, 0xFD, 0xFC]);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("postcard resolved_parameters"),
"error message must contain 'postcard resolved_parameters', got: {msg}"
);
}
#[test]
fn deserialize_resolved_parameters_rejects_empty_buffer() {
let result = super::deserialize_resolved_parameters(&[]);
assert!(result.is_err(), "empty buffer must return Err");
}
#[test]
fn round_trip_empty_resolved_parameters() {
let table = ResolvedParameters::default();
let bytes = super::serialize_resolved_parameters(&table).unwrap();
assert!(
!bytes.is_empty(),
"even an empty table must produce non-empty bytes"
);
let restored = super::deserialize_resolved_parameters(&bytes).unwrap();
let _ = restored;
}
#[test]
fn resolved_parameters_wire_envelope_round_trip() {
let original = broadcast_fixture();
let bytes = super::serialize_resolved_parameters(&original).unwrap();
assert!(!bytes.is_empty(), "encoded envelope must be non-empty");
let restored = super::deserialize_resolved_parameters(&bytes).unwrap();
for id in [EntityId(10), EntityId(20), EntityId(30)] {
for t in 0..4_usize {
assert_eq!(
original.get(id, t).to_bits(),
restored.get(id, t).to_bits(),
"bit mismatch for id={id:?}, stage={t}"
);
}
}
}
#[test]
fn resolved_parameters_wire_envelope_rejects_old_version() {
let old_version: u32 = super::RESOLVED_PARAMETERS_WIRE_VERSION.saturating_sub(1);
let envelope = super::ResolvedParametersWireEnvelope {
version: old_version,
payload: ResolvedParameters::default(),
};
let bytes = postcard::to_allocvec(&envelope).expect("postcard encoding must not fail");
let result = super::deserialize_resolved_parameters(&bytes);
assert!(
result.is_err(),
"decoder must reject an old-version envelope"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("version"),
"error message must contain 'version', got: {msg}"
);
}
}