diff --git a/docs/user_guide.md b/docs/user_guide.md index 1db164ed9..f596005f8 100644 --- a/docs/user_guide.md +++ b/docs/user_guide.md @@ -118,8 +118,8 @@ have a question, feel free to [open an issue]. To visualise the structure of your model, you can use the [`muse2 save-graphs` command] to create graphs of commodity/process relationships. -This command will output a graph for each region/year in the simulation, where nodes are commodities -and edges are processes. +This command will output a graph for each year in the simulation, where nodes +are commodity markets (commodity-region pairs) and edges are processes. Graphs will be saved in [DOT format], which can be visualised locally with [Graphviz], or online with [Graphviz online]. Dashed lines are used to indicate flows for non-primary outputs of a process (as defined in the diff --git a/src/graph.rs b/src/graph.rs index def0ddb06..e388382ee 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -23,9 +23,9 @@ pub type CommoditiesGraph = Graph; #[derive(Eq, PartialEq, Clone, Hash, derive_more::Display)] /// A node in the commodity graph pub enum GraphNode { - /// A node representing a commodity - #[display("{_0}")] - Commodity(CommodityID), + /// A node representing a commodity in a specific region + #[display("{_0} | {_1}")] + Commodity(CommodityID, RegionID), /// A source node for processes that have no inputs #[display("SOURCE")] Source, @@ -40,12 +40,12 @@ pub enum GraphNode { #[derive(Eq, PartialEq, Clone, Hash, derive_more::Display)] /// An edge in the commodity graph pub enum GraphEdge { - /// An edge representing a primary flow of a process - #[display("{_0}")] - Primary(ProcessID), - /// An edge representing a secondary (non-primary) flow of a process - #[display("{_0}")] - Secondary(ProcessID), + /// An edge representing a primary flow of a process installed in the given region + #[display("{_0} | {_1}")] + Primary(ProcessID, RegionID), + /// An edge representing a secondary (non-primary) flow of a process installed in the given region + #[display("{_0} | {_1}")] + Secondary(ProcessID, RegionID), /// An edge representing a service demand #[display("DEMAND")] Demand, @@ -81,98 +81,108 @@ fn get_flow_for_year( None } -/// Creates a directed graph of commodity flows for a given region and year. +/// Creates a directed graph of commodity flows for a given year, covering all regions. /// -/// The graph contains nodes for all commodities that may be consumed/produced by processes in the -/// specified region/year. There will be an edge from commodity A to B if there exists a process -/// that consumes A and produces B. +/// The graph contains nodes for all `(commodity, region)` pairs that may be consumed/produced +/// by processes in the specified year across all regions. There will be an edge from node +/// `(A, R)` to `(B, R)` if there exists a process in region R that consumes commodity A and +/// produces commodity B. Processes currently only consume and produce commodities in their own +/// region, so there are no edges between nodes in different regions. /// /// There are also special `Source` and `Sink` nodes, which are used for processes that have no /// inputs or outputs. /// /// The graph does not take into account process availabilities or commodity demands, both of which /// can vary by time slice. See `prepare_commodities_graph_for_validation`. -fn create_commodities_graph_for_region_year( +fn create_commodities_graph_for_year( processes: &ProcessMap, - region_id: &RegionID, + region_ids: &IndexSet, year: u32, ) -> CommoditiesGraph { let mut graph = Graph::new(); - let mut commodity_to_node_index = HashMap::new(); - - let key = (region_id.clone(), year); - for process in processes.values() { - let Some(flows) = get_flow_for_year(process, key.clone()) else { - // Process doesn't operate in this region/year - continue; - }; - - // Get output nodes for the process - let mut outputs: Vec<_> = flows - .values() - .filter(|flow| flow.direction() == FlowDirection::Output) - .map(|flow| GraphNode::Commodity(flow.commodity.id.clone())) - .collect(); - - // Get input nodes for the process - let mut inputs: Vec<_> = flows - .values() - .filter(|flow| flow.direction() == FlowDirection::Input) - .map(|flow| GraphNode::Commodity(flow.commodity.id.clone())) - .collect(); - - // Use `Source` node if no inputs, `Sink` node if no outputs - if inputs.is_empty() { - inputs.push(GraphNode::Source); - } - if outputs.is_empty() { - outputs.push(GraphNode::Sink); - } - - // Get primary output for the process - let primary_output = &process.primary_output; - - // Create edges from all inputs to all outputs - // We also create nodes the first time they are encountered - for (input, output) in iproduct!(inputs, outputs) { - let source_node_index = *commodity_to_node_index - .entry(input.clone()) - .or_insert_with(|| graph.add_node(input.clone())); - let target_node_index = *commodity_to_node_index - .entry(output.clone()) - .or_insert_with(|| graph.add_node(output.clone())); - let is_primary = match &output { - GraphNode::Commodity(commodity_id) => primary_output.as_ref() == Some(commodity_id), - _ => false, + let mut node_index = HashMap::new(); + + for region_id in region_ids { + let key = (region_id.clone(), year); + for process in processes.values() { + let Some(flows) = get_flow_for_year(process, key.clone()) else { + // Process doesn't operate in this region/year + continue; }; - graph.add_edge( - source_node_index, - target_node_index, - if is_primary { - GraphEdge::Primary(process.id.clone()) - } else { - GraphEdge::Secondary(process.id.clone()) - }, - ); + // Get output nodes for the process + // For now, we assume processes always output commodities in their own region + let mut outputs: Vec<_> = flows + .values() + .filter(|flow| flow.direction() == FlowDirection::Output) + .map(|flow| GraphNode::Commodity(flow.commodity.id.clone(), region_id.clone())) + .collect(); + + // Get input nodes for the process + // For now, we assume processes always input commodities in their own region + let mut inputs: Vec<_> = flows + .values() + .filter(|flow| flow.direction() == FlowDirection::Input) + .map(|flow| GraphNode::Commodity(flow.commodity.id.clone(), region_id.clone())) + .collect(); + + // Use `Source` node if no inputs, `Sink` node if no outputs + if inputs.is_empty() { + inputs.push(GraphNode::Source); + } + if outputs.is_empty() { + outputs.push(GraphNode::Sink); + } + + // Get primary output for the process + let primary_output = &process.primary_output; + + // Create edges from all inputs to all outputs. + // Edges carry the asset's installing region so the availability filter in + // validation can look up `(process_id, asset_region, year)`. + for (input, output) in iproduct!(inputs, outputs) { + let source_node_index = *node_index + .entry(input.clone()) + .or_insert_with(|| graph.add_node(input.clone())); + let target_node_index = *node_index + .entry(output.clone()) + .or_insert_with(|| graph.add_node(output.clone())); + let is_primary = match &output { + GraphNode::Commodity(commodity_id, _) => { + primary_output.as_ref() == Some(commodity_id) + } + _ => false, + }; + + graph.add_edge( + source_node_index, + target_node_index, + if is_primary { + GraphEdge::Primary(process.id.clone(), region_id.clone()) + } else { + GraphEdge::Secondary(process.id.clone(), region_id.clone()) + }, + ); + } } } graph } -/// Builds base commodity graphs for each region and year +/// Builds base commodity graphs for each year, covering all regions. /// -/// These do not take into account demand and process availability +/// These do not take into account demand and process availability. pub fn build_commodity_graphs_for_model( processes: &ProcessMap, region_ids: &IndexSet, years: &[u32], -) -> IndexMap<(RegionID, u32), CommoditiesGraph> { - iproduct!(region_ids, years.iter()) - .map(|(region_id, year)| { - let graph = create_commodities_graph_for_region_year(processes, region_id, *year); - ((region_id.clone(), *year), graph) +) -> IndexMap { + years + .iter() + .map(|&year| { + let graph = create_commodities_graph_for_year(processes, region_ids, year); + (year, graph) }) .collect() } @@ -181,7 +191,7 @@ pub fn build_commodity_graphs_for_model( fn get_edge_attributes(_: &CommoditiesGraph, edge_ref: EdgeReference) -> String { match edge_ref.weight() { // Use dashed lines for secondary flows - GraphEdge::Secondary(_) => "style=dashed".to_string(), + GraphEdge::Secondary(..) => "style=dashed".to_string(), // Other edges use default attributes _ => String::new(), } @@ -189,19 +199,19 @@ fn get_edge_attributes(_: &CommoditiesGraph, edge_ref: EdgeReference) /// Saves commodity graphs to file /// -/// The graphs are saved as DOT files to the specified output path +/// The graphs are saved as DOT files to the specified output path, one file per year. pub fn save_commodity_graphs_for_model( - commodity_graphs: &IndexMap<(RegionID, u32), CommoditiesGraph>, + commodity_graphs: &IndexMap, output_path: &Path, ) -> Result<()> { - for ((region_id, year), graph) in commodity_graphs { + for (year, graph) in commodity_graphs { let dot = Dot::with_attr_getters( graph, &[], &get_edge_attributes, // Custom attributes for edges &|_, _| String::new(), // Use default attributes for nodes ); - let mut file = File::create(output_path.join(format!("{region_id}_{year}.dot")))?; + let mut file = File::create(output_path.join(format!("{year}.dot")))?; write!(file, "{dot}")?; } Ok(()) diff --git a/src/graph/investment.rs b/src/graph/investment.rs index 5f30ac1ec..c5863f991 100644 --- a/src/graph/investment.rs +++ b/src/graph/investment.rs @@ -1,7 +1,6 @@ //! Module for solving the investment order of commodities use super::{CommoditiesGraph, GraphEdge, GraphNode}; use crate::commodity::{CommodityMap, CommodityType}; -use crate::region::RegionID; use crate::simulation::market::MarketSet; use highs::{Col, HighsModelStatus, RowProblem, Sense}; use indexmap::IndexMap; @@ -15,35 +14,30 @@ use std::collections::HashMap; type InvestmentGraph = Graph; -/// Analyse the commodity graphs for a given year to determine the order in which investment +/// Analyse the commodity graph for a given year to determine the order in which investment /// decisions should be made. /// /// Steps: -/// 1. Initialise an `InvestmentGraph` from the set of original `CommodityGraph`s for the given -/// year, filtering to only include SVD/SED commodities. `CommodityGraph`s from -/// all regions are combined into a single `InvestmentGraph`. TODO: at present there can be no -/// edges between regions; in future we will want to implement trade as edges between regions, -/// but this will have no impact on the following steps. +/// 1. Initialise an `InvestmentGraph` from the `CommodityGraph` for the given year, filtering to +/// only include SVD/SED commodities (all edges between them, primary and secondary, are kept). /// 2. Condense strongly connected components (cycles) into `MarketSet::Cycle` nodes. /// 3. Perform a topological sort on the condensed graph. /// 4. Compute layers for investment based on the topological order, grouping independent sets into /// `MarketSet::Layer`s. /// /// Arguments: -/// * `graphs` - Commodity graphs for each region and year, outputted from `build_commodity_graphs_for_model` +/// * `graph` - Commodity graph for this year, from `build_commodity_graphs_for_model` /// * `commodities` - All commodities with their types and demand specifications -/// * `year` - The year to solve the investment order for /// /// # Returns /// A Vec of `MarketSet`s in the order they should be solved, with cycles grouped into /// `MarketSet::Cycle`s and independent sets grouped into `MarketSet::Layer`s. fn solve_investment_order_for_year( - graphs: &IndexMap<(RegionID, u32), CommoditiesGraph>, + graph: &CommoditiesGraph, commodities: &CommodityMap, - year: u32, ) -> Vec { - // Initialise InvestmentGraph for this year from the set of original `CommodityGraph`s - let mut investment_graph = init_investment_graph_for_year(graphs, year, commodities); + // Initialise InvestmentGraph from the commodity graph for this year + let mut investment_graph = init_investment_graph(graph, commodities); // Condense strongly connected components investment_graph = compress_cycles(&investment_graph); @@ -57,61 +51,53 @@ fn solve_investment_order_for_year( compute_layers(&investment_graph, &order) } -/// Initialise an `InvestmentGraph` for the given year from a set of `CommodityGraph`s -/// -/// Commodity graphs for each region are first filtered to only include SVD/SED commodities. Each -/// commodity node is then added to a global investment graph as an `MarketSet::Single`, with -/// edges preserved from the original commodity graphs. -fn init_investment_graph_for_year( - graphs: &IndexMap<(RegionID, u32), CommoditiesGraph>, - year: u32, - commodities: &CommodityMap, -) -> InvestmentGraph { - let mut combined = InvestmentGraph::new(); - - // Iterate over the graphs for the given year - for ((region_id, _), graph) in graphs.iter().filter(|((_, y), _)| *y == year) { - // Filter the graph to only include SVD/SED commodities - let filtered = graph.filter_map( - |_, n| match n { - GraphNode::Commodity(cid) => { - let kind = &commodities[cid].kind; - matches!( - kind, - CommodityType::ServiceDemand | CommodityType::SupplyEqualsDemand - ) - .then(|| GraphNode::Commodity(cid.clone())) - } - _ => None, - }, - |_, e| Some(e.clone()), - ); - - // Add nodes to the combined graph - let node_map: HashMap<_, _> = filtered - .node_indices() - .map(|ni| { - let GraphNode::Commodity(cid) = filtered.node_weight(ni).unwrap() else { - unreachable!() - }; - ( - ni, - combined.add_node(MarketSet::Single((cid.clone(), region_id.clone()))), +/// Initialise an `InvestmentGraph` from a `CommoditiesGraph`. +/// +/// The commodity graph is filtered to only include SVD/SED commodity nodes. Each commodity node +/// (carrying both `CommodityID` and `RegionID`) is added to the investment graph as a +/// `MarketSet::Single`, with edges preserved. +fn init_investment_graph(graph: &CommoditiesGraph, commodities: &CommodityMap) -> InvestmentGraph { + // Filter to only SVD/SED commodities + let filtered = graph.filter_map( + |_, n| match n { + GraphNode::Commodity(cid, region_id) => { + let kind = &commodities[cid].kind; + matches!( + kind, + CommodityType::ServiceDemand | CommodityType::SupplyEqualsDemand ) - }) - .collect(); + .then(|| GraphNode::Commodity(cid.clone(), region_id.clone())) + } + _ => None, + }, + |_, e| Some(e.clone()), + ); - // Add edges to the combined graph - for e in filtered.edge_references() { - combined.add_edge( - node_map[&e.source()], - node_map[&e.target()], - e.weight().clone(), - ); - } + // Add nodes to the graph + let mut investment_graph = InvestmentGraph::new(); + let node_map: HashMap<_, _> = filtered + .node_indices() + .map(|ni| { + let GraphNode::Commodity(cid, region_id) = filtered.node_weight(ni).unwrap() else { + unreachable!() + }; + ( + ni, + investment_graph.add_node(MarketSet::Single((cid.clone(), region_id.clone()))), + ) + }) + .collect(); + + // Add edges to the graph + for e in filtered.edge_references() { + investment_graph.add_edge( + node_map[&e.source()], + node_map[&e.target()], + e.weight().clone(), + ); } - combined + investment_graph } /// Compresses cycles into `MarketSet::Cycle` nodes @@ -476,7 +462,7 @@ fn compute_layers(graph: &InvestmentGraph, order: &[NodeIndex]) -> Vec Vec, + commodity_graphs: &IndexMap, commodities: &CommodityMap, years: &[u32], ) -> HashMap> { let mut investment_orders = HashMap::new(); for year in years { - let order = solve_investment_order_for_year(commodity_graphs, commodities, *year); + let graph = &commodity_graphs[year]; + let order = solve_investment_order_for_year(graph, commodities); investment_orders.insert(*year, order); } investment_orders @@ -520,7 +507,7 @@ mod tests { original.add_edge( node_indices[src], node_indices[dst], - GraphEdge::Primary("process1".into()), + GraphEdge::Primary("process1".into(), "GBR".into()), ); } // External market receiving exports from C; encourages C to appear early. @@ -528,7 +515,7 @@ mod tests { original.add_edge( node_indices[2], external, - GraphEdge::Primary("process2".into()), + GraphEdge::Primary("process2".into(), "GBR".into()), ); // Single SCC containing all markets. @@ -551,13 +538,21 @@ mod tests { // Create a simple linear graph: A -> B -> C let mut graph = Graph::new(); - let node_a = graph.add_node(GraphNode::Commodity("A".into())); - let node_b = graph.add_node(GraphNode::Commodity("B".into())); - let node_c = graph.add_node(GraphNode::Commodity("C".into())); + let node_a = graph.add_node(GraphNode::Commodity("A".into(), "GBR".into())); + let node_b = graph.add_node(GraphNode::Commodity("B".into(), "GBR".into())); + let node_c = graph.add_node(GraphNode::Commodity("C".into(), "GBR".into())); // Add edges: A -> B -> C - graph.add_edge(node_a, node_b, GraphEdge::Primary("process1".into())); - graph.add_edge(node_b, node_c, GraphEdge::Primary("process2".into())); + graph.add_edge( + node_a, + node_b, + GraphEdge::Primary("process1".into(), "GBR".into()), + ); + graph.add_edge( + node_b, + node_c, + GraphEdge::Primary("process2".into(), "GBR".into()), + ); // Create commodities map using fixtures let mut commodities = CommodityMap::new(); @@ -565,8 +560,7 @@ mod tests { commodities.insert("B".into(), Arc::new(sed_commodity)); commodities.insert("C".into(), Arc::new(svd_commodity)); - let graphs = IndexMap::from([(("GBR".into(), 2020), graph)]); - let result = solve_investment_order_for_year(&graphs, &commodities, 2020); + let result = solve_investment_order_for_year(&graph, &commodities); // Expected order: C, B, A (leaf nodes first) // No cycles or layers, so all market sets should be `Single` @@ -581,20 +575,27 @@ mod tests { // Create a simple cyclic graph: A -> B -> A let mut graph = Graph::new(); - let node_a = graph.add_node(GraphNode::Commodity("A".into())); - let node_b = graph.add_node(GraphNode::Commodity("B".into())); + let node_a = graph.add_node(GraphNode::Commodity("A".into(), "GBR".into())); + let node_b = graph.add_node(GraphNode::Commodity("B".into(), "GBR".into())); // Add edges creating a cycle: A -> B -> A - graph.add_edge(node_a, node_b, GraphEdge::Primary("process1".into())); - graph.add_edge(node_b, node_a, GraphEdge::Primary("process2".into())); + graph.add_edge( + node_a, + node_b, + GraphEdge::Primary("process1".into(), "GBR".into()), + ); + graph.add_edge( + node_b, + node_a, + GraphEdge::Primary("process2".into(), "GBR".into()), + ); // Create commodities map using fixtures let mut commodities = CommodityMap::new(); commodities.insert("A".into(), Arc::new(sed_commodity.clone())); commodities.insert("B".into(), Arc::new(sed_commodity)); - let graphs = IndexMap::from([(("GBR".into(), 2020), graph)]); - let result = solve_investment_order_for_year(&graphs, &commodities, 2020); + let result = solve_investment_order_for_year(&graph, &commodities); // Should be a single `Cycle` market set containing both commodities assert_eq!(result.len(), 1); @@ -614,16 +615,32 @@ mod tests { // D let mut graph = Graph::new(); - let node_a = graph.add_node(GraphNode::Commodity("A".into())); - let node_b = graph.add_node(GraphNode::Commodity("B".into())); - let node_c = graph.add_node(GraphNode::Commodity("C".into())); - let node_d = graph.add_node(GraphNode::Commodity("D".into())); + let node_a = graph.add_node(GraphNode::Commodity("A".into(), "GBR".into())); + let node_b = graph.add_node(GraphNode::Commodity("B".into(), "GBR".into())); + let node_c = graph.add_node(GraphNode::Commodity("C".into(), "GBR".into())); + let node_d = graph.add_node(GraphNode::Commodity("D".into(), "GBR".into())); // Add edges - graph.add_edge(node_a, node_b, GraphEdge::Primary("process1".into())); - graph.add_edge(node_a, node_c, GraphEdge::Primary("process2".into())); - graph.add_edge(node_b, node_d, GraphEdge::Primary("process3".into())); - graph.add_edge(node_c, node_d, GraphEdge::Primary("process4".into())); + graph.add_edge( + node_a, + node_b, + GraphEdge::Primary("process1".into(), "GBR".into()), + ); + graph.add_edge( + node_a, + node_c, + GraphEdge::Primary("process2".into(), "GBR".into()), + ); + graph.add_edge( + node_b, + node_d, + GraphEdge::Primary("process3".into(), "GBR".into()), + ); + graph.add_edge( + node_c, + node_d, + GraphEdge::Primary("process4".into(), "GBR".into()), + ); // Create commodities map using fixtures let mut commodities = CommodityMap::new(); @@ -632,8 +649,7 @@ mod tests { commodities.insert("C".into(), Arc::new(sed_commodity)); commodities.insert("D".into(), Arc::new(svd_commodity)); - let graphs = IndexMap::from([(("GBR".into(), 2020), graph)]); - let result = solve_investment_order_for_year(&graphs, &commodities, 2020); + let result = solve_investment_order_for_year(&graph, &commodities); // Expected order: D, Layer(B, C), A assert_eq!(result.len(), 3); @@ -650,16 +666,37 @@ mod tests { #[rstest] fn solve_investment_order_multiple_regions(sed_commodity: Commodity, svd_commodity: Commodity) { - // Create a simple linear graph: A -> B -> C + // Create a single graph spanning two regions, each with a linear chain A -> B -> C let mut graph = Graph::new(); - let node_a = graph.add_node(GraphNode::Commodity("A".into())); - let node_b = graph.add_node(GraphNode::Commodity("B".into())); - let node_c = graph.add_node(GraphNode::Commodity("C".into())); - - // Add edges: A -> B -> C - graph.add_edge(node_a, node_b, GraphEdge::Primary("process1".into())); - graph.add_edge(node_b, node_c, GraphEdge::Primary("process2".into())); + let node_a_gbr = graph.add_node(GraphNode::Commodity("A".into(), "GBR".into())); + let node_b_gbr = graph.add_node(GraphNode::Commodity("B".into(), "GBR".into())); + let node_c_gbr = graph.add_node(GraphNode::Commodity("C".into(), "GBR".into())); + let node_a_fra = graph.add_node(GraphNode::Commodity("A".into(), "FRA".into())); + let node_b_fra = graph.add_node(GraphNode::Commodity("B".into(), "FRA".into())); + let node_c_fra = graph.add_node(GraphNode::Commodity("C".into(), "FRA".into())); + + // Add edges: A -> B -> C for each region + graph.add_edge( + node_a_gbr, + node_b_gbr, + GraphEdge::Primary("process1".into(), "GBR".into()), + ); + graph.add_edge( + node_b_gbr, + node_c_gbr, + GraphEdge::Primary("process2".into(), "GBR".into()), + ); + graph.add_edge( + node_a_fra, + node_b_fra, + GraphEdge::Primary("process1".into(), "FRA".into()), + ); + graph.add_edge( + node_b_fra, + node_c_fra, + GraphEdge::Primary("process2".into(), "FRA".into()), + ); // Create commodities map using fixtures let mut commodities = CommodityMap::new(); @@ -667,12 +704,7 @@ mod tests { commodities.insert("B".into(), Arc::new(sed_commodity)); commodities.insert("C".into(), Arc::new(svd_commodity)); - // Duplicate the graph over two regions - let graphs = IndexMap::from([ - (("GBR".into(), 2020), graph.clone()), - (("FRA".into(), 2020), graph), - ]); - let result = solve_investment_order_for_year(&graphs, &commodities, 2020); + let result = solve_investment_order_for_year(&graph, &commodities); // Expected order: Should have three layers, each with two commodities (one per region) assert_eq!(result.len(), 3); diff --git a/src/graph/validate.rs b/src/graph/validate.rs index b750ae9db..9aaf36f66 100644 --- a/src/graph/validate.rs +++ b/src/graph/validate.rs @@ -11,7 +11,7 @@ use strum::IntoEnumIterator; /// Prepares a graph for validation with [`validate_commodities_graph`]. /// -/// It takes a base graph produced by `create_commodities_graph_for_region_year`, and modifies it to +/// It takes a base graph produced by `create_commodities_graph_for_year`, and modifies it to /// account for process availabilities and commodity demands within the given time slice selection, /// returning a new graph. /// @@ -23,7 +23,6 @@ fn prepare_commodities_graph_for_validation( base_graph: &CommoditiesGraph, processes: &ProcessMap, commodities: &CommodityMap, - region_id: &RegionID, year: u32, time_slice_selection: &TimeSliceSelection, ) -> CommoditiesGraph { @@ -31,36 +30,37 @@ fn prepare_commodities_graph_for_validation( // Filter by process availability // We keep edges if the process has availability > 0 in any time slice in the selection - let key = (region_id.clone(), year); + // Each edge carries the asset's installing region, so we can look up the correct + // ActivityLimits for that (process, region, year) combination. filtered_graph.retain_edges(|graph, edge_idx| { - // Get the process for the edge - let process_id = match graph.edge_weight(edge_idx).unwrap() { - GraphEdge::Primary(process_id) | GraphEdge::Secondary(process_id) => process_id, + // Get the process/region for the edge + let (process_id, region_id) = match graph.edge_weight(edge_idx).unwrap() { + GraphEdge::Primary(process_id, region_id) + | GraphEdge::Secondary(process_id, region_id) => (process_id, region_id), GraphEdge::Demand => panic!("Demand edges should not be present in the base graph"), }; let process = &processes[process_id]; - // Check if the process has availability > 0 in any time slice in the selection - can_be_active(process, &key, time_slice_selection) + // Check if the process in this region has availability > 0 in any time slice in the + // selection + can_be_active(process, &(region_id.clone(), year), time_slice_selection) }); // Add demand edges - // We add edges to the `Demand` node for commodities that are demanded in the selection + // We add an edge to the `Demand` node for each (commodity, region) pair that has positive + // demand for this year and time slice selection // NOTE: we only do this for commodities with the same time_slice_level as the selection let demand_node_index = filtered_graph.add_node(GraphNode::Demand); for (commodity_id, commodity) in commodities { - if commodity - .demand - .get(&(region_id.clone(), year, time_slice_selection.clone())) - .is_some_and(|&v| v > Flow(0.0)) - { - let commodity_node = GraphNode::Commodity(commodity_id.clone()); + for ((region_id, dem_year, dem_ts_sel), &flow) in &commodity.demand { + if *dem_year != year || dem_ts_sel != time_slice_selection || flow <= Flow(0.0) { + continue; + } + let commodity_node = GraphNode::Commodity(commodity_id.clone(), region_id.clone()); let commodity_node_index = filtered_graph .node_indices() .find(|&idx| filtered_graph.node_weight(idx) == Some(&commodity_node)) - .unwrap_or_else(|| { - filtered_graph.add_node(GraphNode::Commodity(commodity_id.clone())) - }); + .unwrap_or_else(|| filtered_graph.add_node(commodity_node)); filtered_graph.add_edge(commodity_node_index, demand_node_index, GraphEdge::Demand); } } @@ -113,9 +113,9 @@ fn validate_commodities_graph( time_slice_level: TimeSliceLevel, ) -> Result<()> { for node_idx in graph.node_indices() { - // Get the commodity ID for the node + // Get the commodity ID and region for the node let graph_node = graph.node_weight(node_idx).unwrap(); - let GraphNode::Commodity(commodity_id) = graph_node else { + let GraphNode::Commodity(commodity_id, region_id) = graph_node else { // Skip special nodes continue; }; @@ -145,7 +145,7 @@ fn validate_commodities_graph( .any(|edge| edge.weight() != &GraphEdge::Demand); ensure!( !has_non_demand_outgoing, - "SVD commodity {commodity_id} cannot be an input to a process" + "SVD commodity {commodity_id} in region {region_id} cannot be an input to a process" ); // If it has `Demand` edges, it must have at least one producer @@ -155,7 +155,7 @@ fn validate_commodities_graph( if has_demand_edges { ensure!( has_incoming, - "SVD commodity {commodity_id} is demanded but has no producers" + "SVD commodity {commodity_id} in region {region_id} is demanded but has no producers" ); } } @@ -163,14 +163,14 @@ fn validate_commodities_graph( // SED: if consumed (outgoing edges), must also be produced (incoming edges) ensure!( !has_outgoing || has_incoming, - "SED commodity {commodity_id} may be consumed but has no producers" + "SED commodity {commodity_id} in region {region_id} may be consumed but has no producers" ); } CommodityType::Other => { // OTH: cannot have both incoming and outgoing edges ensure!( !(has_incoming && has_outgoing), - "OTH commodity {commodity_id} cannot have both producers and consumers" + "OTH commodity {commodity_id} in region {region_id} cannot have both producers and consumers" ); } } @@ -188,11 +188,9 @@ fn validate_commodities_graph( /// /// # Arguments /// -/// * `commodity_graphs` - Commodity graphs for each region and year, outputted from `build_commodity_graphs_for_model` +/// * `commodity_graphs` - Commodity graphs for each year, outputted from `build_commodity_graphs_for_model` /// * `processes` - All processes in the model with their flows and activity limits /// * `commodities` - All commodities with their types and demand specifications -/// * `region_ids` - Collection of regions to model -/// * `years` - Years to analyse /// * `time_slice_info` - Time slice configuration (seasons, day/night periods) /// /// # Errors @@ -201,28 +199,24 @@ fn validate_commodities_graph( /// - Commodity type rules are violated (e.g., SVD commodities being consumed) /// - Demand cannot be satisfied pub fn validate_commodity_graphs_for_model( - commodity_graphs: &IndexMap<(RegionID, u32), CommoditiesGraph>, + commodity_graphs: &IndexMap, processes: &ProcessMap, commodities: &CommodityMap, time_slice_info: &TimeSliceInfo, ) -> Result<()> { // Validate graphs at all time slice levels (taking into account process availability and demand) - for ((region_id, year), base_graph) in commodity_graphs { + for (&year, base_graph) in commodity_graphs { for ts_level in TimeSliceLevel::iter() { for ts_selection in time_slice_info.iter_selections_at_level(ts_level) { let graph = prepare_commodities_graph_for_validation( base_graph, processes, commodities, - region_id, - *year, + year, &ts_selection, ); validate_commodities_graph(&graph, commodities, ts_level).with_context(|| { - format!( - "Error validating commodity graph for \ - {region_id} in {year} in {ts_selection}" - ) + format!("Error validating commodity graph for {year} in {ts_selection}") })?; } } @@ -254,12 +248,20 @@ mod tests { commodities.insert("C".into(), Arc::new(svd_commodity)); // Build valid graph: A(OTH) -> B(SED) -> C(SVD) ->D(DEMAND) - let node_a = graph.add_node(GraphNode::Commodity("A".into())); - let node_b = graph.add_node(GraphNode::Commodity("B".into())); - let node_c = graph.add_node(GraphNode::Commodity("C".into())); + let node_a = graph.add_node(GraphNode::Commodity("A".into(), "GBR".into())); + let node_b = graph.add_node(GraphNode::Commodity("B".into(), "GBR".into())); + let node_c = graph.add_node(GraphNode::Commodity("C".into(), "GBR".into())); let node_d = graph.add_node(GraphNode::Demand); - graph.add_edge(node_a, node_b, GraphEdge::Primary("process1".into())); - graph.add_edge(node_b, node_c, GraphEdge::Primary("process2".into())); + graph.add_edge( + node_a, + node_b, + GraphEdge::Primary("process1".into(), "GBR".into()), + ); + graph.add_edge( + node_b, + node_c, + GraphEdge::Primary("process2".into(), "GBR".into()), + ); graph.add_edge(node_c, node_d, GraphEdge::Demand); // Validate the graph at DayNight level @@ -281,16 +283,24 @@ mod tests { commodities.insert("C".into(), Arc::new(other_commodity)); // Build invalid graph: C(OTH) -> A(SVD) -> B(SED) - SVD cannot be consumed - let node_c = graph.add_node(GraphNode::Commodity("C".into())); - let node_a = graph.add_node(GraphNode::Commodity("A".into())); - let node_b = graph.add_node(GraphNode::Commodity("B".into())); - graph.add_edge(node_c, node_a, GraphEdge::Primary("process1".into())); - graph.add_edge(node_a, node_b, GraphEdge::Primary("process2".into())); + let node_c = graph.add_node(GraphNode::Commodity("C".into(), "GBR".into())); + let node_a = graph.add_node(GraphNode::Commodity("A".into(), "GBR".into())); + let node_b = graph.add_node(GraphNode::Commodity("B".into(), "GBR".into())); + graph.add_edge( + node_c, + node_a, + GraphEdge::Primary("process1".into(), "GBR".into()), + ); + graph.add_edge( + node_a, + node_b, + GraphEdge::Primary("process2".into(), "GBR".into()), + ); // Validate the graph at DayNight level assert_error!( validate_commodities_graph(&graph, &commodities, TimeSliceLevel::DayNight), - "SVD commodity A cannot be an input to a process" + "SVD commodity A in region GBR cannot be an input to a process" ); } @@ -303,14 +313,14 @@ mod tests { commodities.insert("A".into(), Arc::new(svd_commodity)); // Build invalid graph: A(SVD) -> B(DEMAND) - SVD must be produced - let node_a = graph.add_node(GraphNode::Commodity("A".into())); + let node_a = graph.add_node(GraphNode::Commodity("A".into(), "GBR".into())); let node_b = graph.add_node(GraphNode::Demand); graph.add_edge(node_a, node_b, GraphEdge::Demand); // Validate the graph at DayNight level assert_error!( validate_commodities_graph(&graph, &commodities, TimeSliceLevel::DayNight), - "SVD commodity A is demanded but has no producers" + "SVD commodity A in region GBR is demanded but has no producers" ); } @@ -324,14 +334,18 @@ mod tests { commodities.insert("B".into(), Arc::new(sed_commodity)); // Build invalid graph: B(SED) -> A(SED) - let node_a = graph.add_node(GraphNode::Commodity("A".into())); - let node_b = graph.add_node(GraphNode::Commodity("B".into())); - graph.add_edge(node_b, node_a, GraphEdge::Primary("process1".into())); + let node_a = graph.add_node(GraphNode::Commodity("A".into(), "GBR".into())); + let node_b = graph.add_node(GraphNode::Commodity("B".into(), "GBR".into())); + graph.add_edge( + node_b, + node_a, + GraphEdge::Primary("process1".into(), "GBR".into()), + ); // Validate the graph at DayNight level assert_error!( validate_commodities_graph(&graph, &commodities, TimeSliceLevel::DayNight), - "SED commodity B may be consumed but has no producers" + "SED commodity B in region GBR may be consumed but has no producers" ); } @@ -349,16 +363,24 @@ mod tests { commodities.insert("C".into(), Arc::new(sed_commodity)); // Build invalid graph: B(SED) -> A(OTH) -> C(SED) - let node_a = graph.add_node(GraphNode::Commodity("A".into())); - let node_b = graph.add_node(GraphNode::Commodity("B".into())); - let node_c = graph.add_node(GraphNode::Commodity("C".into())); - graph.add_edge(node_b, node_a, GraphEdge::Primary("process1".into())); - graph.add_edge(node_a, node_c, GraphEdge::Primary("process2".into())); + let node_a = graph.add_node(GraphNode::Commodity("A".into(), "GBR".into())); + let node_b = graph.add_node(GraphNode::Commodity("B".into(), "GBR".into())); + let node_c = graph.add_node(GraphNode::Commodity("C".into(), "GBR".into())); + graph.add_edge( + node_b, + node_a, + GraphEdge::Primary("process1".into(), "GBR".into()), + ); + graph.add_edge( + node_a, + node_c, + GraphEdge::Primary("process2".into(), "GBR".into()), + ); // Validate the graph at DayNight level assert_error!( validate_commodities_graph(&graph, &commodities, TimeSliceLevel::DayNight), - "OTH commodity A cannot have both producers and consumers" + "OTH commodity A in region GBR cannot have both producers and consumers" ); } } diff --git a/src/input.rs b/src/input.rs index 7e90e5b08..41e42cb99 100644 --- a/src/input.rs +++ b/src/input.rs @@ -4,7 +4,6 @@ use crate::graph::validate::validate_commodity_graphs_for_model; use crate::graph::{CommoditiesGraph, build_commodity_graphs_for_model}; use crate::id::{HasID, ID}; use crate::model::{Model, ModelParameters}; -use crate::region::RegionID; use crate::units::UnitType; use anyhow::{Context, Result, bail, ensure}; use float_cmp::approx_eq; @@ -318,14 +317,14 @@ pub fn load_model>(model_dir: P) -> Result { /// Load commodity flow graphs for a model. /// -/// Loads necessary input data and creates a graph of commodity flows for each region and year, -/// where nodes are commodities and edges are processes. +/// Loads necessary input data and creates a graph of commodity flows for each year, +/// where nodes are `(commodity, region)` pairs and edges are processes. /// /// Graphs validation is NOT performed. This ensures that graphs can be generated even when /// validation would fail, which may be helpful for debugging. pub fn load_commodity_graphs>( model_dir: P, -) -> Result> { +) -> Result> { let model_params = ModelParameters::from_path(&model_dir)?; let time_slice_info = read_time_slice_info(model_dir.as_ref())?; diff --git a/tests/cli.rs b/tests/cli.rs index 4a51ba3aa..c9a9edfde 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -72,7 +72,7 @@ fn check_save_graphs_command() { ]); // Check that at least one DOT file was created - assert!(output_dir.join("GBR_2020.dot").exists()); + assert!(output_dir.join("2020.dot").exists()); } /// Test the `validate` command