use std::collections::{HashMap, HashSet};
use chrono::{NaiveDate, TimeDelta};
use cobre_core::commissioning::commissioning_active;
use cobre_core::temporal::{
Block, BlockMode, NoiseMethod, ScenarioSourceConfig, Stage, StageRiskConfig, StageStateConfig,
};
use cobre_core::{
AnticipatedCommitmentHistory, AnticipatedConfig, EntityId, FutureAnticipatedDelivery,
PostStudyStages, PostStudyThermalBound, Thermal, VariableRef,
};
use cobre_stochastic::season_cast::{DatedWindow, StageCalendar};
use super::super::{ErrorKind, ValidationContext, schema::ParsedData};
use super::envelope_tolerance;
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 study_stages: &[Stage] = match data.stages.stages.iter().position(|s| s.id >= 0) {
Some(idx) => &data.stages.stages[idx..],
None => &[],
};
let study_stage_ids: Vec<i32> = study_stages.iter().map(|s| s.id).collect();
let n_stages = study_stage_ids.len();
let study_durations = study_stage_durations(data);
let thermals_with_future_delivery: HashSet<EntityId> = data
.initial_conditions
.future_anticipated_deliveries
.iter()
.map(|delivery| delivery.thermal_id)
.collect();
for thermal in &data.thermals {
let Some(ref cfg) = thermal.anticipated_config else {
continue;
};
let thermal_id = thermal.id.0;
if let AnticipatedConfig::LeadTime(delta_hours) = *cfg {
let total_horizon_hours: f64 = study_durations.iter().sum();
if delta_hours > total_horizon_hours
&& !thermals_with_future_delivery.contains(&thermal.id)
{
let entity_str = format!("thermals[id={thermal_id}].anticipated_config.lead_time");
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"system/thermals.json",
Some(&entity_str),
format!(
"Thermal {thermal_id}: lead_time exceeds study horizon \
(lead_time={delta_hours}, total_horizon_hours={total_horizon_hours}); \
the plant can never deliver within the study horizon"
),
);
}
}
let Some(k) = cfg.lead_stages() else {
continue;
};
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"
),
);
}
}
let ic = &data.initial_conditions;
let mut windows_by_id: HashMap<EntityId, Vec<&AnticipatedCommitmentHistory>> = HashMap::new();
for history in &ic.past_anticipated_commitments {
windows_by_id
.entry(history.thermal_id)
.or_default()
.push(history);
}
let calendar = (!windows_by_id.is_empty()).then(|| StageCalendar::new(study_stages));
let anticipated_thermal_ids: HashSet<EntityId> = data
.thermals
.iter()
.filter(|t| t.anticipated_config.is_some())
.map(|t| t.id)
.collect();
for thermal in &data.thermals {
let Some(ref cfg) = thermal.anticipated_config else {
continue;
};
let thermal_id = thermal.id;
match windows_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 at least one commitment window",
thermal_id.0
),
);
}
Some(records) => {
let Some(calendar) = calendar.as_ref() else {
continue;
};
let k_i = lead_delivery_stage_count(*cfg, &study_durations, n_stages);
if check_commitment_coverage(
thermal_id,
records,
calendar,
k_i,
&study_stage_ids,
ctx,
) {
check_committed_value_bounds(thermal, thermal_id, records, ctx);
check_seed_within_window(
thermal,
thermal_id,
records,
calendar,
&study_stage_ids,
ctx,
);
}
}
}
}
let mut reported: HashSet<EntityId> = HashSet::new();
for history in &ic.past_anticipated_commitments {
if !anticipated_thermal_ids.contains(&history.thermal_id)
&& reported.insert(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
),
);
}
}
}
pub(super) fn check_future_delivery_thermal_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 delivery in &data.initial_conditions.future_anticipated_deliveries {
if !anticipated_ids.contains(&delivery.thermal_id) {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"initial_conditions.json",
Some(delivery_entity(delivery)),
format!(
"Thermal {}: referenced in future_anticipated_deliveries but is not an \
anticipated thermal (anticipated_config is None or thermal does not exist)",
delivery.thermal_id.0
),
);
}
}
}
pub(super) fn check_anticipated_cadence_transition(data: &ParsedData, ctx: &mut ValidationContext) {
let study_durations = study_stage_durations(data);
let n_stages = study_durations.len();
if n_stages < 2 {
return;
}
for thermal in &data.thermals {
let Some(cfg) = thermal.anticipated_config else {
continue;
};
let Some(k) = cfg.lead_stages() else {
continue;
};
let thermal_id = thermal.id.0;
let k_u = usize::try_from(k).unwrap_or(usize::MAX);
let decision_start = usize::try_from(thermal.entry_stage_id.unwrap_or(0).max(0))
.unwrap_or(0)
.min(n_stages - 1);
let decision_end_id = thermal.exit_stage_id.map_or_else(
|| i32::try_from(n_stages - 1).unwrap_or(i32::MAX),
|exit| exit - 1,
);
if decision_end_id < 0 {
continue;
}
let decision_end = usize::try_from(decision_end_id)
.unwrap_or(0)
.min(n_stages - 1);
if decision_start > decision_end {
continue;
}
let window_end = decision_end.saturating_add(k_u).min(n_stages - 1);
let entity_str = format!("thermals[id={thermal_id}].anticipated_config.lead_stages");
for i in decision_start..window_end {
let (prev, next) = (study_durations[i], study_durations[i + 1]);
if (prev - next).abs() > 1e-9 {
ctx.add_warning(
ErrorKind::ModelQuality,
"system/thermals.json",
Some(&entity_str),
format!(
"Thermal {thermal_id}: anticipated_config.lead_stages={k} active window \
spans a stage-cadence transition between stage {i} ({prev}h) and stage \
{} ({next}h); a fixed stage-count lead delivers a different physical \
lead on each side of the transition. Consider anticipated_config.lead_time, \
which anchors the lead to physical hours instead of a stage count.",
i + 1
),
);
break;
}
}
}
}
fn study_stage_durations(data: &ParsedData) -> Vec<f64> {
data.stages
.stages
.iter()
.filter(|s| s.id >= 0)
.map(|s| s.blocks.iter().map(|b| b.duration_hours).sum())
.collect()
}
fn lead_delivery_stage_count(
mode: AnticipatedConfig,
study_durations: &[f64],
n_stages: usize,
) -> usize {
match mode {
AnticipatedConfig::LeadStages(lead_stages) => (lead_stages as usize).min(n_stages),
AnticipatedConfig::LeadTime(delta_hours) => {
let mut cumulative_hours = 0.0_f64;
let mut count = 0usize;
for &duration in study_durations.iter().take(n_stages) {
cumulative_hours += duration;
if cumulative_hours > delta_hours {
break;
}
count += 1;
}
count
}
}
}
#[allow(clippy::float_cmp)] fn check_commitment_coverage(
thermal_id: EntityId,
records: &[&AnticipatedCommitmentHistory],
calendar: &StageCalendar,
k_i: usize,
study_stage_ids: &[i32],
ctx: &mut ValidationContext,
) -> bool {
let windows: Vec<DatedWindow> = records
.iter()
.map(|r| DatedWindow {
start_date: r.start_date,
end_date: r.end_date,
})
.collect();
let mut per_stage = vec![0.0_f64; study_stage_ids.len()];
for window in &windows {
for (total, fraction) in per_stage.iter_mut().zip(calendar.coverage(window)) {
*total += fraction;
}
}
let entity_str = format!("thermals[id={}].anticipated_config", thermal_id.0);
let mut valid = true;
if !calendar.covers_exactly(&windows, k_i) {
let uncovered: Vec<i32> = (0..k_i)
.filter(|&i| per_stage[i] != 1.0)
.map(|i| study_stage_ids[i])
.collect();
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"initial_conditions.json",
Some(&entity_str),
format!(
"Thermal {}: past_anticipated_commitments do not tile the leading {k_i} \
delivery stage(s) at coverage 1.0; study stage id(s) {uncovered:?} are not \
covered exactly once. Write a commitment window (a committed 0 MW is explicit) \
for every leading delivery stage.",
thermal_id.0
),
);
valid = false;
}
let over_covered: Vec<i32> = (k_i..study_stage_ids.len())
.filter(|&i| per_stage[i] != 0.0)
.map(|i| study_stage_ids[i])
.collect();
if !over_covered.is_empty() {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"initial_conditions.json",
Some(&entity_str),
format!(
"Thermal {}: past_anticipated_commitments cover study stage id(s) {over_covered:?} \
beyond the leading {k_i} calendar-derived delivery stage(s); commitment windows \
must not extend past the pre-study delivery horizon.",
thermal_id.0
),
);
valid = false;
}
valid
}
fn check_committed_value_bounds(
thermal: &Thermal,
thermal_id: EntityId,
records: &[&AnticipatedCommitmentHistory],
ctx: &mut ValidationContext,
) {
let min_mw = thermal.min_generation_mw;
let max_mw = thermal.max_generation_mw;
let min_tolerance = envelope_tolerance(min_mw);
let max_tolerance = envelope_tolerance(max_mw);
let entity_str = format!("thermals[id={}].anticipated_config", thermal_id.0);
for record in records {
let v = record.value_mw;
if v < min_mw - min_tolerance || v > max_mw + max_tolerance {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"initial_conditions.json",
Some(&entity_str),
format!(
"Thermal {}: past_anticipated_commitments window [{}, {}) value_mw = {v} \
is outside the plant's generation bounds [{min_mw}, {max_mw}]; \
the LP delivery equality on the covered stage(s) cannot be \
satisfied and the LP will be infeasible",
thermal_id.0, record.start_date, record.end_date
),
);
}
}
}
#[allow(clippy::float_cmp)] fn check_seed_within_window(
thermal: &Thermal,
thermal_id: EntityId,
records: &[&AnticipatedCommitmentHistory],
calendar: &StageCalendar,
study_stage_ids: &[i32],
ctx: &mut ValidationContext,
) {
let entry = thermal.entry_stage_id;
let exit = thermal.exit_stage_id;
if entry.is_none() && exit.is_none() {
return;
}
let entity_str = format!("thermals[id={}].anticipated_config", thermal_id.0);
for record in records {
let v = record.value_mw;
if v == 0.0 {
continue;
}
let window = DatedWindow {
start_date: record.start_date,
end_date: record.end_date,
};
for (i, fraction) in calendar.coverage(&window).into_iter().enumerate() {
if fraction == 0.0 {
continue;
}
let stage_id = study_stage_ids[i];
if !commissioning_active(entry, exit, stage_id) {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"initial_conditions.json",
Some(&entity_str),
format!(
"Thermal {}: past_anticipated_commitments window [{}, {}) value_mw = {v} \
matures at study stage id {stage_id}, which is outside the \
plant's commissioning window [entry={entry:?}, exit={exit:?}); \
the matured generation column is pinned to [0, 0] there, so the \
fishing equality reads 0 == {v} and the LP is infeasible. \
Commit a zero rate at this stage or widen the window.",
thermal_id.0, record.start_date, record.end_date
),
);
}
}
}
}
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
),
);
}
}
}
pub(super) fn check_post_study_stages(data: &ParsedData, ctx: &mut ValidationContext) {
let deliveries = &data.initial_conditions.future_anticipated_deliveries;
let Some(post_study) = data.post_study_stages.as_ref() else {
for delivery in deliveries {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"post_study_stages.json",
Some(delivery_entity(delivery)),
format!(
"Thermal {}: future_anticipated_deliveries window [{}, {}) has no \
post_study_stages.json to resolve against; declare a post-study boundary \
calendar covering it.",
delivery.thermal_id.0, delivery.delivery_start, delivery.delivery_end
),
);
}
return;
};
let study_end = data
.stages
.stages
.iter()
.rfind(|s| s.id >= 0)
.map(|s| s.end_date);
let (calendar_stages, well_formed) = build_post_study_calendar(post_study, study_end, ctx);
if !well_formed {
return;
}
if deliveries.is_empty() || calendar_stages.is_empty() {
return;
}
let calendar = StageCalendar::new(&calendar_stages);
let horizon_end = calendar_stages[calendar_stages.len() - 1].end_date;
let bounds: HashMap<(EntityId, usize), &PostStudyThermalBound> = post_study
.thermal_bounds
.iter()
.map(|b| ((b.thermal_id, b.post_study_stage_index), b))
.collect();
for delivery in deliveries {
check_delivery_coverage(
delivery,
&calendar,
&calendar_stages,
horizon_end,
&bounds,
ctx,
);
}
}
fn build_post_study_calendar(
post_study: &PostStudyStages,
study_end: Option<NaiveDate>,
ctx: &mut ValidationContext,
) -> (Vec<Stage>, bool) {
let mut calendar_stages: Vec<Stage> = Vec::with_capacity(post_study.stages.len());
let mut well_formed = true;
for (i, stage) in post_study.stages.iter().enumerate() {
match post_study_end_date(stage.start_date, stage.duration_hours) {
Some(end_date) if end_date > stage.start_date => {
calendar_stages.push(make_calendar_stage(
i,
stage.start_date,
end_date,
stage.duration_hours,
));
}
_ => {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"post_study_stages.json",
Some(format!("stages[{i}]")),
format!(
"post-study stage starting {} has duration_hours {} that rounds to a \
non-positive whole-day span; declare a duration of at least a day.",
stage.start_date, stage.duration_hours
),
);
well_formed = false;
}
}
}
match (calendar_stages.first(), study_end) {
(Some(first), Some(end)) if first.start_date != end => {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"post_study_stages.json",
Some("stages[0].start_date"),
format!(
"first post-study stage starts {} but the study horizon ends {}; the \
post-study calendar must begin exactly at the study horizon end.",
first.start_date, end
),
);
well_formed = false;
}
(Some(_), None) => {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"post_study_stages.json",
Some("stages"),
"post_study_stages.json is declared but the study has no study stages to anchor \
the post-study calendar against."
.to_string(),
);
well_formed = false;
}
_ => {}
}
for pair in calendar_stages.windows(2) {
if pair[0].end_date != pair[1].start_date {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"post_study_stages.json",
Some(format!("stages start_date={}", pair[1].start_date)),
format!(
"post-study stages are not date-contiguous: the stage starting {} ends {} \
but the next stage starts {}; each stage must end exactly where the next \
begins.",
pair[0].start_date, pair[0].end_date, pair[1].start_date
),
);
well_formed = false;
}
}
(calendar_stages, well_formed)
}
#[allow(clippy::float_cmp)] fn check_delivery_coverage(
delivery: &FutureAnticipatedDelivery,
calendar: &StageCalendar,
calendar_stages: &[Stage],
horizon_end: NaiveDate,
bounds: &HashMap<(EntityId, usize), &PostStudyThermalBound>,
ctx: &mut ValidationContext,
) {
let first_start = calendar_stages[0].start_date;
if delivery.delivery_start < first_start || delivery.delivery_end > horizon_end {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"post_study_stages.json",
Some(delivery_entity(delivery)),
format!(
"Thermal {}: future_anticipated_deliveries window [{}, {}) reaches outside the \
post-study horizon [{}, {}); every delivery must fall within the declared \
post-study stages.",
delivery.thermal_id.0,
delivery.delivery_start,
delivery.delivery_end,
first_start,
horizon_end
),
);
return;
}
let window = DatedWindow {
start_date: delivery.delivery_start,
end_date: delivery.delivery_end,
};
let coverage = calendar.coverage(&window);
let mut covered: Vec<usize> = Vec::new();
let mut aligned = true;
for (stage_index, fraction) in coverage.iter().enumerate() {
if *fraction == 1.0 {
covered.push(stage_index);
} else if *fraction != 0.0 {
aligned = false;
}
}
if !aligned || covered.is_empty() {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"post_study_stages.json",
Some(delivery_entity(delivery)),
format!(
"Thermal {}: future_anticipated_deliveries window [{}, {}) is not covered exactly \
(1.0) by the post-study stages; align the window to whole post-study stage \
boundaries.",
delivery.thermal_id.0, delivery.delivery_start, delivery.delivery_end
),
);
return;
}
if covered.len() != 1 {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"post_study_stages.json",
Some(delivery_entity(delivery)),
format!(
"Thermal {}: future_anticipated_deliveries window [{}, {}) spans {} post-study \
stages; a delivery window must resolve to exactly one post-study stage \
(multi-stage delivery is not supported).",
delivery.thermal_id.0,
delivery.delivery_start,
delivery.delivery_end,
covered.len()
),
);
return;
}
for stage_index in covered {
match bounds.get(&(delivery.thermal_id, stage_index)) {
None => ctx.add_error(
ErrorKind::BusinessRuleViolation,
"post_study_stages.json",
Some(delivery_entity(delivery)),
format!(
"Thermal {}: future_anticipated_deliveries window [{}, {}) delivers into \
post-study stage index {stage_index}, but post_study_stages.json has no \
thermal_bounds entry for (thermal_id {}, post_study_stage_index \
{stage_index}).",
delivery.thermal_id.0,
delivery.delivery_start,
delivery.delivery_end,
delivery.thermal_id.0
),
),
Some(bound) => {
let lower = delivery.min_mw.max(bound.min_mw);
let upper = delivery.max_mw.min(bound.max_mw);
if lower > upper {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"post_study_stages.json",
Some(delivery_entity(delivery)),
format!(
"Thermal {}: future_anticipated_deliveries commitment interval \
[{}, {}] and post-study stage index {stage_index} capability \
[{}, {}] do not intersect; the committed amount is unsatisfiable \
within capability.",
delivery.thermal_id.0,
delivery.min_mw,
delivery.max_mw,
bound.min_mw,
bound.max_mw
),
);
}
}
}
}
}
#[allow(clippy::cast_possible_truncation)] fn post_study_end_date(start_date: NaiveDate, duration_hours: f64) -> Option<NaiveDate> {
let days = (duration_hours / 24.0).round() as i64;
start_date.checked_add_signed(TimeDelta::days(days))
}
fn make_calendar_stage(
index: usize,
start_date: NaiveDate,
end_date: NaiveDate,
duration_hours: f64,
) -> Stage {
Stage {
index,
id: i32::try_from(index).unwrap_or(i32::MAX),
start_date,
end_date,
season_id: None,
blocks: vec![Block {
index: 0,
name: "post_study".to_string(),
duration_hours,
}],
block_mode: BlockMode::Parallel,
state_config: StageStateConfig {
storage: true,
inflow_lags: false,
},
risk_config: StageRiskConfig::Expectation,
scenario_config: ScenarioSourceConfig {
branching_factor: 1,
noise_method: NoiseMethod::Saa,
},
}
}
fn delivery_entity(delivery: &FutureAnticipatedDelivery) -> String {
format!(
"thermal_id={}, delivery_start={}",
delivery.thermal_id.0, delivery.delivery_start
)
}
#[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::temporal::{Block, PolicyGraphType, Stage};
use cobre_core::{
AnticipatedCommitmentHistory, AnticipatedConfig, EntityId, FutureAnticipatedDelivery,
HorizonGraph, Thermal,
};
use chrono::{NaiveDate, TimeDelta};
use super::super::test_support::*;
use super::super::validate_semantic_hydro_thermal;
use super::{
check_anticipated_thermals, check_future_delivery_thermal_is_anticipated,
lead_delivery_stage_count,
};
use crate::stages::StagesData;
use crate::validation::schema::ParsedData;
use crate::validation::{ErrorKind, ValidationContext};
fn make_anticipated_thermal(
id: i32,
lead_stages: u32,
entry_stage_id: Option<i32>,
exit_stage_id: Option<i32>,
) -> Thermal {
Thermal {
anticipated_config: Some(AnticipatedConfig::LeadStages(lead_stages)),
entry_stage_id,
exit_stage_id,
..make_thermal(id, 0.0, 500.0)
}
}
fn make_lead_time_anticipated_thermal(
id: i32,
lead_time_hours: f64,
entry_stage_id: Option<i32>,
exit_stage_id: Option<i32>,
) -> Thermal {
Thermal {
anticipated_config: Some(AnticipatedConfig::LeadTime(lead_time_hours)),
entry_stage_id,
exit_stage_id,
..make_thermal(id, 0.0, 500.0)
}
}
fn calendar_anchor() -> NaiveDate {
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()
}
fn stage_boundaries(durations_hours: &[f64]) -> Vec<NaiveDate> {
let mut cursor = calendar_anchor();
let mut dates = vec![cursor];
for &hours in durations_hours {
cursor += TimeDelta::days((hours / 24.0) as i64);
dates.push(cursor);
}
dates
}
fn contiguous_stages(durations_hours: &[f64]) -> Vec<Stage> {
let boundaries = stage_boundaries(durations_hours);
durations_hours
.iter()
.enumerate()
.map(|(i, &hours)| {
let mut stage = make_stage(i as i32);
stage.index = i;
stage.start_date = boundaries[i];
stage.end_date = boundaries[i + 1];
stage.blocks = vec![Block {
index: 0,
name: "S".to_string(),
duration_hours: hours,
}];
stage
})
.collect()
}
fn commitments_on(
thermal_id: i32,
values: &[f64],
durations_hours: &[f64],
) -> Vec<AnticipatedCommitmentHistory> {
let boundaries = stage_boundaries(durations_hours);
values
.iter()
.enumerate()
.map(|(i, &value_mw)| AnticipatedCommitmentHistory {
thermal_id: EntityId::from(thermal_id),
start_date: boundaries[i],
end_date: boundaries[i + 1],
value_mw,
})
.collect()
}
fn commitments(thermal_id: i32, values: &[f64]) -> Vec<AnticipatedCommitmentHistory> {
commitments_on(thermal_id, values, &vec![720.0; values.len().max(1)])
}
fn anticipated_data(
thermals: Vec<Thermal>,
durations_hours: &[f64],
past_anticipated_commitments: Vec<AnticipatedCommitmentHistory>,
) -> ParsedData {
let stages_data = StagesData {
openings_declared: std::collections::HashSet::new(),
stages: contiguous_stages(durations_hours),
policy_graph: HorizonGraph {
stage_discount_rate_overrides: std::collections::HashMap::new(),
graph_type: PolicyGraphType::FiniteHorizon,
annual_discount_rate: 0.06,
transitions: vec![],
nodes: Vec::new(),
season_map: None,
},
};
let mut data = make_data(vec![], thermals, vec![], stages_data, vec![], vec![]);
data.initial_conditions.past_anticipated_commitments = past_anticipated_commitments;
data
}
fn make_data_anticipated_with_durations(
thermals: Vec<Thermal>,
durations_hours: &[f64],
past_anticipated_commitments: Vec<AnticipatedCommitmentHistory>,
) -> ParsedData {
anticipated_data(thermals, durations_hours, past_anticipated_commitments)
}
fn make_data_anticipated(
thermals: Vec<Thermal>,
n_stages: usize,
past_anticipated_commitments: Vec<AnticipatedCommitmentHistory>,
) -> ParsedData {
anticipated_data(
thermals,
&vec![720.0; n_stages],
past_anticipated_commitments,
)
}
#[test]
fn test_valid_anticipated_thermal_ok() {
let thermal = make_anticipated_thermal(1, 2, None, None);
let data = make_data_anticipated(vec![thermal], 5, commitments(1, &[0.0, 0.0]));
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_lead_time_thermal_accepted() {
let thermal = make_lead_time_anticipated_thermal(1, 720.0, None, None);
let durations = [168.0, 168.0, 168.0, 168.0, 720.0, 720.0];
let data = make_data_anticipated_with_durations(
vec![thermal],
&durations,
commitments_on(1, &[0.0, 0.0, 0.0, 0.0], &durations),
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.errors()
.iter()
.any(|e| e.kind == ErrorKind::NotImplemented),
"LeadTime must no longer be rejected as NotImplemented, got: {:?}",
ctx.errors()
);
assert!(
!ctx.has_errors(),
"a valid LeadTime thermal with calendar-covered commitments must pass \
validation entirely, got: {:?}",
ctx.errors()
);
}
#[test]
fn lead_time_single_decider_passes_validation() {
let thermal = make_lead_time_anticipated_thermal(1, 744.0, None, None);
let durations = [744.0, 744.0, 744.0];
let data = make_data_anticipated_with_durations(
vec![thermal],
&durations,
commitments_on(1, &[0.0], &durations),
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"a valid single-decider LeadTime thermal must pass validation, 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 data = make_data_anticipated(vec![thermal], 5, commitments(1, &[100.0, 200.0]));
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_future_delivery_for_non_anticipated_thermal_rejected() {
let thermal = make_thermal(1, 0.0, 500.0); let mut data = make_data_anticipated(vec![thermal], 1, vec![]);
data.initial_conditions.future_anticipated_deliveries = vec![FutureAnticipatedDelivery {
thermal_id: EntityId::from(1),
delivery_start: NaiveDate::from_ymd_opt(2024, 6, 1).unwrap(),
delivery_end: NaiveDate::from_ymd_opt(2024, 6, 8).unwrap(),
min_mw: 0.0,
max_mw: 300.0,
}];
let mut ctx = ValidationContext::new();
check_future_delivery_thermal_is_anticipated(&data, &mut ctx);
assert!(ctx.has_errors());
let errors = ctx.errors();
let relevant: Vec<_> = errors
.iter()
.filter(|e| e.kind == ErrorKind::BusinessRuleViolation)
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one BusinessRuleViolation, got: {errors:?}"
);
let msg = &relevant[0].message;
assert!(
msg.contains("Thermal 1"),
"message should contain 'Thermal 1', got: {msg}"
);
assert!(
msg.contains("not an anticipated thermal"),
"message should contain 'not an anticipated thermal', got: {msg}"
);
}
#[test]
fn test_future_delivery_for_anticipated_thermal_accepted() {
let thermal = make_anticipated_thermal(1, 1, None, None);
let mut data = make_data_anticipated(vec![thermal], 1, vec![]);
data.initial_conditions.future_anticipated_deliveries = vec![FutureAnticipatedDelivery {
thermal_id: EntityId::from(1),
delivery_start: NaiveDate::from_ymd_opt(2024, 6, 1).unwrap(),
delivery_end: NaiveDate::from_ymd_opt(2024, 6, 8).unwrap(),
min_mw: 0.0,
max_mw: 300.0,
}];
let mut ctx = ValidationContext::new();
check_future_delivery_thermal_is_anticipated(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"an anticipated thermal's future delivery must not be rejected, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_over_coverage_beyond_lead_horizon_rejected() {
let thermal = make_anticipated_thermal(1, 2, None, None);
let data = make_data_anticipated(vec![thermal], 5, commitments(1, &[100.0, 200.0, 300.0]));
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("beyond the leading")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one over-coverage error, got: {errors:?}"
);
let msg = &relevant[0].message;
assert!(
msg.contains('2'),
"message should name over-covered study stage id 2, got: {msg}"
);
}
#[test]
fn test_under_coverage_gap_in_lead_horizon_rejected() {
let thermal = make_anticipated_thermal(1, 2, None, None);
let data = make_data_anticipated(vec![thermal], 5, commitments(1, &[100.0]));
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("do not tile the leading")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one under-coverage error, got: {errors:?}"
);
let msg = &relevant[0].message;
assert!(
msg.contains('1'),
"message should name the uncovered study stage id 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 data =
make_data_anticipated(vec![thermal], 5, commitments(1, &[0.0, 0.0, 0.0, 0.0, 0.0]));
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_lead_time_exceeds_study_horizon_error() {
let thermal = make_lead_time_anticipated_thermal(1, 3000.0, None, None);
let data = make_data_anticipated_with_durations(
vec![thermal],
&[168.0, 168.0, 168.0, 168.0, 720.0, 720.0],
vec![],
);
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("lead_time exceeds study horizon")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one BusinessRuleViolation with 'lead_time exceeds study horizon', got: {errors:?}"
);
let msg = &relevant[0].message;
assert!(
msg.contains("Thermal 1"),
"message should contain 'Thermal 1', got: {msg}"
);
assert!(
msg.contains("3000"),
"message should contain delta_hours 3000, got: {msg}"
);
assert!(
msg.contains("2112"),
"message should contain the total horizon hours 2112, got: {msg}"
);
}
#[test]
fn test_lead_time_equal_total_horizon_ok() {
let thermal = make_lead_time_anticipated_thermal(1, 2112.0, None, None);
let durations = [168.0, 168.0, 168.0, 168.0, 720.0, 720.0];
let data = make_data_anticipated_with_durations(
vec![thermal],
&durations,
commitments_on(1, &[0.0, 0.0, 0.0, 0.0, 0.0, 0.0], &durations),
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"delta_hours == total_horizon_hours must be accepted, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_lead_time_exceeding_horizon_with_future_delivery_lead_cap_accepted() {
let thermal = make_lead_time_anticipated_thermal(1, 3000.0, None, None);
let durations = [168.0, 168.0, 168.0, 168.0, 720.0, 720.0];
let mut data = make_data_anticipated_with_durations(vec![thermal], &durations, vec![]);
let horizon_end = stage_boundaries(&durations)[durations.len()];
data.initial_conditions.future_anticipated_deliveries = vec![FutureAnticipatedDelivery {
thermal_id: EntityId::from(1),
delivery_start: horizon_end,
delivery_end: horizon_end + TimeDelta::days(7),
min_mw: 0.0,
max_mw: 500.0,
}];
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let errors = ctx.errors();
let lead_cap_errors: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.message.contains("lead_time exceeds study horizon")
})
.collect();
assert!(
lead_cap_errors.is_empty(),
"a post-horizon-delivery thermal must be exempt from the lead-horizon cap, got: {errors:?}"
);
}
#[test]
fn test_lead_time_exceeding_horizon_without_future_delivery_still_rejected() {
let thermal = make_lead_time_anticipated_thermal(1, 3000.0, None, None);
let durations = [168.0, 168.0, 168.0, 168.0, 720.0, 720.0];
let data = make_data_anticipated_with_durations(vec![thermal], &durations, vec![]);
assert!(
data.initial_conditions
.future_anticipated_deliveries
.is_empty(),
"fixture must carry no future delivery window"
);
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("lead_time exceeds study horizon")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one BusinessRuleViolation with 'lead_time exceeds study horizon', got: {errors:?}"
);
}
#[test]
fn test_post_horizon_delivery_thermal_tiling_all_study_stages_validates() {
let thermal = make_lead_time_anticipated_thermal(1, 3000.0, None, None);
let durations = [168.0, 168.0, 168.0, 168.0, 720.0, 720.0];
let mut data = make_data_anticipated_with_durations(
vec![thermal],
&durations,
commitments_on(1, &[0.0, 0.0, 0.0, 0.0, 0.0, 0.0], &durations),
);
let horizon_end = stage_boundaries(&durations)[durations.len()];
data.initial_conditions.future_anticipated_deliveries = vec![FutureAnticipatedDelivery {
thermal_id: EntityId::from(1),
delivery_start: horizon_end,
delivery_end: horizon_end + TimeDelta::days(7),
min_mw: 0.0,
max_mw: 500.0,
}];
let mut ctx = ValidationContext::new();
check_anticipated_thermals(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"a post-horizon-delivery thermal tiling every leading study stage must validate \
cleanly through check_anticipated_thermals, got: {:?}",
ctx.errors()
);
}
#[test]
fn lead_stages_cadence_transition_emits_advisory() {
let thermal = make_anticipated_thermal(1, 2, None, None);
let durations = [168.0, 168.0, 168.0, 744.0, 744.0];
let data = make_data_anticipated_with_durations(
vec![thermal],
&durations,
commitments_on(1, &[0.0, 0.0], &durations),
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"a cadence transition is advisory-only, never an error; got: {:?}",
ctx.errors()
);
let relevant: Vec<_> = ctx
.warnings()
.into_iter()
.filter(|w| w.kind == ErrorKind::ModelQuality && w.message.contains("Thermal 1"))
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one cadence-transition advisory, got: {:?}",
ctx.warnings()
);
let msg = &relevant[0].message;
assert!(
msg.contains("lead_time"),
"advisory must cite lead_time as the physically-anchored alternative, got: {msg}"
);
assert!(
msg.contains("stage 2") && msg.contains("stage 3"),
"advisory must cite the transition stage pair, got: {msg}"
);
}
#[test]
fn lead_stages_uniform_calendar_no_advisory() {
let thermal = make_anticipated_thermal(1, 2, None, None);
let durations = [744.0, 744.0, 744.0, 744.0, 744.0];
let data = make_data_anticipated_with_durations(
vec![thermal],
&durations,
commitments_on(1, &[0.0, 0.0], &durations),
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"a uniform calendar is valid, got: {:?}",
ctx.errors()
);
assert!(
!ctx.warnings()
.iter()
.any(|w| w.kind == ErrorKind::ModelQuality && w.message.contains("cadence")),
"a uniform calendar must not trigger a cadence-transition advisory, got: {:?}",
ctx.warnings()
);
}
#[test]
fn test_lead_delivery_stage_count_lead_time_pmo_calendar() {
let durations = [168.0, 168.0, 168.0, 168.0, 720.0, 720.0];
let count = lead_delivery_stage_count(
AnticipatedConfig::LeadTime(720.0),
&durations,
durations.len(),
);
assert_eq!(count, 4);
}
#[test]
fn test_lead_delivery_stage_count_lead_time_uniform_calendar() {
let durations = [720.0; 5];
let count = lead_delivery_stage_count(
AnticipatedConfig::LeadTime(1440.0),
&durations,
durations.len(),
);
assert_eq!(count, 2);
}
#[test]
fn test_lead_delivery_stage_count_lead_stages_clamped() {
let count = lead_delivery_stage_count(AnticipatedConfig::LeadStages(2), &[720.0; 5], 5);
assert_eq!(count, 2);
}
#[test]
fn test_anticipated_lead_time_coverage_pmo_calendar() {
let thermal = make_lead_time_anticipated_thermal(1, 720.0, None, None);
let durations = [168.0, 168.0, 168.0, 168.0, 720.0, 720.0];
let data = make_data_anticipated_with_durations(
vec![thermal],
&durations,
commitments_on(1, &[0.0, 0.0, 0.0, 0.0], &durations),
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let errors = ctx.errors();
let coverage_errors: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& (e.message.contains("do not tile the leading")
|| e.message.contains("beyond the leading"))
})
.collect();
assert!(
coverage_errors.is_empty(),
"expected no coverage error, got: {errors:?}"
);
}
#[test]
fn test_anticipated_lead_time_coverage_pmo_calendar_under_coverage_rejected() {
let thermal = make_lead_time_anticipated_thermal(1, 720.0, None, None);
let durations = [168.0, 168.0, 168.0, 168.0, 720.0, 720.0];
let data = make_data_anticipated_with_durations(
vec![thermal],
&durations,
commitments_on(1, &[0.0, 0.0], &durations),
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let errors = ctx.errors();
let coverage_errors: Vec<_> = errors
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.message.contains("do not tile the leading")
})
.collect();
assert_eq!(
coverage_errors.len(),
1,
"expected exactly one under-coverage error, got: {errors:?}"
);
let msg = &coverage_errors[0].message;
assert!(
msg.contains('2') && msg.contains('3'),
"message should name the uncovered study stage ids 2 and 3, got: {msg}"
);
}
#[test]
fn test_entry_window_on_anticipated_thermal_accepted() {
let thermal = make_anticipated_thermal(1, 3, Some(4), None);
let data = make_data_anticipated(vec![thermal], 5, commitments(1, &[0.0, 0.0, 0.0]));
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"an entry window on an anticipated thermal must be accepted, got: {:?}",
ctx.errors()
);
assert!(
!ctx.errors()
.iter()
.any(|e| e.message.contains("not supported on anticipated thermals")),
"the removed window rejection must not fire"
);
}
#[test]
fn test_exit_window_on_anticipated_thermal_accepted() {
let thermal = make_anticipated_thermal(1, 3, None, Some(2));
let data = make_data_anticipated(vec![thermal], 5, commitments(1, &[0.0, 0.0, 0.0]));
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"an exit window on an anticipated thermal must be accepted, got: {:?}",
ctx.errors()
);
assert!(
!ctx.errors()
.iter()
.any(|e| e.message.contains("not supported on anticipated thermals")),
"the removed window rejection must not fire"
);
}
#[test]
fn test_nonzero_seed_outside_window_rejected() {
let thermal = make_anticipated_thermal(1, 2, Some(2), Some(4));
let data = make_data_anticipated(vec![thermal], 5, commitments(1, &[100.0, 0.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.message
.contains("outside the plant's commissioning window")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one seed-vs-window error (stage 0 only), got: {errors:?}"
);
let msg = &relevant[0].message;
assert!(
msg.contains("study stage id 0") && msg.contains("Thermal 1"),
"error must identify study stage 0 of Thermal 1, got: {msg}"
);
}
#[test]
fn test_nonzero_seed_inside_window_accepted() {
let thermal = make_anticipated_thermal(1, 1, Some(0), Some(5));
let data = make_data_anticipated(vec![thermal], 5, commitments(1, &[50.0]));
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.errors().iter().any(|e| e
.message
.contains("outside the plant's commissioning window")),
"an in-window nonzero seed must not be rejected, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_window_on_plain_thermal_accepted() {
let thermal = Thermal {
entry_stage_id: Some(1),
exit_stage_id: Some(2),
..make_thermal(1, 0.0, 100.0)
};
let data = make_data(
vec![],
vec![thermal],
vec![],
make_stages(vec![0, 1, 2]),
vec![],
vec![],
);
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"a window on a plain (non-anticipated) thermal must not error, got: {:?}",
ctx.errors()
);
assert!(
!ctx.errors()
.iter()
.any(|e| e.message.contains("not supported on anticipated thermals")),
"the anticipated-only window rejection must not fire for a plain thermal"
);
}
#[test]
fn test_anticipated_thermal_without_window_accepted() {
let thermal = make_anticipated_thermal(1, 2, None, None);
let data = make_data_anticipated(vec![thermal], 5, commitments(1, &[0.0, 0.0]));
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.errors()
.iter()
.any(|e| e.message.contains("not supported on anticipated thermals")),
"a windowless anticipated thermal must not be rejected, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_committed_value_out_of_bounds_error() {
let thermal = make_anticipated_thermal(3, 2, None, None); let data = make_data_anticipated(vec![thermal], 5, commitments(3, &[600.0, 200.0]));
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 (600 MW window only), got: {errors:?}"
);
let msg0 = &relevant[0].message;
assert!(
msg0.contains("Thermal 3"),
"message should contain 'Thermal 3', got: {msg0}"
);
assert!(
msg0.contains("2024-01-01"),
"message should identify the offending window by its start date, 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 = Thermal {
anticipated_config: Some(AnticipatedConfig::LeadStages(2)),
..make_thermal(5, 100.0, 500.0)
};
let data = make_data_anticipated(vec![thermal], 5, commitments(5, &[200.0, 50.0]));
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 (50 MW window only), got: {errors:?}"
);
let msg1 = &relevant[0].message;
assert!(
msg1.contains("Thermal 5"),
"message should contain 'Thermal 5', 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 = Thermal {
anticipated_config: Some(AnticipatedConfig::LeadStages(3)),
..make_thermal(7, 0.0, 400.0)
};
let data = make_data_anticipated(vec![thermal], 5, commitments(7, &[0.0, 0.0, 0.0]));
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"expected no errors for all-zero window values, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_committed_value_zero_accepted() {
let thermal = Thermal {
anticipated_config: Some(AnticipatedConfig::LeadStages(1)),
..make_thermal(9, 0.0, 400.0)
};
let data = make_data_anticipated(vec![thermal], 5, commitments(9, &[0.0]));
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 = Thermal {
anticipated_config: Some(AnticipatedConfig::LeadStages(1)),
..make_thermal(11, 100.0, 350.0)
};
let data = make_data_anticipated(vec![thermal], 5, commitments(11, &[400.0]));
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("400"),
"message should contain the offending value 400, got: {msg}"
);
}
#[test]
fn test_committed_value_within_tolerance_above_max_accepted() {
let thermal = Thermal {
anticipated_config: Some(AnticipatedConfig::LeadStages(1)),
..make_thermal(13, 100.0, 350.0)
};
let data = make_data_anticipated(vec![thermal], 5, commitments(13, &[350.0 + 1e-8]));
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"a value within relative tolerance of max_generation_mw must not be rejected, \
got: {:?}",
ctx.errors()
);
}
#[test]
fn test_committed_value_within_tolerance_below_min_accepted() {
let thermal = Thermal {
anticipated_config: Some(AnticipatedConfig::LeadStages(1)),
..make_thermal(14, 100.0, 350.0)
};
let data = make_data_anticipated(vec![thermal], 5, commitments(14, &[100.0 - 1e-8]));
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
!ctx.has_errors(),
"a value within relative tolerance of min_generation_mw must not be rejected, \
got: {:?}",
ctx.errors()
);
}
#[test]
fn test_committed_value_beyond_tolerance_above_max_still_rejected() {
let thermal = Thermal {
anticipated_config: Some(AnticipatedConfig::LeadStages(1)),
..make_thermal(15, 100.0, 350.0)
};
let data = make_data_anticipated(vec![thermal], 5, commitments(15, &[350.0 + 3.5e-6]));
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
let relevant: Vec<_> = ctx
.errors()
.into_iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.message.contains("outside the plant's generation bounds")
})
.collect();
assert_eq!(
relevant.len(),
1,
"a value beyond tolerance above max_generation_mw must still be rejected, \
got: {:?}",
ctx.errors()
);
}
#[test]
fn test_nonzero_value_in_bounds_accepted_k1() {
let thermal = Thermal {
anticipated_config: Some(AnticipatedConfig::LeadStages(1)),
..make_thermal(2, 0.0, 350.0)
};
let data = make_data_anticipated(vec![thermal], 5, commitments(2, &[204.5647]));
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(&data, &mut ctx);
assert!(
ctx.errors().is_empty(),
"expected no errors for an in-bounds window value, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_k2_two_in_bounds_nonzero_values_accepted() {
let thermal = make_anticipated_thermal(5, 2, None, None); let data = make_data_anticipated(vec![thermal], 5, commitments(5, &[50.0, 30.0]));
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 windows, got: {:?}",
ctx.errors()
);
}
#[test]
fn test_nonzero_in_bounds_seed_emits_no_semantic_ambiguity_warning() {
let thermal = Thermal {
anticipated_config: Some(AnticipatedConfig::LeadStages(2)),
..make_thermal(3, 0.0, 350.0)
};
let data = make_data_anticipated(vec![thermal], 5, commitments(3, &[100.0, 200.0]));
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 data = make_data_anticipated(vec![thermal], 5, commitments(7, &[0.0, 600.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.message.contains("outside the plant's generation bounds")
})
.collect();
assert_eq!(
relevant.len(),
1,
"expected exactly one bounds error (600 MW window only), got: {errors:?}"
);
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>) -> 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, 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),
},
)],
},
slack: SlackConfig {
enabled: false,
penalty: None,
},
bound_lower_affine: None,
bound_upper_affine: 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, GenericConstraint, LinearTerm, SlackConfig, VariableRef,
entities::AnticipatedConfig,
};
let thermal = Thermal {
anticipated_config: Some(AnticipatedConfig::LeadStages(2)),
..make_thermal(3, 0.0, 500.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),
},
)],
},
slack: SlackConfig {
enabled: false,
penalty: None,
},
bound_lower_affine: None,
bound_upper_affine: None,
};
let mut data = make_data_anticipated(vec![thermal], 5, commitments(3, &[0.0, 0.0]));
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, GenericConstraint, LinearTerm, SlackConfig, VariableRef,
entities::AnticipatedConfig,
};
let thermal = Thermal {
anticipated_config: Some(AnticipatedConfig::LeadStages(1)),
..make_thermal(5, 0.0, 300.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,
},
)],
},
slack: SlackConfig {
enabled: false,
penalty: None,
},
bound_lower_affine: None,
bound_upper_affine: None,
};
let mut data = make_data_anticipated(vec![thermal], 5, commitments(5, &[0.0]));
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, 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,
},
)],
},
slack: SlackConfig {
enabled: false,
penalty: None,
},
bound_lower_affine: None,
bound_upper_affine: 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:?}"
);
}
}