1use crate::commodity::{CommodityID, CommodityMap, CommodityType};
3use crate::process::{ProcessID, ProcessMap};
4use crate::region::RegionID;
5use crate::time_slice::{TimeSliceInfo, TimeSliceLevel, TimeSliceSelection};
6use crate::units::{Dimensionless, Flow};
7use anyhow::{Context, Result, anyhow, ensure};
8use indexmap::IndexSet;
9use itertools::{Itertools, iproduct};
10use petgraph::Directed;
11use petgraph::algo::toposort;
12use petgraph::dot::Dot;
13use petgraph::graph::{EdgeReference, Graph};
14use petgraph::visit::EdgeFiltered;
15use std::collections::HashMap;
16use std::fmt::Display;
17use std::fs::File;
18use std::io::Write as IoWrite;
19use std::path::Path;
20use strum::IntoEnumIterator;
21
22pub type CommoditiesGraph = Graph<GraphNode, GraphEdge, Directed>;
24
25#[derive(Eq, PartialEq, Clone, Hash)]
26pub enum GraphNode {
28 Commodity(CommodityID),
30 Source,
32 Sink,
34 Demand,
36}
37
38impl Display for GraphNode {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 match self {
41 GraphNode::Commodity(id) => write!(f, "{id}"),
42 GraphNode::Source => write!(f, "SOURCE"),
43 GraphNode::Sink => write!(f, "SINK"),
44 GraphNode::Demand => write!(f, "DEMAND"),
45 }
46 }
47}
48
49#[derive(Eq, PartialEq, Clone, Hash)]
50pub enum GraphEdge {
52 Primary(ProcessID),
54 Secondary(ProcessID),
56 Demand,
58}
59
60impl Display for GraphEdge {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 match self {
63 GraphEdge::Primary(process_id) | GraphEdge::Secondary(process_id) => {
64 write!(f, "{process_id}")
65 }
66 GraphEdge::Demand => write!(f, "DEMAND"),
67 }
68 }
69}
70
71fn create_commodities_graph_for_region_year(
83 processes: &ProcessMap,
84 region_id: &RegionID,
85 year: u32,
86) -> CommoditiesGraph {
87 let mut graph = Graph::new();
88 let mut commodity_to_node_index = HashMap::new();
89
90 let key = (region_id.clone(), year);
91 for process in processes.values() {
92 let Some(flows) = process.flows.get(&key) else {
93 continue;
95 };
96
97 let mut outputs: Vec<_> = flows
99 .values()
100 .filter(|flow| flow.is_output())
101 .map(|flow| GraphNode::Commodity(flow.commodity.id.clone()))
102 .collect();
103
104 let mut inputs: Vec<_> = flows
106 .values()
107 .filter(|flow| flow.is_input())
108 .map(|flow| GraphNode::Commodity(flow.commodity.id.clone()))
109 .collect();
110
111 if inputs.is_empty() {
113 inputs.push(GraphNode::Source);
114 }
115 if outputs.is_empty() {
116 outputs.push(GraphNode::Sink);
117 }
118
119 let primary_output = &process.primary_output;
121
122 for (input, output) in iproduct!(inputs, outputs) {
125 let source_node_index = *commodity_to_node_index
126 .entry(input.clone())
127 .or_insert_with(|| graph.add_node(input.clone()));
128 let target_node_index = *commodity_to_node_index
129 .entry(output.clone())
130 .or_insert_with(|| graph.add_node(output.clone()));
131 let is_primary = match &output {
132 GraphNode::Commodity(commodity_id) => primary_output.as_ref() == Some(commodity_id),
133 _ => false,
134 };
135
136 graph.add_edge(
137 source_node_index,
138 target_node_index,
139 if is_primary {
140 GraphEdge::Primary(process.id.clone())
141 } else {
142 GraphEdge::Secondary(process.id.clone())
143 },
144 );
145 }
146 }
147
148 graph
149}
150
151fn prepare_commodities_graph_for_validation(
162 base_graph: &CommoditiesGraph,
163 processes: &ProcessMap,
164 commodities: &CommodityMap,
165 time_slice_info: &TimeSliceInfo,
166 region_id: &RegionID,
167 year: u32,
168 time_slice_selection: &TimeSliceSelection,
169) -> CommoditiesGraph {
170 let mut filtered_graph = base_graph.clone();
171
172 let key = (region_id.clone(), year);
175 filtered_graph.retain_edges(|graph, edge_idx| {
176 let process_id = match graph.edge_weight(edge_idx).unwrap() {
178 GraphEdge::Primary(process_id) | GraphEdge::Secondary(process_id) => process_id,
179 GraphEdge::Demand => panic!("Demand edges should not be present in the base graph"),
180 };
181 let process = &processes[process_id];
182
183 time_slice_selection
185 .iter(time_slice_info)
186 .any(|(time_slice, _)| {
187 let Some(limits_map) = process.activity_limits.get(&key) else {
188 return false;
189 };
190 limits_map
191 .get(time_slice)
192 .is_some_and(|avail| *avail.end() > Dimensionless(0.0))
193 })
194 });
195
196 let demand_node_index = filtered_graph.add_node(GraphNode::Demand);
200 for (commodity_id, commodity) in commodities {
201 if time_slice_selection.level() == commodity.time_slice_level
202 && commodity
203 .demand
204 .get(&(region_id.clone(), year, time_slice_selection.clone()))
205 .is_some_and(|&v| v > Flow(0.0))
206 {
207 let commodity_node = GraphNode::Commodity(commodity_id.clone());
208 let commodity_node_index = filtered_graph
209 .node_indices()
210 .find(|&idx| filtered_graph.node_weight(idx) == Some(&commodity_node))
211 .unwrap_or_else(|| {
212 filtered_graph.add_node(GraphNode::Commodity(commodity_id.clone()))
213 });
214 filtered_graph.add_edge(commodity_node_index, demand_node_index, GraphEdge::Demand);
215 }
216 }
217
218 filtered_graph
219}
220
221fn validate_commodities_graph(
231 graph: &CommoditiesGraph,
232 commodities: &CommodityMap,
233 time_slice_level: TimeSliceLevel,
234) -> Result<()> {
235 for node_idx in graph.node_indices() {
236 let graph_node = graph.node_weight(node_idx).unwrap();
238 let GraphNode::Commodity(commodity_id) = graph_node else {
239 continue;
241 };
242
243 let commodity = &commodities[commodity_id];
245 if commodity.time_slice_level != time_slice_level {
246 continue;
247 }
248
249 let has_incoming = graph
251 .edges_directed(node_idx, petgraph::Direction::Incoming)
252 .next()
253 .is_some();
254 let has_outgoing = graph
255 .edges_directed(node_idx, petgraph::Direction::Outgoing)
256 .next()
257 .is_some();
258
259 match commodity.kind {
261 CommodityType::ServiceDemand => {
262 let has_non_demand_outgoing = graph
264 .edges_directed(node_idx, petgraph::Direction::Outgoing)
265 .any(|edge| edge.weight() != &GraphEdge::Demand);
266 ensure!(
267 !has_non_demand_outgoing,
268 "SVD commodity {commodity_id} cannot be an input to a process"
269 );
270
271 let has_demand_edges = graph
273 .edges_directed(node_idx, petgraph::Direction::Outgoing)
274 .any(|edge| edge.weight() == &GraphEdge::Demand);
275 if has_demand_edges {
276 ensure!(
277 has_incoming,
278 "SVD commodity {commodity_id} is demanded but has no producers"
279 );
280 }
281 }
282 CommodityType::SupplyEqualsDemand => {
283 ensure!(
285 !has_outgoing || has_incoming,
286 "SED commodity {commodity_id} may be consumed but has no producers"
287 );
288 }
289 CommodityType::Other => {
290 ensure!(
292 !(has_incoming && has_outgoing),
293 "OTH commodity {commodity_id} cannot have both producers and consumers"
294 );
295 }
296 }
297 }
298
299 Ok(())
300}
301
302fn topo_sort_commodities(
306 graph: &CommoditiesGraph,
307 commodities: &CommodityMap,
308) -> Result<Vec<CommodityID>> {
309 let primary_graph =
311 EdgeFiltered::from_fn(graph, |edge| matches!(edge.weight(), GraphEdge::Primary(_)));
312
313 let order = toposort(&primary_graph, None).map_err(|cycle| {
315 let cycle_commodity = graph.node_weight(cycle.node_id()).unwrap().clone();
316 anyhow!("Cycle detected in commodity graph for commodity {cycle_commodity}")
317 })?;
318
319 let order = order
322 .iter()
323 .rev()
324 .filter_map(|node_idx| {
325 let GraphNode::Commodity(commodity_id) = graph.node_weight(*node_idx).unwrap() else {
327 return None;
329 };
330 let commodity = &commodities[commodity_id];
331
332 matches!(
334 commodity.kind,
335 CommodityType::ServiceDemand | CommodityType::SupplyEqualsDemand
336 )
337 .then(|| commodity_id.clone())
338 })
339 .collect();
340
341 Ok(order)
342}
343
344pub fn build_commodity_graphs_for_model(
348 processes: &ProcessMap,
349 region_ids: &IndexSet<RegionID>,
350 years: &[u32],
351) -> Result<HashMap<(RegionID, u32), CommoditiesGraph>> {
352 let commodity_graphs: HashMap<(RegionID, u32), CommoditiesGraph> =
353 iproduct!(region_ids, years.iter())
354 .map(|(region_id, year)| {
355 let graph = create_commodities_graph_for_region_year(processes, region_id, *year);
356 ((region_id.clone(), *year), graph)
357 })
358 .collect();
359
360 Ok(commodity_graphs)
361}
362
363pub fn validate_commodity_graphs_for_model(
394 commodity_graphs: &HashMap<(RegionID, u32), CommoditiesGraph>,
395 processes: &ProcessMap,
396 commodities: &CommodityMap,
397 time_slice_info: &TimeSliceInfo,
398) -> Result<HashMap<(RegionID, u32), Vec<CommodityID>>> {
399 let commodity_order: HashMap<(RegionID, u32), Vec<CommodityID>> = commodity_graphs
401 .iter()
402 .map(|((region_id, year), graph)| -> Result<_> {
403 let order = topo_sort_commodities(graph, commodities).with_context(|| {
404 format!("Error validating commodity graph for {region_id} in {year}")
405 })?;
406 Ok(((region_id.clone(), *year), order))
407 })
408 .try_collect()?;
409
410 for ((region_id, year), base_graph) in commodity_graphs {
412 for ts_level in TimeSliceLevel::iter() {
413 for ts_selection in time_slice_info.iter_selections_at_level(ts_level) {
414 let graph = prepare_commodities_graph_for_validation(
415 base_graph,
416 processes,
417 commodities,
418 time_slice_info,
419 region_id,
420 *year,
421 &ts_selection,
422 );
423 validate_commodities_graph(&graph, commodities, ts_level).with_context(|| {
424 format!(
425 "Error validating commodity graph for \
426 {region_id} in {year} in {ts_selection}"
427 )
428 })?;
429 }
430 }
431 }
432
433 Ok(commodity_order)
435}
436
437fn get_edge_attributes(_: &CommoditiesGraph, edge_ref: EdgeReference<GraphEdge>) -> String {
439 match edge_ref.weight() {
440 GraphEdge::Secondary(_) => "style=dashed".to_string(),
442 _ => String::new(),
444 }
445}
446
447pub fn save_commodity_graphs_for_model(
451 commodity_graphs: &HashMap<(RegionID, u32), CommoditiesGraph>,
452 output_path: &Path,
453) -> Result<()> {
454 for ((region_id, year), graph) in commodity_graphs {
455 let dot = Dot::with_attr_getters(
456 graph,
457 &[],
458 &get_edge_attributes, &|_, _| String::new(), );
461 let mut file = File::create(output_path.join(format!("{region_id}_{year}.dot")))?;
462 write!(file, "{dot}")?;
463 }
464 Ok(())
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470 use crate::commodity::Commodity;
471 use crate::fixture::{assert_error, other_commodity, sed_commodity, svd_commodity};
472 use petgraph::graph::Graph;
473 use rstest::rstest;
474 use std::rc::Rc;
475
476 #[rstest]
477 fn test_topo_sort_linear_graph(sed_commodity: Commodity, svd_commodity: Commodity) {
478 let mut graph = Graph::new();
480
481 let node_a = graph.add_node(GraphNode::Commodity("A".into()));
482 let node_b = graph.add_node(GraphNode::Commodity("B".into()));
483 let node_c = graph.add_node(GraphNode::Commodity("C".into()));
484
485 graph.add_edge(node_a, node_b, GraphEdge::Primary("process1".into()));
487 graph.add_edge(node_b, node_c, GraphEdge::Primary("process2".into()));
488
489 let mut commodities = CommodityMap::new();
491 commodities.insert("A".into(), Rc::new(sed_commodity.clone()));
492 commodities.insert("B".into(), Rc::new(sed_commodity));
493 commodities.insert("C".into(), Rc::new(svd_commodity));
494
495 let result = topo_sort_commodities(&graph, &commodities).unwrap();
496
497 assert_eq!(result.len(), 3);
499 assert_eq!(result[0], "C".into());
500 assert_eq!(result[1], "B".into());
501 assert_eq!(result[2], "A".into());
502 }
503
504 #[rstest]
505 fn test_topo_sort_cyclic_graph(sed_commodity: Commodity) {
506 let mut graph = Graph::new();
508
509 let node_a = graph.add_node(GraphNode::Commodity("A".into()));
510 let node_b = graph.add_node(GraphNode::Commodity("B".into()));
511
512 graph.add_edge(node_a, node_b, GraphEdge::Primary("process1".into()));
514 graph.add_edge(node_b, node_a, GraphEdge::Primary("process2".into()));
515
516 let mut commodities = CommodityMap::new();
518 commodities.insert("A".into(), Rc::new(sed_commodity.clone()));
519 commodities.insert("B".into(), Rc::new(sed_commodity));
520
521 let result = topo_sort_commodities(&graph, &commodities);
525 assert_error!(result, "Cycle detected in commodity graph for commodity B");
526 }
527
528 #[rstest]
529 fn test_validate_commodities_graph(
530 other_commodity: Commodity,
531 sed_commodity: Commodity,
532 svd_commodity: Commodity,
533 ) {
534 let mut graph = Graph::new();
535 let mut commodities = CommodityMap::new();
536
537 commodities.insert("A".into(), Rc::new(other_commodity));
539 commodities.insert("B".into(), Rc::new(sed_commodity));
540 commodities.insert("C".into(), Rc::new(svd_commodity));
541
542 let node_a = graph.add_node(GraphNode::Commodity("A".into()));
544 let node_b = graph.add_node(GraphNode::Commodity("B".into()));
545 let node_c = graph.add_node(GraphNode::Commodity("C".into()));
546 let node_d = graph.add_node(GraphNode::Demand);
547 graph.add_edge(node_a, node_b, GraphEdge::Primary("process1".into()));
548 graph.add_edge(node_b, node_c, GraphEdge::Primary("process2".into()));
549 graph.add_edge(node_c, node_d, GraphEdge::Demand);
550
551 let result = validate_commodities_graph(&graph, &commodities, TimeSliceLevel::Annual);
553 assert!(result.is_ok());
554 }
555
556 #[rstest]
557 fn test_validate_commodities_graph_invalid_svd_consumed(
558 svd_commodity: Commodity,
559 sed_commodity: Commodity,
560 other_commodity: Commodity,
561 ) {
562 let mut graph = Graph::new();
563 let mut commodities = CommodityMap::new();
564
565 commodities.insert("A".into(), Rc::new(svd_commodity));
567 commodities.insert("B".into(), Rc::new(sed_commodity));
568 commodities.insert("C".into(), Rc::new(other_commodity));
569
570 let node_c = graph.add_node(GraphNode::Commodity("C".into()));
572 let node_a = graph.add_node(GraphNode::Commodity("A".into()));
573 let node_b = graph.add_node(GraphNode::Commodity("B".into()));
574 graph.add_edge(node_c, node_a, GraphEdge::Primary("process1".into()));
575 graph.add_edge(node_a, node_b, GraphEdge::Primary("process2".into()));
576
577 let result = validate_commodities_graph(&graph, &commodities, TimeSliceLevel::DayNight);
579 assert_error!(result, "SVD commodity A cannot be an input to a process");
580 }
581
582 #[rstest]
583 fn test_validate_commodities_graph_invalid_svd_not_produced(svd_commodity: Commodity) {
584 let mut graph = Graph::new();
585 let mut commodities = CommodityMap::new();
586
587 commodities.insert("A".into(), Rc::new(svd_commodity));
589
590 let node_a = graph.add_node(GraphNode::Commodity("A".into()));
592 let node_b = graph.add_node(GraphNode::Demand);
593 graph.add_edge(node_a, node_b, GraphEdge::Demand);
594
595 let result = validate_commodities_graph(&graph, &commodities, TimeSliceLevel::DayNight);
597 assert_error!(result, "SVD commodity A is demanded but has no producers");
598 }
599
600 #[rstest]
601 fn test_validate_commodities_graph_invalid_sed(sed_commodity: Commodity) {
602 let mut graph = Graph::new();
603 let mut commodities = CommodityMap::new();
604
605 commodities.insert("A".into(), Rc::new(sed_commodity.clone()));
607 commodities.insert("B".into(), Rc::new(sed_commodity));
608
609 let node_a = graph.add_node(GraphNode::Commodity("A".into()));
611 let node_b = graph.add_node(GraphNode::Commodity("B".into()));
612 graph.add_edge(node_b, node_a, GraphEdge::Primary("process1".into()));
613
614 let result = validate_commodities_graph(&graph, &commodities, TimeSliceLevel::DayNight);
616 assert_error!(
617 result,
618 "SED commodity B may be consumed but has no producers"
619 );
620 }
621
622 #[rstest]
623 fn test_validate_commodities_graph_invalid_oth(
624 other_commodity: Commodity,
625 sed_commodity: Commodity,
626 ) {
627 let mut graph = Graph::new();
628 let mut commodities = CommodityMap::new();
629
630 commodities.insert("A".into(), Rc::new(other_commodity));
632 commodities.insert("B".into(), Rc::new(sed_commodity.clone()));
633 commodities.insert("C".into(), Rc::new(sed_commodity));
634
635 let node_a = graph.add_node(GraphNode::Commodity("A".into()));
637 let node_b = graph.add_node(GraphNode::Commodity("B".into()));
638 let node_c = graph.add_node(GraphNode::Commodity("C".into()));
639 graph.add_edge(node_b, node_a, GraphEdge::Primary("process1".into()));
640 graph.add_edge(node_a, node_c, GraphEdge::Primary("process2".into()));
641
642 let result = validate_commodities_graph(&graph, &commodities, TimeSliceLevel::DayNight);
644 assert_error!(
645 result,
646 "OTH commodity A cannot have both producers and consumers"
647 );
648 }
649}