use cobre_core::{
ConstraintExpression, ConstraintSense, EntityId, GenericConstraint, LinearTerm, SlackConfig,
VariableRef,
};
use serde::Deserialize;
use std::collections::HashSet;
use std::path::Path;
use crate::LoadError;
#[derive(Deserialize)]
struct RawGenericConstraintsFile {
#[serde(rename = "$schema")]
_schema: Option<String>,
constraints: Vec<RawConstraint>,
}
#[derive(Deserialize)]
struct RawConstraint {
id: i32,
name: String,
description: Option<String>,
expression: String,
sense: String,
slack: RawSlackConfig,
}
#[derive(Deserialize)]
struct RawSlackConfig {
enabled: bool,
penalty: Option<f64>,
}
pub fn parse_generic_constraints(path: &Path) -> Result<Vec<GenericConstraint>, LoadError> {
let raw_text = std::fs::read_to_string(path).map_err(|e| LoadError::io(path, e))?;
let raw: RawGenericConstraintsFile =
serde_json::from_str(&raw_text).map_err(|e| LoadError::parse(path, e.to_string()))?;
validate_raw(&raw, path)?;
convert(raw, path)
}
fn validate_raw(raw: &RawGenericConstraintsFile, path: &Path) -> Result<(), LoadError> {
validate_no_duplicate_ids(&raw.constraints, path)?;
for (i, constraint) in raw.constraints.iter().enumerate() {
validate_sense(&constraint.sense, i, path)?;
validate_slack(&constraint.slack, i, path)?;
parse_expression(&constraint.expression).map_err(|msg| LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("constraints[{i}].expression"),
message: msg,
})?;
}
Ok(())
}
fn validate_no_duplicate_ids(constraints: &[RawConstraint], path: &Path) -> Result<(), LoadError> {
let mut seen: HashSet<i32> = HashSet::new();
for (i, constraint) in constraints.iter().enumerate() {
if !seen.insert(constraint.id) {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("constraints[{i}].id"),
message: format!("duplicate id {} in constraints array", constraint.id),
});
}
}
Ok(())
}
fn validate_sense(sense: &str, constraint_index: usize, path: &Path) -> Result<(), LoadError> {
match sense {
">=" | "<=" | "==" => Ok(()),
other => Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("constraints[{constraint_index}].sense"),
message: format!("unknown variant \"{other}\": expected one of \">=\", \"<=\", \"==\""),
}),
}
}
fn validate_slack(
slack: &RawSlackConfig,
constraint_index: usize,
path: &Path,
) -> Result<(), LoadError> {
if slack.enabled {
match slack.penalty {
None => {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("constraints[{constraint_index}].slack.penalty"),
message: "slack.enabled is true but slack.penalty is absent".to_string(),
});
}
Some(p) if p <= 0.0 => {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("constraints[{constraint_index}].slack.penalty"),
message: format!("slack.penalty must be > 0.0 when enabled, got {p}"),
});
}
Some(_) => {}
}
}
Ok(())
}
fn convert(
raw: RawGenericConstraintsFile,
path: &Path,
) -> Result<Vec<GenericConstraint>, LoadError> {
let mut result = Vec::with_capacity(raw.constraints.len());
for (i, c) in raw.constraints.into_iter().enumerate() {
let expression = parse_expression(&c.expression).map_err(|msg| LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("constraints[{i}].expression"),
message: msg,
})?;
let sense = match c.sense.as_str() {
">=" => ConstraintSense::GreaterEqual,
"<=" => ConstraintSense::LessEqual,
"==" => ConstraintSense::Equal,
other => {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("constraints[{i}].sense"),
message: format!("unknown sense value \"{other}\""),
});
}
};
let slack = SlackConfig {
enabled: c.slack.enabled,
penalty: c.slack.penalty,
};
result.push(GenericConstraint {
id: EntityId::from(c.id),
name: c.name,
description: c.description,
expression,
sense,
slack,
});
}
result.sort_by_key(|gc| gc.id.0);
Ok(result)
}
pub(crate) fn parse_expression(input: &str) -> Result<ConstraintExpression, String> {
let tokens = tokenize(input)?;
let terms = parse_terms(&tokens)?;
Ok(ConstraintExpression { terms })
}
#[derive(Debug, Clone, PartialEq)]
enum Token {
Plus,
Minus,
Star,
LParen,
RParen,
Comma,
Number(f64),
Ident(String),
}
fn tokenize(input: &str) -> Result<Vec<Token>, String> {
let mut tokens = Vec::new();
let chars: Vec<char> = input.chars().collect();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c.is_ascii_whitespace() {
i += 1;
continue;
}
match c {
'+' => {
tokens.push(Token::Plus);
i += 1;
}
'-' => {
tokens.push(Token::Minus);
i += 1;
}
'*' => {
tokens.push(Token::Star);
i += 1;
}
'(' => {
tokens.push(Token::LParen);
i += 1;
}
')' => {
tokens.push(Token::RParen);
i += 1;
}
',' => {
tokens.push(Token::Comma);
i += 1;
}
c if c.is_ascii_digit() || c == '.' => {
let start = i;
while i < chars.len()
&& (chars[i].is_ascii_digit()
|| chars[i] == '.'
|| chars[i] == 'e'
|| chars[i] == 'E'
|| ((chars[i] == '+' || chars[i] == '-')
&& i > start
&& (chars[i - 1] == 'e' || chars[i - 1] == 'E')))
{
i += 1;
}
let s: String = chars[start..i].iter().collect();
let val: f64 = s
.parse()
.map_err(|_| format!("invalid number literal \"{s}\" at position {start}"))?;
tokens.push(Token::Number(val));
}
c if c.is_alphabetic() || c == '_' => {
let start = i;
while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') {
i += 1;
}
let ident: String = chars[start..i].iter().collect();
tokens.push(Token::Ident(ident));
}
other => {
return Err(format!("unexpected character '{other}' at position {i}"));
}
}
}
Ok(tokens)
}
fn parse_terms(tokens: &[Token]) -> Result<Vec<LinearTerm>, String> {
if tokens.is_empty() {
return Err("expression must not be empty".to_string());
}
let mut terms = Vec::new();
let mut pos = 0;
let mut sign: f64 = 1.0;
if pos < tokens.len() {
match &tokens[pos] {
Token::Plus => {
pos += 1;
}
Token::Minus => {
sign = -1.0;
pos += 1;
}
_ => {}
}
}
let (term, next_pos) = parse_single_term(tokens, pos, sign)?;
terms.push(term);
pos = next_pos;
while pos < tokens.len() {
let op_sign = match &tokens[pos] {
Token::Plus => 1.0,
Token::Minus => -1.0,
other => {
return Err(format!(
"expected '+' or '-' between terms, got {other:?} at position {pos}"
));
}
};
pos += 1;
let (term, next_pos) = parse_single_term(tokens, pos, op_sign)?;
terms.push(term);
pos = next_pos;
}
Ok(terms)
}
fn parse_single_term(
tokens: &[Token],
pos: usize,
sign: f64,
) -> Result<(LinearTerm, usize), String> {
if pos >= tokens.len() {
return Err(format!(
"unexpected end of expression: expected a term at position {pos}"
));
}
match &tokens[pos] {
Token::Number(coeff_val) => {
let coefficient = coeff_val * sign;
let next = pos + 1;
if next >= tokens.len() {
return Err(format!(
"expected '*' after coefficient {coeff_val}, got end of expression"
));
}
if tokens[next] != Token::Star {
return Err(format!(
"expected '*' after coefficient {coeff_val}, got {:?}",
tokens[next]
));
}
let var_pos = next + 1;
if var_pos >= tokens.len() {
return Err("expected variable name after '*', got end of expression".to_string());
}
let (variable, end_pos) = parse_variable_ref(tokens, var_pos)?;
Ok((
LinearTerm {
coefficient,
variable,
},
end_pos,
))
}
Token::Ident(_) => {
let coefficient = sign;
let (variable, end_pos) = parse_variable_ref(tokens, pos)?;
Ok((
LinearTerm {
coefficient,
variable,
},
end_pos,
))
}
other => Err(format!(
"expected a coefficient or variable name at position {pos}, got {other:?}"
)),
}
}
fn token_f64_to_i32(v: f64) -> Option<i32> {
if v < 0.0 || v > f64::from(i32::MAX) || v.fract() != 0.0 {
return None;
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
Some(v as i32)
}
fn token_f64_to_usize(v: f64) -> Option<usize> {
if v < 0.0 || v.fract() != 0.0 {
return None;
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
Some(v as usize)
}
fn parse_variable_ref(tokens: &[Token], pos: usize) -> Result<(VariableRef, usize), String> {
let var_name = match tokens.get(pos) {
Some(Token::Ident(name)) => name.clone(),
Some(other) => {
return Err(format!(
"expected variable name, got {other:?} at position {pos}"
));
}
None => return Err("expected variable name, got end of expression".to_string()),
};
if tokens.get(pos + 1) != Some(&Token::LParen) {
return Err(format!(
"expected '(' after variable name \"{var_name}\" at position {}",
pos + 1
));
}
let entity_id = match tokens.get(pos + 2) {
Some(Token::Number(n)) => {
let n_i32 = token_f64_to_i32(*n).ok_or_else(|| {
format!(
"entity_id must be a non-negative integer, got {n} in variable \"{var_name}\""
)
})?;
EntityId::from(n_i32)
}
Some(other) => {
return Err(format!(
"expected integer entity_id in variable \"{var_name}\", got {other:?} at position {}",
pos + 2
));
}
None => {
return Err(format!(
"unexpected end of expression: expected entity_id in variable \"{var_name}\""
));
}
};
let mut cursor = pos + 3;
let block_id: Option<usize> = match tokens.get(cursor) {
Some(Token::Comma) => {
cursor += 1;
match tokens.get(cursor) {
Some(Token::Number(b)) => {
let b_usize = token_f64_to_usize(*b).ok_or_else(|| {
format!(
"block_id must be a non-negative integer, got {b} in variable \"{var_name}\""
)
})?;
cursor += 1;
Some(b_usize)
}
Some(other) => {
return Err(format!(
"expected integer block_id after comma in variable \"{var_name}\", got {other:?} at position {cursor}"
));
}
None => {
return Err(format!(
"unexpected end of expression: expected block_id in variable \"{var_name}\""
));
}
}
}
Some(Token::RParen) => None,
Some(other) => {
return Err(format!(
"expected ',' or ')' in variable \"{var_name}\" argument list, got {other:?} at position {cursor}"
));
}
None => {
return Err(format!(
"unexpected end of expression: expected ')' after entity_id in variable \"{var_name}\""
));
}
};
if tokens.get(cursor) != Some(&Token::RParen) {
return Err(format!(
"expected ')' to close variable \"{var_name}\", got {:?} at position {cursor}",
tokens.get(cursor)
));
}
cursor += 1;
let variable = build_variable_ref(&var_name, entity_id, block_id)?;
Ok((variable, cursor))
}
fn build_variable_ref(
name: &str,
entity_id: EntityId,
block_id: Option<usize>,
) -> Result<VariableRef, String> {
match name {
"hydro_storage" => {
if block_id.is_some() {
return Err(format!(
"variable \"{name}\" does not accept a block argument"
));
}
Ok(VariableRef::HydroStorage {
hydro_id: entity_id,
})
}
"hydro_evaporation" => {
if block_id.is_some() {
return Err(format!(
"variable \"{name}\" does not accept a block argument"
));
}
Ok(VariableRef::HydroEvaporation {
hydro_id: entity_id,
})
}
"hydro_withdrawal" => {
if block_id.is_some() {
return Err(format!(
"variable \"{name}\" does not accept a block argument"
));
}
Ok(VariableRef::HydroWithdrawal {
hydro_id: entity_id,
})
}
"hydro_turbined" => Ok(VariableRef::HydroTurbined {
hydro_id: entity_id,
block_id,
}),
"hydro_spillage" => Ok(VariableRef::HydroSpillage {
hydro_id: entity_id,
block_id,
}),
"hydro_diversion" => Ok(VariableRef::HydroDiversion {
hydro_id: entity_id,
block_id,
}),
"hydro_outflow" => Ok(VariableRef::HydroOutflow {
hydro_id: entity_id,
block_id,
}),
"hydro_generation" => Ok(VariableRef::HydroGeneration {
hydro_id: entity_id,
block_id,
}),
"thermal_generation" => Ok(VariableRef::ThermalGeneration {
thermal_id: entity_id,
block_id,
}),
"line_direct" => Ok(VariableRef::LineDirect {
line_id: entity_id,
block_id,
}),
"line_reverse" => Ok(VariableRef::LineReverse {
line_id: entity_id,
block_id,
}),
"bus_deficit" => Ok(VariableRef::BusDeficit {
bus_id: entity_id,
block_id,
}),
"bus_excess" => Ok(VariableRef::BusExcess {
bus_id: entity_id,
block_id,
}),
"pumping_flow" => Ok(VariableRef::PumpingFlow {
station_id: entity_id,
block_id,
}),
"pumping_power" => Ok(VariableRef::PumpingPower {
station_id: entity_id,
block_id,
}),
"contract_import" => Ok(VariableRef::ContractImport {
contract_id: entity_id,
block_id,
}),
"contract_export" => Ok(VariableRef::ContractExport {
contract_id: entity_id,
block_id,
}),
"non_controllable_generation" => Ok(VariableRef::NonControllableGeneration {
source_id: entity_id,
block_id,
}),
"non_controllable_curtailment" => Ok(VariableRef::NonControllableCurtailment {
source_id: entity_id,
block_id,
}),
other => Err(format!(
"unknown variable name \"{other}\": not one of the 19 supported LP variable types"
)),
}
}
#[cfg(test)]
#[allow(
clippy::doc_markdown,
clippy::expect_used,
clippy::panic,
clippy::too_many_lines,
clippy::unwrap_used
)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn write_json(content: &str) -> NamedTempFile {
let mut f = NamedTempFile::new().expect("tempfile");
f.write_all(content.as_bytes()).expect("write");
f
}
const VALID_JSON: &str = r#"{
"constraints": [
{
"id": 1,
"name": "min_hydro",
"expression": "hydro_generation(10) + hydro_generation(11)",
"sense": ">=",
"slack": { "enabled": false }
},
{
"id": 0,
"name": "max_thermal",
"expression": "2.5 * thermal_generation(5) - hydro_generation(3)",
"sense": "<=",
"slack": { "enabled": true, "penalty": 5000.0 }
}
]
}"#;
#[test]
fn test_expr_simple_single_term() {
let expr = parse_expression("hydro_generation(10)").unwrap();
assert_eq!(expr.terms.len(), 1);
assert!((expr.terms[0].coefficient - 1.0).abs() < f64::EPSILON);
assert_eq!(
expr.terms[0].variable,
VariableRef::HydroGeneration {
hydro_id: EntityId(10),
block_id: None,
}
);
}
#[test]
fn test_expr_addition_two_terms() {
let expr = parse_expression("hydro_generation(10) + hydro_generation(11)").unwrap();
assert_eq!(expr.terms.len(), 2);
assert!((expr.terms[0].coefficient - 1.0).abs() < f64::EPSILON);
assert_eq!(
expr.terms[0].variable,
VariableRef::HydroGeneration {
hydro_id: EntityId(10),
block_id: None,
}
);
assert!((expr.terms[1].coefficient - 1.0).abs() < f64::EPSILON);
assert_eq!(
expr.terms[1].variable,
VariableRef::HydroGeneration {
hydro_id: EntityId(11),
block_id: None,
}
);
}
#[test]
fn test_expr_coefficient_and_subtraction() {
let expr = parse_expression("2.5 * thermal_generation(5) - hydro_generation(3)").unwrap();
assert_eq!(expr.terms.len(), 2);
assert!((expr.terms[0].coefficient - 2.5).abs() < 1e-10);
assert_eq!(
expr.terms[0].variable,
VariableRef::ThermalGeneration {
thermal_id: EntityId(5),
block_id: None,
}
);
assert!((expr.terms[1].coefficient - (-1.0)).abs() < f64::EPSILON);
assert_eq!(
expr.terms[1].variable,
VariableRef::HydroGeneration {
hydro_id: EntityId(3),
block_id: None,
}
);
}
#[test]
fn test_expr_subtraction_negates_coefficient() {
let expr = parse_expression("thermal_generation(5) - hydro_generation(3)").unwrap();
assert_eq!(expr.terms.len(), 2);
assert!((expr.terms[0].coefficient - 1.0).abs() < f64::EPSILON);
assert!((expr.terms[1].coefficient - (-1.0)).abs() < f64::EPSILON);
}
#[test]
fn test_expr_block_specific_variable() {
let expr = parse_expression("hydro_turbined(5, 0)").unwrap();
assert_eq!(expr.terms.len(), 1);
assert_eq!(
expr.terms[0].variable,
VariableRef::HydroTurbined {
hydro_id: EntityId(5),
block_id: Some(0),
}
);
}
#[test]
fn test_expr_stage_only_hydro_storage() {
let expr = parse_expression("hydro_storage(7)").unwrap();
assert_eq!(expr.terms.len(), 1);
assert_eq!(
expr.terms[0].variable,
VariableRef::HydroStorage {
hydro_id: EntityId(7),
}
);
}
#[test]
fn test_expr_stage_only_with_block_is_error() {
let err = parse_expression("hydro_storage(7, 0)").unwrap_err();
assert!(
err.contains("does not accept a block argument"),
"expected block argument error, got: {err}"
);
}
#[test]
fn test_expr_unknown_variable_name() {
let err = parse_expression("invalid_var(0)").unwrap_err();
assert!(
err.contains("unknown variable name"),
"expected unknown variable error, got: {err}"
);
}
#[test]
fn test_expr_missing_closing_paren() {
let err = parse_expression("hydro_generation(10").unwrap_err();
assert!(
err.contains("expected ')'") || err.contains("unexpected end"),
"expected paren error, got: {err}"
);
}
#[test]
fn test_expr_empty_is_error() {
let err = parse_expression("").unwrap_err();
assert!(
err.contains("empty"),
"expected empty expression error, got: {err}"
);
}
#[test]
fn test_expr_all_19_variable_types_recognised() {
let cases: &[(&str, VariableRef)] = &[
(
"hydro_storage(0)",
VariableRef::HydroStorage {
hydro_id: EntityId(0),
},
),
(
"hydro_turbined(0)",
VariableRef::HydroTurbined {
hydro_id: EntityId(0),
block_id: None,
},
),
(
"hydro_spillage(0)",
VariableRef::HydroSpillage {
hydro_id: EntityId(0),
block_id: None,
},
),
(
"hydro_diversion(0)",
VariableRef::HydroDiversion {
hydro_id: EntityId(0),
block_id: None,
},
),
(
"hydro_outflow(0)",
VariableRef::HydroOutflow {
hydro_id: EntityId(0),
block_id: None,
},
),
(
"hydro_generation(0)",
VariableRef::HydroGeneration {
hydro_id: EntityId(0),
block_id: None,
},
),
(
"hydro_evaporation(0)",
VariableRef::HydroEvaporation {
hydro_id: EntityId(0),
},
),
(
"hydro_withdrawal(0)",
VariableRef::HydroWithdrawal {
hydro_id: EntityId(0),
},
),
(
"thermal_generation(0)",
VariableRef::ThermalGeneration {
thermal_id: EntityId(0),
block_id: None,
},
),
(
"line_direct(0)",
VariableRef::LineDirect {
line_id: EntityId(0),
block_id: None,
},
),
(
"line_reverse(0)",
VariableRef::LineReverse {
line_id: EntityId(0),
block_id: None,
},
),
(
"bus_deficit(0)",
VariableRef::BusDeficit {
bus_id: EntityId(0),
block_id: None,
},
),
(
"bus_excess(0)",
VariableRef::BusExcess {
bus_id: EntityId(0),
block_id: None,
},
),
(
"pumping_flow(0)",
VariableRef::PumpingFlow {
station_id: EntityId(0),
block_id: None,
},
),
(
"pumping_power(0)",
VariableRef::PumpingPower {
station_id: EntityId(0),
block_id: None,
},
),
(
"contract_import(0)",
VariableRef::ContractImport {
contract_id: EntityId(0),
block_id: None,
},
),
(
"contract_export(0)",
VariableRef::ContractExport {
contract_id: EntityId(0),
block_id: None,
},
),
(
"non_controllable_generation(0)",
VariableRef::NonControllableGeneration {
source_id: EntityId(0),
block_id: None,
},
),
(
"non_controllable_curtailment(0)",
VariableRef::NonControllableCurtailment {
source_id: EntityId(0),
block_id: None,
},
),
];
assert_eq!(cases.len(), 19, "must have exactly 19 variable types");
for (input, expected) in cases {
let expr = parse_expression(input)
.unwrap_or_else(|e| panic!("parse failed for \"{input}\": {e}"));
assert_eq!(expr.terms.len(), 1, "single term for \"{input}\"");
assert_eq!(
&expr.terms[0].variable, expected,
"wrong VariableRef for \"{input}\""
);
}
}
#[test]
fn test_parse_valid_two_constraints() {
let f = write_json(VALID_JSON);
let result = parse_generic_constraints(f.path()).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].id, EntityId(0)); assert_eq!(result[1].id, EntityId(1));
let min_hydro = &result[1];
assert_eq!(min_hydro.expression.terms.len(), 2);
assert!((min_hydro.expression.terms[0].coefficient - 1.0).abs() < f64::EPSILON);
assert_eq!(
min_hydro.expression.terms[0].variable,
VariableRef::HydroGeneration {
hydro_id: EntityId(10),
block_id: None,
}
);
assert_eq!(
min_hydro.expression.terms[1].variable,
VariableRef::HydroGeneration {
hydro_id: EntityId(11),
block_id: None,
}
);
assert_eq!(min_hydro.sense, ConstraintSense::GreaterEqual);
}
#[test]
fn test_parse_coefficient_and_subtraction_expression() {
let f = write_json(VALID_JSON);
let result = parse_generic_constraints(f.path()).unwrap();
let max_thermal = &result[0];
assert_eq!(max_thermal.expression.terms.len(), 2);
assert!((max_thermal.expression.terms[0].coefficient - 2.5).abs() < 1e-10);
assert_eq!(
max_thermal.expression.terms[0].variable,
VariableRef::ThermalGeneration {
thermal_id: EntityId(5),
block_id: None,
}
);
assert!((max_thermal.expression.terms[1].coefficient - (-1.0)).abs() < f64::EPSILON);
assert_eq!(
max_thermal.expression.terms[1].variable,
VariableRef::HydroGeneration {
hydro_id: EntityId(3),
block_id: None,
}
);
assert_eq!(max_thermal.sense, ConstraintSense::LessEqual);
}
#[test]
fn test_parse_invalid_expression_returns_schema_error() {
let json = r#"{
"constraints": [
{
"id": 0,
"name": "bad",
"expression": "invalid_var(0)",
"sense": ">=",
"slack": { "enabled": false }
}
]
}"#;
let f = write_json(json);
let err = parse_generic_constraints(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("expression"),
"field should contain 'expression', got: {field}"
);
assert!(
message.contains("unknown variable"),
"message should contain 'unknown variable', got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_parse_duplicate_ids_returns_schema_error() {
let json = r#"{
"constraints": [
{
"id": 0,
"name": "a",
"expression": "hydro_generation(0)",
"sense": ">=",
"slack": { "enabled": false }
},
{
"id": 0,
"name": "b",
"expression": "thermal_generation(1)",
"sense": "<=",
"slack": { "enabled": false }
}
]
}"#;
let f = write_json(json);
let err = parse_generic_constraints(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("id"),
"field should contain 'id', got: {field}"
);
assert!(
message.contains("duplicate"),
"message should contain 'duplicate', got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_parse_slack_enabled_without_penalty_returns_schema_error() {
let json = r#"{
"constraints": [
{
"id": 0,
"name": "a",
"expression": "hydro_generation(0)",
"sense": ">=",
"slack": { "enabled": true }
}
]
}"#;
let f = write_json(json);
let err = parse_generic_constraints(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("penalty"),
"field should contain 'penalty', got: {field}"
);
assert!(
message.contains("absent") || message.contains("enabled"),
"message should explain the issue, got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_parse_invalid_sense_returns_schema_error() {
let json = r#"{
"constraints": [
{
"id": 0,
"name": "a",
"expression": "hydro_generation(0)",
"sense": "!=",
"slack": { "enabled": false }
}
]
}"#;
let f = write_json(json);
let err = parse_generic_constraints(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, .. } => {
assert!(
field.contains("sense"),
"field should contain 'sense', got: {field}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_parse_empty_constraints_array() {
let json = r#"{ "constraints": [] }"#;
let f = write_json(json);
let result = parse_generic_constraints(f.path()).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_parse_sorted_by_id() {
let json = r#"{
"constraints": [
{
"id": 5,
"name": "c",
"expression": "hydro_generation(0)",
"sense": ">=",
"slack": { "enabled": false }
},
{
"id": 2,
"name": "b",
"expression": "thermal_generation(0)",
"sense": "<=",
"slack": { "enabled": false }
},
{
"id": 0,
"name": "a",
"expression": "line_direct(0)",
"sense": "==",
"slack": { "enabled": false }
}
]
}"#;
let f = write_json(json);
let result = parse_generic_constraints(f.path()).unwrap();
assert_eq!(result.len(), 3);
assert_eq!(result[0].id, EntityId(0));
assert_eq!(result[1].id, EntityId(2));
assert_eq!(result[2].id, EntityId(5));
}
#[test]
fn test_parse_slack_zero_penalty_returns_schema_error() {
let json = r#"{
"constraints": [
{
"id": 0,
"name": "a",
"expression": "hydro_generation(0)",
"sense": ">=",
"slack": { "enabled": true, "penalty": 0.0 }
}
]
}"#;
let f = write_json(json);
let err = parse_generic_constraints(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("penalty"),
"field should contain 'penalty', got: {field}"
);
assert!(
message.contains("> 0.0"),
"message should mention > 0.0, got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_parse_description_optional() {
let json = r#"{
"constraints": [
{
"id": 0,
"name": "nodesc",
"expression": "hydro_generation(0)",
"sense": "==",
"slack": { "enabled": false }
}
]
}"#;
let f = write_json(json);
let result = parse_generic_constraints(f.path()).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].description.is_none());
}
}