Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/user_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
174 changes: 92 additions & 82 deletions src/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ pub type CommoditiesGraph = Graph<GraphNode, GraphEdge, Directed>;
#[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),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be renamed to CommodityMarket?

I've also just noticed that MarketSet::Single has a tuple as its inner value, rather than two separate values, like here. Should we change that one for consistency?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. Not sure why MarketSet::Single has a tuple. I'll change it, but will defer to avoid conflicts with other PRs

/// A source node for processes that have no inputs
#[display("SOURCE")]
Source,
Expand All @@ -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,
Expand Down Expand Up @@ -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<RegionID>,
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<RegionID>,
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<u32, CommoditiesGraph> {
years
.iter()
.map(|&year| {
let graph = create_commodities_graph_for_year(processes, region_ids, year);
(year, graph)
})
.collect()
}
Expand All @@ -181,27 +191,27 @@ pub fn build_commodity_graphs_for_model(
fn get_edge_attributes(_: &CommoditiesGraph, edge_ref: EdgeReference<GraphEdge>) -> 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(),
}
}

/// 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<u32, CommoditiesGraph>,
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(())
Expand Down
Loading