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::graph::Graph;
13use std::collections::HashMap;
14use std::fmt::Display;
15
16/// A graph of commodity flows for a given region and year
17type CommoditiesGraph = Graph<GraphNode, GraphEdge, Directed>;
18
19#[derive(Eq, PartialEq, Clone, Hash)]
20/// A node in the commodity graph
21enum GraphNode {
22    /// A node representing a commodity
23    Commodity(CommodityID),
24    /// A source node for processes that have no inputs
25    Source,
26    /// A sink node for processes that have no outputs
27    Sink,
28    /// A demand node for commodities with service demands
29    Demand,
30}
31
32impl Display for GraphNode {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        match self {
35            GraphNode::Commodity(id) => write!(f, "{id}"),
36            GraphNode::Source => write!(f, "SOURCE"),
37            GraphNode::Sink => write!(f, "SINK"),
38            GraphNode::Demand => write!(f, "DEMAND"),
39        }
40    }
41}
42
43#[derive(Eq, PartialEq, Clone, Hash)]
44/// An edge in the commodity graph
45enum GraphEdge {
46    /// An edge representing a process
47    Process(ProcessID),
48    /// An edge representing a service demand
49    Demand,
50}
51
52/// Creates a directed graph of commodity flows for a given region and year.
53///
54/// The graph contains nodes for all commodities that may be consumed/produced by processes in the
55/// specified region/year. There will be an edge from commodity A to B if there exists a process
56/// that consumes A and produces B.
57///
58/// There are also special `Source` and `Sink` nodes, which are used for processes that have no
59/// inputs or outputs.
60///
61/// The graph does not take into account process availabilities or commodity demands, both of which
62/// can vary by time slice. See `prepare_commodities_graph_for_validation`.
63fn create_commodities_graph_for_region_year(
64    processes: &ProcessMap,
65    region_id: &RegionID,
66    year: u32,
67) -> CommoditiesGraph {
68    let mut graph = Graph::new();
69    let mut commodity_to_node_index = HashMap::new();
70
71    let key = (region_id.clone(), year);
72    for process in processes.values() {
73        let Some(flows) = process.flows.get(&key) else {
74            // Process doesn't operate in this region/year
75            continue;
76        };
77
78        // Get output nodes for the process
79        let mut outputs: Vec<_> = flows
80            .values()
81            .filter(|flow| flow.is_output())
82            .map(|flow| GraphNode::Commodity(flow.commodity.id.clone()))
83            .collect();
84
85        // Get input nodes for the process
86        let mut inputs: Vec<_> = flows
87            .values()
88            .filter(|flow| flow.is_input())
89            .map(|flow| GraphNode::Commodity(flow.commodity.id.clone()))
90            .collect();
91
92        // Use `Source` node if no inputs, `Sink` node if no outputs
93        if inputs.is_empty() {
94            inputs.push(GraphNode::Source);
95        }
96        if outputs.is_empty() {
97            outputs.push(GraphNode::Sink);
98        }
99
100        // Create edges from all inputs to all outputs
101        // We also create nodes the first time they are encountered
102        for (input, output) in iproduct!(inputs, outputs) {
103            let source_node_index = *commodity_to_node_index
104                .entry(input.clone())
105                .or_insert_with(|| graph.add_node(input.clone()));
106            let target_node_index = *commodity_to_node_index
107                .entry(output.clone())
108                .or_insert_with(|| graph.add_node(output.clone()));
109            graph.add_edge(
110                source_node_index,
111                target_node_index,
112                GraphEdge::Process(process.id.clone()),
113            );
114        }
115    }
116
117    graph
118}
119
120/// Prepares a graph for validation with `validate_commodities_graph`.
121///
122/// It takes a base graph produced by `create_commodities_graph_for_region_year`, and modifies it to
123/// account for process availabilities and commodity demands within the given time slice selection,
124/// returning a new graph.
125///
126/// Commodity demands are represented by the `Demand` node. We only add edges to the `Demand` node
127/// for commodities with the same time_slice_level as the selection. Other demands can be ignored
128/// since this graph will only be validated for commodities with the same time_slice_level as the
129/// selection.
130fn prepare_commodities_graph_for_validation(
131    base_graph: &CommoditiesGraph,
132    processes: &ProcessMap,
133    commodities: &CommodityMap,
134    time_slice_info: &TimeSliceInfo,
135    region_id: &RegionID,
136    year: u32,
137    time_slice_selection: &TimeSliceSelection,
138) -> CommoditiesGraph {
139    let mut filtered_graph = base_graph.clone();
140
141    // Filter by process availability
142    // We keep edges if the process has availability > 0 in any time slice in the selection
143    filtered_graph.retain_edges(|graph, edge_idx| {
144        // Get the process for the edge
145        let GraphEdge::Process(process_id) = graph.edge_weight(edge_idx).unwrap() else {
146            panic!("Demand edges should not be present in the base graph");
147        };
148        let process = &processes[process_id];
149
150        // Check if the process has availability > 0 in any time slice in the selection
151        time_slice_selection
152            .iter(time_slice_info)
153            .any(|(time_slice, _)| {
154                let key = (region_id.clone(), year, time_slice.clone());
155                process
156                    .activity_limits
157                    .get(&key)
158                    .is_some_and(|avail| *avail.end() > Dimensionless(0.0))
159            })
160    });
161
162    // Add demand edges
163    // We add edges to the `Demand` node for commodities that are demanded in the selection
164    // NOTE: we only do this for commodities with the same time_slice_level as the selection
165    let demand_node_index = filtered_graph.add_node(GraphNode::Demand);
166    for (commodity_id, commodity) in commodities {
167        if time_slice_selection.level() == commodity.time_slice_level
168            && commodity
169                .demand
170                .get(&(region_id.clone(), year, time_slice_selection.clone()))
171                .is_some_and(|&v| v > Flow(0.0))
172        {
173            let commodity_node = GraphNode::Commodity(commodity_id.clone());
174            let commodity_node_index = filtered_graph
175                .node_indices()
176                .find(|&idx| filtered_graph.node_weight(idx) == Some(&commodity_node))
177                .unwrap_or_else(|| {
178                    filtered_graph.add_node(GraphNode::Commodity(commodity_id.clone()))
179                });
180            filtered_graph.add_edge(commodity_node_index, demand_node_index, GraphEdge::Demand);
181        }
182    }
183
184    filtered_graph
185}
186
187/// Validates that the commodity graph follows the rules for different commodity types.
188///
189/// It takes as input a graph created by `create_commodities_graph_for_validation`, which is built
190/// for a specific time slice selection (must match the `time_slice_level` passed to this function).
191///
192/// The validation is only performed for commodities with the specified time slice level. For full
193/// validation of all commodities in the model, we therefore need to run this function for all time
194/// slice selections at all time slice levels. This is handled by
195/// `build_and_validate_commodity_graphs_for_model`.
196fn validate_commodities_graph(
197    graph: &CommoditiesGraph,
198    commodities: &CommodityMap,
199    time_slice_level: TimeSliceLevel,
200) -> Result<()> {
201    for node_idx in graph.node_indices() {
202        // Get the commodity ID for the node
203        let graph_node = graph.node_weight(node_idx).unwrap();
204        let commodity_id = match graph_node {
205            GraphNode::Commodity(id) => id,
206            // Skip special nodes
207            _ => continue,
208        };
209
210        // Only validate commodities with the specified time slice level
211        let commodity = &commodities[commodity_id];
212        if commodity.time_slice_level != time_slice_level {
213            continue;
214        }
215
216        // Count the incoming and outgoing edges for the commodity
217        let has_incoming = graph
218            .edges_directed(node_idx, petgraph::Direction::Incoming)
219            .next()
220            .is_some();
221        let has_outgoing = graph
222            .edges_directed(node_idx, petgraph::Direction::Outgoing)
223            .next()
224            .is_some();
225
226        // Match validation rules to commodity type
227        match commodity.kind {
228            CommodityType::ServiceDemand => {
229                // Cannot have outgoing `Process` (non-`Demand`) edges
230                let has_non_demand_outgoing = graph
231                    .edges_directed(node_idx, petgraph::Direction::Outgoing)
232                    .any(|edge| edge.weight() != &GraphEdge::Demand);
233                ensure!(
234                    !has_non_demand_outgoing,
235                    "SVD commodity {} cannot be an input to a process",
236                    commodity_id
237                );
238
239                // If it has `Demand` edges, it must have at least one producer
240                let has_demand_edges = graph
241                    .edges_directed(node_idx, petgraph::Direction::Outgoing)
242                    .any(|edge| edge.weight() == &GraphEdge::Demand);
243                if has_demand_edges {
244                    ensure!(
245                        has_incoming,
246                        "SVD commodity {} is demanded but has no producers",
247                        commodity_id
248                    );
249                }
250            }
251            CommodityType::SupplyEqualsDemand => {
252                // SED: if consumed (outgoing edges), must also be produced (incoming edges)
253                ensure!(
254                    !has_outgoing || has_incoming,
255                    "SED commodity {} may be consumed but has no producers",
256                    commodity_id
257                );
258            }
259            CommodityType::Other => {
260                // OTH: cannot have both incoming and outgoing edges
261                ensure!(
262                    !(has_incoming && has_outgoing),
263                    "OTH commodity {} cannot have both producers and consumers",
264                    commodity_id
265                );
266            }
267        }
268    }
269
270    Ok(())
271}
272
273/// Performs topological sort on the commodity graph to get the ordering for investments
274///
275/// The returned Vec only includes SVD and SED commodities.
276fn topo_sort_commodities(
277    graph: &CommoditiesGraph,
278    commodities: &CommodityMap,
279) -> Result<Vec<CommodityID>> {
280    // Perform a topological sort on the graph
281    let order = toposort(graph, None).map_err(|cycle| {
282        let cycle_commodity = graph.node_weight(cycle.node_id()).unwrap().clone();
283        anyhow!(
284            "Cycle detected in commodity graph for commodity {}",
285            cycle_commodity
286        )
287    })?;
288
289    // We return the order in reverse so that leaf-node commodities are solved first
290    // We also filter to only include SVD and SED commodities
291    let order = order
292        .iter()
293        .rev()
294        .filter_map(|node_idx| {
295            // Get the commodity for the node
296            let GraphNode::Commodity(commodity_id) = graph.node_weight(*node_idx).unwrap() else {
297                // Skip special nodes
298                return None;
299            };
300            let commodity = &commodities[commodity_id];
301
302            // Only include SVD and SED commodities
303            matches!(
304                commodity.kind,
305                CommodityType::ServiceDemand | CommodityType::SupplyEqualsDemand
306            )
307            .then(|| commodity_id.clone())
308        })
309        .collect();
310
311    Ok(order)
312}
313
314/// Builds and validates commodity graphs for the entire model.
315///
316/// This function creates commodity flow graphs for each region/year combination in the model,
317/// validates the graph structure against commodity type rules, and determines the optimal
318/// investment order for commodities.
319///
320/// The validation process checks three time slice levels:
321/// - **Annual**: Validates annual-level commodities and processes
322/// - **Seasonal**: Validates seasonal-level commodities and processes for each season
323/// - **Day/Night**: Validates day/night-level commodities and processes for each time slice
324///
325/// # Arguments
326///
327/// * `processes` - All processes in the model with their flows and activity limits
328/// * `commodities` - All commodities with their types and demand specifications
329/// * `region_ids` - Collection of regions to model
330/// * `years` - Years to analyse
331/// * `time_slice_info` - Time slice configuration (seasons, day/night periods)
332///
333/// # Returns
334///
335/// A map from `(region, year)` to the ordered list of commodities for investment decisions. The
336/// ordering ensures that leaf-node commodities (those with no outgoing edges) are solved first.
337///
338/// # Errors
339///
340/// Returns an error if:
341/// - Any commodity graph contains cycles
342/// - Commodity type rules are violated (e.g., SVD commodities being consumed)
343/// - Demand cannot be satisfied
344pub fn build_and_validate_commodity_graphs_for_model(
345    processes: &ProcessMap,
346    commodities: &CommodityMap,
347    region_ids: &IndexSet<RegionID>,
348    years: &[u32],
349    time_slice_info: &TimeSliceInfo,
350) -> Result<HashMap<(RegionID, u32), Vec<CommodityID>>> {
351    // Build base commodity graphs for each region and year
352    // These do not take into account demand and process availability
353    let commodity_graphs: HashMap<(RegionID, u32), CommoditiesGraph> =
354        iproduct!(region_ids, years.iter())
355            .map(|(region_id, year)| {
356                let graph = create_commodities_graph_for_region_year(processes, region_id, *year);
357                ((region_id.clone(), *year), graph)
358            })
359            .collect();
360
361    // Determine commodity ordering for each region and year
362    let commodity_order: HashMap<(RegionID, u32), Vec<CommodityID>> = commodity_graphs
363        .iter()
364        .map(|((region_id, year), graph)| -> Result<_> {
365            let order = topo_sort_commodities(graph, commodities).with_context(|| {
366                format!("Error validating commodity graph for {region_id} in {year}")
367            })?;
368            Ok(((region_id.clone(), *year), order))
369        })
370        .try_collect()?;
371
372    // Validate graphs at all time slice levels (taking into account process availability and demand)
373    for ((region_id, year), base_graph) in &commodity_graphs {
374        for ts_level in [
375            TimeSliceLevel::Annual,
376            TimeSliceLevel::Season,
377            TimeSliceLevel::DayNight,
378        ] {
379            for ts_selection in time_slice_info.iter_selections_at_level(ts_level) {
380                let graph = prepare_commodities_graph_for_validation(
381                    base_graph,
382                    processes,
383                    commodities,
384                    time_slice_info,
385                    region_id,
386                    *year,
387                    &ts_selection,
388                );
389                validate_commodities_graph(&graph, commodities, ts_level).with_context(|| {
390                    format!(
391                        "Error validating commodity graph for \
392                            {region_id} in {year} in {ts_selection}"
393                    )
394                })?;
395            }
396        }
397    }
398
399    // If all the validation passes, return the commodity ordering
400    Ok(commodity_order)
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use crate::commodity::Commodity;
407    use crate::fixture::{assert_error, other_commodity, sed_commodity, svd_commodity};
408    use petgraph::graph::Graph;
409    use rstest::rstest;
410    use std::rc::Rc;
411
412    #[rstest]
413    fn test_topo_sort_linear_graph(sed_commodity: Commodity, svd_commodity: Commodity) {
414        // Create a simple linear graph: A -> B -> C
415        let mut graph = Graph::new();
416
417        let node_a = graph.add_node(GraphNode::Commodity("A".into()));
418        let node_b = graph.add_node(GraphNode::Commodity("B".into()));
419        let node_c = graph.add_node(GraphNode::Commodity("C".into()));
420
421        // Add edges: A -> B -> C
422        graph.add_edge(node_a, node_b, GraphEdge::Process("process1".into()));
423        graph.add_edge(node_b, node_c, GraphEdge::Process("process2".into()));
424
425        // Create commodities map using fixtures
426        let mut commodities = CommodityMap::new();
427        commodities.insert("A".into(), Rc::new(sed_commodity.clone()));
428        commodities.insert("B".into(), Rc::new(sed_commodity));
429        commodities.insert("C".into(), Rc::new(svd_commodity));
430
431        let result = topo_sort_commodities(&graph, &commodities).unwrap();
432
433        // Expected order: C, B, A (leaf nodes first)
434        assert_eq!(result.len(), 3);
435        assert_eq!(result[0], "C".into());
436        assert_eq!(result[1], "B".into());
437        assert_eq!(result[2], "A".into());
438    }
439
440    #[rstest]
441    fn test_topo_sort_cyclic_graph(sed_commodity: Commodity) {
442        // Create a simple cyclic graph: A -> B -> A
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
448        // Add edges creating a cycle: A -> B -> A
449        graph.add_edge(node_a, node_b, GraphEdge::Process("process1".into()));
450        graph.add_edge(node_b, node_a, GraphEdge::Process("process2".into()));
451
452        // Create commodities map using fixtures
453        let mut commodities = CommodityMap::new();
454        commodities.insert("A".into(), Rc::new(sed_commodity.clone()));
455        commodities.insert("B".into(), Rc::new(sed_commodity));
456
457        // This should return an error due to the cycle
458        // The error message should flag commodity B
459        // Note: A is also involved in the cycle, but B is flagged as it is encountered first
460        let result = topo_sort_commodities(&graph, &commodities);
461        assert_error!(result, "Cycle detected in commodity graph for commodity B");
462    }
463
464    #[rstest]
465    fn test_validate_commodities_graph(
466        other_commodity: Commodity,
467        sed_commodity: Commodity,
468        svd_commodity: Commodity,
469    ) {
470        let mut graph = Graph::new();
471        let mut commodities = CommodityMap::new();
472
473        // Add test commodities (all have DayNight time slice level)
474        commodities.insert("A".into(), Rc::new(other_commodity));
475        commodities.insert("B".into(), Rc::new(sed_commodity));
476        commodities.insert("C".into(), Rc::new(svd_commodity));
477
478        // Build valid graph: A(OTH) -> B(SED) -> C(SVD) ->D(DEMAND)
479        let node_a = graph.add_node(GraphNode::Commodity("A".into()));
480        let node_b = graph.add_node(GraphNode::Commodity("B".into()));
481        let node_c = graph.add_node(GraphNode::Commodity("C".into()));
482        let node_d = graph.add_node(GraphNode::Demand);
483        graph.add_edge(node_a, node_b, GraphEdge::Process("process1".into()));
484        graph.add_edge(node_b, node_c, GraphEdge::Process("process2".into()));
485        graph.add_edge(node_c, node_d, GraphEdge::Demand);
486
487        // Validate the graph at DayNight level
488        let result = validate_commodities_graph(&graph, &commodities, TimeSliceLevel::Annual);
489        assert!(result.is_ok());
490    }
491
492    #[rstest]
493    fn test_validate_commodities_graph_invalid_svd_consumed(
494        svd_commodity: Commodity,
495        sed_commodity: Commodity,
496        other_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(svd_commodity));
503        commodities.insert("B".into(), Rc::new(sed_commodity));
504        commodities.insert("C".into(), Rc::new(other_commodity));
505
506        // Build invalid graph: C(OTH) -> A(SVD) -> B(SED) - SVD cannot be consumed
507        let node_c = graph.add_node(GraphNode::Commodity("C".into()));
508        let node_a = graph.add_node(GraphNode::Commodity("A".into()));
509        let node_b = graph.add_node(GraphNode::Commodity("B".into()));
510        graph.add_edge(node_c, node_a, GraphEdge::Process("process1".into()));
511        graph.add_edge(node_a, node_b, GraphEdge::Process("process2".into()));
512
513        // Validate the graph at DayNight level
514        let result = validate_commodities_graph(&graph, &commodities, TimeSliceLevel::DayNight);
515        assert_error!(result, "SVD commodity A cannot be an input to a process");
516    }
517
518    #[rstest]
519    fn test_validate_commodities_graph_invalid_svd_not_produced(svd_commodity: Commodity) {
520        let mut graph = Graph::new();
521        let mut commodities = CommodityMap::new();
522
523        // Add test commodities (all have DayNight time slice level)
524        commodities.insert("A".into(), Rc::new(svd_commodity));
525
526        // Build invalid graph: A(SVD) -> B(DEMAND) - SVD must be produced
527        let node_a = graph.add_node(GraphNode::Commodity("A".into()));
528        let node_b = graph.add_node(GraphNode::Demand);
529        graph.add_edge(node_a, node_b, GraphEdge::Demand);
530
531        // Validate the graph at DayNight level
532        let result = validate_commodities_graph(&graph, &commodities, TimeSliceLevel::DayNight);
533        assert_error!(result, "SVD commodity A is demanded but has no producers");
534    }
535
536    #[rstest]
537    fn test_validate_commodities_graph_invalid_sed(sed_commodity: Commodity) {
538        let mut graph = Graph::new();
539        let mut commodities = CommodityMap::new();
540
541        // Add test commodities (all have DayNight time slice level)
542        commodities.insert("A".into(), Rc::new(sed_commodity.clone()));
543        commodities.insert("B".into(), Rc::new(sed_commodity));
544
545        // Build invalid graph: B(SED) -> A(SED)
546        let node_a = graph.add_node(GraphNode::Commodity("A".into()));
547        let node_b = graph.add_node(GraphNode::Commodity("B".into()));
548        graph.add_edge(node_b, node_a, GraphEdge::Process("process1".into()));
549
550        // Validate the graph at DayNight level
551        let result = validate_commodities_graph(&graph, &commodities, TimeSliceLevel::DayNight);
552        assert_error!(
553            result,
554            "SED commodity B may be consumed but has no producers"
555        );
556    }
557
558    #[rstest]
559    fn test_validate_commodities_graph_invalid_oth(
560        other_commodity: Commodity,
561        sed_commodity: Commodity,
562    ) {
563        let mut graph = Graph::new();
564        let mut commodities = CommodityMap::new();
565
566        // Add test commodities (all have DayNight time slice level)
567        commodities.insert("A".into(), Rc::new(other_commodity));
568        commodities.insert("B".into(), Rc::new(sed_commodity.clone()));
569        commodities.insert("C".into(), Rc::new(sed_commodity));
570
571        // Build invalid graph: B(SED) -> A(OTH) -> C(SED)
572        let node_a = graph.add_node(GraphNode::Commodity("A".into()));
573        let node_b = graph.add_node(GraphNode::Commodity("B".into()));
574        let node_c = graph.add_node(GraphNode::Commodity("C".into()));
575        graph.add_edge(node_b, node_a, GraphEdge::Process("process1".into()));
576        graph.add_edge(node_a, node_c, GraphEdge::Process("process2".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            "OTH commodity A cannot have both producers and consumers"
583        );
584    }
585}