use cobre_core::{VariableRef, temporal::BlockMode};
use super::super::{ErrorKind, ValidationContext, schema::ParsedData};
pub(super) fn check_per_block_storage_interior_reference(
data: &ParsedData,
ctx: &mut ValidationContext,
) {
for stage in data.stages.stages.iter().filter(|s| s.id >= 0) {
let k = stage.blocks.len();
let stage_id = stage.id;
for constraint in &data.generic_constraints {
if !constraint_active_on_stage(data, constraint.id.0, stage_id) {
continue;
}
for term in &constraint.expression.terms {
validate_block_ref(
constraint,
&term.variable,
k,
stage_id,
stage.block_mode,
ctx,
);
}
}
}
}
fn constraint_active_on_stage(data: &ParsedData, constraint_id: i32, stage_id: i32) -> bool {
data.generic_constraint_bounds
.iter()
.any(|r| r.constraint_id == constraint_id && r.stage_id == stage_id)
}
#[derive(Clone, Copy)]
enum StorageRef {
Initial(Option<usize>),
Final(Option<usize>),
}
fn validate_block_ref(
constraint: &cobre_core::GenericConstraint,
variable: &VariableRef,
k: usize,
stage_id: i32,
block_mode: BlockMode,
ctx: &mut ValidationContext,
) {
match variable {
VariableRef::HydroStorageInitial { block_id, .. } => {
validate_storage_ref(
constraint,
StorageRef::Initial(*block_id),
k,
stage_id,
block_mode,
ctx,
);
}
VariableRef::HydroStorageFinal { block_id, .. } => {
validate_storage_ref(
constraint,
StorageRef::Final(*block_id),
k,
stage_id,
block_mode,
ctx,
);
}
VariableRef::HydroEvaporation {
block_id: Some(b), ..
} if *b >= k => {
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"constraints/generic_constraints.json",
Some(format!("constraint[id={}]", constraint.id.0)),
format!(
"Constraint \"{}\": per-block evaporation reference \
`hydro_evaporation({b})` at stage {stage_id} references block {b} \
which does not exist at stage {stage_id} (K = {k})",
constraint.name
),
);
}
VariableRef::HydroEvaporation { block_id: None, .. }
if block_mode == BlockMode::Chronological && k > 1 =>
{
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"constraints/generic_constraints.json",
Some(format!("constraint[id={}]", constraint.id.0)),
format!(
"Constraint \"{}\": stage-level `hydro_evaporation` at chronological \
stage {stage_id} is ambiguous — evaporation is per-block there \
(K = {k}); name a block, e.g. `hydro_evaporation(<hydro>, 0)`",
constraint.name
),
);
}
_ => {}
}
}
fn validate_storage_ref(
constraint: &cobre_core::GenericConstraint,
storage: StorageRef,
k: usize,
stage_id: i32,
block_mode: BlockMode,
ctx: &mut ValidationContext,
) {
let (accessor, block_id) = match storage {
StorageRef::Initial(b) => ("hydro_storage_initial", b),
StorageRef::Final(b) => ("hydro_storage_final", b),
};
if let Some(b) = block_id
&& b >= k
{
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"constraints/generic_constraints.json",
Some(format!("constraint[id={}]", constraint.id.0)),
format!(
"Constraint \"{}\": per-block storage reference `{accessor}({b})` at \
stage {stage_id} references block {b} which does not exist at \
stage {stage_id} (K = {k})",
constraint.name
),
);
return;
}
match block_mode {
BlockMode::Chronological => {}
BlockMode::Parallel => {
if k <= 1 {
return;
}
let interior = match (storage, block_id) {
(_, None) => false,
(StorageRef::Initial(_), Some(b)) => boundary_is_interior(b, k),
(StorageRef::Final(_), Some(b)) => boundary_is_interior(b + 1, k),
};
if interior {
let block_label = match block_id {
Some(b) => format!("{accessor}({b})"),
None => format!("{accessor}(all blocks)"),
};
ctx.add_error(
ErrorKind::BusinessRuleViolation,
"constraints/generic_constraints.json",
Some(format!("constraint[id={}]", constraint.id.0)),
format!(
"Constraint \"{}\": per-block storage reference `{block_label}` at \
stage {stage_id} resolves to an interior boundary, which requires \
chronological block mode (stage {stage_id} is parallel with {k} blocks)",
constraint.name
),
);
}
}
}
}
fn boundary_is_interior(k: usize, num_blocks: usize) -> bool {
k > 0 && k < num_blocks
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod tests {
use cobre_core::{
ConstraintExpression, EntityId, GenericConstraint, LinearTerm, SlackConfig, VariableRef,
temporal::{Block, BlockMode},
};
use super::super::test_support::*;
use super::super::validate_semantic_hydro_thermal;
use crate::ValidationEntry;
use crate::constraints::GenericConstraintBoundsRow;
use crate::validation::schema::ParsedData;
use crate::validation::{ErrorKind, ValidationContext};
fn make_blocks(k: usize) -> Vec<Block> {
(0..k)
.map(|i| Block {
index: i,
name: format!("B{i}"),
duration_hours: 168.0,
})
.collect()
}
fn make_data_storage_ref(block_mode: BlockMode, k: usize, variable: VariableRef) -> ParsedData {
let mut data = make_data(
vec![make_hydro(1, None)],
vec![],
vec![],
make_stages(vec![0]),
vec![],
vec![],
);
data.stages.stages[0].block_mode = block_mode;
data.stages.stages[0].blocks = make_blocks(k);
data.generic_constraints = vec![GenericConstraint {
id: EntityId::from(1),
name: "storage_constraint".to_string(),
description: None,
expression: ConstraintExpression {
terms: vec![LinearTerm::literal(1.0, variable)],
},
slack: SlackConfig {
enabled: false,
penalty: None,
},
bound_lower_affine: None,
bound_upper_affine: None,
}];
data.generic_constraint_bounds = vec![GenericConstraintBoundsRow {
constraint_id: 1,
stage_id: 0,
block_id: None,
bound_lower: Some(0.0),
bound_upper: None,
}];
data
}
fn evaporation(block_id: Option<usize>) -> VariableRef {
VariableRef::HydroEvaporation {
hydro_id: EntityId::from(1),
block_id,
}
}
fn interior_errors(data: &ParsedData) -> Vec<ValidationEntry> {
let mut ctx = ValidationContext::new();
validate_semantic_hydro_thermal(data, &mut ctx);
ctx.errors()
.iter()
.filter(|e| {
e.kind == ErrorKind::BusinessRuleViolation
&& e.file
.to_string_lossy()
.contains("constraints/generic_constraints.json")
})
.map(|e| (*e).clone())
.collect()
}
fn initial(block_id: Option<usize>) -> VariableRef {
VariableRef::HydroStorageInitial {
hydro_id: EntityId::from(1),
block_id,
}
}
fn final_(block_id: Option<usize>) -> VariableRef {
VariableRef::HydroStorageFinal {
hydro_id: EntityId::from(1),
block_id,
}
}
#[test]
fn parallel_k3_interior_initial_rejected() {
let data = make_data_storage_ref(BlockMode::Parallel, 3, initial(Some(1)));
let errors = interior_errors(&data);
assert_eq!(errors.len(), 1, "expected one error, got: {errors:?}");
let msg = &errors[0].message;
assert!(
msg.contains("storage_constraint") && msg.contains("interior boundary"),
"message should name the constraint and the interior requirement, got: {msg}"
);
assert!(
msg.contains("parallel with 3 blocks"),
"message should state the parallel mode and block count, got: {msg}"
);
}
#[test]
fn parallel_k3_interior_final_rejected() {
let data = make_data_storage_ref(BlockMode::Parallel, 3, final_(Some(0)));
let errors = interior_errors(&data);
assert_eq!(errors.len(), 1, "expected one error, got: {errors:?}");
}
#[test]
fn parallel_k3_endpoint_initial_accepted() {
let data = make_data_storage_ref(BlockMode::Parallel, 3, initial(Some(0)));
assert!(interior_errors(&data).is_empty());
}
#[test]
fn parallel_k3_endpoint_final_accepted() {
let data = make_data_storage_ref(BlockMode::Parallel, 3, final_(Some(2)));
assert!(interior_errors(&data).is_empty());
}
#[test]
fn parallel_k3_none_initial_accepted() {
let data = make_data_storage_ref(BlockMode::Parallel, 3, initial(None));
assert!(interior_errors(&data).is_empty());
}
#[test]
fn parallel_k3_none_final_accepted() {
let data = make_data_storage_ref(BlockMode::Parallel, 3, final_(None));
assert!(interior_errors(&data).is_empty());
}
#[test]
fn parallel_k1_all_references_accepted() {
for variable in [
initial(Some(0)),
final_(Some(0)),
initial(None),
final_(None),
] {
let data = make_data_storage_ref(BlockMode::Parallel, 1, variable);
assert!(
interior_errors(&data).is_empty(),
"K=1 parallel reference must be accepted"
);
}
}
#[test]
fn chronological_k3_all_references_accepted() {
for variable in [
initial(Some(0)),
initial(Some(1)),
initial(Some(2)),
final_(Some(0)),
final_(Some(1)),
final_(Some(2)),
initial(None),
final_(None),
] {
let data = make_data_storage_ref(BlockMode::Chronological, 3, variable);
assert!(
interior_errors(&data).is_empty(),
"chronological reference must be accepted"
);
}
}
#[test]
fn parallel_k3_out_of_range_initial_rejected() {
let data = make_data_storage_ref(BlockMode::Parallel, 3, initial(Some(5)));
let errors = interior_errors(&data);
assert_eq!(errors.len(), 1, "expected one error, got: {errors:?}");
let msg = &errors[0].message;
assert!(
msg.contains("block 5 which does not exist") && msg.contains("K = 3"),
"message should be the out-of-range message, got: {msg}"
);
}
#[test]
fn parallel_k3_out_of_range_final_rejected() {
let data = make_data_storage_ref(BlockMode::Parallel, 3, final_(Some(5)));
let errors = interior_errors(&data);
assert_eq!(errors.len(), 1, "expected one error, got: {errors:?}");
let msg = &errors[0].message;
assert!(
msg.contains("block 5 which does not exist") && msg.contains("K = 3"),
"message should be the out-of-range message, got: {msg}"
);
}
#[test]
fn chronological_k3_out_of_range_initial_rejected() {
let data = make_data_storage_ref(BlockMode::Chronological, 3, initial(Some(5)));
let errors = interior_errors(&data);
assert_eq!(errors.len(), 1, "expected one error, got: {errors:?}");
assert!(errors[0].message.contains("block 5 which does not exist"));
}
#[test]
fn chronological_k3_out_of_range_final_rejected() {
let data = make_data_storage_ref(BlockMode::Chronological, 3, final_(Some(5)));
let errors = interior_errors(&data);
assert_eq!(errors.len(), 1, "expected one error, got: {errors:?}");
assert!(errors[0].message.contains("block 5 which does not exist"));
}
#[test]
fn parallel_k3_interior_ref_on_inactive_constraint_accepted() {
let mut data = make_data_storage_ref(BlockMode::Parallel, 3, initial(Some(1)));
data.generic_constraint_bounds.clear();
assert!(
interior_errors(&data).is_empty(),
"inactive (constraint, stage) pair must be skipped"
);
}
#[test]
fn mixed_mode_interior_ref_active_only_on_chronological_stage_accepted() {
let mut data = make_data_storage_ref(BlockMode::Chronological, 3, initial(Some(1)));
data.stages.stages.push({
let mut s = data.stages.stages[0].clone();
s.id = 1;
s.block_mode = BlockMode::Parallel;
s
});
assert!(
interior_errors(&data).is_empty(),
"constraint inactive on the parallel stage must not be flagged"
);
}
#[test]
fn parallel_k3_evaporation_block_accepted() {
let data = make_data_storage_ref(BlockMode::Parallel, 3, evaporation(Some(2)));
assert!(interior_errors(&data).is_empty());
}
#[test]
fn evaporation_bare_accepted_in_parallel() {
let data = make_data_storage_ref(BlockMode::Parallel, 3, evaporation(None));
assert!(interior_errors(&data).is_empty());
}
#[test]
fn evaporation_bare_accepted_in_chronological_k1() {
let data = make_data_storage_ref(BlockMode::Chronological, 1, evaporation(None));
assert!(interior_errors(&data).is_empty());
}
#[test]
fn evaporation_block_accepted_in_chronological() {
let data = make_data_storage_ref(BlockMode::Chronological, 3, evaporation(Some(0)));
assert!(interior_errors(&data).is_empty());
}
#[test]
fn evaporation_bare_rejected_in_chronological_multiblock() {
let data = make_data_storage_ref(BlockMode::Chronological, 3, evaporation(None));
let errors = interior_errors(&data);
assert_eq!(errors.len(), 1, "expected one error, got: {errors:?}");
assert!(
errors[0].message.contains("ambiguous") && errors[0].message.contains("name a block"),
"message should ask for an explicit block, got: {}",
errors[0].message
);
}
#[test]
fn evaporation_out_of_range_block_rejected() {
let data = make_data_storage_ref(BlockMode::Parallel, 3, evaporation(Some(5)));
let errors = interior_errors(&data);
assert_eq!(errors.len(), 1, "expected one error, got: {errors:?}");
let msg = &errors[0].message;
assert!(
msg.contains("hydro_evaporation(5)") && msg.contains("block 5 which does not exist"),
"message should be the evaporation out-of-range message, got: {msg}"
);
}
}