use crate::asset::AssetRef;
use crate::commodity::{CommodityID, CommodityMap, PricingStrategy};
use crate::input::try_insert;
use crate::model::Model;
use crate::region::RegionID;
use crate::simulation::optimisation::Solution;
use crate::time_slice::{TimeSliceID, TimeSliceInfo, TimeSliceSelection};
use crate::units::{Activity, Dimensionless, Flow, MoneyPerActivity, MoneyPerFlow, UnitType, Year};
use anyhow::Result;
use indexmap::IndexMap;
use std::collections::{HashMap, HashSet};
use std::marker::PhantomData;
#[derive(Clone, Copy, Debug)]
struct WeightedAverageAccumulator<W: UnitType> {
numerator: MoneyPerFlow,
denominator: Dimensionless,
_weight_type: PhantomData<W>,
}
impl<W: UnitType> Default for WeightedAverageAccumulator<W> {
fn default() -> Self {
Self {
numerator: MoneyPerFlow(0.0),
denominator: Dimensionless(0.0),
_weight_type: PhantomData,
}
}
}
impl<W: UnitType> WeightedAverageAccumulator<W> {
fn add(&mut self, value: MoneyPerFlow, weight: W) {
let weight = Dimensionless(weight.value());
self.numerator += value * weight;
self.denominator += weight;
}
fn finalise(self) -> Option<MoneyPerFlow> {
(self.denominator > Dimensionless::EPSILON).then(|| self.numerator / self.denominator)
}
}
#[derive(Clone, Copy, Debug)]
struct WeightedAverageBackupAccumulator<W: UnitType> {
primary: WeightedAverageAccumulator<W>,
backup: WeightedAverageAccumulator<W>,
}
impl<W: UnitType> Default for WeightedAverageBackupAccumulator<W> {
fn default() -> Self {
Self {
primary: WeightedAverageAccumulator::<W>::default(),
backup: WeightedAverageAccumulator::<W>::default(),
}
}
}
impl<W: UnitType> WeightedAverageBackupAccumulator<W> {
fn add(&mut self, value: MoneyPerFlow, weight: W, backup_weight: W) {
self.primary.add(value, weight);
self.backup.add(value, backup_weight);
}
fn finalise(self) -> Option<MoneyPerFlow> {
self.primary.finalise().or_else(|| self.backup.finalise())
}
}
pub fn calculate_prices(model: &Model, solution: &Solution, year: u32) -> Result<CommodityPrices> {
let shadow_prices = CommodityPrices::from_iter(solution.iter_commodity_balance_duals());
let mut result = CommodityPrices::default();
let mut annual_activities: Option<HashMap<AssetRef, Activity>> = None;
let investment_order = &model.investment_order[&year];
for investment_set in investment_order.iter().rev() {
let mut pricing_sets = HashMap::new();
for (commodity_id, region_id) in investment_set.iter_markets() {
let commodity = &model.commodities[commodity_id];
if commodity.pricing_strategy == PricingStrategy::Unpriced {
continue;
}
pricing_sets
.entry(&commodity.pricing_strategy)
.or_insert_with(HashSet::new)
.insert((commodity_id.clone(), region_id.clone()));
}
if let Some(shadow_set) = pricing_sets.get(&PricingStrategy::Shadow) {
for (commodity_id, region_id, time_slice) in shadow_prices.keys() {
if shadow_set.contains(&(commodity_id.clone(), region_id.clone())) {
let price = shadow_prices
.get(commodity_id, region_id, time_slice)
.unwrap();
result.insert(commodity_id, region_id, time_slice, price);
}
}
}
if let Some(scarcity_set) = pricing_sets.get(&PricingStrategy::ScarcityAdjusted) {
add_scarcity_adjusted_prices(
solution.iter_activity_duals(),
&shadow_prices,
&mut result,
scarcity_set,
);
}
if let Some(marginal_set) = pricing_sets.get(&PricingStrategy::MarginalCost) {
add_marginal_cost_prices(
solution.iter_activity_for_existing(),
solution.iter_activity_keys_for_candidates(),
&mut result,
year,
marginal_set,
&model.commodities,
&model.time_slice_info,
);
}
if let Some(marginal_avg_set) = pricing_sets.get(&PricingStrategy::MarginalCostAverage) {
add_marginal_cost_average_prices(
solution.iter_activity_for_existing(),
solution.iter_activity_keys_for_candidates(),
&mut result,
year,
marginal_avg_set,
&model.commodities,
&model.time_slice_info,
);
}
if let Some(fullcost_set) = pricing_sets.get(&PricingStrategy::FullCost) {
let annual_activities = annual_activities.get_or_insert_with(|| {
calculate_annual_activities(solution.iter_activity_for_existing())
});
add_full_cost_prices(
solution.iter_activity_for_existing(),
solution.iter_activity_keys_for_candidates(),
annual_activities,
&mut result,
year,
fullcost_set,
&model.commodities,
&model.time_slice_info,
);
}
if let Some(full_avg_set) = pricing_sets.get(&PricingStrategy::FullCostAverage) {
let annual_activities = annual_activities.get_or_insert_with(|| {
calculate_annual_activities(solution.iter_activity_for_existing())
});
add_full_cost_average_prices(
solution.iter_activity_for_existing(),
solution.iter_activity_keys_for_candidates(),
annual_activities,
&mut result,
year,
full_avg_set,
&model.commodities,
&model.time_slice_info,
);
}
}
Ok(result)
}
#[derive(Default, Clone)]
pub struct CommodityPrices(IndexMap<(CommodityID, RegionID, TimeSliceID), MoneyPerFlow>);
impl CommodityPrices {
pub fn insert(
&mut self,
commodity_id: &CommodityID,
region_id: &RegionID,
time_slice: &TimeSliceID,
price: MoneyPerFlow,
) {
let key = (commodity_id.clone(), region_id.clone(), time_slice.clone());
try_insert(&mut self.0, &key, price).unwrap();
}
pub fn extend<T>(&mut self, iter: T)
where
T: IntoIterator<Item = ((CommodityID, RegionID, TimeSliceID), MoneyPerFlow)>,
{
for (key, price) in iter {
try_insert(&mut self.0, &key, price).unwrap();
}
}
fn extend_selection_prices(
&mut self,
group_prices: &IndexMap<(CommodityID, RegionID, TimeSliceSelection), MoneyPerFlow>,
time_slice_info: &TimeSliceInfo,
) {
for ((commodity_id, region_id, selection), &selection_price) in group_prices {
for (time_slice_id, _) in selection.iter(time_slice_info) {
self.insert(commodity_id, region_id, time_slice_id, selection_price);
}
}
}
pub fn iter(
&self,
) -> impl Iterator<Item = (&CommodityID, &RegionID, &TimeSliceID, MoneyPerFlow)> {
self.0
.iter()
.map(|((commodity_id, region_id, ts), price)| (commodity_id, region_id, ts, *price))
}
pub fn get(
&self,
commodity_id: &CommodityID,
region_id: &RegionID,
time_slice: &TimeSliceID,
) -> Option<MoneyPerFlow> {
self.0
.get(&(commodity_id.clone(), region_id.clone(), time_slice.clone()))
.copied()
}
pub fn keys(
&self,
) -> indexmap::map::Keys<'_, (CommodityID, RegionID, TimeSliceID), MoneyPerFlow> {
self.0.keys()
}
fn time_slice_weighted_averages(
&self,
time_slice_info: &TimeSliceInfo,
) -> HashMap<(CommodityID, RegionID), MoneyPerFlow> {
let mut weighted_prices = HashMap::new();
for ((commodity_id, region_id, time_slice_id), price) in &self.0 {
let weight = time_slice_info.time_slices[time_slice_id] / Year(1.0);
let key = (commodity_id.clone(), region_id.clone());
weighted_prices
.entry(key)
.and_modify(|v| *v += *price * weight)
.or_insert_with(|| *price * weight);
}
weighted_prices
}
pub fn within_tolerance_weighted(
&self,
other: &Self,
tolerance: Dimensionless,
time_slice_info: &TimeSliceInfo,
) -> bool {
let self_averages = self.time_slice_weighted_averages(time_slice_info);
let other_averages = other.time_slice_weighted_averages(time_slice_info);
for (key, &price) in &self_averages {
let other_price = other_averages[key];
let abs_diff = (price - other_price).abs();
if price == MoneyPerFlow(0.0) {
if other_price != MoneyPerFlow(0.0) {
return false;
}
} else if abs_diff / price.abs() > tolerance {
return false;
}
}
true
}
}
impl<'a> FromIterator<(&'a CommodityID, &'a RegionID, &'a TimeSliceID, MoneyPerFlow)>
for CommodityPrices
{
fn from_iter<I>(iter: I) -> Self
where
I: IntoIterator<Item = (&'a CommodityID, &'a RegionID, &'a TimeSliceID, MoneyPerFlow)>,
{
let map = iter
.into_iter()
.map(|(commodity_id, region_id, time_slice, price)| {
(
(commodity_id.clone(), region_id.clone(), time_slice.clone()),
price,
)
})
.collect();
CommodityPrices(map)
}
}
impl IntoIterator for CommodityPrices {
type Item = ((CommodityID, RegionID, TimeSliceID), MoneyPerFlow);
type IntoIter = indexmap::map::IntoIter<(CommodityID, RegionID, TimeSliceID), MoneyPerFlow>;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
fn add_scarcity_adjusted_prices<'a, I>(
activity_duals: I,
shadow_prices: &CommodityPrices,
existing_prices: &mut CommodityPrices,
markets_to_price: &HashSet<(CommodityID, RegionID)>,
) where
I: Iterator<Item = (&'a AssetRef, &'a TimeSliceID, MoneyPerActivity)>,
{
let mut highest_duals = IndexMap::new();
for (asset, time_slice, dual) in activity_duals {
let region_id = asset.region_id();
for flow in asset.iter_output_flows().filter(|flow| {
markets_to_price.contains(&(flow.commodity.id.clone(), region_id.clone()))
}) {
highest_duals
.entry((
flow.commodity.id.clone(),
region_id.clone(),
time_slice.clone(),
))
.and_modify(|current_dual| {
if dual > *current_dual {
*current_dual = dual;
}
})
.or_insert(dual);
}
}
for ((commodity, region, time_slice), highest_dual) in &highest_duals {
let shadow_price = shadow_prices.get(commodity, region, time_slice).unwrap();
let scarcity_price = shadow_price + MoneyPerFlow(highest_dual.value());
existing_prices.insert(commodity, region, time_slice, scarcity_price);
}
}
fn add_marginal_cost_prices<'a, I, J>(
activity_for_existing: I,
activity_keys_for_candidates: J,
existing_prices: &mut CommodityPrices,
year: u32,
markets_to_price: &HashSet<(CommodityID, RegionID)>,
commodities: &CommodityMap,
time_slice_info: &TimeSliceInfo,
) where
I: Iterator<Item = (&'a AssetRef, &'a TimeSliceID, Activity)>,
J: Iterator<Item = (&'a AssetRef, &'a TimeSliceID)>,
{
let mut group_prices: IndexMap<_, _> = iter_existing_asset_max_prices(
activity_for_existing,
markets_to_price,
existing_prices,
year,
commodities,
&PricingStrategy::MarginalCost,
None,
)
.collect();
let priced_groups: HashSet<_> = group_prices.keys().cloned().collect();
let cand_group_prices = iter_candidate_asset_min_prices(
activity_keys_for_candidates,
markets_to_price,
existing_prices,
&priced_groups,
year,
commodities,
&PricingStrategy::MarginalCost,
);
group_prices.extend(cand_group_prices);
existing_prices.extend_selection_prices(&group_prices, time_slice_info);
}
fn iter_existing_asset_max_prices<'a, I>(
activity_for_existing: I,
markets_to_price: &HashSet<(CommodityID, RegionID)>,
existing_prices: &CommodityPrices,
year: u32,
commodities: &CommodityMap,
pricing_strategy: &PricingStrategy,
annual_activities: Option<&HashMap<AssetRef, Activity>>,
) -> impl Iterator<Item = ((CommodityID, RegionID, TimeSliceSelection), MoneyPerFlow)> + 'a
where
I: Iterator<Item = (&'a AssetRef, &'a TimeSliceID, Activity)>,
{
match pricing_strategy {
PricingStrategy::MarginalCost => assert!(
annual_activities.is_none(),
"Cannot provide annual_activities with marginal pricing strategy"
),
PricingStrategy::FullCost => assert!(
annual_activities.is_some(),
"annual_activities must be provided for full pricing strategy"
),
_ => panic!("Invalid pricing strategy"),
}
let mut existing_accum: IndexMap<
(CommodityID, RegionID, TimeSliceSelection),
IndexMap<AssetRef, WeightedAverageBackupAccumulator<Activity>>,
> = IndexMap::new();
let mut annual_fixed_costs = HashMap::new();
for (asset, time_slice, activity) in activity_for_existing {
let region_id = asset.region_id();
let annual_activity = annual_activities.map(|activities| activities[asset]);
if annual_activity.is_some_and(|annual_activity| annual_activity < Activity::EPSILON) {
continue;
}
let activity_limit = *asset
.get_activity_limits_for_selection(&TimeSliceSelection::Single(time_slice.clone()))
.end();
for (commodity_id, marginal_cost) in asset.iter_marginal_costs_with_filter(
existing_prices,
year,
time_slice,
|cid: &CommodityID| markets_to_price.contains(&(cid.clone(), region_id.clone())),
) {
let ts_selection = commodities[&commodity_id]
.time_slice_level
.containing_selection(time_slice);
let total_cost = match pricing_strategy {
PricingStrategy::FullCost => {
let annual_fixed_costs_per_flow =
annual_fixed_costs.entry(asset.clone()).or_insert_with(|| {
asset.get_annual_fixed_costs_per_flow(annual_activity.unwrap())
});
marginal_cost + *annual_fixed_costs_per_flow
}
PricingStrategy::MarginalCost => marginal_cost,
_ => unreachable!(),
};
existing_accum
.entry((commodity_id.clone(), region_id.clone(), ts_selection))
.or_default()
.entry(asset.clone())
.or_default()
.add(total_cost, activity, activity_limit);
}
}
existing_accum.into_iter().filter_map(|(key, per_asset)| {
per_asset
.into_values()
.filter_map(WeightedAverageBackupAccumulator::finalise)
.reduce(|current, value| current.max(value))
.map(|v| (key, v))
})
}
fn iter_candidate_asset_min_prices<'a, I>(
activity_keys_for_candidates: I,
markets_to_price: &HashSet<(CommodityID, RegionID)>,
existing_prices: &CommodityPrices,
priced_groups: &HashSet<(CommodityID, RegionID, TimeSliceSelection)>,
year: u32,
commodities: &CommodityMap,
pricing_strategy: &PricingStrategy,
) -> impl Iterator<Item = ((CommodityID, RegionID, TimeSliceSelection), MoneyPerFlow)>
where
I: Iterator<Item = (&'a AssetRef, &'a TimeSliceID)>,
{
assert!(matches!(
pricing_strategy,
PricingStrategy::MarginalCost | PricingStrategy::FullCost
));
let mut annual_fixed_costs = HashMap::new();
let mut annual_activity_limits = HashMap::new();
let mut cand_accum: IndexMap<
(CommodityID, RegionID, TimeSliceSelection),
IndexMap<AssetRef, WeightedAverageAccumulator<Activity>>,
> = IndexMap::new();
for (asset, time_slice) in activity_keys_for_candidates {
let region_id = asset.region_id();
let annual_activity_limit =
matches!(pricing_strategy, PricingStrategy::FullCost).then(|| {
*annual_activity_limits
.entry(asset.clone())
.or_insert_with(|| {
*asset
.get_activity_limits_for_selection(&TimeSliceSelection::Annual)
.end()
})
});
if annual_activity_limit.is_some_and(|limit| limit < Activity::EPSILON) {
continue;
}
let activity_limit = *asset
.get_activity_limits_for_selection(&TimeSliceSelection::Single(time_slice.clone()))
.end();
for (commodity_id, marginal_cost) in asset.iter_marginal_costs_with_filter(
existing_prices,
year,
time_slice,
|cid: &CommodityID| markets_to_price.contains(&(cid.clone(), region_id.clone())),
) {
let ts_selection = commodities[&commodity_id]
.time_slice_level
.containing_selection(time_slice);
if priced_groups.contains(&(
commodity_id.clone(),
region_id.clone(),
ts_selection.clone(),
)) {
continue;
}
let total_cost = match pricing_strategy {
PricingStrategy::FullCost => {
let annual_fixed_costs_per_flow =
annual_fixed_costs.entry(asset.clone()).or_insert_with(|| {
asset.get_annual_fixed_costs_per_flow(annual_activity_limit.unwrap())
});
marginal_cost + *annual_fixed_costs_per_flow
}
PricingStrategy::MarginalCost => marginal_cost,
_ => unreachable!(),
};
cand_accum
.entry((commodity_id.clone(), region_id.clone(), ts_selection))
.or_default()
.entry(asset.clone())
.or_default()
.add(total_cost, activity_limit);
}
}
cand_accum.into_iter().filter_map(|(key, per_candidate)| {
per_candidate
.into_values()
.filter_map(WeightedAverageAccumulator::finalise)
.reduce(|current, value| current.min(value))
.map(|v| (key, v))
})
}
fn add_marginal_cost_average_prices<'a, I, J>(
activity_for_existing: I,
activity_keys_for_candidates: J,
existing_prices: &mut CommodityPrices,
year: u32,
markets_to_price: &HashSet<(CommodityID, RegionID)>,
commodities: &CommodityMap,
time_slice_info: &TimeSliceInfo,
) where
I: Iterator<Item = (&'a AssetRef, &'a TimeSliceID, Activity)>,
J: Iterator<Item = (&'a AssetRef, &'a TimeSliceID)>,
{
let mut group_prices: IndexMap<_, _> = iter_existing_asset_average_prices(
activity_for_existing,
markets_to_price,
existing_prices,
year,
commodities,
&PricingStrategy::MarginalCost,
None,
)
.collect();
let priced_groups: HashSet<_> = group_prices.keys().cloned().collect();
let cand_group_prices = iter_candidate_asset_min_prices(
activity_keys_for_candidates,
markets_to_price,
existing_prices,
&priced_groups,
year,
commodities,
&PricingStrategy::MarginalCost,
);
group_prices.extend(cand_group_prices);
existing_prices.extend_selection_prices(&group_prices, time_slice_info);
}
fn iter_existing_asset_average_prices<'a, I>(
activity_for_existing: I,
markets_to_price: &HashSet<(CommodityID, RegionID)>,
existing_prices: &CommodityPrices,
year: u32,
commodities: &CommodityMap,
pricing_strategy: &PricingStrategy,
annual_activities: Option<&HashMap<AssetRef, Activity>>,
) -> impl Iterator<Item = ((CommodityID, RegionID, TimeSliceSelection), MoneyPerFlow)> + 'a
where
I: Iterator<Item = (&'a AssetRef, &'a TimeSliceID, Activity)>,
{
match pricing_strategy {
PricingStrategy::MarginalCost => assert!(
annual_activities.is_none(),
"Cannot provide annual_activities with marginal pricing strategy"
),
PricingStrategy::FullCost => assert!(
annual_activities.is_some(),
"annual_activities must be provided for full pricing strategy"
),
_ => panic!("Invalid pricing strategy"),
}
let mut existing_accum: IndexMap<
(CommodityID, RegionID, TimeSliceSelection),
WeightedAverageBackupAccumulator<Flow>,
> = IndexMap::new();
let mut annual_fixed_costs = HashMap::new();
for (asset, time_slice, activity) in activity_for_existing {
let region_id = asset.region_id();
let annual_activity = annual_activities.map(|activities| activities[asset]);
if annual_activity.is_some_and(|annual_activity| annual_activity < Activity::EPSILON) {
continue;
}
let activity_limit = *asset
.get_activity_limits_for_selection(&TimeSliceSelection::Single(time_slice.clone()))
.end();
for (commodity_id, marginal_cost) in asset.iter_marginal_costs_with_filter(
existing_prices,
year,
time_slice,
|cid: &CommodityID| markets_to_price.contains(&(cid.clone(), region_id.clone())),
) {
let time_slice_selection = commodities[&commodity_id]
.time_slice_level
.containing_selection(time_slice);
let total_cost = match pricing_strategy {
PricingStrategy::FullCost => {
let annual_fixed_costs_per_flow =
annual_fixed_costs.entry(asset.clone()).or_insert_with(|| {
asset.get_annual_fixed_costs_per_flow(annual_activity.unwrap())
});
marginal_cost + *annual_fixed_costs_per_flow
}
PricingStrategy::MarginalCost => marginal_cost,
_ => unreachable!(),
};
let output_coeff = asset
.get_flow(&commodity_id)
.expect("Commodity should be an output flow for this asset")
.coeff;
let output_weight = activity * output_coeff;
let backup_output_weight = activity_limit * output_coeff;
existing_accum
.entry((
commodity_id.clone(),
region_id.clone(),
time_slice_selection,
))
.or_default()
.add(total_cost, output_weight, backup_output_weight);
}
}
existing_accum
.into_iter()
.filter_map(|(key, accum)| accum.finalise().map(|v| (key, v)))
}
fn calculate_annual_activities<'a, I>(activities: I) -> HashMap<AssetRef, Activity>
where
I: IntoIterator<Item = (&'a AssetRef, &'a TimeSliceID, Activity)>,
{
activities
.into_iter()
.map(|(asset, _ts, activity)| (asset.clone(), activity))
.fold(HashMap::new(), |mut acc, (asset, activity)| {
acc.entry(asset)
.and_modify(|e| *e += activity)
.or_insert(activity);
acc
})
}
#[allow(clippy::too_many_arguments)]
fn add_full_cost_prices<'a, I, J>(
activity_for_existing: I,
activity_keys_for_candidates: J,
annual_activities: &HashMap<AssetRef, Activity>,
existing_prices: &mut CommodityPrices,
year: u32,
markets_to_price: &HashSet<(CommodityID, RegionID)>,
commodities: &CommodityMap,
time_slice_info: &TimeSliceInfo,
) where
I: Iterator<Item = (&'a AssetRef, &'a TimeSliceID, Activity)>,
J: Iterator<Item = (&'a AssetRef, &'a TimeSliceID)>,
{
let mut group_prices: IndexMap<_, _> = iter_existing_asset_max_prices(
activity_for_existing,
markets_to_price,
existing_prices,
year,
commodities,
&PricingStrategy::FullCost,
Some(annual_activities),
)
.collect();
let priced_groups: HashSet<_> = group_prices.keys().cloned().collect();
let cand_group_prices = iter_candidate_asset_min_prices(
activity_keys_for_candidates,
markets_to_price,
existing_prices,
&priced_groups,
year,
commodities,
&PricingStrategy::FullCost,
);
group_prices.extend(cand_group_prices);
existing_prices.extend_selection_prices(&group_prices, time_slice_info);
}
#[allow(clippy::too_many_arguments)]
fn add_full_cost_average_prices<'a, I, J>(
activity_for_existing: I,
activity_keys_for_candidates: J,
annual_activities: &HashMap<AssetRef, Activity>,
existing_prices: &mut CommodityPrices,
year: u32,
markets_to_price: &HashSet<(CommodityID, RegionID)>,
commodities: &CommodityMap,
time_slice_info: &TimeSliceInfo,
) where
I: Iterator<Item = (&'a AssetRef, &'a TimeSliceID, Activity)>,
J: Iterator<Item = (&'a AssetRef, &'a TimeSliceID)>,
{
let mut group_prices: IndexMap<_, _> = iter_existing_asset_average_prices(
activity_for_existing,
markets_to_price,
existing_prices,
year,
commodities,
&PricingStrategy::FullCost,
Some(annual_activities),
)
.collect();
let priced_groups: HashSet<_> = group_prices.keys().cloned().collect();
let cand_group_prices = iter_candidate_asset_min_prices(
activity_keys_for_candidates,
markets_to_price,
existing_prices,
&priced_groups,
year,
commodities,
&PricingStrategy::FullCost,
);
group_prices.extend(cand_group_prices);
existing_prices.extend_selection_prices(&group_prices, time_slice_info);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::asset::Asset;
use crate::asset::AssetRef;
use crate::commodity::{Commodity, CommodityID, CommodityMap};
use crate::fixture::{
commodity_id, other_commodity, region_id, sed_commodity, time_slice, time_slice_info,
};
use crate::process::{ActivityLimits, FlowType, Process, ProcessFlow, ProcessParameter};
use crate::region::RegionID;
use crate::time_slice::TimeSliceID;
use crate::units::ActivityPerCapacity;
use crate::units::{
Activity, Capacity, Dimensionless, FlowPerActivity, MoneyPerActivity, MoneyPerCapacity,
MoneyPerCapacityPerYear, MoneyPerFlow,
};
use float_cmp::assert_approx_eq;
use indexmap::{IndexMap, IndexSet};
use rstest::rstest;
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
fn build_process_flow(commodity: &Commodity, coeff: f64, cost: MoneyPerFlow) -> ProcessFlow {
ProcessFlow {
commodity: Rc::new(commodity.clone()),
coeff: FlowPerActivity(coeff),
kind: FlowType::Fixed,
cost,
}
}
#[allow(clippy::too_many_arguments)]
fn build_process(
flows: IndexMap<CommodityID, ProcessFlow>,
region_id: &RegionID,
year: u32,
time_slice_info: &TimeSliceInfo,
variable_operating_cost: MoneyPerActivity,
fixed_operating_cost: MoneyPerCapacityPerYear,
capital_cost: MoneyPerCapacity,
lifetime: u32,
discount_rate: Dimensionless,
) -> Process {
let mut process_flows_map = HashMap::new();
process_flows_map.insert((region_id.clone(), year), Rc::new(flows));
let mut process_parameter_map = HashMap::new();
let proc_param = ProcessParameter {
capital_cost,
fixed_operating_cost,
variable_operating_cost,
lifetime,
discount_rate,
};
process_parameter_map.insert((region_id.clone(), year), Rc::new(proc_param));
let mut activity_limits_map = HashMap::new();
activity_limits_map.insert(
(region_id.clone(), year),
Rc::new(ActivityLimits::new_with_full_availability(time_slice_info)),
);
let regions: IndexSet<RegionID> = IndexSet::from([region_id.clone()]);
Process {
id: "p1".into(),
description: "test process".into(),
years: 2010..=2020,
activity_limits: activity_limits_map,
flows: process_flows_map,
parameters: process_parameter_map,
regions,
primary_output: None,
capacity_to_activity: ActivityPerCapacity(1.0),
investment_constraints: HashMap::new(),
unit_size: None,
}
}
fn assert_price_approx(
prices: &CommodityPrices,
commodity: &CommodityID,
region: &RegionID,
time_slice: &TimeSliceID,
expected: MoneyPerFlow,
) {
let p = prices.get(commodity, region, time_slice).unwrap();
assert_approx_eq!(MoneyPerFlow, p, expected);
}
#[rstest]
#[case(MoneyPerFlow(100.0), MoneyPerFlow(100.0), Dimensionless(0.0), true)] #[case(MoneyPerFlow(100.0), MoneyPerFlow(105.0), Dimensionless(0.1), true)] #[case(MoneyPerFlow(-100.0), MoneyPerFlow(-105.0), Dimensionless(0.1), true)] #[case(MoneyPerFlow(0.0), MoneyPerFlow(0.0), Dimensionless(0.1), true)] #[case(MoneyPerFlow(100.0), MoneyPerFlow(105.0), Dimensionless(0.01), false)] #[case(MoneyPerFlow(100.0), MoneyPerFlow(-105.0), Dimensionless(0.1), false)] #[case(MoneyPerFlow(0.0), MoneyPerFlow(10.0), Dimensionless(0.1), false)] #[case(MoneyPerFlow(0.0), MoneyPerFlow(-10.0), Dimensionless(0.1), false)] #[case(MoneyPerFlow(10.0), MoneyPerFlow(0.0), Dimensionless(0.1), false)] #[case(MoneyPerFlow(-10.0), MoneyPerFlow(0.0), Dimensionless(0.1), false)] fn within_tolerance_scenarios(
#[case] price1: MoneyPerFlow,
#[case] price2: MoneyPerFlow,
#[case] tolerance: Dimensionless,
#[case] expected: bool,
time_slice_info: TimeSliceInfo,
time_slice: TimeSliceID,
) {
let mut prices1 = CommodityPrices::default();
let mut prices2 = CommodityPrices::default();
let commodity = CommodityID::new("test_commodity");
let region = RegionID::new("test_region");
prices1.insert(&commodity, ®ion, &time_slice, price1);
prices2.insert(&commodity, ®ion, &time_slice, price2);
assert_eq!(
prices1.within_tolerance_weighted(&prices2, tolerance, &time_slice_info),
expected
);
}
#[rstest]
fn time_slice_weighted_averages(
commodity_id: CommodityID,
region_id: RegionID,
time_slice_info: TimeSliceInfo,
time_slice: TimeSliceID,
) {
let mut prices = CommodityPrices::default();
prices.insert(&commodity_id, ®ion_id, &time_slice, MoneyPerFlow(100.0));
let averages = prices.time_slice_weighted_averages(&time_slice_info);
assert_eq!(averages[&(commodity_id, region_id)], MoneyPerFlow(100.0));
}
#[rstest]
fn marginal_cost_example(
sed_commodity: Commodity,
other_commodity: Commodity,
region_id: RegionID,
time_slice_info: TimeSliceInfo,
time_slice: TimeSliceID,
) {
let mut a = sed_commodity.clone();
a.id = "A".into();
let mut b = sed_commodity.clone();
b.id = "B".into();
let mut c = sed_commodity.clone();
c.id = "C".into();
let mut d = other_commodity.clone();
d.id = "D".into();
let mut flows = IndexMap::new();
flows.insert(
a.id.clone(),
build_process_flow(&a, -1.0, MoneyPerFlow(0.0)),
);
flows.insert(b.id.clone(), build_process_flow(&b, 1.0, MoneyPerFlow(0.0)));
flows.insert(c.id.clone(), build_process_flow(&c, 2.0, MoneyPerFlow(3.0)));
flows.insert(d.id.clone(), build_process_flow(&d, 1.0, MoneyPerFlow(4.0)));
let process = build_process(
flows,
®ion_id,
2015u32,
&time_slice_info,
MoneyPerActivity(5.0), MoneyPerCapacityPerYear(0.0), MoneyPerCapacity(0.0), 5, Dimensionless(1.0), );
let asset =
Asset::new_candidate(Rc::new(process), region_id.clone(), Capacity(1.0), 2015u32)
.unwrap();
let asset_ref = AssetRef::from(asset);
let existing_prices =
CommodityPrices::from_iter(vec![(&a.id, ®ion_id, &time_slice, MoneyPerFlow(1.0))]);
let mut markets = HashSet::new();
markets.insert((b.id.clone(), region_id.clone()));
markets.insert((c.id.clone(), region_id.clone()));
let mut commodities = CommodityMap::new();
commodities.insert(b.id.clone(), Rc::new(b.clone()));
commodities.insert(c.id.clone(), Rc::new(c.clone()));
let existing = vec![(&asset_ref, &time_slice, Activity(1.0))];
let candidates = Vec::new();
let mut prices = existing_prices.clone();
add_marginal_cost_prices(
existing.into_iter(),
candidates.into_iter(),
&mut prices,
2015u32,
&markets,
&commodities,
&time_slice_info,
);
assert_price_approx(
&prices,
&b.id,
®ion_id,
&time_slice,
MoneyPerFlow(10.0 / 3.0),
);
assert_price_approx(
&prices,
&c.id,
®ion_id,
&time_slice,
MoneyPerFlow(10.0 / 3.0 + 3.0),
);
}
#[rstest]
fn full_cost_example(
sed_commodity: Commodity,
other_commodity: Commodity,
region_id: RegionID,
time_slice_info: TimeSliceInfo,
time_slice: TimeSliceID,
) {
let mut a = sed_commodity.clone();
a.id = "A".into();
let mut b = sed_commodity.clone();
b.id = "B".into();
let mut c = sed_commodity.clone();
c.id = "C".into();
let mut d = other_commodity.clone();
d.id = "D".into();
let mut flows = IndexMap::new();
flows.insert(
a.id.clone(),
build_process_flow(&a, -1.0, MoneyPerFlow(0.0)),
);
flows.insert(b.id.clone(), build_process_flow(&b, 1.0, MoneyPerFlow(0.0)));
flows.insert(c.id.clone(), build_process_flow(&c, 2.0, MoneyPerFlow(3.0)));
flows.insert(d.id.clone(), build_process_flow(&d, 1.0, MoneyPerFlow(4.0)));
let process = build_process(
flows,
®ion_id,
2015u32,
&time_slice_info,
MoneyPerActivity(5.0), MoneyPerCapacityPerYear(1.0), MoneyPerCapacity(1.5), 1, Dimensionless(0.0), );
let asset =
Asset::new_candidate(Rc::new(process), region_id.clone(), Capacity(4.0), 2015u32)
.unwrap();
let asset_ref = AssetRef::from(asset);
let existing_prices =
CommodityPrices::from_iter(vec![(&a.id, ®ion_id, &time_slice, MoneyPerFlow(1.0))]);
let mut markets = HashSet::new();
markets.insert((b.id.clone(), region_id.clone()));
markets.insert((c.id.clone(), region_id.clone()));
let mut commodities = CommodityMap::new();
commodities.insert(b.id.clone(), Rc::new(b.clone()));
commodities.insert(c.id.clone(), Rc::new(c.clone()));
let existing = vec![(&asset_ref, &time_slice, Activity(2.0))];
let candidates = Vec::new();
let mut annual_activities = HashMap::new();
annual_activities.insert(asset_ref.clone(), Activity(2.0));
let mut prices = existing_prices.clone();
add_full_cost_prices(
existing.into_iter(),
candidates.into_iter(),
&annual_activities,
&mut prices,
2015u32,
&markets,
&commodities,
&time_slice_info,
);
assert_price_approx(&prices, &b.id, ®ion_id, &time_slice, MoneyPerFlow(5.0));
assert_price_approx(&prices, &c.id, ®ion_id, &time_slice, MoneyPerFlow(8.0));
}
#[test]
fn weighted_average_accumulator_single_value() {
let mut accum = WeightedAverageAccumulator::<Dimensionless>::default();
accum.add(MoneyPerFlow(100.0), Dimensionless(1.0));
assert_eq!(accum.finalise(), Some(MoneyPerFlow(100.0)));
}
#[test]
fn weighted_average_accumulator_different_weights() {
let mut accum = WeightedAverageAccumulator::<Dimensionless>::default();
accum.add(MoneyPerFlow(100.0), Dimensionless(1.0));
accum.add(MoneyPerFlow(200.0), Dimensionless(2.0));
let result = accum.finalise().unwrap();
assert_approx_eq!(MoneyPerFlow, result, MoneyPerFlow(500.0 / 3.0));
}
#[test]
fn weighted_average_accumulator_zero_weight() {
let accum = WeightedAverageAccumulator::<Dimensionless>::default();
assert_eq!(accum.finalise(), None);
}
#[test]
fn weighted_average_backup_accumulator_primary_preferred() {
let mut accum = WeightedAverageBackupAccumulator::<Dimensionless>::default();
accum.add(MoneyPerFlow(100.0), Dimensionless(3.0), Dimensionless(1.0));
accum.add(MoneyPerFlow(200.0), Dimensionless(1.0), Dimensionless(1.0));
assert_eq!(accum.finalise(), Some(MoneyPerFlow(125.0)));
}
#[test]
fn weighted_average_backup_accumulator_fallback() {
let mut accum = WeightedAverageBackupAccumulator::<Dimensionless>::default();
accum.add(MoneyPerFlow(100.0), Dimensionless(0.0), Dimensionless(2.0));
accum.add(MoneyPerFlow(200.0), Dimensionless(0.0), Dimensionless(2.0));
assert_eq!(accum.finalise(), Some(MoneyPerFlow(150.0)));
}
#[test]
fn weighted_average_backup_accumulator_both_zero() {
let accum = WeightedAverageBackupAccumulator::<Dimensionless>::default();
assert_eq!(accum.finalise(), None);
}
}