Skip to content

Commit 340f7f5

Browse files
authored
Merge pull request #904 from EnergySystemsModellingLab/save_graphs
Save commodity graphs as DOT files
2 parents 928fcbb + a2c9613 commit 340f7f5

7 files changed

Lines changed: 231 additions & 28 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ settings.toml
77

88
# Simulation output files
99
**/muse2_results
10+
**/muse2_graphs
1011

1112
# Generated by Cargo
1213
# will have compiled files and executables

docs/user_guide.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,20 @@
55
Once you have installed MUSE2, you should be able to run it via the `muse2` command-line program.
66
For details of the command-line interface, [see here](./command_line_help.md).
77

8+
### Visualising commodity graphs
9+
10+
To visualise the structure of your model, you can use the [the `muse2 save-graphs` command] to
11+
create graphs of commodity/process relationships.
12+
This command will output a graph for each region/year in the simulation, where nodes are commodities
13+
and edges are processes.
14+
Graphs will be saved in [DOT format], which can be visualised locally with [Graphviz], or online
15+
with [Graphviz online].
16+
17+
[the `muse2 save-graphs` command]: https://energysystemsmodellinglab.github.io/MUSE2/command_line_help.html#muse2-save-graphs
18+
[DOT format]: https://graphviz.org/doc/info/lang.html
19+
[Graphviz]: https://graphviz.org/
20+
[Graphviz online]: https://dreampuf.github.io/GraphvizOnline
21+
822
## Modifying the program settings
923

1024
You can configure the behaviour of MUSE2 with a `settings.toml` file. To edit this file, run:

src/cli.rs

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
//! The command line interface for the simulation.
2-
use crate::input::load_model;
2+
use crate::graph::save_commodity_graphs_for_model;
3+
use crate::input::{load_commodity_graphs, load_model};
34
use crate::log;
4-
use crate::output::{create_output_directory, get_output_dir};
5+
use crate::output::{create_output_directory, get_graphs_dir, get_output_dir};
56
use crate::settings::Settings;
67
use ::log::{info, warn};
78
use anyhow::{Context, Result};
@@ -26,7 +27,7 @@ struct Cli {
2627
markdown_help: bool,
2728
}
2829

29-
/// Options for the run command
30+
/// Options for the `run` command
3031
#[derive(Args)]
3132
pub struct RunOpts {
3233
/// Directory for output files
@@ -40,6 +41,17 @@ pub struct RunOpts {
4041
pub debug_model: Option<bool>,
4142
}
4243

44+
/// Options for the `graph` command
45+
#[derive(Args)]
46+
pub struct GraphOpts {
47+
/// Directory for graph files
48+
#[arg(short, long)]
49+
pub output_dir: Option<PathBuf>,
50+
/// Whether to overwrite the output directory if it already exists
51+
#[arg(long)]
52+
pub overwrite: bool,
53+
}
54+
4355
/// The available commands.
4456
#[derive(Subcommand)]
4557
enum Commands {
@@ -62,6 +74,14 @@ enum Commands {
6274
/// The path to the model directory.
6375
model_dir: PathBuf,
6476
},
77+
/// Build and output commodity flow graphs for a model.
78+
SaveGraphs {
79+
/// The path to the model directory.
80+
model_dir: PathBuf,
81+
/// Other options
82+
#[command(flatten)]
83+
opts: GraphOpts,
84+
},
6585
/// Manage settings file.
6686
Settings {
6787
/// The subcommands for managing the settings file.
@@ -77,6 +97,9 @@ impl Commands {
7797
Self::Run { model_dir, opts } => handle_run_command(&model_dir, &opts, None),
7898
Self::Example { subcommand } => subcommand.execute(),
7999
Self::Validate { model_dir } => handle_validate_command(&model_dir, None),
100+
Self::SaveGraphs { model_dir, opts } => {
101+
handle_save_graphs_command(&model_dir, &opts, None)
102+
}
80103
Self::Settings { subcommand } => subcommand.execute(),
81104
}
82105
}
@@ -178,3 +201,49 @@ pub fn handle_validate_command(model_path: &Path, settings: Option<Settings>) ->
178201

179202
Ok(())
180203
}
204+
205+
/// Handle the `save-graphs` command.
206+
pub fn handle_save_graphs_command(
207+
model_path: &Path,
208+
opts: &GraphOpts,
209+
settings: Option<Settings>,
210+
) -> Result<()> {
211+
// Load program settings, if not provided
212+
let settings = if let Some(settings) = settings {
213+
settings
214+
} else {
215+
Settings::load().context("Failed to load settings.")?
216+
};
217+
218+
// Get path to output folder
219+
let pathbuf: PathBuf;
220+
let output_path = if let Some(p) = opts.output_dir.as_deref() {
221+
p
222+
} else {
223+
pathbuf = get_graphs_dir(model_path)?;
224+
&pathbuf
225+
};
226+
227+
let overwrite =
228+
create_output_directory(output_path, settings.overwrite).with_context(|| {
229+
format!(
230+
"Failed to create graphs directory: {}",
231+
output_path.display()
232+
)
233+
})?;
234+
235+
// Initialise program logger (we won't save log files when running this command)
236+
log::init(&settings.log_level, None).context("Failed to initialise logging.")?;
237+
238+
// NB: We have to wait until the logger is initialised to display this warning
239+
if overwrite {
240+
warn!("Graphs directory will be overwritten");
241+
}
242+
243+
// Load commodity flow graphs and save to file
244+
let commodity_graphs = load_commodity_graphs(model_path).context("Failed to build graphs.")?;
245+
save_commodity_graphs_for_model(&commodity_graphs, output_path)?;
246+
info!("Graphs saved to: {}", output_path.display());
247+
248+
Ok(())
249+
}

src/graph.rs

Lines changed: 56 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,21 @@ use indexmap::IndexSet;
99
use itertools::{Itertools, iproduct};
1010
use petgraph::Directed;
1111
use petgraph::algo::toposort;
12+
use petgraph::dot::Dot;
1213
use petgraph::graph::Graph;
1314
use std::collections::HashMap;
1415
use std::fmt::Display;
16+
use std::fs::File;
17+
use std::io::Write as IoWrite;
18+
use std::path::Path;
1519
use strum::IntoEnumIterator;
1620

1721
/// A graph of commodity flows for a given region and year
18-
type CommoditiesGraph = Graph<GraphNode, GraphEdge, Directed>;
22+
pub type CommoditiesGraph = Graph<GraphNode, GraphEdge, Directed>;
1923

2024
#[derive(Eq, PartialEq, Clone, Hash)]
2125
/// A node in the commodity graph
22-
enum GraphNode {
26+
pub enum GraphNode {
2327
/// A node representing a commodity
2428
Commodity(CommodityID),
2529
/// A source node for processes that have no inputs
@@ -43,13 +47,22 @@ impl Display for GraphNode {
4347

4448
#[derive(Eq, PartialEq, Clone, Hash)]
4549
/// An edge in the commodity graph
46-
enum GraphEdge {
50+
pub enum GraphEdge {
4751
/// An edge representing a process
4852
Process(ProcessID),
4953
/// An edge representing a service demand
5054
Demand,
5155
}
5256

57+
impl 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+
5366
/// Creates a directed graph of commodity flows for a given region and year.
5467
///
5568
/// The graph contains nodes for all commodities that may be consumed/produced by processes in the
@@ -118,7 +131,7 @@ fn create_commodities_graph_for_region_year(
118131
graph
119132
}
120133

121-
/// Prepares a graph for validation with `validate_commodities_graph`.
134+
/// Prepares a graph for validation with [`validate_commodities_graph`].
122135
///
123136
/// It takes a base graph produced by `create_commodities_graph_for_region_year`, and modifies it to
124137
/// account for process availabilities and commodity demands within the given time slice selection,
@@ -196,7 +209,7 @@ fn prepare_commodities_graph_for_validation(
196209
/// The validation is only performed for commodities with the specified time slice level. For full
197210
/// validation of all commodities in the model, we therefore need to run this function for all time
198211
/// slice selections at all time slice levels. This is handled by
199-
/// `build_and_validate_commodity_graphs_for_model`.
212+
/// [`validate_commodity_graphs_for_model`].
200213
fn validate_commodities_graph(
201214
graph: &CommoditiesGraph,
202215
commodities: &CommodityMap,
@@ -307,7 +320,26 @@ fn topo_sort_commodities(
307320
Ok(order)
308321
}
309322

310-
/// Builds and validates commodity graphs for the entire model.
323+
/// Builds base commodity graphs for each region and year
324+
///
325+
/// These do not take into account demand and process availability
326+
pub 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.
311343
///
312344
/// This function creates commodity flow graphs for each region/year combination in the model,
313345
/// validates the graph structure against commodity type rules, and determines the optimal
@@ -337,23 +369,12 @@ fn topo_sort_commodities(
337369
/// - Any commodity graph contains cycles
338370
/// - Commodity type rules are violated (e.g., SVD commodities being consumed)
339371
/// - Demand cannot be satisfied
340-
pub fn build_and_validate_commodity_graphs_for_model(
372+
pub fn validate_commodity_graphs_for_model(
373+
commodity_graphs: &HashMap<(RegionID, u32), CommoditiesGraph>,
341374
processes: &ProcessMap,
342375
commodities: &CommodityMap,
343-
region_ids: &IndexSet<RegionID>,
344-
years: &[u32],
345376
time_slice_info: &TimeSliceInfo,
346377
) -> Result<HashMap<(RegionID, u32), Vec<CommodityID>>> {
347-
// Build base commodity graphs for each region and year
348-
// These do not take into account demand and process availability
349-
let commodity_graphs: HashMap<(RegionID, u32), CommoditiesGraph> =
350-
iproduct!(region_ids, years.iter())
351-
.map(|(region_id, year)| {
352-
let graph = create_commodities_graph_for_region_year(processes, region_id, *year);
353-
((region_id.clone(), *year), graph)
354-
})
355-
.collect();
356-
357378
// Determine commodity ordering for each region and year
358379
let commodity_order: HashMap<(RegionID, u32), Vec<CommodityID>> = commodity_graphs
359380
.iter()
@@ -366,7 +387,7 @@ pub fn build_and_validate_commodity_graphs_for_model(
366387
.try_collect()?;
367388

368389
// Validate graphs at all time slice levels (taking into account process availability and demand)
369-
for ((region_id, year), base_graph) in &commodity_graphs {
390+
for ((region_id, year), base_graph) in commodity_graphs {
370391
for ts_level in TimeSliceLevel::iter() {
371392
for ts_selection in time_slice_info.iter_selections_at_level(ts_level) {
372393
let graph = prepare_commodities_graph_for_validation(
@@ -392,6 +413,21 @@ pub fn build_and_validate_commodity_graphs_for_model(
392413
Ok(commodity_order)
393414
}
394415

416+
/// Saves commodity graphs to file
417+
///
418+
/// The graphs are saved as DOT files to the specified output path
419+
pub 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+
395431
#[cfg(test)]
396432
mod tests {
397433
use super::*;

src/input.rs

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
//! Common routines for handling input data.
22
use crate::asset::AssetPool;
3-
use crate::graph::build_and_validate_commodity_graphs_for_model;
3+
use crate::graph::{
4+
CommoditiesGraph, build_commodity_graphs_for_model, validate_commodity_graphs_for_model,
5+
};
46
use crate::id::{HasID, IDLike};
57
use crate::model::{Model, ModelParameters};
8+
use crate::region::RegionID;
69
use crate::units::UnitType;
710
use anyhow::{Context, Result, bail, ensure};
811
use float_cmp::approx_eq;
@@ -213,11 +216,11 @@ pub fn load_model<P: AsRef<Path>>(model_dir: P) -> Result<(Model, AssetPool)> {
213216

214217
// Build and validate commodity graphs for all regions and years
215218
// This gives us the commodity order for each region/year which is passed to the model
216-
let commodity_order = build_and_validate_commodity_graphs_for_model(
219+
let commodity_graphs = build_commodity_graphs_for_model(&processes, &region_ids, years)?;
220+
let commodity_order = validate_commodity_graphs_for_model(
221+
&commodity_graphs,
217222
&processes,
218223
&commodities,
219-
&region_ids,
220-
years,
221224
&time_slice_info,
222225
)?;
223226

@@ -238,6 +241,36 @@ pub fn load_model<P: AsRef<Path>>(model_dir: P) -> Result<(Model, AssetPool)> {
238241
Ok((model, AssetPool::new(assets)))
239242
}
240243

244+
/// Load commodity flow graphs for a model.
245+
///
246+
/// Loads necessary input data and creates a graph of commodity flows for each region and year,
247+
/// where nodes are commodities and edges are processes.
248+
///
249+
/// Graphs validation is NOT performed. This ensures that graphs can be generated even when
250+
/// validation would fail, which may be helpful for debugging.
251+
pub fn load_commodity_graphs<P: AsRef<Path>>(
252+
model_dir: P,
253+
) -> Result<HashMap<(RegionID, u32), CommoditiesGraph>> {
254+
let model_params = ModelParameters::from_path(&model_dir)?;
255+
256+
let time_slice_info = read_time_slice_info(model_dir.as_ref())?;
257+
let regions = read_regions(model_dir.as_ref())?;
258+
let region_ids = regions.keys().cloned().collect();
259+
let years = &model_params.milestone_years;
260+
261+
let commodities = read_commodities(model_dir.as_ref(), &region_ids, &time_slice_info, years)?;
262+
let processes = read_processes(
263+
model_dir.as_ref(),
264+
&commodities,
265+
&region_ids,
266+
&time_slice_info,
267+
years,
268+
)?;
269+
270+
let commodity_graphs = build_commodity_graphs_for_model(&processes, &region_ids, years)?;
271+
Ok(commodity_graphs)
272+
}
273+
241274
#[cfg(test)]
242275
mod tests {
243276
use super::*;

src/output.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,10 @@ const SOLVER_VALUES_FILE_NAME: &str = "debug_solver.csv";
4747
/// The output file name for appraisal results
4848
const APPRAISAL_RESULTS_FILE_NAME: &str = "debug_appraisal_results.csv";
4949

50-
/// Get the model name from the specified directory path
50+
/// The root folder in which commodity flow graphs will be created
51+
const GRAPHS_DIRECTORY_ROOT: &str = "muse2_graphs";
52+
53+
/// Get the default output directory for the model
5154
pub fn get_output_dir(model_dir: &Path) -> Result<PathBuf> {
5255
// Get the model name from the dir path. This ends up being convoluted because we need to check
5356
// for all possible errors. Ugh.
@@ -65,6 +68,19 @@ pub fn get_output_dir(model_dir: &Path) -> Result<PathBuf> {
6568
Ok([OUTPUT_DIRECTORY_ROOT, model_name].iter().collect())
6669
}
6770

71+
/// Get the default output directory for commodity flow graphs for the model
72+
pub fn get_graphs_dir(model_dir: &Path) -> Result<PathBuf> {
73+
let model_dir = model_dir
74+
.canonicalize() // canonicalise in case the user has specified "."
75+
.context("Could not resolve path to model")?;
76+
let model_name = model_dir
77+
.file_name()
78+
.context("Model cannot be in root folder")?
79+
.to_str()
80+
.context("Invalid chars in model dir name")?;
81+
Ok([GRAPHS_DIRECTORY_ROOT, model_name].iter().collect())
82+
}
83+
6884
/// Create a new output directory for the model, optionally overwriting existing data
6985
///
7086
/// # Arguments

0 commit comments

Comments
 (0)