@@ -13,16 +13,14 @@ use crate::units::{Capacity, Dimensionless, Flow, FlowPerCapacity};
1313use anyhow:: { Context , Result , bail, ensure} ;
1414use indexmap:: IndexMap ;
1515use itertools:: { Itertools , chain} ;
16- use log:: { debug, warn} ;
16+ use log:: debug;
17+ use std:: cmp:: Ordering ;
1718use std:: collections:: HashMap ;
1819use std:: fmt:: Display ;
1920
2021pub mod appraisal;
2122use 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
2826type 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
810813fn update_assets (
811814 mut best_asset : AssetRef ,
@@ -852,14 +855,17 @@ fn update_assets(
852855#[ cfg( test) ]
853856mod 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}
0 commit comments