muse2/
graph.rs

1//! Module for creating and analysing commodity graphs
2use 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
21/// A graph of commodity flows for a given region and year
22pub type CommoditiesGraph = Graph<GraphNode, GraphEdge, Directed>;
23
24#[derive(Eq, PartialEq, Clone, Hash)]
25/// A node in the commodity graph
26pub enum GraphNode {
27    /// A node representing a commodity
28    Commodity(CommodityID),
29    /// A source node for processes that have no inputs
30    Source,
31    /// A sink node for processes that have no outputs
32    Sink,
33    /// A demand node for commodities with service demands
34    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)]
49/// An edge in the commodity graph
50pub enum GraphEdge {
51    /// An edge representing a process
52    Process(ProcessID),
53    /// An edge representing a service demand
54    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
66/// Creates a directed graph of commodity flows for a given region and year.
67///
68/// The graph contains nodes for all commodities that may be consumed/produced by processes in the
69/// specified region/year. There will be an edge from commodity A to B if there exists a process
70/// that consumes A and produces B.
71///
72/// There are also special `Source` and `Sink` nodes, which are used for processes that have no
73/// inputs or outputs.
74///
75/// The graph does not take into account process availabilities or commodity demands, both of which
76/// can vary by time slice. See `prepare_commodities_graph_for_validation`.
77fn 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            // Process doesn't operate in this region/year
89            continue;
90        };
91
92        // Get output nodes for the process
93        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        // Get input nodes for the process
100        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        // Use `Source` node if no inputs, `Sink` node if no outputs
107        if inputs.is_empty() {
108            inputs.push(GraphNode::Source);
109        }
110        if outputs.is_empty() {
111            outputs.push(GraphNode::Sink);
112        }
113
114        // Create edges from all inputs to all outputs
115        // We also create nodes the first time they are encountered
116        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
134/// Prepares a graph for validation with [`validate_commodities_graph`].
135///
136/// It takes a base graph produced by `create_commodities_graph_for_region_year`, and modifies it to
137/// account for process availabilities and commodity demands within the given time slice selection,
138/// returning a new graph.
139///
140/// Commodity demands are represented by the `Demand` node. We only add edges to the `Demand` node
141/// for commodities with the same `time_slice_level` as the selection. Other demands can be ignored
142/// since this graph will only be validated for commodities with the same `time_slice_level` as the
143/// selection.
144fn 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    // Filter by process availability
156    // We keep edges if the process has availability > 0 in any time slice in the selection
157    let key = (region_id.clone(), year);
158    filtered_graph.retain_edges(|graph, edge_idx| {
159        // Get the process for the edge
160        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        // Check if the process has availability > 0 in any time slice in the selection
166
167        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    // Add demand edges
180    // We add edges to the `Demand` node for commodities that are demanded in the selection
181    // NOTE: we only do this for commodities with the same time_slice_level as the selection
182    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
204/// Validates that the commodity graph follows the rules for different commodity types.
205///
206/// It takes as input a graph created by `create_commodities_graph_for_validation`, which is built
207/// for a specific time slice selection (must match the `time_slice_level` passed to this function).
208///
209/// The validation is only performed for commodities with the specified time slice level. For full
210/// validation of all commodities in the model, we therefore need to run this function for all time
211/// slice selections at all time slice levels. This is handled by
212/// [`validate_commodity_graphs_for_model`].
213fn 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        // Get the commodity ID for the node
220        let graph_node = graph.node_weight(node_idx).unwrap();
221        let GraphNode::Commodity(commodity_id) = graph_node else {
222            // Skip special nodes
223            continue;
224        };
225
226        // Only validate commodities with the specified time slice level
227        let commodity = &commodities[commodity_id];
228        if commodity.time_slice_level != time_slice_level {
229            continue;
230        }
231
232        // Count the incoming and outgoing edges for the commodity
233        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 validation rules to commodity type
243        match commodity.kind {
244            CommodityType::ServiceDemand => {
245                // Cannot have outgoing `Process` (non-`Demand`) edges
246                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                // If it has `Demand` edges, it must have at least one producer
255                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                // SED: if consumed (outgoing edges), must also be produced (incoming edges)
267                ensure!(
268                    !has_outgoing || has_incoming,
269                    "SED commodity {commodity_id} may be consumed but has no producers"
270                );
271            }
272            CommodityType::Other => {
273                // OTH: cannot have both incoming and outgoing edges
274                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
285/// Performs topological sort on the commodity graph to get the ordering for investments
286///
287/// The returned Vec only includes SVD and SED commodities.
288fn topo_sort_commodities(
289    graph: &CommoditiesGraph,
290    commodities: &CommodityMap,
291) -> Result<Vec<CommodityID>> {
292    // Perform a topological sort on the graph
293    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    // We return the order in reverse so that leaf-node commodities are solved first
299    // We also filter to only include SVD and SED commodities
300    let order = order
301        .iter()
302        .rev()
303        .filter_map(|node_idx| {
304            // Get the commodity for the node
305            let GraphNode::Commodity(commodity_id) = graph.node_weight(*node_idx).unwrap() else {
306                // Skip special nodes
307                return None;
308            };
309            let commodity = &commodities[commodity_id];
310
311            // Only include SVD and SED commodities
312            matches!(
313                commodity.kind,
314                CommodityType::ServiceDemand | CommodityType::SupplyEqualsDemand
315            )
316            .then(|| commodity_id.clone())
317        })
318        .collect();
319
320    Ok(order)
321}
322
323/// Builds base commodity graphs for each region and year
324///
325/// These do not take into account demand and process availability
326pub 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
342/// Validates commodity graphs for the entire model.
343///
344/// This function creates commodity flow graphs for each region/year combination in the model,
345/// validates the graph structure against commodity type rules, and determines the optimal
346/// investment order for commodities.
347///
348/// The validation process checks three time slice levels:
349/// - **Annual**: Validates annual-level commodities and processes
350/// - **Seasonal**: Validates seasonal-level commodities and processes for each season
351/// - **Day/Night**: Validates day/night-level commodities and processes for each time slice
352///
353/// # Arguments
354///
355/// * `processes` - All processes in the model with their flows and activity limits
356/// * `commodities` - All commodities with their types and demand specifications
357/// * `region_ids` - Collection of regions to model
358/// * `years` - Years to analyse
359/// * `time_slice_info` - Time slice configuration (seasons, day/night periods)
360///
361/// # Returns
362///
363/// A map from `(region, year)` to the ordered list of commodities for investment decisions. The
364/// ordering ensures that leaf-node commodities (those with no outgoing edges) are solved first.
365///
366/// # Errors
367///
368/// Returns an error if:
369/// - Any commodity graph contains cycles
370/// - Commodity type rules are violated (e.g., SVD commodities being consumed)
371/// - Demand cannot be satisfied
372pub 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    // Determine commodity ordering for each region and year
379    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    // Validate graphs at all time slice levels (taking into account process availability and demand)
390    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    // If all the validation passes, return the commodity ordering
413    Ok(commodity_order)
414}
415
416/// Saves commodity graphs to file
417///
418/// The graphs are saved as DOT files to the specified output path
419pub 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        // Create a simple linear graph: A -> B -> C
443        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        // Add edges: A -> B -> C
450        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        // Create commodities map using fixtures
454        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        // Expected order: C, B, A (leaf nodes first)
462        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        // Create a simple cyclic graph: A -> B -> A
471        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        // Add edges creating a cycle: A -> B -> A
477        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        // Create commodities map using fixtures
481        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        // This should return an error due to the cycle
486        // The error message should flag commodity B
487        // Note: A is also involved in the cycle, but B is flagged as it is encountered first
488        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        // Add test commodities (all have DayNight time slice level)
502        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        // Build valid graph: A(OTH) -> B(SED) -> C(SVD) ->D(DEMAND)
507        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        // Validate the graph at DayNight level
516        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        // Add test commodities (all have DayNight time slice level)
530        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        // Build invalid graph: C(OTH) -> A(SVD) -> B(SED) - SVD cannot be consumed
535        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        // Validate the graph at DayNight level
542        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        // Add test commodities (all have DayNight time slice level)
552        commodities.insert("A".into(), Rc::new(svd_commodity));
553
554        // Build invalid graph: A(SVD) -> B(DEMAND) - SVD must be produced
555        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        // Validate the graph at DayNight level
560        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        // Add test commodities (all have DayNight time slice level)
570        commodities.insert("A".into(), Rc::new(sed_commodity.clone()));
571        commodities.insert("B".into(), Rc::new(sed_commodity));
572
573        // Build invalid graph: B(SED) -> A(SED)
574        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        // Validate the graph at DayNight level
579        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        // Add test commodities (all have DayNight time slice level)
595        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        // Build invalid graph: B(SED) -> A(OTH) -> C(SED)
600        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        // Validate the graph at DayNight level
607        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}