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
7 changes: 5 additions & 2 deletions schemas/input/agents.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@ fields:
notes: One or more region IDs, separated by semicolons or the string `all`
- name: agent_type
type: string
enum: [competitive]
enum: [competitive, portfolio]
description: The asset selection algorithm used by the agent
notes: |
Optional (defaults to `competitive`). Currently the only supported value is `competitive`.
Optional (defaults to `competitive`). With `competitive`, existing and candidate assets
compete in rounds, with the best option according to the agent's objective selected in each
round. With `portfolio`, the capacities of all existing and candidate assets are chosen
together in a single optimisation.
- name: decision_rule
type: string
description: The decision rule applied to objectives
Expand Down
5 changes: 4 additions & 1 deletion src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,12 @@ impl Agent {
#[derive(Debug, Clone, Copy, Default, PartialEq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AgentType {
/// The capacities of all existing and candidate assets are chosen together in a single
/// optimisation
#[default]
Portfolio,
/// Existing and candidate assets compete in rounds, with the best option according to the
/// agent's objective selected in each round
#[default]
Competitive,
}

Expand Down
14 changes: 14 additions & 0 deletions src/asset/capacity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,20 @@ impl AssetCapacity {
}
}

/// Returns a capacity of the same kind holding `total`, rounded up to a whole number of
/// tranches if discrete.
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
pub fn with_total_rounded_up(self, total: Capacity) -> Self {
match self {
AssetCapacity::Continuous(_) => AssetCapacity::continuous(total),
AssetCapacity::Discrete { tranche_size, .. } => {
// The small offset stops solver noise (e.g. 3.0000000001) adding a whole tranche
let ratio = (total / tranche_size).value();
AssetCapacity::discrete((ratio - 1e-6).ceil() as u32, tranche_size)
}
}
}

/// Returns a zero capacity of the same kind (and tranche size) as this one.
pub fn zero(&self) -> Self {
match self {
Expand Down
27 changes: 27 additions & 0 deletions src/input/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,9 @@ where
mod tests {
use super::*;
use crate::agent::DecisionRule;
use crate::fixture::assert_patched_runs_ok_simple;
use crate::patch::FilePatch;
use itertools::Itertools;
use rstest::rstest;
use std::{fs, iter};
use tempfile::tempdir;
Expand Down Expand Up @@ -240,6 +243,10 @@ mod tests {
"id,description,regions,decision_rule,agent_type\nagent,,GBR,single,competitive\n",
Some(AgentType::Competitive)
)]
#[case(
"id,description,regions,decision_rule,agent_type\nagent,,GBR,single,portfolio\n",
Some(AgentType::Portfolio)
)]
#[case(
"id,description,regions,decision_rule,agent_type\nagent,,GBR,single,invalid\n",
None
Expand All @@ -257,4 +264,24 @@ mod tests {
}
}
}

#[rstest]
#[case::all_competitive(["competitive"; 4])]
#[case::all_portfolio(["portfolio"; 4])]
#[case::mixed(["competitive", "portfolio", "competitive", "portfolio"])]
fn model_runs_with_agent_types(#[case] agent_types: [&str; 4]) {
let mut lines = vec![
"id,description,regions,decision_rule,decision_lexico_tolerance,agent_type".to_string(),
];
for (id, agent_type) in ["A0_GEX", "A0_GPR", "A0_ELC", "A0_RES"]
.iter()
.zip(agent_types)
{
lines.push(format!("{id},,all,single,,{agent_type}"));
}
let lines = lines.iter().map(String::as_str).collect_vec();
assert_patched_runs_ok_simple!(vec![
FilePatch::new(AGENT_FILE_NAME).with_replacement(&lines)
]);
}
}
1 change: 1 addition & 0 deletions src/simulation/investment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ use std::collections::HashMap;
use strum::IntoEnumIterator;

pub mod competitive;
pub mod portfolio;

/// A map of demand across time-slice selections for a specific market
pub type DemandMap = IndexMap<TimeSliceSelection, Flow>;
Expand Down
14 changes: 7 additions & 7 deletions src/simulation/investment/competitive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,19 +61,19 @@ pub fn select_assets(
agent: &Agent,
commodity: &Commodity,
region_id: &RegionID,
demand: DemandMap,
demand: &DemandMap,
existing_assets: &[AssetRef],
excluded_processes: &[ProcessID],
agent_addition_limits: HashMap<ProcessID, Capacity>,
agent_total_limits: HashMap<ProcessID, Capacity>,
agent_addition_limits: &HashMap<ProcessID, Capacity>,
agent_total_limits: &HashMap<ProcessID, Capacity>,
prices: &Prices,
year: u32,
writer: &mut DataWriter,
) -> Result<Vec<AssetRef>> {
// Existing and candidate assets from which to choose
let mut opt_assets = get_asset_options(
existing_assets,
&demand,
demand,
agent,
commodity,
region_id,
Expand All @@ -88,13 +88,13 @@ pub fn select_assets(
select_best_assets(
model,
opt_assets,
agent_addition_limits,
agent_total_limits,
agent_addition_limits.clone(),
agent_total_limits.clone(),
commodity,
agent,
region_id,
prices,
demand,
demand.clone(),
year,
writer,
)
Expand Down
163 changes: 163 additions & 0 deletions src/simulation/investment/portfolio.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
//! The portfolio asset selection algorithm.
//!
//! Rather than selecting assets one at a time, a single optimisation chooses the capacities of all
//! existing and candidate assets at once so as to meet demand at least cost.
use crate::agent::Agent;
use crate::asset::{Asset, AssetCapacity, AssetIterator, AssetRef};
use crate::commodity::Commodity;
use crate::model::Model;
use crate::output::DataWriter;
use crate::process::ProcessID;
use crate::region::RegionID;
use crate::simulation::investment::DemandMap;
use crate::simulation::prices::Prices;
use crate::units::Capacity;
use anyhow::Result;
use itertools::chain;
use std::collections::HashMap;

mod constraints;
mod costs;
mod debug;
mod optimisation;
use debug::write_debug_info;
use optimisation::perform_optimisation;

/// Select assets for an agent to meet its share of demand for a commodity in a region.
///
/// Existing assets of the agent which produce the commodity and one candidate for each process in
/// the agent's search space are considered together in a single optimisation. Processes in
/// `excluded_processes` are not considered.
///
/// # Arguments
///
/// * `model` - The model
/// * `agent` - The agent selecting assets
/// * `commodity` - The commodity for which demand must be met
/// * `region_id` - The region
/// * `demand` - The agent's share of demand for the commodity in the region
/// * `existing_assets` - The asset pool (commissioned and otherwise)
/// * `excluded_processes` - Processes which may not be selected
/// * `agent_addition_limits` - The agent's share of addition limits for candidate processes
/// * `agent_total_limits` - The agent's share of total capacity limits for processes
/// * `prices` - Commodity prices from the previous full system dispatch
/// * `year` - Current milestone year
/// * `writer` - Data writer
///
/// # Returns
///
/// The assets selected (including retained commissioned assets), or an error.
#[allow(clippy::too_many_arguments)]
pub fn select_assets(
model: &Model,
agent: &Agent,
commodity: &Commodity,
region_id: &RegionID,
demand: &DemandMap,
existing_assets: &[AssetRef],
excluded_processes: &[ProcessID],
agent_addition_limits: &HashMap<ProcessID, Capacity>,
agent_total_limits: &HashMap<ProcessID, Capacity>,
prices: &Prices,
year: u32,
writer: &mut DataWriter,
) -> Result<Vec<AssetRef>> {
// Existing and candidate assets from which to choose
let mut opt_assets =
get_asset_options(existing_assets, agent, commodity, region_id, year).collect::<Vec<_>>();

// Exclude certain processes from the options
opt_assets.retain(|asset| !excluded_processes.contains(asset.process_id()));

// Perform optimisation
let results = perform_optimisation(
model,
&opt_assets,
commodity,
prices,
year,
agent_addition_limits,
agent_total_limits,
demand,
)?;

// Write optimisation results
let commodity_id = &commodity.id;
let agent_id = &agent.id;
write_debug_info(
writer,
year,
&format!("{commodity_id} {region_id} {agent_id}"),
&results,
)?;

// Assemble assets from optimisation result
let mut selected_assets = Vec::new();
for (asset, result) in results.capacities {
// Solver noise can give tiny negative values
let capacity = asset
.capacity()
.with_total_rounded_up(result.max(Capacity(0.0)));
if capacity.is_zero() {
continue; // existing assets left out here are mothballed by the pool
}

// Selected assets are unmothballed. This must happen before `set_capacity`.
let mut selected = asset.with_no_mothballed_capacity();
let inner = selected.make_mut();
if inner.is_candidate() {
inner.select_candidate_for_investment(agent.id.clone());
}
inner.set_capacity(capacity);
selected_assets.push(selected);
}

Ok(selected_assets)
}

/// Get options from existing and potential assets for the given parameters
fn get_asset_options<'a>(
all_existing_assets: &'a [AssetRef],
agent: &'a Agent,
commodity: &'a Commodity,
region_id: &'a RegionID,
year: u32,
) -> impl Iterator<Item = AssetRef> + 'a {
// Get existing assets which produce the commodity of interest
let existing_assets = all_existing_assets
.iter()
.filter_agent(&agent.id)
.filter_region(region_id)
.filter_primary_producers_of(&commodity.id)
.cloned();

// Get candidates assets which produce the commodity of interest
let candidate_assets = get_candidate_assets(agent, region_id, commodity, year);

chain(existing_assets, candidate_assets)
}

/// Get candidate assets which produce a particular commodity for a given agent
///
/// Candidates have zero capacity, as the investment optimisation determines how much to build.
/// Capacity is discrete (in tranches of `tranche_size`) for processes with a defined
/// `tranche_size` and continuous otherwise.
fn get_candidate_assets<'a>(
agent: &'a Agent,
region_id: &'a RegionID,
commodity: &'a Commodity,
year: u32,
) -> impl Iterator<Item = AssetRef> + 'a {
agent
.iter_search_space(region_id, &commodity.id, year)
.map(move |process| {
let tranche_size = process.tranche_size.unwrap_or(Capacity(0.0));
let mut asset =
Asset::new_candidate(process.clone(), region_id.clone(), tranche_size, year)
.unwrap();
if process.tranche_size.is_none() {
asset.set_capacity(AssetCapacity::continuous(Capacity(0.0)));
}
asset.into()
})
}
Loading