use cobre_core::{
ConstraintExpression, EntityId, GenericConstraint, Line, LinearTerm, SlackConfig, VariableRef,
};
use serde::Deserialize;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use super::named_expression_inline::{
ParsedExpression, ParsedTerm, detect_cycles, inline, validate_references_resolve,
};
use super::rhs_normalize::{RelOp, SideTerm, normalize};
use crate::LoadError;
#[cfg(test)]
use cobre_core::AffineBound;
#[derive(Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub(crate) struct RawGenericConstraintsFile {
#[serde(rename = "$schema")]
_schema: Option<String>,
constraints: Vec<RawConstraint>,
#[serde(default)]
expressions: Vec<RawNamedExpression>,
}
#[derive(Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
struct RawConstraint {
id: i32,
name: String,
description: Option<String>,
expression: String,
slack: RawSlackConfig,
}
#[derive(Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
struct RawSlackConfig {
enabled: bool,
penalty: Option<f64>,
}
#[derive(Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
struct RawNamedExpression {
name: String,
expression: String,
#[serde(rename = "description")]
_description: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LinePairOrientation {
Forward,
Reversed,
}
#[derive(Debug, Clone, Default)]
pub struct LineBusPairIndex {
by_pair: HashMap<(EntityId, EntityId), (EntityId, LinePairOrientation)>,
}
impl LineBusPairIndex {
fn resolve(
&self,
source_bus: EntityId,
target_bus: EntityId,
) -> Option<(EntityId, LinePairOrientation)> {
self.by_pair.get(&(source_bus, target_bus)).copied()
}
}
pub fn build_line_bus_pair_index(lines: &[Line]) -> Result<LineBusPairIndex, LoadError> {
let mut by_pair: HashMap<(EntityId, EntityId), (EntityId, LinePairOrientation)> =
HashMap::with_capacity(lines.len().saturating_mul(2));
for line in lines {
insert_line_pair(
&mut by_pair,
(line.source_bus_id, line.target_bus_id),
line.id,
LinePairOrientation::Forward,
)?;
insert_line_pair(
&mut by_pair,
(line.target_bus_id, line.source_bus_id),
line.id,
LinePairOrientation::Reversed,
)?;
}
Ok(LineBusPairIndex { by_pair })
}
fn insert_line_pair(
by_pair: &mut HashMap<(EntityId, EntityId), (EntityId, LinePairOrientation)>,
key: (EntityId, EntityId),
line_id: EntityId,
orientation: LinePairOrientation,
) -> Result<(), LoadError> {
match by_pair.get(&key) {
Some((existing, _)) if *existing != line_id => Err(duplicate_line_pair_error(key)),
Some(_) => Ok(()),
None => {
by_pair.insert(key, (line_id, orientation));
Ok(())
}
}
}
fn duplicate_line_pair_error(key: (EntityId, EntityId)) -> LoadError {
let (a, b) = if key.0.0 <= key.1.0 {
(key.0, key.1)
} else {
(key.1, key.0)
};
LoadError::SchemaError {
path: PathBuf::from("system/lines.json"),
field: format!("lines[source_bus={a},target_bus={b}]"),
message: format!(
"buses {a} and {b} are connected by more than one line, so \
(source_bus={a}, target_bus={b}) does not identify a single line; \
reference the intended line by its id, or sum the lines with a named expression"
),
}
}
#[allow(clippy::implicit_hasher)]
pub fn parse_generic_constraints(
path: &Path,
name_to_id: &HashMap<String, EntityId>,
line_index: &LineBusPairIndex,
) -> 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, name_to_id, line_index)?;
let table = parse_named_expressions(&raw.expressions, path, name_to_id, line_index)?;
detect_cycles(&table).map_err(|message| LoadError::SchemaError {
path: path.to_path_buf(),
field: "expressions".to_string(),
message,
})?;
validate_expression_references_resolve(&table, path)?;
convert(raw, path, name_to_id, &table, line_index)
}
fn validate_expression_references_resolve(
table: &[(String, ParsedExpression)],
path: &Path,
) -> Result<(), LoadError> {
validate_references_resolve(table).map_err(|(i, message)| LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("expressions[{i}].expression"),
message,
})
}
fn validate_raw(
raw: &RawGenericConstraintsFile,
path: &Path,
name_to_id: &HashMap<String, EntityId>,
line_index: &LineBusPairIndex,
) -> Result<(), LoadError> {
validate_no_duplicate_ids(&raw.constraints, path)?;
for (i, constraint) in raw.constraints.iter().enumerate() {
validate_slack(&constraint.slack, i, path)?;
parse_relation(&constraint.expression, name_to_id, line_index).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_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,
name_to_id: &HashMap<String, EntityId>,
table: &[(String, ParsedExpression)],
line_index: &LineBusPairIndex,
) -> Result<Vec<GenericConstraint>, LoadError> {
let mut result = Vec::with_capacity(raw.constraints.len());
for (i, c) in raw.constraints.into_iter().enumerate() {
let field = || format!("constraints[{i}].expression");
let expression_err = |message: String| LoadError::SchemaError {
path: path.to_path_buf(),
field: field(),
message,
};
let relation =
parse_relation(&c.expression, name_to_id, line_index).map_err(expression_err)?;
let (terms, bound_lower_affine, bound_upper_affine) = match relation {
ParsedRelation::Plain(parsed) => {
let terms = inline(&parsed, table).map_err(expression_err)?;
(terms, None, None)
}
ParsedRelation::Relational { lhs, op, rhs } => {
let lhs = resolve_split_side(lhs, name_to_id, table).map_err(expression_err)?;
let rhs = resolve_split_side(rhs, name_to_id, table).map_err(expression_err)?;
normalize(lhs, rhs, op)
}
};
let slack = SlackConfig {
enabled: c.slack.enabled,
penalty: c.slack.penalty,
};
let mut expression = ConstraintExpression { terms };
expression.canonicalize();
result.push(GenericConstraint {
id: EntityId::from(c.id),
name: c.name,
description: c.description,
expression,
slack,
bound_lower_affine,
bound_upper_affine,
});
}
result.sort_by_key(|gc| gc.id.0);
Ok(result)
}
fn parse_expression_terms(
input: &str,
name_to_id: &HashMap<String, EntityId>,
line_index: &LineBusPairIndex,
) -> Result<ParsedExpression, String> {
let tokens = tokenize(input)?;
parse_terms(&tokens, name_to_id, line_index)
}
fn parse_named_expressions(
entries: &[RawNamedExpression],
path: &Path,
name_to_id: &HashMap<String, EntityId>,
line_index: &LineBusPairIndex,
) -> Result<Vec<(String, ParsedExpression)>, LoadError> {
let mut table: Vec<(String, ParsedExpression)> = Vec::with_capacity(entries.len());
let mut seen: HashSet<&str> = HashSet::new();
for (i, entry) in entries.iter().enumerate() {
if !seen.insert(entry.name.as_str()) {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("expressions[{i}].name"),
message: format!(
"duplicate named expression \"{}\" in expressions array",
entry.name
),
});
}
if name_to_id.contains_key(&entry.name) {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("expressions[{i}].name"),
message: format!(
"name \"{}\" is declared as both a scalar parameter and a named expression; the \"@name\" namespace is shared",
entry.name
),
});
}
let parsed = parse_expression_terms(&entry.expression, name_to_id, line_index).map_err(
|message| LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("expressions[{i}].expression"),
message,
},
)?;
table.push((entry.name.clone(), parsed));
}
Ok(table)
}
#[derive(Debug, Clone, PartialEq)]
enum Token {
Plus,
Minus,
Star,
LParen,
RParen,
Comma,
Equals,
Le,
Ge,
EqEq,
Number(f64),
Ident(String),
ParamRef(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;
}
'=' => {
if chars.get(i + 1) == Some(&'=') {
tokens.push(Token::EqEq);
i += 2;
} else {
tokens.push(Token::Equals);
i += 1;
}
}
'<' => {
if chars.get(i + 1) == Some(&'=') {
tokens.push(Token::Le);
i += 2;
} else {
return Err(format!(
"bare '<' at position {i} is not a valid operator; use '<=' for a relational bound"
));
}
}
'>' => {
if chars.get(i + 1) == Some(&'=') {
tokens.push(Token::Ge);
i += 2;
} else {
return Err(format!(
"bare '>' at position {i} is not a valid operator; use '>=' for a relational bound"
));
}
}
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));
}
'@' => {
let at_pos = i;
i += 1;
if i >= chars.len() || !(chars[i].is_alphabetic() || chars[i] == '_') {
return Err(format!(
"@ must be followed by an identifier at position {at_pos}"
));
}
let start = i;
while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') {
i += 1;
}
let name: String = chars[start..i].iter().collect();
tokens.push(Token::ParamRef(name));
}
other => {
return Err(format!("unexpected character '{other}' at position {i}"));
}
}
}
Ok(tokens)
}
fn parse_terms(
tokens: &[Token],
name_to_id: &HashMap<String, EntityId>,
line_index: &LineBusPairIndex,
) -> Result<ParsedExpression, 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;
}
Token::Number(_)
| Token::Ident(_)
| Token::ParamRef(_)
| Token::Star
| Token::LParen
| Token::RParen
| Token::Comma
| Token::Equals
| Token::Le
| Token::Ge
| Token::EqEq => {}
}
}
let (mut group, next_pos) = parse_single_term(tokens, pos, sign, name_to_id, line_index)?;
terms.append(&mut group);
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 (mut group, next_pos) =
parse_single_term(tokens, pos, op_sign, name_to_id, line_index)?;
terms.append(&mut group);
pos = next_pos;
}
Ok(terms)
}
fn parse_single_term(
tokens: &[Token],
pos: usize,
sign: f64,
name_to_id: &HashMap<String, EntityId>,
line_index: &LineBusPairIndex,
) -> Result<(Vec<ParsedTerm>, 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 literal = *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 after_star = next + 1;
if after_star >= tokens.len() {
return Err(
"expected variable name, @parameter, or '(' after '*', got end of expression"
.to_string(),
);
}
if tokens[after_star] == Token::LParen {
let (group, end_pos) =
parse_group_terms(tokens, after_star, name_to_id, line_index)?;
return Ok((distribute_into_group(group, literal), end_pos));
}
if let Token::ParamRef(name) = &tokens[after_star] {
let star2 = after_star + 1;
if star2 < tokens.len() && tokens[star2] == Token::Star {
let var_pos = star2 + 1;
if tokens.get(var_pos) == Some(&Token::LParen) {
return Err(param_scaled_group_error(name));
}
let id = resolve_param_ref(name, name_to_id)?;
if let Some(Token::ParamRef(second)) = tokens.get(var_pos) {
return Err(format!(
"only one @parameter reference is allowed per term; found \"@{name}\" and \"@{second}\""
));
}
let (variable, orientation, end_pos) =
parse_variable_ref(tokens, var_pos, line_index)?;
Ok((
vec![ParsedTerm::Flat(LinearTerm::parameter(
id,
literal * orientation,
variable,
))],
end_pos,
))
} else {
Ok((
vec![ParsedTerm::Ref {
name: name.clone(),
scale: literal,
}],
after_star + 1,
))
}
} else {
let (variable, orientation, end_pos) =
parse_variable_ref(tokens, after_star, line_index)?;
Ok((
vec![ParsedTerm::Flat(LinearTerm::literal(
literal * orientation,
variable,
))],
end_pos,
))
}
}
Token::ParamRef(name) => {
let star = pos + 1;
if star < tokens.len() && tokens[star] == Token::Star {
let var_pos = star + 1;
if tokens.get(var_pos) == Some(&Token::LParen) {
return Err(param_scaled_group_error(name));
}
let id = resolve_param_ref(name, name_to_id)?;
if var_pos >= tokens.len() {
return Err(format!(
"parameter \"@{name}\" must multiply a variable, e.g. \"@{name} * hydro_generation(0)\""
));
}
if let Token::ParamRef(second) = &tokens[var_pos] {
return Err(format!(
"only one @parameter reference is allowed per term; found \"@{name}\" and \"@{second}\""
));
}
let (variable, orientation, end_pos) =
parse_variable_ref(tokens, var_pos, line_index)?;
Ok((
vec![ParsedTerm::Flat(LinearTerm::parameter(
id,
sign * orientation,
variable,
))],
end_pos,
))
} else {
Ok((
vec![ParsedTerm::Ref {
name: name.clone(),
scale: sign,
}],
pos + 1,
))
}
}
Token::Ident(_) => {
let (variable, orientation, end_pos) = parse_variable_ref(tokens, pos, line_index)?;
Ok((
vec![ParsedTerm::Flat(LinearTerm::literal(
sign * orientation,
variable,
))],
end_pos,
))
}
Token::LParen => {
let (group, end_pos) = parse_group_terms(tokens, pos, name_to_id, line_index)?;
Ok((distribute_into_group(group, sign), end_pos))
}
other => Err(format!(
"expected a coefficient, variable name, or '(' at position {pos}, got {other:?}"
)),
}
}
fn parse_group_terms(
tokens: &[Token],
pos: usize,
name_to_id: &HashMap<String, EntityId>,
line_index: &LineBusPairIndex,
) -> Result<(Vec<ParsedTerm>, usize), String> {
let mut cursor = pos + 1;
if tokens.get(cursor) == Some(&Token::RParen) {
return Err(format!("empty parenthesized group at position {pos}"));
}
let mut sign: f64 = 1.0;
match tokens.get(cursor) {
Some(Token::Plus) => cursor += 1,
Some(Token::Minus) => {
sign = -1.0;
cursor += 1;
}
_ => {}
}
let (mut terms, next_pos) = parse_single_term(tokens, cursor, sign, name_to_id, line_index)?;
cursor = next_pos;
loop {
match tokens.get(cursor) {
Some(Token::RParen) => {
cursor += 1;
break;
}
Some(Token::Plus | Token::Minus) => {
let op_sign = if tokens[cursor] == Token::Plus {
1.0
} else {
-1.0
};
cursor += 1;
let (mut group, next_pos) =
parse_single_term(tokens, cursor, op_sign, name_to_id, line_index)?;
terms.append(&mut group);
cursor = next_pos;
}
Some(other) => {
return Err(format!(
"expected '+', '-', or ')' inside parenthesized group opened at position {pos}, got {other:?} at position {cursor}"
));
}
None => {
return Err(format!(
"unexpected end of expression: expected ')' to close parenthesized group opened at position {pos}"
));
}
}
}
Ok((terms, cursor))
}
fn distribute_into_group(mut terms: Vec<ParsedTerm>, coeff: f64) -> Vec<ParsedTerm> {
for term in &mut terms {
match term {
ParsedTerm::Flat(lt) => lt.scale *= coeff,
ParsedTerm::Ref { scale, .. } => *scale *= coeff,
}
}
terms
}
fn param_scaled_group_error(name: &str) -> String {
format!(
"parameter \"@{name}\" cannot scale a parenthesized group: a group takes only a literal coefficient, not \"@{name} * (...)\""
)
}
fn resolve_param_ref(
name: &str,
name_to_id: &HashMap<String, EntityId>,
) -> Result<EntityId, String> {
name_to_id.get(name).copied().ok_or_else(|| {
format!("unknown parameter \"@{name}\": no definition with this name was loaded")
})
}
#[derive(Debug)]
enum SplitTerm {
Named(ParsedTerm),
Constant(f64),
}
#[derive(Debug)]
enum ParsedRelation {
Plain(ParsedExpression),
Relational {
lhs: Vec<SplitTerm>,
op: RelOp,
rhs: Vec<SplitTerm>,
},
}
fn parse_relation(
input: &str,
name_to_id: &HashMap<String, EntityId>,
line_index: &LineBusPairIndex,
) -> Result<ParsedRelation, String> {
let tokens = tokenize(input)?;
match split_relational(&tokens)? {
None => Ok(ParsedRelation::Plain(parse_terms(
&tokens, name_to_id, line_index,
)?)),
Some((lhs_tokens, op, rhs_tokens)) => {
let lhs = parse_split_side(lhs_tokens, name_to_id, line_index)?;
let rhs = parse_split_side(rhs_tokens, name_to_id, line_index)?;
Ok(ParsedRelation::Relational { lhs, op, rhs })
}
}
}
type RelationalSplit<'a> = (&'a [Token], RelOp, &'a [Token]);
fn split_relational(tokens: &[Token]) -> Result<Option<RelationalSplit<'_>>, String> {
let mut depth: i32 = 0;
let mut found: Option<(usize, RelOp)> = None;
for (i, tok) in tokens.iter().enumerate() {
match tok {
Token::LParen => depth += 1,
Token::RParen => depth -= 1,
_ => {}
}
if depth != 0 {
continue;
}
let op = match tok {
Token::Le => RelOp::Le,
Token::Ge => RelOp::Ge,
Token::EqEq => RelOp::Eq,
_ => continue,
};
if found.is_some() {
return Err(
"a generic constraint expression carries at most one relational operator \
(<=, >=, ==); an inline double-relational range is not supported — split \
into two constraints"
.to_string(),
);
}
found = Some((i, op));
}
Ok(found.map(|(i, op)| (&tokens[..i], op, &tokens[i + 1..])))
}
fn parse_split_side(
tokens: &[Token],
name_to_id: &HashMap<String, EntityId>,
line_index: &LineBusPairIndex,
) -> Result<Vec<SplitTerm>, 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;
match tokens.first() {
Some(Token::Plus) => pos += 1,
Some(Token::Minus) => {
sign = -1.0;
pos += 1;
}
_ => {}
}
let (mut group, next_pos) = parse_split_single_term(tokens, pos, sign, name_to_id, line_index)?;
terms.append(&mut group);
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 (mut group, next_pos) =
parse_split_single_term(tokens, pos, op_sign, name_to_id, line_index)?;
terms.append(&mut group);
pos = next_pos;
}
Ok(terms)
}
fn parse_split_single_term(
tokens: &[Token],
pos: usize,
sign: f64,
name_to_id: &HashMap<String, EntityId>,
line_index: &LineBusPairIndex,
) -> Result<(Vec<SplitTerm>, usize), String> {
if let Some(Token::Number(v)) = tokens.get(pos)
&& tokens.get(pos + 1) != Some(&Token::Star)
{
return Ok((vec![SplitTerm::Constant(v * sign)], pos + 1));
}
let (parsed, next_pos) = parse_single_term(tokens, pos, sign, name_to_id, line_index)?;
Ok((parsed.into_iter().map(SplitTerm::Named).collect(), next_pos))
}
fn resolve_split_side(
raw: Vec<SplitTerm>,
name_to_id: &HashMap<String, EntityId>,
table: &[(String, ParsedExpression)],
) -> Result<Vec<SideTerm>, String> {
let mut out = Vec::with_capacity(raw.len());
for term in raw {
match term {
SplitTerm::Constant(v) => out.push(SideTerm::Constant(v)),
SplitTerm::Named(ParsedTerm::Flat(lt)) => out.push(SideTerm::Variable(lt)),
SplitTerm::Named(ParsedTerm::Ref { name, scale }) => {
if table.iter().any(|(declared, _)| declared == &name) {
let inlined = inline(&vec![ParsedTerm::Ref { name, scale }], table)?;
out.extend(inlined.into_iter().map(SideTerm::Variable));
} else {
let id = resolve_param_ref(&name, name_to_id)?;
out.push(SideTerm::Param(scale, id));
}
}
}
}
Ok(out)
}
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,
line_index: &LineBusPairIndex,
) -> Result<(VariableRef, f64, 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
));
}
if let Some(Token::Ident(arg)) = tokens.get(pos + 2)
&& (arg == "source_bus" || arg == "target_bus")
{
return parse_line_bus_pair_ref(tokens, pos, &var_name, line_index);
}
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 mut block_id: Option<usize> = None;
let mut bus_id: Option<EntityId> = None;
let mut seen_bus = false;
loop {
match tokens.get(cursor) {
Some(Token::RParen) => break,
Some(Token::Comma) => {
cursor += 1;
match tokens.get(cursor) {
Some(Token::Number(b)) => {
if seen_bus {
return Err(format!(
"positional block argument must precede the named \"bus=\" argument in variable \"{var_name}\""
));
}
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}\""
)
})?;
block_id = Some(b_usize);
cursor += 1;
}
Some(Token::Ident(name)) if name == "bus" => {
if seen_bus {
return Err(format!(
"repeated \"bus=\" argument in variable \"{var_name}\""
));
}
seen_bus = true;
cursor += 1;
if tokens.get(cursor) != Some(&Token::Equals) {
return Err(format!(
"expected '=' after \"bus\" in variable \"{var_name}\""
));
}
cursor += 1;
match tokens.get(cursor) {
Some(Token::Number(b)) => {
let b_i32 = token_f64_to_i32(*b).ok_or_else(|| {
format!(
"bus_id must be a non-negative integer, got {b} in variable \"{var_name}\""
)
})?;
bus_id = Some(EntityId::from(b_i32));
cursor += 1;
}
Some(other) => {
return Err(format!(
"expected integer bus_id after \"bus=\" in variable \"{var_name}\", got {other:?} at position {cursor}"
));
}
None => {
return Err(format!(
"unexpected end of expression: expected bus_id after \"bus=\" in variable \"{var_name}\""
));
}
}
}
Some(Token::Ident(name)) if name == "source_bus" || name == "target_bus" => {
return Err(format!(
"the (source_bus=, target_bus=) pair form is only accepted on \"line_exchange\" as its sole argument, e.g. \"line_exchange(source_bus=3, target_bus=7)\"; it cannot follow a positional argument in \"{var_name}\""
));
}
Some(Token::Ident(name)) => {
return Err(format!(
"unknown named argument \"{name}\" in variable \"{var_name}\": only \"bus\" is supported"
));
}
Some(other) => {
return Err(format!(
"expected block_id, \"bus=\", or ')' in variable \"{var_name}\" argument list, got {other:?} at position {cursor}"
));
}
None => {
return Err(format!(
"unexpected end of expression: expected an argument after ',' in variable \"{var_name}\""
));
}
}
}
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 ')' to close variable \"{var_name}\""
));
}
}
}
cursor += 1;
let variable = build_variable_ref(&var_name, entity_id, block_id, bus_id)?;
Ok((variable, 1.0, cursor))
}
fn parse_line_bus_pair_ref(
tokens: &[Token],
pos: usize,
var_name: &str,
line_index: &LineBusPairIndex,
) -> Result<(VariableRef, f64, usize), String> {
if var_name != "line_exchange" {
return Err(format!(
"the (source_bus=, target_bus=) pair form addresses a line by its endpoint buses and is only accepted on \"line_exchange\", not \"{var_name}\""
));
}
let mut source_bus: Option<EntityId> = None;
let mut target_bus: Option<EntityId> = None;
let mut cursor = pos + 2;
loop {
let arg = match tokens.get(cursor) {
Some(Token::Ident(name)) if name == "source_bus" || name == "target_bus" => {
name.clone()
}
Some(other) => {
return Err(format!(
"expected \"source_bus\" or \"target_bus\" in \"{var_name}\" pair form, got {other:?} at position {cursor}"
));
}
None => {
return Err(format!(
"unexpected end of expression in \"{var_name}\" pair form"
));
}
};
cursor += 1;
if tokens.get(cursor) != Some(&Token::Equals) {
return Err(format!(
"expected '=' after \"{arg}\" in \"{var_name}\" pair form"
));
}
cursor += 1;
let bus = match tokens.get(cursor) {
Some(Token::Number(n)) => {
let n_i32 = token_f64_to_i32(*n).ok_or_else(|| {
format!("{arg} must be a non-negative integer, got {n} in \"{var_name}\"")
})?;
EntityId::from(n_i32)
}
Some(other) => {
return Err(format!(
"expected an integer bus id after \"{arg}=\" in \"{var_name}\", got {other:?} at position {cursor}"
));
}
None => {
return Err(format!(
"unexpected end of expression: expected a bus id after \"{arg}=\" in \"{var_name}\""
));
}
};
cursor += 1;
if arg == "source_bus" {
if source_bus.is_some() {
return Err(format!(
"repeated \"source_bus=\" argument in \"{var_name}\""
));
}
source_bus = Some(bus);
} else {
if target_bus.is_some() {
return Err(format!(
"repeated \"target_bus=\" argument in \"{var_name}\""
));
}
target_bus = Some(bus);
}
match tokens.get(cursor) {
Some(Token::Comma) => cursor += 1,
Some(Token::RParen) => {
cursor += 1;
break;
}
Some(other) => {
return Err(format!(
"expected ',' or ')' in \"{var_name}\" pair form, got {other:?} at position {cursor}"
));
}
None => {
return Err(format!(
"unexpected end of expression: expected ')' to close \"{var_name}\""
));
}
}
}
let (Some(source_bus), Some(target_bus)) = (source_bus, target_bus) else {
return Err(format!(
"the \"{var_name}\" pair form requires both \"source_bus=\" and \"target_bus=\"; write \"line_exchange(source_bus=X, target_bus=Y)\""
));
};
let (line_id, orientation) = line_index.resolve(source_bus, target_bus).ok_or_else(|| {
format!("no line connects buses source_bus={source_bus} and target_bus={target_bus}")
})?;
let sign = match orientation {
LinePairOrientation::Forward => 1.0,
LinePairOrientation::Reversed => -1.0,
};
Ok((
VariableRef::LineExchange {
line_id,
block_id: None,
},
sign,
cursor,
))
}
#[allow(clippy::too_many_lines)]
fn build_variable_ref(
name: &str,
entity_id: EntityId,
block_id: Option<usize>,
bus_id: Option<EntityId>,
) -> Result<VariableRef, String> {
let variable = 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_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_evaporation" => Ok(VariableRef::HydroEvaporation {
hydro_id: entity_id,
block_id,
}),
"hydro_inflow" => Ok(VariableRef::HydroInflow {
hydro_id: entity_id,
block_id,
}),
"hydro_storage_initial" => Ok(VariableRef::HydroStorageInitial {
hydro_id: entity_id,
block_id,
}),
"hydro_storage_final" => Ok(VariableRef::HydroStorageFinal {
hydro_id: entity_id,
block_id,
}),
"hydro_turbined" => Ok(VariableRef::HydroTurbined {
hydro_id: entity_id,
block_id,
bus_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,
bus_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,
}),
"line_exchange" => Ok(VariableRef::LineExchange {
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,
}),
"anticipated_decision" => {
if block_id.is_some() {
return Err(format!(
"variable \"anticipated_decision\" is a stage-level scalar and \
does not accept a block_id — write \"anticipated_decision({})\", \
not \"anticipated_decision({}, ...)\"",
entity_id.0, entity_id.0,
));
}
Ok(VariableRef::AnticipatedDecision {
thermal_id: entity_id,
})
}
other => Err(format!(
"unknown variable name \"{other}\": not one of the 24 supported LP variable types"
)),
}?;
if bus_id.is_some()
&& !matches!(
variable,
VariableRef::HydroTurbined { .. } | VariableRef::HydroGeneration { .. }
)
{
return Err(format!(
"variable \"{name}\" does not accept a bus selector; only \"hydro_turbined\" and \"hydro_generation\" accept a \"bus=\" argument"
));
}
Ok(variable)
}
#[cfg(test)]
#[allow(
clippy::doc_markdown,
clippy::expect_used,
clippy::panic,
clippy::too_many_lines,
clippy::unwrap_used
)]
mod tests {
use super::*;
use cobre_core::CoefficientRef;
use std::fmt::Write as _;
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
}
fn lit(term: &LinearTerm) -> f64 {
match term.coefficient {
CoefficientRef::Literal(v) => v,
CoefficientRef::Parameter(_) => panic!("expected literal"),
}
}
fn parse_expression(
input: &str,
name_to_id: &HashMap<String, EntityId>,
) -> Result<ConstraintExpression, String> {
parse_expression_with_index(input, name_to_id, &LineBusPairIndex::default())
}
fn parse_expression_with_index(
input: &str,
name_to_id: &HashMap<String, EntityId>,
line_index: &LineBusPairIndex,
) -> Result<ConstraintExpression, String> {
let parsed = parse_expression_terms(input, name_to_id, line_index)?;
let terms = inline(&parsed, &[])?;
Ok(ConstraintExpression { terms })
}
fn line(id: i32, source: i32, target: i32) -> Line {
Line {
id: EntityId(id),
name: format!("L{id}"),
operational_start_date: chrono::NaiveDate::from_ymd_opt(2024, 1, 1)
.expect("valid date"),
source_bus_id: EntityId(source),
target_bus_id: EntityId(target),
entry_stage_id: None,
exit_stage_id: None,
direct_capacity_mw: 100.0,
reverse_capacity_mw: 100.0,
losses_percent: 0.0,
exchange_cost: 0.0,
}
}
fn flat(term: &ParsedTerm) -> &LinearTerm {
match term {
ParsedTerm::Flat(lt) => lt,
ParsedTerm::Ref { name, .. } => panic!("expected a Flat term, got reference @{name}"),
}
}
fn param_id(term: &LinearTerm) -> EntityId {
match term.coefficient {
CoefficientRef::Parameter(id) => id,
CoefficientRef::Literal(_) => panic!("expected Parameter coefficient"),
}
}
fn one_param_table() -> std::collections::HashMap<String, EntityId> {
let mut m = std::collections::HashMap::new();
m.insert("rho_eq".to_string(), EntityId(7));
m.insert("rho".to_string(), EntityId(7));
m
}
const VALID_JSON: &str = r#"{
"constraints": [
{
"id": 1,
"name": "min_hydro",
"expression": "hydro_generation(10) + hydro_generation(11)",
"slack": { "enabled": false }
},
{
"id": 0,
"name": "max_thermal",
"expression": "2.5 * thermal_generation(5) - hydro_generation(3)",
"slack": { "enabled": true, "penalty": 5000.0 }
}
]
}"#;
#[test]
fn test_expr_simple_single_term() {
let expr = parse_expression("hydro_generation(10)", &HashMap::new()).unwrap();
assert_eq!(expr.terms.len(), 1);
assert!((lit(&expr.terms[0]) - 1.0).abs() < f64::EPSILON);
assert_eq!(
expr.terms[0].variable,
VariableRef::HydroGeneration {
hydro_id: EntityId(10),
block_id: None,
bus_id: None,
}
);
}
#[test]
fn test_expr_addition_two_terms() {
let expr = parse_expression(
"hydro_generation(10) + hydro_generation(11)",
&HashMap::new(),
)
.unwrap();
assert_eq!(expr.terms.len(), 2);
assert!((lit(&expr.terms[0]) - 1.0).abs() < f64::EPSILON);
assert_eq!(
expr.terms[0].variable,
VariableRef::HydroGeneration {
hydro_id: EntityId(10),
block_id: None,
bus_id: None,
}
);
assert!((lit(&expr.terms[1]) - 1.0).abs() < f64::EPSILON);
assert_eq!(
expr.terms[1].variable,
VariableRef::HydroGeneration {
hydro_id: EntityId(11),
block_id: None,
bus_id: None,
}
);
}
#[test]
fn test_expr_coefficient_and_subtraction() {
let expr = parse_expression(
"2.5 * thermal_generation(5) - hydro_generation(3)",
&HashMap::new(),
)
.unwrap();
assert_eq!(expr.terms.len(), 2);
assert!((lit(&expr.terms[0]) - 2.5).abs() < 1e-10);
assert_eq!(
expr.terms[0].variable,
VariableRef::ThermalGeneration {
thermal_id: EntityId(5),
block_id: None,
}
);
assert!((lit(&expr.terms[1]) - (-1.0)).abs() < f64::EPSILON);
assert_eq!(
expr.terms[1].variable,
VariableRef::HydroGeneration {
hydro_id: EntityId(3),
block_id: None,
bus_id: None,
}
);
}
#[test]
fn test_expr_subtraction_negates_coefficient() {
let expr = parse_expression(
"thermal_generation(5) - hydro_generation(3)",
&HashMap::new(),
)
.unwrap();
assert_eq!(expr.terms.len(), 2);
assert!((lit(&expr.terms[0]) - 1.0).abs() < f64::EPSILON);
assert!((lit(&expr.terms[1]) - (-1.0)).abs() < f64::EPSILON);
}
#[test]
fn test_expr_block_specific_variable() {
let expr = parse_expression("hydro_turbined(5, 0)", &HashMap::new()).unwrap();
assert_eq!(expr.terms.len(), 1);
assert_eq!(
expr.terms[0].variable,
VariableRef::HydroTurbined {
hydro_id: EntityId(5),
block_id: Some(0),
bus_id: None,
}
);
}
#[test]
fn test_expr_line_exchange_with_block() {
let expr = parse_expression("line_exchange(0, 1)", &HashMap::new()).unwrap();
assert_eq!(expr.terms.len(), 1);
assert_eq!(
expr.terms[0].variable,
VariableRef::LineExchange {
line_id: EntityId(0),
block_id: Some(1),
}
);
}
#[test]
fn test_expr_stage_only_hydro_storage() {
let expr = parse_expression("hydro_storage(7)", &HashMap::new()).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)", &HashMap::new()).unwrap_err();
assert!(
err.contains("does not accept a block argument"),
"expected block argument error, got: {err}"
);
}
#[test]
fn test_build_hydro_inflow_no_block() {
let var = build_variable_ref("hydro_inflow", EntityId(3), None, None).unwrap();
assert_eq!(
var,
VariableRef::HydroInflow {
hydro_id: EntityId(3),
block_id: None,
}
);
}
#[test]
fn test_build_hydro_inflow_with_block() {
let var = build_variable_ref("hydro_inflow", EntityId(3), Some(0), None).unwrap();
assert_eq!(
var,
VariableRef::HydroInflow {
hydro_id: EntityId(3),
block_id: Some(0),
}
);
}
#[test]
fn test_build_hydro_storage_initial_block_none_and_some() {
let none = build_variable_ref("hydro_storage_initial", EntityId(4), None, None).unwrap();
assert_eq!(
none,
VariableRef::HydroStorageInitial {
hydro_id: EntityId(4),
block_id: None,
}
);
let some = build_variable_ref("hydro_storage_initial", EntityId(4), Some(2), None).unwrap();
assert_eq!(
some,
VariableRef::HydroStorageInitial {
hydro_id: EntityId(4),
block_id: Some(2),
}
);
}
#[test]
fn test_build_hydro_storage_final_block_none_and_some() {
let none = build_variable_ref("hydro_storage_final", EntityId(4), None, None).unwrap();
assert_eq!(
none,
VariableRef::HydroStorageFinal {
hydro_id: EntityId(4),
block_id: None,
}
);
let some = build_variable_ref("hydro_storage_final", EntityId(4), Some(1), None).unwrap();
assert_eq!(
some,
VariableRef::HydroStorageFinal {
hydro_id: EntityId(4),
block_id: Some(1),
}
);
}
#[test]
fn test_expr_storage_ramp_two_terms() {
let expr = parse_expression(
"hydro_storage_final(5, 1) - hydro_storage_initial(5, 1)",
&HashMap::new(),
)
.unwrap();
assert_eq!(expr.terms.len(), 2);
assert!((lit(&expr.terms[0]) - 1.0).abs() < f64::EPSILON);
assert_eq!(
expr.terms[0].variable,
VariableRef::HydroStorageFinal {
hydro_id: EntityId(5),
block_id: Some(1),
}
);
assert!((lit(&expr.terms[1]) - (-1.0)).abs() < f64::EPSILON);
assert_eq!(
expr.terms[1].variable,
VariableRef::HydroStorageInitial {
hydro_id: EntityId(5),
block_id: Some(1),
}
);
}
#[test]
fn test_expr_unknown_variable_name() {
let err = parse_expression("invalid_var(0)", &HashMap::new()).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", &HashMap::new()).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("", &HashMap::new()).unwrap_err();
assert!(
err.contains("empty"),
"expected empty expression error, got: {err}"
);
}
#[test]
fn tokenize_recognises_relational_operators() {
assert_eq!(tokenize("<=").unwrap(), vec![Token::Le]);
assert_eq!(tokenize(">=").unwrap(), vec![Token::Ge]);
assert_eq!(tokenize("==").unwrap(), vec![Token::EqEq]);
assert_eq!(tokenize("=").unwrap(), vec![Token::Equals]);
}
#[test]
fn tokenize_bare_relational_char_is_error() {
for input in ["hydro_generation(0) < 5", "hydro_generation(0) > 5"] {
let err = tokenize(input).unwrap_err();
assert!(
err.contains("not a valid operator"),
"expected a bare-operator error for \"{input}\", got: {err}"
);
}
}
#[test]
fn parse_relation_zero_operators_is_plain() {
let input = "hydro_generation(10) + hydro_generation(11)";
let ParsedRelation::Plain(parsed) =
parse_relation(input, &HashMap::new(), &LineBusPairIndex::default()).unwrap()
else {
panic!("expected ParsedRelation::Plain for an operator-free expression");
};
let tokens = tokenize(input).unwrap();
let direct = parse_terms(&tokens, &HashMap::new(), &LineBusPairIndex::default()).unwrap();
assert_eq!(parsed, direct);
}
#[test]
fn parse_relation_single_operator_splits() {
for (input, expected_op) in [
("hydro_generation(0) <= 5", RelOp::Le),
("hydro_generation(0) >= 5", RelOp::Ge),
("hydro_generation(0) == 5", RelOp::Eq),
] {
let ParsedRelation::Relational { lhs, op, rhs } =
parse_relation(input, &HashMap::new(), &LineBusPairIndex::default()).unwrap()
else {
panic!("expected ParsedRelation::Relational for \"{input}\"");
};
assert_eq!(op, expected_op, "wrong operator for \"{input}\"");
assert_eq!(lhs.len(), 1, "lhs for \"{input}\"");
assert_eq!(rhs.len(), 1, "rhs for \"{input}\"");
}
}
#[test]
fn parse_relation_ignores_operator_shaped_tokens_inside_parens() {
let input = "hydro_turbined(5, bus=2) <= 10";
let ParsedRelation::Relational { op, .. } =
parse_relation(input, &HashMap::new(), &LineBusPairIndex::default()).unwrap()
else {
panic!("expected ParsedRelation::Relational for \"{input}\"");
};
assert_eq!(op, RelOp::Le);
}
#[test]
fn parse_relation_two_operators_is_error() {
let err = parse_relation(
"line_exchange(3) <= 10 >= 2",
&HashMap::new(),
&LineBusPairIndex::default(),
)
.unwrap_err();
assert!(
err.contains("at most one relational operator"),
"expected the too-many-operators error, got: {err}"
);
}
#[test]
fn parse_split_side_standalone_constant() {
let tokens = tokenize("73").unwrap();
let terms =
parse_split_side(&tokens, &HashMap::new(), &LineBusPairIndex::default()).unwrap();
assert_eq!(terms.len(), 1);
match &terms[0] {
SplitTerm::Constant(v) => assert!((v - 73.0).abs() < f64::EPSILON),
SplitTerm::Named(t) => panic!("expected a Constant term, got: {t:?}"),
}
}
#[test]
fn parse_split_side_negated_standalone_constant() {
let tokens = tokenize("10 - 73").unwrap();
let terms =
parse_split_side(&tokens, &HashMap::new(), &LineBusPairIndex::default()).unwrap();
assert_eq!(terms.len(), 2);
for (term, expected) in terms.iter().zip([10.0, -73.0]) {
match term {
SplitTerm::Constant(v) => assert!((v - expected).abs() < f64::EPSILON),
SplitTerm::Named(t) => panic!("expected a Constant term, got: {t:?}"),
}
}
}
#[test]
fn ac1_inline_constant_and_variable_rhs_normalizes() {
let json = r#"{
"constraints": [
{ "id": 0, "name": "c0", "expression": "thermal_generation(5) <= 0.87 * hydro_generation(140) + 73", "slack": { "enabled": false } }
]
}"#;
let f = write_json(json);
let result =
parse_generic_constraints(f.path(), &HashMap::new(), &LineBusPairIndex::default())
.unwrap();
let hand_flattened = parse_expression(
"thermal_generation(5) - 0.87 * hydro_generation(140)",
&HashMap::new(),
)
.unwrap();
let mut hand_flattened = hand_flattened;
hand_flattened.canonicalize();
assert_eq!(result[0].expression.terms, hand_flattened.terms);
assert_eq!(
result[0].bound_upper_affine,
Some(AffineBound {
constant: 73.0,
terms: vec![]
})
);
assert_eq!(result[0].bound_lower_affine, None);
}
#[test]
fn ac2_inline_lower_bound_bare_param_reference() {
let json = r#"{
"constraints": [
{ "id": 0, "name": "c0", "expression": "line_exchange(3) >= @target", "slack": { "enabled": false } }
]
}"#;
let f = write_json(json);
let mut tbl = HashMap::new();
tbl.insert("target".to_string(), EntityId(99));
let result =
parse_generic_constraints(f.path(), &tbl, &LineBusPairIndex::default()).unwrap();
assert_eq!(
result[0].bound_lower_affine,
Some(AffineBound::single(EntityId(99)))
);
assert_eq!(result[0].bound_upper_affine, None);
}
#[test]
fn ac3_net_zero_column_drops_and_constant_bound_survives() {
let json = r#"{
"constraints": [
{ "id": 0, "name": "c0", "expression": "hydro_generation(0) - hydro_generation(0) <= 5", "slack": { "enabled": false } }
]
}"#;
let f = write_json(json);
let result =
parse_generic_constraints(f.path(), &HashMap::new(), &LineBusPairIndex::default())
.unwrap();
assert!(result[0].expression.terms.is_empty());
assert_eq!(
result[0].bound_upper_affine,
Some(AffineBound {
constant: 5.0,
terms: vec![]
})
);
assert_eq!(result[0].bound_lower_affine, None);
}
#[test]
fn decomp_shaped_decision_variable_rhs() {
let json = r#"{
"constraints": [
{ "id": 0, "name": "rse", "expression": "bus_deficit(0) <= -0.316 * bus_deficit(1) + 0.5 * bus_deficit(2) + 12352", "slack": { "enabled": false } }
]
}"#;
let f = write_json(json);
let result =
parse_generic_constraints(f.path(), &HashMap::new(), &LineBusPairIndex::default())
.unwrap();
let terms = &result[0].expression.terms;
assert_eq!(terms.len(), 3);
let find = |bus_id: i32| {
terms
.iter()
.find(|t| {
t.variable
== VariableRef::BusDeficit {
bus_id: EntityId(bus_id),
block_id: None,
}
})
.unwrap_or_else(|| panic!("expected bus_deficit({bus_id}) in {terms:?}"))
};
assert!((effective(find(0)) - 1.0).abs() < f64::EPSILON);
assert!((effective(find(1)) - 0.316).abs() < 1e-10);
assert!((effective(find(2)) - (-0.5)).abs() < 1e-10);
assert_eq!(
result[0].bound_upper_affine,
Some(AffineBound {
constant: 12352.0,
terms: vec![]
})
);
assert_eq!(result[0].bound_lower_affine, None);
}
#[test]
fn decomp_shaped_affine_param_rhs() {
let json = r#"{
"constraints": [
{ "id": 0, "name": "rne", "expression": "bus_deficit(0) <= 11000 - 0.04 * @val_demanda_3 + 0.06 * @ger_pee_11", "slack": { "enabled": false } }
]
}"#;
let f = write_json(json);
let mut tbl = HashMap::new();
tbl.insert("val_demanda_3".to_string(), EntityId(50));
tbl.insert("ger_pee_11".to_string(), EntityId(51));
let result =
parse_generic_constraints(f.path(), &tbl, &LineBusPairIndex::default()).unwrap();
assert_eq!(result[0].expression.terms.len(), 1);
assert!((lit(&result[0].expression.terms[0]) - 1.0).abs() < f64::EPSILON);
let bound = result[0]
.bound_upper_affine
.as_ref()
.expect("upper bound present");
assert!((bound.constant - 11000.0).abs() < f64::EPSILON);
assert_eq!(bound.terms.len(), 2);
assert!(bound.terms.contains(&(-0.04, EntityId(50))));
assert!(bound.terms.contains(&(0.06, EntityId(51))));
assert_eq!(result[0].bound_lower_affine, None);
}
#[test]
fn decomp_shaped_pure_constant_rhs() {
let json = r#"{
"constraints": [
{ "id": 0, "name": "fsior", "expression": "bus_deficit(0) <= 73", "slack": { "enabled": false } }
]
}"#;
let f = write_json(json);
let result =
parse_generic_constraints(f.path(), &HashMap::new(), &LineBusPairIndex::default())
.unwrap();
assert_eq!(result[0].expression.terms.len(), 1);
assert_eq!(
result[0].bound_upper_affine,
Some(AffineBound {
constant: 73.0,
terms: vec![]
})
);
assert_eq!(result[0].bound_lower_affine, None);
}
#[test]
fn test_expr_group_distribution_equals_hand_flattened() {
let grouped = parse_expression(
"2 * (hydro_generation(0) - hydro_generation(1))",
&HashMap::new(),
)
.unwrap();
let flattened = parse_expression(
"2 * hydro_generation(0) - 2 * hydro_generation(1)",
&HashMap::new(),
)
.unwrap();
assert_eq!(grouped.terms.len(), flattened.terms.len());
for (g, f) in grouped.terms.iter().zip(flattened.terms.iter()) {
assert!(
(effective(g) - effective(f)).abs() < f64::EPSILON,
"effective coefficients differ: {} vs {}",
effective(g),
effective(f)
);
assert_eq!(g.variable, f.variable);
}
}
#[test]
fn test_expr_group_distribution_positive_inner_signs() {
let expr = parse_expression(
"3 * (hydro_generation(0) + hydro_generation(1))",
&HashMap::new(),
)
.unwrap();
assert_eq!(expr.terms.len(), 2);
assert!((effective(&expr.terms[0]) - 3.0).abs() < f64::EPSILON);
assert!((effective(&expr.terms[1]) - 3.0).abs() < f64::EPSILON);
}
#[test]
fn test_expr_group_nested_distribution_multiplies_inward() {
let expr = parse_expression(
"2 * (hydro_generation(0) - 3 * (hydro_generation(1) + hydro_generation(2)))",
&HashMap::new(),
)
.unwrap();
assert_eq!(expr.terms.len(), 3);
assert!((effective(&expr.terms[0]) - 2.0).abs() < f64::EPSILON);
assert_eq!(
expr.terms[0].variable,
VariableRef::HydroGeneration {
hydro_id: EntityId(0),
block_id: None,
bus_id: None,
}
);
assert!((effective(&expr.terms[1]) - (-6.0)).abs() < f64::EPSILON);
assert_eq!(
expr.terms[1].variable,
VariableRef::HydroGeneration {
hydro_id: EntityId(1),
block_id: None,
bus_id: None,
}
);
assert!((effective(&expr.terms[2]) - (-6.0)).abs() < f64::EPSILON);
assert_eq!(
expr.terms[2].variable,
VariableRef::HydroGeneration {
hydro_id: EntityId(2),
block_id: None,
bus_id: None,
}
);
}
#[test]
fn test_expr_bare_group_implicit_unit_coefficient() {
let grouped = parse_expression(
"(hydro_generation(0) + hydro_generation(1))",
&HashMap::new(),
)
.unwrap();
let flat =
parse_expression("hydro_generation(0) + hydro_generation(1)", &HashMap::new()).unwrap();
assert_eq!(grouped.terms, flat.terms);
}
#[test]
fn test_expr_negated_bare_group() {
let expr = parse_expression(
"hydro_generation(5) - (hydro_generation(0) + hydro_generation(1))",
&HashMap::new(),
)
.unwrap();
assert_eq!(expr.terms.len(), 3);
assert!((effective(&expr.terms[1]) - (-1.0)).abs() < f64::EPSILON);
assert!((effective(&expr.terms[2]) - (-1.0)).abs() < f64::EPSILON);
}
#[test]
fn test_expr_param_scaled_group_is_rejected() {
let tbl = one_param_table();
let err = parse_expression("@rho * (hydro_generation(0) + hydro_generation(1))", &tbl)
.unwrap_err();
assert!(
err.contains("rho"),
"error should name the parameter, got: {err}"
);
assert!(
err.contains("literal coefficient"),
"error should state a group takes only a literal coefficient, got: {err}"
);
}
#[test]
fn test_expr_literal_times_param_scaled_group_is_rejected() {
let tbl = one_param_table();
let err = parse_expression(
"2.0 * @rho * (hydro_generation(0) + hydro_generation(1))",
&tbl,
)
.unwrap_err();
assert!(
err.contains("rho"),
"error should name the parameter, got: {err}"
);
assert!(
err.contains("literal coefficient"),
"error should state a group takes only a literal coefficient, got: {err}"
);
}
#[test]
fn test_expr_empty_group_is_rejected() {
let err = parse_expression("3 * ()", &HashMap::new()).unwrap_err();
assert!(
err.contains("empty parenthesized group"),
"expected empty group error, got: {err}"
);
}
#[test]
fn test_expr_unterminated_group_is_rejected() {
let err = parse_expression(
"hydro_generation(0) + (hydro_generation(1) + hydro_generation(2)",
&HashMap::new(),
)
.unwrap_err();
assert!(
err.contains("expected ')'") || err.contains("unexpected end"),
"expected unterminated-group error, got: {err}"
);
}
#[test]
fn test_expr_group_stray_token_is_rejected() {
let err = parse_expression(
"2 * (hydro_generation(0) hydro_generation(1))",
&HashMap::new(),
)
.unwrap_err();
assert!(
err.contains("expected '+', '-', or ')'"),
"expected stray-token-in-group error, got: {err}"
);
}
#[test]
fn test_expr_group_with_param_term_preserves_parameter_kind() {
let tbl = one_param_table();
let expr = parse_expression("2.0 * (@rho * hydro_generation(0))", &tbl).unwrap();
assert_eq!(expr.terms.len(), 1);
assert_eq!(param_id(&expr.terms[0]), EntityId(7));
assert!((expr.terms[0].scale - 2.0).abs() < f64::EPSILON);
}
#[test]
fn test_expr_all_21_entity_keyed_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,
bus_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,
bus_id: None,
},
),
(
"hydro_evaporation(0)",
VariableRef::HydroEvaporation {
hydro_id: EntityId(0),
block_id: None,
},
),
(
"hydro_withdrawal(0)",
VariableRef::HydroWithdrawal {
hydro_id: EntityId(0),
},
),
(
"hydro_inflow(0)",
VariableRef::HydroInflow {
hydro_id: EntityId(0),
block_id: None,
},
),
(
"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,
},
),
(
"line_exchange(0)",
VariableRef::LineExchange {
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(), 21, "must have exactly 21 variable types");
for (input, expected) in cases {
let expr = parse_expression(input, &HashMap::new())
.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 parse_bus_selector_all_four_forms() {
let cases: &[(&str, Option<usize>, Option<i32>)] = &[
("hydro_turbined(5)", None, None),
("hydro_turbined(5, 0)", Some(0), None),
("hydro_turbined(5, bus=2)", None, Some(2)),
("hydro_turbined(5, 0, bus=2)", Some(0), Some(2)),
];
for (input, expected_block, expected_bus) in cases {
let expr = parse_expression(input, &HashMap::new())
.unwrap_or_else(|e| panic!("parse failed for \"{input}\": {e}"));
assert_eq!(expr.terms.len(), 1, "single term for \"{input}\"");
match &expr.terms[0].variable {
VariableRef::HydroTurbined {
hydro_id,
block_id,
bus_id,
} => {
assert_eq!(*hydro_id, EntityId(5), "hydro_id for \"{input}\"");
assert_eq!(block_id, expected_block, "block_id for \"{input}\"");
assert_eq!(bus_id.map(|b| b.0), *expected_bus, "bus_id for \"{input}\"");
}
other => panic!("expected HydroTurbined for \"{input}\", got {other:?}"),
}
}
}
#[test]
fn parse_bus_selector_rejections() {
for expr in [
"hydro_storage(5, bus=2)",
"hydro_spillage(5, bus=2)",
"hydro_outflow(5, bus=2)",
] {
let err = parse_expression(expr, &HashMap::new()).unwrap_err();
assert!(
err.contains("hydro_turbined") && err.contains("hydro_generation"),
"expected message naming hydro_turbined/hydro_generation for \"{expr}\", got: {err}"
);
}
for expr in [
"hydro_turbined(5, foo=2)",
"hydro_turbined(5, bus=2, bus=3)",
"hydro_turbined(5, bus=2, 0)",
"hydro_turbined(5, bus=)",
"hydro_turbined(5, bus 2)",
"hydro_turbined(5, bus=2.5)",
"hydro_turbined(5, bus=-1)",
] {
assert!(
parse_expression(expr, &HashMap::new()).is_err(),
"expected Err for \"{expr}\""
);
}
let err = parse_expression("not_a_variable(5, bus=2)", &HashMap::new()).unwrap_err();
assert!(
err.contains("not_a_variable"),
"expected the unknown variable name in the message, got: {err}"
);
assert!(
!err.contains("bus selector"),
"an unknown variable must not be reported as a bus-selector error, got: {err}"
);
}
#[test]
fn line_bus_pair_forward_resolves_to_line_positive() {
let index = build_line_bus_pair_index(&[line(5, 3, 7)]).expect("index builds");
let expr = parse_expression_with_index(
"line_exchange(source_bus=3, target_bus=7)",
&HashMap::new(),
&index,
)
.expect("pair form parses");
assert_eq!(expr.terms.len(), 1);
assert_eq!(
expr.terms[0].variable,
VariableRef::LineExchange {
line_id: EntityId(5),
block_id: None,
}
);
assert!((lit(&expr.terms[0]) * expr.terms[0].scale - 1.0).abs() < f64::EPSILON);
}
#[test]
fn line_bus_pair_reversed_resolves_to_same_line_negative() {
let index = build_line_bus_pair_index(&[line(5, 3, 7)]).expect("index builds");
let expr = parse_expression_with_index(
"line_exchange(source_bus=7, target_bus=3)",
&HashMap::new(),
&index,
)
.expect("reversed pair form parses");
assert_eq!(expr.terms.len(), 1);
assert_eq!(
expr.terms[0].variable,
VariableRef::LineExchange {
line_id: EntityId(5),
block_id: None,
}
);
assert!((lit(&expr.terms[0]) * expr.terms[0].scale - (-1.0)).abs() < f64::EPSILON);
}
#[test]
fn line_bus_pair_on_wrong_variable_is_rejected() {
let index = build_line_bus_pair_index(&[line(5, 3, 7)]).expect("index builds");
for input in [
"hydro_generation(0, source_bus=3, target_bus=7)",
"hydro_generation(source_bus=3, target_bus=7)",
] {
let err = parse_expression_with_index(input, &HashMap::new(), &index).unwrap_err();
assert!(
err.contains("line_exchange"),
"message must state the pair form is only accepted on line_exchange for \"{input}\", got: {err}"
);
}
}
#[test]
fn line_bus_pair_partial_form_is_rejected() {
let index = build_line_bus_pair_index(&[line(5, 3, 7)]).expect("index builds");
for input in ["line_exchange(source_bus=3)", "line_exchange(target_bus=7)"] {
let err = parse_expression_with_index(input, &HashMap::new(), &index).unwrap_err();
assert!(
err.contains("both") && err.contains("source_bus") && err.contains("target_bus"),
"expected a both-required error for \"{input}\", got: {err}"
);
}
}
#[test]
fn line_bus_pair_no_matching_line_is_error() {
let index = build_line_bus_pair_index(&[line(5, 3, 7)]).expect("index builds");
let err = parse_expression_with_index(
"line_exchange(source_bus=1, target_bus=2)",
&HashMap::new(),
&index,
)
.unwrap_err();
assert!(
err.contains("no line connects") && err.contains('1') && err.contains('2'),
"expected an unmatched-pair error naming the buses, got: {err}"
);
}
#[test]
fn line_bus_pair_duplicate_pair_is_loud_error() {
for lines in [
[line(1, 3, 7), line(2, 3, 7)],
[line(2, 7, 3), line(1, 3, 7)],
] {
let err = build_line_bus_pair_index(&lines).unwrap_err();
match err {
LoadError::SchemaError { message, .. } => {
assert!(
message.contains("buses 3 and 7"),
"message must name buses 3 and 7: {message}"
);
assert!(
message.contains("named expression"),
"message must point at named expressions: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
}
#[test]
fn line_bus_pair_byte_identical_to_direct_form() {
let index = build_line_bus_pair_index(&[line(5, 3, 7)]).expect("index builds");
let pair = parse_expression_with_index(
"line_exchange(source_bus=3, target_bus=7)",
&HashMap::new(),
&index,
)
.expect("pair form parses");
let direct = parse_expression_with_index("line_exchange(5)", &HashMap::new(), &index)
.expect("direct form parses");
assert_eq!(pair.terms, direct.terms);
}
#[test]
fn parses_param_ref_implicit_scale() {
let tbl = one_param_table();
let expr = parse_expression("@rho_eq * hydro_generation(0)", &tbl).unwrap();
assert_eq!(expr.terms.len(), 1);
assert_eq!(param_id(&expr.terms[0]), EntityId(7));
assert!((expr.terms[0].scale - 1.0).abs() < f64::EPSILON);
assert_eq!(
expr.terms[0].variable,
VariableRef::HydroGeneration {
hydro_id: EntityId(0),
block_id: None,
bus_id: None,
}
);
}
#[test]
fn parses_param_ref_with_literal_scale() {
let tbl = one_param_table();
let expr = parse_expression("2.5 * @rho_eq * hydro_generation(0)", &tbl).unwrap();
assert_eq!(expr.terms.len(), 1);
assert_eq!(param_id(&expr.terms[0]), EntityId(7));
assert!((expr.terms[0].scale - 2.5).abs() < 1e-10);
}
#[test]
fn unknown_param_ref_returns_named_error() {
let err = parse_expression("@unknown * hydro_generation(0)", &HashMap::new()).unwrap_err();
assert!(
err.contains("unknown parameter"),
"error should contain 'unknown parameter', got: {err}"
);
assert!(
err.contains("@unknown"),
"error should name the offending ref, got: {err}"
);
}
#[test]
fn bare_reference_without_declaration_is_undeclared() {
let tbl = one_param_table();
let err = parse_expression("@rho_eq + hydro_generation(0)", &tbl).unwrap_err();
assert!(
err.contains("undeclared") && err.contains("rho_eq"),
"error should name the undeclared reference, got: {err}"
);
}
#[test]
fn param_ref_after_minus_negates_scale() {
let tbl = one_param_table();
let expr =
parse_expression("thermal_generation(5) - @rho * hydro_generation(3)", &tbl).unwrap();
assert_eq!(expr.terms.len(), 2);
assert!((lit(&expr.terms[0]) - 1.0).abs() < f64::EPSILON);
assert_eq!(param_id(&expr.terms[1]), EntityId(7));
assert!((expr.terms[1].scale - (-1.0)).abs() < f64::EPSILON);
assert_eq!(
expr.terms[1].variable,
VariableRef::HydroGeneration {
hydro_id: EntityId(3),
block_id: None,
bus_id: None,
}
);
}
#[test]
fn two_param_refs_in_one_term_is_error() {
let mut tbl = one_param_table();
tbl.insert("b".to_string(), EntityId(8));
let err = parse_expression("@rho_eq * @b * hydro_generation(0)", &tbl).unwrap_err();
assert!(
err.contains("only one @parameter reference"),
"error should say 'only one @parameter reference', got: {err}"
);
}
#[test]
fn nested_literal_and_param_ref() {
let tbl = one_param_table();
let expr = parse_expression("2.0 * @rho * hydro_generation(0)", &tbl).unwrap();
assert_eq!(expr.terms.len(), 1);
assert_eq!(param_id(&expr.terms[0]), EntityId(7));
assert!((expr.terms[0].scale - 2.0).abs() < f64::EPSILON);
}
#[test]
fn test_parse_valid_two_constraints() {
let f = write_json(VALID_JSON);
let result =
parse_generic_constraints(f.path(), &HashMap::new(), &LineBusPairIndex::default())
.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!((lit(&min_hydro.expression.terms[0]) - 1.0).abs() < f64::EPSILON);
assert_eq!(
min_hydro.expression.terms[0].variable,
VariableRef::HydroGeneration {
hydro_id: EntityId(10),
block_id: None,
bus_id: None,
}
);
assert_eq!(
min_hydro.expression.terms[1].variable,
VariableRef::HydroGeneration {
hydro_id: EntityId(11),
block_id: None,
bus_id: None,
}
);
}
#[test]
fn canonical_parse_order_invariance_hydro_generation() {
let forward = r#"{
"constraints": [
{ "id": 0, "name": "c", "expression": "hydro_generation(0) + hydro_generation(1)", "slack": { "enabled": false } }
]
}"#;
let reversed = r#"{
"constraints": [
{ "id": 0, "name": "c", "expression": "hydro_generation(1) + hydro_generation(0)", "slack": { "enabled": false } }
]
}"#;
let ff = write_json(forward);
let rf = write_json(reversed);
let a = parse_generic_constraints(ff.path(), &HashMap::new(), &LineBusPairIndex::default())
.unwrap();
let b = parse_generic_constraints(rf.path(), &HashMap::new(), &LineBusPairIndex::default())
.unwrap();
assert_eq!(a[0].expression.terms, b[0].expression.terms);
assert_eq!(
a[0].expression.terms[0].variable,
VariableRef::HydroGeneration {
hydro_id: EntityId(0),
block_id: None,
bus_id: None,
}
);
assert_eq!(
a[0].expression.terms[1].variable,
VariableRef::HydroGeneration {
hydro_id: EntityId(1),
block_id: None,
bus_id: None,
}
);
}
#[test]
fn test_parse_coefficient_and_subtraction_expression() {
let f = write_json(VALID_JSON);
let result =
parse_generic_constraints(f.path(), &HashMap::new(), &LineBusPairIndex::default())
.unwrap();
let max_thermal = &result[0];
assert_eq!(max_thermal.expression.terms.len(), 2);
assert!((lit(&max_thermal.expression.terms[0]) - (-1.0)).abs() < f64::EPSILON);
assert!((max_thermal.expression.terms[0].scale - 1.0).abs() < f64::EPSILON);
assert_eq!(
max_thermal.expression.terms[0].variable,
VariableRef::HydroGeneration {
hydro_id: EntityId(3),
block_id: None,
bus_id: None,
}
);
assert!((lit(&max_thermal.expression.terms[1]) - 2.5).abs() < 1e-10);
assert!((max_thermal.expression.terms[1].scale - 1.0).abs() < f64::EPSILON);
assert_eq!(
max_thermal.expression.terms[1].variable,
VariableRef::ThermalGeneration {
thermal_id: EntityId(5),
block_id: None,
}
);
}
#[test]
fn test_parse_param_ref_in_json_constraint() {
let json = r#"{
"constraints": [
{
"id": 0,
"name": "mixed",
"expression": "thermal_generation(5) - @rho_eq * hydro_generation(3)",
"slack": { "enabled": false }
}
]
}"#;
let f = write_json(json);
let tbl = one_param_table();
let result =
parse_generic_constraints(f.path(), &tbl, &LineBusPairIndex::default()).unwrap();
assert_eq!(result.len(), 1);
let expr = &result[0].expression;
assert_eq!(expr.terms.len(), 2);
assert_eq!(param_id(&expr.terms[0]), EntityId(7));
assert!((expr.terms[0].scale - (-1.0)).abs() < f64::EPSILON);
assert_eq!(
expr.terms[0].variable,
VariableRef::HydroGeneration {
hydro_id: EntityId(3),
block_id: None,
bus_id: None,
}
);
assert!((lit(&expr.terms[1]) - 1.0).abs() < f64::EPSILON);
assert_eq!(
expr.terms[1].variable,
VariableRef::ThermalGeneration {
thermal_id: EntityId(5),
block_id: None,
}
);
}
#[test]
fn test_parse_inline_upper_bound_resolves_to_entity_id() {
let json = r#"{
"constraints": [
{
"id": 0,
"name": "demand_cap",
"expression": "hydro_generation(3) <= @rho_eq",
"slack": { "enabled": false }
}
]
}"#;
let f = write_json(json);
let tbl = one_param_table();
let result =
parse_generic_constraints(f.path(), &tbl, &LineBusPairIndex::default()).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(
result[0].bound_upper_affine,
Some(AffineBound::single(EntityId(7)))
);
assert_eq!(result[0].bound_lower_affine, None);
}
#[test]
fn test_parse_inline_lower_bound_resolves_to_entity_id() {
let json = r#"{
"constraints": [
{
"id": 0,
"name": "demand_floor",
"expression": "hydro_generation(3) >= @rho_eq",
"slack": { "enabled": false }
}
]
}"#;
let f = write_json(json);
let tbl = one_param_table();
let result =
parse_generic_constraints(f.path(), &tbl, &LineBusPairIndex::default()).unwrap();
assert_eq!(
result[0].bound_lower_affine,
Some(AffineBound::single(EntityId(7)))
);
}
#[test]
fn test_parse_unknown_inline_bound_returns_schema_error() {
let json = r#"{
"constraints": [
{
"id": 0,
"name": "bad_bound",
"expression": "hydro_generation(0) >= @missing_param",
"slack": { "enabled": false }
}
]
}"#;
let f = write_json(json);
let err =
parse_generic_constraints(f.path(), &HashMap::new(), &LineBusPairIndex::default())
.unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("expression"),
"field should name the expression, got: {field}"
);
assert!(
message.contains("unknown parameter") && message.contains("missing_param"),
"message should be the unknown-parameter wording naming the missing \
parameter, got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_parse_bound_side_named_expression_inlines_into_merged_lhs() {
let json = r#"{
"expressions": [
{ "name": "fnese", "expression": "hydro_generation(10) + hydro_generation(11)" }
],
"constraints": [
{ "id": 0, "name": "c0", "expression": "hydro_generation(0) >= @fnese", "slack": { "enabled": false } }
]
}"#;
let f = write_json(json);
let result =
parse_generic_constraints(f.path(), &HashMap::new(), &LineBusPairIndex::default())
.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(
result[0].bound_lower_affine,
Some(AffineBound {
constant: 0.0,
terms: vec![]
}),
"an all-variable RHS supplies an explicit zero bound, not \"no bound\""
);
assert_eq!(result[0].bound_upper_affine, None);
let terms = &result[0].expression.terms;
assert_eq!(terms.len(), 3, "hydro_generation(0) plus fnese's two terms");
for hydro_id in [0, 10, 11] {
assert!(
terms.iter().any(|t| t.variable
== VariableRef::HydroGeneration {
hydro_id: EntityId(hydro_id),
block_id: None,
bus_id: None,
}),
"expected hydro_generation({hydro_id}) in the merged terms, got: {terms:?}"
);
}
}
#[test]
fn test_parse_retired_bound_upper_ref_field_is_unknown_field_error() {
let json = r#"{
"constraints": [
{
"id": 0,
"name": "a",
"expression": "hydro_generation(0)",
"slack": { "enabled": false },
"bound_upper_ref": "demanda"
}
]
}"#;
let f = write_json(json);
let err =
parse_generic_constraints(f.path(), &HashMap::new(), &LineBusPairIndex::default())
.unwrap_err();
assert!(
matches!(err, LoadError::ParseError { .. }),
"expected ParseError for the retired \"bound_upper_ref\" field, got: {err:?}"
);
}
#[test]
fn test_parse_absent_bound_refs_are_none() {
let f = write_json(VALID_JSON);
let result =
parse_generic_constraints(f.path(), &HashMap::new(), &LineBusPairIndex::default())
.unwrap();
for gc in &result {
assert_eq!(gc.bound_lower_affine, None);
assert_eq!(gc.bound_upper_affine, None);
}
}
#[test]
fn test_parse_unknown_param_returns_schema_error() {
let json = r#"{
"constraints": [
{
"id": 0,
"name": "bad_ref",
"expression": "@missing * hydro_generation(0)",
"slack": { "enabled": false }
}
]
}"#;
let f = write_json(json);
let err =
parse_generic_constraints(f.path(), &HashMap::new(), &LineBusPairIndex::default())
.unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("expression"),
"field should contain 'expression', got: {field}"
);
assert!(
message.contains("unknown parameter"),
"message should contain 'unknown parameter', got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_parse_invalid_expression_returns_schema_error() {
let json = r#"{
"constraints": [
{
"id": 0,
"name": "bad",
"expression": "invalid_var(0)",
"slack": { "enabled": false }
}
]
}"#;
let f = write_json(json);
let err =
parse_generic_constraints(f.path(), &HashMap::new(), &LineBusPairIndex::default())
.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)",
"slack": { "enabled": false }
},
{
"id": 0,
"name": "b",
"expression": "thermal_generation(1)",
"slack": { "enabled": false }
}
]
}"#;
let f = write_json(json);
let err =
parse_generic_constraints(f.path(), &HashMap::new(), &LineBusPairIndex::default())
.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)",
"slack": { "enabled": true }
}
]
}"#;
let f = write_json(json);
let err =
parse_generic_constraints(f.path(), &HashMap::new(), &LineBusPairIndex::default())
.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_unknown_sense_field_returns_parse_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(), &HashMap::new(), &LineBusPairIndex::default())
.unwrap_err();
assert!(
matches!(err, LoadError::ParseError { .. }),
"expected ParseError for an unknown 'sense' field, got: {err:?}"
);
}
#[test]
fn test_parse_empty_constraints_array() {
let json = r#"{ "constraints": [] }"#;
let f = write_json(json);
let result =
parse_generic_constraints(f.path(), &HashMap::new(), &LineBusPairIndex::default())
.unwrap();
assert!(result.is_empty());
}
#[test]
fn test_parse_sorted_by_id() {
let json = r#"{
"constraints": [
{
"id": 5,
"name": "c",
"expression": "hydro_generation(0)",
"slack": { "enabled": false }
},
{
"id": 2,
"name": "b",
"expression": "thermal_generation(0)",
"slack": { "enabled": false }
},
{
"id": 0,
"name": "a",
"expression": "line_direct(0)",
"slack": { "enabled": false }
}
]
}"#;
let f = write_json(json);
let result =
parse_generic_constraints(f.path(), &HashMap::new(), &LineBusPairIndex::default())
.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_line_exchange_json_constraint() {
let json = r#"{
"constraints": [
{
"id": 0,
"name": "net_exchange",
"expression": "line_exchange(0)",
"slack": { "enabled": false }
}
]
}"#;
let f = write_json(json);
let result =
parse_generic_constraints(f.path(), &HashMap::new(), &LineBusPairIndex::default())
.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "net_exchange");
assert_eq!(result[0].expression.terms.len(), 1);
assert_eq!(
result[0].expression.terms[0].variable,
VariableRef::LineExchange {
line_id: EntityId(0),
block_id: None,
}
);
}
#[test]
fn test_parse_slack_zero_penalty_returns_schema_error() {
let json = r#"{
"constraints": [
{
"id": 0,
"name": "a",
"expression": "hydro_generation(0)",
"slack": { "enabled": true, "penalty": 0.0 }
}
]
}"#;
let f = write_json(json);
let err =
parse_generic_constraints(f.path(), &HashMap::new(), &LineBusPairIndex::default())
.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)",
"slack": { "enabled": false }
}
]
}"#;
let f = write_json(json);
let result =
parse_generic_constraints(f.path(), &HashMap::new(), &LineBusPairIndex::default())
.unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].description.is_none());
}
#[test]
fn anticipated_decision_simple_parse() {
let expr = parse_expression("anticipated_decision(5)", &HashMap::new()).unwrap();
assert_eq!(expr.terms.len(), 1);
assert!(
(lit(&expr.terms[0]) - 1.0).abs() < f64::EPSILON,
"coefficient must be 1.0"
);
assert_eq!(
expr.terms[0].variable,
VariableRef::AnticipatedDecision {
thermal_id: EntityId(5),
}
);
}
#[test]
fn anticipated_decision_with_coefficient() {
let expr = parse_expression("2.5 * anticipated_decision(5)", &HashMap::new()).unwrap();
assert_eq!(expr.terms.len(), 1);
assert!(
(lit(&expr.terms[0]) - 2.5).abs() < f64::EPSILON,
"coefficient must be 2.5"
);
assert_eq!(
expr.terms[0].variable,
VariableRef::AnticipatedDecision {
thermal_id: EntityId(5),
}
);
}
#[test]
fn anticipated_decision_rejects_block_id() {
let result = parse_expression("anticipated_decision(5, 0)", &HashMap::new());
assert!(
result.is_err(),
"should reject block_id for anticipated_decision"
);
let msg = result.unwrap_err();
assert!(
msg.contains("anticipated_decision"),
"error message must name the variable: {msg}"
);
assert!(
msg.contains("block_id") || msg.contains("stage-level"),
"error message should explain no block_id accepted: {msg}"
);
}
#[test]
fn anticipated_decision_whitespace_insensitive() {
let expr1 = parse_expression("anticipated_decision(5)", &HashMap::new()).unwrap();
let expr2 = parse_expression("anticipated_decision( 5 )", &HashMap::new()).unwrap();
assert_eq!(expr1.terms.len(), 1);
assert_eq!(expr2.terms.len(), 1);
assert_eq!(
expr1.terms[0].variable, expr2.terms[0].variable,
"whitespace must not affect the parsed variable"
);
}
#[test]
fn anticipated_decision_multi_term_expression() {
let expr = parse_expression(
"anticipated_decision(5) + anticipated_decision(6)",
&HashMap::new(),
)
.unwrap();
assert_eq!(expr.terms.len(), 2);
assert_eq!(
expr.terms[0].variable,
VariableRef::AnticipatedDecision {
thermal_id: EntityId(5),
}
);
assert_eq!(
expr.terms[1].variable,
VariableRef::AnticipatedDecision {
thermal_id: EntityId(6),
}
);
}
fn raw_named(name: &str, expression: &str) -> RawNamedExpression {
RawNamedExpression {
name: name.to_string(),
expression: expression.to_string(),
_description: None,
}
}
#[test]
fn parse_named_expressions_empty_is_ok() {
let table = parse_named_expressions(
&[],
Path::new("generic_constraints.json"),
&HashMap::new(),
&LineBusPairIndex::default(),
)
.unwrap();
assert!(table.is_empty());
}
#[test]
fn parse_named_expressions_two_valid_definitions() {
let entries = [
raw_named("fnese", "hydro_generation(0) + hydro_generation(1)"),
raw_named("fns", "2.5 * thermal_generation(5) - hydro_generation(3)"),
];
let table = parse_named_expressions(
&entries,
Path::new("generic_constraints.json"),
&HashMap::new(),
&LineBusPairIndex::default(),
)
.unwrap();
assert_eq!(table.len(), 2);
assert_eq!(table[0].0, "fnese");
assert_eq!(table[0].1.len(), 2);
assert_eq!(
flat(&table[0].1[0]).variable,
VariableRef::HydroGeneration {
hydro_id: EntityId(0),
block_id: None,
bus_id: None,
}
);
assert_eq!(table[1].0, "fns");
assert_eq!(table[1].1.len(), 2);
assert!((lit(flat(&table[1].1[0])) - 2.5).abs() < 1e-10);
assert_eq!(
flat(&table[1].1[1]).variable,
VariableRef::HydroGeneration {
hydro_id: EntityId(3),
block_id: None,
bus_id: None,
}
);
}
#[test]
fn parse_named_expressions_duplicate_name_is_error() {
let entries = [
raw_named("fnese", "hydro_generation(0)"),
raw_named("fnese", "hydro_generation(1)"),
];
let err = parse_named_expressions(
&entries,
Path::new("generic_constraints.json"),
&HashMap::new(),
&LineBusPairIndex::default(),
)
.unwrap_err();
match err {
LoadError::SchemaError { field, message, .. } => {
assert!(field.contains("expressions"), "field: {field}");
assert!(message.contains("fnese"), "message: {message}");
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn parse_named_expressions_param_collision_is_error() {
let entries = [raw_named("rho", "hydro_generation(0)")];
let err = parse_named_expressions(
&entries,
Path::new("generic_constraints.json"),
&one_param_table(),
&LineBusPairIndex::default(),
)
.unwrap_err();
match err {
LoadError::SchemaError { field, message, .. } => {
assert!(field.contains("expressions"), "field: {field}");
assert!(message.contains("rho"), "message: {message}");
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn parse_named_expressions_reference_parses_to_ref() {
let entries = [raw_named("combo", "@fnese + hydro_generation(0)")];
let table = parse_named_expressions(
&entries,
Path::new("generic_constraints.json"),
&HashMap::new(),
&LineBusPairIndex::default(),
)
.unwrap();
assert_eq!(table.len(), 1);
assert_eq!(table[0].1.len(), 2);
match &table[0].1[0] {
ParsedTerm::Ref { name, scale } => {
assert_eq!(name, "fnese");
assert!((scale - 1.0).abs() < f64::EPSILON);
}
ParsedTerm::Flat(lt) => panic!("expected a reference term, got Flat: {lt:?}"),
}
assert_eq!(
flat(&table[0].1[1]).variable,
VariableRef::HydroGeneration {
hydro_id: EntityId(0),
block_id: None,
bus_id: None,
}
);
}
#[test]
fn parse_named_expressions_standalone_param_name_parses_to_ref() {
let entries = [raw_named("combo", "@rho")];
let table = parse_named_expressions(
&entries,
Path::new("generic_constraints.json"),
&one_param_table(),
&LineBusPairIndex::default(),
)
.unwrap();
assert_eq!(table.len(), 1);
match &table[0].1[0] {
ParsedTerm::Ref { name, .. } => assert_eq!(name, "rho"),
ParsedTerm::Flat(lt) => panic!("expected a reference term, got Flat: {lt:?}"),
}
}
#[test]
fn parse_named_expressions_param_coefficient_is_allowed() {
let entries = [raw_named("scaled", "@rho * hydro_generation(0)")];
let table = parse_named_expressions(
&entries,
Path::new("generic_constraints.json"),
&one_param_table(),
&LineBusPairIndex::default(),
)
.unwrap();
assert_eq!(table.len(), 1);
assert_eq!(param_id(flat(&table[0].1[0])), EntityId(7));
}
#[test]
fn parse_generic_constraints_with_expressions_key() {
let json = r#"{
"expressions": [
{ "name": "fnese", "expression": "hydro_generation(10) + hydro_generation(11)", "description": "SE net flow" }
],
"constraints": [
{
"id": 0,
"name": "c0",
"expression": "hydro_generation(0)",
"slack": { "enabled": false }
}
]
}"#;
let f = write_json(json);
let result =
parse_generic_constraints(f.path(), &HashMap::new(), &LineBusPairIndex::default())
.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].id, EntityId(0));
assert_eq!(result[0].expression.terms.len(), 1);
}
#[test]
fn parse_generic_constraints_bad_expression_declaration_fails_load() {
let json = r#"{
"expressions": [
{ "name": "bad", "expression": "@other + hydro_generation(0)" }
],
"constraints": [
{ "id": 0, "name": "c0", "expression": "hydro_generation(0)", "slack": { "enabled": false } }
]
}"#;
let f = write_json(json);
let err =
parse_generic_constraints(f.path(), &HashMap::new(), &LineBusPairIndex::default())
.unwrap_err();
assert!(
matches!(&err, LoadError::SchemaError { message, .. } if message.contains("undeclared") && message.contains("other")),
"expected an undeclared-reference error naming \"other\", got: {err:?}"
);
}
#[test]
fn parse_generic_constraints_empty_expressions_is_byte_neutral() {
let with_key = r#"{ "expressions": [], "constraints": [ { "id": 0, "name": "c0", "expression": "hydro_generation(0)", "slack": { "enabled": false } } ] }"#;
let without_key = r#"{ "constraints": [ { "id": 0, "name": "c0", "expression": "hydro_generation(0)", "slack": { "enabled": false } } ] }"#;
let with_file = write_json(with_key);
let without_file = write_json(without_key);
let a = parse_generic_constraints(
with_file.path(),
&HashMap::new(),
&LineBusPairIndex::default(),
)
.unwrap();
let b = parse_generic_constraints(
without_file.path(),
&HashMap::new(),
&LineBusPairIndex::default(),
)
.unwrap();
assert_eq!(a.len(), b.len());
assert_eq!(a[0].id, b[0].id);
assert_eq!(a[0].expression.terms.len(), b[0].expression.terms.len());
}
#[test]
fn parse_generic_constraints_unknown_expression_field_returns_parse_error() {
let json = r#"{
"expressions": [
{ "name": "x", "expression": "hydro_generation(0)", "unexpected": 1 }
],
"constraints": []
}"#;
let f = write_json(json);
let err =
parse_generic_constraints(f.path(), &HashMap::new(), &LineBusPairIndex::default())
.unwrap_err();
assert!(
matches!(err, LoadError::ParseError { .. }),
"expected ParseError for an unknown declaration field, got: {err:?}"
);
}
fn effective(term: &LinearTerm) -> f64 {
lit(term) * term.scale
}
#[test]
fn parse_generic_constraints_inlines_single_reference() {
let json = r#"{
"expressions": [
{ "name": "fnese", "expression": "hydro_generation(0) + hydro_generation(1)" }
],
"constraints": [
{ "id": 0, "name": "c0", "expression": "2.0 * @fnese - thermal_generation(3)", "slack": { "enabled": false } }
]
}"#;
let f = write_json(json);
let result =
parse_generic_constraints(f.path(), &HashMap::new(), &LineBusPairIndex::default())
.unwrap();
assert_eq!(result.len(), 1);
let terms = &result[0].expression.terms;
assert_eq!(terms.len(), 3);
assert!((effective(&terms[0]) - 2.0).abs() < 1e-10);
assert!((effective(&terms[1]) - 2.0).abs() < 1e-10);
assert!((effective(&terms[2]) - (-1.0)).abs() < 1e-10);
assert_eq!(
terms[0].variable,
VariableRef::HydroGeneration {
hydro_id: EntityId(0),
block_id: None,
bus_id: None,
}
);
assert_eq!(
terms[1].variable,
VariableRef::HydroGeneration {
hydro_id: EntityId(1),
block_id: None,
bus_id: None,
}
);
assert_eq!(
terms[2].variable,
VariableRef::ThermalGeneration {
thermal_id: EntityId(3),
block_id: None,
}
);
}
#[test]
fn parse_generic_constraints_group_distributes_into_named_reference() {
let json = r#"{
"expressions": [
{ "name": "fnese", "expression": "hydro_generation(0) + hydro_generation(1)" }
],
"constraints": [
{ "id": 0, "name": "c0", "expression": "2.0 * (@fnese - hydro_generation(2))", "slack": { "enabled": false } }
]
}"#;
let f = write_json(json);
let result =
parse_generic_constraints(f.path(), &HashMap::new(), &LineBusPairIndex::default())
.unwrap();
assert_eq!(result.len(), 1);
let terms = &result[0].expression.terms;
assert_eq!(terms.len(), 3);
assert!((effective(&terms[0]) - 2.0).abs() < 1e-10);
assert!((effective(&terms[1]) - 2.0).abs() < 1e-10);
assert!((effective(&terms[2]) - (-2.0)).abs() < 1e-10);
}
#[test]
fn parse_generic_constraints_inlines_composition() {
let json = r#"{
"expressions": [
{ "name": "base", "expression": "hydro_generation(0)" },
{ "name": "inner", "expression": "3.0 * @base" },
{ "name": "outer", "expression": "@inner + hydro_generation(1)" }
],
"constraints": [
{ "id": 0, "name": "c0", "expression": "2.0 * @outer", "slack": { "enabled": false } }
]
}"#;
let f = write_json(json);
let result =
parse_generic_constraints(f.path(), &HashMap::new(), &LineBusPairIndex::default())
.unwrap();
assert_eq!(result.len(), 1);
let terms = &result[0].expression.terms;
assert_eq!(terms.len(), 2);
assert!((effective(&terms[0]) - 6.0).abs() < 1e-10);
assert!((effective(&terms[1]) - 2.0).abs() < 1e-10);
assert_eq!(
terms[0].variable,
VariableRef::HydroGeneration {
hydro_id: EntityId(0),
block_id: None,
bus_id: None,
}
);
assert_eq!(
terms[1].variable,
VariableRef::HydroGeneration {
hydro_id: EntityId(1),
block_id: None,
bus_id: None,
}
);
}
#[test]
fn parse_generic_constraints_two_node_cycle_errors() {
let json = r#"{
"expressions": [
{ "name": "a", "expression": "@b" },
{ "name": "b", "expression": "@a" }
],
"constraints": []
}"#;
let f = write_json(json);
let err =
parse_generic_constraints(f.path(), &HashMap::new(), &LineBusPairIndex::default())
.unwrap_err();
match &err {
LoadError::SchemaError { message, .. } => {
assert!(
message.contains('a') && message.contains('b'),
"message: {message}"
);
assert!(message.contains("a -> b -> a"), "message: {message}");
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn parse_generic_constraints_self_reference_errors() {
let json = r#"{
"expressions": [
{ "name": "e", "expression": "@e" }
],
"constraints": []
}"#;
let f = write_json(json);
let err =
parse_generic_constraints(f.path(), &HashMap::new(), &LineBusPairIndex::default())
.unwrap_err();
assert!(
matches!(&err, LoadError::SchemaError { message, .. } if message.contains("e -> e")),
"expected a self-reference cycle naming \"e\", got: {err:?}"
);
}
#[test]
fn parse_generic_constraints_undeclared_reference_in_constraint_errors() {
let json = r#"{
"constraints": [
{ "id": 0, "name": "c0", "expression": "@nope", "slack": { "enabled": false } }
]
}"#;
let f = write_json(json);
let err =
parse_generic_constraints(f.path(), &HashMap::new(), &LineBusPairIndex::default())
.unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(field.contains("expression"), "field: {field}");
assert!(
message.contains("undeclared") && message.contains("nope"),
"message: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn parse_generic_constraints_param_times_reference_rejected() {
let json = r#"{
"constraints": [
{ "id": 0, "name": "c0", "expression": "@rho * @fnese", "slack": { "enabled": false } }
]
}"#;
let f = write_json(json);
let err =
parse_generic_constraints(f.path(), &one_param_table(), &LineBusPairIndex::default())
.unwrap_err();
assert!(
matches!(&err, LoadError::SchemaError { message, .. } if message.contains("only one @parameter reference")),
"expected the two-reference error class, got: {err:?}"
);
}
fn doubling_chain_expressions(levels: u32) -> String {
let mut exprs = String::from(r#"{ "name": "e0", "expression": "hydro_generation(0)" }"#);
for k in 1..=levels {
let _ = write!(
exprs,
r#", {{ "name": "e{k}", "expression": "@e{prev} + @e{prev}" }}"#,
prev = k - 1
);
}
exprs
}
#[test]
fn parse_generic_constraints_unreferenced_doubling_chain_parses_fast() {
let json = format!(
r#"{{ "expressions": [ {} ], "constraints": [ {{ "id": 0, "name": "c0", "expression": "hydro_generation(0)", "slack": {{ "enabled": false }} }} ] }}"#,
doubling_chain_expressions(60)
);
let f = write_json(&json);
let result =
parse_generic_constraints(f.path(), &HashMap::new(), &LineBusPairIndex::default())
.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].expression.terms.len(), 1);
}
#[test]
fn parse_generic_constraints_referenced_doubling_chain_hits_budget_fast() {
let json = format!(
r#"{{ "expressions": [ {} ], "constraints": [ {{ "id": 0, "name": "c0", "expression": "@e60", "slack": {{ "enabled": false }} }} ] }}"#,
doubling_chain_expressions(60)
);
let f = write_json(&json);
let err =
parse_generic_constraints(f.path(), &HashMap::new(), &LineBusPairIndex::default())
.unwrap_err();
assert!(
matches!(&err, LoadError::SchemaError { field, message, .. }
if field.contains("constraints[0].expression")
&& message.contains("more than")
&& message.contains("100000")),
"expected a budget-exceeded error on the constraint, got: {err:?}"
);
}
#[test]
fn parse_generic_constraints_large_flat_expression_under_cap_parses() {
let mut expr = String::from("hydro_generation(0)");
for id in 1..1000 {
let _ = write!(expr, " + hydro_generation({id})");
}
let json = format!(
r#"{{ "constraints": [ {{ "id": 0, "name": "big", "expression": "{expr}", "slack": {{ "enabled": false }} }} ] }}"#
);
let f = write_json(&json);
let result =
parse_generic_constraints(f.path(), &HashMap::new(), &LineBusPairIndex::default())
.unwrap();
assert_eq!(result[0].expression.terms.len(), 1000);
}
}