From 7cd769d7dbdb7656277256f8d2b368ce53f81484 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Tue, 22 Sep 2026 10:37:37 +0100 Subject: [PATCH 1/2] Make price cycle iterations configurable --- docs/release_notes/upcoming.md | 2 ++ schemas/input/model.yaml | 4 ++++ src/model/parameters.rs | 37 ++++++++++++++++++++++++++++++++++ src/simulation/prices.rs | 5 +---- 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/docs/release_notes/upcoming.md b/docs/release_notes/upcoming.md index 43a3827bf..a0f493b09 100644 --- a/docs/release_notes/upcoming.md +++ b/docs/release_notes/upcoming.md @@ -3,6 +3,8 @@ ## New features - Seasonal/annual utilisation penalties are now included in the investment mini-dispatch ([#1548]) +- The number of price calculation iterations for cyclically-dependent markets can now be configured + with `price_cycle_iterations` in `model.toml` (default: 1) ## Breaking changes diff --git a/schemas/input/model.yaml b/schemas/input/model.yaml index 5c56b718a..5db20013a 100644 --- a/schemas/input/model.yaml +++ b/schemas/input/model.yaml @@ -91,6 +91,10 @@ properties: type: number description: The relative tolerance for price convergence in the ironing out loop default: 1e-6 + price_cycle_iterations: + type: integer + description: Number of iterations to perform when calculating prices for cyclically-dependent markets + default: 1 capacity_margin: type: number description: Slack proportion for assets selected during cycle balancing to absorb small demand shifts diff --git a/src/model/parameters.rs b/src/model/parameters.rs index be9704f74..6a50ec1e8 100644 --- a/src/model/parameters.rs +++ b/src/model/parameters.rs @@ -113,6 +113,8 @@ pub struct ModelParameters { /// The relative tolerance for price convergence in the ironing out loop #[serde(deserialize_with = "deserialise_finite_non_negative")] pub price_tolerance: Dimensionless, + /// Number of iterations to perform when calculating prices for cyclically-dependent markets. + pub price_cycle_iterations: u32, /// Slack applied during cycle balancing, allowing newly selected assets to flex their capacity /// by this proportion. /// @@ -152,6 +154,7 @@ impl Default for ModelParameters { annual_utilisation_penalty: MoneyPerCapacityPerYear(1e-5), max_ironing_out_iterations: 1, price_tolerance: Dimensionless(1e-6), + price_cycle_iterations: 1, capacity_margin: Dimensionless(0.2), mothball_years: 0, remaining_demand_absolute_tolerance: DEFAULT_REMAINING_DEMAND_ABSOLUTE_TOLERANCE, @@ -263,6 +266,13 @@ fn check_max_ironing_out_iterations(value: u32) -> Result<()> { Ok(()) } +/// Check that the number of cycle price iterations is valid. +fn check_price_cycle_iterations(value: u32) -> Result<()> { + ensure!(value > 0, "price_cycle_iterations cannot be zero"); + + Ok(()) +} + /// Check that the `remaining_demand_absolute_tolerance` parameter is valid. fn check_remaining_demand_absolute_tolerance( dangerous_options_enabled: bool, @@ -355,6 +365,9 @@ impl ModelParameters { // price_tolerance already validated with deserialise_finite_non_negative + // price_cycle_iterations + check_price_cycle_iterations(self.price_cycle_iterations)?; + // capacity_margin already validated with deserialise_finite_non_negative // remaining_demand_absolute_tolerance already validated with @@ -428,6 +441,15 @@ mod tests { let model_params = ModelParameters::from_path(dir.path()).unwrap(); assert_eq!(model_params.milestone_years, [2020, 2100]); + assert_eq!(model_params.price_cycle_iterations, 1); + } + + #[test] + fn model_params_deserialises_price_cycle_iterations() { + let model_params: ModelParameters = + toml::from_str("milestone_years = [2020, 2100]\nprice_cycle_iterations = 3").unwrap(); + + assert_eq!(model_params.price_cycle_iterations, 3); } #[test] @@ -585,6 +607,21 @@ mod tests { ); } + #[rstest] + #[case(1, true)] + #[case(10, true)] + #[case(0, false)] + fn check_price_cycle_iterations_works(#[case] value: u32, #[case] expected_valid: bool) { + let result = check_price_cycle_iterations(value); + + assert_validation_result( + result, + expected_valid, + value, + "price_cycle_iterations cannot be zero", + ); + } + #[rstest] #[case(true, 1e-12, true)] // Valid: default value, dangerous options allowed #[case(true, 1.0, true)] // Valid: non-default value with dangerous options allowed diff --git a/src/simulation/prices.rs b/src/simulation/prices.rs index d40e897ab..87be3f0e6 100644 --- a/src/simulation/prices.rs +++ b/src/simulation/prices.rs @@ -14,9 +14,6 @@ use indexmap::IndexMap; use std::collections::{HashMap, HashSet}; use std::marker::PhantomData; -/// Number of iterations to perform when calculating prices for cyclically-dependent markets. -const N_CYCLE_ITERATIONS: i32 = 1; - /// Weighted average accumulator for `MoneyPerFlow` prices. #[derive(Clone, Copy, Debug)] struct WeightedAverageAccumulator { @@ -359,7 +356,7 @@ fn price_cycle( } // Iterate over the markets for a fixed number of iterations, updating prices each time - for _ in 0..N_CYCLE_ITERATIONS { + for _ in 0..model.parameters.price_cycle_iterations { // Price markets in reverse order (i.e. upstream markets first) for market in markets.iter().rev() { price_markets( From a677af9ab2e9c2556e55497a1aec61ac05e3858b Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Tue, 22 Sep 2026 10:42:26 +0100 Subject: [PATCH 2/2] Update docs --- docs/model/prices.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/model/prices.md b/docs/model/prices.md index 3ffb25abc..d03bf689a 100644 --- a/docs/model/prices.md +++ b/docs/model/prices.md @@ -124,11 +124,11 @@ those furthest upstream relative to the rest of the system (i.e., furthest from downstream of the SCC), and working down. Marginal cost calculations use newly updated prices for input commodities already evaluated in the sequence, and fall back to the seeded shadow prices for any not yet evaluated, which may occur due to circular dependencies. - - +circular markets. The number of iterations is controlled by the `price_cycle_iterations` parameter +in `model.toml` (default = 1 means just a single pass). ## Example