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::Graph;
14use std::collections::HashMap;
15use std::fmt::Display;
16use std::fs::File;
17use std::io::Write as IoWrite;
18use std::path::Path;
19use strum::IntoEnumIterator;
20
21pub type CommoditiesGraph = Graph<GraphNode, GraphEdge, Directed>;
23
24#[derive(Eq, PartialEq, Clone, Hash)]
25pub enum GraphNode {
27 Commodity(CommodityID),
29 Source,
31 Sink,
33 Demand,
35}
36
37impl Display for GraphNode {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 match self {
40 GraphNode::Commodity(id) => write!(f, "{id}"),
41 GraphNode::Source => write!(f, "SOURCE"),
42 GraphNode::Sink => write!(f, "SINK"),
43 GraphNode::Demand => write!(f, "DEMAND"),
44 }
45 }
46}
47
48#[derive(Eq, PartialEq, Clone, Hash)]
49pub enum GraphEdge {
51 Process(ProcessID),
53 Demand,
55}
56
57impl Display for GraphEdge {
58 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59 match self {
60 GraphEdge::Process(id) => write!(f, "{id}"),
61 GraphEdge::Demand => write!(f, "DEMAND"),
62 }
63 }
64}
65
66fn create_commodities_graph_for_region_year(
78 processes: &ProcessMap,
79 region_id: &RegionID,
80 year: u32,
81) -> CommoditiesGraph {
82 let mut graph = Graph::new();
83 let mut commodity_to_node_index = HashMap::new();
84
85 let key = (region_id.clone(), year);
86 for process in processes.values() {
87 let Some(flows) = process.flows.get(&key) else {
88 continue;
90 };
91
92 let mut outputs: Vec<_> = flows
94 .values()
95 .filter(|flow| flow.is_output())
96 .map(|flow| GraphNode::Commodity(flow.commodity.id.clone()))
97 .collect();
98
99 let mut inputs: Vec<_> = flows
101 .values()
102 .filter(|flow| flow.is_input())
103 .map(|flow| GraphNode::Commodity(flow.commodity.id.clone()))
104 .collect();
105
106 if inputs.is_empty() {
108 inputs.push(GraphNode::Source);
109 }
110 if outputs.is_empty() {
111 outputs.push(GraphNode::Sink);
112 }
113
114 for (input, output) in iproduct!(inputs, outputs) {
117 let source_node_index = *commodity_to_node_index
118 .entry(input.clone())
119 .or_insert_with(|| graph.add_node(input.clone()));
120 let target_node_index = *commodity_to_node_index
121 .entry(output.clone())
122 .or_insert_with(|| graph.add_node(output.clone()));
123 graph.add_edge(
124 source_node_index,
125 target_node_index,
126 GraphEdge::Process(process.id.clone()),
127 );
128 }
129 }
130
131 graph
132}
133
134fn prepare_commodities_graph_for_validation(
145 base_graph: &CommoditiesGraph,
146 processes: &ProcessMap,
147 commodities: &CommodityMap,
148 time_slice_info: &TimeSliceInfo,
149 region_id: &RegionID,
150 year: u32,
151 time_slice_selection: &TimeSliceSelection,
152) -> CommoditiesGraph {
153 let mut filtered_graph = base_graph.clone();
154
155 let key = (region_id.clone(), year);
158 filtered_graph.retain_edges(|graph, edge_idx| {
159 let GraphEdge::Process(process_id) = graph.edge_weight(edge_idx).unwrap() else {
161 panic!("Demand edges should not be present in the base graph");
162 };
163 let process = &processes[process_id];
164
165 time_slice_selection
168 .iter(time_slice_info)
169 .any(|(time_slice, _)| {
170 let Some(limits_map) = process.activity_limits.get(&key) else {
171 return false;
172 };
173 limits_map
174 .get(time_slice)
175 .is_some_and(|avail| *avail.end() > Dimensionless(0.0))
176 })
177 });
178
179 let demand_node_index = filtered_graph.add_node(GraphNode::Demand);
183 for (commodity_id, commodity) in commodities {
184 if time_slice_selection.level() == commodity.time_slice_level
185 && commodity
186 .demand
187 .get(&(region_id.clone(), year, time_slice_selection.clone()))
188 .is_some_and(|&v| v > Flow(0.0))
189 {
190 let commodity_node = GraphNode::Commodity(commodity_id.clone());
191 let commodity_node_index = filtered_graph
192 .node_indices()
193 .find(|&idx| filtered_graph.node_weight(idx) == Some(&commodity_node))
194 .unwrap_or_else(|| {
195 filtered_graph.add_node(GraphNode::Commodity(commodity_id.clone()))
196 });
197 filtered_graph.add_edge(commodity_node_index, demand_node_index, GraphEdge::Demand);
198 }
199 }
200
201 filtered_graph
202}
203
204fn validate_commodities_graph(
214 graph: &CommoditiesGraph,
215 commodities: &CommodityMap,
216 time_slice_level: TimeSliceLevel,
217) -> Result<()> {
218 for node_idx in graph.node_indices() {
219 let graph_node = graph.node_weight(node_idx).unwrap();
221 let GraphNode::Commodity(commodity_id) = graph_node else {
222 continue;
224 };
225
226 let commodity = &commodities[commodity_id];
228 if commodity.time_slice_level != time_slice_level {
229 continue;
230 }
231
232 let has_incoming = graph
234 .edges_directed(node_idx, petgraph::Direction::Incoming)
235 .next()
236 .is_some();
237 let has_outgoing = graph
238 .edges_directed(node_idx, petgraph::Direction::Outgoing)
239 .next()
240 .is_some();
241
242 match commodity.kind {
244 CommodityType::ServiceDemand => {
245 let has_non_demand_outgoing = graph
247 .edges_directed(node_idx, petgraph::Direction::Outgoing)
248 .any(|edge| edge.weight() != &GraphEdge::Demand);
249 ensure!(
250 !has_non_demand_outgoing,
251 "SVD commodity {commodity_id} cannot be an input to a process"
252 );
253
254 let has_demand_edges = graph
256 .edges_directed(node_idx, petgraph::Direction::Outgoing)
257 .any(|edge| edge.weight() == &GraphEdge::Demand);
258 if has_demand_edges {
259 ensure!(
260 has_incoming,
261 "SVD commodity {commodity_id} is demanded but has no producers"
262 );
263 }
264 }
265 CommodityType::SupplyEqualsDemand => {
266 ensure!(
268 !has_outgoing || has_incoming,
269 "SED commodity {commodity_id} may be consumed but has no producers"
270 );
271 }
272 CommodityType::Other => {
273 ensure!(
275 !(has_incoming && has_outgoing),
276 "OTH commodity {commodity_id} cannot have both producers and consumers"
277 );
278 }
279 }
280 }
281
282 Ok(())
283}
284
285fn topo_sort_commodities(
289 graph: &CommoditiesGraph,
290 commodities: &CommodityMap,
291) -> Result<Vec<CommodityID>> {
292 let order = toposort(graph, None).map_err(|cycle| {
294 let cycle_commodity = graph.node_weight(cycle.node_id()).unwrap().clone();
295 anyhow!("Cycle detected in commodity graph for commodity {cycle_commodity}")
296 })?;
297
298 let order = order
301 .iter()
302 .rev()
303 .filter_map(|node_idx| {
304 let GraphNode::Commodity(commodity_id) = graph.node_weight(*node_idx).unwrap() else {
306 return None;
308 };
309 let commodity = &commodities[commodity_id];
310
311 matches!(
313 commodity.kind,
314 CommodityType::ServiceDemand | CommodityType::SupplyEqualsDemand
315 )
316 .then(|| commodity_id.clone())
317 })
318 .collect();
319
320 Ok(order)
321}
322
323pub fn build_commodity_graphs_for_model(
327 processes: &ProcessMap,
328 region_ids: &IndexSet<RegionID>,
329 years: &[u32],
330) -> Result<HashMap<(RegionID, u32), CommoditiesGraph>> {
331 let commodity_graphs: HashMap<(RegionID, u32), CommoditiesGraph> =
332 iproduct!(region_ids, years.iter())
333 .map(|(region_id, year)| {
334 let graph = create_commodities_graph_for_region_year(processes, region_id, *year);
335 ((region_id.clone(), *year), graph)
336 })
337 .collect();
338
339 Ok(commodity_graphs)
340}
341
342pub fn validate_commodity_graphs_for_model(
373 commodity_graphs: &HashMap<(RegionID, u32), CommoditiesGraph>,
374 processes: &ProcessMap,
375 commodities: &CommodityMap,
376 time_slice_info: &TimeSliceInfo,
377) -> Result<HashMap<(RegionID, u32), Vec<CommodityID>>> {
378 let commodity_order: HashMap<(RegionID, u32), Vec<CommodityID>> = commodity_graphs
380 .iter()
381 .map(|((region_id, year), graph)| -> Result<_> {
382 let order = topo_sort_commodities(graph, commodities).with_context(|| {
383 format!("Error validating commodity graph for {region_id} in {year}")
384 })?;
385 Ok(((region_id.clone(), *year), order))
386 })
387 .try_collect()?;
388
389 for ((region_id, year), base_graph) in commodity_graphs {
391 for ts_level in TimeSliceLevel::iter() {
392 for ts_selection in time_slice_info.iter_selections_at_level(ts_level) {
393 let graph = prepare_commodities_graph_for_validation(
394 base_graph,
395 processes,
396 commodities,
397 time_slice_info,
398 region_id,
399 *year,
400 &ts_selection,
401 );
402 validate_commodities_graph(&graph, commodities, ts_level).with_context(|| {
403 format!(
404 "Error validating commodity graph for \
405 {region_id} in {year} in {ts_selection}"
406 )
407 })?;
408 }
409 }
410 }
411
412 Ok(commodity_order)
414}
415
416pub fn save_commodity_graphs_for_model(
420 commodity_graphs: &HashMap<(RegionID, u32), CommoditiesGraph>,
421 output_path: &Path,
422) -> Result<()> {
423 for ((region_id, year), graph) in commodity_graphs {
424 let dot = Dot::new(&graph);
425 let mut file = File::create(output_path.join(format!("{region_id}_{year}.dot")))?;
426 write!(file, "{dot}")?;
427 }
428 Ok(())
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434 use crate::commodity::Commodity;
435 use crate::fixture::{assert_error, other_commodity, sed_commodity, svd_commodity};
436 use petgraph::graph::Graph;
437 use rstest::rstest;
438 use std::rc::Rc;
439
440 #[rstest]
441 fn test_topo_sort_linear_graph(sed_commodity: Commodity, svd_commodity: Commodity) {
442 let mut graph = Graph::new();
444
445 let node_a = graph.add_node(GraphNode::Commodity("A".into()));
446 let node_b = graph.add_node(GraphNode::Commodity("B".into()));
447 let node_c = graph.add_node(GraphNode::Commodity("C".into()));
448
449 graph.add_edge(node_a, node_b, GraphEdge::Process("process1".into()));
451 graph.add_edge(node_b, node_c, GraphEdge::Process("process2".into()));
452
453 let mut commodities = CommodityMap::new();
455 commodities.insert("A".into(), Rc::new(sed_commodity.clone()));
456 commodities.insert("B".into(), Rc::new(sed_commodity));
457 commodities.insert("C".into(), Rc::new(svd_commodity));
458
459 let result = topo_sort_commodities(&graph, &commodities).unwrap();
460
461 assert_eq!(result.len(), 3);
463 assert_eq!(result[0], "C".into());
464 assert_eq!(result[1], "B".into());
465 assert_eq!(result[2], "A".into());
466 }
467
468 #[rstest]
469 fn test_topo_sort_cyclic_graph(sed_commodity: Commodity) {
470 let mut graph = Graph::new();
472
473 let node_a = graph.add_node(GraphNode::Commodity("A".into()));
474 let node_b = graph.add_node(GraphNode::Commodity("B".into()));
475
476 graph.add_edge(node_a, node_b, GraphEdge::Process("process1".into()));
478 graph.add_edge(node_b, node_a, GraphEdge::Process("process2".into()));
479
480 let mut commodities = CommodityMap::new();
482 commodities.insert("A".into(), Rc::new(sed_commodity.clone()));
483 commodities.insert("B".into(), Rc::new(sed_commodity));
484
485 let result = topo_sort_commodities(&graph, &commodities);
489 assert_error!(result, "Cycle detected in commodity graph for commodity B");
490 }
491
492 #[rstest]
493 fn test_validate_commodities_graph(
494 other_commodity: Commodity,
495 sed_commodity: Commodity,
496 svd_commodity: Commodity,
497 ) {
498 let mut graph = Graph::new();
499 let mut commodities = CommodityMap::new();
500
501 commodities.insert("A".into(), Rc::new(other_commodity));
503 commodities.insert("B".into(), Rc::new(sed_commodity));
504 commodities.insert("C".into(), Rc::new(svd_commodity));
505
506 let node_a = graph.add_node(GraphNode::Commodity("A".into()));
508 let node_b = graph.add_node(GraphNode::Commodity("B".into()));
509 let node_c = graph.add_node(GraphNode::Commodity("C".into()));
510 let node_d = graph.add_node(GraphNode::Demand);
511 graph.add_edge(node_a, node_b, GraphEdge::Process("process1".into()));
512 graph.add_edge(node_b, node_c, GraphEdge::Process("process2".into()));
513 graph.add_edge(node_c, node_d, GraphEdge::Demand);
514
515 let result = validate_commodities_graph(&graph, &commodities, TimeSliceLevel::Annual);
517 assert!(result.is_ok());
518 }
519
520 #[rstest]
521 fn test_validate_commodities_graph_invalid_svd_consumed(
522 svd_commodity: Commodity,
523 sed_commodity: Commodity,
524 other_commodity: Commodity,
525 ) {
526 let mut graph = Graph::new();
527 let mut commodities = CommodityMap::new();
528
529 commodities.insert("A".into(), Rc::new(svd_commodity));
531 commodities.insert("B".into(), Rc::new(sed_commodity));
532 commodities.insert("C".into(), Rc::new(other_commodity));
533
534 let node_c = graph.add_node(GraphNode::Commodity("C".into()));
536 let node_a = graph.add_node(GraphNode::Commodity("A".into()));
537 let node_b = graph.add_node(GraphNode::Commodity("B".into()));
538 graph.add_edge(node_c, node_a, GraphEdge::Process("process1".into()));
539 graph.add_edge(node_a, node_b, GraphEdge::Process("process2".into()));
540
541 let result = validate_commodities_graph(&graph, &commodities, TimeSliceLevel::DayNight);
543 assert_error!(result, "SVD commodity A cannot be an input to a process");
544 }
545
546 #[rstest]
547 fn test_validate_commodities_graph_invalid_svd_not_produced(svd_commodity: Commodity) {
548 let mut graph = Graph::new();
549 let mut commodities = CommodityMap::new();
550
551 commodities.insert("A".into(), Rc::new(svd_commodity));
553
554 let node_a = graph.add_node(GraphNode::Commodity("A".into()));
556 let node_b = graph.add_node(GraphNode::Demand);
557 graph.add_edge(node_a, node_b, GraphEdge::Demand);
558
559 let result = validate_commodities_graph(&graph, &commodities, TimeSliceLevel::DayNight);
561 assert_error!(result, "SVD commodity A is demanded but has no producers");
562 }
563
564 #[rstest]
565 fn test_validate_commodities_graph_invalid_sed(sed_commodity: Commodity) {
566 let mut graph = Graph::new();
567 let mut commodities = CommodityMap::new();
568
569 commodities.insert("A".into(), Rc::new(sed_commodity.clone()));
571 commodities.insert("B".into(), Rc::new(sed_commodity));
572
573 let node_a = graph.add_node(GraphNode::Commodity("A".into()));
575 let node_b = graph.add_node(GraphNode::Commodity("B".into()));
576 graph.add_edge(node_b, node_a, GraphEdge::Process("process1".into()));
577
578 let result = validate_commodities_graph(&graph, &commodities, TimeSliceLevel::DayNight);
580 assert_error!(
581 result,
582 "SED commodity B may be consumed but has no producers"
583 );
584 }
585
586 #[rstest]
587 fn test_validate_commodities_graph_invalid_oth(
588 other_commodity: Commodity,
589 sed_commodity: Commodity,
590 ) {
591 let mut graph = Graph::new();
592 let mut commodities = CommodityMap::new();
593
594 commodities.insert("A".into(), Rc::new(other_commodity));
596 commodities.insert("B".into(), Rc::new(sed_commodity.clone()));
597 commodities.insert("C".into(), Rc::new(sed_commodity));
598
599 let node_a = graph.add_node(GraphNode::Commodity("A".into()));
601 let node_b = graph.add_node(GraphNode::Commodity("B".into()));
602 let node_c = graph.add_node(GraphNode::Commodity("C".into()));
603 graph.add_edge(node_b, node_a, GraphEdge::Process("process1".into()));
604 graph.add_edge(node_a, node_c, GraphEdge::Process("process2".into()));
605
606 let result = validate_commodities_graph(&graph, &commodities, TimeSliceLevel::DayNight);
608 assert_error!(
609 result,
610 "OTH commodity A cannot have both producers and consumers"
611 );
612 }
613}