Skip to main content

cobre_core/entities/
non_controllable.rs

1//! Non-controllable generation source entity — intermittent wind/solar.
2//!
3//! A `NonControllableSource` represents intermittent generation (wind, solar, run-of-river)
4//! that cannot be dispatched. Curtailment may be optionally allowed. This entity is a
5//! NO-OP stub: the type exists in the registry but contributes zero LP
6//! variables or constraints.
7
8use crate::EntityId;
9
10/// Intermittent generation source that cannot be dispatched.
11///
12/// A `NonControllableSource` injects all available generation into the network.
13/// If curtailment is permitted, excess generation can be curtailed at a cost of
14/// `curtailment_cost` per `MWh`. In the minimal viable solver this entity is
15/// data-complete but contributes no LP variables or constraints.
16///
17/// Source: `system/non_controllable.json`. See Input System Entities SS1.9.8.
18#[derive(Debug, Clone, PartialEq)]
19#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
20pub struct NonControllableSource {
21    /// Unique source identifier.
22    pub id: EntityId,
23    /// Human-readable source name.
24    pub name: String,
25    /// Bus to which this source's generation is injected.
26    pub bus_id: EntityId,
27    /// Stage index when the source enters service. None = always exists.
28    pub entry_stage_id: Option<i32>,
29    /// Stage index when the source is decommissioned. None = never decommissioned.
30    pub exit_stage_id: Option<i32>,
31    /// Maximum generation (installed capacity) \[MW\].
32    pub max_generation_mw: f64,
33    /// Resolved cost per `MWh` of curtailed generation \[$/`MWh`\].
34    ///
35    /// This is a resolved field — defaults are applied during loading so this
36    /// value is always ready for LP construction without further lookup.
37    pub curtailment_cost: f64,
38}
39
40#[cfg(test)]
41mod tests {
42    use super::*;
43
44    #[test]
45    fn test_non_controllable_construction() {
46        let source = NonControllableSource {
47            id: EntityId::from(1),
48            name: "Eólica Caetité".to_string(),
49            bus_id: EntityId::from(7),
50            entry_stage_id: None,
51            exit_stage_id: None,
52            max_generation_mw: 300.0,
53            curtailment_cost: 0.01,
54        };
55
56        assert_eq!(source.id, EntityId::from(1));
57        assert_eq!(source.name, "Eólica Caetité");
58        assert_eq!(source.bus_id, EntityId::from(7));
59        assert_eq!(source.entry_stage_id, None);
60        assert_eq!(source.exit_stage_id, None);
61        assert!((source.max_generation_mw - 300.0).abs() < f64::EPSILON);
62        assert!((source.curtailment_cost - 0.01).abs() < f64::EPSILON);
63    }
64
65    #[test]
66    fn test_non_controllable_curtailment_cost() {
67        let source = NonControllableSource {
68            id: EntityId::from(2),
69            name: "Solar Pirapora".to_string(),
70            bus_id: EntityId::from(3),
71            entry_stage_id: Some(12),
72            exit_stage_id: None,
73            max_generation_mw: 400.0,
74            curtailment_cost: 5.0,
75        };
76
77        assert!((source.curtailment_cost - 5.0).abs() < f64::EPSILON);
78    }
79}