Skip to content

Commit 1dcc12a

Browse files
authored
Merge pull request #1039 from EnergySystemsModellingLab/refactor-equal-metrics-comparison
Refactor equal metrics comparison code
2 parents 5d8a60d + c632d74 commit 1dcc12a

File tree

3 files changed

+141
-131
lines changed

3 files changed

+141
-131
lines changed

src/asset.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,33 @@ impl Asset {
213213
)
214214
}
215215

216+
/// Create a new commissioned asset
217+
///
218+
/// This is only used for testing. WARNING: These assets always have an ID of zero, so can
219+
/// create hash collisions. Use with care.
220+
#[cfg(test)]
221+
pub fn new_commissioned(
222+
agent_id: AgentID,
223+
process: Rc<Process>,
224+
region_id: RegionID,
225+
capacity: Capacity,
226+
commission_year: u32,
227+
) -> Result<Self> {
228+
Self::new_with_state(
229+
AssetState::Commissioned {
230+
id: AssetID(0),
231+
agent_id,
232+
mothballed_year: None,
233+
group_id: None,
234+
},
235+
process,
236+
region_id,
237+
capacity,
238+
commission_year,
239+
None,
240+
)
241+
}
242+
216243
/// Private helper to create an asset with the given state
217244
fn new_with_state(
218245
state: AssetState,

src/simulation/investment.rs

Lines changed: 113 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,14 @@ use crate::units::{Capacity, Dimensionless, Flow, FlowPerCapacity};
1313
use anyhow::{Context, Result, bail, ensure};
1414
use indexmap::IndexMap;
1515
use itertools::{Itertools, chain};
16-
use log::{debug, warn};
16+
use log::debug;
17+
use std::cmp::Ordering;
1718
use std::collections::HashMap;
1819
use std::fmt::Display;
1920

2021
pub mod appraisal;
2122
use appraisal::coefficients::calculate_coefficients_for_assets;
22-
use appraisal::{
23-
AppraisalComparisonMethod, AppraisalOutput, appraise_investment,
24-
classify_appraisal_comparison_method,
25-
};
23+
use appraisal::{AppraisalOutput, appraise_investment};
2624

2725
/// A map of demand across time slices for a specific market
2826
type DemandMap = IndexMap<TimeSliceID, Flow>;
@@ -56,6 +54,8 @@ impl InvestmentSet {
5654
/// Selects assets for this investment set variant and passes through the shared
5755
/// context needed by single-market, cycle, or layered selection.
5856
///
57+
/// # Arguments
58+
///
5959
/// * `model` – Simulation model supplying parameters, processes, and dispatch.
6060
/// * `year` – Planning year being solved.
6161
/// * `demand` – Net demand profiles available to all markets before selection.
@@ -621,35 +621,45 @@ fn get_candidate_assets<'a>(
621621
})
622622
}
623623

624-
fn select_from_assets_with_equal_metric(
625-
region_id: &RegionID,
624+
/// Print debug message if there are multiple equally good outputs
625+
fn warn_on_equal_appraisal_outputs(
626+
outputs: &[AppraisalOutput],
626627
agent_id: &AgentID,
627628
commodity_id: &CommodityID,
628-
equally_good_assets: Vec<AppraisalOutput>,
629-
) -> AppraisalOutput {
630-
// Format asset details for diagnostic logging
631-
let asset_details = equally_good_assets
629+
region_id: &RegionID,
630+
) {
631+
if outputs.is_empty() {
632+
return;
633+
}
634+
635+
// Count the number of identical (or nearly identical) appraisal outputs
636+
let num_identical = outputs[1..]
632637
.iter()
633-
.map(|output| {
634-
format!(
635-
"Process id: '{}' (State: {}{})",
636-
output.asset.process_id(),
637-
output.asset.state(),
638-
output
639-
.asset
640-
.id()
641-
.map(|id| format!(", Asset id: {id}"))
642-
.unwrap_or_default(),
643-
)
644-
})
645-
.collect::<Vec<_>>()
646-
.join(", ");
647-
let warning_message = format!(
648-
"Could not resolve deadlock between equally good appraisals for Agent id: {agent_id}, Commodity: '{commodity_id}', Region: {region_id}. Options: [{asset_details}]. Selecting first option.",
649-
);
650-
warn!("{warning_message}");
651-
// Select the first asset arbitrarily from the equally performing options
652-
equally_good_assets.into_iter().next().unwrap()
638+
.take_while(|output| outputs[0].compare_metric(output).is_eq())
639+
.count();
640+
641+
if num_identical > 0 {
642+
let asset_details = outputs[..=num_identical]
643+
.iter()
644+
.map(|output| {
645+
let asset = &output.asset;
646+
format!(
647+
"Process ID: '{}' (State: {}{}, Commission year: {})",
648+
asset.process_id(),
649+
asset.state(),
650+
asset
651+
.id()
652+
.map(|id| format!(", Asset ID: {id}"))
653+
.unwrap_or_default(),
654+
asset.commission_year()
655+
)
656+
})
657+
.join(", ");
658+
debug!(
659+
"Found equally good appraisals for Agent ID: {agent_id}, Commodity: '{commodity_id}', \
660+
Region: {region_id}. Options: [{asset_details}]. Selecting first option.",
661+
);
662+
}
653663
}
654664

655665
/// Get the best assets for meeting demand for the given commodity
@@ -716,56 +726,39 @@ fn select_best_assets(
716726
)?;
717727

718728
// Sort assets by appraisal metric
719-
let assets_sorted_by_metric: Vec<_> = outputs_for_opts
729+
let assets_sorted_by_metric = outputs_for_opts
720730
.into_iter()
721731
.filter(|output| output.capacity > Capacity(0.0))
722-
.sorted_by(AppraisalOutput::compare_metric)
723-
.collect();
724-
725-
// check if all options have zero capacity
726-
if assets_sorted_by_metric.is_empty() {
727-
// In this case, we cannot meet demand, so have to bail out.
728-
// This may happen if:
729-
// - the asset has zero activity limits for all time slices with demand.
730-
// - known issue with the NPV objective
731-
// (see https://github.com/EnergySystemsModellingLab/MUSE2/issues/716).
732-
bail!(
733-
"No feasible investment options for commodity '{}' after appraisal",
734-
&commodity.id
735-
)
736-
}
732+
.sorted_by(|output1, output2| match output1.compare_metric(output2) {
733+
// If equal, we fall back on comparing asset properties
734+
Ordering::Equal => compare_asset_fallback(&output1.asset, &output2.asset),
735+
cmp => cmp,
736+
})
737+
.collect_vec();
738+
739+
// Check if all options have zero capacity. If so, we cannot meet demand, so have to bail
740+
// out.
741+
//
742+
// This may happen if:
743+
// - the asset has zero activity limits for all time slices with
744+
// demand.
745+
// - known issue with the NPV objective
746+
// (see https://github.com/EnergySystemsModellingLab/MUSE2/issues/716).
747+
ensure!(
748+
!assets_sorted_by_metric.is_empty(),
749+
"No feasible investment options for commodity '{}' after appraisal",
750+
&commodity.id
751+
);
737752

738-
let appraisal_comparison_method = classify_appraisal_comparison_method(
739-
&assets_sorted_by_metric.iter().collect::<Vec<_>>(),
753+
// Warn if there are multiple equally good assets
754+
warn_on_equal_appraisal_outputs(
755+
&assets_sorted_by_metric,
756+
&agent.id,
757+
&commodity.id,
758+
region_id,
740759
);
741760

742-
// Determine the best asset based on whether multiple equally-good options exist
743-
let best_output = match appraisal_comparison_method {
744-
// there are multiple equally good assets by metric
745-
AppraisalComparisonMethod::EqualMetrics => {
746-
// Count how many assets have the same metric as the best one
747-
let count = assets_sorted_by_metric
748-
.iter()
749-
.take_while(|output| {
750-
AppraisalOutput::compare_metric(&assets_sorted_by_metric[0], output).is_eq()
751-
})
752-
.count();
753-
754-
// select from all equally good assets
755-
let equally_good_assets: Vec<_> =
756-
assets_sorted_by_metric.into_iter().take(count).collect();
757-
select_from_assets_with_equal_metric(
758-
region_id,
759-
&agent.id,
760-
&commodity.id,
761-
equally_good_assets,
762-
)
763-
}
764-
// there is a single best asset by metric
765-
AppraisalComparisonMethod::Metric => {
766-
assets_sorted_by_metric.into_iter().next().unwrap()
767-
}
768-
};
761+
let best_output = assets_sorted_by_metric.into_iter().next().unwrap();
769762

770763
// Log the selected asset
771764
debug!(
@@ -806,6 +799,16 @@ fn is_any_remaining_demand(demand: &DemandMap) -> bool {
806799
demand.values().any(|flow| *flow > Flow(0.0))
807800
}
808801

802+
/// Compare assets as a fallback if metrics are equal.
803+
///
804+
/// Commissioned assets are ordered before uncommissioned and newer before older.
805+
///
806+
/// Used as a fallback to sort assets when they have equal appraisal tool outputs.
807+
fn compare_asset_fallback(asset1: &Asset, asset2: &Asset) -> Ordering {
808+
(asset2.is_commissioned(), asset2.commission_year())
809+
.cmp(&(asset1.is_commissioned(), asset1.commission_year()))
810+
}
811+
809812
/// Update capacity of chosen asset, if needed, and update both asset options and chosen assets
810813
fn update_assets(
811814
mut best_asset: AssetRef,
@@ -852,14 +855,17 @@ fn update_assets(
852855
#[cfg(test)]
853856
mod tests {
854857
use super::*;
858+
use crate::agent::AgentID;
859+
use crate::asset::Asset;
855860
use crate::commodity::Commodity;
856861
use crate::fixture::{
857-
asset, process, process_activity_limits_map, process_flows_map, svd_commodity, time_slice,
858-
time_slice_info, time_slice_info2,
862+
agent_id, asset, process, process_activity_limits_map, process_flows_map, region_id,
863+
svd_commodity, time_slice, time_slice_info, time_slice_info2,
859864
};
860865
use crate::process::{ActivityLimits, FlowType, Process, ProcessFlow};
866+
use crate::region::RegionID;
861867
use crate::time_slice::{TimeSliceID, TimeSliceInfo};
862-
use crate::units::{Flow, FlowPerActivity, MoneyPerFlow};
868+
use crate::units::{Capacity, Flow, FlowPerActivity, MoneyPerFlow};
863869
use indexmap::indexmap;
864870
use rstest::rstest;
865871
use std::rc::Rc;
@@ -945,4 +951,32 @@ mod tests {
945951
// Maximum = 20.0
946952
assert_eq!(result, Capacity(20.0));
947953
}
954+
955+
#[rstest]
956+
fn test_compare_assets_fallback(process: Process, region_id: RegionID, agent_id: AgentID) {
957+
let process = Rc::new(process);
958+
let capacity = Capacity(2.0);
959+
let asset1 = Asset::new_commissioned(
960+
agent_id.clone(),
961+
process.clone(),
962+
region_id.clone(),
963+
capacity,
964+
2015,
965+
)
966+
.unwrap();
967+
let asset2 =
968+
Asset::new_candidate(process.clone(), region_id.clone(), capacity, 2015).unwrap();
969+
let asset3 =
970+
Asset::new_commissioned(agent_id, process, region_id.clone(), capacity, 2010).unwrap();
971+
972+
assert!(compare_asset_fallback(&asset1, &asset1).is_eq());
973+
assert!(compare_asset_fallback(&asset2, &asset2).is_eq());
974+
assert!(compare_asset_fallback(&asset3, &asset3).is_eq());
975+
assert!(compare_asset_fallback(&asset1, &asset2).is_lt());
976+
assert!(compare_asset_fallback(&asset2, &asset1).is_gt());
977+
assert!(compare_asset_fallback(&asset1, &asset3).is_lt());
978+
assert!(compare_asset_fallback(&asset3, &asset1).is_gt());
979+
assert!(compare_asset_fallback(&asset3, &asset2).is_lt());
980+
assert!(compare_asset_fallback(&asset2, &asset3).is_gt());
981+
}
948982
}

src/simulation/investment/appraisal.rs

Lines changed: 1 addition & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -54,62 +54,11 @@ impl AppraisalOutput {
5454
);
5555

5656
if approx_eq!(f64, self.metric, other.metric) {
57-
self.compare_with_equal_metrics(other)
57+
Ordering::Equal
5858
} else {
5959
self.metric.partial_cmp(&other.metric).unwrap()
6060
}
6161
}
62-
63-
/// Compare this appraisal to another when the metrics are known to be equal.
64-
pub fn compare_with_equal_metrics(&self, other: &Self) -> Ordering {
65-
assert!(
66-
approx_eq!(f64, self.metric, other.metric),
67-
"Appraisal metrics must be equal"
68-
);
69-
70-
// Favour commissioned assets over non-commissioned
71-
if self.asset.is_commissioned() && !other.asset.is_commissioned() {
72-
return Ordering::Less;
73-
}
74-
if !self.asset.is_commissioned() && other.asset.is_commissioned() {
75-
return Ordering::Greater;
76-
}
77-
78-
// if both commissioned, favour newer ones
79-
if self.asset.is_commissioned() && other.asset.is_commissioned() {
80-
return self
81-
.asset
82-
.commission_year()
83-
.cmp(&other.asset.commission_year())
84-
.reverse();
85-
}
86-
87-
Ordering::Equal
88-
}
89-
}
90-
91-
/// methods used to compare multiple appraisal outputs
92-
pub enum AppraisalComparisonMethod {
93-
/// If all appraisal outputs have different metrics
94-
Metric,
95-
/// two or more appraisal outputs have equal metrics
96-
EqualMetrics,
97-
}
98-
99-
/// Classify the appropriate method to compare appraisal outputs
100-
/// given an array of appraisal outputs sorted by metric
101-
pub fn classify_appraisal_comparison_method(
102-
appraisals_sorted_by_metric: &[&AppraisalOutput],
103-
) -> AppraisalComparisonMethod {
104-
if appraisals_sorted_by_metric.len() >= 2
105-
&& appraisals_sorted_by_metric[0]
106-
.compare_metric(appraisals_sorted_by_metric[1])
107-
.is_eq()
108-
{
109-
AppraisalComparisonMethod::EqualMetrics
110-
} else {
111-
AppraisalComparisonMethod::Metric
112-
}
11362
}
11463

11564
/// Calculate LCOX for a hypothetical investment in the given asset.

0 commit comments

Comments
 (0)