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 20 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 20 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 /// Net exchange flow on a transmission line (direct - reverse) (MW).
147 ///
148 /// This is a derived variable: the resolver maps it to two LP columns
149 /// (forward flow with +1.0 and reverse flow with -1.0), representing
150 /// net flow in the source-to-target direction.
151 LineExchange {
152 /// Transmission line identifier.
153 line_id: EntityId,
154 /// Block index. `None` = sum over all blocks; `Some(i)` = block `i`.
155 block_id: Option<usize>,
156 },
157 /// Load deficit (unserved energy) at a bus (MW).
158 BusDeficit {
159 /// Bus identifier.
160 bus_id: EntityId,
161 /// Block index. `None` = sum over all blocks; `Some(i)` = block `i`.
162 block_id: Option<usize>,
163 },
164 /// Load excess (over-generation) at a bus (MW).
165 BusExcess {
166 /// Bus identifier.
167 bus_id: EntityId,
168 /// Block index. `None` = sum over all blocks; `Some(i)` = block `i`.
169 block_id: Option<usize>,
170 },
171 /// Pumped water flow at a pumping station (m³/s).
172 PumpingFlow {
173 /// Pumping station identifier.
174 station_id: EntityId,
175 /// Block index. `None` = sum over all blocks; `Some(i)` = block `i`.
176 block_id: Option<usize>,
177 },
178 /// Electrical power consumed by a pumping station (MW).
179 PumpingPower {
180 /// Pumping station identifier.
181 station_id: EntityId,
182 /// Block index. `None` = sum over all blocks; `Some(i)` = block `i`.
183 block_id: Option<usize>,
184 },
185 /// Energy imported via a contract (MW).
186 ContractImport {
187 /// Energy contract identifier.
188 contract_id: EntityId,
189 /// Block index. `None` = sum over all blocks; `Some(i)` = block `i`.
190 block_id: Option<usize>,
191 },
192 /// Energy exported via a contract (MW).
193 ContractExport {
194 /// Energy contract identifier.
195 contract_id: EntityId,
196 /// Block index. `None` = sum over all blocks; `Some(i)` = block `i`.
197 block_id: Option<usize>,
198 },
199 /// Generation from a non-controllable source (wind, solar, etc.) (MW).
200 NonControllableGeneration {
201 /// Non-controllable source identifier.
202 source_id: EntityId,
203 /// Block index. `None` = sum over all blocks; `Some(i)` = block `i`.
204 block_id: Option<usize>,
205 },
206 /// Curtailment of a non-controllable source (MW).
207 NonControllableCurtailment {
208 /// Non-controllable source identifier.
209 source_id: EntityId,
210 /// Block index. `None` = sum over all blocks; `Some(i)` = block `i`.
211 block_id: Option<usize>,
212 },
213}
214
215/// One term in a linear constraint expression: `coefficient * variable`.
216///
217/// The expression is `coefficient × variable_ref`. A coefficient of `1.0`
218/// represents an unweighted variable reference.
219#[derive(Debug, Clone, PartialEq)]
220#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
221pub struct LinearTerm {
222 /// Scalar multiplier for the variable reference.
223 pub coefficient: f64,
224 /// The LP variable being referenced.
225 pub variable: VariableRef,
226}
227
228/// Parsed linear constraint expression.
229///
230/// Represents the left-hand side of a generic constraint as a list of weighted
231/// variable references. An empty `terms` vector is valid (constant-only
232/// expression, unusual but not rejected at this layer).
233///
234/// The expression parser (string → `ConstraintExpression`) lives in `cobre-io`.
235#[derive(Debug, Clone, PartialEq)]
236#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
237pub struct ConstraintExpression {
238 /// Ordered list of linear terms that form the left-hand side of the constraint.
239 pub terms: Vec<LinearTerm>,
240}
241
242/// Comparison sense for a generic constraint.
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
245pub enum ConstraintSense {
246 /// The expression must be greater than or equal to the bound (`>=`).
247 GreaterEqual,
248 /// The expression must be less than or equal to the bound (`<=`).
249 LessEqual,
250 /// The expression must be exactly equal to the bound (`==`).
251 Equal,
252}
253
254/// Slack variable configuration for a generic constraint.
255///
256/// When `enabled` is `true`, a slack variable is added to the LP so that the
257/// constraint can be violated at a cost. This prevents infeasibility when
258/// bounds are tight or conflicting. The penalty cost enters the LP objective
259/// function.
260///
261/// `penalty` must be `Some(value)` with a positive value when `enabled` is
262/// `true`, and `None` when `enabled` is `false`.
263#[derive(Debug, Clone, PartialEq)]
264#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
265pub struct SlackConfig {
266 /// Whether a slack variable is added to allow soft violation of the constraint.
267 pub enabled: bool,
268 /// Penalty cost per unit of constraint violation. `None` when `enabled` is `false`.
269 pub penalty: Option<f64>,
270}
271
272/// A user-defined generic linear constraint.
273///
274/// Stored in [`crate::System::generic_constraints`] after loading and
275/// validation. Constraints are sorted by `id` after loading to satisfy the
276/// declaration-order invariance requirement.
277///
278/// The expression parser, referential validation (entity IDs exist), and
279/// bounds loading (from `generic_constraint_bounds.parquet`) are all
280/// performed by `cobre-io`, not here.
281#[derive(Debug, Clone, PartialEq)]
282#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
283pub struct GenericConstraint {
284 /// Unique constraint identifier.
285 pub id: EntityId,
286 /// Short name used in reports and log output.
287 pub name: String,
288 /// Optional human-readable description.
289 pub description: Option<String>,
290 /// Parsed left-hand-side expression of the constraint.
291 pub expression: ConstraintExpression,
292 /// Comparison sense (`>=`, `<=`, or `==`).
293 pub sense: ConstraintSense,
294 /// Slack variable configuration.
295 pub slack: SlackConfig,
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 #[test]
303 fn test_variable_ref_variants() {
304 let variants: &[(&str, VariableRef)] = &[
305 (
306 "HydroStorage",
307 VariableRef::HydroStorage {
308 hydro_id: EntityId(0),
309 },
310 ),
311 (
312 "HydroTurbined",
313 VariableRef::HydroTurbined {
314 hydro_id: EntityId(0),
315 block_id: None,
316 },
317 ),
318 (
319 "HydroSpillage",
320 VariableRef::HydroSpillage {
321 hydro_id: EntityId(0),
322 block_id: Some(1),
323 },
324 ),
325 (
326 "HydroDiversion",
327 VariableRef::HydroDiversion {
328 hydro_id: EntityId(0),
329 block_id: None,
330 },
331 ),
332 (
333 "HydroOutflow",
334 VariableRef::HydroOutflow {
335 hydro_id: EntityId(0),
336 block_id: None,
337 },
338 ),
339 (
340 "HydroGeneration",
341 VariableRef::HydroGeneration {
342 hydro_id: EntityId(0),
343 block_id: Some(0),
344 },
345 ),
346 (
347 "HydroEvaporation",
348 VariableRef::HydroEvaporation {
349 hydro_id: EntityId(0),
350 },
351 ),
352 (
353 "HydroWithdrawal",
354 VariableRef::HydroWithdrawal {
355 hydro_id: EntityId(0),
356 },
357 ),
358 (
359 "ThermalGeneration",
360 VariableRef::ThermalGeneration {
361 thermal_id: EntityId(0),
362 block_id: None,
363 },
364 ),
365 (
366 "LineDirect",
367 VariableRef::LineDirect {
368 line_id: EntityId(0),
369 block_id: None,
370 },
371 ),
372 (
373 "LineReverse",
374 VariableRef::LineReverse {
375 line_id: EntityId(0),
376 block_id: None,
377 },
378 ),
379 (
380 "LineExchange",
381 VariableRef::LineExchange {
382 line_id: EntityId(0),
383 block_id: None,
384 },
385 ),
386 (
387 "BusDeficit",
388 VariableRef::BusDeficit {
389 bus_id: EntityId(0),
390 block_id: None,
391 },
392 ),
393 (
394 "BusExcess",
395 VariableRef::BusExcess {
396 bus_id: EntityId(0),
397 block_id: None,
398 },
399 ),
400 (
401 "PumpingFlow",
402 VariableRef::PumpingFlow {
403 station_id: EntityId(0),
404 block_id: None,
405 },
406 ),
407 (
408 "PumpingPower",
409 VariableRef::PumpingPower {
410 station_id: EntityId(0),
411 block_id: None,
412 },
413 ),
414 (
415 "ContractImport",
416 VariableRef::ContractImport {
417 contract_id: EntityId(0),
418 block_id: None,
419 },
420 ),
421 (
422 "ContractExport",
423 VariableRef::ContractExport {
424 contract_id: EntityId(0),
425 block_id: None,
426 },
427 ),
428 (
429 "NonControllableGeneration",
430 VariableRef::NonControllableGeneration {
431 source_id: EntityId(0),
432 block_id: None,
433 },
434 ),
435 (
436 "NonControllableCurtailment",
437 VariableRef::NonControllableCurtailment {
438 source_id: EntityId(0),
439 block_id: None,
440 },
441 ),
442 ];
443
444 assert_eq!(
445 variants.len(),
446 20,
447 "VariableRef must have exactly 20 variants"
448 );
449
450 for (name, variant) in variants {
451 let debug_str = format!("{variant:?}");
452 assert!(
453 debug_str.contains(name),
454 "Debug output for {name} does not contain the variant name: {debug_str}"
455 );
456 }
457 }
458
459 #[test]
460 fn test_generic_constraint_construction() {
461 let expr = ConstraintExpression {
462 terms: vec![
463 LinearTerm {
464 coefficient: 1.0,
465 variable: VariableRef::HydroGeneration {
466 hydro_id: EntityId(10),
467 block_id: None,
468 },
469 },
470 LinearTerm {
471 coefficient: 1.0,
472 variable: VariableRef::HydroGeneration {
473 hydro_id: EntityId(11),
474 block_id: None,
475 },
476 },
477 ],
478 };
479
480 let gc = GenericConstraint {
481 id: EntityId(0),
482 name: "min_southeast_hydro".to_string(),
483 description: Some("Minimum hydro generation in Southeast region".to_string()),
484 expression: expr,
485 sense: ConstraintSense::GreaterEqual,
486 slack: SlackConfig {
487 enabled: true,
488 penalty: Some(5_000.0),
489 },
490 };
491
492 assert_eq!(gc.expression.terms.len(), 2);
493 assert_eq!(gc.id, EntityId(0));
494 assert_eq!(gc.name, "min_southeast_hydro");
495 assert!(gc.description.is_some());
496 assert_eq!(gc.sense, ConstraintSense::GreaterEqual);
497 assert!(gc.slack.enabled);
498 assert_eq!(gc.slack.penalty, Some(5_000.0));
499 }
500
501 #[test]
502 fn test_slack_config_disabled_has_no_penalty() {
503 let slack = SlackConfig {
504 enabled: false,
505 penalty: None,
506 };
507 assert!(!slack.enabled);
508 assert!(slack.penalty.is_none());
509 }
510
511 #[test]
512 fn test_constraint_sense_variants() {
513 assert_ne!(ConstraintSense::GreaterEqual, ConstraintSense::LessEqual);
514 assert_ne!(ConstraintSense::GreaterEqual, ConstraintSense::Equal);
515 assert_ne!(ConstraintSense::LessEqual, ConstraintSense::Equal);
516 }
517
518 #[test]
519 fn test_linear_term_with_coefficient() {
520 let term = LinearTerm {
521 coefficient: 2.5,
522 variable: VariableRef::ThermalGeneration {
523 thermal_id: EntityId(5),
524 block_id: None,
525 },
526 };
527 assert_eq!(term.coefficient, 2.5);
528 let debug = format!("{:?}", term.variable);
529 assert!(debug.contains("ThermalGeneration"));
530 }
531
532 #[test]
533 fn test_variable_ref_block_none_vs_some() {
534 let all_blocks = VariableRef::HydroTurbined {
535 hydro_id: EntityId(3),
536 block_id: None,
537 };
538 let specific_block = VariableRef::HydroTurbined {
539 hydro_id: EntityId(3),
540 block_id: Some(0),
541 };
542 assert_ne!(all_blocks, specific_block);
543 }
544
545 #[cfg(feature = "serde")]
546 #[test]
547 fn test_generic_constraint_serde_roundtrip() {
548 let gc = GenericConstraint {
549 id: EntityId(0),
550 name: "test".to_string(),
551 description: None,
552 expression: ConstraintExpression {
553 terms: vec![
554 LinearTerm {
555 coefficient: 1.0,
556 variable: VariableRef::HydroGeneration {
557 hydro_id: EntityId(10),
558 block_id: None,
559 },
560 },
561 LinearTerm {
562 coefficient: 1.0,
563 variable: VariableRef::HydroGeneration {
564 hydro_id: EntityId(11),
565 block_id: None,
566 },
567 },
568 ],
569 },
570 sense: ConstraintSense::GreaterEqual,
571 slack: SlackConfig {
572 enabled: true,
573 penalty: Some(5_000.0),
574 },
575 };
576
577 let json = serde_json::to_string(&gc).unwrap();
578 let deserialized: GenericConstraint = serde_json::from_str(&json).unwrap();
579 assert_eq!(gc, deserialized);
580 assert_eq!(deserialized.expression.terms.len(), 2);
581 }
582}