Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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 (covering all regions), where nodes
are commodities in a given region and edges are processes.
Comment thread
tsmbland marked this conversation as resolved.
Resolved
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