use crate::EntityId;
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum CoefficientRef {
Literal(f64),
Parameter(EntityId),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(tag = "tag", rename_all = "snake_case"))]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum ComputedParameter {
EquivalentProductivity {
hydro_id: EntityId,
},
AccumulatedProductivity {
hydro_id: EntityId,
},
ReferenceVolume {
hydro_id: EntityId,
},
ReferenceTurbine {
hydro_id: EntityId,
},
MinStorage {
hydro_id: EntityId,
},
MaxStorage {
hydro_id: EntityId,
},
SpecificProductivity {
hydro_id: EntityId,
},
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[cfg_attr(feature = "serde", serde(into = "ParameterKindJson"))]
pub enum ParameterKind {
Constant {
value: f64,
},
PerStage {
values: Vec<f64>,
},
Seasonal {
values: Vec<(i32, f64)>,
},
Computed {
computed_spec: ComputedParameter,
},
}
impl ParameterKind {
#[must_use]
pub fn new_seasonal(mut pairs: Vec<(i32, f64)>) -> Self {
pairs.sort_by_key(|(k, _)| *k);
pairs.dedup_by_key(|(k, _)| *k);
Self::Seasonal { values: pairs }
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ScalarParameter {
pub id: EntityId,
pub name: String,
pub kind: ParameterKind,
}
#[cfg(feature = "serde")]
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum ParameterKindJson {
Constant { value: f64 },
PerStage { values: Vec<(i32, f64)> },
Seasonal { values: Vec<(i32, f64)> },
Computed { computed_spec: ComputedParameter },
}
#[cfg(feature = "serde")]
impl From<ParameterKind> for ParameterKindJson {
fn from(kind: ParameterKind) -> Self {
match kind {
ParameterKind::Constant { value } => ParameterKindJson::Constant { value },
ParameterKind::PerStage { values } => ParameterKindJson::PerStage {
values: values
.into_iter()
.enumerate()
.map(|(i, v)| {
(i32::try_from(i).unwrap_or(i32::MAX), v)
})
.collect(),
},
ParameterKind::Seasonal { values } => ParameterKindJson::Seasonal { values },
ParameterKind::Computed { computed_spec } => {
ParameterKindJson::Computed { computed_spec }
}
}
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for ParameterKind {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let json = ParameterKindJson::deserialize(deserializer)?;
match json {
ParameterKindJson::Constant { value } => Ok(ParameterKind::Constant { value }),
ParameterKindJson::PerStage { mut values } => {
values.sort_by_key(|(k, _)| *k);
for window in values.windows(2) {
if window[0].0 == window[1].0 {
return Err(serde::de::Error::custom(format!(
"duplicate stage_id {} in per_stage values",
window[0].0
)));
}
}
for (expected, (actual, _)) in values.iter().enumerate() {
let expected_i32 = i32::try_from(expected).unwrap_or(i32::MAX);
if *actual != expected_i32 {
return Err(serde::de::Error::custom(format!(
"per_stage values must have contiguous stage_ids starting at 0; \
expected stage_id {expected_i32} but got {actual}"
)));
}
}
let dense: Vec<f64> = values.into_iter().map(|(_, v)| v).collect();
Ok(ParameterKind::PerStage { values: dense })
}
ParameterKindJson::Seasonal { values } => {
let mut seen: std::collections::HashSet<i32> = std::collections::HashSet::new();
for &(season_id, _) in &values {
if !seen.insert(season_id) {
return Err(serde::de::Error::custom(format!(
"duplicate season_id {season_id} in seasonal values"
)));
}
}
Ok(ParameterKind::new_seasonal(values))
}
ParameterKindJson::Computed { computed_spec } => {
Ok(ParameterKind::Computed { computed_spec })
}
}
}
}
#[cfg(test)]
mod tests {
use super::{CoefficientRef, ComputedParameter, EntityId, ParameterKind};
#[cfg(feature = "serde")]
use super::ScalarParameter;
#[test]
fn seven_computed_parameter_variants() {
let variants = [
ComputedParameter::EquivalentProductivity {
hydro_id: EntityId(1),
},
ComputedParameter::AccumulatedProductivity {
hydro_id: EntityId(2),
},
ComputedParameter::ReferenceVolume {
hydro_id: EntityId(3),
},
ComputedParameter::ReferenceTurbine {
hydro_id: EntityId(4),
},
ComputedParameter::MinStorage {
hydro_id: EntityId(5),
},
ComputedParameter::MaxStorage {
hydro_id: EntityId(6),
},
ComputedParameter::SpecificProductivity {
hydro_id: EntityId(7),
},
];
assert_eq!(
variants.len(),
7,
"ComputedParameter must have exactly 7 variants"
);
for variant in &variants {
let _name = match variant {
ComputedParameter::EquivalentProductivity { .. } => "EquivalentProductivity",
ComputedParameter::AccumulatedProductivity { .. } => "AccumulatedProductivity",
ComputedParameter::ReferenceVolume { .. } => "ReferenceVolume",
ComputedParameter::ReferenceTurbine { .. } => "ReferenceTurbine",
ComputedParameter::MinStorage { .. } => "MinStorage",
ComputedParameter::MaxStorage { .. } => "MaxStorage",
ComputedParameter::SpecificProductivity { .. } => "SpecificProductivity",
};
}
}
#[test]
fn parameter_kind_four_variants() {
let variants = [
ParameterKind::Constant { value: 1.0 },
ParameterKind::PerStage {
values: vec![1.0, 2.0],
},
ParameterKind::Seasonal {
values: vec![(1, 0.5)],
},
ParameterKind::Computed {
computed_spec: ComputedParameter::EquivalentProductivity {
hydro_id: EntityId(1),
},
},
];
for variant in &variants {
let _name = match variant {
ParameterKind::Constant { .. } => "Constant",
ParameterKind::PerStage { .. } => "PerStage",
ParameterKind::Seasonal { .. } => "Seasonal",
ParameterKind::Computed { .. } => "Computed",
};
}
}
#[test]
fn seasonal_constructor_sorts_and_dedups() {
let input = vec![(3, 1.5), (1, 0.5), (1, 0.9), (2, 1.0)];
let result = ParameterKind::new_seasonal(input);
assert_eq!(
result,
ParameterKind::Seasonal {
values: vec![(1, 0.5), (2, 1.0), (3, 1.5)]
}
);
}
#[test]
fn coefficient_ref_copy_semantics() {
let a = CoefficientRef::Literal(1.0);
let b = a;
assert_eq!(a, b);
}
#[cfg(feature = "serde")]
#[test]
fn scalar_parameter_seasonal_serde_roundtrip() {
let param = ScalarParameter {
id: EntityId(7),
name: "rho_acum_h1".to_string(),
kind: ParameterKind::Seasonal {
values: vec![(1, 0.5), (2, 1.0)],
},
};
let json = serde_json::to_string(¶m).unwrap();
let deserialized: ScalarParameter = serde_json::from_str(&json).unwrap();
assert_eq!(param, deserialized);
}
#[cfg(feature = "serde")]
#[test]
fn parameter_kind_serde_constant_form() {
let kind = ParameterKind::Constant { value: 3.6 };
let json = serde_json::to_string(&kind).unwrap();
assert_eq!(json, r#"{"kind":"constant","value":3.6}"#);
let roundtrip: ParameterKind = serde_json::from_str(&json).unwrap();
assert_eq!(roundtrip, kind);
}
#[cfg(feature = "serde")]
#[test]
fn parameter_kind_serde_per_stage_form() {
let kind = ParameterKind::PerStage {
values: vec![1.0, 1.1, 0.9],
};
let json = serde_json::to_string(&kind).unwrap();
assert_eq!(
json,
r#"{"kind":"per_stage","values":[[0,1.0],[1,1.1],[2,0.9]]}"#
);
let roundtrip: ParameterKind = serde_json::from_str(&json).unwrap();
assert_eq!(roundtrip, kind);
}
#[cfg(feature = "serde")]
#[test]
fn parameter_kind_serde_seasonal_form() {
let kind = ParameterKind::Seasonal {
values: vec![(1, 0.5), (2, 1.5)],
};
let json = serde_json::to_string(&kind).unwrap();
assert_eq!(json, r#"{"kind":"seasonal","values":[[1,0.5],[2,1.5]]}"#);
let roundtrip: ParameterKind = serde_json::from_str(&json).unwrap();
assert_eq!(roundtrip, kind);
}
#[cfg(feature = "serde")]
#[test]
fn parameter_kind_serde_computed_form() {
let kind = ParameterKind::Computed {
computed_spec: ComputedParameter::EquivalentProductivity {
hydro_id: EntityId(7),
},
};
let json = serde_json::to_string(&kind).unwrap();
assert_eq!(
json,
r#"{"kind":"computed","computed_spec":{"tag":"equivalent_productivity","hydro_id":7}}"#
);
let roundtrip: ParameterKind = serde_json::from_str(&json).unwrap();
assert_eq!(roundtrip, kind);
}
#[cfg(feature = "serde")]
#[test]
fn parameter_kind_per_stage_rejects_non_contiguous_stage_ids() {
let json = r#"{"kind":"per_stage","values":[[0,1.0],[2,0.9]]}"#;
let result: Result<ParameterKind, _> = serde_json::from_str(json);
assert!(
result.is_err(),
"expected an error for non-contiguous stage_ids, got: {result:?}"
);
}
#[cfg(feature = "serde")]
#[test]
fn parameter_kind_seasonal_rejects_duplicate_season_id_via_serde() {
let json = r#"{"kind":"seasonal","values":[[1,0.5],[1,0.9],[2,1.0]]}"#;
let result: Result<ParameterKind, _> = serde_json::from_str(json);
let err = result.expect_err("expected an error for duplicate season_id");
assert!(
err.to_string().contains("duplicate season_id 1"),
"error message must mention the duplicate id; got: {err}"
);
}
}