Skip to content

Commit b838db7

Browse files
authored
Merge pull request #882 from EnergySystemsModellingLab/exclude-coi-price-for-lcox
Fix: Exclude commodity of interest from reduced costs calculation for LCOX assets
2 parents 7353bcd + 28385a0 commit b838db7

12 files changed

Lines changed: 1498 additions & 1436 deletions

Cargo.lock

Lines changed: 0 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ dirs = "6.0.0"
3939
edit = "0.1.5"
4040

4141
[dev-dependencies]
42-
current_dir = "0.1.2"
4342
map-macro = "0.3.0"
4443
rstest = {version = "0.26.1", default-features = false, features = ["crate-name"]}
4544

src/agent.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,14 @@ pub enum ObjectiveType {
100100
#[string = "npv"]
101101
NetPresentValue,
102102
}
103+
104+
impl ObjectiveType {
105+
/// Whether to exclude the price of the primary output commodity from reduced cost calculation
106+
pub fn exclude_primary_output_price_from_reduced_costs(&self) -> bool {
107+
// Deliberately written as a `match` block, in case we add more objective types in future
108+
match self {
109+
ObjectiveType::LevelisedCostOfX => true,
110+
ObjectiveType::NetPresentValue => false,
111+
}
112+
}
113+
}

src/asset.rs

Lines changed: 61 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
//! Assets are instances of a process which are owned and invested in by agents.
2-
use crate::agent::AgentID;
2+
use crate::agent::{AgentID, AgentMap};
33
use crate::commodity::CommodityID;
44
use crate::process::{Process, ProcessFlow, ProcessID, ProcessParameter};
55
use crate::region::RegionID;
6+
use crate::simulation::CommodityPrices;
67
use crate::time_slice::TimeSliceID;
7-
use crate::units::{
8-
Activity, ActivityPerCapacity, Capacity, Dimensionless, MoneyPerActivity, MoneyPerFlow,
9-
};
8+
use crate::units::{Activity, ActivityPerCapacity, Capacity, Dimensionless, MoneyPerActivity};
109
use anyhow::{Context, Result, ensure};
1110
use indexmap::IndexMap;
1211
use itertools::{Itertools, chain};
@@ -272,23 +271,67 @@ impl Asset {
272271
self.process_parameter.variable_operating_cost + flows_cost
273272
}
274273

275-
/// Get the cost of input flows using the commodity prices in `input_prices`
274+
/// Get the total revenue from all flows for this asset, accounting for the parent agent's
275+
/// objective.
276+
///
277+
/// We need to account for the agent's objective when calculating reduced costs, because if it
278+
/// is LCOX then we should exclude the primary output from the calculation.
279+
///
280+
/// If a price is missing from `prices`, then it is assumed to be zero.
281+
///
282+
/// # Panics
283+
///
284+
/// Panics if this asset has no parent agent (i.e. it's a candidate).
285+
pub fn get_revenue_from_flows_for_objective(
286+
&self,
287+
agents: &AgentMap,
288+
prices: &CommodityPrices,
289+
year: u32,
290+
time_slice: &TimeSliceID,
291+
) -> MoneyPerActivity {
292+
let exclude_commodity = self.primary_output().and_then(|flow| {
293+
let agent = &agents[self.agent_id().unwrap()];
294+
let exclude_coi =
295+
agent.objectives[&year].exclude_primary_output_price_from_reduced_costs();
296+
exclude_coi.then_some(&flow.commodity.id)
297+
});
298+
299+
self.get_revenue_from_flows_with_filter(prices, time_slice, |flow| {
300+
exclude_commodity.is_none_or(|commodity_id| commodity_id != &flow.commodity.id)
301+
})
302+
}
303+
304+
/// Get the cost of input flows using the commodity prices in `input_prices`.
305+
///
306+
/// If a price is missing, there is assumed to be no cost.
276307
pub fn get_input_cost_from_prices(
277308
&self,
278-
input_prices: &HashMap<(CommodityID, RegionID, TimeSliceID), MoneyPerFlow>,
309+
input_prices: &CommodityPrices,
279310
time_slice: &TimeSliceID,
280311
) -> MoneyPerActivity {
312+
-self.get_revenue_from_flows_with_filter(input_prices, time_slice, ProcessFlow::is_input)
313+
}
314+
315+
/// Get the total revenue from a subset of flows.
316+
///
317+
/// Takes a function as an argument to filter the flows. If a price is missing, it is assumed to
318+
/// be zero.
319+
fn get_revenue_from_flows_with_filter<F>(
320+
&self,
321+
prices: &CommodityPrices,
322+
time_slice: &TimeSliceID,
323+
mut filter_for_flows: F,
324+
) -> MoneyPerActivity
325+
where
326+
F: FnMut(&ProcessFlow) -> bool,
327+
{
281328
self.iter_flows()
282-
.filter_map(|flow| {
283-
if !flow.is_input() {
284-
return None;
285-
}
286-
let price = *input_prices.get(&(
287-
flow.commodity.id.clone(),
288-
self.region_id.clone(),
289-
time_slice.clone(),
290-
))?;
291-
Some(-flow.coeff * price)
329+
.filter(|flow| filter_for_flows(flow))
330+
.map(|flow| {
331+
flow.coeff
332+
* prices
333+
.get(&flow.commodity.id, self.region_id(), time_slice)
334+
.unwrap_or_default()
292335
})
293336
.sum()
294337
}
@@ -905,11 +948,8 @@ mod tests {
905948
let asset = Asset::new_candidate(process, region_id.clone(), Capacity(1.0), 2020).unwrap();
906949

907950
// Set input prices
908-
let mut input_prices = HashMap::new();
909-
input_prices.insert(
910-
(commodity_id.clone(), region_id.clone(), time_slice.clone()),
911-
MoneyPerFlow(3.0),
912-
);
951+
let mut input_prices = CommodityPrices::default();
952+
input_prices.insert(&commodity_id, &region_id, &time_slice, MoneyPerFlow(3.0));
913953

914954
// Call function
915955
let cost = asset.get_input_cost_from_prices(&input_prices, &time_slice);

src/simulation/investment.rs

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use crate::output::DataWriter;
99
use crate::region::RegionID;
1010
use crate::simulation::CommodityPrices;
1111
use crate::time_slice::{TimeSliceID, TimeSliceInfo};
12-
use crate::units::{Capacity, Dimensionless, Flow, FlowPerCapacity, MoneyPerFlow};
12+
use crate::units::{Capacity, Dimensionless, Flow, FlowPerCapacity};
1313
use anyhow::{Result, ensure};
1414
use indexmap::IndexMap;
1515
use itertools::{chain, iproduct};
@@ -68,11 +68,7 @@ pub fn perform_agent_investment(
6868
// performed will, by definition, not have any producers. For these, we provide prices
6969
// from the previous dispatch run otherwise they will appear to be free to the model.
7070
for time_slice in model.time_slice_info.iter_ids() {
71-
external_prices.remove(&(
72-
commodity_id.clone(),
73-
region_id.clone(),
74-
time_slice.clone(),
75-
));
71+
external_prices.remove(commodity_id, region_id, time_slice);
7672
}
7773

7874
// List of assets selected/retained for this region/commodity
@@ -347,14 +343,11 @@ fn get_prices_for_commodities(
347343
time_slice_info: &TimeSliceInfo,
348344
region_id: &RegionID,
349345
commodities: &[CommodityID],
350-
) -> HashMap<(CommodityID, RegionID, TimeSliceID), MoneyPerFlow> {
346+
) -> CommodityPrices {
351347
iproduct!(commodities.iter(), time_slice_info.iter_ids())
352348
.map(|(commodity_id, time_slice)| {
353349
let price = prices.get(commodity_id, region_id, time_slice).unwrap();
354-
(
355-
(commodity_id.clone(), region_id.clone(), time_slice.clone()),
356-
price,
357-
)
350+
(commodity_id, region_id, time_slice, price)
358351
})
359352
.collect()
360353
}

src/simulation/optimisation.rs

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@ use crate::commodity::CommodityID;
66
use crate::model::Model;
77
use crate::output::DataWriter;
88
use crate::region::RegionID;
9+
use crate::simulation::CommodityPrices;
910
use crate::time_slice::{TimeSliceID, TimeSliceInfo};
1011
use crate::units::{Activity, Flow, Money, MoneyPerActivity, MoneyPerFlow, UnitType};
1112
use anyhow::{Result, anyhow, ensure};
1213
use highs::{HighsModelStatus, RowProblem as Problem, Sense};
1314
use indexmap::IndexMap;
1415
use itertools::{chain, iproduct};
1516
use log::debug;
16-
use std::collections::{HashMap, HashSet};
17+
use std::collections::HashSet;
1718
use std::ops::Range;
1819

1920
mod constraints;
@@ -180,10 +181,7 @@ pub fn solve_optimal(model: highs::Model) -> Result<highs::SolvedModel> {
180181
///
181182
/// Input prices should only be provided for commodities for which there will be no commodity
182183
/// balance constraint.
183-
fn check_input_prices(
184-
input_prices: &HashMap<(CommodityID, RegionID, TimeSliceID), MoneyPerFlow>,
185-
commodities: &[CommodityID],
186-
) {
184+
fn check_input_prices(input_prices: &CommodityPrices, commodities: &[CommodityID]) {
187185
let commodities_set: HashSet<_> = commodities.iter().collect();
188186
let has_prices_for_commodity_subset = input_prices
189187
.keys()
@@ -204,7 +202,7 @@ pub struct DispatchRun<'model, 'run> {
204202
existing_assets: &'run [AssetRef],
205203
candidate_assets: &'run [AssetRef],
206204
commodities: &'run [CommodityID],
207-
input_prices: Option<&'run HashMap<(CommodityID, RegionID, TimeSliceID), MoneyPerFlow>>,
205+
input_prices: Option<&'run CommodityPrices>,
208206
year: u32,
209207
}
210208

@@ -240,10 +238,7 @@ impl<'model, 'run> DispatchRun<'model, 'run> {
240238
}
241239

242240
/// Explicitly provide prices for certain input commodities
243-
pub fn with_input_prices(
244-
self,
245-
input_prices: &'run HashMap<(CommodityID, RegionID, TimeSliceID), MoneyPerFlow>,
246-
) -> Self {
241+
pub fn with_input_prices(self, input_prices: &'run CommodityPrices) -> Self {
247242
Self {
248243
input_prices: Some(input_prices),
249244
..self
@@ -346,7 +341,7 @@ fn add_variables(
346341
problem: &mut Problem,
347342
variables: &mut VariableMap,
348343
time_slice_info: &TimeSliceInfo,
349-
input_prices: Option<&HashMap<(CommodityID, RegionID, TimeSliceID), MoneyPerFlow>>,
344+
input_prices: Option<&CommodityPrices>,
350345
assets: &[AssetRef],
351346
year: u32,
352347
) -> Range<usize> {
@@ -384,7 +379,7 @@ fn calculate_cost_coefficient(
384379
asset: &Asset,
385380
year: u32,
386381
time_slice: &TimeSliceID,
387-
input_prices: Option<&HashMap<(CommodityID, RegionID, TimeSliceID), MoneyPerFlow>>,
382+
input_prices: Option<&CommodityPrices>,
388383
) -> MoneyPerActivity {
389384
let opex = asset.get_operating_cost(year, time_slice);
390385
let input_cost = input_prices

src/simulation/prices.rs

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use crate::time_slice::{TimeSliceID, TimeSliceInfo};
99
use crate::units::{Dimensionless, MoneyPerActivity, MoneyPerFlow, Year};
1010
use indexmap::IndexMap;
1111
use itertools::iproduct;
12-
use std::collections::{BTreeMap, HashMap};
12+
use std::collections::{BTreeMap, HashMap, btree_map};
1313

1414
/// A map of reduced costs for different assets in different time slices
1515
///
@@ -146,7 +146,7 @@ pub fn calculate_prices_and_reduced_costs(
146146
// Add new reduced costs, using old values if not provided
147147
reduced_costs.extend(reduced_costs_for_candidates);
148148
reduced_costs.extend(reduced_costs_for_existing(
149-
&model.time_slice_info,
149+
model,
150150
existing_assets,
151151
&prices,
152152
year,
@@ -256,6 +256,22 @@ impl CommodityPrices {
256256
.copied()
257257
}
258258

259+
/// Iterate over the price map's keys
260+
pub fn keys(&self) -> btree_map::Keys<'_, (CommodityID, RegionID, TimeSliceID), MoneyPerFlow> {
261+
self.0.keys()
262+
}
263+
264+
/// Remove the specified entry from the map
265+
pub fn remove(
266+
&mut self,
267+
commodity_id: &CommodityID,
268+
region_id: &RegionID,
269+
time_slice: &TimeSliceID,
270+
) -> Option<MoneyPerFlow> {
271+
self.0
272+
.remove(&(commodity_id.clone(), region_id.clone(), time_slice.clone()))
273+
}
274+
259275
/// Calculate time slice-weighted average prices for each commodity-region pair
260276
///
261277
/// This method aggregates prices across time slices by weighting each price
@@ -419,22 +435,15 @@ fn get_scarcity_adjustment(
419435

420436
/// Calculate reduced costs for existing assets
421437
fn reduced_costs_for_existing<'a>(
422-
time_slice_info: &'a TimeSliceInfo,
438+
model: &'a Model,
423439
assets: &'a [AssetRef],
424440
prices: &'a CommodityPrices,
425441
year: u32,
426442
) -> impl Iterator<Item = ((AssetRef, TimeSliceID), MoneyPerActivity)> + 'a {
427-
iproduct!(assets, time_slice_info.iter_ids()).map(move |(asset, time_slice)| {
443+
iproduct!(assets, model.time_slice_info.iter_ids()).map(move |(asset, time_slice)| {
428444
let operating_cost = asset.get_operating_cost(year, time_slice);
429-
let revenue_from_flows = asset
430-
.iter_flows()
431-
.map(|flow| {
432-
flow.coeff
433-
* prices
434-
.get(&flow.commodity.id, asset.region_id(), time_slice)
435-
.unwrap()
436-
})
437-
.sum();
445+
let revenue_from_flows =
446+
asset.get_revenue_from_flows_for_objective(&model.agents, prices, year, time_slice);
438447
let reduced_cost = operating_cost - revenue_from_flows;
439448

440449
((asset.clone(), time_slice.clone()), reduced_cost)

0 commit comments

Comments
 (0)