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}