use std::collections::{HashMap, HashSet};
use cobre_core::entities::HydroGenerationModel;
use super::{ErrorKind, ValidationContext, schema::ParsedData};
use crate::extensions::{ProductionModelConfig, SelectionMode};
#[allow(clippy::too_many_lines)]
pub(crate) fn validate_dimensional_consistency(data: &ParsedData, ctx: &mut ValidationContext) {
let study_stage_ids: Vec<i32> = data
.stages
.stages
.iter()
.filter(|s| s.id >= 0)
.map(|s| s.id)
.collect();
if !data.inflow_seasonal_stats.is_empty() {
let inflow_pairs: HashSet<(i32, i32)> = data
.inflow_seasonal_stats
.iter()
.map(|row| (row.hydro_id.0, row.stage_id))
.collect();
for hydro in &data.hydros {
for &stage_id in &study_stage_ids {
if let Some(entry) = hydro.entry_stage_id
&& stage_id < entry
{
continue;
}
if let Some(exit) = hydro.exit_stage_id
&& stage_id >= exit
{
continue;
}
if !inflow_pairs.contains(&(hydro.id.0, stage_id)) {
ctx.add_error(
ErrorKind::DimensionMismatch,
"scenarios/inflow_seasonal_stats.parquet",
Some(format!("Hydro {}", hydro.id.0)),
format!(
"Hydro {} missing inflow seasonal stats for stage {}",
hydro.id.0, stage_id
),
);
}
}
}
}
if !data.load_seasonal_stats.is_empty() {
let load_pairs: HashSet<(i32, i32)> = data
.load_seasonal_stats
.iter()
.map(|row| (row.bus_id.0, row.stage_id))
.collect();
for bus in &data.buses {
for &stage_id in &study_stage_ids {
if !load_pairs.contains(&(bus.id.0, stage_id)) {
ctx.add_error(
ErrorKind::DimensionMismatch,
"scenarios/load_seasonal_stats.parquet",
Some(format!("Bus {}", bus.id.0)),
format!(
"Bus {} missing load seasonal stats for stage {}",
bus.id.0, stage_id
),
);
}
}
}
}
if let Some(correlation) = &data.correlation {
for (profile_name, profile) in &correlation.profiles {
for group in &profile.groups {
let n_entities = group.entities.len();
let n_rows = group.matrix.len();
if n_rows != n_entities {
ctx.add_error(
ErrorKind::DimensionMismatch,
"scenarios/correlation.json",
Some(format!("group '{}' in profile '{}'", group.name, profile_name)),
format!(
"Correlation group '{}' in profile '{}': matrix has {} rows but {} entities",
group.name, profile_name, n_rows, n_entities
),
);
continue;
}
for (i, row) in group.matrix.iter().enumerate() {
if row.len() != n_entities {
ctx.add_error(
ErrorKind::DimensionMismatch,
"scenarios/correlation.json",
Some(format!("group '{}' in profile '{}'", group.name, profile_name)),
format!(
"Correlation group '{}' in profile '{}': matrix row {} has {} columns but {} entities",
group.name, profile_name, i, row.len(), n_entities
),
);
}
}
}
}
for entry in &correlation.schedule {
if !correlation.profiles.contains_key(&entry.profile_name) {
ctx.add_error(
ErrorKind::DimensionMismatch,
"scenarios/correlation.json",
Some(format!("schedule stage_id={}", entry.stage_id)),
format!(
"Correlation schedule references profile '{}' which does not exist in profiles",
entry.profile_name
),
);
}
}
}
if !data.fpha_hyperplanes.is_empty() {
let hydros_with_hyperplanes: HashSet<i32> = data
.fpha_hyperplanes
.iter()
.map(|row| row.hydro_id.0)
.collect();
let fpha_hydro_ids = collect_fpha_hydro_ids(&data.hydros, &data.production_models);
for &hydro_id in &fpha_hydro_ids {
if !hydros_with_hyperplanes.contains(&hydro_id) {
ctx.add_error(
ErrorKind::DimensionMismatch,
"system/fpha_hyperplanes.parquet",
Some(format!("Hydro {hydro_id}")),
format!(
"Hydro {hydro_id} is configured with FPHA model but has no FPHA hyperplanes"
),
);
}
}
}
if !data.hydro_geometry.is_empty() {
let mut geometry_row_counts: HashMap<i32, usize> = HashMap::new();
for row in &data.hydro_geometry {
*geometry_row_counts.entry(row.hydro_id.0).or_insert(0) += 1;
}
let head_hydro_ids =
collect_head_dependent_hydro_ids(&data.hydros, &data.production_models);
let fpha_hydro_ids = collect_fpha_hydro_ids(&data.hydros, &data.production_models);
for &hydro_id in &head_hydro_ids {
let count = geometry_row_counts.get(&hydro_id).copied().unwrap_or(0);
let min_required = if fpha_hydro_ids.contains(&hydro_id) {
1
} else {
2
};
if count < min_required {
ctx.add_error(
ErrorKind::DimensionMismatch,
"system/hydro_geometry.parquet",
Some(format!("Hydro {hydro_id}")),
format!(
"Hydro {hydro_id} requires head-dependent model but has {count} hydro geometry row(s) (minimum {min_required} required)"
),
);
}
}
}
}
fn collect_fpha_hydro_ids(
hydros: &[cobre_core::entities::Hydro],
production_models: &[ProductionModelConfig],
) -> HashSet<i32> {
let mut ids = HashSet::new();
for hydro in hydros {
if matches!(hydro.generation_model, HydroGenerationModel::Fpha) {
ids.insert(hydro.id.0);
}
}
for config in production_models {
if production_model_uses_fpha(config) {
ids.insert(config.hydro_id.0);
}
}
ids
}
fn collect_head_dependent_hydro_ids(
hydros: &[cobre_core::entities::Hydro],
production_models: &[ProductionModelConfig],
) -> HashSet<i32> {
let mut ids = HashSet::new();
for hydro in hydros {
if matches!(
hydro.generation_model,
HydroGenerationModel::Fpha | HydroGenerationModel::LinearizedHead
) {
ids.insert(hydro.id.0);
}
}
for config in production_models {
if production_model_uses_head_dependent(config) {
ids.insert(config.hydro_id.0);
}
}
ids
}
fn production_model_uses_fpha(config: &ProductionModelConfig) -> bool {
match &config.selection_mode {
SelectionMode::StageRanges { ranges } => ranges.iter().any(|r| r.model == "fpha"),
SelectionMode::Seasonal {
default_model,
seasons,
} => default_model == "fpha" || seasons.iter().any(|s| s.model == "fpha"),
}
}
fn production_model_uses_head_dependent(config: &ProductionModelConfig) -> bool {
match &config.selection_mode {
SelectionMode::StageRanges { ranges } => ranges
.iter()
.any(|r| r.model == "fpha" || r.model == "linearized_head"),
SelectionMode::Seasonal {
default_model,
seasons,
} => {
default_model == "fpha"
|| default_model == "linearized_head"
|| seasons
.iter()
.any(|s| s.model == "fpha" || s.model == "linearized_head")
}
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::panic,
clippy::too_many_lines,
clippy::doc_markdown,
clippy::cast_sign_loss,
clippy::uninlined_format_args
)]
mod tests {
use std::collections::BTreeMap;
use cobre_core::{
EntityId,
entities::{Bus, HydroGenerationModel, HydroPenalties},
scenario::{
CorrelationEntity, CorrelationGroup, CorrelationModel, CorrelationProfile,
CorrelationScheduleEntry,
},
temporal::{
Block, BlockMode, NoiseMethod, PolicyGraph, ScenarioSourceConfig, Stage,
StageRiskConfig, StageStateConfig,
},
};
use crate::{
extensions::{FphaHyperplaneRow, HydroGeometryRow, SeasonConfig},
scenarios::{InflowSeasonalStatsRow, LoadSeasonalStatsRow},
validation::{ErrorKind, ValidationContext},
};
use super::*;
use chrono::NaiveDate;
fn make_hydro(
id: i32,
generation_model: HydroGenerationModel,
entry_stage_id: Option<i32>,
exit_stage_id: Option<i32>,
) -> cobre_core::entities::Hydro {
cobre_core::entities::Hydro {
id: EntityId(id),
name: format!("Hydro {id}"),
operational_start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
bus_id: EntityId(1),
downstream_id: None,
travel_time_hours: None,
entry_stage_id,
exit_stage_id,
min_storage_hm3: 0.0,
max_storage_hm3: 1000.0,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
generation_model,
min_turbined_m3s: 0.0,
max_turbined_m3s: 100.0,
specific_productivity_mw_per_m3s_per_m: None,
min_generation_mw: 0.0,
max_generation_mw: 500.0,
tailrace: None,
hydraulic_losses: None,
efficiency: None,
evaporation_coefficients_mm: None,
evaporation_reference_volumes_hm3: None,
diversion: None,
filling: None,
penalties: penalties_default(),
}
}
fn make_bus(id: i32) -> Bus {
Bus {
id: EntityId(id),
name: format!("Bus {id}"),
operational_start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
deficit_segments: vec![],
excess_cost: 0.0,
}
}
fn make_stage(id: i32) -> Stage {
Stage {
index: id as usize,
id,
start_date: chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
end_date: chrono::NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
season_id: None,
blocks: vec![Block {
index: 0,
name: "FLAT".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 make_pre_study_stage(id: i32) -> Stage {
Stage {
index: 0,
id,
start_date: chrono::NaiveDate::from_ymd_opt(2023, 12, 1).unwrap(),
end_date: chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
season_id: None,
blocks: vec![],
block_mode: BlockMode::Parallel,
state_config: StageStateConfig {
storage: false,
inflow_lags: false,
},
risk_config: StageRiskConfig::Expectation,
scenario_config: ScenarioSourceConfig {
branching_factor: 1,
noise_method: NoiseMethod::Saa,
},
}
}
fn penalties_default() -> HydroPenalties {
HydroPenalties {
spillage_cost: 1.0,
diversion_cost: 1.0,
turbined_cost: 1.0,
storage_violation_below_cost: 1.0,
filling_target_violation_cost: 1.0,
turbined_violation_below_cost: 1.0,
outflow_violation_below_cost: 1.0,
outflow_violation_above_cost: 1.0,
generation_violation_below_cost: 1.0,
evaporation_violation_cost: 1.0,
water_withdrawal_violation_cost: 1.0,
water_withdrawal_violation_pos_cost: 1.0,
water_withdrawal_violation_neg_cost: 1.0,
evaporation_violation_pos_cost: 1.0,
evaporation_violation_neg_cost: 1.0,
inflow_nonnegativity_cost: 1000.0,
}
}
fn inflow_stats_row(hydro_id: i32, stage_id: i32) -> InflowSeasonalStatsRow {
InflowSeasonalStatsRow {
hydro_id: EntityId(hydro_id),
stage_id,
mean_m3s: 100.0,
std_m3s: 10.0,
}
}
fn load_stats_row(bus_id: i32, stage_id: i32) -> LoadSeasonalStatsRow {
LoadSeasonalStatsRow {
bus_id: EntityId(bus_id),
stage_id,
mean_mw: 500.0,
std_mw: 50.0,
}
}
fn fpha_row(hydro_id: i32) -> FphaHyperplaneRow {
FphaHyperplaneRow {
hydro_id: EntityId(hydro_id),
stage_id: None,
plane_id: 0,
gamma_0: 100.0,
gamma_v: 0.001,
gamma_q: 0.9,
gamma_s: -0.01,
kappa: 1.0,
valid_v_min_hm3: None,
valid_v_max_hm3: None,
valid_q_max_m3s: None,
}
}
fn geometry_row(hydro_id: i32, volume: f64) -> HydroGeometryRow {
HydroGeometryRow {
hydro_id: EntityId(hydro_id),
volume_hm3: volume,
height_m: volume * 0.1,
area_km2: volume * 0.001,
}
}
fn make_correlation_model(
groups: Vec<CorrelationGroup>,
schedule: Vec<CorrelationScheduleEntry>,
) -> CorrelationModel {
let mut profiles = BTreeMap::new();
profiles.insert("default".to_string(), CorrelationProfile { groups });
CorrelationModel {
method: "spectral".to_string(),
profiles,
schedule,
}
}
fn base_parsed_data() -> ParsedData {
use crate::{
config::{
Config, EstimationConfig, ExportsConfig, ModelingConfig, PolicyConfig,
RowSelectionConfig, SimulationConfig, StoppingRuleConfig, TrainingConfig,
TrainingSolverConfig, UpperBoundEvaluationConfig,
},
stages::StagesData,
};
use cobre_core::{
entities::DeficitSegment, initial_conditions::InitialConditions,
penalty::GlobalPenaltyDefaults,
};
let config = Config {
schema: None,
modeling: ModelingConfig::default(),
training: TrainingConfig {
enabled: true,
tree_seed: None,
forward_passes: Some(10),
stopping_rules: Some(vec![StoppingRuleConfig::IterationLimit { limit: 100 }]),
stopping_mode: "any".to_string(),
cut_selection: RowSelectionConfig::default(),
solver: TrainingSolverConfig::default(),
scenario_source: None,
},
upper_bound_evaluation: UpperBoundEvaluationConfig::default(),
policy: PolicyConfig::default(),
simulation: SimulationConfig::default(),
exports: ExportsConfig::default(),
estimation: EstimationConfig::default(),
};
ParsedData {
config,
penalties: GlobalPenaltyDefaults {
bus_deficit_segments: vec![DeficitSegment {
depth_mw: None,
cost_per_mwh: 1.0,
}],
bus_excess_cost: 1.0,
line_exchange_cost: 1.0,
hydro: HydroPenalties {
spillage_cost: 1.0,
diversion_cost: 1.0,
turbined_cost: 1.0,
storage_violation_below_cost: 1.0,
filling_target_violation_cost: 1.0,
turbined_violation_below_cost: 1.0,
outflow_violation_below_cost: 1.0,
outflow_violation_above_cost: 1.0,
generation_violation_below_cost: 1.0,
evaporation_violation_cost: 1.0,
water_withdrawal_violation_cost: 1.0,
water_withdrawal_violation_pos_cost: 1.0,
water_withdrawal_violation_neg_cost: 1.0,
evaporation_violation_pos_cost: 1.0,
evaporation_violation_neg_cost: 1.0,
inflow_nonnegativity_cost: 1000.0,
},
ncs_curtailment_cost: 1.0,
},
stages: StagesData {
stages: vec![make_stage(0), make_stage(1)],
policy_graph: PolicyGraph::default(),
},
initial_conditions: InitialConditions {
storage: vec![],
filling_storage: vec![],
past_inflows: vec![],
past_anticipated_commitments: vec![],
recent_observations: vec![],
past_defluences: vec![],
},
buses: vec![],
thermals: vec![],
hydros: vec![],
lines: vec![],
non_controllable_sources: vec![],
pumping_stations: vec![],
energy_contracts: vec![],
hydro_geometry: vec![],
production_models: vec![],
plane_reduction: None,
hydro_energy_productivity_rows: vec![],
fpha_hyperplanes: vec![],
scalar_parameters: vec![],
inflow_history: vec![],
inflow_seasonal_stats: vec![],
inflow_ar_coefficients: vec![],
inflow_annual_components: vec![],
external_scenarios: vec![],
external_load_scenarios: vec![],
external_ncs_scenarios: vec![],
load_seasonal_stats: vec![],
load_factors: vec![],
correlation: None,
non_controllable_factors: vec![],
ncs_models: vec![],
thermal_bounds: vec![],
hydro_bounds: vec![],
line_bounds: vec![],
pumping_bounds: vec![],
contract_bounds: vec![],
exchange_factors: vec![],
generic_constraints: vec![],
generic_constraint_bounds: vec![],
penalty_overrides_bus: vec![],
penalty_overrides_line: vec![],
penalty_overrides_hydro: vec![],
penalty_overrides_ncs: vec![],
ncs_bounds: vec![],
}
}
#[test]
fn test_valid_coverage_no_errors() {
let mut data = base_parsed_data();
data.hydros = vec![
make_hydro(1, HydroGenerationModel::ConstantProductivity, None, None),
make_hydro(2, HydroGenerationModel::ConstantProductivity, None, None),
];
data.inflow_seasonal_stats = vec![
inflow_stats_row(1, 0),
inflow_stats_row(1, 1),
inflow_stats_row(2, 0),
inflow_stats_row(2, 1),
];
let mut ctx = ValidationContext::new();
validate_dimensional_consistency(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"expected no errors for valid coverage, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_missing_inflow_stats_one_hydro_one_stage() {
let mut data = base_parsed_data();
data.hydros = vec![
make_hydro(1, HydroGenerationModel::ConstantProductivity, None, None),
make_hydro(2, HydroGenerationModel::ConstantProductivity, None, None),
make_hydro(3, HydroGenerationModel::ConstantProductivity, None, None),
];
data.inflow_seasonal_stats = vec![
inflow_stats_row(1, 0),
inflow_stats_row(1, 1),
inflow_stats_row(2, 0), inflow_stats_row(3, 0),
inflow_stats_row(3, 1),
];
let mut ctx = ValidationContext::new();
validate_dimensional_consistency(&data, &mut ctx);
let errors = ctx.errors();
assert_eq!(
errors.len(),
1,
"expected exactly 1 error, got {}: {:?}",
errors.len(),
errors
);
assert_eq!(errors[0].kind, ErrorKind::DimensionMismatch);
assert!(
errors[0].message.contains("Hydro 2"),
"error message should contain 'Hydro 2', got: {}",
errors[0].message
);
assert!(
errors[0].message.contains("stage 1"),
"error message should contain 'stage 1', got: {}",
errors[0].message
);
}
#[test]
fn test_correlation_matrix_row_count_mismatch() {
let mut data = base_parsed_data();
let group = CorrelationGroup {
name: "Southeast".to_string(),
entities: vec![
CorrelationEntity {
entity_type: "inflow".to_string(),
id: EntityId(1),
},
CorrelationEntity {
entity_type: "inflow".to_string(),
id: EntityId(2),
},
CorrelationEntity {
entity_type: "inflow".to_string(),
id: EntityId(3),
},
],
matrix: vec![vec![1.0, 0.8], vec![0.8, 1.0]],
};
data.correlation = Some(make_correlation_model(vec![group], vec![]));
let mut ctx = ValidationContext::new();
validate_dimensional_consistency(&data, &mut ctx);
let errors = ctx.errors();
assert!(
!errors.is_empty(),
"expected at least 1 error for row count mismatch"
);
assert_eq!(errors[0].kind, ErrorKind::DimensionMismatch);
assert!(
errors[0].message.contains("Southeast"),
"error should mention group name 'Southeast', got: {}",
errors[0].message
);
assert!(
errors[0].message.contains("3 entities"),
"error should mention '3 entities', got: {}",
errors[0].message
);
}
#[test]
fn test_correlation_matrix_non_square_row() {
let mut data = base_parsed_data();
let group = CorrelationGroup {
name: "North".to_string(),
entities: vec![
CorrelationEntity {
entity_type: "inflow".to_string(),
id: EntityId(1),
},
CorrelationEntity {
entity_type: "inflow".to_string(),
id: EntityId(2),
},
],
matrix: vec![vec![1.0, 0.5], vec![0.5]],
};
data.correlation = Some(make_correlation_model(vec![group], vec![]));
let mut ctx = ValidationContext::new();
validate_dimensional_consistency(&data, &mut ctx);
let errors = ctx.errors();
assert_eq!(
errors.len(),
1,
"expected exactly 1 column mismatch error, got: {:?}",
errors
);
assert_eq!(errors[0].kind, ErrorKind::DimensionMismatch);
assert!(
errors[0].message.contains("North"),
"error should mention group name 'North', got: {}",
errors[0].message
);
}
#[test]
fn test_empty_optional_data_no_false_positives() {
let mut data = base_parsed_data();
data.hydros = vec![make_hydro(1, HydroGenerationModel::Fpha, None, None)];
data.buses = vec![make_bus(1)];
let mut ctx = ValidationContext::new();
validate_dimensional_consistency(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"expected no errors for empty optional data, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_fpha_hydro_missing_hyperplane_rows() {
let mut data = base_parsed_data();
data.hydros = vec![
make_hydro(1, HydroGenerationModel::Fpha, None, None),
make_hydro(2, HydroGenerationModel::ConstantProductivity, None, None),
];
data.fpha_hyperplanes = vec![fpha_row(2)];
let mut ctx = ValidationContext::new();
validate_dimensional_consistency(&data, &mut ctx);
let errors = ctx.errors();
assert_eq!(
errors.len(),
1,
"expected exactly 1 error for missing FPHA hyperplanes, got: {:?}",
errors
);
assert_eq!(errors[0].kind, ErrorKind::DimensionMismatch);
assert!(
errors[0].message.contains('1'),
"error should mention hydro ID 1, got: {}",
errors[0].message
);
assert!(
errors[0]
.message
.to_lowercase()
.contains("fpha hyperplanes"),
"error should mention 'FPHA hyperplanes', got: {}",
errors[0].message
);
}
#[test]
fn test_hydro_lifecycle_entry_stage_id_skips_earlier_stages() {
let mut data = base_parsed_data();
data.hydros = vec![make_hydro(
1,
HydroGenerationModel::ConstantProductivity,
Some(1), None,
)];
data.inflow_seasonal_stats = vec![inflow_stats_row(1, 1)];
let mut ctx = ValidationContext::new();
validate_dimensional_consistency(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"expected no errors when stage 0 is before hydro entry, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_hydro_lifecycle_exit_stage_id_skips_later_stages() {
let mut data = base_parsed_data();
data.hydros = vec![make_hydro(
1,
HydroGenerationModel::ConstantProductivity,
None,
Some(1), )];
data.inflow_seasonal_stats = vec![inflow_stats_row(1, 0)];
let mut ctx = ValidationContext::new();
validate_dimensional_consistency(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"expected no errors when stage 1 is after hydro exit, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_pre_study_stages_not_checked() {
use crate::stages::StagesData;
let mut data = base_parsed_data();
data.stages = StagesData {
stages: vec![make_pre_study_stage(-1), make_stage(0), make_stage(1)],
policy_graph: PolicyGraph::default(),
};
data.hydros = vec![make_hydro(
1,
HydroGenerationModel::ConstantProductivity,
None,
None,
)];
data.inflow_seasonal_stats = vec![inflow_stats_row(1, 0), inflow_stats_row(1, 1)];
let mut ctx = ValidationContext::new();
validate_dimensional_consistency(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"pre-study stages should not require inflow stats, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_load_stats_missing_for_one_stage() {
let mut data = base_parsed_data();
data.buses = vec![make_bus(1), make_bus(2)];
data.load_seasonal_stats = vec![
load_stats_row(1, 0),
load_stats_row(1, 1),
load_stats_row(2, 0), ];
let mut ctx = ValidationContext::new();
validate_dimensional_consistency(&data, &mut ctx);
let errors = ctx.errors();
assert_eq!(
errors.len(),
1,
"expected 1 error for missing load stats, got: {:?}",
errors
);
assert_eq!(errors[0].kind, ErrorKind::DimensionMismatch);
assert!(
errors[0].message.contains("Bus 2"),
"error should mention 'Bus 2', got: {}",
errors[0].message
);
assert!(
errors[0].message.contains("stage 1"),
"error should mention 'stage 1', got: {}",
errors[0].message
);
}
#[test]
fn test_correlation_schedule_missing_profile() {
let mut data = base_parsed_data();
data.correlation = Some(make_correlation_model(
vec![],
vec![CorrelationScheduleEntry {
stage_id: 0,
profile_name: "nonexistent_profile".to_string(),
}],
));
let mut ctx = ValidationContext::new();
validate_dimensional_consistency(&data, &mut ctx);
let errors = ctx.errors();
assert!(
!errors.is_empty(),
"expected error for missing profile reference"
);
assert_eq!(errors[0].kind, ErrorKind::DimensionMismatch);
assert!(
errors[0].message.contains("nonexistent_profile"),
"error should mention the missing profile name, got: {}",
errors[0].message
);
}
#[test]
fn test_linearized_head_hydro_missing_geometry() {
let mut data = base_parsed_data();
data.hydros = vec![make_hydro(
1,
HydroGenerationModel::LinearizedHead,
None,
None,
)];
data.hydro_geometry = vec![geometry_row(1, 100.0)];
let mut ctx = ValidationContext::new();
validate_dimensional_consistency(&data, &mut ctx);
let errors = ctx.errors();
assert_eq!(
errors.len(),
1,
"expected 1 error for insufficient geometry rows, got: {:?}",
errors
);
assert_eq!(errors[0].kind, ErrorKind::DimensionMismatch);
}
#[test]
fn test_fpha_hydro_single_geometry_row_accepted() {
let mut data = base_parsed_data();
data.hydros = vec![make_hydro(1, HydroGenerationModel::Fpha, None, None)];
data.hydro_geometry = vec![geometry_row(1, 100.0)];
let mut ctx = ValidationContext::new();
validate_dimensional_consistency(&data, &mut ctx);
assert!(
ctx.errors().is_empty(),
"a single geometry row must be valid for an FPHA plant, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_fpha_hydro_zero_geometry_rows_rejected() {
let mut data = base_parsed_data();
data.hydros = vec![
make_hydro(1, HydroGenerationModel::Fpha, None, None),
make_hydro(2, HydroGenerationModel::ConstantProductivity, None, None),
];
data.hydro_geometry = vec![geometry_row(2, 100.0), geometry_row(2, 200.0)];
let mut ctx = ValidationContext::new();
validate_dimensional_consistency(&data, &mut ctx);
assert!(
ctx.errors().iter().any(|e| {
e.kind == ErrorKind::DimensionMismatch
&& e.message.contains("Hydro 1")
&& e.message.contains("minimum 1 required")
}),
"FPHA hydro with zero geometry rows must fail with minimum-1, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_all_rules_checked_independently() {
let mut data = base_parsed_data();
data.hydros = vec![make_hydro(
1,
HydroGenerationModel::ConstantProductivity,
None,
None,
)];
data.inflow_seasonal_stats = vec![inflow_stats_row(1, 0)];
data.buses = vec![make_bus(1)];
data.load_seasonal_stats = vec![load_stats_row(1, 0)];
let mut ctx = ValidationContext::new();
validate_dimensional_consistency(&data, &mut ctx);
let errors = ctx.errors();
assert!(
errors.len() >= 2,
"expected at least 2 errors (rules 1 and 2), got {}: {:?}",
errors.len(),
errors
);
assert!(
errors
.iter()
.all(|e| e.kind == ErrorKind::DimensionMismatch),
"all errors should be DimensionMismatch"
);
}
#[test]
fn test_production_model_uses_fpha_agrees_with_resolve_stage_on_seasonal_default() {
let config = ProductionModelConfig {
hydro_id: EntityId(0),
selection_mode: SelectionMode::Seasonal {
default_model: "fpha".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.5),
}],
},
};
assert!(
production_model_uses_fpha(&config),
"production_model_uses_fpha must agree with the solver crate's \
resolve_stage/selection_entries: default_model == \"fpha\" classifies \
as FPHA even when every listed season is non-FPHA"
);
}
}