use std::collections::HashSet;
use super::{ErrorKind, ValidationContext, schema::ParsedData};
pub(crate) fn validate_referential_integrity(data: &ParsedData, ctx: &mut ValidationContext) {
let ids = LookupSets {
bus: data.buses.iter().map(|b| b.id.0).collect(),
hydro: data.hydros.iter().map(|h| h.id.0).collect(),
thermal: data.thermals.iter().map(|t| t.id.0).collect(),
line: data.lines.iter().map(|l| l.id.0).collect(),
pumping: data.pumping_stations.iter().map(|p| p.id.0).collect(),
contract: data.energy_contracts.iter().map(|c| c.id.0).collect(),
ncs: data
.non_controllable_sources
.iter()
.map(|n| n.id.0)
.collect(),
generic_constraint: data.generic_constraints.iter().map(|g| g.id.0).collect(),
};
check_line_references(data, ctx, &ids.bus);
check_hydro_references(data, ctx, &ids.bus, &ids.hydro);
check_thermal_references(data, ctx, &ids.bus);
check_ncs_references(data, ctx, &ids.bus, &ids.ncs);
check_pumping_references(data, ctx, &ids.bus, &ids.hydro);
check_contract_references(data, ctx, &ids.bus);
check_extension_references(data, ctx, &ids.hydro);
check_scenario_references(data, ctx, &ids.bus, &ids.hydro, &ids.ncs);
check_bounds_references(data, ctx, &ids);
check_penalty_override_references(data, ctx, &ids.bus, &ids.hydro, &ids.line, &ids.ncs);
check_load_factor_references(data, ctx, &ids.bus);
check_generic_constraint_expression_references(data, ctx, &ids);
check_generic_constraint_bounds_validity(data, ctx);
check_ncs_bounds_and_factors(data, ctx, &ids.ncs);
}
struct LookupSets {
bus: HashSet<i32>,
hydro: HashSet<i32>,
thermal: HashSet<i32>,
line: HashSet<i32>,
pumping: HashSet<i32>,
contract: HashSet<i32>,
ncs: HashSet<i32>,
generic_constraint: HashSet<i32>,
}
fn check_line_references(data: &ParsedData, ctx: &mut ValidationContext, bus_ids: &HashSet<i32>) {
for line in &data.lines {
let entity_str = format!("Line {}", line.id.0);
if !bus_ids.contains(&line.source_bus_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"system/lines.json",
Some(&entity_str),
format!(
"{entity_str} references non-existent Bus {} via field 'source_bus_id'",
line.source_bus_id.0
),
);
}
if !bus_ids.contains(&line.target_bus_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"system/lines.json",
Some(&entity_str),
format!(
"{entity_str} references non-existent Bus {} via field 'target_bus_id'",
line.target_bus_id.0
),
);
}
}
}
fn check_hydro_references(
data: &ParsedData,
ctx: &mut ValidationContext,
bus_ids: &HashSet<i32>,
hydro_ids: &HashSet<i32>,
) {
for hydro in &data.hydros {
let entity_str = format!("Hydro {}", hydro.id.0);
if !bus_ids.contains(&hydro.bus_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"system/hydros.json",
Some(&entity_str),
format!(
"{entity_str} references non-existent Bus {} via field 'bus_id'",
hydro.bus_id.0
),
);
}
if let Some(downstream_id) = hydro.downstream_id
&& !hydro_ids.contains(&downstream_id.0)
{
ctx.add_error(
ErrorKind::InvalidReference,
"system/hydros.json",
Some(&entity_str),
format!(
"{entity_str} references non-existent Hydro {} via field 'downstream_id'",
downstream_id.0
),
);
}
if let Some(ref diversion) = hydro.diversion
&& !hydro_ids.contains(&diversion.downstream_id.0)
{
ctx.add_error(
ErrorKind::InvalidReference,
"system/hydros.json",
Some(&entity_str),
format!(
"{entity_str} references non-existent Hydro {} via field 'diversion.downstream_id'",
diversion.downstream_id.0
),
);
}
}
}
fn check_thermal_references(
data: &ParsedData,
ctx: &mut ValidationContext,
bus_ids: &HashSet<i32>,
) {
for thermal in &data.thermals {
let entity_str = format!("Thermal {}", thermal.id.0);
if !bus_ids.contains(&thermal.bus_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"system/thermals.json",
Some(&entity_str),
format!(
"{entity_str} references non-existent Bus {} via field 'bus_id'",
thermal.bus_id.0
),
);
}
}
}
fn check_ncs_references(
data: &ParsedData,
ctx: &mut ValidationContext,
bus_ids: &HashSet<i32>,
ncs_ids: &HashSet<i32>,
) {
for ncs in &data.non_controllable_sources {
let entity_str = format!("NonControllableSource {}", ncs.id.0);
if !bus_ids.contains(&ncs.bus_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"system/non_controllable_sources.json",
Some(&entity_str),
format!(
"{entity_str} references non-existent Bus {} via field 'bus_id'",
ncs.bus_id.0
),
);
}
}
for (i, model) in data.ncs_models.iter().enumerate() {
if !ncs_ids.contains(&model.ncs_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"scenarios/non_controllable_stats.parquet",
Some(format!("NcsModel[{i}]")),
format!(
"NcsModel[{i}] references non-existent NonControllableSource {} via field 'ncs_id'",
model.ncs_id.0
),
);
}
}
}
fn check_pumping_references(
data: &ParsedData,
ctx: &mut ValidationContext,
bus_ids: &HashSet<i32>,
hydro_ids: &HashSet<i32>,
) {
for station in &data.pumping_stations {
let entity_str = format!("PumpingStation {}", station.id.0);
if !bus_ids.contains(&station.bus_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"system/pumping_stations.json",
Some(&entity_str),
format!(
"{entity_str} references non-existent Bus {} via field 'bus_id'",
station.bus_id.0
),
);
}
if !hydro_ids.contains(&station.source_hydro_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"system/pumping_stations.json",
Some(&entity_str),
format!(
"{entity_str} references non-existent Hydro {} via field 'source_hydro_id'",
station.source_hydro_id.0
),
);
}
if !hydro_ids.contains(&station.destination_hydro_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"system/pumping_stations.json",
Some(&entity_str),
format!(
"{entity_str} references non-existent Hydro {} via field 'destination_hydro_id'",
station.destination_hydro_id.0
),
);
}
}
}
fn check_contract_references(
data: &ParsedData,
ctx: &mut ValidationContext,
bus_ids: &HashSet<i32>,
) {
for contract in &data.energy_contracts {
let entity_str = format!("EnergyContract {}", contract.id.0);
if !bus_ids.contains(&contract.bus_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"system/energy_contracts.json",
Some(&entity_str),
format!(
"{entity_str} references non-existent Bus {} via field 'bus_id'",
contract.bus_id.0
),
);
}
}
}
fn check_extension_references(
data: &ParsedData,
ctx: &mut ValidationContext,
hydro_ids: &HashSet<i32>,
) {
for (i, row) in data.hydro_geometry.iter().enumerate() {
if !hydro_ids.contains(&row.hydro_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"system/hydro_geometry.parquet",
Some(format!("HydroGeometryRow[{i}]")),
format!(
"HydroGeometryRow[{i}] references non-existent Hydro {} via field 'hydro_id'",
row.hydro_id.0
),
);
}
}
for (i, model) in data.production_models.iter().enumerate() {
if !hydro_ids.contains(&model.hydro_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"system/hydro_production_models.json",
Some(format!("ProductionModelConfig[{i}]")),
format!(
"ProductionModelConfig[{i}] references non-existent Hydro {} via field 'hydro_id'",
model.hydro_id.0
),
);
}
}
for (i, row) in data.fpha_hyperplanes.iter().enumerate() {
if !hydro_ids.contains(&row.hydro_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"system/fpha_hyperplanes.parquet",
Some(format!("FphaHyperplaneRow[{i}]")),
format!(
"FphaHyperplaneRow[{i}] references non-existent Hydro {} via field 'hydro_id'",
row.hydro_id.0
),
);
}
}
}
#[allow(clippy::too_many_lines)]
fn check_scenario_references(
data: &ParsedData,
ctx: &mut ValidationContext,
bus_ids: &HashSet<i32>,
hydro_ids: &HashSet<i32>,
ncs_ids: &HashSet<i32>,
) {
for (i, row) in data.inflow_seasonal_stats.iter().enumerate() {
if !hydro_ids.contains(&row.hydro_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"scenarios/inflow_seasonal_stats.parquet",
Some(format!("InflowSeasonalStatsRow[{i}]")),
format!(
"InflowSeasonalStatsRow[{i}] references non-existent Hydro {} via field 'hydro_id'",
row.hydro_id.0
),
);
}
}
for (i, row) in data.inflow_ar_coefficients.iter().enumerate() {
if !hydro_ids.contains(&row.hydro_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"scenarios/inflow_ar_coefficients.parquet",
Some(format!("InflowArCoefficientRow[{i}]")),
format!(
"InflowArCoefficientRow[{i}] references non-existent Hydro {} via field 'hydro_id'",
row.hydro_id.0
),
);
}
}
for (i, row) in data.inflow_annual_components.iter().enumerate() {
if !hydro_ids.contains(&row.hydro_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"scenarios/inflow_annual_component.parquet",
Some(format!("InflowAnnualComponentRow[{i}]")),
format!(
"InflowAnnualComponentRow[{i}] references non-existent Hydro {} via field 'hydro_id'",
row.hydro_id.0
),
);
}
}
for (i, row) in data.inflow_history.iter().enumerate() {
if !hydro_ids.contains(&row.hydro_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"scenarios/inflow_history.parquet",
Some(format!("InflowHistoryRow[{i}]")),
format!(
"InflowHistoryRow[{i}] references non-existent Hydro {} via field 'hydro_id'",
row.hydro_id.0
),
);
}
}
for (i, row) in data.load_seasonal_stats.iter().enumerate() {
if !bus_ids.contains(&row.bus_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"scenarios/load_seasonal_stats.parquet",
Some(format!("LoadSeasonalStatsRow[{i}]")),
format!(
"LoadSeasonalStatsRow[{i}] references non-existent Bus {} via field 'bus_id'",
row.bus_id.0
),
);
}
}
if let Some(ref correlation) = data.correlation {
for profile in correlation.profiles.values() {
for group in &profile.groups {
for entity in &group.entities {
let (valid, type_label, registry_label) = match entity.entity_type.as_str() {
"inflow" => (hydro_ids.contains(&entity.id.0), "inflow", "Hydro"),
"load" => (bus_ids.contains(&entity.id.0), "load", "Bus"),
"ncs" => (
ncs_ids.contains(&entity.id.0),
"ncs",
"NonControllableSource",
),
other => {
ctx.add_error(
ErrorKind::InvalidReference,
"scenarios/correlation.json",
Some(format!("CorrelationEntity({other}, {})", entity.id.0)),
format!(
"unknown entity_type '{other}'; valid types are: inflow, load, ncs"
),
);
continue;
}
};
if !valid {
let entity_str =
format!("CorrelationEntity({type_label}, {})", entity.id.0);
ctx.add_error(
ErrorKind::InvalidReference,
"scenarios/correlation.json",
Some(&entity_str),
format!(
"{entity_str} references non-existent {registry_label} {} via field 'id'",
entity.id.0
),
);
}
}
}
}
}
for (i, row) in data.external_scenarios.iter().enumerate() {
if !hydro_ids.contains(&row.hydro_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"scenarios/external_inflow_scenarios.parquet",
Some(format!("ExternalScenarioRow[{i}]")),
format!(
"ExternalScenarioRow[{i}] references non-existent Hydro {} via field 'hydro_id'",
row.hydro_id.0
),
);
}
}
for (i, row) in data.external_load_scenarios.iter().enumerate() {
if !bus_ids.contains(&row.bus_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"scenarios/external_load_scenarios.parquet",
Some(format!("ExternalLoadRow[{i}]")),
format!(
"ExternalLoadRow[{i}] references non-existent Bus {} via field 'bus_id'",
row.bus_id.0
),
);
}
}
for (i, row) in data.external_ncs_scenarios.iter().enumerate() {
if !ncs_ids.contains(&row.ncs_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"scenarios/external_ncs_scenarios.parquet",
Some(format!("ExternalNcsRow[{i}]")),
format!(
"ExternalNcsRow[{i}] references non-existent NonControllableSource {} via field 'ncs_id'",
row.ncs_id.0
),
);
}
}
}
fn check_bounds_references(data: &ParsedData, ctx: &mut ValidationContext, ids: &LookupSets) {
for (i, row) in data.thermal_bounds.iter().enumerate() {
if !ids.thermal.contains(&row.thermal_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"constraints/thermal_bounds.parquet",
Some(format!("ThermalBoundsRow[{i}]")),
format!(
"ThermalBoundsRow[{i}] references non-existent Thermal {} via field 'thermal_id'",
row.thermal_id.0
),
);
}
}
for (i, row) in data.hydro_bounds.iter().enumerate() {
if !ids.hydro.contains(&row.hydro_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"constraints/hydro_bounds.parquet",
Some(format!("HydroBoundsRow[{i}]")),
format!(
"HydroBoundsRow[{i}] references non-existent Hydro {} via field 'hydro_id'",
row.hydro_id.0
),
);
}
}
for (i, row) in data.line_bounds.iter().enumerate() {
if !ids.line.contains(&row.line_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"constraints/line_bounds.parquet",
Some(format!("LineBoundsRow[{i}]")),
format!(
"LineBoundsRow[{i}] references non-existent Line {} via field 'line_id'",
row.line_id.0
),
);
}
}
for (i, row) in data.pumping_bounds.iter().enumerate() {
if !ids.pumping.contains(&row.station_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"constraints/pumping_bounds.parquet",
Some(format!("PumpingBoundsRow[{i}]")),
format!(
"PumpingBoundsRow[{i}] references non-existent PumpingStation {} via field 'station_id'",
row.station_id.0
),
);
}
}
for (i, row) in data.contract_bounds.iter().enumerate() {
if !ids.contract.contains(&row.contract_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"constraints/contract_bounds.parquet",
Some(format!("ContractBoundsRow[{i}]")),
format!(
"ContractBoundsRow[{i}] references non-existent EnergyContract {} via field 'contract_id'",
row.contract_id.0
),
);
}
}
for (i, row) in data.generic_constraint_bounds.iter().enumerate() {
if !ids.generic_constraint.contains(&row.constraint_id) {
ctx.add_error(
ErrorKind::InvalidReference,
"constraints/generic_constraint_bounds.parquet",
Some(format!("GenericConstraintBoundsRow[{i}]")),
format!(
"GenericConstraintBoundsRow[{i}] references non-existent GenericConstraint {} via field 'constraint_id'",
row.constraint_id
),
);
}
}
}
fn check_penalty_override_references(
data: &ParsedData,
ctx: &mut ValidationContext,
bus_ids: &HashSet<i32>,
hydro_ids: &HashSet<i32>,
line_ids: &HashSet<i32>,
ncs_ids: &HashSet<i32>,
) {
for (i, row) in data.penalty_overrides_bus.iter().enumerate() {
if !bus_ids.contains(&row.bus_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"constraints/penalty_overrides_bus.parquet",
Some(format!("BusPenaltyOverrideRow[{i}]")),
format!(
"BusPenaltyOverrideRow[{i}] references non-existent Bus {} via field 'bus_id'",
row.bus_id.0
),
);
}
}
for (i, row) in data.penalty_overrides_line.iter().enumerate() {
if !line_ids.contains(&row.line_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"constraints/penalty_overrides_line.parquet",
Some(format!("LinePenaltyOverrideRow[{i}]")),
format!(
"LinePenaltyOverrideRow[{i}] references non-existent Line {} via field 'line_id'",
row.line_id.0
),
);
}
}
for (i, row) in data.penalty_overrides_hydro.iter().enumerate() {
if !hydro_ids.contains(&row.hydro_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"constraints/penalty_overrides_hydro.parquet",
Some(format!("HydroPenaltyOverrideRow[{i}]")),
format!(
"HydroPenaltyOverrideRow[{i}] references non-existent Hydro {} via field 'hydro_id'",
row.hydro_id.0
),
);
}
}
for (i, row) in data.penalty_overrides_ncs.iter().enumerate() {
if !ncs_ids.contains(&row.source_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"constraints/penalty_overrides_ncs.parquet",
Some(format!("NcsPenaltyOverrideRow[{i}]")),
format!(
"NcsPenaltyOverrideRow[{i}] references non-existent NonControllableSource {} via field 'source_id'",
row.source_id.0
),
);
}
}
}
fn check_load_factor_references(
data: &ParsedData,
ctx: &mut ValidationContext,
bus_ids: &HashSet<i32>,
) {
let study_stage_ids: HashSet<i32> = data
.stages
.stages
.iter()
.filter(|s| s.id >= 0)
.map(|s| s.id)
.collect();
for (i, entry) in data.load_factors.iter().enumerate() {
if !bus_ids.contains(&entry.bus_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"scenarios/load_factors.json",
Some(format!("LoadFactorEntry[{i}]")),
format!(
"LoadFactorEntry[{i}] references non-existent Bus {} via field 'bus_id'",
entry.bus_id.0
),
);
}
if !study_stage_ids.contains(&entry.stage_id) {
ctx.add_error(
ErrorKind::InvalidReference,
"scenarios/load_factors.json",
Some(format!("LoadFactorEntry[{i}]")),
format!(
"LoadFactorEntry[{i}] references non-existent Stage {} via field 'stage_id'",
entry.stage_id
),
);
}
}
}
fn check_generic_constraint_expression_references(
data: &ParsedData,
ctx: &mut ValidationContext,
ids: &LookupSets,
) {
let entity_ids = EntityIdSets {
hydro: &ids.hydro,
thermal: &ids.thermal,
line: &ids.line,
bus: &ids.bus,
pumping: &ids.pumping,
contract: &ids.contract,
ncs: &ids.ncs,
};
for constraint in &data.generic_constraints {
let gc_label = format!("GenericConstraint {}", constraint.id.0);
for (term_idx, term) in constraint.expression.terms.iter().enumerate() {
let label = format!("{gc_label} term[{term_idx}]");
validate_variable_ref_entity(&term.variable, &label, &entity_ids, ctx);
}
}
}
fn check_generic_constraint_bounds_validity(data: &ParsedData, ctx: &mut ValidationContext) {
let stage_block_counts: std::collections::HashMap<i32, usize> = data
.stages
.stages
.iter()
.filter(|s| s.id >= 0)
.map(|s| (s.id, s.blocks.len()))
.collect();
for (i, row) in data.generic_constraint_bounds.iter().enumerate() {
if let Some(blk) = row.block_id
&& let Some(&n_blocks) = stage_block_counts.get(&row.stage_id)
{
#[allow(clippy::cast_sign_loss)]
let blk_usize = blk as usize;
if blk < 0 || blk_usize >= n_blocks {
ctx.add_error(
ErrorKind::InvalidValue,
"constraints/generic_constraint_bounds.parquet",
Some(format!("GenericConstraintBoundsRow[{i}]")),
format!(
"GenericConstraintBoundsRow[{i}] has block_id={blk} but Stage {} has only {n_blocks} block(s) (valid range: 0..{n_blocks})",
row.stage_id
),
);
}
}
}
{
let mut seen_keys: HashSet<(i32, i32, Option<i32>)> = HashSet::new();
for (i, row) in data.generic_constraint_bounds.iter().enumerate() {
let key = (row.constraint_id, row.stage_id, row.block_id);
if !seen_keys.insert(key) {
ctx.add_error(
ErrorKind::DuplicateId,
"constraints/generic_constraint_bounds.parquet",
Some(format!("GenericConstraintBoundsRow[{i}]")),
format!(
"Duplicate key (constraint_id={}, stage_id={}, block_id={:?}) in generic constraint bounds",
row.constraint_id, row.stage_id, row.block_id
),
);
}
}
}
}
fn check_ncs_bounds_and_factors(
data: &ParsedData,
ctx: &mut ValidationContext,
ncs_ids: &HashSet<i32>,
) {
let study_stage_ids: HashSet<i32> = data
.stages
.stages
.iter()
.filter(|s| s.id >= 0)
.map(|s| s.id)
.collect();
for (i, row) in data.ncs_bounds.iter().enumerate() {
if !ncs_ids.contains(&row.ncs_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"constraints/ncs_bounds.parquet",
Some(format!("NcsBoundsRow[{i}]")),
format!(
"NcsBoundsRow[{i}] references non-existent NonControllableSource {} via field 'ncs_id'",
row.ncs_id.0
),
);
}
if !study_stage_ids.contains(&row.stage_id) {
ctx.add_error(
ErrorKind::InvalidReference,
"constraints/ncs_bounds.parquet",
Some(format!("NcsBoundsRow[{i}]")),
format!(
"NcsBoundsRow[{i}] has invalid stage_id {} (not a valid study stage)",
row.stage_id
),
);
}
if row.available_generation_mw < 0.0 {
ctx.add_error(
ErrorKind::InvalidValue,
"constraints/ncs_bounds.parquet",
Some(format!("NcsBoundsRow[{i}]")),
format!(
"NcsBoundsRow[{i}] has negative available_generation_mw: {}",
row.available_generation_mw
),
);
}
}
for (i, entry) in data.non_controllable_factors.iter().enumerate() {
if !ncs_ids.contains(&entry.ncs_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"scenarios/non_controllable_factors.json",
Some(format!("NcsFactorEntry[{i}]")),
format!(
"NcsFactorEntry[{i}] references non-existent NonControllableSource {} via field 'ncs_id'",
entry.ncs_id.0
),
);
}
if !study_stage_ids.contains(&entry.stage_id) {
ctx.add_error(
ErrorKind::InvalidReference,
"scenarios/non_controllable_factors.json",
Some(format!("NcsFactorEntry[{i}]")),
format!(
"NcsFactorEntry[{i}] has invalid stage_id {} (not a valid study stage)",
entry.stage_id
),
);
}
for (j, bf) in entry.block_factors.iter().enumerate() {
if bf.factor < 0.0 {
ctx.add_error(
ErrorKind::InvalidValue,
"scenarios/non_controllable_factors.json",
Some(format!("NcsFactorEntry[{i}].block_factors[{j}]")),
format!(
"NcsFactorEntry[{i}] block_factors[{j}] has negative factor: {}",
bf.factor
),
);
}
}
}
}
struct EntityIdSets<'a> {
hydro: &'a HashSet<i32>,
thermal: &'a HashSet<i32>,
line: &'a HashSet<i32>,
bus: &'a HashSet<i32>,
pumping: &'a HashSet<i32>,
contract: &'a HashSet<i32>,
ncs: &'a HashSet<i32>,
}
fn validate_variable_ref_entity(
var: &cobre_core::VariableRef,
label: &str,
ids: &EntityIdSets<'_>,
ctx: &mut ValidationContext,
) {
use cobre_core::VariableRef;
let file = "system/generic_constraints.json";
match var {
VariableRef::HydroStorage { hydro_id, .. }
| VariableRef::HydroEvaporation { hydro_id, .. }
| VariableRef::HydroWithdrawal { hydro_id, .. }
| VariableRef::HydroTurbined { hydro_id, .. }
| VariableRef::HydroSpillage { hydro_id, .. }
| VariableRef::HydroDiversion { hydro_id, .. }
| VariableRef::HydroOutflow { hydro_id, .. }
| VariableRef::HydroGeneration { hydro_id, .. }
| VariableRef::HydroInflow { hydro_id, .. }
| VariableRef::HydroStorageInitial { hydro_id, .. }
| VariableRef::HydroStorageFinal { hydro_id, .. } => {
if !ids.hydro.contains(&hydro_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
file,
Some(label.to_string()),
format!("{label} references non-existent Hydro {}", hydro_id.0),
);
}
}
VariableRef::ThermalGeneration { thermal_id, .. }
| VariableRef::AnticipatedDecision { thermal_id, .. } => {
if !ids.thermal.contains(&thermal_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
file,
Some(label.to_string()),
format!("{label} references non-existent Thermal {}", thermal_id.0),
);
}
}
VariableRef::LineDirect { line_id, .. }
| VariableRef::LineReverse { line_id, .. }
| VariableRef::LineExchange { line_id, .. } => {
if !ids.line.contains(&line_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
file,
Some(label.to_string()),
format!("{label} references non-existent Line {}", line_id.0),
);
}
}
VariableRef::BusDeficit { bus_id, .. } | VariableRef::BusExcess { bus_id, .. } => {
if !ids.bus.contains(&bus_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
file,
Some(label.to_string()),
format!("{label} references non-existent Bus {}", bus_id.0),
);
}
}
VariableRef::PumpingFlow { station_id, .. }
| VariableRef::PumpingPower { station_id, .. } => {
if !ids.pumping.contains(&station_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
file,
Some(label.to_string()),
format!(
"{label} references non-existent PumpingStation {}",
station_id.0
),
);
}
}
VariableRef::ContractImport { contract_id, .. }
| VariableRef::ContractExport { contract_id, .. } => {
if !ids.contract.contains(&contract_id.0) {
ctx.add_warning(
ErrorKind::UnusedEntity,
file,
Some(label.to_string()),
format!(
"{label} references Contract {} which is a stub entity with no LP effect",
contract_id.0
),
);
}
}
VariableRef::NonControllableGeneration { source_id, .. }
| VariableRef::NonControllableCurtailment { source_id, .. } => {
if !ids.ncs.contains(&source_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
file,
Some(label.to_string()),
format!(
"{label} references non-existent NonControllableSource {}",
source_id.0
),
);
}
}
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::too_many_lines,
clippy::doc_markdown
)]
mod tests {
use super::*;
use chrono::NaiveDate;
use cobre_core::{
EntityId,
entities::{
DiversionChannel, Hydro, HydroGenerationModel, HydroPenalties, Line,
NonControllableSource, PumpingStation, Thermal,
},
scenario::{CorrelationEntity, CorrelationGroup, CorrelationModel, CorrelationProfile},
};
use std::collections::BTreeMap;
use std::fs;
use tempfile::TempDir;
use crate::{
constraints::{
BusPenaltyOverrideRow, GenericConstraintBoundsRow, HydroBoundsRow, LineBoundsRow,
NcsBoundsRow, NcsPenaltyOverrideRow, ThermalBoundsRow,
},
extensions::HydroGeometryRow,
scenarios::{
BlockFactor, InflowSeasonalStatsRow, LoadFactorEntry, LoadSeasonalStatsRow,
NcsFactorEntry,
},
validation::{
schema::{ParsedData, validate_schema},
structural::validate_structure,
},
};
const VALID_CONFIG_JSON: &str = r#"{
"training": {
"forward_passes": 10,
"stopping_rules": [
{ "type": "iteration_limit", "limit": 100 }
]
}
}"#;
const VALID_PENALTIES_JSON: &str = r#"{
"bus": {
"deficit_segments": [
{ "depth_mw": 500.0, "cost": 1000.0 },
{ "depth_mw": null, "cost": 5000.0 }
],
"excess_cost": 100.0
},
"line": { "exchange_cost": 2.0 },
"hydro": {
"spillage_cost": 0.01,
"turbined_cost": 0.05,
"diversion_cost": 0.1,
"storage_violation_below_cost": 10000.0,
"filling_target_violation_cost": 50000.0,
"turbined_violation_below_cost": 500.0,
"outflow_violation_below_cost": 500.0,
"outflow_violation_above_cost": 500.0,
"generation_violation_below_cost": 1000.0,
"evaporation_violation_cost": 5000.0,
"water_withdrawal_violation_cost": 1000.0
},
"non_controllable_source": { "curtailment_cost": 0.005 }
}"#;
const VALID_STAGES_JSON: &str = r#"{
"policy_graph": {
"type": "finite_horizon",
"annual_discount_rate": 0.06,
"transitions": []
},
"stages": [
{
"id": 0,
"start_date": "2024-01-01",
"end_date": "2024-02-01",
"blocks": [{ "id": 0, "name": "FLAT", "hours": 744.0 }],
"num_scenarios": 50
}
]
}"#;
const VALID_INITIAL_CONDITIONS_JSON: &str = r#"{
"storage": [],
"filling_storage": []
}"#;
fn write_file(root: &std::path::Path, relative: &str, content: &str) {
let full = root.join(relative);
if let Some(parent) = full.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&full, content).unwrap();
}
fn make_minimal_case(dir: &TempDir) {
let root = dir.path();
write_file(root, "config.json", VALID_CONFIG_JSON);
write_file(root, "penalties.json", VALID_PENALTIES_JSON);
write_file(root, "stages.json", VALID_STAGES_JSON);
write_file(
root,
"initial_conditions.json",
VALID_INITIAL_CONDITIONS_JSON,
);
write_file(
root,
"system/buses.json",
r#"{ "buses": [{ "id": 1, "name": "BUS_1", "operational_start_date": "2024-01-01" }] }"#,
);
write_file(root, "system/lines.json", r#"{ "lines": [] }"#);
write_file(root, "system/hydros.json", r#"{ "hydros": [] }"#);
write_file(root, "system/thermals.json", r#"{ "thermals": [] }"#);
}
fn parse_case(dir: &TempDir) -> ParsedData {
let mut ctx = ValidationContext::new();
let manifest = validate_structure(dir.path(), &mut ctx);
assert!(
!ctx.has_errors(),
"structural validation failed: {:?}",
ctx.errors()
);
let data = validate_schema(dir.path(), &manifest, &mut ctx)
.expect("schema validation should succeed for valid case");
assert!(
!ctx.has_errors(),
"schema validation failed: {:?}",
ctx.errors()
);
data
}
fn hydro_penalties() -> 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 make_hydro(id: i32, bus_id: i32) -> Hydro {
Hydro {
id: EntityId::from(id),
name: format!("Hydro_{id}"),
operational_start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
bus_id: EntityId::from(bus_id),
downstream_id: None,
travel_time_hours: None,
entry_stage_id: None,
exit_stage_id: None,
min_storage_hm3: 0.0,
max_storage_hm3: 1000.0,
min_outflow_m3s: 0.0,
max_outflow_m3s: None,
generation_model: HydroGenerationModel::ConstantProductivity,
min_turbined_m3s: 0.0,
max_turbined_m3s: 1000.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: hydro_penalties(),
}
}
fn make_line(id: i32, source_bus: i32, target_bus: i32) -> Line {
Line {
id: EntityId::from(id),
name: format!("Line_{id}"),
operational_start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
source_bus_id: EntityId::from(source_bus),
target_bus_id: EntityId::from(target_bus),
entry_stage_id: None,
exit_stage_id: None,
direct_capacity_mw: 100.0,
reverse_capacity_mw: 100.0,
losses_percent: 0.0,
exchange_cost: 0.0,
}
}
fn make_ncs(id: i32, bus_id: i32) -> NonControllableSource {
NonControllableSource {
id: EntityId::from(id),
name: format!("Ncs_{id}"),
operational_start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
bus_id: EntityId::from(bus_id),
entry_stage_id: None,
exit_stage_id: None,
max_generation_mw: 50.0,
allow_curtailment: true,
curtailment_cost: 1.0,
}
}
fn make_pumping(id: i32, bus_id: i32, src_hydro: i32, dst_hydro: i32) -> PumpingStation {
PumpingStation {
id: EntityId::from(id),
name: format!("Pump_{id}"),
operational_start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
bus_id: EntityId::from(bus_id),
source_hydro_id: EntityId::from(src_hydro),
destination_hydro_id: EntityId::from(dst_hydro),
entry_stage_id: None,
exit_stage_id: None,
consumption_mw_per_m3s: 0.5,
min_flow_m3s: 0.0,
max_flow_m3s: 100.0,
}
}
#[test]
fn test_all_valid_references_no_errors() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let data = parse_case(&dir);
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"expected no errors for valid data, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_line_invalid_source_bus() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.lines = vec![make_line(5, 999, 1)];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(ctx.has_errors(), "expected errors for invalid line ref");
let errors = ctx.errors();
let inv_ref: Vec<_> = errors
.iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.collect();
assert_eq!(
inv_ref.len(),
1,
"expected exactly 1 InvalidReference error"
);
let msg = &inv_ref[0].message;
assert!(
msg.contains("Line 5"),
"message should contain 'Line 5', got: {msg}"
);
assert!(
msg.contains("999"),
"message should contain '999', got: {msg}"
);
}
#[test]
fn test_hydro_invalid_downstream_id() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
let mut hydro = make_hydro(3, 1);
hydro.downstream_id = Some(EntityId::from(100)); data.hydros = vec![hydro];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(
ctx.has_errors(),
"expected error for dangling downstream_id"
);
let errors = ctx.errors();
let inv_ref: Vec<_> = errors
.iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.collect();
assert!(
!inv_ref.is_empty(),
"expected at least 1 InvalidReference error"
);
let msg = &inv_ref[0].message;
assert!(
msg.contains("Hydro 3"),
"message should contain 'Hydro 3', got: {msg}"
);
assert!(
msg.contains("downstream_id"),
"message should contain 'downstream_id', got: {msg}"
);
}
#[test]
fn test_empty_optional_collections_no_errors() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.pumping_stations = vec![];
data.energy_contracts = vec![];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"empty optional collections should not produce errors, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_multiple_invalid_references_all_collected() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.lines = vec![make_line(5, 1, 999)];
data.thermals = vec![Thermal {
id: EntityId::from(20),
name: "T20".to_string(),
operational_start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
bus_id: EntityId::from(777), entry_stage_id: None,
exit_stage_id: None,
cost_per_mwh: 50.0,
min_generation_mw: 0.0,
max_generation_mw: 100.0,
anticipated_config: None,
}];
data.hydro_geometry = vec![HydroGeometryRow {
hydro_id: EntityId::from(888),
volume_hm3: 0.0,
area_km2: 0.0,
height_m: 0.0,
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(
ctx.has_errors(),
"expected errors for multiple invalid refs"
);
let errors = ctx.errors();
let inv_ref: Vec<_> = errors
.iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.collect();
assert_eq!(
inv_ref.len(),
3,
"expected exactly 3 InvalidReference errors, got {}: {:?}",
inv_ref.len(),
inv_ref.iter().map(|e| &e.message).collect::<Vec<_>>()
);
}
#[test]
fn test_hydro_valid_bus_ref() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.hydros = vec![make_hydro(10, 1)]; let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(!ctx.has_errors());
}
#[test]
fn test_hydro_invalid_bus_ref() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.hydros = vec![make_hydro(10, 42)]; let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(ctx.has_errors());
let errors = ctx.errors();
let inv_ref: Vec<_> = errors
.iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.collect();
assert_eq!(inv_ref.len(), 1);
assert!(inv_ref[0].message.contains("Hydro 10"));
assert!(inv_ref[0].message.contains("42"));
assert!(inv_ref[0].message.contains("bus_id"));
}
#[test]
fn test_hydro_downstream_id_none_no_error() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
let mut hydro = make_hydro(10, 1);
hydro.downstream_id = None;
data.hydros = vec![hydro];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"downstream_id = None should not produce errors"
);
}
#[test]
fn test_hydro_diversion_none_no_error() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
let mut hydro = make_hydro(10, 1);
hydro.diversion = None;
data.hydros = vec![hydro];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"diversion = None should not produce errors"
);
}
#[test]
fn test_hydro_diversion_invalid_downstream() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
let mut hydro = make_hydro(10, 1);
hydro.diversion = Some(DiversionChannel {
downstream_id: EntityId::from(999), max_flow_m3s: 100.0,
});
data.hydros = vec![hydro];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(ctx.has_errors());
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.collect();
assert_eq!(inv.len(), 1);
assert!(inv[0].message.contains("diversion.downstream_id"));
assert!(inv[0].message.contains("999"));
}
#[test]
fn test_pumping_valid_refs() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.hydros = vec![make_hydro(10, 1)];
data.pumping_stations = vec![make_pumping(1, 1, 10, 10)]; let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(!ctx.has_errors());
}
#[test]
fn test_pumping_invalid_source_hydro() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.hydros = vec![make_hydro(10, 1)];
data.pumping_stations = vec![make_pumping(1, 1, 999, 10)];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(ctx.has_errors());
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.collect();
assert_eq!(inv.len(), 1);
assert!(inv[0].message.contains("source_hydro_id"));
assert!(inv[0].message.contains("999"));
}
#[test]
fn test_pumping_invalid_bus() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.hydros = vec![make_hydro(10, 1)];
data.pumping_stations = vec![make_pumping(1, 777, 10, 10)];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(ctx.has_errors());
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.collect();
assert_eq!(inv.len(), 1);
assert!(inv[0].message.contains("bus_id"));
assert!(inv[0].message.contains("777"));
}
#[test]
fn test_pumping_invalid_destination_hydro() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.hydros = vec![make_hydro(10, 1)];
data.pumping_stations = vec![make_pumping(1, 1, 10, 999)];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(ctx.has_errors());
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.collect();
assert_eq!(inv.len(), 1);
assert!(inv[0].message.contains("destination_hydro_id"));
assert!(inv[0].message.contains("999"));
}
#[test]
fn test_inflow_seasonal_stats_invalid_hydro_ref() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.inflow_seasonal_stats = vec![InflowSeasonalStatsRow {
hydro_id: EntityId::from(999),
stage_id: 0,
mean_m3s: 100.0,
std_m3s: 10.0,
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(ctx.has_errors());
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.collect();
assert_eq!(inv.len(), 1);
assert!(inv[0].message.contains("999"));
assert!(inv[0].message.contains("hydro_id"));
}
#[test]
fn test_load_seasonal_stats_invalid_bus_ref() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.load_seasonal_stats = vec![LoadSeasonalStatsRow {
bus_id: EntityId::from(777),
stage_id: 0,
mean_mw: 100.0,
std_mw: 10.0,
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(ctx.has_errors());
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.collect();
assert_eq!(inv.len(), 1);
assert!(inv[0].message.contains("777"));
assert!(inv[0].message.contains("bus_id"));
}
#[test]
fn test_correlation_entity_inflow_invalid_hydro() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
let mut profiles = BTreeMap::new();
profiles.insert(
"profile1".to_string(),
CorrelationProfile {
groups: vec![CorrelationGroup {
name: "group1".to_string(),
entities: vec![
CorrelationEntity {
entity_type: "inflow".to_string(),
id: EntityId::from(999), },
CorrelationEntity {
entity_type: "unknown".to_string(), id: EntityId::from(9999),
},
],
matrix: vec![],
}],
},
);
data.correlation = Some(CorrelationModel {
method: "pearson".to_string(),
profiles,
schedule: vec![],
});
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(ctx.has_errors());
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.collect();
assert_eq!(
inv.len(),
2,
"expected errors for invalid hydro and unknown entity_type"
);
assert!(inv.iter().any(|e| e.message.contains("999")));
assert!(
inv.iter()
.any(|e| e.message.contains("unknown entity_type"))
);
}
#[test]
fn test_correlation_entity_inflow_valid_hydro() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.hydros = vec![make_hydro(10, 1)];
let mut profiles = BTreeMap::new();
profiles.insert(
"profile1".to_string(),
CorrelationProfile {
groups: vec![CorrelationGroup {
name: "group1".to_string(),
entities: vec![CorrelationEntity {
entity_type: "inflow".to_string(),
id: EntityId::from(10), }],
matrix: vec![],
}],
},
);
data.correlation = Some(CorrelationModel {
method: "pearson".to_string(),
profiles,
schedule: vec![],
});
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"valid inflow ref should not produce errors"
);
}
#[test]
fn test_thermal_bounds_invalid_thermal_ref() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.thermal_bounds = vec![ThermalBoundsRow {
thermal_id: EntityId::from(999),
stage_id: 0,
min_generation_mw: None,
max_generation_mw: None,
cost_per_mwh: None,
block_id: None,
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(ctx.has_errors());
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.collect();
assert_eq!(inv.len(), 1);
assert!(inv[0].message.contains("999"));
assert!(inv[0].message.contains("thermal_id"));
}
#[test]
fn test_hydro_bounds_invalid_hydro_ref() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.hydro_bounds = vec![HydroBoundsRow {
hydro_id: EntityId::from(555),
stage_id: 0,
min_turbined_m3s: None,
max_turbined_m3s: None,
min_storage_hm3: None,
max_storage_hm3: None,
min_outflow_m3s: None,
max_outflow_m3s: None,
min_generation_mw: None,
max_generation_mw: None,
max_diversion_m3s: None,
filling_min_rate_m3s: None,
water_withdrawal_m3s: None,
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(ctx.has_errors());
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.collect();
assert_eq!(inv.len(), 1);
assert!(inv[0].message.contains("555"));
assert!(inv[0].message.contains("hydro_id"));
}
#[test]
fn test_line_bounds_invalid_line_ref() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.line_bounds = vec![LineBoundsRow {
line_id: EntityId::from(333),
stage_id: 0,
direct_mw: None,
reverse_mw: None,
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(ctx.has_errors());
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.collect();
assert_eq!(inv.len(), 1);
assert!(inv[0].message.contains("333"));
assert!(inv[0].message.contains("line_id"));
}
#[test]
fn test_generic_constraint_bounds_invalid_constraint_ref() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.generic_constraint_bounds = vec![GenericConstraintBoundsRow {
constraint_id: 888,
stage_id: 0,
block_id: None,
bound: 1000.0,
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(ctx.has_errors());
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.collect();
assert_eq!(inv.len(), 1);
assert!(inv[0].message.contains("888"));
assert!(inv[0].message.contains("constraint_id"));
}
#[test]
fn test_bus_penalty_override_invalid_bus_ref() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.penalty_overrides_bus = vec![BusPenaltyOverrideRow {
bus_id: EntityId::from(777),
stage_id: 0,
excess_cost: None,
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(ctx.has_errors());
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.collect();
assert_eq!(inv.len(), 1);
assert!(inv[0].message.contains("777"));
assert!(inv[0].message.contains("bus_id"));
}
#[test]
fn test_ncs_penalty_override_invalid_ncs_ref() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.penalty_overrides_ncs = vec![NcsPenaltyOverrideRow {
source_id: EntityId::from(444),
stage_id: 0,
curtailment_cost: None,
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(ctx.has_errors());
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.collect();
assert_eq!(inv.len(), 1);
assert!(inv[0].message.contains("444"));
assert!(inv[0].message.contains("source_id"));
}
#[test]
fn test_ncs_penalty_override_valid_ncs_ref() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.non_controllable_sources = vec![make_ncs(1, 1)];
data.penalty_overrides_ncs = vec![NcsPenaltyOverrideRow {
source_id: EntityId::from(1),
stage_id: 0,
curtailment_cost: None,
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(!ctx.has_errors(), "valid NCS ref should not produce errors");
}
#[test]
fn test_load_factors_invalid_bus_ref() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.load_factors = vec![LoadFactorEntry {
bus_id: EntityId::from(999),
stage_id: 0,
block_factors: vec![BlockFactor {
block_id: 0,
factor: 1.0,
}],
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(ctx.has_errors());
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.collect();
assert_eq!(inv.len(), 1);
assert!(inv[0].message.contains("999"));
assert!(inv[0].message.contains("bus_id"));
assert!(
inv[0]
.entity
.as_deref()
.unwrap_or("")
.contains("LoadFactorEntry")
);
}
#[test]
fn test_load_factors_invalid_stage_ref() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.load_factors = vec![LoadFactorEntry {
bus_id: EntityId::from(1),
stage_id: 999,
block_factors: vec![BlockFactor {
block_id: 0,
factor: 1.0,
}],
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(ctx.has_errors());
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.collect();
assert_eq!(inv.len(), 1);
assert!(inv[0].message.contains("999"));
assert!(inv[0].message.contains("stage_id"));
assert!(
inv[0]
.entity
.as_deref()
.unwrap_or("")
.contains("LoadFactorEntry")
);
}
#[test]
fn test_load_factors_valid_refs_no_error() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.load_factors = vec![LoadFactorEntry {
bus_id: EntityId::from(1),
stage_id: 0,
block_factors: vec![BlockFactor {
block_id: 0,
factor: 1.0,
}],
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"valid load_factors refs should produce no errors"
);
}
#[test]
fn test_ncs_bounds_valid_refs_no_error() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.non_controllable_sources = vec![make_ncs(1, 1)];
data.ncs_bounds = vec![NcsBoundsRow {
ncs_id: EntityId::from(1),
stage_id: 0,
available_generation_mw: 50.0,
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"valid NCS bounds should produce no errors"
);
}
#[test]
fn test_ncs_bounds_invalid_ncs_ref() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.ncs_bounds = vec![NcsBoundsRow {
ncs_id: EntityId::from(999),
stage_id: 0,
available_generation_mw: 50.0,
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(ctx.has_errors());
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.filter(|e| e.file.to_str().unwrap_or("").contains("ncs_bounds"))
.collect();
assert_eq!(inv.len(), 1);
assert!(inv[0].message.contains("999"));
}
#[test]
fn test_ncs_bounds_negative_available_generation() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.non_controllable_sources = vec![make_ncs(1, 1)];
data.ncs_bounds = vec![NcsBoundsRow {
ncs_id: EntityId::from(1),
stage_id: 0,
available_generation_mw: -10.0,
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(ctx.has_errors());
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidValue)
.collect();
assert_eq!(inv.len(), 1);
assert!(inv[0].message.contains("negative"));
}
#[test]
fn test_ncs_factors_valid_refs_no_error() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.non_controllable_sources = vec![make_ncs(1, 1)];
data.non_controllable_factors = vec![NcsFactorEntry {
ncs_id: EntityId::from(1),
stage_id: 0,
block_factors: vec![BlockFactor {
block_id: 0,
factor: 1.0,
}],
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"valid NCS factors should produce no errors"
);
}
#[test]
fn test_ncs_factors_invalid_ncs_ref() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.non_controllable_factors = vec![NcsFactorEntry {
ncs_id: EntityId::from(999),
stage_id: 0,
block_factors: vec![BlockFactor {
block_id: 0,
factor: 1.0,
}],
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(ctx.has_errors());
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.filter(|e| {
e.file
.to_str()
.unwrap_or("")
.contains("non_controllable_factors")
})
.collect();
assert_eq!(inv.len(), 1);
assert!(inv[0].message.contains("999"));
}
#[test]
fn test_ncs_factors_invalid_stage_ref() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.non_controllable_sources = vec![make_ncs(1, 1)];
data.non_controllable_factors = vec![NcsFactorEntry {
ncs_id: EntityId::from(1),
stage_id: 999,
block_factors: vec![BlockFactor {
block_id: 0,
factor: 1.0,
}],
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(ctx.has_errors());
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.filter(|e| {
e.file
.to_str()
.unwrap_or("")
.contains("non_controllable_factors")
})
.collect();
assert_eq!(inv.len(), 1);
assert!(inv[0].message.contains("999"));
}
#[test]
fn test_ncs_factors_negative_factor() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.non_controllable_sources = vec![make_ncs(1, 1)];
data.non_controllable_factors = vec![NcsFactorEntry {
ncs_id: EntityId::from(1),
stage_id: 0,
block_factors: vec![BlockFactor {
block_id: 0,
factor: -0.5,
}],
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(ctx.has_errors());
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidValue)
.collect();
assert_eq!(inv.len(), 1);
assert!(inv[0].message.contains("negative"));
}
#[test]
fn test_anticipated_decision_unknown_thermal_ref() {
use cobre_core::{
ConstraintExpression, ConstraintSense, GenericConstraint, LinearTerm, SlackConfig,
VariableRef,
};
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
let gc = GenericConstraint {
id: EntityId::from(1),
name: "test_constraint".to_string(),
description: None,
expression: ConstraintExpression {
terms: vec![LinearTerm::literal(
1.0,
VariableRef::AnticipatedDecision {
thermal_id: EntityId::from(99),
},
)],
},
sense: ConstraintSense::LessEqual,
slack: SlackConfig {
enabled: false,
penalty: None,
},
};
data.generic_constraints = vec![gc];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(ctx.has_errors(), "expected referential errors");
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.collect();
assert_eq!(
inv.len(),
1,
"expected exactly 1 InvalidReference, got: {inv:?}"
);
assert!(
inv[0].message.contains("99"),
"error message must name Thermal 99, got: {}",
inv[0].message
);
assert!(
inv[0].message.contains("Thermal"),
"error message must include 'Thermal', got: {}",
inv[0].message
);
}
#[test]
fn test_hydro_inflow_unknown_hydro_ref() {
use cobre_core::{
ConstraintExpression, ConstraintSense, GenericConstraint, LinearTerm, SlackConfig,
VariableRef,
};
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
let gc = GenericConstraint {
id: EntityId::from(1),
name: "test_constraint".to_string(),
description: None,
expression: ConstraintExpression {
terms: vec![LinearTerm::literal(
1.0,
VariableRef::HydroInflow {
hydro_id: EntityId::from(99),
block_id: None,
},
)],
},
sense: ConstraintSense::LessEqual,
slack: SlackConfig {
enabled: false,
penalty: None,
},
};
data.generic_constraints = vec![gc];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(ctx.has_errors(), "expected referential errors");
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.collect();
assert_eq!(
inv.len(),
1,
"expected exactly 1 InvalidReference, got: {inv:?}"
);
assert!(
inv[0].message.contains("non-existent Hydro 99"),
"error message must name non-existent Hydro 99, got: {}",
inv[0].message
);
}
#[test]
fn test_hydro_inflow_with_block_unknown_hydro_ref() {
use cobre_core::{
ConstraintExpression, ConstraintSense, GenericConstraint, LinearTerm, SlackConfig,
VariableRef,
};
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
let gc = GenericConstraint {
id: EntityId::from(1),
name: "test_constraint".to_string(),
description: None,
expression: ConstraintExpression {
terms: vec![LinearTerm::literal(
1.0,
VariableRef::HydroInflow {
hydro_id: EntityId::from(99),
block_id: Some(0),
},
)],
},
sense: ConstraintSense::LessEqual,
slack: SlackConfig {
enabled: false,
penalty: None,
},
};
data.generic_constraints = vec![gc];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(ctx.has_errors(), "expected referential errors");
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.collect();
assert_eq!(
inv.len(),
1,
"expected exactly 1 InvalidReference, got: {inv:?}"
);
assert!(
inv[0].message.contains("non-existent Hydro 99"),
"error message must name non-existent Hydro 99, got: {}",
inv[0].message
);
}
#[test]
fn test_hydro_storage_initial_unknown_hydro_ref() {
use cobre_core::{
ConstraintExpression, ConstraintSense, GenericConstraint, LinearTerm, SlackConfig,
VariableRef,
};
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
let gc = GenericConstraint {
id: EntityId::from(1),
name: "test_constraint".to_string(),
description: None,
expression: ConstraintExpression {
terms: vec![LinearTerm::literal(
1.0,
VariableRef::HydroStorageInitial {
hydro_id: EntityId::from(99),
block_id: Some(0),
},
)],
},
sense: ConstraintSense::LessEqual,
slack: SlackConfig {
enabled: false,
penalty: None,
},
};
data.generic_constraints = vec![gc];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(ctx.has_errors(), "expected referential errors");
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.collect();
assert_eq!(
inv.len(),
1,
"expected exactly 1 InvalidReference, got: {inv:?}"
);
assert!(
inv[0].message.contains("non-existent Hydro 99"),
"error message must name non-existent Hydro 99, got: {}",
inv[0].message
);
}
}