Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
44 changes: 39 additions & 5 deletions docs/model/investment.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ Investment appraisal uses two distinct price sets, both sourced from the previou
optimisation step of each appraisal (see [Mini Dispatch Optimisation](#mini-dispatch-optimisation)).
- **Market prices** \\( \pi\_{c,r,t} \\): used to calculate the investment metric (Cost Index or
SNAS) after dispatch.
- **Fallback prices** \\( \phi_{c,r,t} \\): used to incentivise dispatch in the mini dispatch
optimisation when shadow prices alone are insufficient (see
[Mini Dispatch Optimisation](#mini-dispatch-optimisation)). Calculated using the strategy defined
by `fallback_price_strategy` in [`model.toml`][model-toml].

See [Commodity Prices][prices] for how these price sets are calculated.

Expand Down Expand Up @@ -171,17 +175,47 @@ optimal activity profile given the current remaining demand.

### Activity coefficients

The objective coefficient for each time slice is the net revenue per unit of activity, calculated
using **shadow prices**:
The mini dispatch optimisation implicitly frames each time slice as a choice: the asset can either
produce the commodity of interest, or it can be treated as procured from an alternative source at
the **fallback price** \\( \phi_{c,r,t} \\). Each unit of activity produces
\\( f_{c,\text{primary}} \\) units of output, displacing that quantity from the fallback source.
The optimiser therefore dispatches the asset whenever doing so is cheaper than procuring from
elsewhere.

This is captured by the activity coefficient \\( \alpha_t \\), which combines two components:

\\[
\text{NetOperatingCost}_t = \text{OperatingCost}(t) - \text{RevenueFromFlows}(\lambda, t)
\\]

\\[
\text{FallbackCost} = \phi_{c,r,t} \cdot f_{c,\text{primary}}
\\]

\\[
\alpha_t = \text{RevenueFromFlows}(\lambda, t) - \text{OperatingCost}(t) + \varepsilon
\alpha_t = \text{FallbackCost} - \text{NetOperatingCost}_t + \varepsilon
\\]

where \\( \text{RevenueFromFlows} \\) is the sum of all commodity flow revenues and costs (positive
for outputs, negative for inputs) valued at shadow prices, \\( \text{OperatingCost} \\) is the
variable operating cost plus levies and flow costs, and \\( \varepsilon \\) is a
small positive constant added to ensure that break-even assets are still dispatched.
variable operating cost plus levies and flow costs, \\( f_{c,\text{primary}} \\) is the primary
output flow coefficient, and \\( \varepsilon \\) is a small positive constant added to ensure that
break-even assets are still dispatched.

**NetOperatingCost** is the net cost of running the asset for one unit of activity at shadow prices
— negative when the asset is profitable (revenues exceed costs).

**FallbackCost** is the cost of procuring one unit of activity's worth of primary output from an
alternative source at the fallback price.

The asset dispatches when \\( \alpha_t > 0 \\), i.e. when:

\\[
\text{NetOperatingCost}_t < \text{FallbackCost}
\\]

\\( \phi_{c,r,t} \\) is calculated according to the strategy defined by `fallback_price_strategy`
in [`model.toml`][model-toml].

### Constraints

Expand Down
19 changes: 19 additions & 0 deletions schemas/input/model.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,25 @@ properties:

Must be >0 and <=1.
Lower values produce smaller investment increments, while higher values produce larger increments.
fallback_price_strategy:
type: string
description: Pricing strategy used to calculate fallback prices in the mini dispatch optimisation during investment appraisal
default: full_average
enum:
- unpriced
- shadow
- scarcity
- marginal
- marginal_average
- full
- full_average
notes: |
The fallback price represents the cost of procuring the commodity of interest from an
alternative source rather than producing it with the asset being appraised. It provides an
additional incentive for an asset to dispatch even when shadow prices alone are insufficient.

Setting this to `unpriced` uses a fallback price of zero, reverting to the pure
shadow-price formulation.
value_of_lost_load:
type: number
description: The cost applied to unmet demand
Expand Down
2 changes: 1 addition & 1 deletion src/commodity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ pub enum CommodityType {
}

/// The strategy used for calculating commodity prices
#[derive(Debug, PartialEq, Clone, Deserialize, Hash, Eq)]
#[derive(Debug, PartialEq, Clone, Copy, Deserialize, Hash, Eq)]
pub enum PricingStrategy {
/// Take commodity prices directly from the shadow prices
#[serde(rename = "shadow")]
Expand Down
10 changes: 10 additions & 0 deletions src/model/parameters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//! `model.toml` configuration used by the model. Validation functions ensure sensible numeric
//! ranges and invariants for runtime use.
use crate::asset::check_capacity_valid_for_asset;
use crate::commodity::PricingStrategy;
use crate::input::{
deserialise_proportion_nonzero, input_err_msg, is_sorted_and_unique, read_toml,
};
Expand Down Expand Up @@ -82,6 +83,12 @@ pub struct ModelParameters {
/// It is the proportion of maximum capacity that could be required across time slices.
#[serde(deserialize_with = "deserialise_proportion_nonzero")]
pub capacity_limit_factor: Dimensionless,
/// The pricing strategy used to calculate fallback prices for the mini dispatch optimisation
/// during investment appraisal.
///
/// If set to `unpriced`, a fallback price of zero is used, which reverts to the
/// pure shadow-price formulation.
pub fallback_price_strategy: PricingStrategy,
/// The cost applied to unmet demand.
///
/// Currently this only applies to the LCOX appraisal.
Expand Down Expand Up @@ -119,6 +126,7 @@ impl Default for ModelParameters {
allow_dangerous_options: false,
candidate_asset_capacity: Capacity(1e-4),
capacity_limit_factor: Dimensionless(0.05),
fallback_price_strategy: PricingStrategy::FullCostAverage,
value_of_lost_load: MoneyPerFlow(1e9),
max_ironing_out_iterations: 1,
price_tolerance: Dimensionless(1e-6),
Expand Down Expand Up @@ -330,6 +338,8 @@ impl ModelParameters {

// capacity_limit_factor already validated with deserialise_proportion_nonzero

// fallback_price_strategy already validated by deserialisation

// candidate_asset_capacity
check_capacity_valid_for_asset(self.candidate_asset_capacity)
.context("Invalid value for candidate_asset_capacity")?;
Expand Down
16 changes: 12 additions & 4 deletions src/simulation/investment/appraisal/coefficients.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use crate::model::Model;
use crate::simulation::PriceMap;
use crate::simulation::prices::Prices;
use crate::time_slice::{TimeSliceID, TimeSliceInfo};
use crate::units::MoneyPerActivity;
use crate::units::{MoneyPerActivity, MoneyPerFlow};
use indexmap::IndexMap;
use std::collections::HashMap;
use std::rc::Rc;
Expand Down Expand Up @@ -74,16 +74,24 @@ pub fn calculate_coefficients_for_asset(
// Activity coefficients
let mut activity_coefficients = IndexMap::new();
let mut market_costs = IndexMap::new();
let primary_output_flow = asset.primary_output().unwrap();
let asset_region = asset.region_id();
for time_slice in time_slice_info.iter_ids() {
// Get the operating cost of the asset. This includes the variable operating cost, levies and
// flow costs, but excludes costs/revenues from commodity consumption/production.
let operating_cost = asset.get_operating_cost(year, time_slice);
let net_operating_cost =
-calculate_asset_revenues(asset, operating_cost, time_slice, &prices.shadow);

let fallback_cost = prices
.fallback
.get(&primary_output_flow.commodity.id, asset_region, time_slice)
.unwrap_or(MoneyPerFlow(0.0))
* primary_output_flow.coeff;
Comment thread
tsmbland marked this conversation as resolved.

let coefficient =
calculate_asset_revenues(asset, operating_cost, time_slice, &prices.shadow);
activity_coefficients.insert(
time_slice.clone(),
coefficient + EPSILON_ACTIVITY_COEFFICIENT,
fallback_cost - net_operating_cost + EPSILON_ACTIVITY_COEFFICIENT,
);

let market_cost = match objective_type {
Expand Down
54 changes: 46 additions & 8 deletions src/simulation/prices.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//!
#![doc = concat!("See <", crate::docs_url!("/model/prices.html"), ">")]
use crate::asset::AssetRef;
use crate::commodity::{CommodityID, CommodityMap, PricingStrategy};
use crate::commodity::{CommodityID, CommodityMap, CommodityType, PricingStrategy};
use crate::model::Model;
use crate::region::RegionID;
use crate::simulation::market::MarketSet;
Expand Down Expand Up @@ -61,6 +61,8 @@ pub struct Prices {
pub market: PriceMap,
/// Commodity shadow prices
pub shadow: PriceMap,
/// Commodity fallback prices calculated according to the model's `fallback_price_strategy`
pub fallback: PriceMap,
}

/// Calculate commodity prices.
Expand Down Expand Up @@ -90,9 +92,12 @@ pub fn calculate_prices(
let shadow_prices =
PriceMap::from_iter(solution_with_candidates.iter_commodity_balance_duals());

// Set up empty prices map
// Set up empty market prices map
let mut market_prices = PriceMap::default();

// Set up empty fallback prices map
let mut fallback_prices = PriceMap::default();

// Lazily computed only if at least one FullCost market is encountered.
let mut annual_activities: Option<HashMap<AssetRef, Activity>> = None;

Expand All @@ -107,12 +112,27 @@ pub fn calculate_prices(
&shadow_prices,
&mut annual_activities,
&mut market_prices,
None,
);
if model.parameters.fallback_price_strategy != PricingStrategy::Unpriced {
price_market_set(
market_set,
model,
solution_without_candidates,
solution_with_candidates,
year,
&shadow_prices,
&mut annual_activities,
&mut fallback_prices,
Some(model.parameters.fallback_price_strategy),
);
}
}

Ok(Prices {
market: market_prices,
shadow: shadow_prices,
fallback: fallback_prices,
})
}

Expand All @@ -127,6 +147,7 @@ fn price_market_set(
shadow_prices: &PriceMap,
annual_activities: &mut Option<HashMap<AssetRef, Activity>>,
market_prices: &mut PriceMap,
strategy_override: Option<PricingStrategy>,
) {
match market_set {
MarketSet::Single(market) => {
Expand All @@ -139,6 +160,7 @@ fn price_market_set(
shadow_prices,
annual_activities,
market_prices,
strategy_override,
);
}
MarketSet::Cycle(markets) => {
Expand All @@ -151,6 +173,7 @@ fn price_market_set(
shadow_prices,
annual_activities,
market_prices,
strategy_override,
);
}
MarketSet::Layer(market_sets) => {
Expand All @@ -164,6 +187,7 @@ fn price_market_set(
shadow_prices,
annual_activities,
market_prices,
strategy_override,
);
}
}
Expand Down Expand Up @@ -198,17 +222,29 @@ fn price_markets(
shadow_prices: &PriceMap,
annual_activities: &mut Option<HashMap<AssetRef, Activity>>,
market_prices: &mut PriceMap,
strategy_override: Option<PricingStrategy>,
) {
// Partition markets by pricing strategy into a map keyed by `PricingStrategy`.
// For now, commodities use a single strategy for all regions, but this may change in the future.
let mut pricing_sets = HashMap::new();
for (commodity_id, region_id) in markets {
let commodity = &model.commodities[commodity_id];
if commodity.pricing_strategy == PricingStrategy::Unpriced {
continue;
}
// If a strategy override is provided, apply it to all commodities in the markets list.
let strategy = if let Some(strategy) = strategy_override.as_ref() {
// Skip OTH commodities — they are not subject to investment decisions
if model.commodities[commodity_id].kind == CommodityType::Other {
continue;
}
strategy
} else {
// Otherwise, use the commodity's pricing strategy. For now, commodities use a single
// strategy for all regions, but this may change in the future.
let strategy = &model.commodities[commodity_id].pricing_strategy;
if *strategy == PricingStrategy::Unpriced {
continue;
}
strategy
};
pricing_sets
.entry(&commodity.pricing_strategy)
.entry(strategy)
.or_insert_with(HashSet::new)
.insert((commodity_id.clone(), region_id.clone()));
}
Expand Down Expand Up @@ -311,6 +347,7 @@ fn price_cycle(
shadow_prices: &PriceMap,
annual_activities: &mut Option<HashMap<AssetRef, Activity>>,
market_prices: &mut PriceMap,
strategy_override: Option<PricingStrategy>,
) {
// Seed the markets with shadow prices
for (commodity_id, region_id) in markets {
Expand All @@ -334,6 +371,7 @@ fn price_cycle(
shadow_prices,
annual_activities,
market_prices,
strategy_override,
);
}
}
Expand Down
17 changes: 17 additions & 0 deletions tests/data/circularity/asset_capacities.csv
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,20 @@ milestone_year,asset_id,group_id,capacity,num_units
2030,18,,113.37276042798105,
2030,19,,2944.146516324062,
2030,20,,119.46854244531636,
2040,1,,1738.05,
2040,5,,3.964844,
2040,6,,2.999,
2040,16,,1849.2839907535792,
2040,18,,113.37276042798105,
2040,20,,119.46854244531636,
2040,21,,912.8939641298446,
2040,22,,2162.373384722901,
2040,23,,33.51213708228713,
2040,24,,4.285571618385969,
2040,25,,9.849164928608296,
2040,26,,115.45981236997261,
2040,27,,462.124901316292,
2040,28,,972.4856516656134,
2040,29,,2492.1031454342215,
2040,30,,485.2311463821062,
2040,31,,2627.9227668603858,
11 changes: 11 additions & 0 deletions tests/data/circularity/assets.csv
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,14 @@ asset_id,group_id,process_id,region_id,agent_id,commission_year
18,,BIOPLL,GBR,A0_BPL,2030
19,,OAGRSV,GBR,A0_OAG,2030
20,,BIOPRO,GBR,A0_BPD,2030
21,,THYBCR,GBR,A0_TRA,2040
22,,RBIOBL,GBR,A0_RES,2040
23,,WNDFRM,GBR,A0_ELC,2040
24,,H2YGEN,GBR,A0_ELC,2040
25,,GASCGT,GBR,A0_ELC,2040
26,,H2YPRO,GBR,A0_ELC,2040
27,,GASPRC,GBR,A0_OAG,2040
28,,OILRF2,GBR,A0_REF,2040
29,,BIOPLL,GBR,A0_BPL,2040
30,,GASDRV,GBR,A0_OAG,2040
31,,BIOPRO,GBR,A0_BPD,2040
Loading
Loading