use std::collections::{HashMap, HashSet};
use cobre_core::{AnticipatedCommitmentHistory, EntityId, VariableRef};
use super::super::{ErrorKind, ValidationContext, schema::ParsedData};
pub(super) fn check_thermal_generation_bounds(data: &ParsedData, ctx: &mut ValidationContext) {
for thermal in &data.thermals {
if thermal.min_generation_mw > thermal.max_generation_mw {
let entity_str = format!("Thermal {}", thermal.id.0);
ctx.add_error(
ErrorKind::InvalidValue,
"system/thermals.json",
Some(&entity_str),
format!(
"{entity_str}: min_generation_mw ({}) > max_generation_mw ({}); generation bounds are inconsistent",
thermal.min_generation_mw, thermal.max_generation_mw
),
);
}
}
}
pub(super) fn check_anticipated_thermals(data: &ParsedData, ctx: &mut ValidationContext) {
let n_stages = data.stages.stages.iter().filter(|s| s.id >= 0).count();
for thermal in &data.thermals {
let Some(ref cfg) = thermal.anticipated_config else {
continue;
};
let k = cfg.lead_stages;
let thermal_id = thermal.id.0;
let entity_str = format!("thermals[id={thermal_id}].anticipated_config.lead_stages");
if k == 0 {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"system/thermals.json",
Some(&entity_str),
format!("Thermal {thermal_id}: anticipated_config.lead_stages must be >= 1, got 0"),
);
continue;
}
let k_u = k as usize;
if k_u > n_stages {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"system/thermals.json",
Some(&entity_str),
format!(
"Thermal {thermal_id}: lead_stages exceeds study horizon \
(lead_stages={k}, n_stages={n_stages}); \
the plant can never deliver within the study horizon"
),
);
}
if let Some(e) = thermal.entry_stage_id {
let e_i = i64::from(e);
let k_i = i64::from(k);
let n_i = i64::try_from(n_stages).unwrap_or(i64::MAX);
let sum = e_i + k_i;
if sum > n_i {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"system/thermals.json",
Some(&entity_str),
format!(
"Thermal {thermal_id}: entry_stage_id + lead_stages > n_stages \
({e} + {k} = {sum} > {n_stages}); \
the anticipated configuration cannot produce a valid delivery \
within the study horizon"
),
);
}
}
if let Some(x) = thermal.exit_stage_id
&& i64::from(x) < i64::from(k)
{
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"system/thermals.json",
Some(&entity_str),
format!(
"Thermal {thermal_id}: exit_stage_id ({x}) < lead_stages ({k}); \
the plant exits before its earliest possible delivery at stage {k}, \
so the anticipated configuration cannot produce a valid delivery"
),
);
}
}
let ic = &data.initial_conditions;
let mut history_by_id: HashMap<EntityId, &AnticipatedCommitmentHistory> = HashMap::new();
for history in &ic.past_anticipated_commitments {
history_by_id.insert(history.thermal_id, history);
}
let mut anticipated_thermal_ids: std::collections::HashSet<EntityId> =
std::collections::HashSet::new();
for thermal in &data.thermals {
if thermal.anticipated_config.is_some() {
anticipated_thermal_ids.insert(thermal.id);
}
}
for thermal in &data.thermals {
let Some(ref cfg) = thermal.anticipated_config else {
continue;
};
let k = cfg.lead_stages;
let thermal_id = thermal.id;
match history_by_id.get(&thermal_id) {
None => {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"initial_conditions.json",
Some("initial_conditions.past_anticipated_commitments"),
format!(
"Thermal {}: missing entry in initial_conditions.past_anticipated_commitments; \
every anticipated thermal must have a corresponding history entry",
thermal_id.0
),
);
}
Some(history) => {
let expected = k as usize;
let actual = history.values_mw.len();
if actual == expected {
check_committed_value_bounds(thermal, thermal_id, &history.values_mw, ctx);
} else {
let entity_str = format!(
"thermals[id={}].anticipated_config.lead_stages",
thermal_id.0
);
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"initial_conditions.json",
Some(&entity_str),
format!(
"Thermal {}: past_anticipated_commitments.values_mw has wrong length: \
expected {expected} values, got {actual}",
thermal_id.0
),
);
}
}
}
}
for history in &ic.past_anticipated_commitments {
if !anticipated_thermal_ids.contains(&history.thermal_id) {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"initial_conditions.json",
Some(format!(
"initial_conditions.past_anticipated_commitments[thermal_id={}]",
history.thermal_id.0
)),
format!(
"Thermal {}: referenced in past_anticipated_commitments \
but is not an anticipated thermal (anticipated_config is None or thermal does not exist)",
history.thermal_id.0
),
);
}
}
}
fn check_committed_value_bounds(
thermal: &cobre_core::entities::Thermal,
thermal_id: EntityId,
values_mw: &[f64],
ctx: &mut ValidationContext,
) {
let min_mw = thermal.min_generation_mw;
let max_mw = thermal.max_generation_mw;
let entity_str = format!("thermals[id={}].anticipated_config", thermal_id.0);
for (j, &v) in values_mw.iter().enumerate() {
if v < min_mw || v > max_mw {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"initial_conditions.json",
Some(&entity_str),
format!(
"Thermal {}: past_anticipated_commitments.values_mw[{j}] = {v} \
is outside the plant's generation bounds [{min_mw}, {max_mw}]; \
the LP delivery equality at the corresponding stage cannot be \
satisfied and the LP will be infeasible",
thermal_id.0
),
);
}
}
}
pub(super) fn check_anticipated_decision_target_is_anticipated(
data: &ParsedData,
ctx: &mut ValidationContext,
) {
let anticipated_ids: HashSet<EntityId> = data
.thermals
.iter()
.filter(|t| t.anticipated_config.is_some())
.map(|t| t.id)
.collect();
for constraint in &data.generic_constraints {
for term in &constraint.expression.terms {
if let VariableRef::AnticipatedDecision { thermal_id } = term.variable
&& !anticipated_ids.contains(&thermal_id)
{
let entity_str = format!("constraint[id={}]", constraint.id.0);
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"constraints/generic_constraints.json",
Some(&entity_str),
format!(
"Constraint \"{}\": anticipated_decision({}) references Thermal {} \
which is not an anticipated thermal (anticipated_config is None). \
The anticipated_decision column only exists for plants with \
anticipated_config set.",
constraint.name, thermal_id.0, thermal_id.0,
),
);
}
}
}
}
pub(super) fn warn_thermal_generation_on_anticipated_thermal(
data: &ParsedData,
ctx: &mut ValidationContext,
) {
let anticipated_ids: HashSet<EntityId> = data
.thermals
.iter()
.filter(|t| t.anticipated_config.is_some())
.map(|t| t.id)
.collect();
if anticipated_ids.is_empty() {
return;
}
for constraint in &data.generic_constraints {
for term in &constraint.expression.terms {
if let VariableRef::ThermalGeneration { thermal_id, .. } = term.variable
&& anticipated_ids.contains(&thermal_id)
{
let entity_str = format!("constraint[id={}]", constraint.id.0);
ctx.add_warning(
ErrorKind::SemanticAmbiguity,
"constraints/generic_constraints.json",
Some(&entity_str),
format!(
"Constraint \"{}\": thermal_generation({id}) references an \
anticipated thermal. thermal_generation refers to the \
per-block generation at the delivery stage, not the \
forward commitment. If you intend to constrain the \
commitment itself, use anticipated_decision({id}) instead.",
constraint.name,
id = thermal_id.0,
),
);
}
}
}
}
pub(super) fn check_thermal_bounds_override_stage_range(
data: &ParsedData,
ctx: &mut ValidationContext,
) {
let n_stages = data.stages.stages.iter().filter(|s| s.id >= 0).count();
let n_stages_i = i64::try_from(n_stages).unwrap_or(i64::MAX);
for row in &data.thermal_bounds {
let s = i64::from(row.stage_id);
if s < 0 || s >= n_stages_i {
let entity_str = format!("thermal_id={}, stage_id={}", row.thermal_id.0, row.stage_id);
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"constraints/thermal_bounds.parquet",
Some(&entity_str),
format!(
"Thermal {}: thermal_bounds override at stage_id={} is \
outside the study horizon [0, {}); per-stage thermal \
overrides past the horizon are not allowed",
row.thermal_id.0, row.stage_id, n_stages
),
);
}
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::panic,
clippy::too_many_lines,
clippy::doc_markdown,
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_sign_loss
)]
mod tests {
use cobre_core::{AnticipatedCommitmentHistory, EntityId, entities::AnticipatedConfig};
use super::super::test_support::*;
use super::super::validate_semantic_hydro_thermal;
use crate::validation::{ErrorKind, ValidationContext};
fn make_anticipated_thermal(
id: i32,
lead_stages: u32,
entry_stage_id: Option<i32>,
exit_stage_id: Option<i32>,
) -> cobre_core::entities::Thermal {
cobre_core::entities::Thermal {
anticipated_config: Some(AnticipatedConfig { lead_stages }),
entry_stage_id,
exit_stage_id,
..make_thermal(id, 0.0, 500.0)
}
}
fn make_data_anticipated(
thermals: Vec<cobre_core::entities::Thermal>,
n_stages: usize,
past_anticipated_commitments: Vec<AnticipatedCommitmentHistory>,
) -> crate::validation::schema::ParsedData {
let stage_ids: Vec<i32> = (0..n_stages as i32).collect();
let mut data = make_data(
vec![],
thermals,
vec![],
make_stages(stage_ids),
vec![],
vec![],
);
data.initial_conditions.past_anticipated_commitments = past_anticipated_commitments;
data
}
#[test]
fn test_valid_anticipated_thermal_ok() {
let thermal = make_anticipated_thermal(1, 2, None, None);
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(1),
values_mw: vec![0.0, 0.0],
};
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"expected no errors, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_missing_history_entry_error() {
let thermal = make_anticipated_thermal(1, 2, None, None);
let data = make_data_anticipated(vec![thermal], 5, vec![]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(ctx.has_errors());
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| e.kind == ErrorKind::BusinessRuleViolation)
.collect();
assert!(
!relevant.is_empty(),
"expected BusinessRuleViolation, got: {errors:?}"
);
let msg = &relevant[0].message;
assert!(
msg.contains("Thermal 1"),
"message should contain 'Thermal 1', got: {msg}"
);
assert!(
msg.contains("missing"),
"message should contain 'missing', got: {msg}"
);
let file = relevant[0].file.to_string_lossy();
assert!(
file.contains("initial_conditions"),
"file path should reference initial_conditions, got: {file}"
);
let entity = relevant[0].entity.as_deref().unwrap_or("");
assert!(
entity.contains("initial_conditions.past_anticipated_commitments"),
"entity should contain 'initial_conditions.past_anticipated_commitments', got: {entity}"
);
}
#[test]
fn test_history_entry_for_non_anticipated_thermal_error() {
let thermal = make_thermal(1, 0.0, 500.0);
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(1),
values_mw: vec![100.0, 200.0],
};
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(ctx.has_errors());
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| e.kind == ErrorKind::BusinessRuleViolation)
.collect();
assert!(
!relevant.is_empty(),
"expected BusinessRuleViolation, got: {errors:?}"
);
let msg = &relevant[0].message;
assert!(
msg.contains("not an anticipated thermal"),
"message should contain 'not an anticipated thermal', got: {msg}"
);
}
#[test]
fn test_over_length_values_mw_error() {
let thermal = make_anticipated_thermal(1, 2, None, None);
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(1),
values_mw: vec![100.0, 200.0, 300.0], };
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(ctx.has_errors());
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| e.kind == ErrorKind::BusinessRuleViolation)
.collect();
assert!(
!relevant.is_empty(),
"expected BusinessRuleViolation, got: {errors:?}"
);
let msg = &relevant[0].message;
assert!(
msg.contains("expected 2 values, got 3"),
"message should contain 'expected 2 values, got 3', got: {msg}"
);
}
#[test]
fn test_under_length_values_mw_error() {
let thermal = make_anticipated_thermal(1, 2, None, None);
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(1),
values_mw: vec![100.0], };
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(ctx.has_errors());
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| e.kind == ErrorKind::BusinessRuleViolation)
.collect();
assert!(
!relevant.is_empty(),
"expected BusinessRuleViolation, got: {errors:?}"
);
let msg = &relevant[0].message;
assert!(
msg.contains("expected 2 values, got 1"),
"message should contain 'expected 2 values, got 1', got: {msg}"
);
}
#[test]
fn test_lead_stages_exceeds_study_horizon_error() {
let thermal = make_anticipated_thermal(1, 10, None, None);
let data = make_data_anticipated(vec![thermal], 5, vec![]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(ctx.has_errors());
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.message.contains("lead_stages exceeds study horizon")
})
.collect();
assert!(
!relevant.is_empty(),
"expected BusinessRuleViolation with 'lead_stages exceeds study horizon', got: {errors:?}"
);
}
#[test]
fn test_lead_stages_equal_n_stages_ok() {
let thermal = make_anticipated_thermal(1, 5, None, None);
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(1),
values_mw: vec![0.0, 0.0, 0.0, 0.0, 0.0],
};
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"lead_stages == n_stages must be accepted, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_entry_plus_lead_exceeds_horizon_error() {
let thermal = make_anticipated_thermal(1, 3, Some(4), None);
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(1),
values_mw: vec![0.0, 0.0, 0.0],
};
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(ctx.has_errors());
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.message
.contains("entry_stage_id + lead_stages > n_stages")
})
.collect();
assert!(
!relevant.is_empty(),
"expected BusinessRuleViolation with 'entry_stage_id + lead_stages > n_stages', got: {errors:?}"
);
assert_eq!(
relevant[0].severity,
crate::validation::Severity::Error,
"must be an error"
);
let msg = &relevant[0].message;
assert!(
msg.contains("4 + 3") && msg.contains('7') && msg.contains('5'),
"message should show '4 + 3 = 7 > 5', got: {msg}"
);
}
#[test]
fn test_exit_before_earliest_delivery_error() {
let thermal = make_anticipated_thermal(1, 3, None, Some(2));
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(1),
values_mw: vec![0.0, 0.0, 0.0],
};
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(ctx.has_errors());
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation && e.message.contains("exit_stage_id")
})
.collect();
assert!(
!relevant.is_empty(),
"expected a BusinessRuleViolation mentioning 'exit_stage_id', got: {errors:?}"
);
}
#[test]
fn test_committed_value_out_of_bounds_error() {
let thermal = make_anticipated_thermal(3, 2, None, None); let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(3),
values_mw: vec![600.0, 200.0], };
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(ctx.has_errors());
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.message.contains("outside the plant's generation bounds")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one bounds-violation error (slot 0 only), got: {errors:?}"
);
let msg0 = &relevant[0].message;
assert!(
msg0.contains("Thermal 3"),
"message should contain 'Thermal 3', got: {msg0}"
);
assert!(
msg0.contains("values_mw[0]"),
"message should identify the index [0], got: {msg0}"
);
assert!(
msg0.contains("600"),
"message should contain the offending value 600, got: {msg0}"
);
let file = relevant[0].file.to_string_lossy();
assert!(
file.contains("initial_conditions"),
"file path should reference initial_conditions, got: {file}"
);
let entity = relevant[0].entity.as_deref().unwrap_or("");
assert!(
entity.contains("thermals[id=3].anticipated_config"),
"entity should reference the thermal anticipated_config, got: {entity}"
);
}
#[test]
fn test_committed_value_below_min_gen_bounds_error() {
let thermal = cobre_core::entities::Thermal {
anticipated_config: Some(cobre_core::entities::AnticipatedConfig { lead_stages: 2 }),
..make_thermal(5, 100.0, 500.0)
};
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(5),
values_mw: vec![200.0, 50.0], };
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(ctx.has_errors());
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.message.contains("outside the plant's generation bounds")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one bounds-violation error (slot 1 only), got: {errors:?}"
);
let msg1 = &relevant[0].message;
assert!(
msg1.contains("Thermal 5"),
"message should contain 'Thermal 5', got: {msg1}"
);
assert!(
msg1.contains("values_mw[1]"),
"message should identify the index [1], got: {msg1}"
);
assert!(
msg1.contains("50"),
"message should contain the offending value 50, got: {msg1}"
);
}
#[test]
fn test_committed_values_all_zero_ok() {
let thermal = cobre_core::entities::Thermal {
anticipated_config: Some(cobre_core::entities::AnticipatedConfig { lead_stages: 3 }),
..make_thermal(7, 0.0, 400.0)
};
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(7),
values_mw: vec![0.0, 0.0, 0.0],
};
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"expected no errors for all-zero values_mw, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_committed_value_zero_accepted() {
let thermal = cobre_core::entities::Thermal {
anticipated_config: Some(cobre_core::entities::AnticipatedConfig { lead_stages: 1 }),
..make_thermal(9, 0.0, 400.0)
};
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(9),
values_mw: vec![0.0],
};
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"zero value must be accepted, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_committed_value_above_max_bounds_error() {
let thermal = cobre_core::entities::Thermal {
anticipated_config: Some(cobre_core::entities::AnticipatedConfig { lead_stages: 1 }),
..make_thermal(11, 100.0, 350.0)
};
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(11),
values_mw: vec![400.0], };
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(ctx.has_errors());
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.message.contains("outside the plant's generation bounds")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one bounds-violation error, got: {errors:?}"
);
let msg = &relevant[0].message;
assert!(
msg.contains("Thermal 11"),
"message should contain 'Thermal 11', got: {msg}"
);
assert!(
msg.contains("values_mw[0]"),
"message should identify the index [0], got: {msg}"
);
assert!(
msg.contains("400"),
"message should contain the offending value 400, got: {msg}"
);
}
#[test]
fn test_f3_002_nonzero_values_mw_in_bounds_accepted_k1() {
let thermal = cobre_core::entities::Thermal {
anticipated_config: Some(cobre_core::entities::AnticipatedConfig { lead_stages: 1 }),
..make_thermal(2, 0.0, 350.0)
};
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(2),
values_mw: vec![204.5647], };
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
ctx.errors().is_empty(),
"expected no errors for in-bounds values_mw, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_k2_two_in_bounds_nonzero_values_accepted() {
let thermal = make_anticipated_thermal(5, 2, None, None); let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(5),
values_mw: vec![50.0, 30.0], };
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
ctx.errors().is_empty(),
"expected no errors for in-bounds K=2 values_mw, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_nonzero_in_bounds_seed_emits_no_semantic_ambiguity_warning() {
let thermal = cobre_core::entities::Thermal {
anticipated_config: Some(cobre_core::entities::AnticipatedConfig { lead_stages: 2 }),
..make_thermal(3, 0.0, 350.0)
};
let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(3),
values_mw: vec![100.0, 200.0], };
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let all_warnings = ctx.warnings();
let ambiguity_warnings: Vec<_> = all_warnings
.iter()
.filter(|w| {
w.kind == ErrorKind::SemanticAmbiguity
&& w.file.to_string_lossy().contains("initial_conditions.json")
})
.collect();
assert!(
ambiguity_warnings.is_empty(),
"expected no SemanticAmbiguity warning from initial_conditions.json \
for an in-bounds non-zero seed, got: {ambiguity_warnings:?}"
);
}
#[test]
fn test_k2_mixed_in_bounds_and_out_of_bounds_only_oob_reported() {
let thermal = make_anticipated_thermal(7, 2, None, None); let history = AnticipatedCommitmentHistory {
thermal_id: EntityId::from(7),
values_mw: vec![0.0, 600.0], };
let data = make_data_anticipated(vec![thermal], 5, vec![history]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.message.contains("outside the plant's generation bounds")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one bounds error (slot 1 only), got: {errors:?}"
);
assert!(
relevant[0].message.contains("values_mw[1]"),
"error must identify slot [1], got: {}",
relevant[0].message
);
assert!(
relevant[0].message.contains("600"),
"error must contain value 600, got: {}",
relevant[0].message
);
}
#[test]
fn test_thermal_generation_min_greater_than_max() {
let thermal = make_thermal(10, 500.0, 100.0); let data = make_data(
vec![],
vec![thermal],
vec![],
make_stages(vec![0]),
vec![],
vec![],
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(ctx.has_errors());
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| e.kind == ErrorKind::InvalidValue)
.collect();
assert_eq!(relevant.len(), 1, "exactly 1 InvalidValue error expected");
let msg = &relevant[0].message;
assert!(
msg.contains("Thermal 10"),
"message should contain 'Thermal 10', got: {msg}"
);
}
#[test]
fn test_thermal_generation_equal_bounds_valid() {
let thermal = make_thermal(11, 200.0, 200.0);
let data = make_data(
vec![],
vec![thermal],
vec![],
make_stages(vec![0]),
vec![],
vec![],
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(!ctx.has_errors());
}
fn make_thermal_bounds_row(thermal_id: i32, stage_id: i32) -> crate::ThermalBoundsRow {
crate::ThermalBoundsRow {
thermal_id: EntityId::from(thermal_id),
stage_id,
min_generation_mw: None,
max_generation_mw: None,
cost_per_mwh: None,
block_id: None,
}
}
fn make_data_thermal_bounds(
n_stages: usize,
rows: Vec<crate::ThermalBoundsRow>,
) -> crate::validation::schema::ParsedData {
let thermal = make_thermal(1, 0.0, 100.0);
let stage_ids: Vec<i32> = (0..n_stages as i32).collect();
let mut data = make_data(
vec![],
vec![thermal],
vec![],
make_stages(stage_ids),
vec![],
vec![],
);
data.thermal_bounds = rows;
data
}
#[test]
fn test_thermal_bounds_override_stage_within_horizon_accepted() {
let data = make_data_thermal_bounds(5, vec![make_thermal_bounds_row(1, 4)]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| {
e.file
.to_string_lossy()
.contains("constraints/thermal_bounds.parquet")
})
.collect();
assert!(
relevant.is_empty(),
"expected no thermal_bounds.parquet errors, got: {relevant:?}"
);
}
#[test]
fn test_thermal_bounds_override_stage_equals_n_rejected() {
let data = make_data_thermal_bounds(5, vec![make_thermal_bounds_row(1, 5)]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.file
.to_string_lossy()
.contains("constraints/thermal_bounds.parquet")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one BusinessRuleViolation, got: {relevant:?}"
);
let msg = &relevant[0].message;
assert!(
msg.contains("stage_id=5"),
"message should contain 'stage_id=5', got: {msg}"
);
assert!(
msg.contains("[0, 5)"),
"message should contain '[0, 5)', got: {msg}"
);
assert!(
msg.contains("not allowed"),
"message should contain 'not allowed', got: {msg}"
);
}
#[test]
fn test_thermal_bounds_override_stage_negative_rejected() {
let data = make_data_thermal_bounds(5, vec![make_thermal_bounds_row(1, -1)]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.file
.to_string_lossy()
.contains("constraints/thermal_bounds.parquet")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one BusinessRuleViolation for stage_id=-1, got: {relevant:?}"
);
}
#[test]
fn test_thermal_bounds_override_multiple_offending_rows() {
let rows = vec![
make_thermal_bounds_row(1, 0), make_thermal_bounds_row(1, 5), make_thermal_bounds_row(1, 9), ];
let data = make_data_thermal_bounds(5, rows);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.file
.to_string_lossy()
.contains("constraints/thermal_bounds.parquet")
})
.collect();
assert_eq!(
relevant.len(),
2,
"expected exactly two BusinessRuleViolations, got: {relevant:?}"
);
}
#[test]
fn test_thermal_bounds_override_zero_n_stages_all_rejected() {
let data = make_data_thermal_bounds(0, vec![make_thermal_bounds_row(1, 0)]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.file
.to_string_lossy()
.contains("constraints/thermal_bounds.parquet")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one BusinessRuleViolation when n_stages=0, got: {relevant:?}"
);
}
mod boundary_tests {
use super::*;
#[test]
fn override_at_t_minus_1_acceptance_boundary() {
let data = make_data_thermal_bounds(5, vec![make_thermal_bounds_row(1, 4)]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| {
e.file
.to_string_lossy()
.contains("constraints/thermal_bounds.parquet")
})
.collect();
assert!(
relevant.is_empty(),
"stage_id=4 with n_stages=5 must be accepted, got: {relevant:?}"
);
}
#[test]
fn override_at_t_rejection_boundary() {
let data = make_data_thermal_bounds(5, vec![make_thermal_bounds_row(1, 5)]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.file
.to_string_lossy()
.contains("constraints/thermal_bounds.parquet")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one BusinessRuleViolation at stage_id=5, got: {relevant:?}"
);
}
#[test]
fn override_at_t_plus_one_rejection() {
let data = make_data_thermal_bounds(5, vec![make_thermal_bounds_row(1, 6)]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.file
.to_string_lossy()
.contains("constraints/thermal_bounds.parquet")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one BusinessRuleViolation at stage_id=6, got: {relevant:?}"
);
}
#[test]
fn override_negative_stage_rejection() {
let data = make_data_thermal_bounds(5, vec![make_thermal_bounds_row(1, -1)]);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.file
.to_string_lossy()
.contains("constraints/thermal_bounds.parquet")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one BusinessRuleViolation at stage_id=-1, got: {relevant:?}"
);
}
}
#[test]
fn test_anticipated_decision_on_non_anticipated_thermal_error() {
use cobre_core::{
ConstraintExpression, ConstraintSense, GenericConstraint, LinearTerm, SlackConfig,
VariableRef,
};
let thermal = make_thermal(7, 0.0, 500.0); let constraint = GenericConstraint {
id: EntityId::from(1),
name: "bad_constraint".to_string(),
description: None,
expression: ConstraintExpression {
terms: vec![LinearTerm::literal(
1.0,
VariableRef::AnticipatedDecision {
thermal_id: EntityId::from(7),
},
)],
},
sense: ConstraintSense::LessEqual,
slack: SlackConfig {
enabled: false,
penalty: None,
},
};
let stage_ids: Vec<i32> = (0..5).collect();
let mut data = make_data(
vec![],
vec![thermal],
vec![],
make_stages(stage_ids),
vec![],
vec![],
);
data.generic_constraints = vec![constraint];
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
ctx.has_errors(),
"expected error for non-anticipated thermal"
);
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| e.kind == ErrorKind::BusinessRuleViolation)
.collect();
assert!(
!relevant.is_empty(),
"expected BusinessRuleViolation, got: {errors:?}"
);
let msg = &relevant[0].message;
assert!(
msg.contains("bad_constraint"),
"message should contain constraint name, got: {msg}"
);
assert!(
msg.contains('7'),
"message should contain thermal id 7, got: {msg}"
);
assert!(
msg.contains("not an anticipated thermal"),
"message should explain the rule, got: {msg}"
);
let file = relevant[0].file.to_string_lossy();
assert!(
file.contains("generic_constraints.json"),
"file should reference generic_constraints.json, got: {file}"
);
}
#[test]
fn test_anticipated_decision_on_anticipated_thermal_ok() {
use cobre_core::{
ConstraintExpression, ConstraintSense, GenericConstraint, LinearTerm, SlackConfig,
VariableRef, entities::AnticipatedConfig,
};
let thermal = cobre_core::entities::Thermal {
anticipated_config: Some(AnticipatedConfig { lead_stages: 2 }),
..make_thermal(3, 0.0, 500.0)
};
let history = cobre_core::AnticipatedCommitmentHistory {
thermal_id: EntityId::from(3),
values_mw: vec![0.0, 0.0],
};
let constraint = GenericConstraint {
id: EntityId::from(10),
name: "valid_anticipated_constraint".to_string(),
description: None,
expression: ConstraintExpression {
terms: vec![LinearTerm::literal(
1.0,
VariableRef::AnticipatedDecision {
thermal_id: EntityId::from(3),
},
)],
},
sense: ConstraintSense::LessEqual,
slack: SlackConfig {
enabled: false,
penalty: None,
},
};
let stage_ids: Vec<i32> = (0..5).collect();
let mut data = make_data(
vec![],
vec![thermal],
vec![],
make_stages(stage_ids),
vec![],
vec![],
);
data.initial_conditions.past_anticipated_commitments = vec![history];
data.generic_constraints = vec![constraint];
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.file
.to_string_lossy()
.contains("generic_constraints.json")
})
.collect();
assert!(
relevant.is_empty(),
"anticipated_decision on an anticipated thermal must not produce a BusinessRuleViolation, got: {relevant:?}"
);
}
#[test]
fn test_thermal_generation_on_anticipated_thermal_warns() {
use cobre_core::{
ConstraintExpression, ConstraintSense, GenericConstraint, LinearTerm, SlackConfig,
VariableRef, entities::AnticipatedConfig,
};
let thermal = cobre_core::entities::Thermal {
anticipated_config: Some(AnticipatedConfig { lead_stages: 1 }),
..make_thermal(5, 0.0, 300.0)
};
let history = cobre_core::AnticipatedCommitmentHistory {
thermal_id: EntityId::from(5),
values_mw: vec![0.0],
};
let constraint = GenericConstraint {
id: EntityId::from(20),
name: "ambiguous_constraint".to_string(),
description: None,
expression: ConstraintExpression {
terms: vec![LinearTerm::literal(
1.0,
VariableRef::ThermalGeneration {
thermal_id: EntityId::from(5),
block_id: None,
},
)],
},
sense: ConstraintSense::GreaterEqual,
slack: SlackConfig {
enabled: false,
penalty: None,
},
};
let stage_ids: Vec<i32> = (0..5).collect();
let mut data = make_data(
vec![],
vec![thermal],
vec![],
make_stages(stage_ids),
vec![],
vec![],
);
data.initial_conditions.past_anticipated_commitments = vec![history];
data.generic_constraints = vec![constraint];
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let errors = ctx.errors();
let hard: Vec<_> = errors
.iter()
.filter(|e| {
e.file
.to_string_lossy()
.contains("generic_constraints.json")
})
.collect();
assert!(
hard.is_empty(),
"thermal_generation on anticipated thermal must not produce a hard error, got: {hard:?}"
);
let warnings = ctx.warnings();
let relevant: Vec<_> = warnings
.iter()
.filter(|w| w.kind == ErrorKind::SemanticAmbiguity)
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one SemanticAmbiguity warning, got: {warnings:?}"
);
let msg = &relevant[0].message;
assert!(
msg.contains("ambiguous_constraint"),
"warning should name the constraint, got: {msg}"
);
assert!(
msg.contains('5'),
"warning should mention thermal id 5, got: {msg}"
);
assert!(
msg.contains("anticipated_decision"),
"warning should suggest anticipated_decision, got: {msg}"
);
let file = relevant[0].file.to_string_lossy();
assert!(
file.contains("generic_constraints.json"),
"file should reference generic_constraints.json, got: {file}"
);
}
#[test]
fn test_thermal_generation_on_non_anticipated_thermal_no_warn() {
use cobre_core::{
ConstraintExpression, ConstraintSense, GenericConstraint, LinearTerm, SlackConfig,
VariableRef,
};
let thermal = make_thermal(9, 0.0, 200.0); let constraint = GenericConstraint {
id: EntityId::from(30),
name: "plain_thermal_constraint".to_string(),
description: None,
expression: ConstraintExpression {
terms: vec![LinearTerm::literal(
1.0,
VariableRef::ThermalGeneration {
thermal_id: EntityId::from(9),
block_id: None,
},
)],
},
sense: ConstraintSense::GreaterEqual,
slack: SlackConfig {
enabled: false,
penalty: None,
},
};
let stage_ids: Vec<i32> = (0..5).collect();
let mut data = make_data(
vec![],
vec![thermal],
vec![],
make_stages(stage_ids),
vec![],
vec![],
);
data.generic_constraints = vec![constraint];
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let warnings = ctx.warnings();
let relevant: Vec<_> = warnings
.iter()
.filter(|w| w.kind == ErrorKind::SemanticAmbiguity)
.collect();
assert!(
relevant.is_empty(),
"thermal_generation on a non-anticipated thermal must not emit SemanticAmbiguity, got: {relevant:?}"
);
}
}