From c0ac6b2606d93638ec6092ee387844b9fef1af61 Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Thu, 23 Apr 2026 15:18:16 +0100 Subject: [PATCH 1/8] ModelParameters: Remove some unnecessarily derived traits --- src/model/parameters.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/model/parameters.rs b/src/model/parameters.rs index 60119613..53fa434d 100644 --- a/src/model/parameters.rs +++ b/src/model/parameters.rs @@ -63,7 +63,7 @@ fn set_dangerous_model_options_flag(enabled: bool) { /// /// NOTE: If you add or change a field in this struct, you must also update the schema in /// `schemas/input/model.yaml`. -#[derive(Debug, Deserialize, PartialEq)] +#[derive(Deserialize)] #[serde(default)] pub struct ModelParameters { /// Milestone years From ca2d2504cb07647e011fa5fd65b49b708b146787 Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Fri, 24 Apr 2026 11:41:45 +0100 Subject: [PATCH 2/8] Add `must_use` attribute to `DispatchRun` It doesn't make sense to create a `DispatchRun` struct and not do anything with it. --- src/simulation/optimisation.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/simulation/optimisation.rs b/src/simulation/optimisation.rs index 39d7b1ce..a381e428 100644 --- a/src/simulation/optimisation.rs +++ b/src/simulation/optimisation.rs @@ -461,6 +461,7 @@ fn get_parent_or_self(assets: &[AssetRef]) -> Vec { /// For a detailed description, please see the [dispatch optimisation formulation][1]. /// /// [1]: https://energysystemsmodellinglab.github.io/MUSE2/model/dispatch_optimisation.html +#[must_use = "Must call run() method on DispatchRun struct"] pub struct DispatchRun<'model, 'run> { model: &'model Model, existing_assets: &'run [AssetRef], From dcc9e50acf0b71c24c18af6b4592890ae8fde742 Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Fri, 24 Apr 2026 12:22:13 +0100 Subject: [PATCH 3/8] Allow `ModelError` to contain arbitrary errors other than just incoherent/non-optimal --- .../investment/appraisal/optimisation.rs | 5 ++- src/simulation/optimisation.rs | 38 +++++++++++++------ 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/simulation/investment/appraisal/optimisation.rs b/src/simulation/investment/appraisal/optimisation.rs index 6d87775c..65f35796 100644 --- a/src/simulation/investment/appraisal/optimisation.rs +++ b/src/simulation/investment/appraisal/optimisation.rs @@ -6,6 +6,7 @@ use super::constraints::{ }; use crate::asset::{AssetCapacity, AssetRef}; use crate::commodity::Commodity; +use crate::simulation::optimisation::ModelError; use crate::simulation::optimisation::solve_optimal; use crate::time_slice::{TimeSliceID, TimeSliceInfo}; use crate::units::{Activity, Capacity, Flow}; @@ -150,7 +151,9 @@ pub fn perform_optimisation( ); // Solve model - let solution = solve_optimal(problem.optimise(sense))?.get_solution(); + let solution = solve_optimal(problem.optimise(sense)) + .map_err(ModelError::into_anyhow)? + .get_solution(); let solution_values = solution.columns(); Ok(ResultsMap { // If the asset has a defined unit size, the capacity variable represents number of units, diff --git a/src/simulation/optimisation.rs b/src/simulation/optimisation.rs index a381e428..cfa626a6 100644 --- a/src/simulation/optimisation.rs +++ b/src/simulation/optimisation.rs @@ -14,8 +14,8 @@ use crate::units::{ Activity, Capacity, Dimensionless, Flow, Money, MoneyPerActivity, MoneyPerCapacity, MoneyPerFlow, Year, }; -use anyhow::{Result, bail, ensure}; -use highs::{HighsModelStatus, HighsStatus, RowProblem as Problem, Sense}; +use anyhow::{Result, anyhow, bail, ensure}; +use highs::{HighsModelStatus, RowProblem as Problem, Sense}; use indexmap::{IndexMap, IndexSet}; use itertools::{chain, iproduct}; use std::cell::Cell; @@ -371,23 +371,37 @@ impl Solution<'_> { } /// Defines the possible errors that can occur when running the solver -#[derive(Debug, Clone)] +#[derive(Debug)] pub enum ModelError { - /// The model definition is incoherent. - /// - /// Users should not be able to trigger this error. - Incoherent(HighsStatus), /// An optimal solution could not be found NonOptimal(HighsModelStatus), + /// Another error occurred + Other(anyhow::Error), +} + +impl From for ModelError { + fn from(value: anyhow::Error) -> Self { + Self::Other(value) + } +} + +impl ModelError { + /// Convert this error into an [`anyhow::Error`] + pub fn into_anyhow(self) -> anyhow::Error { + match self { + ModelError::NonOptimal(status) => anyhow!("Could not find optimal result: {status:?}"), + ModelError::Other(error) => error, + } + } } impl fmt::Display for ModelError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - ModelError::Incoherent(status) => write!(f, "Incoherent model: {status:?}"), ModelError::NonOptimal(status) => { write!(f, "Could not find optimal result: {status:?}") } + ModelError::Other(error) => error.fmt(f), } } } @@ -396,7 +410,9 @@ impl Error for ModelError {} /// Try to solve the model, returning an error if the model is incoherent or result is non-optimal pub fn solve_optimal(model: highs::Model) -> Result { - let solved = model.try_solve().map_err(ModelError::Incoherent)?; + let solved = model + .try_solve() + .map_err(|status| anyhow!("Incoherent model: {status:?}"))?; match solved.status() { HighsModelStatus::Optimal => Ok(solved), @@ -603,9 +619,9 @@ impl<'model, 'run> DispatchRun<'model, 'run> { the supplied assets could not meet the required demand. Demand was not met \ for the following markets: {}", format_items_with_cap(markets) - ) + ); } - Err(err) => Err(err)?, + Err(err) => Err(err.into_anyhow()), } } From 6b740d0198ff648ba7273cb359a4f78b2cf51bed Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Fri, 24 Apr 2026 15:34:14 +0100 Subject: [PATCH 4/8] Allow users to set custom HiGHS options for dispatch and appraisal Closes #420. --- clippy.toml | 2 + src/model/parameters.rs | 65 ++++++++++++++++++- src/simulation/investment/appraisal.rs | 4 +- .../investment/appraisal/optimisation.rs | 13 ++-- src/simulation/optimisation.rs | 48 +++++++++++++- 5 files changed, 122 insertions(+), 10 deletions(-) diff --git a/clippy.toml b/clippy.toml index 94887b94..c52f2f81 100644 --- a/clippy.toml +++ b/clippy.toml @@ -4,3 +4,5 @@ # The AssetRef type uses Rc internally for shared ownership, but the hash # implementation is carefully designed to be consistent regardless of interior mutability ignore-interior-mutability = ["muse2::asset::AssetRef"] +# Things that should not be treated as identifiers. The ".." represents the default options. +doc-valid-idents = ["HiGHS", ".."] diff --git a/src/model/parameters.rs b/src/model/parameters.rs index 53fa434d..f0cd1b9d 100644 --- a/src/model/parameters.rs +++ b/src/model/parameters.rs @@ -13,6 +13,7 @@ use log::warn; use serde::Deserialize; use std::path::Path; use std::sync::OnceLock; +use toml::Table; const MODEL_PARAMETERS_FILE_NAME: &str = "model.toml"; @@ -98,6 +99,12 @@ pub struct ModelParameters { pub mothball_years: u32, /// Absolute tolerance when checking if remaining demand is close enough to zero pub remaining_demand_absolute_tolerance: Flow, + /// Options for the HiGHS solver. + /// + /// For a full list of options, see [the HiGHS documentation]. + /// + /// [the HiGHS documentation]: https://ergo-code.github.io/HiGHS/stable/options/definitions/ + pub highs: HighsOptions, } impl Default for ModelParameters { @@ -117,10 +124,47 @@ impl Default for ModelParameters { capacity_margin: Dimensionless(0.2), mothball_years: 0, remaining_demand_absolute_tolerance: DEFAULT_REMAINING_DEMAND_ABSOLUTE_TOLERANCE, + highs: HighsOptions::default(), } } } +/// Defines the TOML table holding the sub-tables to define HiGHS options +#[derive(Default, Deserialize)] +#[serde(default)] +pub struct HighsOptions { + /// HiGHS options applied to both dispatch and appraisal + global_options: Table, + /// HiGHS options applied only to dispatch + pub dispatch_options: Table, + /// HiGHS options applied only to appraisal + pub appraisal_options: Table, +} + +impl HighsOptions { + /// Copy the options specified in `global_options` to `dispatch_options` and `appraisal_options` + fn apply_global_options(&mut self) { + let append_global_opts_to = |opts: &mut Table| { + for (option, value) in &self.global_options { + opts.entry(option).or_insert(value.clone()); + } + }; + + append_global_opts_to(&mut self.dispatch_options); + append_global_opts_to(&mut self.appraisal_options); + + // Now that we're finished with global options we can discard + self.global_options.clear(); + } + + /// Check whether any options have been set + pub fn is_empty(&self) -> bool { + self.global_options.is_empty() + && self.dispatch_options.is_empty() + && self.appraisal_options.is_empty() + } +} + /// Check that the `milestone_years` parameter is valid fn check_milestone_years(years: &[u32]) -> Result<()> { ensure!( @@ -195,6 +239,20 @@ fn check_capacity_margin(value: Dimensionless) -> Result<()> { Ok(()) } +/// Check the custom HiGHS options are valid. +/// +/// Note that we cannot know whether the options specified exist and are of the correct type until +/// we attempt to use them. We could check for types that are never valid (e.g. an array), but as +/// we're checking later anyway, we don't bother. +fn check_highs_options(dangerous_options_enabled: bool, highs: &HighsOptions) -> Result<()> { + ensure!( + dangerous_options_enabled || highs.is_empty(), + "Cannot set custom HiGHS options without enabling {ALLOW_DANGEROUS_OPTION_NAME}" + ); + + Ok(()) +} + impl ModelParameters { /// Read a model file from the specified directory. /// @@ -207,7 +265,7 @@ impl ModelParameters { /// The model file contents as a [`ModelParameters`] struct or an error if the file is invalid pub fn from_path>(model_dir: P) -> Result { let file_path = model_dir.as_ref().join(MODEL_PARAMETERS_FILE_NAME); - let model_params: ModelParameters = read_toml(&file_path)?; + let mut model_params: ModelParameters = read_toml(&file_path)?; set_dangerous_model_options_flag(model_params.allow_dangerous_options); @@ -215,6 +273,9 @@ impl ModelParameters { .validate() .with_context(|| input_err_msg(file_path))?; + // Copy global options to other tables + model_params.highs.apply_global_options(); + Ok(model_params) } @@ -256,6 +317,8 @@ impl ModelParameters { self.remaining_demand_absolute_tolerance, )?; + check_highs_options(self.allow_dangerous_options, &self.highs)?; + Ok(()) } } diff --git a/src/simulation/investment/appraisal.rs b/src/simulation/investment/appraisal.rs index c32a0e9f..ae79d4db 100644 --- a/src/simulation/investment/appraisal.rs +++ b/src/simulation/investment/appraisal.rs @@ -258,12 +258,12 @@ fn calculate_lcox( demand: &DemandMap, ) -> Result { let results = perform_optimisation( + model, asset, max_capacity, commodity, coefficients, demand, - &model.time_slice_info, highs::Sense::Minimise, )?; @@ -296,12 +296,12 @@ fn calculate_npv( demand: &DemandMap, ) -> Result { let results = perform_optimisation( + model, asset, max_capacity, commodity, coefficients, demand, - &model.time_slice_info, highs::Sense::Maximise, )?; diff --git a/src/simulation/investment/appraisal/optimisation.rs b/src/simulation/investment/appraisal/optimisation.rs index 65f35796..152b7597 100644 --- a/src/simulation/investment/appraisal/optimisation.rs +++ b/src/simulation/investment/appraisal/optimisation.rs @@ -6,11 +6,13 @@ use super::constraints::{ }; use crate::asset::{AssetCapacity, AssetRef}; use crate::commodity::Commodity; +use crate::model::Model; use crate::simulation::optimisation::ModelError; +use crate::simulation::optimisation::apply_highs_options_from_toml; use crate::simulation::optimisation::solve_optimal; use crate::time_slice::{TimeSliceID, TimeSliceInfo}; use crate::units::{Activity, Capacity, Flow}; -use anyhow::Result; +use anyhow::{Context, Result}; use highs::{RowProblem as Problem, Sense}; use indexmap::IndexMap; @@ -127,12 +129,12 @@ fn add_constraints( /// The optimisation will use continuous or integer capacity variables depending on whether the /// asset has a defined unit size. pub fn perform_optimisation( + model: &Model, asset: &AssetRef, max_capacity: Option, commodity: &Commodity, coefficients: &ObjectiveCoefficients, demand: &DemandMap, - time_slice_info: &TimeSliceInfo, sense: Sense, ) -> Result { // Create problem and add variables @@ -147,11 +149,14 @@ pub fn perform_optimisation( commodity, &variables, demand, - time_slice_info, + &model.time_slice_info, ); // Solve model - let solution = solve_optimal(problem.optimise(sense)) + let mut highs_model = problem.optimise(sense); + apply_highs_options_from_toml(&mut highs_model, &model.parameters.highs.appraisal_options) + .context("Failed to apply custom HiGHS options to appraisal optimisation")?; + let solution = solve_optimal(highs_model) .map_err(ModelError::into_anyhow)? .get_solution(); let solution_values = solution.columns(); diff --git a/src/simulation/optimisation.rs b/src/simulation/optimisation.rs index cfa626a6..7cd0fc82 100644 --- a/src/simulation/optimisation.rs +++ b/src/simulation/optimisation.rs @@ -14,10 +14,11 @@ use crate::units::{ Activity, Capacity, Dimensionless, Flow, Money, MoneyPerActivity, MoneyPerCapacity, MoneyPerFlow, Year, }; -use anyhow::{Result, anyhow, bail, ensure}; +use anyhow::{Context, Result, anyhow, bail, ensure}; use highs::{HighsModelStatus, RowProblem as Problem, Sense}; use indexmap::{IndexMap, IndexSet}; use itertools::{chain, iproduct}; +use log::debug; use std::cell::Cell; use std::collections::HashMap; use std::error::Error; @@ -408,6 +409,44 @@ impl fmt::Display for ModelError { impl Error for ModelError {} +/// Apply the specified HiGHS options from a [`toml::Table`] +pub fn apply_highs_options_from_toml( + model: &mut highs::Model, + options: &toml::Table, +) -> Result<()> { + // Attempt to set an option, returning an error if it fails + macro_rules! try_set_opt { + ($option:expr, $value:expr) => {{ + let option = $option.as_str(); + let value = $value; + + debug!("Setting HiGHS option \"{option}\" to \"{value}\""); + model + .try_set_option(option, value) + .map_err(|_| anyhow!("Invalid option name or value"))?; + + Ok(()) + }}; + } + + // Iterate through options, applying each in turn to the HiGHS model + for (option, value) in options { + match value { + toml::Value::String(value) => try_set_opt!(option, value.as_str()), + toml::Value::Integer(value) => match i32::try_from(*value) { + Ok(value) => try_set_opt!(option, value), + Err(_) => Err(anyhow!("Value out of range")), + }, + toml::Value::Float(value) => try_set_opt!(option, *value), + toml::Value::Boolean(value) => try_set_opt!(option, *value), + _ => Err(anyhow!("HiGHS options cannot have this type")), + } + .with_context(|| format!("Failed to set option \"{option}\" to value \"{value}\""))?; + } + + Ok(()) +} + /// Try to solve the model, returning an error if the model is incoherent or result is non-optimal pub fn solve_optimal(model: highs::Model) -> Result { let solved = model @@ -694,8 +733,11 @@ impl<'model, 'run> DispatchRun<'model, 'run> { self.year, ); - // Solve model - let solution = solve_optimal(problem.optimise(Sense::Minimise))?; + // Create model and apply any user-supplied HiGHS options to it + let mut model = problem.optimise(Sense::Minimise); + apply_highs_options_from_toml(&mut model, &self.model.parameters.highs.dispatch_options) + .context("Failed to apply custom HiGHS options to dispatch optimisation")?; + let solution = solve_optimal(model)?; let solution = Solution { solution: solution.get_solution(), From e4fbde2bf8d830e3880cc66a3b51ccddeb6799af Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Fri, 24 Apr 2026 15:57:43 +0100 Subject: [PATCH 5/8] Document `highs` property for `model.toml` in schema and with extra doc --- docs/SUMMARY.md | 1 + docs/developer_guide/custom_highs_options.md | 35 ++++++++++++++++++++ schemas/input/model.yaml | 21 ++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 docs/developer_guide/custom_highs_options.md diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 78ff94c4..38e09211 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -18,6 +18,7 @@ - [Setting up your development environment](developer_guide/setup.md) - [Building and developing MUSE2](developer_guide/coding.md) - [Architecture and coding style](developer_guide/architecture_quickstart.md) + - [Applying custom HiGHS options](developer_guide/custom_highs_options.md) - [Developing the documentation](developer_guide/docs.md) - [API documentation](./api/muse2/README.md) - [Release notes](release_notes/README.md) diff --git a/docs/developer_guide/custom_highs_options.md b/docs/developer_guide/custom_highs_options.md new file mode 100644 index 00000000..4c2c5d05 --- /dev/null +++ b/docs/developer_guide/custom_highs_options.md @@ -0,0 +1,35 @@ +# Applying custom HiGHS options + +As part of development, you may wish to directly set custom options for the HiGHS solver. Note that +while some of these options will not affect results of simulations (e.g. to enable console logging +for HiGHS), as we cannot guarantee this for all options, in order to use this feature, you have to +set `please_give_me_broken_results = true` in your [`model.toml` file][model.toml]. + +You can change any of the options exposed by the HiGHS solver; for more information, see [the HiGHS +documentation][highs-opts]. + +You can set options to be applied to all optimisations, just dispatch or just appraisal. + +Here is an example: + +```toml +milestone_years = [2020, 2030, 2040] + +# These options are applied to all optimisations +[highs.global_options] +# These two options are required to be enabled to log to console +log_to_console = true +output_flag = true + +# These ones are just applied to dispatch +[highs.dispatch_options] +# Increase to higher than default +primal_feasibility_tolerance = 10e-6 + +# These ones are just applied to appraisal +[highs.appraisal_options] +optimality_tolerance = 10e-6 +``` + +[model.toml]: https://energysystemsmodellinglab.github.io/MUSE2/file_formats/input_files.html#model-parameters-modeltoml +[highs-opts]: https://ergo-code.github.io/HiGHS/stable/options/definitions/ diff --git a/schemas/input/model.yaml b/schemas/input/model.yaml index 7b4f64d0..1380ed97 100644 --- a/schemas/input/model.yaml +++ b/schemas/input/model.yaml @@ -59,5 +59,26 @@ properties: investment cycle. Changing the value of this parameter is potentially dangerous, so it requires setting `please_give_me_broken_results` to true. default: 1e-12 + highs: + type: object + description: | + Used for setting custom HiGHS options. As this is unsafe, it requires setting + `please_give_me_broken_results` to true. For more information, see the relevant [section of + the developer documentation][highs-opts-docs]. + + [highs-opts-docs]: https://energysystemsmodellinglab.github.io/MUSE2/developer_guide/custom_highs_options.html + properties: + global_options: + type: object + description: HiGHS options applied to all optimisations + properties: {} + dispatch_options: + type: object + description: HiGHS options applied only to dispatch optimisations + properties: {} + appraisal_options: + type: object + description: HiGHS options applied only to appraisal optimisations + properties: {} required: [milestone_years] From 3df49f3095d418ee83d119bd72c3010dde390b80 Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Thu, 7 May 2026 16:08:24 +0100 Subject: [PATCH 6/8] Update release notes --- docs/release_notes/upcoming.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/release_notes/upcoming.md b/docs/release_notes/upcoming.md index 438946a2..ec5f6c7b 100644 --- a/docs/release_notes/upcoming.md +++ b/docs/release_notes/upcoming.md @@ -16,7 +16,7 @@ ready to be released, carry out the following steps: ## New features - +- Users can now optionally pass [custom options][highs-options] to the HiGHS solver [#1276] ## Breaking changes @@ -26,4 +26,6 @@ ready to be released, carry out the following steps: - Fix misleading warning message for assets decommissioned before simulation start ([#1259]) +[highs-options]: https://ergo-code.github.io/HiGHS/stable/options/definitions/ [#1259]: https://github.com/EnergySystemsModellingLab/MUSE2/pull/1259 +[#1276]: https://github.com/EnergySystemsModellingLab/MUSE2/pull/1276 From b8913813df4e8c5902786953acd849bf86a2e180 Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Thu, 7 May 2026 16:14:27 +0100 Subject: [PATCH 7/8] Implement `Error::source()` for `ModelError` Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/simulation/optimisation.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/simulation/optimisation.rs b/src/simulation/optimisation.rs index 7cd0fc82..0b0243a5 100644 --- a/src/simulation/optimisation.rs +++ b/src/simulation/optimisation.rs @@ -407,7 +407,14 @@ impl fmt::Display for ModelError { } } -impl Error for ModelError {} +impl Error for ModelError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + ModelError::NonOptimal(_) => None, + ModelError::Other(error) => Some(error.as_ref()), + } + } +} /// Apply the specified HiGHS options from a [`toml::Table`] pub fn apply_highs_options_from_toml( From a29c7f6f44181c47aaef7d549301cc98ab8a018c Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Thu, 7 May 2026 16:16:58 +0100 Subject: [PATCH 8/8] Fix: Enable `please_give_me_broken_results` for example use of HiGHS options Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/developer_guide/custom_highs_options.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/developer_guide/custom_highs_options.md b/docs/developer_guide/custom_highs_options.md index 4c2c5d05..3f790b76 100644 --- a/docs/developer_guide/custom_highs_options.md +++ b/docs/developer_guide/custom_highs_options.md @@ -13,6 +13,7 @@ You can set options to be applied to all optimisations, just dispatch or just ap Here is an example: ```toml +please_give_me_broken_results = true milestone_years = [2020, 2030, 2040] # These options are applied to all optimisations