cobre_core/generic_constraint.rs
1//! User-defined generic linear constraints.
2//!
3//! This module defines the in-memory representation of generic constraints
4//! that users can specify to add custom linear relationships between LP
5//! variables. The expression parser (string → [`ConstraintExpression`])
6//! lives in `cobre-io`, not here. This module contains only the output types.
7//!
8//! See `internal-structures.md §15` and `input-constraints.md §3` for the
9//! full specification, grammar, and validation rules.
10//!
11//! # Variable Reference Catalog
12//!
13//! [`VariableRef`] covers all 19 LP variable types defined in the spec (SS15).
14//! Each variant carries the entity ID and, for block-specific variables, an
15//! optional block ID (`None` = sum over all blocks, `Some(i)` = block `i`).
16//!
17//! # Examples
18//!
19//! ```
20//! use cobre_core::{
21//! EntityId, GenericConstraint, ConstraintExpression, ConstraintSense,
22//! LinearTerm, SlackConfig, VariableRef,
23//! };
24//!
25//! // Represents: hydro_generation(10) + hydro_generation(11)
26//! let expr = ConstraintExpression {
27//! terms: vec![
28//! LinearTerm {
29//! coefficient: 1.0,
30//! variable: VariableRef::HydroGeneration {
31//! hydro_id: EntityId(10),
32//! block_id: None,
33//! },
34//! },
35//! LinearTerm {
36//! coefficient: 1.0,
37//! variable: VariableRef::HydroGeneration {
38//! hydro_id: EntityId(11),
39//! block_id: None,
40//! },
41//! },
42//! ],
43//! };
44//!
45//! assert_eq!(expr.terms.len(), 2);
46//!
47//! let gc = GenericConstraint {
48//! id: EntityId(0),
49//! name: "min_southeast_hydro".to_string(),
50//! description: Some("Minimum hydro generation in Southeast region".to_string()),
51//! expression: expr,
52//! sense: ConstraintSense::GreaterEqual,
53//! slack: SlackConfig { enabled: true, penalty: Some(5_000.0) },
54//! };
55//!
56//! assert_eq!(gc.expression.terms.len(), 2);
57//! ```
58
59use crate::EntityId;
60
61/// Reference to a single LP variable in a generic constraint expression.
62///
63/// Each variant names the variable type and carries the entity ID. For
64/// block-specific variables, `block_id` is `None` to sum over all blocks or
65/// `Some(i)` to reference block `i` specifically.
66///
67/// The 19 variants cover the full variable catalog defined in
68/// `internal-structures.md §15` (table in the "Variable References" section).
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
71pub enum VariableRef {
72 /// Reservoir storage level for a hydro plant (stage-level, not block-specific).
73 HydroStorage {
74 /// Hydro plant identifier.
75 hydro_id: EntityId,
76 },
77 /// Turbined water flow for a hydro plant (m³/s).
78 HydroTurbined {
79 /// Hydro plant identifier.
80 hydro_id: EntityId,
81 /// Block index. `None` = sum over all blocks; `Some(i)` = block `i`.
82 block_id: Option<usize>,
83 },
84 /// Spillage flow for a hydro plant (m³/s).
85 HydroSpillage {
86 /// Hydro plant identifier.
87 hydro_id: EntityId,
88 /// Block index. `None` = sum over all blocks; `Some(i)` = block `i`.
89 block_id: Option<usize>,
90 },
91 /// Diversion flow for a hydro plant (m³/s). Only valid for hydros with diversion.
92 HydroDiversion {
93 /// Hydro plant identifier.
94 hydro_id: EntityId,
95 /// Block index. `None` = sum over all blocks; `Some(i)` = block `i`.
96 block_id: Option<usize>,
97 },
98 /// Total outflow (turbined + spillage) for a hydro plant (m³/s).
99 ///
100 /// Currently an alias for turbined + spillage. Future CEPEL formulations
101 /// may turn this into an independent variable.
102 HydroOutflow {
103 /// Hydro plant identifier.
104 hydro_id: EntityId,
105 /// Block index. `None` = sum over all blocks; `Some(i)` = block `i`.
106 block_id: Option<usize>,
107 },
108 /// Electrical generation from a hydro plant (MW).
109 HydroGeneration {
110 /// Hydro plant identifier.
111 hydro_id: EntityId,
112 /// Block index. `None` = sum over all blocks; `Some(i)` = block `i`.
113 block_id: Option<usize>,
114 },
115 /// Evaporation flow from a hydro reservoir (m³/s). Stage-level, not block-specific.
116 HydroEvaporation {
117 /// Hydro plant identifier.
118 hydro_id: EntityId,
119 },
120 /// Water withdrawal from a hydro reservoir (m³/s). Stage-level, not block-specific.
121 HydroWithdrawal {
122 /// Hydro plant identifier.
123 hydro_id: EntityId,
124 },
125 /// Electrical generation from a thermal unit (MW).
126 ThermalGeneration {
127 /// Thermal unit identifier.
128 thermal_id: EntityId,
129 /// Block index. `None` = sum over all blocks; `Some(i)` = block `i`.
130 block_id: Option<usize>,
131 },
132 /// Direct (forward) power flow on a transmission line (MW).
133 LineDirect {
134 /// Transmission line identifier.
135 line_id: EntityId,
136 /// Block index. `None` = sum over all blocks; `Some(i)` = block `i`.
137 block_id: Option<usize>,
138 },
139 /// Reverse power flow on a transmission line (MW).
140 LineReverse {
141 /// Transmission line identifier.
142 line_id: EntityId,
143 /// Block index. `None` = sum over all blocks; `Some(i)` = block `i`.
144 block_id: Option<usize>,
145 },
146 /// Load deficit (unserved energy) at a bus (MW).
147 BusDeficit {
148 /// Bus identifier.
149 bus_id: EntityId,
150 /// Block index. `None` = sum over all blocks; `Some(i)` = block `i`.
151 block_id: Option<usize>,
152 },
153 /// Load excess (over-generation) at a bus (MW).
154 BusExcess {
155 /// Bus identifier.
156 bus_id: EntityId,
157 /// Block index. `None` = sum over all blocks; `Some(i)` = block `i`.
158 block_id: Option<usize>,
159 },
160 /// Pumped water flow at a pumping station (m³/s).
161 PumpingFlow {
162 /// Pumping station identifier.
163 station_id: EntityId,
164 /// Block index. `None` = sum over all blocks; `Some(i)` = block `i`.
165 block_id: Option<usize>,
166 },
167 /// Electrical power consumed by a pumping station (MW).
168 PumpingPower {
169 /// Pumping station identifier.
170 station_id: EntityId,
171 /// Block index. `None` = sum over all blocks; `Some(i)` = block `i`.
172 block_id: Option<usize>,
173 },
174 /// Energy imported via a contract (MW).
175 ContractImport {
176 /// Energy contract identifier.
177 contract_id: EntityId,
178 /// Block index. `None` = sum over all blocks; `Some(i)` = block `i`.
179 block_id: Option<usize>,
180 },
181 /// Energy exported via a contract (MW).
182 ContractExport {
183 /// Energy contract identifier.
184 contract_id: EntityId,
185 /// Block index. `None` = sum over all blocks; `Some(i)` = block `i`.
186 block_id: Option<usize>,
187 },
188 /// Generation from a non-controllable source (wind, solar, etc.) (MW).
189 NonControllableGeneration {
190 /// Non-controllable source identifier.
191 source_id: EntityId,
192 /// Block index. `None` = sum over all blocks; `Some(i)` = block `i`.
193 block_id: Option<usize>,
194 },
195 /// Curtailment of a non-controllable source (MW).
196 NonControllableCurtailment {
197 /// Non-controllable source identifier.
198 source_id: EntityId,
199 /// Block index. `None` = sum over all blocks; `Some(i)` = block `i`.
200 block_id: Option<usize>,
201 },
202}
203
204/// One term in a linear constraint expression: `coefficient * variable`.
205///
206/// The expression is `coefficient × variable_ref`. A coefficient of `1.0`
207/// represents an unweighted variable reference.
208#[derive(Debug, Clone, PartialEq)]
209#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
210pub struct LinearTerm {
211 /// Scalar multiplier for the variable reference.
212 pub coefficient: f64,
213 /// The LP variable being referenced.
214 pub variable: VariableRef,
215}
216
217/// Parsed linear constraint expression.
218///
219/// Represents the left-hand side of a generic constraint as a list of weighted
220/// variable references. An empty `terms` vector is valid (constant-only
221/// expression, unusual but not rejected at this layer).
222///
223/// The expression parser (string → `ConstraintExpression`) lives in `cobre-io`.
224#[derive(Debug, Clone, PartialEq)]
225#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
226pub struct ConstraintExpression {
227 /// Ordered list of linear terms that form the left-hand side of the constraint.
228 pub terms: Vec<LinearTerm>,
229}
230
231/// Comparison sense for a generic constraint.
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
234pub enum ConstraintSense {
235 /// The expression must be greater than or equal to the bound (`>=`).
236 GreaterEqual,
237 /// The expression must be less than or equal to the bound (`<=`).
238 LessEqual,
239 /// The expression must be exactly equal to the bound (`==`).
240 Equal,
241}
242
243/// Slack variable configuration for a generic constraint.
244///
245/// When `enabled` is `true`, a slack variable is added to the LP so that the
246/// constraint can be violated at a cost. This prevents infeasibility when
247/// bounds are tight or conflicting. The penalty cost enters the LP objective
248/// function.
249///
250/// `penalty` must be `Some(value)` with a positive value when `enabled` is
251/// `true`, and `None` when `enabled` is `false`.
252#[derive(Debug, Clone, PartialEq)]
253#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
254pub struct SlackConfig {
255 /// Whether a slack variable is added to allow soft violation of the constraint.
256 pub enabled: bool,
257 /// Penalty cost per unit of constraint violation. `None` when `enabled` is `false`.
258 pub penalty: Option<f64>,
259}
260
261/// A user-defined generic linear constraint.
262///
263/// Stored in [`crate::System::generic_constraints`] after loading and
264/// validation. Constraints are sorted by `id` after loading to satisfy the
265/// declaration-order invariance requirement.
266///
267/// The expression parser, referential validation (entity IDs exist), and
268/// bounds loading (from `generic_constraint_bounds.parquet`) are all
269/// performed by `cobre-io`, not here.
270#[derive(Debug, Clone, PartialEq)]
271#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
272pub struct GenericConstraint {
273 /// Unique constraint identifier.
274 pub id: EntityId,
275 /// Short name used in reports and log output.
276 pub name: String,
277 /// Optional human-readable description.
278 pub description: Option<String>,
279 /// Parsed left-hand-side expression of the constraint.
280 pub expression: ConstraintExpression,
281 /// Comparison sense (`>=`, `<=`, or `==`).
282 pub sense: ConstraintSense,
283 /// Slack variable configuration.
284 pub slack: SlackConfig,
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 #[test]
292 fn test_variable_ref_variants() {
293 let variants: &[(&str, VariableRef)] = &[
294 (
295 "HydroStorage",
296 VariableRef::HydroStorage {
297 hydro_id: EntityId(0),
298 },
299 ),
300 (
301 "HydroTurbined",
302 VariableRef::HydroTurbined {
303 hydro_id: EntityId(0),
304 block_id: None,
305 },
306 ),
307 (
308 "HydroSpillage",
309 VariableRef::HydroSpillage {
310 hydro_id: EntityId(0),
311 block_id: Some(1),
312 },
313 ),
314 (
315 "HydroDiversion",
316 VariableRef::HydroDiversion {
317 hydro_id: EntityId(0),
318 block_id: None,
319 },
320 ),
321 (
322 "HydroOutflow",
323 VariableRef::HydroOutflow {
324 hydro_id: EntityId(0),
325 block_id: None,
326 },
327 ),
328 (
329 "HydroGeneration",
330 VariableRef::HydroGeneration {
331 hydro_id: EntityId(0),
332 block_id: Some(0),
333 },
334 ),
335 (
336 "HydroEvaporation",
337 VariableRef::HydroEvaporation {
338 hydro_id: EntityId(0),
339 },
340 ),
341 (
342 "HydroWithdrawal",
343 VariableRef::HydroWithdrawal {
344 hydro_id: EntityId(0),
345 },
346 ),
347 (
348 "ThermalGeneration",
349 VariableRef::ThermalGeneration {
350 thermal_id: EntityId(0),
351 block_id: None,
352 },
353 ),
354 (
355 "LineDirect",
356 VariableRef::LineDirect {
357 line_id: EntityId(0),
358 block_id: None,
359 },
360 ),
361 (
362 "LineReverse",
363 VariableRef::LineReverse {
364 line_id: EntityId(0),
365 block_id: None,
366 },
367 ),
368 (
369 "BusDeficit",
370 VariableRef::BusDeficit {
371 bus_id: EntityId(0),
372 block_id: None,
373 },
374 ),
375 (
376 "BusExcess",
377 VariableRef::BusExcess {
378 bus_id: EntityId(0),
379 block_id: None,
380 },
381 ),
382 (
383 "PumpingFlow",
384 VariableRef::PumpingFlow {
385 station_id: EntityId(0),
386 block_id: None,
387 },
388 ),
389 (
390 "PumpingPower",
391 VariableRef::PumpingPower {
392 station_id: EntityId(0),
393 block_id: None,
394 },
395 ),
396 (
397 "ContractImport",
398 VariableRef::ContractImport {
399 contract_id: EntityId(0),
400 block_id: None,
401 },
402 ),
403 (
404 "ContractExport",
405 VariableRef::ContractExport {
406 contract_id: EntityId(0),
407 block_id: None,
408 },
409 ),
410 (
411 "NonControllableGeneration",
412 VariableRef::NonControllableGeneration {
413 source_id: EntityId(0),
414 block_id: None,
415 },
416 ),
417 (
418 "NonControllableCurtailment",
419 VariableRef::NonControllableCurtailment {
420 source_id: EntityId(0),
421 block_id: None,
422 },
423 ),
424 ];
425
426 assert_eq!(
427 variants.len(),
428 19,
429 "VariableRef must have exactly 19 variants"
430 );
431
432 for (name, variant) in variants {
433 let debug_str = format!("{variant:?}");
434 assert!(
435 debug_str.contains(name),
436 "Debug output for {name} does not contain the variant name: {debug_str}"
437 );
438 }
439 }
440
441 #[test]
442 fn test_generic_constraint_construction() {
443 let expr = ConstraintExpression {
444 terms: vec![
445 LinearTerm {
446 coefficient: 1.0,
447 variable: VariableRef::HydroGeneration {
448 hydro_id: EntityId(10),
449 block_id: None,
450 },
451 },
452 LinearTerm {
453 coefficient: 1.0,
454 variable: VariableRef::HydroGeneration {
455 hydro_id: EntityId(11),
456 block_id: None,
457 },
458 },
459 ],
460 };
461
462 let gc = GenericConstraint {
463 id: EntityId(0),
464 name: "min_southeast_hydro".to_string(),
465 description: Some("Minimum hydro generation in Southeast region".to_string()),
466 expression: expr,
467 sense: ConstraintSense::GreaterEqual,
468 slack: SlackConfig {
469 enabled: true,
470 penalty: Some(5_000.0),
471 },
472 };
473
474 assert_eq!(gc.expression.terms.len(), 2);
475 assert_eq!(gc.id, EntityId(0));
476 assert_eq!(gc.name, "min_southeast_hydro");
477 assert!(gc.description.is_some());
478 assert_eq!(gc.sense, ConstraintSense::GreaterEqual);
479 assert!(gc.slack.enabled);
480 assert_eq!(gc.slack.penalty, Some(5_000.0));
481 }
482
483 #[test]
484 fn test_slack_config_disabled_has_no_penalty() {
485 let slack = SlackConfig {
486 enabled: false,
487 penalty: None,
488 };
489 assert!(!slack.enabled);
490 assert!(slack.penalty.is_none());
491 }
492
493 #[test]
494 fn test_constraint_sense_variants() {
495 assert_ne!(ConstraintSense::GreaterEqual, ConstraintSense::LessEqual);
496 assert_ne!(ConstraintSense::GreaterEqual, ConstraintSense::Equal);
497 assert_ne!(ConstraintSense::LessEqual, ConstraintSense::Equal);
498 }
499
500 #[test]
501 fn test_linear_term_with_coefficient() {
502 let term = LinearTerm {
503 coefficient: 2.5,
504 variable: VariableRef::ThermalGeneration {
505 thermal_id: EntityId(5),
506 block_id: None,
507 },
508 };
509 assert_eq!(term.coefficient, 2.5);
510 let debug = format!("{:?}", term.variable);
511 assert!(debug.contains("ThermalGeneration"));
512 }
513
514 #[test]
515 fn test_variable_ref_block_none_vs_some() {
516 let all_blocks = VariableRef::HydroTurbined {
517 hydro_id: EntityId(3),
518 block_id: None,
519 };
520 let specific_block = VariableRef::HydroTurbined {
521 hydro_id: EntityId(3),
522 block_id: Some(0),
523 };
524 assert_ne!(all_blocks, specific_block);
525 }
526
527 #[cfg(feature = "serde")]
528 #[test]
529 fn test_generic_constraint_serde_roundtrip() {
530 let gc = GenericConstraint {
531 id: EntityId(0),
532 name: "test".to_string(),
533 description: None,
534 expression: ConstraintExpression {
535 terms: vec![
536 LinearTerm {
537 coefficient: 1.0,
538 variable: VariableRef::HydroGeneration {
539 hydro_id: EntityId(10),
540 block_id: None,
541 },
542 },
543 LinearTerm {
544 coefficient: 1.0,
545 variable: VariableRef::HydroGeneration {
546 hydro_id: EntityId(11),
547 block_id: None,
548 },
549 },
550 ],
551 },
552 sense: ConstraintSense::GreaterEqual,
553 slack: SlackConfig {
554 enabled: true,
555 penalty: Some(5_000.0),
556 },
557 };
558
559 let json = serde_json::to_string(&gc).unwrap();
560 let deserialized: GenericConstraint = serde_json::from_str(&json).unwrap();
561 assert_eq!(gc, deserialized);
562 assert_eq!(deserialized.expression.terms.len(), 2);
563 }
564}