Skip to main content

cobre_core/topology/
network.rs

1//! Resolved electrical transmission network topology.
2//!
3//! `NetworkTopology` holds the validated adjacency structure derived from the
4//! `Line` entity collection. It is built during case loading after all `Bus` and
5//! `Line` entities have been validated and their cross-references verified.
6
7use std::collections::HashMap;
8use std::sync::OnceLock;
9
10use crate::entities::Bus;
11use crate::{
12    EnergyContract, EntityId, Hydro, Line, NonControllableSource, PumpingStation, Thermal,
13};
14
15/// A line connection from a bus perspective.
16///
17/// Describes whether a bus is the source or target end of a transmission line,
18/// and which line it refers to. Used in bus-line incidence lookups.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
21pub struct BusLineConnection {
22    /// The line's entity ID.
23    pub line_id: EntityId,
24    /// True if this bus is the line's source (direct flow direction).
25    /// False if this bus is the line's target (reverse flow direction).
26    pub is_source: bool,
27}
28
29/// Generator entities connected to a bus.
30///
31/// Groups hydro, thermal, and non-controllable source IDs by type. All ID
32/// lists are in canonical ascending-`i32` order for determinism.
33#[derive(Debug, Clone, PartialEq, Default)]
34#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
35pub struct BusGenerators {
36    /// Hydro plant IDs connected to this bus.
37    pub hydro_ids: Vec<EntityId>,
38    /// Thermal plant IDs connected to this bus.
39    pub thermal_ids: Vec<EntityId>,
40    /// Non-controllable source IDs connected to this bus.
41    pub ncs_ids: Vec<EntityId>,
42}
43
44/// Load/demand entities connected to a bus.
45///
46/// Groups energy contract and pumping station IDs. All ID lists are in
47/// canonical ascending-`i32` order for determinism.
48#[derive(Debug, Clone, PartialEq, Default)]
49#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
50pub struct BusLoads {
51    /// Energy contract IDs at this bus.
52    pub contract_ids: Vec<EntityId>,
53    /// Pumping station IDs consuming power at this bus.
54    pub pumping_station_ids: Vec<EntityId>,
55}
56
57/// Resolved transmission network topology for buses and lines.
58///
59/// Provides O(1) lookup for bus-line incidence, bus-to-generator maps,
60/// and bus-to-load maps. Built from entity collections during System
61/// construction and immutable thereafter.
62///
63/// Used for power balance constraint generation.
64// The three private fields all share the `bus_` prefix intentionally: they form a
65// cohesive group keyed by bus identity. The prefix mirrors the public accessor names
66// and the spec's field names, making the code self-documenting.
67#[allow(clippy::struct_field_names)]
68#[derive(Debug, Clone, PartialEq)]
69#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
70pub struct NetworkTopology {
71    /// Bus-line incidence: `bus_id` -> list of (`line_id`, `is_source`).
72    /// `is_source` is true when the bus is the source (direct flow direction).
73    bus_lines: HashMap<EntityId, Vec<BusLineConnection>>,
74
75    /// Bus generation map: `bus_id` -> list of generator IDs by type.
76    bus_generators: HashMap<EntityId, BusGenerators>,
77
78    /// Bus load map: `bus_id` -> list of load/demand entity IDs.
79    bus_loads: HashMap<EntityId, BusLoads>,
80}
81
82/// Global default for buses with no generators. Used as fallback in `bus_generators`.
83static DEFAULT_BUS_GENERATORS: OnceLock<BusGenerators> = OnceLock::new();
84
85/// Global default for buses with no loads. Used as fallback in `bus_loads`.
86static DEFAULT_BUS_LOADS: OnceLock<BusLoads> = OnceLock::new();
87
88impl NetworkTopology {
89    /// Build network topology from entity collections.
90    ///
91    /// Constructs bus-line incidence, bus generation maps, and bus load maps
92    /// from the entity collections. Does not validate (no bus existence checks) --
93    /// validation is separate.
94    ///
95    /// All entity slices are assumed to be in canonical ID order.
96    ///
97    /// # Arguments
98    ///
99    /// * `buses` - All bus entities in canonical ID order.
100    /// * `lines` - All line entities in canonical ID order.
101    /// * `hydros` - All hydro plant entities in canonical ID order.
102    /// * `thermals` - All thermal plant entities in canonical ID order.
103    /// * `non_controllable_sources` - All NCS entities in canonical ID order.
104    /// * `contracts` - All energy contract entities in canonical ID order.
105    /// * `pumping_stations` - All pumping station entities in canonical ID order.
106    #[must_use]
107    pub fn build(
108        buses: &[Bus],
109        lines: &[Line],
110        hydros: &[Hydro],
111        thermals: &[Thermal],
112        non_controllable_sources: &[NonControllableSource],
113        contracts: &[EnergyContract],
114        pumping_stations: &[PumpingStation],
115    ) -> Self {
116        let mut bus_lines: HashMap<EntityId, Vec<BusLineConnection>> = HashMap::new();
117        let mut bus_generators: HashMap<EntityId, BusGenerators> = HashMap::new();
118        let mut bus_loads: HashMap<EntityId, BusLoads> = HashMap::new();
119
120        // TODO: use `buses` for disconnected-bus validation (ValidationError::DisconnectedBus)
121        let _ = buses;
122
123        for line in lines {
124            bus_lines
125                .entry(line.source_bus_id)
126                .or_default()
127                .push(BusLineConnection {
128                    line_id: line.id,
129                    is_source: true,
130                });
131            bus_lines
132                .entry(line.target_bus_id)
133                .or_default()
134                .push(BusLineConnection {
135                    line_id: line.id,
136                    is_source: false,
137                });
138        }
139        for connections in bus_lines.values_mut() {
140            connections.sort_by_key(|c| c.line_id.0);
141        }
142
143        for hydro in hydros {
144            bus_generators
145                .entry(hydro.bus_id)
146                .or_default()
147                .hydro_ids
148                .push(hydro.id);
149        }
150
151        for thermal in thermals {
152            bus_generators
153                .entry(thermal.bus_id)
154                .or_default()
155                .thermal_ids
156                .push(thermal.id);
157        }
158
159        for ncs in non_controllable_sources {
160            bus_generators
161                .entry(ncs.bus_id)
162                .or_default()
163                .ncs_ids
164                .push(ncs.id);
165        }
166
167        for generators in bus_generators.values_mut() {
168            generators.hydro_ids.sort_by_key(|id| id.0);
169            generators.thermal_ids.sort_by_key(|id| id.0);
170            generators.ncs_ids.sort_by_key(|id| id.0);
171        }
172
173        for contract in contracts {
174            bus_loads
175                .entry(contract.bus_id)
176                .or_default()
177                .contract_ids
178                .push(contract.id);
179        }
180
181        for station in pumping_stations {
182            bus_loads
183                .entry(station.bus_id)
184                .or_default()
185                .pumping_station_ids
186                .push(station.id);
187        }
188
189        for loads in bus_loads.values_mut() {
190            loads.contract_ids.sort_by_key(|id| id.0);
191            loads.pumping_station_ids.sort_by_key(|id| id.0);
192        }
193
194        Self {
195            bus_lines,
196            bus_generators,
197            bus_loads,
198        }
199    }
200
201    /// Returns the lines connected to a bus.
202    ///
203    /// Returns an empty slice if the bus has no connected lines.
204    #[must_use]
205    pub fn bus_lines(&self, bus_id: EntityId) -> &[BusLineConnection] {
206        self.bus_lines.get(&bus_id).map_or(&[], Vec::as_slice)
207    }
208
209    /// Returns the generators connected to a bus.
210    ///
211    /// Returns a reference to an empty `BusGenerators` if the bus has no generators.
212    #[must_use]
213    pub fn bus_generators(&self, bus_id: EntityId) -> &BusGenerators {
214        self.bus_generators
215            .get(&bus_id)
216            .unwrap_or_else(|| DEFAULT_BUS_GENERATORS.get_or_init(BusGenerators::default))
217    }
218
219    /// Returns the loads connected to a bus.
220    ///
221    /// Returns a reference to an empty `BusLoads` if the bus has no loads.
222    #[must_use]
223    pub fn bus_loads(&self, bus_id: EntityId) -> &BusLoads {
224        self.bus_loads
225            .get(&bus_id)
226            .unwrap_or_else(|| DEFAULT_BUS_LOADS.get_or_init(BusLoads::default))
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::entities::{ContractType, HydroGenerationModel, HydroPenalties, ThermalCostSegment};
234
235    fn make_bus(id: i32) -> Bus {
236        Bus {
237            id: EntityId(id),
238            name: String::new(),
239            deficit_segments: vec![],
240            excess_cost: 0.0,
241        }
242    }
243
244    fn make_line(id: i32, source_bus_id: i32, target_bus_id: i32) -> Line {
245        Line {
246            id: EntityId(id),
247            name: String::new(),
248            source_bus_id: EntityId(source_bus_id),
249            target_bus_id: EntityId(target_bus_id),
250            entry_stage_id: None,
251            exit_stage_id: None,
252            direct_capacity_mw: 100.0,
253            reverse_capacity_mw: 100.0,
254            losses_percent: 0.0,
255            exchange_cost: 0.0,
256        }
257    }
258
259    fn make_hydro(id: i32, bus_id: i32) -> Hydro {
260        let zero_penalties = HydroPenalties {
261            spillage_cost: 0.0,
262            diversion_cost: 0.0,
263            fpha_turbined_cost: 0.0,
264            storage_violation_below_cost: 0.0,
265            filling_target_violation_cost: 0.0,
266            turbined_violation_below_cost: 0.0,
267            outflow_violation_below_cost: 0.0,
268            outflow_violation_above_cost: 0.0,
269            generation_violation_below_cost: 0.0,
270            evaporation_violation_cost: 0.0,
271            water_withdrawal_violation_cost: 0.0,
272        };
273        Hydro {
274            id: EntityId(id),
275            name: String::new(),
276            bus_id: EntityId(bus_id),
277            downstream_id: None,
278            entry_stage_id: None,
279            exit_stage_id: None,
280            min_storage_hm3: 0.0,
281            max_storage_hm3: 1.0,
282            min_outflow_m3s: 0.0,
283            max_outflow_m3s: None,
284            generation_model: HydroGenerationModel::ConstantProductivity {
285                productivity_mw_per_m3s: 1.0,
286            },
287            min_turbined_m3s: 0.0,
288            max_turbined_m3s: 1.0,
289            min_generation_mw: 0.0,
290            max_generation_mw: 1.0,
291            tailrace: None,
292            hydraulic_losses: None,
293            efficiency: None,
294            evaporation_coefficients_mm: None,
295            evaporation_reference_volumes_hm3: None,
296            diversion: None,
297            filling: None,
298            penalties: zero_penalties,
299        }
300    }
301
302    fn make_thermal(id: i32, bus_id: i32) -> Thermal {
303        Thermal {
304            id: EntityId(id),
305            name: String::new(),
306            bus_id: EntityId(bus_id),
307            entry_stage_id: None,
308            exit_stage_id: None,
309            cost_segments: vec![ThermalCostSegment {
310                capacity_mw: 100.0,
311                cost_per_mwh: 50.0,
312            }],
313            min_generation_mw: 0.0,
314            max_generation_mw: 100.0,
315            gnl_config: None,
316        }
317    }
318
319    fn make_ncs(id: i32, bus_id: i32) -> NonControllableSource {
320        NonControllableSource {
321            id: EntityId(id),
322            name: String::new(),
323            bus_id: EntityId(bus_id),
324            entry_stage_id: None,
325            exit_stage_id: None,
326            max_generation_mw: 50.0,
327            curtailment_cost: 0.0,
328        }
329    }
330
331    fn make_contract(id: i32, bus_id: i32) -> EnergyContract {
332        EnergyContract {
333            id: EntityId(id),
334            name: String::new(),
335            bus_id: EntityId(bus_id),
336            contract_type: ContractType::Import,
337            entry_stage_id: None,
338            exit_stage_id: None,
339            price_per_mwh: 0.0,
340            min_mw: 0.0,
341            max_mw: 100.0,
342        }
343    }
344
345    fn make_pumping_station(id: i32, bus_id: i32) -> PumpingStation {
346        PumpingStation {
347            id: EntityId(id),
348            name: String::new(),
349            bus_id: EntityId(bus_id),
350            source_hydro_id: EntityId(0),
351            destination_hydro_id: EntityId(1),
352            entry_stage_id: None,
353            exit_stage_id: None,
354            consumption_mw_per_m3s: 0.5,
355            min_flow_m3s: 0.0,
356            max_flow_m3s: 10.0,
357        }
358    }
359
360    #[test]
361    fn test_empty_network() {
362        let topo = NetworkTopology::build(&[], &[], &[], &[], &[], &[], &[]);
363
364        // Any bus ID returns empty collections.
365        assert_eq!(topo.bus_lines(EntityId(0)), &[]);
366        assert!(topo.bus_generators(EntityId(0)).hydro_ids.is_empty());
367        assert!(topo.bus_generators(EntityId(0)).thermal_ids.is_empty());
368        assert!(topo.bus_generators(EntityId(0)).ncs_ids.is_empty());
369        assert!(topo.bus_loads(EntityId(0)).contract_ids.is_empty());
370        assert!(topo.bus_loads(EntityId(0)).pumping_station_ids.is_empty());
371    }
372
373    #[test]
374    fn test_single_line() {
375        // Line 0 connects bus 0 (source) -> bus 1 (target).
376        let buses = vec![make_bus(0), make_bus(1)];
377        let lines = vec![make_line(0, 0, 1)];
378        let topo = NetworkTopology::build(&buses, &lines, &[], &[], &[], &[], &[]);
379
380        // Bus 0 is the source.
381        let conns_0 = topo.bus_lines(EntityId(0));
382        assert_eq!(conns_0.len(), 1);
383        assert_eq!(conns_0[0].line_id, EntityId(0));
384        assert!(conns_0[0].is_source);
385
386        // Bus 1 is the target.
387        let conns_1 = topo.bus_lines(EntityId(1));
388        assert_eq!(conns_1.len(), 1);
389        assert_eq!(conns_1[0].line_id, EntityId(0));
390        assert!(!conns_1[0].is_source);
391    }
392
393    #[test]
394    fn test_multiple_lines_same_bus() {
395        // Bus 0 is source of lines 0, 1, 2; each targeting a different bus.
396        let buses = vec![make_bus(0), make_bus(1), make_bus(2), make_bus(3)];
397        let lines = vec![make_line(0, 0, 1), make_line(1, 0, 2), make_line(2, 0, 3)];
398        let topo = NetworkTopology::build(&buses, &lines, &[], &[], &[], &[], &[]);
399
400        let conns = topo.bus_lines(EntityId(0));
401        assert_eq!(conns.len(), 3);
402        // All connections belong to bus 0 as source.
403        assert!(conns.iter().all(|c| c.is_source));
404        // Sorted by line_id inner i32.
405        assert_eq!(conns[0].line_id, EntityId(0));
406        assert_eq!(conns[1].line_id, EntityId(1));
407        assert_eq!(conns[2].line_id, EntityId(2));
408    }
409
410    #[test]
411    fn test_generators_per_bus() {
412        // Bus 0: hydro 0, hydro 1, thermal 0.  Bus 1: NCS 0.
413        let buses = vec![make_bus(0), make_bus(1)];
414        let hydros = vec![make_hydro(0, 0), make_hydro(1, 0)];
415        let thermals = vec![make_thermal(0, 0)];
416        let ncs = vec![make_ncs(0, 1)];
417        let topo = NetworkTopology::build(&buses, &[], &hydros, &thermals, &ncs, &[], &[]);
418
419        let gen0 = topo.bus_generators(EntityId(0));
420        assert_eq!(gen0.hydro_ids.len(), 2);
421        assert_eq!(gen0.thermal_ids.len(), 1);
422        assert!(gen0.ncs_ids.is_empty());
423
424        let gen1 = topo.bus_generators(EntityId(1));
425        assert!(gen1.hydro_ids.is_empty());
426        assert!(gen1.thermal_ids.is_empty());
427        assert_eq!(gen1.ncs_ids.len(), 1);
428        assert_eq!(gen1.ncs_ids[0], EntityId(0));
429    }
430
431    #[test]
432    fn test_loads_per_bus() {
433        // Bus 0: contract 0, pumping station 0.
434        let buses = vec![make_bus(0)];
435        let contracts = vec![make_contract(0, 0)];
436        let stations = vec![make_pumping_station(0, 0)];
437        let topo = NetworkTopology::build(&buses, &[], &[], &[], &[], &contracts, &stations);
438
439        let loads0 = topo.bus_loads(EntityId(0));
440        assert_eq!(loads0.contract_ids.len(), 1);
441        assert_eq!(loads0.contract_ids[0], EntityId(0));
442        assert_eq!(loads0.pumping_station_ids.len(), 1);
443        assert_eq!(loads0.pumping_station_ids[0], EntityId(0));
444    }
445
446    #[test]
447    fn test_bus_no_connections() {
448        // Bus 0 exists but nothing is connected to it.
449        let buses = vec![make_bus(0)];
450        let topo = NetworkTopology::build(&buses, &[], &[], &[], &[], &[], &[]);
451
452        assert_eq!(topo.bus_lines(EntityId(0)), &[]);
453        let generators = topo.bus_generators(EntityId(0));
454        assert!(generators.hydro_ids.is_empty());
455        assert!(generators.thermal_ids.is_empty());
456        assert!(generators.ncs_ids.is_empty());
457        let loads = topo.bus_loads(EntityId(0));
458        assert!(loads.contract_ids.is_empty());
459        assert!(loads.pumping_station_ids.is_empty());
460    }
461
462    #[test]
463    fn test_deterministic_ordering() {
464        // Insert generators in reverse ID order; expect canonical ID-ascending order.
465        let buses = vec![make_bus(0)];
466        // Hydros with IDs 5, 3, 1 connected to bus 0 — supplied in reverse order.
467        let hydros = vec![make_hydro(5, 0), make_hydro(3, 0), make_hydro(1, 0)];
468        // Thermals with IDs 4, 2 connected to bus 0 — supplied in reverse order.
469        let thermals = vec![make_thermal(4, 0), make_thermal(2, 0)];
470        // Contracts with IDs 10, 7 connected to bus 0 — supplied in reverse order.
471        let contracts = vec![make_contract(10, 0), make_contract(7, 0)];
472        let topo = NetworkTopology::build(&buses, &[], &hydros, &thermals, &[], &contracts, &[]);
473
474        let generators = topo.bus_generators(EntityId(0));
475        // Hydro IDs must be sorted ascending: 1, 3, 5.
476        assert_eq!(
477            generators.hydro_ids,
478            vec![EntityId(1), EntityId(3), EntityId(5)]
479        );
480        // Thermal IDs must be sorted ascending: 2, 4.
481        assert_eq!(generators.thermal_ids, vec![EntityId(2), EntityId(4)]);
482
483        let loads = topo.bus_loads(EntityId(0));
484        // Contract IDs must be sorted ascending: 7, 10.
485        assert_eq!(loads.contract_ids, vec![EntityId(7), EntityId(10)]);
486    }
487
488    #[cfg(feature = "serde")]
489    #[test]
490    fn test_topology_serde_roundtrip_network() {
491        // Build a network with buses, lines, hydros, and thermals.
492        let buses = vec![make_bus(0), make_bus(1)];
493        let lines = vec![make_line(0, 0, 1)];
494        let hydros = vec![make_hydro(0, 0)];
495        let thermals = vec![make_thermal(0, 1)];
496        let topo = NetworkTopology::build(&buses, &lines, &hydros, &thermals, &[], &[], &[]);
497        let json = serde_json::to_string(&topo).unwrap();
498        let deserialized: NetworkTopology = serde_json::from_str(&json).unwrap();
499        assert_eq!(topo, deserialized);
500    }
501}