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            diversion: None,
296            filling: None,
297            penalties: zero_penalties,
298        }
299    }
300
301    fn make_thermal(id: i32, bus_id: i32) -> Thermal {
302        Thermal {
303            id: EntityId(id),
304            name: String::new(),
305            bus_id: EntityId(bus_id),
306            entry_stage_id: None,
307            exit_stage_id: None,
308            cost_segments: vec![ThermalCostSegment {
309                capacity_mw: 100.0,
310                cost_per_mwh: 50.0,
311            }],
312            min_generation_mw: 0.0,
313            max_generation_mw: 100.0,
314            gnl_config: None,
315        }
316    }
317
318    fn make_ncs(id: i32, bus_id: i32) -> NonControllableSource {
319        NonControllableSource {
320            id: EntityId(id),
321            name: String::new(),
322            bus_id: EntityId(bus_id),
323            entry_stage_id: None,
324            exit_stage_id: None,
325            max_generation_mw: 50.0,
326            curtailment_cost: 0.0,
327        }
328    }
329
330    fn make_contract(id: i32, bus_id: i32) -> EnergyContract {
331        EnergyContract {
332            id: EntityId(id),
333            name: String::new(),
334            bus_id: EntityId(bus_id),
335            contract_type: ContractType::Import,
336            entry_stage_id: None,
337            exit_stage_id: None,
338            price_per_mwh: 0.0,
339            min_mw: 0.0,
340            max_mw: 100.0,
341        }
342    }
343
344    fn make_pumping_station(id: i32, bus_id: i32) -> PumpingStation {
345        PumpingStation {
346            id: EntityId(id),
347            name: String::new(),
348            bus_id: EntityId(bus_id),
349            source_hydro_id: EntityId(0),
350            destination_hydro_id: EntityId(1),
351            entry_stage_id: None,
352            exit_stage_id: None,
353            consumption_mw_per_m3s: 0.5,
354            min_flow_m3s: 0.0,
355            max_flow_m3s: 10.0,
356        }
357    }
358
359    #[test]
360    fn test_empty_network() {
361        let topo = NetworkTopology::build(&[], &[], &[], &[], &[], &[], &[]);
362
363        // Any bus ID returns empty collections.
364        assert_eq!(topo.bus_lines(EntityId(0)), &[]);
365        assert!(topo.bus_generators(EntityId(0)).hydro_ids.is_empty());
366        assert!(topo.bus_generators(EntityId(0)).thermal_ids.is_empty());
367        assert!(topo.bus_generators(EntityId(0)).ncs_ids.is_empty());
368        assert!(topo.bus_loads(EntityId(0)).contract_ids.is_empty());
369        assert!(topo.bus_loads(EntityId(0)).pumping_station_ids.is_empty());
370    }
371
372    #[test]
373    fn test_single_line() {
374        // Line 0 connects bus 0 (source) -> bus 1 (target).
375        let buses = vec![make_bus(0), make_bus(1)];
376        let lines = vec![make_line(0, 0, 1)];
377        let topo = NetworkTopology::build(&buses, &lines, &[], &[], &[], &[], &[]);
378
379        // Bus 0 is the source.
380        let conns_0 = topo.bus_lines(EntityId(0));
381        assert_eq!(conns_0.len(), 1);
382        assert_eq!(conns_0[0].line_id, EntityId(0));
383        assert!(conns_0[0].is_source);
384
385        // Bus 1 is the target.
386        let conns_1 = topo.bus_lines(EntityId(1));
387        assert_eq!(conns_1.len(), 1);
388        assert_eq!(conns_1[0].line_id, EntityId(0));
389        assert!(!conns_1[0].is_source);
390    }
391
392    #[test]
393    fn test_multiple_lines_same_bus() {
394        // Bus 0 is source of lines 0, 1, 2; each targeting a different bus.
395        let buses = vec![make_bus(0), make_bus(1), make_bus(2), make_bus(3)];
396        let lines = vec![make_line(0, 0, 1), make_line(1, 0, 2), make_line(2, 0, 3)];
397        let topo = NetworkTopology::build(&buses, &lines, &[], &[], &[], &[], &[]);
398
399        let conns = topo.bus_lines(EntityId(0));
400        assert_eq!(conns.len(), 3);
401        // All connections belong to bus 0 as source.
402        assert!(conns.iter().all(|c| c.is_source));
403        // Sorted by line_id inner i32.
404        assert_eq!(conns[0].line_id, EntityId(0));
405        assert_eq!(conns[1].line_id, EntityId(1));
406        assert_eq!(conns[2].line_id, EntityId(2));
407    }
408
409    #[test]
410    fn test_generators_per_bus() {
411        // Bus 0: hydro 0, hydro 1, thermal 0.  Bus 1: NCS 0.
412        let buses = vec![make_bus(0), make_bus(1)];
413        let hydros = vec![make_hydro(0, 0), make_hydro(1, 0)];
414        let thermals = vec![make_thermal(0, 0)];
415        let ncs = vec![make_ncs(0, 1)];
416        let topo = NetworkTopology::build(&buses, &[], &hydros, &thermals, &ncs, &[], &[]);
417
418        let gen0 = topo.bus_generators(EntityId(0));
419        assert_eq!(gen0.hydro_ids.len(), 2);
420        assert_eq!(gen0.thermal_ids.len(), 1);
421        assert!(gen0.ncs_ids.is_empty());
422
423        let gen1 = topo.bus_generators(EntityId(1));
424        assert!(gen1.hydro_ids.is_empty());
425        assert!(gen1.thermal_ids.is_empty());
426        assert_eq!(gen1.ncs_ids.len(), 1);
427        assert_eq!(gen1.ncs_ids[0], EntityId(0));
428    }
429
430    #[test]
431    fn test_loads_per_bus() {
432        // Bus 0: contract 0, pumping station 0.
433        let buses = vec![make_bus(0)];
434        let contracts = vec![make_contract(0, 0)];
435        let stations = vec![make_pumping_station(0, 0)];
436        let topo = NetworkTopology::build(&buses, &[], &[], &[], &[], &contracts, &stations);
437
438        let loads0 = topo.bus_loads(EntityId(0));
439        assert_eq!(loads0.contract_ids.len(), 1);
440        assert_eq!(loads0.contract_ids[0], EntityId(0));
441        assert_eq!(loads0.pumping_station_ids.len(), 1);
442        assert_eq!(loads0.pumping_station_ids[0], EntityId(0));
443    }
444
445    #[test]
446    fn test_bus_no_connections() {
447        // Bus 0 exists but nothing is connected to it.
448        let buses = vec![make_bus(0)];
449        let topo = NetworkTopology::build(&buses, &[], &[], &[], &[], &[], &[]);
450
451        assert_eq!(topo.bus_lines(EntityId(0)), &[]);
452        let generators = topo.bus_generators(EntityId(0));
453        assert!(generators.hydro_ids.is_empty());
454        assert!(generators.thermal_ids.is_empty());
455        assert!(generators.ncs_ids.is_empty());
456        let loads = topo.bus_loads(EntityId(0));
457        assert!(loads.contract_ids.is_empty());
458        assert!(loads.pumping_station_ids.is_empty());
459    }
460
461    #[test]
462    fn test_deterministic_ordering() {
463        // Insert generators in reverse ID order; expect canonical ID-ascending order.
464        let buses = vec![make_bus(0)];
465        // Hydros with IDs 5, 3, 1 connected to bus 0 — supplied in reverse order.
466        let hydros = vec![make_hydro(5, 0), make_hydro(3, 0), make_hydro(1, 0)];
467        // Thermals with IDs 4, 2 connected to bus 0 — supplied in reverse order.
468        let thermals = vec![make_thermal(4, 0), make_thermal(2, 0)];
469        // Contracts with IDs 10, 7 connected to bus 0 — supplied in reverse order.
470        let contracts = vec![make_contract(10, 0), make_contract(7, 0)];
471        let topo = NetworkTopology::build(&buses, &[], &hydros, &thermals, &[], &contracts, &[]);
472
473        let generators = topo.bus_generators(EntityId(0));
474        // Hydro IDs must be sorted ascending: 1, 3, 5.
475        assert_eq!(
476            generators.hydro_ids,
477            vec![EntityId(1), EntityId(3), EntityId(5)]
478        );
479        // Thermal IDs must be sorted ascending: 2, 4.
480        assert_eq!(generators.thermal_ids, vec![EntityId(2), EntityId(4)]);
481
482        let loads = topo.bus_loads(EntityId(0));
483        // Contract IDs must be sorted ascending: 7, 10.
484        assert_eq!(loads.contract_ids, vec![EntityId(7), EntityId(10)]);
485    }
486
487    #[cfg(feature = "serde")]
488    #[test]
489    fn test_topology_serde_roundtrip_network() {
490        // Build a network with buses, lines, hydros, and thermals.
491        let buses = vec![make_bus(0), make_bus(1)];
492        let lines = vec![make_line(0, 0, 1)];
493        let hydros = vec![make_hydro(0, 0)];
494        let thermals = vec![make_thermal(0, 1)];
495        let topo = NetworkTopology::build(&buses, &lines, &hydros, &thermals, &[], &[], &[]);
496        let json = serde_json::to_string(&topo).unwrap();
497        let deserialized: NetworkTopology = serde_json::from_str(&json).unwrap();
498        assert_eq!(topo, deserialized);
499    }
500}