Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
8e9060d
Basic approach to allow commodity-dependent pricing strategy
tsmbland Dec 2, 2025
ffefd7d
Add MarginalCost pricing strategy
tsmbland Dec 3, 2025
5bb3260
Add FullCost pricing strategy
tsmbland Dec 3, 2025
12f3876
Small tidy-ups
tsmbland Dec 3, 2025
6c15425
Less wasted price calculation
tsmbland Dec 3, 2025
21af6a6
Fix tests
tsmbland Dec 3, 2025
91f617e
Add unpriced method
tsmbland Dec 3, 2025
7d20913
Add "default" option
tsmbland Dec 3, 2025
bcda35d
Restore warnings
tsmbland Dec 3, 2025
d7a652b
Fix calculation errors
tsmbland Dec 4, 2025
3c68f83
Get working for assets with multiple outputs
tsmbland Dec 4, 2025
0c418dd
Small tidy ups
tsmbland Dec 4, 2025
4f1d351
Fix mistake with multiple output commodities
tsmbland Dec 4, 2025
efdbb0b
Update schema
tsmbland Dec 4, 2025
93cd72e
Add tests for validate_commodity
tsmbland Dec 4, 2025
c8d3008
Caching capital cost for more efficient price calculation
tsmbland Dec 4, 2025
0429d75
Merge branch 'main' into pricing_strategy
tsmbland Dec 8, 2025
d708aee
Use candidates for new strategies
tsmbland Dec 8, 2025
6f8f6f1
Fix for some missing prices
tsmbland Dec 8, 2025
f7cd18f
Performance improvements
tsmbland Dec 8, 2025
fba830a
Merge branch 'main' into pricing_strategy
tsmbland Dec 10, 2025
e4fe4fa
Apply output levies and flow costs to the specific commodity
tsmbland Dec 10, 2025
e61bf68
Move some code to assets submodule
tsmbland Dec 10, 2025
c937eb8
Use candidates only as backup
tsmbland Dec 10, 2025
7de4257
Only consider existing assets with activity
tsmbland Dec 10, 2025
aff8bb2
Revert some changes
tsmbland Dec 10, 2025
89c8f82
Docstrings
tsmbland Dec 10, 2025
46c362c
Update example models to use full cost method
tsmbland Dec 10, 2025
9c6c408
Update circularity model
tsmbland Dec 10, 2025
80659cd
Docstrings and small improvements
tsmbland Dec 11, 2025
5a438f2
Add examples to docstrings
tsmbland Dec 11, 2025
80f4b1f
Fix to take availability limits into account
tsmbland Dec 11, 2025
f12ce18
Fix tests
tsmbland Dec 11, 2025
19cb06b
Update regression test data
tsmbland Dec 11, 2025
456ad27
Prevent unnecessary calculation of activity limits
tsmbland Dec 11, 2025
11f60de
Suggestions from code review
tsmbland Dec 17, 2025
6e3fa57
Update tests
tsmbland Dec 17, 2025
1a74dfc
Remove Default option from PricingStrategy
tsmbland Dec 17, 2025
3f2fc6b
Simplify code for assembling pricing sets
tsmbland Dec 17, 2025
dcbc443
iter_marginal_costs_with_filter
tsmbland Dec 17, 2025
5bf3988
EPSILON constant for unit types
tsmbland Dec 17, 2025
26908e0
Move broken options check to input layer
tsmbland Dec 17, 2025
f6d0d6a
Add item type
tsmbland Dec 17, 2025
6c6d6d6
Add tests for new pricing methods
tsmbland Dec 17, 2025
678c498
Revert "Update example models to use full cost method"
tsmbland Dec 17, 2025
1eb56b1
Leave pricing strategy unspecified
tsmbland Dec 17, 2025
e6a88d2
Update output files
tsmbland Dec 17, 2025
af30546
Merge branch 'main' into pricing_strategy
tsmbland Dec 17, 2025
22a7b1a
Merge branch 'activity_limits_bug_fix' into pricing_strategy
tsmbland Dec 17, 2025
68e2f3d
Remove empty pricing_strategy columns from commodities files
tsmbland Dec 17, 2025
7c6370b
Update documentation
tsmbland Dec 18, 2025
dc7a617
Gatekeep new pricing strategies
tsmbland Dec 18, 2025
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
17 changes: 17 additions & 0 deletions schemas/input/commodities.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,29 @@ fields:
description: A human-readable label for the commodity
- name: type
type: string
enum: [svd, sed, oth]
description: The type of commodity
notes: |
Must be one of `svd` (service demand), `sed` (supply equals demand) or `oth` (other)
- name: time_slice_level
type: string
enum: [annual, season, daynight]
description: The time slice level at which constraints for this commodity are applied
notes: |
Must be one of `annual` (whole year), `season` (whole season) or `daynight` (a particular time
of day)
- name: pricing_strategy
type: string
enum: [shadow, marginal, full, scarcity, unpriced]
description: The pricing strategy for this commodity
notes: |
Optional. If specified, must be one of `shadow` (priced using shadow prices from supply-demand
constraints), `marginal` (priced at the marginal cost of production) , `full` (priced at the
full cost of production including capital costs), `scarcity` (priced adjusted for scarcity),
or `unpriced` (not priced at all).

For `svd` and `sed` commodities, this must be one of `shadow`, `marginal` or `full`. For `oth`
commodities, this must be `unpriced`.

If unspecified, the commodity will use the default pricing strategy according to the commodity
type: `shadow` for `svd` and `sed` commodities, and `unpriced` for `oth` commodities.
11 changes: 0 additions & 11 deletions schemas/input/model.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,6 @@ properties:
notes: |
This is the proportion of the maximum required capacity across time slices (for a given
asset/commodity etc. combination).
pricing_strategy:
type: string
enum: [shadow_prices, scarcity_adjusted]
description: Change the algorithm used for calculating commodity prices
default: shadow_prices
notes: |
The `shadow_prices` option just uses the shadow prices for commodity prices.

The `scarcity_adjusted` option adjusts prices for scarcity. This may cause price instability
for assets with more than one output commodity. Do not use this unless you know what you're
doing!
value_of_lost_load:
type: number
description: The cost applied to unmet demand
Expand Down
186 changes: 177 additions & 9 deletions src/asset.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
//! Assets are instances of a process which are owned and invested in by agents.
use crate::agent::AgentID;
use crate::commodity::CommodityID;
use crate::commodity::{CommodityID, CommodityType};
use crate::finance::annual_capital_cost;
use crate::process::{
ActivityLimits, FlowDirection, Process, ProcessFlow, ProcessID, ProcessParameter,
};
use crate::region::RegionID;
use crate::simulation::CommodityPrices;
use crate::time_slice::{TimeSliceID, TimeSliceSelection};
use crate::units::{Activity, ActivityPerCapacity, Capacity, MoneyPerActivity};
use crate::units::{
Activity, ActivityPerCapacity, Capacity, FlowPerActivity, MoneyPerActivity, MoneyPerCapacity,
MoneyPerFlow,
};
use anyhow::{Context, Result, ensure};
use indexmap::IndexMap;
use itertools::{Itertools, chain};
Expand Down Expand Up @@ -288,6 +292,18 @@ impl Asset {
(cap2act * *limits.start())..=(cap2act * *limits.end())
}

/// Get the activity limits for this asset for a given time slice selection
pub fn get_activity_limits_for_selection(
&self,
time_slice_selection: &TimeSliceSelection,
) -> RangeInclusive<Activity> {
let activity_per_capacity_limits = self.activity_limits.get_limit(time_slice_selection);
let cap2act = self.process.capacity_to_activity;
let lb = self.capacity * cap2act * *activity_per_capacity_limits.start();
let ub = self.capacity * cap2act * *activity_per_capacity_limits.end();
lb..=ub
}

/// Iterate over activity limits for this asset
pub fn iter_activity_limits(
&self,
Expand Down Expand Up @@ -318,12 +334,22 @@ impl Asset {
})
}

/// Gets the total SED/SVD output per unit of activity for this asset
///
/// Note: Since we are summing coefficients from different commodities, this ONLY makes sense
/// if these commodities have the same units (e.g., all in PJ). Users are currently not made to
/// give units for commodities, so we cannot possibly enforce this. Something to potentially
/// address in future.
pub fn get_total_output_per_activity(&self) -> FlowPerActivity {
self.iter_output_flows().map(|flow| flow.coeff).sum()
}

/// Get the operating cost for this asset in a given year and time slice
pub fn get_operating_cost(&self, year: u32, time_slice: &TimeSliceID) -> MoneyPerActivity {
// The cost for all commodity flows (including levies/incentives)
let flows_cost: MoneyPerActivity = self
let flows_cost = self
.iter_flows()
.map(|flow| flow.get_total_cost(&self.region_id, year, time_slice))
.map(|flow| flow.get_total_cost_per_activity(&self.region_id, year, time_slice))
.sum();

self.process_parameter.variable_operating_cost + flows_cost
Expand Down Expand Up @@ -355,15 +381,16 @@ impl Asset {
})
}

/// Get the cost of input flows using the commodity prices in `input_prices`.
/// Get the total cost of purchasing input commodities per unit of activity for this asset.
///
/// If a price is missing, there is assumed to be no cost.
pub fn get_input_cost_from_prices(
&self,
input_prices: &CommodityPrices,
prices: &CommodityPrices,
time_slice: &TimeSliceID,
) -> MoneyPerActivity {
-self.get_revenue_from_flows_with_filter(input_prices, time_slice, |x| {
// Revenues of input flows are negative costs, so we negate the result
-self.get_revenue_from_flows_with_filter(prices, time_slice, |x| {
x.direction() == FlowDirection::Input
})
}
Expand All @@ -386,12 +413,142 @@ impl Asset {
.map(|flow| {
flow.coeff
* prices
.get(&flow.commodity.id, self.region_id(), time_slice)
.get(&flow.commodity.id, &self.region_id, time_slice)
.unwrap_or_default()
})
.sum()
}

/// Get the generic activity cost per unit of activity for this asset.
///
/// These are all activity-related costs that are not associated with specific SED/SVD outputs.
/// Includes levies, flow costs, costs of inputs and variable operating costs
fn get_generic_activity_cost(
&self,
prices: &CommodityPrices,
year: u32,
time_slice: &TimeSliceID,
) -> MoneyPerActivity {
// The cost of purchasing input commodities
let cost_of_inputs = self.get_input_cost_from_prices(prices, time_slice);

// Flow costs/levies for all flows except SED/SVD outputs
let excludes_sed_svd_output = |flow: &&ProcessFlow| {
!(flow.direction() == FlowDirection::Output
&& matches!(
flow.commodity.kind,
CommodityType::SupplyEqualsDemand | CommodityType::ServiceDemand
))
};
let flow_costs = self
.iter_flows()
.filter(excludes_sed_svd_output)
.map(|flow| flow.get_total_cost_per_activity(&self.region_id, year, time_slice))
.sum();

cost_of_inputs + flow_costs + self.process_parameter.variable_operating_cost
}

/// Iterate over marginal costs for a filtered set of SED/SVD output commodities for this asset
///
/// For each SED/SVD output commodity, the marginal cost is calculated as the sum of:
/// - Generic activity costs (variable operating costs, cost of purchasing inputs, plus all
/// levies and flow costs not associated with specific SED/SVD outputs), which are
/// shared equally over all SED/SVD outputs
/// - Production levies and flow costs for the specific SED/SVD output commodity
pub fn iter_marginal_costs_with_filter<'a>(
&'a self,
prices: &'a CommodityPrices,
year: u32,
time_slice: &'a TimeSliceID,
filter: impl Fn(&CommodityID) -> bool + 'a,
) -> Box<dyn Iterator<Item = (CommodityID, MoneyPerFlow)> + 'a> {
// Iterator over SED/SVD output flows matching the filter
let mut output_flows_iter = self
.iter_output_flows()
.filter(move |flow| filter(&flow.commodity.id))
.peekable();

// If there are no output flows after filtering, return an empty iterator
if output_flows_iter.peek().is_none() {
return Box::new(std::iter::empty::<(CommodityID, MoneyPerFlow)>());
}

// Calculate generic activity costs.
// This is all activity costs not associated with specific SED/SVD outputs, which will get
// shared equally over all SED/SVD outputs. Includes levies, flow costs, costs of inputs and
// variable operating costs
let generic_activity_cost = self.get_generic_activity_cost(prices, year, time_slice);

// Share generic activity costs equally over all SED/SVD outputs
// We sum the output coefficients of all SED/SVD commodities to get total output, then
// divide costs by this total output to get the generic cost per unit of output.
// Note: only works if all SED/SVD outputs have the same units - not currently checked!
let total_output_per_activity = self.get_total_output_per_activity();
assert!(total_output_per_activity > FlowPerActivity::EPSILON); // input checks should guarantee this
let generic_cost_per_flow = generic_activity_cost / total_output_per_activity;

// Iterate over SED/SVD output flows
Box::new(output_flows_iter.map(move |flow| {
// Get the costs for this specific commodity flow
let commodity_specific_costs_per_flow =
flow.get_total_cost_per_flow(&self.region_id, year, time_slice);

// Add these to the generic costs to get total cost for this commodity
let marginal_cost = generic_cost_per_flow + commodity_specific_costs_per_flow;
(flow.commodity.id.clone(), marginal_cost)
}))
}

/// Iterate over marginal costs for all SED/SVD output commodities for this asset
///
/// See `iter_marginal_costs_with_filter` for details.
pub fn iter_marginal_costs<'a>(
&'a self,
prices: &'a CommodityPrices,
year: u32,
time_slice: &'a TimeSliceID,
) -> Box<dyn Iterator<Item = (CommodityID, MoneyPerFlow)> + 'a> {
self.iter_marginal_costs_with_filter(prices, year, time_slice, move |_| true)
}

/// Get the annual capital cost per unit of capacity for this asset
pub fn get_annual_capital_cost_per_capacity(&self) -> MoneyPerCapacity {
let capital_cost = self.process_parameter.capital_cost;
let lifetime = self.process_parameter.lifetime;
let discount_rate = self.process_parameter.discount_rate;
annual_capital_cost(capital_cost, lifetime, discount_rate)
}

/// Get the annual capital cost per unit of activity for this asset
///
/// Total capital costs (cost per capacity * capacity) are shared equally over the year in
/// accordance with the annual activity.
pub fn get_annual_capital_cost_per_activity(
&self,
annual_activity: Activity,
) -> MoneyPerActivity {
let annual_capital_cost_per_capacity = self.get_annual_capital_cost_per_capacity();
let total_annual_capital_cost = annual_capital_cost_per_capacity * self.capacity();
assert!(
annual_activity > Activity::EPSILON,
"Cannot calculate annual capital cost per activity for an asset with zero annual activity"
);
Comment thread
tsmbland marked this conversation as resolved.
total_annual_capital_cost / annual_activity
}

/// Get the annual capital cost per unit of output flow for this asset
///
/// Total capital costs (cost per capacity * capacity) are shared equally across all output
/// flows in accordance with the annual activity and total output per unit of activity.
pub fn get_annual_capital_cost_per_flow(&self, annual_activity: Activity) -> MoneyPerFlow {
let annual_capital_cost_per_activity =
self.get_annual_capital_cost_per_activity(annual_activity);
let total_output_per_activity = self.get_total_output_per_activity();
assert!(total_output_per_activity > FlowPerActivity::EPSILON); // input checks should guarantee this
annual_capital_cost_per_activity / total_output_per_activity
}

/// Maximum activity for this asset
pub fn max_activity(&self) -> Activity {
self.capacity * self.process.capacity_to_activity
Expand All @@ -407,6 +564,17 @@ impl Asset {
self.flows.values()
}

/// Iterate over the asset's output SED/SVD flows
pub fn iter_output_flows(&self) -> impl Iterator<Item = &ProcessFlow> {
self.flows.values().filter(|flow| {
flow.direction() == FlowDirection::Output
&& matches!(
flow.commodity.kind,
CommodityType::SupplyEqualsDemand | CommodityType::ServiceDemand
)
})
}

/// Get the primary output flow (if any) for this asset
pub fn primary_output(&self) -> Option<&ProcessFlow> {
self.process
Expand Down Expand Up @@ -792,7 +960,7 @@ impl AssetPool {
the start of the simulation",
asset.process_id(),
asset.commission_year,
asset.process_parameter().lifetime
asset.process_parameter.lifetime
);
continue;
}
Expand Down
28 changes: 23 additions & 5 deletions src/commodity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,37 +24,35 @@ pub type DemandMap = HashMap<(RegionID, u32, TimeSliceSelection), Flow>;
///
/// Represents a substance (e.g. CO2) or form of energy (e.g. electricity) that can be produced or
/// consumed by processes.
#[derive(PartialEq, Debug, Deserialize, Clone)]
#[derive(PartialEq, Debug, Clone)]
pub struct Commodity {
/// Unique identifier for the commodity (e.g. "ELC")
pub id: CommodityID,
/// Text description of commodity (e.g. "electricity")
pub description: String,
/// Commodity balance type
#[serde(rename = "type")] // NB: we can't name a field type as it's a reserved keyword
pub kind: CommodityType,
/// The time slice level for commodity balance
pub time_slice_level: TimeSliceLevel,
/// Defines the strategy used for calculating commodity prices
pub pricing_strategy: PricingStrategy,
/// Production levies for this commodity for different combinations of region, year and time slice.
///
/// May be empty if there are no production levies for this commodity, otherwise there must be
/// entries for every combination of parameters. Note that these values can be negative,
/// indicating an incentive.
#[serde(skip)]
pub levies_prod: CommodityLevyMap,
/// Consumption levies for this commodity for different combinations of region, year and time slice.
///
/// May be empty if there are no consumption levies for this commodity, otherwise there must be
/// entries for every combination of parameters. Note that these values can be negative,
/// indicating an incentive.
#[serde(skip)]
pub levies_cons: CommodityLevyMap,
/// Demand as defined in input files. Will be empty for non-service-demand commodities.
///
/// The [`TimeSliceSelection`] part of the key is always at the same [`TimeSliceLevel`] as the
/// `time_slice_level` field. E.g. if the `time_slice_level` is seasonal, then there will be
/// keys representing each season (and not e.g. individual time slices).
#[serde(skip)]
pub demand: DemandMap,
}
define_id_getter! {Commodity, CommodityID}
Expand Down Expand Up @@ -89,6 +87,26 @@ pub enum CommodityType {
Other,
}

/// The strategy used for calculating commodity prices
#[derive(Debug, PartialEq, Clone, Deserialize, Hash, Eq)]
pub enum PricingStrategy {
/// Take commodity prices directly from the shadow prices
#[serde(rename = "shadow")]
Comment thread
tsmbland marked this conversation as resolved.
Shadow,
/// Adjust shadow prices for scarcity
#[serde(rename = "scarcity")]
ScarcityAdjusted,
/// Use marginal cost of highest-cost active asset producing the commodity
#[serde(rename = "marginal")]
MarginalCost,
/// Use full cost of highest-cost active asset producing the commodity
#[serde(rename = "full")]
FullCost,
/// Commodities that should not have prices calculated
#[serde(rename = "unpriced")]
Unpriced,
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
Loading