use std::collections::{HashMap, HashSet};
use cobre_core::AffineBound;
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(),
hydro_unit_group: data
.hydros
.iter()
.map(|h| (h.id.0, h.unit_groups.iter().map(|g| g.id.0).collect()))
.collect(),
hydro_group_bus: data
.hydros
.iter()
.map(|h| (h.id.0, h.unit_groups.iter().map(|g| g.bus_id.0).collect()))
.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>,
hydro_unit_group: HashMap<i32, HashSet<i32>>,
hydro_group_bus: HashMap<i32, 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 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
),
);
}
for group in &hydro.unit_groups {
if !bus_ids.contains(&group.bus_id.0) {
let group_str = format!("{entity_str} unit group {}", group.id.0);
ctx.add_error(
ErrorKind::InvalidReference,
"system/hydros.json",
Some(&group_str),
format!(
"{group_str} references non-existent Bus {} via field 'bus_id'",
group.bus_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.hydro_unit_group_bounds.iter().enumerate() {
if !ids.hydro.contains(&row.hydro_id.0) {
ctx.add_error(
ErrorKind::InvalidReference,
"constraints/hydro_unit_group_bounds.parquet",
Some(format!("HydroUnitGroupBoundsRow[{i}]")),
format!(
"HydroUnitGroupBoundsRow[{i}] references non-existent Hydro {} via field 'hydro_id'",
row.hydro_id.0
),
);
} else if !ids
.hydro_unit_group
.get(&row.hydro_id.0)
.is_some_and(|groups| groups.contains(&row.hydro_unit_group_id.0))
{
ctx.add_error(
ErrorKind::InvalidReference,
"constraints/hydro_unit_group_bounds.parquet",
Some(format!("HydroUnitGroupBoundsRow[{i}]")),
format!(
"HydroUnitGroupBoundsRow[{i}] references non-existent unit group {} of Hydro {} via field 'hydro_unit_group_id'; unit group ids are unique within a plant, not globally",
row.hydro_unit_group_id.0, 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 collect_study_stage_ids(data: &ParsedData) -> HashSet<i32> {
data.stages
.stages
.iter()
.filter(|s| s.id >= 0)
.map(|s| s.id)
.collect()
}
fn check_load_factor_references(
data: &ParsedData,
ctx: &mut ValidationContext,
bus_ids: &HashSet<i32>,
) {
let study_stage_ids = collect_study_stage_ids(data);
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,
) {
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, ids, ctx);
}
}
}
fn static_fold(literal: Option<f64>, affine: Option<&AffineBound>) -> Option<f64> {
match affine {
None => literal,
Some(bound) if bound.terms.is_empty() => Some(literal.unwrap_or(0.0) + bound.constant),
Some(_) => None,
}
}
fn check_generic_constraint_bounds_validity(data: &ParsedData, ctx: &mut ValidationContext) {
let stage_block_counts: 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 constraint_affines: HashMap<i32, (Option<&AffineBound>, Option<&AffineBound>)> = data
.generic_constraints
.iter()
.map(|gc| {
(
gc.id.0,
(
gc.bound_lower_affine.as_ref(),
gc.bound_upper_affine.as_ref(),
),
)
})
.collect();
for (i, row) in data.generic_constraint_bounds.iter().enumerate() {
let (lower_affine, upper_affine) = constraint_affines
.get(&row.constraint_id)
.copied()
.unwrap_or((None, None));
if row.bound_lower.is_none()
&& row.bound_upper.is_none()
&& lower_affine.is_none()
&& upper_affine.is_none()
{
ctx.add_error(
ErrorKind::InvalidValue,
"constraints/generic_constraint_bounds.parquet",
Some(format!("GenericConstraintBoundsRow[{i}]")),
format!(
"GenericConstraintBoundsRow[{i}] on constraint {} has neither bound_lower nor bound_upper: at least one endpoint is required",
row.constraint_id
),
);
}
let static_lower = static_fold(row.bound_lower, lower_affine);
let static_upper = static_fold(row.bound_upper, upper_affine);
if let (Some(bound_lower), Some(bound_upper)) = (static_lower, static_upper)
&& bound_upper < bound_lower
{
ctx.add_error(
ErrorKind::InvalidValue,
"constraints/generic_constraint_bounds.parquet",
Some(format!("GenericConstraintBoundsRow[{i}]")),
format!(
"GenericConstraintBoundsRow[{i}] on constraint {} has bound_upper={bound_upper} less than bound_lower={bound_lower}: an inverted interval is not allowed",
row.constraint_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
),
);
}
}
let constraints_with_rows: HashSet<i32> = data
.generic_constraint_bounds
.iter()
.map(|row| row.constraint_id)
.collect();
for gc in &data.generic_constraints {
if (gc.bound_lower_affine.is_some() || gc.bound_upper_affine.is_some())
&& !constraints_with_rows.contains(&gc.id.0)
{
ctx.add_error(
ErrorKind::InvalidReference,
"constraints/generic_constraints.json",
Some(format!("GenericConstraint {}", gc.id.0)),
format!(
"GenericConstraint {} declares a bound reference but has no activation rows in generic_constraint_bounds.parquet: the reference would apply to nothing",
gc.id.0
),
);
}
}
}
fn check_ncs_bounds_and_factors(
data: &ParsedData,
ctx: &mut ValidationContext,
ncs_ids: &HashSet<i32>,
) {
let study_stage_ids = collect_study_stage_ids(data);
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
),
);
}
}
}
}
fn validate_variable_ref_entity(
var: &cobre_core::VariableRef,
label: &str,
ids: &LookupSets,
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::HydroSpillage { hydro_id, .. }
| VariableRef::HydroDiversion { hydro_id, .. }
| VariableRef::HydroOutflow { 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::HydroTurbined {
hydro_id, bus_id, ..
}
| VariableRef::HydroGeneration {
hydro_id, bus_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),
);
} else if let Some(b) = bus_id
&& !ids
.hydro_group_bus
.get(&hydro_id.0)
.is_some_and(|buses| buses.contains(&b.0))
{
ctx.add_error(
ErrorKind::InvalidReference,
file,
Some(label.to_string()),
format!(
"{label} references bus {}, on which Hydro {} has no unit group, via field 'bus_id'; a bus selector names one side of a split plant, not any bus in the system",
b.0, 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::{
Bus, DiversionChannel, Hydro, HydroGenerationModel, HydroPenalties, HydroUnitGroup,
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,
HydroUnitGroupBoundsRow, 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": {
"selection": {"method": "sampled", "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_openings": 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) -> Hydro {
let mut hydro = Hydro {
unit_groups: Vec::new(),
id: EntityId::from(id),
name: format!("Hydro_{id}"),
operational_start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
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(),
};
hydro.sort_unit_groups();
hydro
}
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,
}
}
fn make_unit_group(
id: i32,
bus_id: i32,
min_generation_mw: f64,
max_generation_mw: f64,
min_turbined_m3s: f64,
max_turbined_m3s: f64,
) -> HydroUnitGroup {
HydroUnitGroup {
id: EntityId::from(id),
name: format!("Group {id}"),
bus_id: EntityId::from(bus_id),
min_generation_mw,
max_generation_mw,
min_turbined_m3s,
max_turbined_m3s,
}
}
#[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);
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)];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(!ctx.has_errors());
}
#[test]
fn test_hydro_invalid_plant_bus_with_valid_group_bus_produces_no_error() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
let mut hydro = make_hydro(10);
hydro.unit_groups = vec![make_unit_group(0, 1, 0.0, 100.0, 0.0, 100.0)]; data.hydros = vec![hydro];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(!ctx.has_errors());
}
#[test]
fn test_hydro_group_bus_equals_invalid_plant_bus_is_rejected() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
let mut hydro = make_hydro(10);
hydro.unit_groups = vec![make_unit_group(0, 999, 0.0, 100.0, 0.0, 100.0)]; data.hydros = vec![hydro];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.collect();
assert_eq!(inv.len(), 1);
assert!(inv[0].message.contains("Hydro 10 unit group 0"));
assert!(!inv[0].message.contains("Hydro 10 references"));
assert!(inv[0].message.contains("Bus 999"));
}
#[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);
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);
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);
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_unit_group_on_nonexistent_bus_is_rejected() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.buses.push(Bus {
id: EntityId::from(0),
name: "BUS_0".to_string(),
operational_start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
deficit_segments: vec![],
excess_cost: 100.0,
});
let mut hydro1 = make_hydro(1);
hydro1.unit_groups = vec![HydroUnitGroup {
id: EntityId::from(4),
name: "Group A".to_string(),
bus_id: EntityId::from(0), min_generation_mw: 0.0,
max_generation_mw: 100.0,
min_turbined_m3s: 0.0,
max_turbined_m3s: 100.0,
}];
let mut hydro2 = make_hydro(2);
hydro2.unit_groups = vec![HydroUnitGroup {
id: EntityId::from(7),
name: "Group B".to_string(),
bus_id: EntityId::from(42), min_generation_mw: 0.0,
max_generation_mw: 100.0,
min_turbined_m3s: 0.0,
max_turbined_m3s: 100.0,
}];
data.hydros = vec![hydro1, hydro2];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
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.iter().map(|e| &e.message).collect::<Vec<_>>()
);
assert!(
inv[0].message.contains("Hydro 2 unit group 7"),
"message should name Hydro 2 unit group 7, got: {}",
inv[0].message
);
assert!(inv[0].message.contains("Bus 42"));
assert!(inv[0].message.contains("bus_id"));
assert!(
!inv.iter().any(|e| e.message.contains("Hydro 1")),
"hydro 1's valid-bus group must produce no finding, got: {:?}",
inv.iter().map(|e| &e.message).collect::<Vec<_>>()
);
}
#[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)];
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)];
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)];
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)];
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)];
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,
..Default::default()
}];
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_hydro_unit_group_bounds_unknown_group_ref() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
let mut hydro7 = make_hydro(7);
hydro7.unit_groups = vec![
make_unit_group(0, 1, 0.0, 100.0, 0.0, 100.0),
make_unit_group(3, 1, 0.0, 100.0, 0.0, 100.0),
];
let mut hydro2 = make_hydro(2);
hydro2.unit_groups = vec![make_unit_group(4, 1, 0.0, 100.0, 0.0, 100.0)];
data.hydros = vec![hydro7, hydro2];
data.hydro_unit_group_bounds = vec![HydroUnitGroupBoundsRow {
hydro_id: EntityId::from(7),
hydro_unit_group_id: EntityId::from(4),
stage_id: 9,
min_turbined_m3s: None,
max_turbined_m3s: None,
min_generation_mw: None,
max_generation_mw: Some(50.0),
block_id: Some(1),
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
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.iter().map(|e| &e.message).collect::<Vec<_>>()
);
assert!(inv[0].file == std::path::Path::new("constraints/hydro_unit_group_bounds.parquet"));
assert!(inv[0].message.contains("unit group 4"));
assert!(inv[0].message.contains("Hydro 7"));
}
#[test]
fn test_hydro_unit_group_bounds_unknown_hydro_ref_emits_one_finding() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
let mut hydro7 = make_hydro(7);
hydro7.unit_groups = vec![
make_unit_group(0, 1, 0.0, 100.0, 0.0, 100.0),
make_unit_group(3, 1, 0.0, 100.0, 0.0, 100.0),
];
data.hydros = vec![hydro7];
data.hydro_unit_group_bounds = vec![HydroUnitGroupBoundsRow {
hydro_id: EntityId::from(99),
hydro_unit_group_id: EntityId::from(4),
stage_id: 9,
min_turbined_m3s: None,
max_turbined_m3s: None,
min_generation_mw: None,
max_generation_mw: Some(50.0),
block_id: Some(1),
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.collect();
assert_eq!(
inv.len(),
1,
"expected exactly 1 InvalidReference (hydro_id only), got: {:?}",
inv.iter().map(|e| &e.message).collect::<Vec<_>>()
);
assert!(inv[0].message.contains("Hydro 99"));
assert!(inv[0].message.contains("hydro_id"));
assert!(!inv[0].message.contains("hydro_unit_group_id"));
}
#[test]
fn test_hydro_unit_group_bounds_valid_refs_no_error() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
let mut hydro5 = make_hydro(5);
hydro5.unit_groups = vec![
make_unit_group(7, 1, 0.0, 100.0, 0.0, 100.0),
make_unit_group(2, 1, 0.0, 100.0, 0.0, 100.0),
];
let mut hydro6 = make_hydro(6);
hydro6.unit_groups = vec![
make_unit_group(10, 1, 0.0, 100.0, 0.0, 100.0),
make_unit_group(20, 1, 0.0, 100.0, 0.0, 100.0),
];
data.hydros = vec![hydro5, hydro6];
data.hydro_unit_group_bounds = vec![
HydroUnitGroupBoundsRow {
hydro_id: EntityId::from(5),
hydro_unit_group_id: EntityId::from(2),
stage_id: 8,
min_turbined_m3s: None,
max_turbined_m3s: None,
min_generation_mw: None,
max_generation_mw: Some(50.0),
block_id: Some(3),
},
HydroUnitGroupBoundsRow {
hydro_id: EntityId::from(6),
hydro_unit_group_id: EntityId::from(10),
stage_id: 4,
min_turbined_m3s: Some(1.0),
max_turbined_m3s: None,
min_generation_mw: None,
max_generation_mw: None,
block_id: None,
},
];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.filter(|e| {
e.file == std::path::Path::new("constraints/hydro_unit_group_bounds.parquet")
})
.collect();
assert!(
inv.is_empty(),
"expected no hydro_unit_group_bounds errors, got: {:?}",
inv.iter().map(|e| &e.message).collect::<Vec<_>>()
);
}
#[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,
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("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_lower: Some(1000.0),
bound_upper: 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("888"));
assert!(inv[0].message.contains("constraint_id"));
}
#[test]
fn test_generic_constraint_bounds_rejects_both_absent() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.generic_constraint_bounds = vec![GenericConstraintBoundsRow {
constraint_id: 1,
stage_id: 0,
block_id: None,
bound_lower: None,
bound_upper: None,
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidValue)
.collect();
assert_eq!(
inv.len(),
1,
"expected exactly 1 InvalidValue, got: {inv:?}"
);
assert!(inv[0].message.contains("constraint 1"));
}
fn symbolic_constraint(
id: i32,
lower_ref: Option<i32>,
upper_ref: Option<i32>,
) -> cobre_core::GenericConstraint {
use cobre_core::{AffineBound, ConstraintExpression, GenericConstraint, SlackConfig};
GenericConstraint {
id: EntityId(id),
name: format!("sym{id}"),
description: None,
expression: ConstraintExpression { terms: vec![] },
slack: SlackConfig {
enabled: false,
penalty: None,
},
bound_lower_affine: lower_ref.map(|id| AffineBound::single(EntityId(id))),
bound_upper_affine: upper_ref.map(|id| AffineBound::single(EntityId(id))),
}
}
#[test]
fn test_generic_constraint_bounds_literal_and_constant_affine_fold_adds_no_error() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
let mut constraint = symbolic_constraint(5, None, None);
constraint.bound_upper_affine = Some(AffineBound {
constant: -5.0,
terms: vec![],
});
data.generic_constraints = vec![constraint];
data.generic_constraint_bounds = vec![GenericConstraintBoundsRow {
constraint_id: 5,
stage_id: 0,
block_id: None,
bound_lower: None,
bound_upper: Some(100.0),
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"a literal base composed with a constant-only remainder is a legal fold, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_generic_constraint_bounds_literal_and_param_affine_fold_adds_no_error() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.generic_constraints = vec![symbolic_constraint(5, None, Some(99))];
data.generic_constraint_bounds = vec![GenericConstraintBoundsRow {
constraint_id: 5,
stage_id: 0,
block_id: None,
bound_lower: None,
bound_upper: Some(100.0),
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"a literal base composed with a @param-bearing remainder is a legal fold, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_generic_constraint_bounds_constant_fold_reveals_inversion() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
let mut constraint = symbolic_constraint(5, None, None);
constraint.bound_upper_affine = Some(AffineBound {
constant: -30.0,
terms: vec![],
});
data.generic_constraints = vec![constraint];
data.generic_constraint_bounds = vec![GenericConstraintBoundsRow {
constraint_id: 5,
stage_id: 0,
block_id: None,
bound_lower: Some(50.0),
bound_upper: Some(60.0),
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidValue)
.collect();
assert_eq!(
inv.len(),
1,
"the folded upper (60 + -30 = 30) is below the lower (50); expected exactly 1 InvalidValue, got: {inv:?}"
);
assert!(inv[0].message.contains("constraint 5"));
assert!(inv[0].message.contains("bound_upper"));
}
#[test]
fn test_generic_constraint_bounds_param_fold_defers_inversion_check() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.generic_constraints = vec![symbolic_constraint(5, None, Some(99))];
data.generic_constraint_bounds = vec![GenericConstraintBoundsRow {
constraint_id: 5,
stage_id: 0,
block_id: None,
bound_lower: Some(50.0),
bound_upper: Some(60.0),
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"a @param-bearing remainder is stage-varying; its inversion is left to LP infeasibility, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_generic_constraint_bounds_ref_fills_side_allows_both_null() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.generic_constraints = vec![symbolic_constraint(5, None, Some(99))];
data.generic_constraint_bounds = vec![GenericConstraintBoundsRow {
constraint_id: 5,
stage_id: 0,
block_id: None,
bound_lower: None,
bound_upper: None,
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"a reference fills the endpoint, so a both-null row is valid, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_generic_constraint_bounds_ref_with_no_rows_is_invalid_reference() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.generic_constraints = vec![symbolic_constraint(5, None, Some(99))];
data.generic_constraint_bounds = vec![];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
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("GenericConstraint 5"));
}
#[test]
fn test_generic_constraint_bounds_rejects_inverted_interval() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.generic_constraint_bounds = vec![GenericConstraintBoundsRow {
constraint_id: 1,
stage_id: 0,
block_id: None,
bound_lower: Some(20.0),
bound_upper: Some(5.0),
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidValue)
.collect();
assert_eq!(
inv.len(),
1,
"expected exactly 1 InvalidValue, got: {inv:?}"
);
assert!(inv[0].message.contains("constraint 1"));
assert!(inv[0].message.contains("bound_upper"));
}
#[test]
fn test_generic_constraint_bounds_accepts_degenerate_equal_band() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.generic_constraint_bounds = vec![GenericConstraintBoundsRow {
constraint_id: 1,
stage_id: 0,
block_id: None,
bound_lower: Some(10.0),
bound_upper: Some(10.0),
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidValue)
.collect();
assert!(
inv.is_empty(),
"a degenerate equal band must not be rejected, got: {inv:?}"
);
}
#[test]
fn test_generic_constraint_bounds_accepts_lower_only_row() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.generic_constraint_bounds = vec![GenericConstraintBoundsRow {
constraint_id: 1,
stage_id: 0,
block_id: None,
bound_lower: Some(5.0),
bound_upper: None,
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidValue)
.collect();
assert!(
inv.is_empty(),
"a lower-only row must not be rejected, got: {inv:?}"
);
}
#[test]
fn test_generic_constraint_bounds_accepts_upper_only_row() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.generic_constraint_bounds = vec![GenericConstraintBoundsRow {
constraint_id: 1,
stage_id: 0,
block_id: None,
bound_lower: None,
bound_upper: Some(20.0),
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidValue)
.collect();
assert!(
inv.is_empty(),
"an upper-only row must not be rejected, got: {inv:?}"
);
}
#[test]
fn test_generic_constraint_bounds_accepts_two_sided_band() {
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = parse_case(&dir);
data.generic_constraint_bounds = vec![GenericConstraintBoundsRow {
constraint_id: 1,
stage_id: 0,
block_id: None,
bound_lower: Some(5.0),
bound_upper: Some(20.0),
}];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidValue)
.collect();
assert!(
inv.is_empty(),
"a well-formed two-sided band must not be rejected, got: {inv:?}"
);
}
#[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, 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),
},
)],
},
slack: SlackConfig {
enabled: false,
penalty: None,
},
bound_lower_affine: None,
bound_upper_affine: 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, 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,
},
)],
},
slack: SlackConfig {
enabled: false,
penalty: None,
},
bound_lower_affine: None,
bound_upper_affine: 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, 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),
},
)],
},
slack: SlackConfig {
enabled: false,
penalty: None,
},
bound_lower_affine: None,
bound_upper_affine: 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, 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),
},
)],
},
slack: SlackConfig {
enabled: false,
penalty: None,
},
bound_lower_affine: None,
bound_upper_affine: 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
);
}
fn make_split_plant_bus_selector_fixture(dir: &TempDir) -> ParsedData {
let mut data = parse_case(dir);
data.buses.push(Bus {
id: EntityId::from(4),
name: "BUS_4".to_string(),
operational_start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
deficit_segments: vec![],
excess_cost: 100.0,
});
data.buses.push(Bus {
id: EntityId::from(9),
name: "BUS_9".to_string(),
operational_start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
deficit_segments: vec![],
excess_cost: 100.0,
});
let mut hydro7 = make_hydro(7);
hydro7.unit_groups = vec![
make_unit_group(20, 1, 0.0, 100.0, 0.0, 100.0),
make_unit_group(21, 4, 0.0, 100.0, 0.0, 100.0),
];
let mut hydro8 = make_hydro(8);
hydro8.unit_groups = vec![make_unit_group(30, 9, 0.0, 100.0, 0.0, 100.0)];
data.hydros = vec![hydro7, hydro8];
data
}
#[test]
fn test_generic_constraint_unknown_bus_selector_rejected() {
use cobre_core::{
ConstraintExpression, GenericConstraint, LinearTerm, SlackConfig, VariableRef,
};
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = make_split_plant_bus_selector_fixture(&dir);
let gc = GenericConstraint {
id: EntityId::from(1),
name: "test_constraint".to_string(),
description: None,
expression: ConstraintExpression {
terms: vec![LinearTerm::literal(
1.0,
VariableRef::HydroTurbined {
hydro_id: EntityId::from(7),
block_id: None,
bus_id: Some(EntityId::from(9)),
},
)],
},
slack: SlackConfig {
enabled: false,
penalty: None,
},
bound_lower_affine: None,
bound_upper_affine: None,
};
data.generic_constraints = vec![gc];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.filter(|e| e.file == std::path::Path::new("system/generic_constraints.json"))
.collect();
assert_eq!(
inv.len(),
1,
"expected exactly 1 InvalidReference, got: {:?}",
inv.iter().map(|e| &e.message).collect::<Vec<_>>()
);
assert!(
inv[0].message.contains("bus 9"),
"message must name bus 9, got: {}",
inv[0].message
);
assert!(
inv[0].message.contains("Hydro 7"),
"message must name Hydro 7, got: {}",
inv[0].message
);
assert!(
inv[0].message.contains("GenericConstraint 1 term[0]"),
"message must name the constraint's term label, got: {}",
inv[0].message
);
}
#[test]
fn test_generic_constraint_valid_bus_selector_and_none_accepted() {
use cobre_core::{
ConstraintExpression, GenericConstraint, LinearTerm, SlackConfig, VariableRef,
};
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = make_split_plant_bus_selector_fixture(&dir);
let gc_turbined = GenericConstraint {
id: EntityId::from(1),
name: "test_constraint_turbined".to_string(),
description: None,
expression: ConstraintExpression {
terms: vec![LinearTerm::literal(
1.0,
VariableRef::HydroTurbined {
hydro_id: EntityId::from(7),
block_id: None,
bus_id: Some(EntityId::from(4)),
},
)],
},
slack: SlackConfig {
enabled: false,
penalty: None,
},
bound_lower_affine: None,
bound_upper_affine: None,
};
let gc_generation = GenericConstraint {
id: EntityId::from(2),
name: "test_constraint_generation".to_string(),
description: None,
expression: ConstraintExpression {
terms: vec![LinearTerm::literal(
1.0,
VariableRef::HydroGeneration {
hydro_id: EntityId::from(7),
block_id: None,
bus_id: None,
},
)],
},
slack: SlackConfig {
enabled: false,
penalty: None,
},
bound_lower_affine: None,
bound_upper_affine: None,
};
data.generic_constraints = vec![gc_turbined, gc_generation];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.filter(|e| e.file == std::path::Path::new("system/generic_constraints.json"))
.collect();
assert!(
inv.is_empty(),
"expected no InvalidReference against generic_constraints.json, got: {:?}",
inv.iter().map(|e| &e.message).collect::<Vec<_>>()
);
}
#[test]
fn test_generic_constraint_unknown_hydro_with_selector_emits_one_finding() {
use cobre_core::{
ConstraintExpression, 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::HydroGeneration {
hydro_id: EntityId::from(99),
block_id: None,
bus_id: Some(EntityId::from(1)),
},
)],
},
slack: SlackConfig {
enabled: false,
penalty: None,
},
bound_lower_affine: None,
bound_upper_affine: None,
};
data.generic_constraints = vec![gc];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.filter(|e| e.file == std::path::Path::new("system/generic_constraints.json"))
.collect();
assert_eq!(
inv.len(),
1,
"expected exactly 1 InvalidReference, got: {:?}",
inv.iter().map(|e| &e.message).collect::<Vec<_>>()
);
assert!(
inv[0].message.contains("Hydro 99"),
"message must name Hydro 99, got: {}",
inv[0].message
);
assert!(
!inv[0].message.contains("bus"),
"message must not carry a bus finding, got: {}",
inv[0].message
);
}
#[test]
fn test_generic_constraint_nonexistent_bus_selector_emits_one_finding() {
use cobre_core::{
ConstraintExpression, GenericConstraint, LinearTerm, SlackConfig, VariableRef,
};
let dir = TempDir::new().unwrap();
make_minimal_case(&dir);
let mut data = make_split_plant_bus_selector_fixture(&dir);
let gc = GenericConstraint {
id: EntityId::from(1),
name: "test_constraint".to_string(),
description: None,
expression: ConstraintExpression {
terms: vec![LinearTerm::literal(
1.0,
VariableRef::HydroTurbined {
hydro_id: EntityId::from(7),
block_id: None,
bus_id: Some(EntityId::from(777)),
},
)],
},
slack: SlackConfig {
enabled: false,
penalty: None,
},
bound_lower_affine: None,
bound_upper_affine: None,
};
data.generic_constraints = vec![gc];
let mut ctx = ValidationContext::new();
validate_referential_integrity(&data, &mut ctx);
let inv: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| e.kind == ErrorKind::InvalidReference)
.filter(|e| e.file == std::path::Path::new("system/generic_constraints.json"))
.collect();
assert_eq!(
inv.len(),
1,
"expected exactly 1 InvalidReference, got: {:?}",
inv.iter().map(|e| &e.message).collect::<Vec<_>>()
);
assert!(
inv[0].message.contains("bus 777"),
"message must name bus 777, got: {}",
inv[0].message
);
assert!(
inv[0].message.contains("Hydro 7"),
"message must name Hydro 7, got: {}",
inv[0].message
);
}
}