use std::collections::HashMap;
use std::path::Path;
use rayon::prelude::*;
use cobre_core::{EntityId, System, entities::hydro::HydroGenerationModel};
use cobre_io::HydroReferenceVolumeFractions;
use cobre_io::extensions::{
FphaColumnLayout, FphaHyperplaneRow, HydroGeometryRow, ProductionModelConfig, ReferenceVolume,
SelectionMode, build_hydro_reference_volumes_resolved,
};
use super::load_artifacts_for_hydro_models;
use super::types::{
FphaFitDeviationEntry, FphaPlane, ProductionModelSet, ProductionModelSource,
ResolvedProductionModel,
};
use crate::SddpError;
use crate::fpha_fitting::{
ForebayTable, FphaDeviationPoint, FphaFitDeviation, FphaFitResult, TailraceFamilies,
TailraceSource, build_tailrace_families_map, fit_fpha_planes,
};
type ResolveProductionResult = (
ProductionModelSet,
crate::energy_conversion::HydroEnergyProductivityOverride,
Vec<(EntityId, ProductionModelSource)>,
Vec<cobre_io::FphaHyperplaneRow>,
Vec<(EntityId, usize, f64)>,
Vec<FphaFitDeviationEntry>,
Vec<cobre_io::FphaDeviationPointRow>,
);
pub fn resolve_production_models(
system: &System,
case_dir: &Path,
collect_deviation_points: bool,
) -> Result<ResolveProductionResult, SddpError> {
let artifacts = load_artifacts_for_hydro_models(case_dir)?;
resolve_production_models_from_artifacts(system, &artifacts, collect_deviation_points)
}
pub fn resolve_production_models_from_artifacts(
system: &System,
artifacts: &cobre_io::CaseArtifacts,
collect_deviation_points: bool,
) -> Result<ResolveProductionResult, SddpError> {
let override_table = crate::energy_conversion::build_hydro_energy_productivity_override(
&artifacts.hydro_energy_productivity,
)
.map_err(|e| SddpError::Validation(e.to_string()))?;
let prod_configs: &[ProductionModelConfig] = &artifacts.production_models;
let plane_reduction: Option<&cobre_io::extensions::PlaneReductionConfig> =
artifacts.plane_reduction.as_ref();
let config_map: HashMap<EntityId, &ProductionModelConfig> =
prod_configs.iter().map(|c| (c.hydro_id, c)).collect();
let mut hyperplane_map: HashMap<(EntityId, Option<i32>), Vec<&FphaHyperplaneRow>> =
HashMap::new();
if prod_configs.iter().any(config_uses_precomputed_fpha) {
for row in &artifacts.fpha_hyperplanes {
hyperplane_map
.entry((row.hydro_id, row.stage_id))
.or_default()
.push(row);
}
}
let uses_computed_fpha = prod_configs.iter().any(config_uses_computed_fpha);
let geometry_map: HashMap<EntityId, Vec<&HydroGeometryRow>> = if uses_computed_fpha {
build_geometry_map(&artifacts.hydro_geometry)
} else {
HashMap::new()
};
let families_map: HashMap<EntityId, TailraceFamilies> =
if uses_computed_fpha && !artifacts.tailrace_curves.is_empty() {
build_tailrace_families_map(&artifacts.tailrace_curves)?
} else {
HashMap::new()
};
let study_stages: Vec<&cobre_core::temporal::Stage> =
system.stages().iter().filter(|s| s.id >= 0).collect();
let n_stages = study_stages.len();
let n_hydros = system.hydros().len();
let reference_volumes_hm3: Vec<(EntityId, usize, f64)> = system
.hydros()
.iter()
.flat_map(|hydro| {
study_stages.iter().enumerate().map(|(stage_pos, stage)| {
let rv = config_map
.get(&hydro.id)
.and_then(|config| find_reference_volume_for_stage(config, stage));
let resolved =
resolve_reference_volume_hm3(rv, hydro.min_storage_hm3, hydro.max_storage_hm3);
(hydro.id, stage_pos, resolved)
})
})
.collect();
let reference_volume_fractions =
build_hydro_reference_volumes_resolved(&reference_volumes_hm3, 0.0);
let mut all_models: Vec<Vec<ResolvedProductionModel>> = Vec::with_capacity(n_hydros);
let mut provenance: Vec<(EntityId, ProductionModelSource)> = Vec::with_capacity(n_hydros);
let mut export_rows: Vec<cobre_io::FphaHyperplaneRow> = Vec::new();
let mut fpha_fit_deviations: Vec<FphaFitDeviationEntry> = Vec::new();
let mut fpha_deviation_point_rows: Vec<cobre_io::FphaDeviationPointRow> = Vec::new();
let fits: Vec<PerHydroFit> = system
.hydros()
.par_iter()
.map(|hydro| {
fit_one_hydro(
hydro,
&config_map,
&geometry_map,
&families_map,
&reference_volume_fractions,
&hyperplane_map,
&override_table,
&study_stages,
plane_reduction,
system,
n_stages,
collect_deviation_points,
)
})
.collect::<Result<Vec<_>, SddpError>>()?;
for (hydro, fit) in system.hydros().iter().zip(fits) {
provenance.push(fit.provenance);
export_rows.extend(fit.export_rows);
fpha_deviation_point_rows.extend(fit.deviation_point_rows);
all_models.push(fit.stage_models);
for diag in fit.fpha_deviations {
fpha_fit_deviations.push(FphaFitDeviationEntry {
hydro_id: hydro.id,
stage_id: diag.stage_id,
mean_abs_mw: diag.deviation.mean_abs_mw,
max_abs_mw: diag.deviation.max_abs_mw,
mean_signed_mw: diag.deviation.mean_signed_mw,
relative: diag.deviation.relative,
});
if diag.deviation.exceeds_warn_threshold() {
tracing::warn!(
"FPHA fit for hydro {} (stage {}) deviates {:.1}% from the exact \
production function (mean |Δ| {:.1} MW, max {:.1} MW); the \
convex-hull approximation is poor here — typically a strongly \
non-concave production surface that no single α correction can track",
diag.hydro_name,
diag.stage_id,
diag.deviation.relative * 100.0,
diag.deviation.mean_abs_mw,
diag.deviation.max_abs_mw,
);
}
}
}
let set = ProductionModelSet::new(all_models, n_hydros, n_stages);
Ok((
set,
override_table,
provenance,
export_rows,
reference_volumes_hm3,
fpha_fit_deviations,
fpha_deviation_point_rows,
))
}
struct FphaDeviationDiagnostic {
hydro_name: String,
stage_id: i32,
deviation: FphaFitDeviation,
}
struct PerHydroFit {
stage_models: Vec<ResolvedProductionModel>,
provenance: (EntityId, ProductionModelSource),
export_rows: Vec<cobre_io::FphaHyperplaneRow>,
fpha_deviations: Vec<FphaDeviationDiagnostic>,
deviation_point_rows: Vec<cobre_io::FphaDeviationPointRow>,
}
#[allow(clippy::too_many_arguments)]
fn fit_one_hydro(
hydro: &cobre_core::entities::hydro::Hydro,
config_map: &HashMap<EntityId, &ProductionModelConfig>,
geometry_map: &HashMap<EntityId, Vec<&HydroGeometryRow>>,
families_map: &HashMap<EntityId, TailraceFamilies>,
reference_volume_fractions: &HydroReferenceVolumeFractions,
hyperplane_map: &HashMap<(EntityId, Option<i32>), Vec<&FphaHyperplaneRow>>,
override_table: &crate::energy_conversion::HydroEnergyProductivityOverride,
study_stages: &[&cobre_core::temporal::Stage],
plane_reduction: Option<&cobre_io::extensions::PlaneReductionConfig>,
system: &System,
n_stages: usize,
collect_deviation_points: bool,
) -> Result<PerHydroFit, SddpError> {
let config_entry = config_map.get(&hydro.id).copied();
let source = determine_source(hydro, config_entry)?;
let mut export_rows: Vec<cobre_io::FphaHyperplaneRow> = Vec::new();
let mut fpha_deviations: Vec<FphaDeviationDiagnostic> = Vec::new();
let mut deviation_point_rows: Vec<cobre_io::FphaDeviationPointRow> = Vec::new();
let computed_planes_per_stage: Option<Vec<Vec<FphaPlane>>> =
if source == ProductionModelSource::ComputedFromGeometry {
let long_term_mean_inflow_m3s = long_term_mean_inflow(system, hydro.id);
let per_stage = fit_computed_planes_per_stage(
hydro,
config_entry,
geometry_map,
families_map,
reference_volume_fractions,
system,
study_stages,
long_term_mean_inflow_m3s,
plane_reduction,
collect_deviation_points,
&mut export_rows,
&mut fpha_deviations,
&mut deviation_point_rows,
)?;
Some(per_stage)
} else {
None
};
let mut stage_models: Vec<ResolvedProductionModel> = Vec::with_capacity(n_stages);
for (stage_idx, stage) in study_stages.iter().enumerate() {
let cached_stage_planes = computed_planes_per_stage
.as_ref()
.map(|per_stage| per_stage[stage_idx].as_slice());
let model = resolve_stage_model(
hydro,
stage,
config_entry,
source,
hyperplane_map,
cached_stage_planes,
Some(override_table),
)?;
stage_models.push(model);
}
Ok(PerHydroFit {
stage_models,
provenance: (hydro.id, source),
export_rows,
fpha_deviations,
deviation_point_rows,
})
}
fn build_geometry_map(
geometry_rows: &[HydroGeometryRow],
) -> HashMap<EntityId, Vec<&HydroGeometryRow>> {
let mut geometry_map: HashMap<EntityId, Vec<&HydroGeometryRow>> = HashMap::new();
for row in geometry_rows {
geometry_map.entry(row.hydro_id).or_default().push(row);
}
for rows in geometry_map.values_mut() {
rows.sort_by(|a, b| a.volume_hm3.total_cmp(&b.volume_hm3));
}
geometry_map
}
fn resolve_downstream_level(
hydro: &cobre_core::entities::hydro::Hydro,
stage_pos: usize,
system: &System,
geometry_map: &HashMap<EntityId, Vec<&HydroGeometryRow>>,
reference_volume_fractions: &HydroReferenceVolumeFractions,
) -> Option<f64> {
let downstream_id = hydro.downstream_id?;
let downstream = system.hydro(downstream_id)?;
let geo_refs = geometry_map.get(&downstream_id)?;
if geo_refs.is_empty() {
return None;
}
let geo_rows: Vec<HydroGeometryRow> = geo_refs.iter().map(|r| (*r).clone()).collect();
let forebay = ForebayTable::new(&geo_rows, &downstream.name).ok()?;
let v_ref = reference_volume_fractions.get(downstream_id, stage_pos);
Some(forebay.height(v_ref))
}
fn long_term_mean_inflow(system: &System, hydro_id: EntityId) -> f64 {
let mut sum = 0.0_f64;
let mut count = 0_u64;
for row in system.inflow_history() {
if row.hydro_id == hydro_id {
sum += row.value_m3s;
count += 1;
}
}
if count == 0 {
0.0
} else {
#[allow(clippy::cast_precision_loss)]
let n = count as f64;
sum / n
}
}
fn fit_planes_for_hydro(
hydro: &cobre_core::entities::hydro::Hydro,
config: &FphaColumnLayout,
geometry_map: &HashMap<EntityId, Vec<&HydroGeometryRow>>,
long_term_mean_inflow_m3s: f64,
tailrace_source: TailraceSource,
plane_reduction: Option<&cobre_io::extensions::PlaneReductionConfig>,
entry_level_bits: u64,
collect_deviation_points: bool,
) -> Result<FphaFitResult, SddpError> {
validate_computed_prerequisites(hydro, geometry_map)?;
let geo_rows_owned: Vec<HydroGeometryRow> = geometry_map
.get(&hydro.id)
.map_or(&[][..], Vec::as_slice)
.iter()
.map(|r| (*r).clone())
.collect();
Ok(fit_fpha_planes(
&geo_rows_owned,
hydro,
config,
long_term_mean_inflow_m3s,
tailrace_source,
plane_reduction,
hydro.id.0,
entry_level_bits,
collect_deviation_points,
)?)
}
fn resolve_tailrace_source(
hydro: &cobre_core::entities::hydro::Hydro,
stage_pos: usize,
families_map: &HashMap<EntityId, TailraceFamilies>,
geometry_map: &HashMap<EntityId, Vec<&HydroGeometryRow>>,
reference_volume_fractions: &HydroReferenceVolumeFractions,
system: &System,
) -> TailraceSource {
if let Some(families) = families_map.get(&hydro.id) {
let downstream_level_m = resolve_downstream_level(
hydro,
stage_pos,
system,
geometry_map,
reference_volume_fractions,
);
TailraceSource::Families {
families: families.clone(),
downstream_level_m,
}
} else {
TailraceSource::Entity(hydro.tailrace.clone())
}
}
type FittedCacheEntry<'a> = (
(&'a FphaColumnLayout, Option<u64>),
Vec<FphaPlane>,
Vec<FphaDeviationPoint>,
);
#[allow(clippy::too_many_arguments)]
fn fit_computed_planes_per_stage(
hydro: &cobre_core::entities::hydro::Hydro,
config_entry: Option<&ProductionModelConfig>,
geometry_map: &HashMap<EntityId, Vec<&HydroGeometryRow>>,
families_map: &HashMap<EntityId, TailraceFamilies>,
reference_volume_fractions: &HydroReferenceVolumeFractions,
system: &System,
study_stages: &[&cobre_core::temporal::Stage],
long_term_mean_inflow_m3s: f64,
plane_reduction: Option<&cobre_io::extensions::PlaneReductionConfig>,
collect_deviation_points: bool,
export_rows: &mut Vec<cobre_io::FphaHyperplaneRow>,
diagnostics: &mut Vec<FphaDeviationDiagnostic>,
deviation_point_rows: &mut Vec<cobre_io::FphaDeviationPointRow>,
) -> Result<Vec<Vec<FphaPlane>>, SddpError> {
let mut fitted: Vec<FittedCacheEntry> = Vec::new();
let mut per_stage: Vec<Vec<FphaPlane>> = Vec::with_capacity(study_stages.len());
for (stage_pos, stage) in study_stages.iter().enumerate() {
let config = config_entry
.and_then(|c| find_fpha_config_for_stage(c, stage))
.ok_or_else(|| {
SddpError::Validation(format!(
"hydro {} (id={}) has source: \"computed\" but no FphaColumnLayout \
covers stage {} in hydro_production_models.json",
hydro.name, hydro.id.0, stage.id
))
})?;
let tailrace_source = resolve_tailrace_source(
hydro,
stage_pos,
families_map,
geometry_map,
reference_volume_fractions,
system,
);
let level_bits = match &tailrace_source {
TailraceSource::Families {
downstream_level_m, ..
} => downstream_level_m.map(f64::to_bits),
TailraceSource::Entity(_) => None,
};
let key = (config, level_bits);
let (planes, deviation_points) =
if let Some((_, planes, points)) = fitted.iter().find(|(k, _, _)| *k == key) {
(planes.clone(), points.clone())
} else {
let fit_result = fit_planes_for_hydro(
hydro,
config,
geometry_map,
long_term_mean_inflow_m3s,
tailrace_source,
plane_reduction,
level_bits.unwrap_or(0),
collect_deviation_points,
)?;
diagnostics.push(FphaDeviationDiagnostic {
hydro_name: hydro.name.clone(),
stage_id: stage.id,
deviation: fit_result.deviation,
});
fitted.push((
key,
fit_result.planes.clone(),
fit_result.deviation_points.clone(),
));
(fit_result.planes, fit_result.deviation_points)
};
for point in &deviation_points {
deviation_point_rows.push(cobre_io::FphaDeviationPointRow {
hydro_id: hydro.id,
stage_id: Some(stage.id),
v: point.v,
q: point.q,
fph_exact: point.fph_exact,
fpha_fitted: point.fpha_fitted,
deviation: point.deviation,
relative: point.relative_to_peak,
});
}
for (plane_id, plane) in planes.iter().enumerate() {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
export_rows.push(cobre_io::FphaHyperplaneRow {
hydro_id: hydro.id,
stage_id: Some(stage.id),
plane_id: plane_id as i32,
gamma_0: plane.intercept,
gamma_v: plane.gamma_v,
gamma_q: plane.gamma_q,
gamma_s: plane.gamma_s,
kappa: 1.0,
valid_v_min_hm3: None,
valid_v_max_hm3: None,
valid_q_max_m3s: None,
});
}
per_stage.push(planes);
}
Ok(per_stage)
}
fn config_uses_precomputed_fpha(config: &ProductionModelConfig) -> bool {
match &config.selection_mode {
SelectionMode::StageRanges { ranges } => ranges.iter().any(|r| {
r.fpha_config
.as_ref()
.is_some_and(|f| f.source == "precomputed")
}),
SelectionMode::Seasonal { seasons, .. } => seasons.iter().any(|s| {
s.fpha_config
.as_ref()
.is_some_and(|f| f.source == "precomputed")
}),
}
}
fn config_uses_computed_fpha(config: &ProductionModelConfig) -> bool {
match &config.selection_mode {
SelectionMode::StageRanges { ranges } => ranges.iter().any(|r| {
r.fpha_config
.as_ref()
.is_some_and(|f| f.source == "computed")
}),
SelectionMode::Seasonal { seasons, .. } => seasons.iter().any(|s| {
s.fpha_config
.as_ref()
.is_some_and(|f| f.source == "computed")
}),
}
}
fn find_fpha_config_for_stage<'a>(
config: &'a ProductionModelConfig,
stage: &cobre_core::temporal::Stage,
) -> Option<&'a FphaColumnLayout> {
match &config.selection_mode {
SelectionMode::StageRanges { ranges } => {
for range in ranges {
let after_start = stage.id >= range.start_stage_id;
let before_end = range.end_stage_id.is_none_or(|end| stage.id <= end);
if after_start && before_end {
return range.fpha_config.as_ref();
}
}
None
}
SelectionMode::Seasonal {
default_model: _,
seasons,
} => {
if let Some(season_id) = stage.season_id {
for season in seasons {
if i32::try_from(season_id).is_ok_and(|sid| sid == season.season_id) {
return season.fpha_config.as_ref();
}
}
}
None
}
}
}
pub(crate) const DEFAULT_REFERENCE_VOLUME_FRACTION: f64 = 0.65;
fn find_reference_volume_for_stage<'a>(
config: &'a ProductionModelConfig,
stage: &cobre_core::temporal::Stage,
) -> Option<&'a ReferenceVolume> {
match &config.selection_mode {
SelectionMode::StageRanges { ranges } => {
for range in ranges {
let after_start = stage.id >= range.start_stage_id;
let before_end = range.end_stage_id.is_none_or(|end| stage.id <= end);
if after_start && before_end {
return range.reference_volume.as_ref();
}
}
None
}
SelectionMode::Seasonal {
default_model: _,
seasons,
} => {
if let Some(season_id) = stage.season_id {
for season in seasons {
if i32::try_from(season_id).is_ok_and(|sid| sid == season.season_id) {
return season.reference_volume.as_ref();
}
}
}
None
}
}
}
pub(crate) fn resolve_reference_volume_hm3(
rv: Option<&ReferenceVolume>,
v_min: f64,
v_max: f64,
) -> f64 {
match rv {
Some(ReferenceVolume::AbsoluteHm3(volume_hm3)) => *volume_hm3,
Some(ReferenceVolume::Percentile(percentile)) => v_min + percentile * (v_max - v_min),
None => v_min + DEFAULT_REFERENCE_VOLUME_FRACTION * (v_max - v_min),
}
}
fn validate_computed_prerequisites(
hydro: &cobre_core::entities::hydro::Hydro,
geometry_map: &HashMap<EntityId, Vec<&HydroGeometryRow>>,
) -> Result<(), SddpError> {
let missing = if hydro.tailrace.is_none() {
Some("tailrace")
} else if hydro.hydraulic_losses.is_none() {
Some("hydraulic_losses")
} else if hydro.efficiency.is_none() {
Some("efficiency")
} else if geometry_map.get(&hydro.id).is_none_or(Vec::is_empty) {
Some("geometry data")
} else {
None
};
if let Some(missing_item) = missing {
return Err(SddpError::Validation(format!(
"hydro {} (id={}) has source: \"computed\" but is missing {}. \
Computed FPHA fitting requires tailrace, hydraulic_losses, \
efficiency, and geometry data.",
hydro.name, hydro.id.0, missing_item
)));
}
Ok(())
}
fn determine_source(
hydro: &cobre_core::entities::hydro::Hydro,
config_entry: Option<&ProductionModelConfig>,
) -> Result<ProductionModelSource, SddpError> {
if let Some(config) = config_entry {
let computed_range = match &config.selection_mode {
SelectionMode::StageRanges { ranges } => ranges
.iter()
.find(|r| {
r.fpha_config
.as_ref()
.is_some_and(|f| f.source == "computed")
})
.map(|r| r.model.clone()),
SelectionMode::Seasonal { seasons, .. } => seasons
.iter()
.find(|s| {
s.fpha_config
.as_ref()
.is_some_and(|f| f.source == "computed")
})
.map(|s| s.model.clone()),
};
if computed_range.is_some() {
return Ok(ProductionModelSource::ComputedFromGeometry);
}
let has_fpha = match &config.selection_mode {
SelectionMode::StageRanges { ranges } => ranges.iter().any(|r| r.model == "fpha"),
SelectionMode::Seasonal { seasons, .. } => seasons.iter().any(|s| s.model == "fpha"),
};
Ok(if has_fpha {
ProductionModelSource::PrecomputedHyperplanes
} else {
ProductionModelSource::DefaultConstant
})
} else {
match &hydro.generation_model {
HydroGenerationModel::ConstantProductivity | HydroGenerationModel::LinearizedHead => {
Ok(ProductionModelSource::DefaultConstant)
}
HydroGenerationModel::Fpha => Err(SddpError::Validation(format!(
"hydro {} (id={}) has generation_model: \"fpha\" in hydros.json \
but no entry in hydro_production_models.json. \
Add an entry with source: \"precomputed\" to specify the hyperplane source.",
hydro.name, hydro.id.0
))),
}
}
}
fn resolve_stage_model(
hydro: &cobre_core::entities::hydro::Hydro,
stage: &cobre_core::temporal::Stage,
config_entry: Option<&ProductionModelConfig>,
source: ProductionModelSource,
hyperplane_map: &HashMap<(EntityId, Option<i32>), Vec<&FphaHyperplaneRow>>,
cached_computed_planes: Option<&[FphaPlane]>,
productivity_override: Option<&crate::energy_conversion::HydroEnergyProductivityOverride>,
) -> Result<ResolvedProductionModel, SddpError> {
let stage_idx = usize::try_from(stage.id.max(0)).unwrap_or(0);
let parquet_productivity =
productivity_override.and_then(|o| o.equivalent_productivity(hydro.id, stage_idx));
if let Some(config) = config_entry {
let model_info = find_model_for_stage(config, stage);
if model_info.as_ref().map(|(name, _)| name.as_str()) == Some("fpha") {
if source == ProductionModelSource::ComputedFromGeometry {
let planes = cached_computed_planes
.ok_or_else(|| {
SddpError::Validation(format!(
"hydro {} (id={}) is ComputedFromGeometry but no cached planes \
were provided to resolve_stage_model",
hydro.name, hydro.id.0
))
})?
.to_vec();
Ok(ResolvedProductionModel::Fpha { planes })
} else {
build_fpha_model(hydro, stage, source, hyperplane_map)
}
} else {
let productivity = parquet_productivity
.or_else(|| model_info.and_then(|(_, p)| p))
.unwrap_or_else(|| {
debug_assert!(
false,
"non-FPHA {}/{} reached resolve_stage_model with productivity=None; \
see cobre_io::validation::productivity_resolution",
hydro.name, stage.id
);
0.0
});
Ok(ResolvedProductionModel::ConstantProductivity { productivity })
}
} else {
let productivity = parquet_productivity.unwrap_or_else(|| {
debug_assert!(
false,
"non-FPHA {}/{} reached resolve_stage_model with productivity=None; \
see cobre_io::validation::productivity_resolution",
hydro.name, stage.id
);
0.0
});
Ok(ResolvedProductionModel::ConstantProductivity { productivity })
}
}
fn find_model_for_stage(
config: &ProductionModelConfig,
stage: &cobre_core::temporal::Stage,
) -> Option<(String, Option<f64>)> {
match &config.selection_mode {
SelectionMode::StageRanges { ranges } => {
for range in ranges {
let after_start = stage.id >= range.start_stage_id;
let before_end = range.end_stage_id.is_none_or(|end| stage.id <= end);
if after_start && before_end {
return Some((range.model.clone(), range.productivity_mw_per_m3s));
}
}
None
}
SelectionMode::Seasonal {
default_model,
seasons,
} => {
if let Some(season_id) = stage.season_id {
for season in seasons {
if i32::try_from(season_id).is_ok_and(|sid| sid == season.season_id) {
return Some((season.model.clone(), season.productivity_mw_per_m3s));
}
}
}
Some((default_model.clone(), None))
}
}
}
fn build_fpha_model(
hydro: &cobre_core::entities::hydro::Hydro,
stage: &cobre_core::temporal::Stage,
_source: ProductionModelSource,
hyperplane_map: &HashMap<(EntityId, Option<i32>), Vec<&FphaHyperplaneRow>>,
) -> Result<ResolvedProductionModel, SddpError> {
let rows: &[&FphaHyperplaneRow] = hyperplane_map
.get(&(hydro.id, Some(stage.id)))
.or_else(|| hyperplane_map.get(&(hydro.id, None)))
.ok_or_else(|| {
SddpError::Validation(format!(
"hydro {} (id={}) is configured as FPHA but has no hyperplane rows \
in fpha_hyperplanes.parquet for stage {} (and no global all-stage rows).",
hydro.name, hydro.id.0, stage.id
))
})?;
if rows.is_empty() {
return Err(SddpError::Validation(format!(
"hydro {} (id={}) has zero hyperplane rows for stage {}.",
hydro.name, hydro.id.0, stage.id
)));
}
let mut planes: Vec<FphaPlane> = Vec::with_capacity(rows.len());
for row in rows {
validate_hyperplane_row(hydro, stage, row)?;
planes.push(FphaPlane {
intercept: row.gamma_0 * row.kappa,
gamma_v: row.gamma_v,
gamma_q: row.gamma_q,
gamma_s: row.gamma_s,
});
}
Ok(ResolvedProductionModel::Fpha { planes })
}
fn validate_hyperplane_row(
hydro: &cobre_core::entities::hydro::Hydro,
stage: &cobre_core::temporal::Stage,
row: &FphaHyperplaneRow,
) -> Result<(), SddpError> {
let ctx = format!(
"hydro {} (id={}) plane {} stage {}",
hydro.name, hydro.id.0, row.plane_id, stage.id
);
if row.gamma_v < 0.0 {
return Err(SddpError::Validation(format!(
"{ctx}: gamma_v must be >= 0 (higher storage must not decrease generation; \
zero is valid for constant-head plants), got gamma_v = {}",
row.gamma_v
)));
}
if row.gamma_s > 0.0 {
return Err(SddpError::Validation(format!(
"{ctx}: gamma_s must be <= 0 (spillage reduces generation), \
got gamma_s = {}",
row.gamma_s
)));
}
if row.gamma_q <= 0.0 {
return Err(SddpError::Validation(format!(
"{ctx}: gamma_q must be > 0 (more turbined flow → more generation), \
got gamma_q = {}",
row.gamma_q
)));
}
if row.kappa <= 0.0 || row.kappa > 1.0 {
return Err(SddpError::Validation(format!(
"{ctx}: kappa must be in (0, 1] (correction factor range), \
got kappa = {}",
row.kappa
)));
}
Ok(())
}
#[cfg(test)]
#[allow(
clippy::doc_markdown,
clippy::match_wildcard_for_single_variants,
clippy::cast_precision_loss,
clippy::float_cmp,
clippy::similar_names,
clippy::unwrap_used,
clippy::expect_used,
clippy::panic
)]
mod tests {
use std::collections::HashMap;
use chrono::NaiveDate;
use cobre_core::{
Bus, EfficiencyModel, EntityId, HydraulicLossesModel, InflowHistoryRow, SystemBuilder,
TailraceModel,
entities::hydro::{HydroGenerationModel, HydroPenalties},
temporal::{
Block, BlockMode, NoiseMethod, ScenarioSourceConfig, Stage, StageRiskConfig,
StageStateConfig,
},
};
use cobre_io::extensions::{
FittingWindow, FphaColumnLayout, FphaHyperplaneRow, HydroGeometryRow,
ProductionModelConfig, SeasonConfig, SelectionMode, StageRange, TailraceCurveRow,
};
use super::*;
fn make_stage(id: i32) -> Stage {
Stage {
index: usize::try_from(id.max(0)).unwrap_or(0),
id,
start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap_or_default(),
end_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap_or_default(),
season_id: Some(0),
blocks: vec![Block {
index: 0,
name: "SINGLE".to_string(),
duration_hours: 744.0,
}],
block_mode: BlockMode::Parallel,
state_config: StageStateConfig {
storage: true,
inflow_lags: false,
},
risk_config: StageRiskConfig::Expectation,
scenario_config: ScenarioSourceConfig {
branching_factor: 50,
noise_method: NoiseMethod::Saa,
},
}
}
fn zero_penalties() -> HydroPenalties {
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_hydro(id: i32, model: HydroGenerationModel) -> cobre_core::entities::hydro::Hydro {
cobre_core::entities::hydro::Hydro {
id: EntityId::from(id),
name: format!("Hydro{id}"),
bus_id: EntityId::from(10),
downstream_id: None,
entry_stage_id: None,
exit_stage_id: None,
min_storage_hm3: 100.0,
max_storage_hm3: 2000.0,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
generation_model: model,
min_turbined_m3s: 0.0,
max_turbined_m3s: 500.0,
specific_productivity_mw_per_m3s_per_m: None,
min_generation_mw: 0.0,
max_generation_mw: 1000.0,
tailrace: None,
hydraulic_losses: None,
efficiency: None,
evaporation_coefficients_mm: None,
evaporation_reference_volumes_hm3: None,
diversion: None,
filling: None,
penalties: zero_penalties(),
}
}
fn valid_row(hydro_id: i32, stage_id: Option<i32>, plane_id: i32) -> FphaHyperplaneRow {
FphaHyperplaneRow {
hydro_id: EntityId::from(hydro_id),
stage_id,
plane_id,
gamma_0: 1000.0,
gamma_v: 0.002,
gamma_q: 0.85,
gamma_s: -0.01,
kappa: 1.0,
valid_v_min_hm3: None,
valid_v_max_hm3: None,
valid_q_max_m3s: None,
}
}
fn precomputed_fpha_config(hydro_id: i32) -> ProductionModelConfig {
ProductionModelConfig {
hydro_id: EntityId::from(hydro_id),
selection_mode: SelectionMode::StageRanges {
ranges: vec![StageRange {
start_stage_id: 0,
end_stage_id: None,
model: "fpha".to_string(),
fpha_config: Some(FphaColumnLayout {
source: "precomputed".to_string(),
volume_discretization_points: None,
turbine_discretization_points: None,
spillage_discretization_points: None,
max_planes_per_hydro: None,
fitting_window: None,
}),
reference_volume: None,
productivity_mw_per_m3s: None,
}],
},
}
}
fn computed_fpha_config(hydro_id: i32) -> ProductionModelConfig {
ProductionModelConfig {
hydro_id: EntityId::from(hydro_id),
selection_mode: SelectionMode::StageRanges {
ranges: vec![StageRange {
start_stage_id: 0,
end_stage_id: None,
model: "fpha".to_string(),
fpha_config: Some(FphaColumnLayout {
source: "computed".to_string(),
volume_discretization_points: None,
turbine_discretization_points: None,
spillage_discretization_points: None,
max_planes_per_hydro: None,
fitting_window: None,
}),
reference_volume: None,
productivity_mw_per_m3s: None,
}],
},
}
}
#[test]
fn all_constant_no_config_returns_default_constant_provenance() {
let hydro0 = make_hydro(0, HydroGenerationModel::ConstantProductivity);
let hydro1 = make_hydro(1, HydroGenerationModel::ConstantProductivity);
let src0 = determine_source(&hydro0, None).expect("should succeed");
let src1 = determine_source(&hydro1, None).expect("should succeed");
assert_eq!(src0, ProductionModelSource::DefaultConstant);
assert_eq!(src1, ProductionModelSource::DefaultConstant);
}
#[test]
fn linearized_head_entity_resolves_to_constant_productivity() {
let hydro = make_hydro(0, HydroGenerationModel::LinearizedHead);
let src = determine_source(&hydro, None).expect("should succeed");
assert_eq!(src, ProductionModelSource::DefaultConstant);
}
#[test]
fn fpha_entity_without_config_entry_returns_validation_error() {
let hydro = make_hydro(0, HydroGenerationModel::Fpha);
let err = determine_source(&hydro, None).expect_err("should fail");
assert!(
matches!(err, crate::SddpError::Validation(ref msg) if
msg.contains("fpha") || msg.contains("no entry") || msg.contains("hydro_production_models")),
"expected Validation error mentioning missing config entry, got {err:?}"
);
}
#[test]
fn computed_source_returns_computed_from_geometry() {
let hydro = make_hydro(0, HydroGenerationModel::Fpha);
let config = computed_fpha_config(0);
let source = determine_source(&hydro, Some(&config)).expect("should succeed");
assert_eq!(
source,
ProductionModelSource::ComputedFromGeometry,
"expected ComputedFromGeometry, got {source:?}"
);
}
fn make_computed_hydro(id: i32) -> cobre_core::entities::hydro::Hydro {
let mut hydro = make_hydro(id, HydroGenerationModel::Fpha);
hydro.tailrace = Some(TailraceModel::Polynomial {
coefficients: vec![300.0],
});
hydro.hydraulic_losses = Some(HydraulicLossesModel::Factor { value: 0.02 });
hydro.efficiency = Some(EfficiencyModel::Constant { value: 0.92 });
hydro
}
fn make_geometry_rows(hydro_id: i32) -> Vec<HydroGeometryRow> {
vec![
HydroGeometryRow {
hydro_id: EntityId::from(hydro_id),
volume_hm3: 100.0,
height_m: 400.0,
area_km2: 10.0,
},
HydroGeometryRow {
hydro_id: EntityId::from(hydro_id),
volume_hm3: 2000.0,
height_m: 450.0,
area_km2: 50.0,
},
]
}
#[test]
fn computed_source_missing_tailrace_returns_validation_error() {
let hydro = make_hydro(0, HydroGenerationModel::Fpha);
let rows = make_geometry_rows(0);
let mut geometry_map: HashMap<EntityId, Vec<&HydroGeometryRow>> = HashMap::new();
let row_refs: Vec<&HydroGeometryRow> = rows.iter().collect();
geometry_map.insert(EntityId::from(0), row_refs);
let err = validate_computed_prerequisites(&hydro, &geometry_map)
.expect_err("should fail when tailrace is None");
let msg = err.to_string();
assert!(
msg.contains("tailrace"),
"error must mention 'tailrace', got: {msg}"
);
assert!(
msg.contains(&hydro.name),
"error must include hydro name '{}', got: {msg}",
hydro.name
);
}
#[test]
fn computed_source_missing_geometry_returns_validation_error() {
let hydro = make_computed_hydro(0);
let empty_geometry_map: HashMap<EntityId, Vec<&HydroGeometryRow>> = HashMap::new();
let err = validate_computed_prerequisites(&hydro, &empty_geometry_map)
.expect_err("should fail when geometry rows are absent");
let msg = err.to_string();
assert!(
msg.contains("geometry"),
"error must mention 'geometry', got: {msg}"
);
assert!(
msg.contains(&hydro.name),
"error must include hydro name '{}', got: {msg}",
hydro.name
);
}
#[test]
fn find_fpha_config_for_stage_returns_config_in_range() {
let config = computed_fpha_config(0);
let stage = make_stage(5);
let result = find_fpha_config_for_stage(&config, &stage);
assert!(
result.is_some(),
"expected Some(FphaColumnLayout) for stage 5, got None"
);
assert_eq!(
result.expect("just checked is_some").source,
"computed",
"expected source 'computed'"
);
}
#[test]
fn find_fpha_config_for_stage_returns_none_outside_range() {
let config = ProductionModelConfig {
hydro_id: EntityId::from(0),
selection_mode: SelectionMode::StageRanges {
ranges: vec![StageRange {
start_stage_id: 5,
end_stage_id: Some(10),
model: "fpha".to_string(),
fpha_config: Some(FphaColumnLayout {
source: "computed".to_string(),
volume_discretization_points: None,
turbine_discretization_points: None,
spillage_discretization_points: None,
max_planes_per_hydro: None,
fitting_window: None,
}),
reference_volume: None,
productivity_mw_per_m3s: None,
}],
},
};
let stage = make_stage(0);
let result = find_fpha_config_for_stage(&config, &stage);
assert!(
result.is_none(),
"expected None for stage 0 (outside range [5,10]), got {result:?}"
);
}
#[test]
fn gamma_0_is_scaled_by_kappa() {
let hydro = make_hydro(0, HydroGenerationModel::Fpha);
let stage = make_stage(0);
let row = FphaHyperplaneRow {
hydro_id: EntityId::from(0),
stage_id: None,
plane_id: 0,
gamma_0: 1000.0,
gamma_v: 0.002,
gamma_q: 0.85,
gamma_s: -0.01,
kappa: 0.95,
valid_v_min_hm3: None,
valid_v_max_hm3: None,
valid_q_max_m3s: None,
};
let mut map = std::collections::HashMap::new();
map.insert(
(EntityId::from(0), None::<i32>),
vec![&row as &FphaHyperplaneRow],
);
let model = build_fpha_model(
&hydro,
&stage,
ProductionModelSource::PrecomputedHyperplanes,
&map,
)
.expect("should build FPHA model");
match model {
ResolvedProductionModel::Fpha { planes, .. } => {
assert_eq!(planes.len(), 1);
let expected = 1000.0 * 0.95;
assert!(
(planes[0].intercept - expected).abs() < 1e-10,
"intercept must be gamma_0 * kappa = {expected}, got {}",
planes[0].intercept
);
}
other => panic!("expected Fpha variant, got {other:?}"),
}
}
#[test]
fn validation_rejects_gamma_v_negative() {
let hydro = make_hydro(0, HydroGenerationModel::Fpha);
let stage = make_stage(0);
let mut row = valid_row(0, None, 0);
row.gamma_v = -0.1;
let err = validate_hyperplane_row(&hydro, &stage, &row).expect_err("should fail");
let msg = err.to_string();
assert!(
msg.contains("gamma_v"),
"error must mention gamma_v, got: {msg}"
);
}
#[test]
fn validation_accepts_gamma_v_zero() {
let hydro = make_hydro(0, HydroGenerationModel::Fpha);
let stage = make_stage(0);
let mut row = valid_row(0, None, 0);
row.gamma_v = 0.0;
validate_hyperplane_row(&hydro, &stage, &row)
.expect("gamma_v = 0.0 must be valid for constant-head plants");
}
#[test]
fn validation_rejects_gamma_s_positive() {
let hydro = make_hydro(0, HydroGenerationModel::Fpha);
let stage = make_stage(0);
let mut row = valid_row(0, None, 0);
row.gamma_s = 0.01;
let err = validate_hyperplane_row(&hydro, &stage, &row).expect_err("should fail");
let msg = err.to_string();
assert!(
msg.contains("gamma_s"),
"error must mention gamma_s, got: {msg}"
);
}
#[test]
fn validation_rejects_gamma_q_nonpositive() {
let hydro = make_hydro(0, HydroGenerationModel::Fpha);
let stage = make_stage(0);
let mut row = valid_row(0, None, 0);
row.gamma_q = 0.0;
let err = validate_hyperplane_row(&hydro, &stage, &row).expect_err("should fail");
let msg = err.to_string();
assert!(
msg.contains("gamma_q"),
"error must mention gamma_q, got: {msg}"
);
}
#[test]
fn validation_rejects_kappa_zero() {
let hydro = make_hydro(0, HydroGenerationModel::Fpha);
let stage = make_stage(0);
let mut row = valid_row(0, None, 0);
row.kappa = 0.0;
let err = validate_hyperplane_row(&hydro, &stage, &row).expect_err("should fail");
let msg = err.to_string();
assert!(
msg.contains("kappa"),
"error must mention kappa, got: {msg}"
);
}
#[test]
fn validation_rejects_kappa_above_one() {
let hydro = make_hydro(0, HydroGenerationModel::Fpha);
let stage = make_stage(0);
let mut row = valid_row(0, None, 0);
row.kappa = 1.5;
let err = validate_hyperplane_row(&hydro, &stage, &row).expect_err("should fail");
let msg = err.to_string();
assert!(
msg.contains("kappa"),
"error must mention kappa, got: {msg}"
);
}
#[test]
fn stage_specific_hyperplanes_override_all_stage() {
let hydro = make_hydro(0, HydroGenerationModel::Fpha);
let stage = make_stage(0);
let global_row = FphaHyperplaneRow {
hydro_id: EntityId::from(0),
stage_id: None,
plane_id: 0,
gamma_0: 500.0, gamma_v: 0.001,
gamma_q: 0.80,
gamma_s: -0.005,
kappa: 1.0,
valid_v_min_hm3: None,
valid_v_max_hm3: None,
valid_q_max_m3s: None,
};
let stage_row = FphaHyperplaneRow {
hydro_id: EntityId::from(0),
stage_id: Some(0),
plane_id: 0,
gamma_0: 900.0, gamma_v: 0.002,
gamma_q: 0.85,
gamma_s: -0.01,
kappa: 1.0,
valid_v_min_hm3: None,
valid_v_max_hm3: None,
valid_q_max_m3s: None,
};
let mut map = std::collections::HashMap::new();
map.insert(
(EntityId::from(0), None::<i32>),
vec![&global_row as &FphaHyperplaneRow],
);
map.insert(
(EntityId::from(0), Some(0i32)),
vec![&stage_row as &FphaHyperplaneRow],
);
let model = build_fpha_model(
&hydro,
&stage,
ProductionModelSource::PrecomputedHyperplanes,
&map,
)
.expect("should succeed");
match model {
ResolvedProductionModel::Fpha { planes, .. } => {
assert!(
(planes[0].intercept - 900.0).abs() < 1e-10,
"stage-specific intercept 900 should override global 500, got {}",
planes[0].intercept
);
}
other => panic!("expected Fpha variant, got {other:?}"),
}
}
#[test]
fn all_stage_hyperplanes_used_when_no_stage_specific_rows() {
let hydro = make_hydro(0, HydroGenerationModel::Fpha);
let stage = make_stage(5);
let global_row = FphaHyperplaneRow {
hydro_id: EntityId::from(0),
stage_id: None,
plane_id: 0,
gamma_0: 700.0,
gamma_v: 0.002,
gamma_q: 0.85,
gamma_s: -0.01,
kappa: 1.0,
valid_v_min_hm3: None,
valid_v_max_hm3: None,
valid_q_max_m3s: None,
};
let mut map = std::collections::HashMap::new();
map.insert(
(EntityId::from(0), None::<i32>),
vec![&global_row as &FphaHyperplaneRow],
);
let model = build_fpha_model(
&hydro,
&stage,
ProductionModelSource::PrecomputedHyperplanes,
&map,
)
.expect("should succeed using global rows");
match model {
ResolvedProductionModel::Fpha { planes, .. } => {
assert!(
(planes[0].intercept - 700.0).abs() < 1e-10,
"expected global intercept 700, got {}",
planes[0].intercept
);
}
other => panic!("expected Fpha, got {other:?}"),
}
}
#[test]
fn zero_hyperplanes_for_stage_returns_validation_error() {
let hydro = make_hydro(0, HydroGenerationModel::Fpha);
let stage = make_stage(0);
let mut map = std::collections::HashMap::new();
map.insert(
(EntityId::from(0), None::<i32>),
Vec::<&FphaHyperplaneRow>::new(),
);
let err = build_fpha_model(
&hydro,
&stage,
ProductionModelSource::PrecomputedHyperplanes,
&map,
)
.expect_err("should fail with zero hyperplanes");
assert!(
matches!(err, crate::SddpError::Validation(_)),
"expected Validation error, got {err:?}"
);
}
#[test]
fn find_model_for_stage_returns_correct_model_name_in_range() {
let config = precomputed_fpha_config(0);
let stage = make_stage(3);
let result = find_model_for_stage(&config, &stage);
assert_eq!(result.as_ref().map(|(name, _)| name.as_str()), Some("fpha"));
}
#[test]
fn find_model_for_stage_returns_none_when_before_range_start() {
let config = ProductionModelConfig {
hydro_id: EntityId::from(0),
selection_mode: SelectionMode::StageRanges {
ranges: vec![StageRange {
start_stage_id: 5,
end_stage_id: Some(10),
model: "fpha".to_string(),
fpha_config: None,
reference_volume: None,
productivity_mw_per_m3s: None,
}],
},
};
let stage = make_stage(3); let result = find_model_for_stage(&config, &stage);
assert!(
result.is_none(),
"stage 3 is before range [5, 10], expected None"
);
}
#[test]
fn find_model_for_stage_open_ended_range_covers_all_stages() {
let config = ProductionModelConfig {
hydro_id: EntityId::from(0),
selection_mode: SelectionMode::StageRanges {
ranges: vec![StageRange {
start_stage_id: 0,
end_stage_id: None,
model: "constant_productivity".to_string(),
fpha_config: None,
reference_volume: None,
productivity_mw_per_m3s: None,
}],
},
};
for stage_id in [0, 5, 11, 100] {
let stage = make_stage(stage_id);
let result = find_model_for_stage(&config, &stage);
assert_eq!(
result.as_ref().map(|(name, _)| name.as_str()),
Some("constant_productivity"),
"open-ended range must cover stage {stage_id}"
);
}
}
#[test]
fn resolve_stage_model_uses_productivity_override() {
let hydro = make_hydro(0, HydroGenerationModel::ConstantProductivity);
let stage = make_stage(0);
let config = ProductionModelConfig {
hydro_id: EntityId::from(0),
selection_mode: SelectionMode::StageRanges {
ranges: vec![StageRange {
start_stage_id: 0,
end_stage_id: None,
model: "constant_productivity".to_string(),
fpha_config: None,
reference_volume: None,
productivity_mw_per_m3s: Some(0.55),
}],
},
};
let empty_map = std::collections::HashMap::new();
let model = super::resolve_stage_model(
&hydro,
&stage,
Some(&config),
ProductionModelSource::DefaultConstant,
&empty_map,
None,
None,
)
.expect("should succeed");
assert!(
matches!(model, ResolvedProductionModel::ConstantProductivity { productivity }
if (productivity - 0.55).abs() < f64::EPSILON),
"expected ConstantProductivity 0.55 (override), got {model:?}"
);
}
#[test]
fn test_resolve_stage_model_returns_sentinel_when_json_lacks_productivity() {
let hydro = make_hydro(0, HydroGenerationModel::ConstantProductivity);
let stage = make_stage(0);
let config = ProductionModelConfig {
hydro_id: EntityId::from(0),
selection_mode: SelectionMode::StageRanges {
ranges: vec![StageRange {
start_stage_id: 0,
end_stage_id: None,
model: "constant_productivity".to_string(),
fpha_config: None,
reference_volume: None,
productivity_mw_per_m3s: None,
}],
},
};
let empty_map = std::collections::HashMap::new();
#[cfg(debug_assertions)]
{
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
super::resolve_stage_model(
&hydro,
&stage,
Some(&config),
ProductionModelSource::DefaultConstant,
&empty_map,
None,
None,
)
}));
let panic_payload = result
.expect_err("debug build must panic via debug_assert! when productivity is None");
let msg = panic_payload
.downcast_ref::<String>()
.cloned()
.or_else(|| {
panic_payload
.downcast_ref::<&str>()
.map(|s| (*s).to_owned())
})
.unwrap_or_default();
assert!(
msg.contains("Hydro0") && msg.contains("validation::productivity_resolution"),
"panic message must name the hydro and the validator; got: {msg}"
);
}
#[cfg(not(debug_assertions))]
{
let model = super::resolve_stage_model(
&hydro,
&stage,
Some(&config),
ProductionModelSource::DefaultConstant,
&empty_map,
None,
None,
)
.expect("release build returns sentinel");
assert!(
matches!(
model,
ResolvedProductionModel::ConstantProductivity { productivity }
if productivity == 0.0
),
"release build must return ConstantProductivity {{ productivity: 0.0 }}; got {model:?}"
);
}
}
#[test]
fn test_resolve_stage_model_uses_parquet_override_when_json_omits_productivity() {
let hydro = make_hydro(0, HydroGenerationModel::ConstantProductivity);
let stage = make_stage(0);
let config = ProductionModelConfig {
hydro_id: EntityId::from(0),
selection_mode: SelectionMode::StageRanges {
ranges: vec![StageRange {
start_stage_id: 0,
end_stage_id: None,
model: "constant_productivity".to_string(),
fpha_config: None,
reference_volume: None,
productivity_mw_per_m3s: None,
}],
},
};
let empty_map = std::collections::HashMap::new();
let override_table = crate::energy_conversion::build_hydro_energy_productivity_override(&[
cobre_io::HydroEnergyProductivityRow {
hydro_id: EntityId::from(0),
stage_id: Some(0),
equivalent_productivity_mw_per_m3s: Some(0.42),
reference_outflow_m3s: None,
specific_productivity_mw_per_m3s_per_m: None,
},
])
.expect("override builds");
let model = super::resolve_stage_model(
&hydro,
&stage,
Some(&config),
ProductionModelSource::DefaultConstant,
&empty_map,
None,
Some(&override_table),
)
.expect("resolve succeeds");
assert!(
matches!(
model,
ResolvedProductionModel::ConstantProductivity { productivity }
if (productivity - 0.42).abs() < 1e-12
),
"override value must reach the resolved model; got {model:?}"
);
}
#[test]
fn test_resolve_stage_model_returns_sentinel_when_no_config_entry() {
let hydro = make_hydro(7, HydroGenerationModel::ConstantProductivity);
let stage = make_stage(3);
let empty_map = std::collections::HashMap::new();
#[cfg(debug_assertions)]
{
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
super::resolve_stage_model(
&hydro,
&stage,
None,
ProductionModelSource::DefaultConstant,
&empty_map,
None,
None,
)
}));
let panic_payload = result
.expect_err("debug build must panic via debug_assert! when no config entry exists");
let msg = panic_payload
.downcast_ref::<String>()
.cloned()
.or_else(|| {
panic_payload
.downcast_ref::<&str>()
.map(|s| (*s).to_owned())
})
.unwrap_or_default();
assert!(
msg.contains("Hydro7") && msg.contains("validation::productivity_resolution"),
"panic message must name the hydro and the validator; got: {msg}"
);
}
#[cfg(not(debug_assertions))]
{
let model = super::resolve_stage_model(
&hydro,
&stage,
None,
ProductionModelSource::DefaultConstant,
&empty_map,
None,
None,
)
.expect("release build returns sentinel");
assert!(
matches!(
model,
ResolvedProductionModel::ConstantProductivity { productivity }
if productivity == 0.0
),
"release build must return ConstantProductivity {{ productivity: 0.0 }}; got {model:?}"
);
}
}
#[test]
fn find_model_for_stage_returns_override_in_tuple() {
let config = ProductionModelConfig {
hydro_id: EntityId::from(0),
selection_mode: SelectionMode::StageRanges {
ranges: vec![StageRange {
start_stage_id: 0,
end_stage_id: None,
model: "constant_productivity".to_string(),
fpha_config: None,
reference_volume: None,
productivity_mw_per_m3s: Some(0.75),
}],
},
};
let stage = make_stage(0);
let result = find_model_for_stage(&config, &stage);
assert_eq!(
result,
Some(("constant_productivity".to_string(), Some(0.75)))
);
}
#[test]
fn find_model_for_stage_seasonal_with_override() {
let config = ProductionModelConfig {
hydro_id: EntityId::from(0),
selection_mode: SelectionMode::Seasonal {
default_model: "constant_productivity".to_string(),
seasons: vec![SeasonConfig {
season_id: 1,
model: "constant_productivity".to_string(),
fpha_config: None,
reference_volume: None,
productivity_mw_per_m3s: Some(0.60),
}],
},
};
let mut stage_match = make_stage(0);
stage_match.season_id = Some(1);
let result = find_model_for_stage(&config, &stage_match);
assert_eq!(
result,
Some(("constant_productivity".to_string(), Some(0.60)))
);
let mut stage_default = make_stage(0);
stage_default.season_id = Some(99);
let result = find_model_for_stage(&config, &stage_default);
assert_eq!(result, Some(("constant_productivity".to_string(), None)));
}
#[test]
fn precomputed_config_returns_precomputed_source() {
let hydro = make_hydro(0, HydroGenerationModel::Fpha);
let config = precomputed_fpha_config(0);
let src = determine_source(&hydro, Some(&config)).expect("should succeed");
assert_eq!(src, ProductionModelSource::PrecomputedHyperplanes);
}
fn make_sobradinho_computed_hydro(id: i32) -> cobre_core::entities::hydro::Hydro {
let mut hydro = make_hydro(id, HydroGenerationModel::Fpha);
hydro.name = format!("Sobradinho{id}");
hydro.min_storage_hm3 = 100.0;
hydro.max_storage_hm3 = 20_000.0;
hydro.max_turbined_m3s = 500.0;
hydro.tailrace = Some(TailraceModel::Polynomial {
coefficients: vec![0.0, 0.001_f64],
});
hydro.hydraulic_losses = Some(HydraulicLossesModel::Constant { value_m: 2.0 });
hydro.efficiency = Some(EfficiencyModel::Constant { value: 0.92 });
hydro
}
fn make_sobradinho_geometry_rows(hydro_id: i32) -> Vec<HydroGeometryRow> {
vec![
HydroGeometryRow {
hydro_id: EntityId::from(hydro_id),
volume_hm3: 100.0,
height_m: 386.5,
area_km2: 500.0,
},
HydroGeometryRow {
hydro_id: EntityId::from(hydro_id),
volume_hm3: 5_000.0,
height_m: 392.0,
area_km2: 800.0,
},
HydroGeometryRow {
hydro_id: EntityId::from(hydro_id),
volume_hm3: 12_000.0,
height_m: 396.5,
area_km2: 1_100.0,
},
HydroGeometryRow {
hydro_id: EntityId::from(hydro_id),
volume_hm3: 20_000.0,
height_m: 400.0,
area_km2: 1_400.0,
},
]
}
#[test]
fn computed_source_end_to_end_produces_valid_fpha_planes() {
let hydro = make_sobradinho_computed_hydro(0);
let config = computed_fpha_config(0);
let stage = make_stage(0);
let geo_rows = make_sobradinho_geometry_rows(0);
let geo_refs: Vec<&HydroGeometryRow> = geo_rows.iter().collect();
let mut geometry_map: HashMap<EntityId, Vec<&HydroGeometryRow>> = HashMap::new();
geometry_map.insert(EntityId::from(0), geo_refs);
let layout =
find_fpha_config_for_stage(&config, &stage).expect("computed config covers stage 0");
let fit_result = super::fit_planes_for_hydro(
&hydro,
layout,
&geometry_map,
0.0,
super::TailraceSource::Entity(hydro.tailrace.clone()),
None,
0,
false,
)
.expect("fit_planes_for_hydro must succeed for valid Sobradinho-style input");
let planes = &fit_result.planes;
assert!(
!planes.is_empty(),
"expected at least one plane, got {}",
planes.len()
);
for (idx, plane) in planes.iter().enumerate() {
assert!(
plane.gamma_v >= 0.0,
"plane {idx}: gamma_v={} must be >= 0",
plane.gamma_v
);
assert!(
plane.gamma_q >= 0.0,
"plane {idx}: gamma_q={} must be >= 0",
plane.gamma_q
);
assert!(
plane.gamma_s <= 0.0,
"plane {idx}: gamma_s={} must be <= 0",
plane.gamma_s
);
}
let empty_hyperplane_map: HashMap<(EntityId, Option<i32>), Vec<&FphaHyperplaneRow>> =
HashMap::new();
let model = super::resolve_stage_model(
&hydro,
&stage,
Some(&config),
ProductionModelSource::ComputedFromGeometry,
&empty_hyperplane_map,
Some(planes),
None,
)
.expect("resolve_stage_model must succeed for ComputedFromGeometry with cached planes");
match model {
ResolvedProductionModel::Fpha {
planes: out_planes, ..
} => {
assert_eq!(
out_planes.len(),
planes.len(),
"stage model must have the same plane count as the fitted planes"
);
}
other => panic!("expected Fpha variant, got {other:?}"),
}
}
#[test]
fn computed_export_rows_round_trip_through_parquet() {
let hydro = make_sobradinho_computed_hydro(0);
let config = computed_fpha_config(0);
let rows = make_sobradinho_geometry_rows(0);
let geometry_map = sobradinho_geometry_map(&rows);
let stages = [make_stage(0)];
let stage_refs: Vec<&Stage> = stages.iter().collect();
let mut export_rows = Vec::new();
let families_map = empty_families_map();
let (system, fractions) = entity_fit_context(&hydro);
let per_stage = super::fit_computed_planes_per_stage(
&hydro,
Some(&config),
&geometry_map,
&families_map,
&fractions,
&system,
&stage_refs,
0.0,
None,
false,
&mut export_rows,
&mut Vec::new(),
&mut Vec::new(),
)
.expect("per-stage fit must succeed");
let fitted_planes = &per_stage[0];
assert!(!export_rows.is_empty(), "export rows must be non-empty");
assert_eq!(
export_rows.len(),
fitted_planes.len(),
"one export row per fitted plane"
);
for row in &export_rows {
assert_eq!(row.kappa, 1.0, "computed rows pin kappa = 1.0");
}
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("fpha_hyperplanes.parquet");
cobre_io::output::write_fpha_hyperplanes(&path, &export_rows)
.expect("write_fpha_hyperplanes must succeed");
let read_rows =
cobre_io::extensions::parse_fpha_hyperplanes(&path).expect("re-read must succeed");
assert_eq!(
read_rows.len(),
fitted_planes.len(),
"re-read row count must match fitted plane count"
);
for (row, fitted) in read_rows.iter().zip(fitted_planes) {
let reconstructed = FphaPlane {
intercept: row.gamma_0 * row.kappa,
gamma_v: row.gamma_v,
gamma_q: row.gamma_q,
gamma_s: row.gamma_s,
};
assert!(
(reconstructed.intercept - fitted.intercept).abs() < 1e-9,
"intercept mismatch: {} vs {}",
reconstructed.intercept,
fitted.intercept
);
assert!((reconstructed.gamma_v - fitted.gamma_v).abs() < 1e-9);
assert!((reconstructed.gamma_q - fitted.gamma_q).abs() < 1e-9);
assert!((reconstructed.gamma_s - fitted.gamma_s).abs() < 1e-9);
}
}
#[test]
fn mixed_precomputed_and_computed_sources_resolve_correctly() {
let hydro0 = make_hydro(0, HydroGenerationModel::Fpha);
let config0 = precomputed_fpha_config(0);
let precomp_row_a = valid_row(0, None, 0);
let precomp_row_b = valid_row(0, None, 1);
let precomp_row_c = valid_row(0, None, 2);
let mut hyperplane_map: HashMap<(EntityId, Option<i32>), Vec<&FphaHyperplaneRow>> =
HashMap::new();
hyperplane_map.insert(
(EntityId::from(0), None),
vec![&precomp_row_a, &precomp_row_b, &precomp_row_c],
);
let hydro1 = make_sobradinho_computed_hydro(1);
let config1 = computed_fpha_config(1);
let geo_rows = make_sobradinho_geometry_rows(1);
let geo_refs: Vec<&HydroGeometryRow> = geo_rows.iter().collect();
let mut geometry_map: HashMap<EntityId, Vec<&HydroGeometryRow>> = HashMap::new();
geometry_map.insert(EntityId::from(1), geo_refs);
let stage = make_stage(0);
let src0 = determine_source(&hydro0, Some(&config0)).expect("hydro0 source");
let src1 = determine_source(&hydro1, Some(&config1)).expect("hydro1 source");
assert_eq!(
src0,
ProductionModelSource::PrecomputedHyperplanes,
"hydro 0 must be PrecomputedHyperplanes"
);
assert_eq!(
src1,
ProductionModelSource::ComputedFromGeometry,
"hydro 1 must be ComputedFromGeometry"
);
let layout1 =
find_fpha_config_for_stage(&config1, &stage).expect("computed config covers stage 0");
let computed_fit = super::fit_planes_for_hydro(
&hydro1,
layout1,
&geometry_map,
0.0,
super::TailraceSource::Entity(hydro1.tailrace.clone()),
None,
0,
false,
)
.expect("fit_planes_for_hydro must succeed for hydro 1");
let model0 = super::resolve_stage_model(
&hydro0,
&stage,
Some(&config0),
src0,
&hyperplane_map,
None,
None,
)
.expect("resolve_stage_model must succeed for hydro 0 (precomputed)");
let empty_hyperplane_map: HashMap<(EntityId, Option<i32>), Vec<&FphaHyperplaneRow>> =
HashMap::new();
let model1 = super::resolve_stage_model(
&hydro1,
&stage,
Some(&config1),
src1,
&empty_hyperplane_map,
Some(&computed_fit.planes),
None,
)
.expect("resolve_stage_model must succeed for hydro 1 (computed)");
assert!(
matches!(model0, ResolvedProductionModel::Fpha { .. }),
"hydro 0 must resolve to Fpha, got {model0:?}"
);
assert!(
matches!(model1, ResolvedProductionModel::Fpha { .. }),
"hydro 1 must resolve to Fpha, got {model1:?}"
);
assert_eq!(
src0,
ProductionModelSource::PrecomputedHyperplanes,
"provenance[0] must be PrecomputedHyperplanes"
);
assert_eq!(
src1,
ProductionModelSource::ComputedFromGeometry,
"provenance[1] must be ComputedFromGeometry"
);
}
#[test]
fn computed_source_all_stages_produce_identical_planes() {
let hydro = make_sobradinho_computed_hydro(0);
let config = computed_fpha_config(0);
let geo_rows = make_sobradinho_geometry_rows(0);
let geo_refs: Vec<&HydroGeometryRow> = geo_rows.iter().collect();
let mut geometry_map: HashMap<EntityId, Vec<&HydroGeometryRow>> = HashMap::new();
geometry_map.insert(EntityId::from(0), geo_refs);
let stages = [make_stage(0), make_stage(1), make_stage(2)];
let layout = find_fpha_config_for_stage(&config, &stages[0])
.expect("computed config covers stage 0");
let cached_fit = super::fit_planes_for_hydro(
&hydro,
layout,
&geometry_map,
0.0,
super::TailraceSource::Entity(hydro.tailrace.clone()),
None,
0,
false,
)
.expect("fit_planes_for_hydro must succeed");
let empty_hyperplane_map: HashMap<(EntityId, Option<i32>), Vec<&FphaHyperplaneRow>> =
HashMap::new();
let stage_planes: Vec<Vec<FphaPlane>> = stages
.iter()
.map(|stage| {
let model = super::resolve_stage_model(
&hydro,
stage,
Some(&config),
ProductionModelSource::ComputedFromGeometry,
&empty_hyperplane_map,
Some(&cached_fit.planes),
None,
)
.expect("resolve_stage_model must succeed");
match model {
ResolvedProductionModel::Fpha { planes, .. } => planes,
other => panic!("expected Fpha, got {other:?}"),
}
})
.collect();
assert_eq!(
stage_planes.len(),
3,
"must have plane vectors for 3 stages"
);
let expected_count = stage_planes[0].len();
for (s, planes) in stage_planes.iter().enumerate() {
assert_eq!(
planes.len(),
expected_count,
"stage {s}: plane count must be {expected_count}, got {}",
planes.len()
);
}
for (s, planes) in stage_planes.iter().enumerate().skip(1) {
for (p, plane) in planes.iter().enumerate() {
assert_eq!(
*plane, stage_planes[0][p],
"stage {s} plane {p}: must be identical to stage 0 plane {p}"
);
}
}
}
#[test]
fn computed_source_missing_efficiency_returns_validation_error() {
let mut hydro = make_computed_hydro(0);
hydro.name = "TestHydro".to_string();
hydro.efficiency = None;
let rows = make_geometry_rows(0);
let mut geometry_map: HashMap<EntityId, Vec<&HydroGeometryRow>> = HashMap::new();
let row_refs: Vec<&HydroGeometryRow> = rows.iter().collect();
geometry_map.insert(EntityId::from(0), row_refs);
let err = validate_computed_prerequisites(&hydro, &geometry_map)
.expect_err("should fail when efficiency is None");
let msg = err.to_string();
assert!(
msg.contains("efficiency"),
"error must mention 'efficiency', got: {msg}"
);
assert!(
msg.contains("TestHydro"),
"error must include hydro name 'TestHydro', got: {msg}"
);
}
#[test]
fn computed_source_missing_losses_returns_validation_error() {
let mut hydro = make_computed_hydro(0);
hydro.hydraulic_losses = None;
let rows = make_geometry_rows(0);
let mut geometry_map: HashMap<EntityId, Vec<&HydroGeometryRow>> = HashMap::new();
let row_refs: Vec<&HydroGeometryRow> = rows.iter().collect();
geometry_map.insert(EntityId::from(0), row_refs);
let err = validate_computed_prerequisites(&hydro, &geometry_map)
.expect_err("should fail when hydraulic_losses is None");
let msg = err.to_string();
assert!(
msg.contains("hydraulic_losses"),
"error must mention 'hydraulic_losses', got: {msg}"
);
assert!(
msg.contains(&hydro.name),
"error must include hydro name '{}', got: {msg}",
hydro.name
);
}
fn sobradinho_geometry_map(
rows: &[HydroGeometryRow],
) -> HashMap<EntityId, Vec<&HydroGeometryRow>> {
let mut map: HashMap<EntityId, Vec<&HydroGeometryRow>> = HashMap::new();
map.insert(rows[0].hydro_id, rows.iter().collect());
map
}
fn empty_families_map() -> HashMap<EntityId, TailraceFamilies> {
HashMap::new()
}
fn entity_fit_context(
hydro: &cobre_core::entities::hydro::Hydro,
) -> (System, HydroReferenceVolumeFractions) {
let system = SystemBuilder::new()
.buses(vec![make_bus()])
.hydros(vec![hydro.clone()])
.build()
.expect("single-hydro system builds");
let fractions = flat_reference_fractions(0.65, system.hydros());
(system, fractions)
}
fn computed_layout_with_window(min: Option<f64>, max: Option<f64>) -> FphaColumnLayout {
FphaColumnLayout {
source: "computed".to_string(),
volume_discretization_points: None,
turbine_discretization_points: None,
spillage_discretization_points: None,
max_planes_per_hydro: None,
fitting_window: Some(FittingWindow {
volume_min_hm3: min,
volume_max_hm3: max,
volume_min_percentile: None,
volume_max_percentile: None,
}),
}
}
#[test]
fn per_range_fits_differ_when_windows_differ() {
let hydro = make_sobradinho_computed_hydro(0);
let rows = make_sobradinho_geometry_rows(0);
let geometry_map = sobradinho_geometry_map(&rows);
let config = ProductionModelConfig {
hydro_id: EntityId::from(0),
selection_mode: SelectionMode::StageRanges {
ranges: vec![
StageRange {
start_stage_id: 0,
end_stage_id: Some(1),
model: "fpha".to_string(),
fpha_config: Some(computed_layout_with_window(Some(100.0), Some(8_000.0))),
reference_volume: None,
productivity_mw_per_m3s: None,
},
StageRange {
start_stage_id: 2,
end_stage_id: None,
model: "fpha".to_string(),
fpha_config: Some(computed_layout_with_window(Some(100.0), Some(20_000.0))),
reference_volume: None,
productivity_mw_per_m3s: None,
},
],
},
};
let stages = [make_stage(0), make_stage(1), make_stage(2), make_stage(3)];
let stage_refs: Vec<&Stage> = stages.iter().collect();
let mut export_rows = Vec::new();
let families_map = empty_families_map();
let (system, fractions) = entity_fit_context(&hydro);
let per_stage = super::fit_computed_planes_per_stage(
&hydro,
Some(&config),
&geometry_map,
&families_map,
&fractions,
&system,
&stage_refs,
0.0,
None,
false,
&mut export_rows,
&mut Vec::new(),
&mut Vec::new(),
)
.expect("per-stage fit must succeed");
assert_eq!(per_stage[0], per_stage[1], "range A stages must match");
assert_eq!(per_stage[2], per_stage[3], "range B stages must match");
assert_ne!(
per_stage[0], per_stage[2],
"range A and range B plane sets must differ when windows differ"
);
}
#[test]
fn per_season_fits_differ_when_season_configs_differ() {
let hydro = make_sobradinho_computed_hydro(0);
let rows = make_sobradinho_geometry_rows(0);
let geometry_map = sobradinho_geometry_map(&rows);
let config = ProductionModelConfig {
hydro_id: EntityId::from(0),
selection_mode: SelectionMode::Seasonal {
default_model: "fpha".to_string(),
seasons: vec![
SeasonConfig {
season_id: 0,
model: "fpha".to_string(),
fpha_config: Some(computed_layout_with_window(Some(100.0), Some(8_000.0))),
reference_volume: None,
productivity_mw_per_m3s: None,
},
SeasonConfig {
season_id: 1,
model: "fpha".to_string(),
fpha_config: Some(computed_layout_with_window(Some(100.0), Some(20_000.0))),
reference_volume: None,
productivity_mw_per_m3s: None,
},
],
},
};
let mut stage_s0 = make_stage(0);
stage_s0.season_id = Some(0);
let mut stage_s1 = make_stage(1);
stage_s1.season_id = Some(1);
let stages = [stage_s0, stage_s1];
let stage_refs: Vec<&Stage> = stages.iter().collect();
let mut export_rows = Vec::new();
let families_map = empty_families_map();
let (system, fractions) = entity_fit_context(&hydro);
let per_stage = super::fit_computed_planes_per_stage(
&hydro,
Some(&config),
&geometry_map,
&families_map,
&fractions,
&system,
&stage_refs,
0.0,
None,
false,
&mut export_rows,
&mut Vec::new(),
&mut Vec::new(),
)
.expect("per-season fit must succeed");
assert_ne!(
per_stage[0], per_stage[1],
"season 0 and season 1 plane sets must differ when configs differ"
);
}
#[test]
fn single_config_dedup_yields_identical_planes_across_stages() {
let hydro = make_sobradinho_computed_hydro(0);
let rows = make_sobradinho_geometry_rows(0);
let geometry_map = sobradinho_geometry_map(&rows);
let config = computed_fpha_config(0);
let stages = [make_stage(0), make_stage(1), make_stage(2)];
let stage_refs: Vec<&Stage> = stages.iter().collect();
let mut export_rows = Vec::new();
let families_map = empty_families_map();
let (system, fractions) = entity_fit_context(&hydro);
let per_stage = super::fit_computed_planes_per_stage(
&hydro,
Some(&config),
&geometry_map,
&families_map,
&fractions,
&system,
&stage_refs,
0.0,
None,
false,
&mut export_rows,
&mut Vec::new(),
&mut Vec::new(),
)
.expect("single-config fit must succeed");
assert_eq!(per_stage.len(), 3, "one plane set per study stage");
for s in 1..3 {
assert_eq!(
per_stage[0], per_stage[s],
"single-config hydro: stage {s} planes must be identical to stage 0 (dedup)"
);
}
assert_eq!(
export_rows.len(),
per_stage[0].len() * 3,
"export rows must cover every (stage, plane)"
);
}
#[test]
fn export_rows_carry_stage_id_and_canonical_order() {
let hydro = make_sobradinho_computed_hydro(0);
let rows = make_sobradinho_geometry_rows(0);
let geometry_map = sobradinho_geometry_map(&rows);
let config = computed_fpha_config(0);
let stages = [make_stage(0), make_stage(1), make_stage(2)];
let stage_refs: Vec<&Stage> = stages.iter().collect();
let mut export_rows = Vec::new();
let families_map = empty_families_map();
let (system, fractions) = entity_fit_context(&hydro);
super::fit_computed_planes_per_stage(
&hydro,
Some(&config),
&geometry_map,
&families_map,
&fractions,
&system,
&stage_refs,
0.0,
None,
false,
&mut export_rows,
&mut Vec::new(),
&mut Vec::new(),
)
.expect("fit must succeed");
for row in &export_rows {
assert!(
row.stage_id.is_some(),
"computed-FPHA export row must carry stage_id = Some(...), got None"
);
}
let keys: Vec<(i32, Option<i32>, i32)> = export_rows
.iter()
.map(|r| (r.hydro_id.0, r.stage_id, r.plane_id))
.collect();
let mut sorted = keys.clone();
sorted.sort();
assert_eq!(
keys, sorted,
"export rows must be ordered by (hydro_id, stage_id, plane_id)"
);
let mut seen_stages: Vec<i32> = export_rows.iter().filter_map(|r| r.stage_id).collect();
seen_stages.sort_unstable();
seen_stages.dedup();
assert_eq!(
seen_stages,
vec![0, 1, 2],
"every study stage must appear in the export rows"
);
}
#[test]
fn coverage_gap_returns_validation_error() {
let hydro = make_sobradinho_computed_hydro(0);
let rows = make_sobradinho_geometry_rows(0);
let geometry_map = sobradinho_geometry_map(&rows);
let config = ProductionModelConfig {
hydro_id: EntityId::from(0),
selection_mode: SelectionMode::StageRanges {
ranges: vec![StageRange {
start_stage_id: 0,
end_stage_id: Some(1),
model: "fpha".to_string(),
fpha_config: Some(computed_layout_with_window(None, None)),
reference_volume: None,
productivity_mw_per_m3s: None,
}],
},
};
let stages = [make_stage(0), make_stage(1), make_stage(2)];
let stage_refs: Vec<&Stage> = stages.iter().collect();
let mut export_rows = Vec::new();
let families_map = empty_families_map();
let (system, fractions) = entity_fit_context(&hydro);
let err = super::fit_computed_planes_per_stage(
&hydro,
Some(&config),
&geometry_map,
&families_map,
&fractions,
&system,
&stage_refs,
0.0,
None,
false,
&mut export_rows,
&mut Vec::new(),
&mut Vec::new(),
)
.expect_err("a coverage gap must error, not silently drop the stage");
assert!(
matches!(err, crate::SddpError::Validation(ref msg) if msg.contains("stage 2")),
"expected Validation naming the uncovered stage, got {err:?}"
);
}
fn inflow_row(hydro_id: i32, day: u32, value_m3s: f64) -> InflowHistoryRow {
InflowHistoryRow {
hydro_id: EntityId::from(hydro_id),
date: NaiveDate::from_ymd_opt(2000, 1, day).unwrap(),
value_m3s,
}
}
#[test]
fn long_term_mean_inflow_is_per_hydro_canonical_mean() {
let rows = vec![
inflow_row(1, 1, 100.0),
inflow_row(2, 1, 9_999.0), inflow_row(1, 2, 200.0),
inflow_row(1, 3, 300.0),
];
let system = SystemBuilder::new()
.inflow_history(rows)
.build()
.expect("valid system");
let long_term_mean_inflow_m3s = super::long_term_mean_inflow(&system, EntityId::from(1));
assert_eq!(
long_term_mean_inflow_m3s, 200.0,
"long-term mean inflow must be the per-hydro mean of its own series"
);
}
#[test]
fn long_term_mean_inflow_empty_history_is_zero() {
let system = SystemBuilder::new().build().expect("empty system is valid");
let long_term_mean_inflow_m3s = super::long_term_mean_inflow(&system, EntityId::from(1));
assert_eq!(
long_term_mean_inflow_m3s, 0.0,
"no inflow history must yield long-term mean inflow = 0"
);
}
#[test]
fn long_term_mean_inflow_is_order_independent() {
let ascending = vec![
inflow_row(1, 1, 10.0),
inflow_row(1, 2, 20.0),
inflow_row(1, 3, 60.0),
];
let descending = vec![
inflow_row(1, 3, 60.0),
inflow_row(1, 2, 20.0),
inflow_row(1, 1, 10.0),
];
let sys_asc = SystemBuilder::new()
.inflow_history(ascending)
.build()
.expect("valid");
let sys_desc = SystemBuilder::new()
.inflow_history(descending)
.build()
.expect("valid");
let mlt_asc = super::long_term_mean_inflow(&sys_asc, EntityId::from(1));
let mlt_desc = super::long_term_mean_inflow(&sys_desc, EntityId::from(1));
assert_eq!(
mlt_asc, mlt_desc,
"long-term mean inflow must be order-independent (declaration-order invariance)"
);
}
fn make_bus() -> Bus {
Bus {
id: EntityId::from(10),
name: "Bus10".to_string(),
deficit_segments: Vec::new(),
excess_cost: 0.0,
}
}
fn flat_reference_fractions(
default_fraction: f64,
hydros: &[cobre_core::entities::hydro::Hydro],
) -> HydroReferenceVolumeFractions {
let resolved: Vec<(EntityId, usize, f64)> = hydros
.iter()
.flat_map(|h| {
let v =
h.min_storage_hm3 + default_fraction * (h.max_storage_hm3 - h.min_storage_hm3);
(0..8).map(move |s| (h.id, s, v))
})
.collect();
cobre_io::extensions::build_hydro_reference_volumes_resolved(&resolved, 0.0)
}
fn geometry_map_for(rows: &[HydroGeometryRow]) -> HashMap<EntityId, Vec<&HydroGeometryRow>> {
let mut map: HashMap<EntityId, Vec<&HydroGeometryRow>> = HashMap::new();
for r in rows {
map.entry(r.hydro_id).or_default().push(r);
}
map
}
#[test]
fn resolve_downstream_level_no_downstream_is_none() {
let hydro = make_hydro(0, HydroGenerationModel::Fpha); let stage = make_stage(0);
let system = SystemBuilder::new()
.buses(vec![make_bus()])
.hydros(vec![hydro.clone()])
.build()
.expect("system");
let geometry_map: HashMap<EntityId, Vec<&HydroGeometryRow>> = HashMap::new();
let fractions = flat_reference_fractions(0.5, system.hydros());
let level =
resolve_downstream_level(&hydro, stage.index, &system, &geometry_map, &fractions);
assert!(level.is_none(), "no downstream_id must resolve to None");
}
#[test]
fn resolve_downstream_level_matches_independent_forebay_height() {
let mut upstream = make_hydro(0, HydroGenerationModel::Fpha);
upstream.downstream_id = Some(EntityId::from(1));
let downstream = make_hydro(1, HydroGenerationModel::ConstantProductivity);
let stage = make_stage(0);
let system = SystemBuilder::new()
.buses(vec![make_bus()])
.hydros(vec![upstream.clone(), downstream.clone()])
.build()
.expect("system");
let geo_rows = make_geometry_rows(1);
let geometry_map = geometry_map_for(&geo_rows);
let fraction = 0.5;
let fractions = flat_reference_fractions(fraction, system.hydros());
let level =
resolve_downstream_level(&upstream, stage.index, &system, &geometry_map, &fractions)
.expect("downstream level must resolve");
let v_ref = downstream.min_storage_hm3
+ fraction * (downstream.max_storage_hm3 - downstream.min_storage_hm3);
let table = ForebayTable::new(&geo_rows, &downstream.name).expect("forebay");
let expected = table.height(v_ref);
assert!(
(level - expected).abs() < 1e-9,
"resolved level {level} must match independent ForebayTable::height {expected}"
);
}
#[test]
fn resolve_downstream_level_missing_geometry_is_none() {
let mut upstream = make_hydro(0, HydroGenerationModel::Fpha);
upstream.downstream_id = Some(EntityId::from(1));
let downstream = make_hydro(1, HydroGenerationModel::ConstantProductivity);
let stage = make_stage(0);
let system = SystemBuilder::new()
.buses(vec![make_bus()])
.hydros(vec![upstream.clone(), downstream])
.build()
.expect("system");
let geometry_map: HashMap<EntityId, Vec<&HydroGeometryRow>> = HashMap::new();
let fractions = flat_reference_fractions(0.5, system.hydros());
let level =
resolve_downstream_level(&upstream, stage.index, &system, &geometry_map, &fractions);
assert!(
level.is_none(),
"missing downstream geometry must resolve to None"
);
}
#[test]
fn resolve_tailrace_source_absent_from_map_is_entity() {
let hydro = make_sobradinho_computed_hydro(0);
let stage = make_stage(0);
let (system, fractions) = entity_fit_context(&hydro);
let geometry_map: HashMap<EntityId, Vec<&HydroGeometryRow>> = HashMap::new();
let other_rows = vec![TailraceCurveRow {
hydro_id: EntityId::from(99),
family_id: 1,
downstream_reference_level_m: None,
segment_id: 1,
outflow_min_m3s: 0.0,
outflow_max_m3s: 1000.0,
coefficient_0: 5.0,
coefficient_1: 0.0,
coefficient_2: 0.0,
coefficient_3: 0.0,
coefficient_4: 0.0,
}];
let families_map =
super::build_tailrace_families_map(&other_rows).expect("families map builds");
let source = super::resolve_tailrace_source(
&hydro,
stage.index,
&families_map,
&geometry_map,
&fractions,
&system,
);
match source {
TailraceSource::Entity(model) => {
assert_eq!(
model, hydro.tailrace,
"fallback must carry the entity TailraceModel unchanged"
);
}
TailraceSource::Families { .. } => {
panic!("a plant absent from the families map must resolve to Entity")
}
}
}
#[test]
fn dedup_key_separates_distinct_downstream_levels() {
let mut upstream = make_sobradinho_computed_hydro(0);
upstream.downstream_id = Some(EntityId::from(1));
let downstream = make_hydro(1, HydroGenerationModel::ConstantProductivity);
let down_geo = [
HydroGeometryRow {
hydro_id: EntityId::from(1),
volume_hm3: 100.0,
height_m: 380.0,
area_km2: 10.0,
},
HydroGeometryRow {
hydro_id: EntityId::from(1),
volume_hm3: 2000.0,
height_m: 420.0,
area_km2: 50.0,
},
];
let up_geo = make_sobradinho_geometry_rows(0);
let mut geometry_map: HashMap<EntityId, Vec<&HydroGeometryRow>> = HashMap::new();
geometry_map.insert(EntityId::from(0), up_geo.iter().collect());
geometry_map.insert(EntityId::from(1), down_geo.iter().collect());
let tailrace_rows = vec![
TailraceCurveRow {
hydro_id: EntityId::from(0),
family_id: 1,
downstream_reference_level_m: Some(380.0),
segment_id: 1,
outflow_min_m3s: 0.0,
outflow_max_m3s: 100_000.0,
coefficient_0: 2.0,
coefficient_1: 0.0,
coefficient_2: 0.0,
coefficient_3: 0.0,
coefficient_4: 0.0,
},
TailraceCurveRow {
hydro_id: EntityId::from(0),
family_id: 2,
downstream_reference_level_m: Some(420.0),
segment_id: 1,
outflow_min_m3s: 0.0,
outflow_max_m3s: 100_000.0,
coefficient_0: 40.0,
coefficient_1: 0.0,
coefficient_2: 0.0,
coefficient_3: 0.0,
coefficient_4: 0.0,
},
];
let families_map =
super::build_tailrace_families_map(&tailrace_rows).expect("families map builds");
let mut stage0 = make_stage(0);
stage0.season_id = Some(0);
let mut stage1 = make_stage(1);
stage1.season_id = Some(1);
let down_v_min = downstream.min_storage_hm3;
let down_span = downstream.max_storage_hm3 - downstream.min_storage_hm3;
let fractions = cobre_io::extensions::build_hydro_reference_volumes_resolved(
&[
(EntityId::from(1), 0, down_v_min + 0.0001 * down_span),
(EntityId::from(1), 1, down_v_min + 1.0 * down_span),
],
0.0,
);
let system = SystemBuilder::new()
.buses(vec![make_bus()])
.hydros(vec![upstream.clone(), downstream])
.build()
.expect("two-hydro system builds");
let config = computed_fpha_config(0);
let stages = [&stage0, &stage1];
let stage_refs: Vec<&Stage> = stages.to_vec();
let mut export_rows = Vec::new();
let per_stage = super::fit_computed_planes_per_stage(
&upstream,
Some(&config),
&geometry_map,
&families_map,
&fractions,
&system,
&stage_refs,
0.0,
None,
false,
&mut export_rows,
&mut Vec::new(),
&mut Vec::new(),
)
.expect("per-stage fit succeeds");
let lvl0 =
resolve_downstream_level(&upstream, stage0.index, &system, &geometry_map, &fractions)
.expect("stage 0 level");
let lvl1 =
resolve_downstream_level(&upstream, stage1.index, &system, &geometry_map, &fractions)
.expect("stage 1 level");
assert_ne!(
lvl0.to_bits(),
lvl1.to_bits(),
"the two stages must resolve different downstream levels"
);
assert_ne!(
per_stage[0], per_stage[1],
"distinct downstream levels must yield distinct fits (dedup key includes the level)"
);
}
#[test]
fn fit_computed_planes_per_stage_records_every_distinct_fit() {
let mut upstream = make_sobradinho_computed_hydro(0);
upstream.downstream_id = Some(EntityId::from(1));
let downstream = make_hydro(1, HydroGenerationModel::ConstantProductivity);
let down_geo = [
HydroGeometryRow {
hydro_id: EntityId::from(1),
volume_hm3: 100.0,
height_m: 380.0,
area_km2: 10.0,
},
HydroGeometryRow {
hydro_id: EntityId::from(1),
volume_hm3: 2000.0,
height_m: 420.0,
area_km2: 50.0,
},
];
let up_geo = make_sobradinho_geometry_rows(0);
let mut geometry_map: HashMap<EntityId, Vec<&HydroGeometryRow>> = HashMap::new();
geometry_map.insert(EntityId::from(0), up_geo.iter().collect());
geometry_map.insert(EntityId::from(1), down_geo.iter().collect());
let tailrace_rows = vec![
TailraceCurveRow {
hydro_id: EntityId::from(0),
family_id: 1,
downstream_reference_level_m: Some(380.0),
segment_id: 1,
outflow_min_m3s: 0.0,
outflow_max_m3s: 100_000.0,
coefficient_0: 2.0,
coefficient_1: 0.0,
coefficient_2: 0.0,
coefficient_3: 0.0,
coefficient_4: 0.0,
},
TailraceCurveRow {
hydro_id: EntityId::from(0),
family_id: 2,
downstream_reference_level_m: Some(420.0),
segment_id: 1,
outflow_min_m3s: 0.0,
outflow_max_m3s: 100_000.0,
coefficient_0: 40.0,
coefficient_1: 0.0,
coefficient_2: 0.0,
coefficient_3: 0.0,
coefficient_4: 0.0,
},
];
let families_map =
super::build_tailrace_families_map(&tailrace_rows).expect("families map builds");
let mut stage0 = make_stage(0);
stage0.season_id = Some(0);
let mut stage1 = make_stage(1);
stage1.season_id = Some(1);
let down_v_min = downstream.min_storage_hm3;
let down_span = downstream.max_storage_hm3 - downstream.min_storage_hm3;
let fractions = cobre_io::extensions::build_hydro_reference_volumes_resolved(
&[
(EntityId::from(1), 0, down_v_min + 0.0001 * down_span),
(EntityId::from(1), 1, down_v_min + 1.0 * down_span),
],
0.0,
);
let system = SystemBuilder::new()
.buses(vec![make_bus()])
.hydros(vec![upstream.clone(), downstream])
.build()
.expect("two-hydro system builds");
let config = computed_fpha_config(0);
let stage_refs: Vec<&Stage> = vec![&stage0, &stage1];
let mut export_rows = Vec::new();
let mut diagnostics = Vec::new();
let per_stage = super::fit_computed_planes_per_stage(
&upstream,
Some(&config),
&geometry_map,
&families_map,
&fractions,
&system,
&stage_refs,
0.0,
None,
false,
&mut export_rows,
&mut diagnostics,
&mut Vec::new(),
)
.expect("per-stage fit succeeds");
assert_ne!(
per_stage[0], per_stage[1],
"the two stages must resolve distinct fits for this test to exercise two captures"
);
assert_eq!(
diagnostics.len(),
2,
"one diagnostic per distinct fit must be captured (capture-all), not only warn-worthy ones"
);
assert_eq!(diagnostics[0].stage_id, 0);
assert_eq!(diagnostics[1].stage_id, 1);
assert!(
diagnostics
.iter()
.any(|d| !d.deviation.exceeds_warn_threshold()),
"a well-fit (under-threshold) fit must still be captured"
);
}
#[test]
fn resolver_carries_every_fit_deviation_in_canonical_order() {
let hydro = make_sobradinho_computed_hydro(0);
let geo_rows = make_sobradinho_geometry_rows(0);
let artifacts = cobre_io::CaseArtifacts {
production_models: vec![computed_fpha_config(0)],
hydro_geometry: geo_rows,
..Default::default()
};
let stages = vec![make_stage(0), make_stage(1)];
let system = SystemBuilder::new()
.buses(vec![make_bus()])
.hydros(vec![hydro])
.stages(stages)
.build()
.expect("computed-FPHA system builds");
let (_, _, _, _, _, fpha_fit_deviations, _) =
super::resolve_production_models_from_artifacts(&system, &artifacts, false)
.expect("resolve must succeed for a computed-FPHA case");
assert_eq!(
fpha_fit_deviations.len(),
1,
"a single distinct fit must yield exactly one carried deviation entry"
);
let entry = fpha_fit_deviations[0];
assert_eq!(entry.hydro_id, EntityId::from(0), "hydro_id from outer zip");
assert_eq!(entry.stage_id, 0, "tagged with the first covered stage");
assert!(
entry.mean_abs_mw >= 0.0 && entry.max_abs_mw >= entry.mean_abs_mw,
"deviation magnitudes must be well-formed"
);
}
#[test]
fn export_rows_are_declaration_order_invariant_with_tailrace() {
fn build_artifacts(
tailrace_rows: Vec<TailraceCurveRow>,
geo_rows: Vec<HydroGeometryRow>,
) -> cobre_io::CaseArtifacts {
cobre_io::CaseArtifacts {
production_models: vec![computed_fpha_config(0)],
hydro_geometry: geo_rows,
tailrace_curves: tailrace_rows,
..Default::default()
}
}
let hydro = make_sobradinho_computed_hydro(0);
let stage = make_stage(0);
let system = SystemBuilder::new()
.buses(vec![make_bus()])
.hydros(vec![hydro.clone()])
.stages(vec![stage])
.build()
.expect("single-hydro system builds");
let geo_fwd = make_sobradinho_geometry_rows(0);
let mut geo_rev = geo_fwd.clone();
geo_rev.reverse();
let segment = TailraceCurveRow {
hydro_id: EntityId::from(0),
family_id: 1,
downstream_reference_level_m: None,
segment_id: 1,
outflow_min_m3s: 0.0,
outflow_max_m3s: 100_000.0,
coefficient_0: 2.0,
coefficient_1: 0.0,
coefficient_2: 0.0,
coefficient_3: 0.0,
coefficient_4: 0.0,
};
let art_fwd = build_artifacts(vec![segment.clone()], geo_fwd);
let art_rev = build_artifacts(vec![segment], geo_rev);
let (_, _, _, rows_fwd, _, _, _) =
resolve_production_models_from_artifacts(&system, &art_fwd, false)
.expect("forward resolve");
let (_, _, _, rows_rev, _, _, _) =
resolve_production_models_from_artifacts(&system, &art_rev, false)
.expect("reversed resolve");
assert_eq!(
rows_fwd.len(),
rows_rev.len(),
"export-row counts must match across input orderings"
);
for (a, b) in rows_fwd.iter().zip(&rows_rev) {
assert_eq!(a.hydro_id, b.hydro_id);
assert_eq!(a.stage_id, b.stage_id);
assert_eq!(a.plane_id, b.plane_id);
assert_eq!(
a.gamma_0.to_bits(),
b.gamma_0.to_bits(),
"gamma_0 must be bit-identical across input orderings"
);
assert_eq!(a.gamma_v.to_bits(), b.gamma_v.to_bits());
assert_eq!(a.gamma_q.to_bits(), b.gamma_q.to_bits());
assert_eq!(a.gamma_s.to_bits(), b.gamma_s.to_bits());
}
}
fn config_with_reference_volume(rv: Option<ReferenceVolume>) -> ProductionModelConfig {
ProductionModelConfig {
hydro_id: EntityId::from(1),
selection_mode: SelectionMode::StageRanges {
ranges: vec![StageRange {
start_stage_id: 0,
end_stage_id: None,
model: "constant_productivity".to_string(),
fpha_config: None,
reference_volume: rv,
productivity_mw_per_m3s: Some(0.5),
}],
},
}
}
#[test]
fn find_reference_volume_for_stage_returns_covering_entry_value() {
let config = config_with_reference_volume(Some(ReferenceVolume::AbsoluteHm3(800.0)));
let stage0 = make_stage(0);
assert_eq!(
find_reference_volume_for_stage(&config, &stage0),
Some(&ReferenceVolume::AbsoluteHm3(800.0))
);
}
#[test]
fn find_reference_volume_for_stage_returns_none_when_unset() {
let config = config_with_reference_volume(None);
let stage0 = make_stage(0);
assert_eq!(find_reference_volume_for_stage(&config, &stage0), None);
}
#[test]
fn find_reference_volume_for_stage_returns_none_outside_coverage() {
let config = ProductionModelConfig {
hydro_id: EntityId::from(1),
selection_mode: SelectionMode::StageRanges {
ranges: vec![StageRange {
start_stage_id: 5,
end_stage_id: Some(10),
model: "constant_productivity".to_string(),
fpha_config: None,
reference_volume: Some(ReferenceVolume::AbsoluteHm3(800.0)),
productivity_mw_per_m3s: Some(0.5),
}],
},
};
let stage0 = make_stage(0);
assert_eq!(find_reference_volume_for_stage(&config, &stage0), None);
}
#[test]
fn resolve_reference_volume_hm3_absolute_passthrough() {
let rv = ReferenceVolume::AbsoluteHm3(800.0);
let resolved = resolve_reference_volume_hm3(Some(&rv), 100.0, 2000.0);
assert_eq!(resolved, 800.0);
}
#[test]
fn resolve_reference_volume_hm3_percentile_against_band() {
let rv = ReferenceVolume::Percentile(0.5);
let resolved = resolve_reference_volume_hm3(Some(&rv), 100.0, 200.0);
assert!((resolved - 150.0).abs() <= f64::EPSILON);
}
#[test]
fn resolve_reference_volume_hm3_none_reproduces_065_fraction() {
let v_min = 100.0_f64;
let v_max = 200.0_f64;
let resolved = resolve_reference_volume_hm3(None, v_min, v_max);
let expected = v_min + 0.65 * (v_max - v_min);
assert_eq!(resolved.to_bits(), expected.to_bits());
}
#[test]
fn resolve_reference_volume_hm3_degenerate_band_is_not_nan() {
let rv = ReferenceVolume::Percentile(0.5);
let resolved = resolve_reference_volume_hm3(Some(&rv), 50.0, 50.0);
assert!(!resolved.is_nan());
assert_eq!(resolved, 50.0);
}
fn ref_vol_system(v_min: f64, v_max: f64) -> System {
let mut hydro = make_hydro(0, HydroGenerationModel::ConstantProductivity);
hydro.min_storage_hm3 = v_min;
hydro.max_storage_hm3 = v_max;
SystemBuilder::new()
.buses(vec![make_bus()])
.hydros(vec![hydro])
.stages(vec![make_stage(0), make_stage(1)])
.build()
.expect("single-hydro two-stage system builds")
}
fn ref_vol_artifacts(rv: Option<ReferenceVolume>) -> cobre_io::CaseArtifacts {
cobre_io::CaseArtifacts {
production_models: vec![ProductionModelConfig {
hydro_id: EntityId::from(0),
selection_mode: SelectionMode::StageRanges {
ranges: vec![StageRange {
start_stage_id: 0,
end_stage_id: None,
model: "constant_productivity".to_string(),
fpha_config: None,
reference_volume: rv,
productivity_mw_per_m3s: Some(1.0),
}],
},
}],
..Default::default()
}
}
#[test]
fn resolver_default_path_returns_065_fraction_hm3_bit_for_bit() {
let v_min = 100.0_f64;
let v_max = 200.0_f64;
let system = ref_vol_system(v_min, v_max);
let artifacts = ref_vol_artifacts(None);
let (_, _, _, _, table, _, _) =
resolve_production_models_from_artifacts(&system, &artifacts, false)
.expect("resolve succeeds");
let resolver = build_hydro_reference_volumes_resolved(&table, 0.0);
let expected = v_min + 0.65 * (v_max - v_min);
for stage_idx in [0_usize, 1] {
assert_eq!(
resolver.get(EntityId::from(0), stage_idx).to_bits(),
expected.to_bits(),
"stage {stage_idx}: undeclared reference volume must be the 0.65-fraction hm³"
);
}
}
#[test]
fn resolver_declared_absolute_flows_through_get() {
let system = ref_vol_system(100.0, 2000.0);
let artifacts = ref_vol_artifacts(Some(ReferenceVolume::AbsoluteHm3(800.0)));
let (_, _, _, _, table, _, _) =
resolve_production_models_from_artifacts(&system, &artifacts, false)
.expect("resolve succeeds");
let resolver = build_hydro_reference_volumes_resolved(&table, 0.0);
assert_eq!(resolver.get(EntityId::from(0), 0), 800.0);
assert_eq!(resolver.get(EntityId::from(0), 1), 800.0);
}
#[test]
fn resolver_declared_percentile_resolves_against_band() {
let system = ref_vol_system(100.0, 200.0);
let artifacts = ref_vol_artifacts(Some(ReferenceVolume::Percentile(0.5)));
let (_, _, _, _, table, _, _) =
resolve_production_models_from_artifacts(&system, &artifacts, false)
.expect("resolve succeeds");
let resolver = build_hydro_reference_volumes_resolved(&table, 0.0);
assert_eq!(resolver.get(EntityId::from(0), 0), 150.0);
}
#[test]
fn seasonal_reference_volume_supports_nonzero_start_season() {
let (v_min, v_max) = (100.0_f64, 200.0_f64);
let mut hydro = make_hydro(0, HydroGenerationModel::ConstantProductivity);
hydro.min_storage_hm3 = v_min;
hydro.max_storage_hm3 = v_max;
let mut stage0 = make_stage(0);
stage0.season_id = Some(2);
let mut stage1 = make_stage(1);
stage1.season_id = Some(1);
let system = SystemBuilder::new()
.buses(vec![make_bus()])
.hydros(vec![hydro])
.stages(vec![stage0, stage1])
.build()
.expect("two-stage non-zero-start-season system builds");
let artifacts = cobre_io::CaseArtifacts {
production_models: vec![ProductionModelConfig {
hydro_id: EntityId::from(0),
selection_mode: SelectionMode::Seasonal {
default_model: "constant_productivity".to_string(),
seasons: vec![
SeasonConfig {
season_id: 1,
model: "constant_productivity".to_string(),
fpha_config: None,
reference_volume: Some(ReferenceVolume::Percentile(0.8)),
productivity_mw_per_m3s: Some(1.0),
},
SeasonConfig {
season_id: 2,
model: "constant_productivity".to_string(),
fpha_config: None,
reference_volume: Some(ReferenceVolume::Percentile(0.2)),
productivity_mw_per_m3s: Some(1.0),
},
],
},
}],
..Default::default()
};
let (_, _, _, _, table, _, _) =
resolve_production_models_from_artifacts(&system, &artifacts, false)
.expect("resolve succeeds");
let resolver = build_hydro_reference_volumes_resolved(&table, 0.0);
assert_eq!(
resolver.get(EntityId::from(0), 0),
v_min + 0.2 * (v_max - v_min),
"study position 0 (season 2) must resolve percentile 0.2"
);
assert_eq!(
resolver.get(EntityId::from(0), 1),
v_min + 0.8 * (v_max - v_min),
"study position 1 (season 1) must resolve percentile 0.8"
);
}
}