diff --git a/schemas/settings.yaml b/schemas/settings.yaml index 0dca722e3..f43602a60 100644 --- a/schemas/settings.yaml +++ b/schemas/settings.yaml @@ -26,3 +26,13 @@ properties: notes: | This includes raw values such as commodity balance duals, which may be useful for debugging the model or understanding results in more detail. + results_root: + type: string + description: Results root path to save MUSE2 results + default: "" + notes: Defaults to a "muse2_results" folder within the current working directory. + graph_results_root: + type: string + description: Results root path to save MUSE2 graph outputs + default: "" + notes: Defaults to a "muse2_graphs" folder within the current working directory. diff --git a/src/cli.rs b/src/cli.rs index d294be3cb..d6a3b5c8e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -151,7 +151,7 @@ pub fn handle_run_command( let output_path = if let Some(p) = opts.output_dir.as_deref() { p } else { - pathbuf = get_output_dir(model_path)?; + pathbuf = get_output_dir(model_path, settings.results_root)?; &pathbuf }; @@ -211,18 +211,22 @@ pub fn handle_save_graphs_command( settings: Option, ) -> Result<()> { // Load program settings, if not provided - let settings = if let Some(settings) = settings { + let mut settings = if let Some(settings) = settings { settings } else { Settings::load().context("Failed to load settings.")? }; + if opts.overwrite { + settings.overwrite = true; + } + // Get path to output folder let pathbuf: PathBuf; let output_path = if let Some(p) = opts.output_dir.as_deref() { p } else { - pathbuf = get_graphs_dir(model_path)?; + pathbuf = get_graphs_dir(model_path, settings.graph_results_root)?; &pathbuf }; diff --git a/src/graph.rs b/src/graph.rs index 553ca182d..66f7c65e1 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -1,6 +1,6 @@ //! Module for creating and analysing commodity graphs use crate::commodity::CommodityID; -use crate::process::{FlowDirection, ProcessID, ProcessMap}; +use crate::process::{FlowDirection, Process, ProcessFlow, ProcessID, ProcessMap}; use crate::region::RegionID; use anyhow::Result; use indexmap::{IndexMap, IndexSet}; @@ -13,6 +13,7 @@ use std::fmt::Display; use std::fs::File; use std::io::Write as IoWrite; use std::path::Path; +use std::rc::Rc; pub mod investment; pub mod validate; @@ -66,6 +67,44 @@ impl Display for GraphEdge { } } +/// Helper function to return a possible flow operating in the requested year +/// +/// We are interested only on the flows direction, which are always the same for all years. So this +/// function checks if the process can be operating in the target year and region and, if so, it +/// returns its flows. It considers not only when the process can be commissioned, but also the +/// lifetime of the process, since a process can be opperating many years after the commission time +/// window is over. If the process cannot be opperating in the target year and region, None is +/// returned. +fn get_flow_for_year( + process: &Process, + target: (RegionID, u32), +) -> Option>> { + // If its already in the map, we return it + if process.flows.contains_key(&target) { + return process.flows.get(&target).cloned(); + } + + // Otherwise we try to find one that operates in the target year. It is assumed that + // parameters are defined in the same (region, year) combinations than flows, at least. + let (target_region, target_year) = target; + for ((region, year), value) in &process.flows { + if *region != target_region { + continue; + } + if year + + process + .parameters + .get(&(region.clone(), *year)) + .unwrap() + .lifetime + >= target_year + { + return Some(value.clone()); + } + } + None +} + /// Creates a directed graph of commodity flows for a given region and year. /// /// The graph contains nodes for all commodities that may be consumed/produced by processes in the @@ -87,7 +126,7 @@ fn create_commodities_graph_for_region_year( let key = (region_id.clone(), year); for process in processes.values() { - let Some(flows) = process.flows.get(&key) else { + let Some(flows) = get_flow_for_year(process, key.clone()) else { // Process doesn't operate in this region/year continue; }; diff --git a/src/graph/validate.rs b/src/graph/validate.rs index bf5798349..a488731ca 100644 --- a/src/graph/validate.rs +++ b/src/graph/validate.rs @@ -1,9 +1,9 @@ //! Module for validating commodity graphs use super::{CommoditiesGraph, GraphEdge, GraphNode}; use crate::commodity::{CommodityMap, CommodityType}; -use crate::process::ProcessMap; +use crate::process::{Process, ProcessMap}; use crate::region::RegionID; -use crate::time_slice::{TimeSliceInfo, TimeSliceLevel, TimeSliceSelection}; +use crate::time_slice::{TimeSliceID, TimeSliceInfo, TimeSliceLevel, TimeSliceSelection}; use crate::units::{Dimensionless, Flow}; use anyhow::{Context, Result, ensure}; use indexmap::IndexMap; @@ -44,14 +44,7 @@ fn prepare_commodities_graph_for_validation( // Check if the process has availability > 0 in any time slice in the selection time_slice_selection .iter(time_slice_info) - .any(|(time_slice, _)| { - let Some(limits_map) = process.activity_limits.get(&key) else { - return false; - }; - limits_map - .get(time_slice) - .is_some_and(|avail| *avail.end() > Dimensionless(0.0)) - }) + .any(|(time_slice, _)| can_be_active(process, &key, time_slice)) }); // Add demand edges @@ -78,6 +71,35 @@ fn prepare_commodities_graph_for_validation( filtered_graph } +/// Checks if a process can be active for a particular timeslice in a given year and region +/// +/// It considers all commission years that can lead to a running process in the target region and +/// year, accounting for the process lifetime, and then checks if, for any of those, the process +/// is active in the required timeslice. In other words, this checks if there is the _possibility_ +/// of having an active process, although there is no guarantee of that happening since it depends +/// on the investment. +fn can_be_active(process: &Process, target: &(RegionID, u32), time_slice: &TimeSliceID) -> bool { + let (target_region, target_year) = target; + + for ((region, year), value) in &process.parameters { + if region != target_region { + continue; + } + if year + value.lifetime >= *target_year { + let Some(limits_map) = process.activity_limits.get(target) else { + continue; + }; + if limits_map + .get(time_slice) + .is_some_and(|avail| *avail.end() > Dimensionless(0.0)) + { + return true; + } + } + } + false +} + /// Validates that the commodity graph follows the rules for different commodity types. /// /// It takes as input a graph created by `create_commodities_graph_for_validation`, which is built diff --git a/src/output.rs b/src/output.rs index 5faa9ae33..f74526b6f 100644 --- a/src/output.rs +++ b/src/output.rs @@ -23,9 +23,6 @@ use std::path::{Path, PathBuf}; pub mod metadata; use metadata::write_metadata; -/// The root folder in which model-specific output folders will be created -const OUTPUT_DIRECTORY_ROOT: &str = "muse2_results"; - /// The output file name for commodity flows const COMMODITY_FLOWS_FILE_NAME: &str = "commodity_flows.csv"; @@ -53,11 +50,8 @@ const APPRAISAL_RESULTS_FILE_NAME: &str = "debug_appraisal_results.csv"; /// The output file name for appraisal time slice results const APPRAISAL_RESULTS_TIME_SLICE_FILE_NAME: &str = "debug_appraisal_results_time_slices.csv"; -/// The root folder in which commodity flow graphs will be created -const GRAPHS_DIRECTORY_ROOT: &str = "muse2_graphs"; - /// Get the default output directory for the model -pub fn get_output_dir(model_dir: &Path) -> Result { +pub fn get_output_dir(model_dir: &Path, results_root: PathBuf) -> Result { // Get the model name from the dir path. This ends up being convoluted because we need to check // for all possible errors. Ugh. let model_dir = model_dir @@ -71,11 +65,11 @@ pub fn get_output_dir(model_dir: &Path) -> Result { .context("Invalid chars in model dir name")?; // Construct path - Ok([OUTPUT_DIRECTORY_ROOT, model_name].iter().collect()) + Ok([results_root, model_name.into()].iter().collect()) } /// Get the default output directory for commodity flow graphs for the model -pub fn get_graphs_dir(model_dir: &Path) -> Result { +pub fn get_graphs_dir(model_dir: &Path, graph_results_root: PathBuf) -> Result { let model_dir = model_dir .canonicalize() // canonicalise in case the user has specified "." .context("Could not resolve path to model")?; @@ -84,7 +78,7 @@ pub fn get_graphs_dir(model_dir: &Path) -> Result { .context("Model cannot be in root folder")? .to_str() .context("Invalid chars in model dir name")?; - Ok([GRAPHS_DIRECTORY_ROOT, model_name].iter().collect()) + Ok([graph_results_root, model_name.into()].iter().collect()) } /// Create a new output directory for the model, optionally overwriting existing data diff --git a/src/settings.rs b/src/settings.rs index ead537a55..e0ef749a2 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -5,6 +5,7 @@ use crate::log::DEFAULT_LOG_LEVEL; use anyhow::Result; use documented::DocumentedFields; use serde::{Deserialize, Serialize}; +use std::env::current_dir; use std::fmt::Write; use std::path::{Path, PathBuf}; @@ -47,6 +48,10 @@ pub struct Settings { pub overwrite: bool, /// Whether to write additional information to CSV files pub debug_model: bool, + /// Results root path to save MUSE2 results. Defaults to `{pwd}/muse2_results`. + pub results_root: PathBuf, + /// Results root path to save MUSE2 graph outputs. Defaults to `{pwd}/muse2_graphs`. + pub graph_results_root: PathBuf, } impl Default for Settings { @@ -55,6 +60,8 @@ impl Default for Settings { log_level: DEFAULT_LOG_LEVEL.to_string(), overwrite: false, debug_model: false, + results_root: current_dir().unwrap().join("muse2_results"), + graph_results_root: current_dir().unwrap().join("muse2_graphs"), } } } @@ -141,7 +148,9 @@ mod tests { Settings { log_level: "warn".to_string(), debug_model: false, - overwrite: false + overwrite: false, + results_root: current_dir().unwrap().join("muse2_results"), + graph_results_root: current_dir().unwrap().join("muse2_graphs"), } ); }