Skip to content
Open
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
11 changes: 7 additions & 4 deletions schemas/input/model.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,14 @@ properties:
The default value should work. Do not change this value unless you know what you're doing!
capacity_limit_factor:
type: number
description: A factor which constrains the maximum capacity given to candidate assets
default: 0.1
description: Scales the capacity assigned to candidate assets
default: 0.05
notes: |
Comment thread
tsmbland marked this conversation as resolved.
This is the proportion of the maximum required capacity across time slices (for a given
asset/commodity etc. combination).
Candidate assets for investment are assigned a characteristic capacity scale based on annual
demand. This parameter specifies the fraction of that scale appraised each investment round.

Must be >0 and <=1.
Lower values produce smaller investment increments, while higher values produce larger increments.
value_of_lost_load:
type: number
description: The cost applied to unmet demand
Expand Down
31 changes: 1 addition & 30 deletions src/asset/capacity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,27 +144,12 @@ impl AssetCapacity {
"Cannot change capacity type"
);
}

/// Applies a limit factor to the capacity, scaling it accordingly.
///
/// For discrete capacities, the number of units is scaled by the limit factor and rounded up to
/// the nearest integer.
pub fn apply_limit_factor(self, limit_factor: Dimensionless) -> Self {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
match self {
AssetCapacity::Continuous(cap) => AssetCapacity::Continuous(cap * limit_factor),
AssetCapacity::Discrete(units, size) => {
let new_units = (units as f64 * limit_factor.value()).ceil() as u32;
AssetCapacity::Discrete(new_units, size)
}
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::units::{Capacity, Dimensionless};
use crate::units::Capacity;
use rstest::rstest;

#[rstest]
Expand Down Expand Up @@ -209,20 +194,6 @@ mod tests {
assert_eq!(got.total_capacity(), expected_total);
}

#[rstest]
#[case::round_up(3u32, Capacity(4.0), Dimensionless(0.5), 2u32)]
#[case::exact(3u32, Capacity(4.0), Dimensionless(0.33), 1u32)]
fn apply_limit_factor(
#[case] start_units: u32,
#[case] unit_size: Capacity,
#[case] factor: Dimensionless,
#[case] expected_units: u32,
) {
let orig = AssetCapacity::Discrete(start_units, unit_size);
let got = orig.apply_limit_factor(factor);
assert_eq!(got, AssetCapacity::Discrete(expected_units, unit_size));
}

#[rstest]
#[case::less(
AssetCapacity::Continuous(Capacity(4.0)),
Expand Down
2 changes: 1 addition & 1 deletion src/model/parameters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ impl Default for ModelParameters {
// Default values for optional parameters
allow_dangerous_options: false,
candidate_asset_capacity: Capacity(1e-4),
capacity_limit_factor: Dimensionless(0.1),
capacity_limit_factor: Dimensionless(0.05),
value_of_lost_load: MoneyPerFlow(1e9),
Comment on lines 118 to 122

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There will be more to add to the release notes. Best to do this just before making a release, as we've done in the past.

max_ironing_out_iterations: 1,
price_tolerance: Dimensionless(1e-6),
Expand Down
96 changes: 82 additions & 14 deletions src/simulation/investment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use crate::model::Model;
use crate::output::DataWriter;
use crate::region::RegionID;
use crate::simulation::prices::Prices;
use crate::time_slice::{TimeSliceID, TimeSliceInfo, TimeSliceLevel};
use crate::time_slice::{TimeSliceID, TimeSliceInfo, TimeSliceLevel, TimeSliceSelection};
use crate::units::{ActivityPerCapacity, Capacity, Dimensionless, Flow, FlowPerCapacity};
use anyhow::{Context, Result, ensure};
use indexmap::IndexMap;
Expand Down Expand Up @@ -285,7 +285,6 @@ fn select_assets_for_single_market(

// Existing and candidate assets from which to choose
let opt_assets = get_asset_options(
&model.time_slice_info,
existing_assets,
&demand_portion_for_market,
agent,
Expand Down Expand Up @@ -572,6 +571,34 @@ where
})
}

/// Calculates a characteristic capacity scale for a candidate asset.
///
/// The returned value is the capacity that would satisfy the total annual demand assuming the asset
/// operates at its maximum annual activity for the entire year. It ignores finer-grained activity
/// constraints and temporal variations in demand.
///
/// This value is later scaled by `capacity_limit_factor` to set capacities for candidate assets in
/// each round of investments.
///
/// If the asset has zero maximum annual supply, zero capacity is returned. This indicates that the
/// asset is non-feasible, and will be excluded from consideration by `select_best_assets`.
fn calculate_candidate_asset_capacity_scale(
asset: &Asset,
commodity: &Commodity,
demand: &DemandMap,
) -> Capacity {
let coeff = asset.get_flow(&commodity.id).unwrap().coeff;
let max_annual_supply_per_capacity = *asset
.get_activity_per_capacity_limits_for_selection(&TimeSliceSelection::Annual)
.end()
* coeff;
if max_annual_supply_per_capacity < FlowPerCapacity::EPSILON {
return Capacity(0.0);
}
let annual_demand = demand.values().copied().sum::<Flow>();
annual_demand / max_annual_supply_per_capacity
}

/// Returns the minimum installed capacity required for `asset` to satisfy the demand that it can
/// potentially serve, accounting for its activity constraints.
///
Expand Down Expand Up @@ -656,9 +683,7 @@ fn get_demand_limiting_capacity(
}

/// Get options from existing and potential assets for the given parameters
#[allow(clippy::too_many_arguments)]
fn get_asset_options<'a>(
time_slice_info: &'a TimeSliceInfo,
all_existing_assets: &'a [AssetRef],
demand: &'a DemandMap,
agent: &'a Agent,
Expand All @@ -677,7 +702,6 @@ fn get_asset_options<'a>(

// Get candidates assets which produce the commodity of interest
let candidate_assets = get_candidate_assets(
time_slice_info,
demand,
agent,
region_id,
Expand All @@ -691,10 +715,11 @@ fn get_asset_options<'a>(

/// Get candidate assets which produce a particular commodity for a given agent
///
/// Capacities of candidate assets are set to the demand-limiting capacity for the given market,
/// scaled by the `capacity_limit_factor`.
/// Each candidate is assigned a capacity. For divisible assets, the capacity is set to 1 unit.
/// For indivisible assets, a capacity is calculated based on the total demand for the commodity and
/// the asset's maximum annual production per unit capacity
/// (see `calculate_candidate_asset_capacity_scale`), then multiplied by `capacity_limit_factor`.
fn get_candidate_assets<'a>(
time_slice_info: &'a TimeSliceInfo,
demand: &'a DemandMap,
agent: &'a Agent,
region_id: &'a RegionID,
Expand All @@ -705,17 +730,22 @@ fn get_candidate_assets<'a>(
agent
.iter_search_space(region_id, &commodity.id, year)
.map(move |process| {
// Create asset with zero capacity, which will be updated below
let mut asset =
Asset::new_candidate(process.clone(), region_id.clone(), Capacity(0.0), year)
.unwrap();

// Set capacity based on demand
// This will serve as the upper limit when appraising the asset
let capacity = get_demand_limiting_capacity(time_slice_info, &asset, commodity, demand);
let asset_capacity = AssetCapacity::from_capacity(capacity, asset.unit_size())
.apply_limit_factor(capacity_limit_factor);
// Set capacity of the candidate
// This will serve as the upper limit when appraising the asset (may later be
// constrained by process addition limits and demand-limiting capacity)
let asset_capacity = if let Some(unit_size) = asset.unit_size() {
AssetCapacity::Discrete(1, unit_size)
} else {
let capacity_scale =
calculate_candidate_asset_capacity_scale(&asset, commodity, demand);
AssetCapacity::Continuous(capacity_scale * capacity_limit_factor)
};
asset.set_capacity(asset_capacity);

asset.into()
})
}
Expand Down Expand Up @@ -1139,6 +1169,44 @@ mod tests {
assert_eq!(result, Capacity(20.0));
}

#[rstest]
#[case(Flow(10.0), Dimensionless(1.0), Capacity(10.0))] // normal: demand / (limit * coeff) = 10 / (1 * 1) = 10
#[case(Flow(0.0), Dimensionless(1.0), Capacity(0.0))] // zero demand → zero capacity
#[case(Flow(10.0), Dimensionless(0.5), Capacity(20.0))] // activity limit < 1: 10 / (0.5 * 1) = 20
#[case(Flow(10.0), Dimensionless(0.0), Capacity(0.0))] // activity limit = 0 → zero capacity
fn calculate_asset_capacity_scale_works(
time_slice: TimeSliceID,
time_slice_info: TimeSliceInfo,
svd_commodity: Commodity,
mut process: Process,
#[case] demand_value: Flow,
#[case] activity_limit: Dimensionless,
#[case] expected: Capacity,
) {
let commodity_rc = Rc::new(svd_commodity);
let process_flow = ProcessFlow {
commodity: Rc::clone(&commodity_rc),
coeff: FlowPerActivity(1.0),
kind: FlowType::Fixed,
cost: MoneyPerFlow(0.0),
};
process.flows = process_flows_map(
process.regions.clone(),
Rc::new(indexmap! { commodity_rc.id.clone() => process_flow }),
);

let mut limits = ActivityLimits::new_with_full_availability(&time_slice_info);
limits.add_time_slice_limit(time_slice.clone(), Dimensionless(0.0)..=activity_limit);
process.activity_limits = process_activity_limits_map(process.regions.clone(), limits);

let asset = asset(process);
let demand = indexmap! { time_slice => demand_value };
assert_eq!(
calculate_candidate_asset_capacity_scale(&asset, &commodity_rc, &demand),
expected
);
}

#[rstest]
fn collect_investment_limits_for_candidates_empty_list() {
let result = collect_investment_limits_for_candidates(&[], Dimensionless(1.0));
Expand Down
26 changes: 6 additions & 20 deletions tests/data/circularity/asset_capacities.csv
Original file line number Diff line number Diff line change
Expand Up @@ -21,23 +21,9 @@ milestone_year,asset_id,group_id,capacity,num_units
2030,6,,2.999,
2030,13,,2900.0,
2030,14,,399.98,
2030,15,,777.3007440837422,
2030,16,,1827.9092029382402,
2030,17,,4076.5105773701275,
2030,18,,1643.7237844403953,
2030,19,,2649.731875290583,
2030,20,,1625.1150136624153,
2040,1,,1738.05,
2040,5,,3.964844,
2040,16,,1827.9092029382402,
2040,17,,4076.5105773701275,
2040,18,,1643.7237844403953,
2040,19,,2649.731875290583,
2040,20,,1625.1150136624153,
2040,21,,912.8939641298439,
2040,22,,2005.8286877382402,
2040,23,,177.9194848000003,
2040,24,,1.3918603724966105,
2040,25,,1905.1678522886418,
2040,26,,3111.954097466583,
2040,27,,2000.426244903074,
2030,15,,777.3007440837426,
2030,16,,1849.2839907535792,
2030,17,,3680.183145405078,
2030,18,,113.37276042798105,
2030,19,,2944.146516324062,
2030,20,,119.46854244531636,
7 changes: 0 additions & 7 deletions tests/data/circularity/assets.csv
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,3 @@ 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,,TDIECR,GBR,A0_TRA,2040
22,,RBIOBL,GBR,A0_RES,2040
23,,RELCHP,GBR,A0_RES,2040
24,,WNDFRM,GBR,A0_ELC,2040
25,,BIOPLL,GBR,A0_BPL,2040
26,,OAGRSV,GBR,A0_OAG,2040
27,,BIOPRO,GBR,A0_BPD,2040
Loading