Skip to main content

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}