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 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}