From e47ed842845e521e42a819dacb97d92d897036e4 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Wed, 18 Mar 2026 16:43:42 +0000 Subject: [PATCH 1/8] Split price calculation pathways into separate functions --- src/simulation/prices.rs | 415 +++++++++++++++++++++------------------ 1 file changed, 226 insertions(+), 189 deletions(-) diff --git a/src/simulation/prices.rs b/src/simulation/prices.rs index ffc571dc..7ea9cbeb 100644 --- a/src/simulation/prices.rs +++ b/src/simulation/prices.rs @@ -485,6 +485,48 @@ fn add_marginal_cost_prices<'a, I, J>( ) where I: Iterator, J: Iterator, +{ + // Calculate marginal cost prices from existing assets + let mut group_prices: IndexMap<_, _> = calculate_existing_asset_marginal_prices( + activity_for_existing, + markets_to_price, + existing_prices, + year, + commodities, + ) + .collect(); + let priced_groups: HashSet<_> = group_prices.keys().cloned().collect(); + + // Calculate marginal cost prices from candidate assets, skipping any groups already covered by + // existing assets + let cand_group_prices = calculate_candidate_asset_marginal_prices( + activity_keys_for_candidates, + markets_to_price, + existing_prices, + &priced_groups, + year, + commodities, + ); + + // Merge existing and candidate group prices + group_prices.extend(cand_group_prices); + + // Expand selection-level prices to individual time slices and add to the main prices map + existing_prices.extend_selection_prices(&group_prices, time_slice_info); +} + +/// Calculate marginal cost prices using existing assets, taking a weighted average across time +/// slices for seasonal/annual commodities, and taking the max across assets for each +/// commodity/region/selection. +fn calculate_existing_asset_marginal_prices<'a, I>( + activity_for_existing: I, + markets_to_price: &HashSet<(CommodityID, RegionID)>, + existing_prices: &CommodityPrices, + year: u32, + commodities: &CommodityMap, +) -> impl Iterator +where + I: Iterator, { // Accumulator map to collect marginal costs from existing assets. For each (commodity, region, // ts selection), this maps each asset to a weighted average of the marginal costs for that @@ -533,17 +575,29 @@ fn add_marginal_cost_prices<'a, I, J>( } // For each group, finalise per-asset weighted averages then take the max across assets - let group_prices: IndexMap<_, MoneyPerFlow> = 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)) - }) - .collect(); + 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)) + }) +} +/// Calculate marginal cost prices using candidate assets, taking a weighted average across time +/// slices for seasonal/annual commodities, and taking the min across assets for each +/// commodity/region/selection. Only groups not already covered by existing assets are considered. +fn calculate_candidate_asset_marginal_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, +) -> impl Iterator +where + I: Iterator, +{ // Accumulator map to collect marginal costs from candidate assets. Similar to existing_accum, // but costs are weighted according to activity limits (i.e. assuming full utilisation). let mut cand_accum: IndexMap< @@ -573,7 +627,7 @@ fn add_marginal_cost_prices<'a, I, J>( .containing_selection(time_slice); // Skip groups already covered by existing assets - if group_prices.contains_key(&( + if priced_groups.contains(&( commodity_id.clone(), region_id.clone(), ts_selection.clone(), @@ -592,20 +646,13 @@ fn add_marginal_cost_prices<'a, I, J>( } // For each group, finalise per-candidate weighted averages then take the min across candidates - let cand_group_prices = cand_accum.into_iter().filter_map(|(key, per_candidate)| { + 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)) - }); - - // Merge existing and candidate group prices - let mut all_group_prices = group_prices; - all_group_prices.extend(cand_group_prices); - - // Expand selection-level prices to individual time slices and add to the main prices map - existing_prices.extend_selection_prices(&all_group_prices, time_slice_info); + }) } /// Calculate marginal cost prices for a set of commodities using a load-weighted average across @@ -627,6 +674,47 @@ fn add_marginal_cost_average_prices<'a, I, J>( ) where I: Iterator, J: Iterator, +{ + // Calculate marginal cost prices from existing assets + let mut group_prices = calculate_existing_asset_marginal_average_prices( + activity_for_existing, + markets_to_price, + existing_prices, + year, + commodities, + ); + let priced_groups: HashSet<_> = group_prices.keys().cloned().collect(); + + // Calculate marginal cost prices from candidate assets, skipping any groups already covered by + // existing assets + let cand_group_prices = calculate_candidate_asset_marginal_prices( + activity_keys_for_candidates, + markets_to_price, + existing_prices, + &priced_groups, + year, + commodities, + ); + + // Merge existing and candidate group prices + group_prices.extend(cand_group_prices); + + // Expand selection-level prices to individual time slices and add to the main prices map + existing_prices.extend_selection_prices(&group_prices, time_slice_info); +} + +/// Calculate marginal cost prices for existing assets using a weighted average across time slices +/// for seasonal/annual commodities, and a weighted average across assets according to output (with +/// a backup weight based on potential output if there is zero activity across the selection). +fn calculate_existing_asset_marginal_average_prices<'a, I>( + activity_for_existing: I, + markets_to_price: &HashSet<(CommodityID, RegionID)>, + existing_prices: &CommodityPrices, + year: u32, + commodities: &CommodityMap, +) -> IndexMap<(CommodityID, RegionID, TimeSliceSelection), MoneyPerFlow> +where + I: Iterator, { // Accumulator map to collect marginal costs from existing assets. Collects a weighted average // for each (commodity, region, ts selection), across all contributing assets, weighted @@ -681,78 +769,10 @@ fn add_marginal_cost_average_prices<'a, I, J>( } // For each group, finalise weighted averages - let group_prices: IndexMap<_, MoneyPerFlow> = existing_accum + existing_accum .into_iter() .filter_map(|(key, accum)| accum.finalise().map(|v| (key, v))) - .collect(); - - // Accumulator map to collect marginal costs from candidate assets. For each (commodity, region, - // ts selection), this maps each candidate to a weighted average of the marginal costs for that - // commodity across all time slices in the selection, weighted by activity limits. - let mut cand_accum: IndexMap< - (CommodityID, RegionID, TimeSliceSelection), - IndexMap, - > = IndexMap::new(); - - // Iterate over candidate assets (assuming full utilisation) - for (asset, time_slice) in activity_keys_for_candidates { - let region_id = asset.region_id(); - - // Get activity limits: used to weight marginal costs for seasonal/annual commodities - let activity_limit = *asset - .get_activity_limits_for_selection(&TimeSliceSelection::Single(time_slice.clone())) - .end(); - - // Iterate over the marginal costs for commodities we need prices for - 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())), - ) { - // Get the time slice selection according to the commodity's time slice level - let time_slice_selection = commodities[&commodity_id] - .time_slice_level - .containing_selection(time_slice); - - // Skip groups already covered by existing assets - if group_prices.contains_key(&( - commodity_id.clone(), - region_id.clone(), - time_slice_selection.clone(), - )) { - continue; - } - - // Accumulate marginal cost for this candidate, weighted by the activity limit - cand_accum - .entry(( - commodity_id.clone(), - region_id.clone(), - time_slice_selection, - )) - .or_default() - .entry(asset.clone()) - .or_default() - .add(marginal_cost, Dimensionless(activity_limit.value())); - } - } - - // For each group, finalise per-candidate weighted averages then reduce to the min across candidates - let cand_group_prices = 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)) - }); - - // Merge existing and candidate group prices - let mut all_group_prices = group_prices; - all_group_prices.extend(cand_group_prices); - - // Expand selection-level prices to individual time slices and add to the main prices map - existing_prices.extend_selection_prices(&all_group_prices, time_slice_info); + .collect() } /// Calculate annual activities for each asset by summing across all time slices @@ -841,6 +861,50 @@ fn add_full_cost_prices<'a, I, J>( ) where I: Iterator, J: Iterator, +{ + // Calculate full cost prices from existing assets + let mut group_prices: IndexMap<_, _> = calculate_existing_asset_full_prices( + activity_for_existing, + annual_activities, + markets_to_price, + existing_prices, + year, + commodities, + ) + .collect(); + let priced_groups: HashSet<_> = group_prices.keys().cloned().collect(); + + // Calculate full cost prices from candidate assets, skipping any groups already covered by + // existing assets + let cand_group_prices = calculate_candidate_asset_full_prices( + activity_keys_for_candidates, + markets_to_price, + existing_prices, + &priced_groups, + year, + commodities, + ); + + // Merge existing and candidate group prices + group_prices.extend(cand_group_prices); + + // Expand selection-level prices to individual time slices and add to the main prices map + existing_prices.extend_selection_prices(&group_prices, time_slice_info); +} + +/// Calculate full cost prices using existing assets, taking a weighted average across time +/// slices for seasonal/annual commodities, and taking the max across assets for each +/// commodity/region/selection. +fn calculate_existing_asset_full_prices<'a, I>( + activity_for_existing: I, + annual_activities: &HashMap, + markets_to_price: &HashSet<(CommodityID, RegionID)>, + existing_prices: &CommodityPrices, + year: u32, + commodities: &CommodityMap, +) -> impl Iterator +where + I: Iterator, { // Accumulator map to collect full costs from existing assets. For each (commodity, region, // ts selection), this maps each asset to a weighted average of the full costs for that @@ -904,16 +968,31 @@ fn add_full_cost_prices<'a, I, J>( } // For each group, finalise per-asset weighted averages then reduce to the max across assets - let group_prices: IndexMap<_, MoneyPerFlow> = 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)) - }) - .collect(); + 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)) + }) +} + +/// Calculate full cost prices using candidate assets, taking a weighted average across time slices +/// for seasonal/annual commodities, and taking the min across assets for each +/// commodity/region/selection. Only groups not already covered by existing assets are considered. +fn calculate_candidate_asset_full_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, +) -> impl Iterator +where + I: Iterator, +{ + // Cache of annual fixed costs per flow for each asset, to avoid recalculating + let mut annual_fixed_costs: HashMap<_, _> = HashMap::new(); // Accumulator map to collect full costs from candidate assets. Similar to existing_accum, but // costs are weighted according to activity limits (i.e. assuming full utilisation). @@ -944,7 +1023,7 @@ fn add_full_cost_prices<'a, I, J>( .containing_selection(time_slice); // Skip groups already covered by existing assets - if group_prices.contains_key(&( + if priced_groups.contains(&( commodity_id.clone(), region_id.clone(), ts_selection.clone(), @@ -976,20 +1055,13 @@ fn add_full_cost_prices<'a, I, J>( } // For each group, finalise per-candidate weighted averages then reduce to the min across candidates - let cand_group_prices = cand_accum.into_iter().filter_map(|(key, per_candidate)| { + 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)) - }); - - // Merge existing and candidate group prices - let mut all_group_prices = group_prices; - all_group_prices.extend(cand_group_prices); - - // Expand selection-level prices to individual time slices and add to the main prices map - existing_prices.extend_selection_prices(&all_group_prices, time_slice_info); + }) } /// Calculate full cost prices for a set of commodities using a load-weighted average across @@ -1013,6 +1085,49 @@ fn add_full_cost_average_prices<'a, I, J>( ) where I: Iterator, J: Iterator, +{ + // Calculate full cost prices from existing assets + let mut group_prices: IndexMap<_, _> = calculate_existing_asset_full_average_prices( + activity_for_existing, + annual_activities, + markets_to_price, + existing_prices, + year, + commodities, + ) + .collect(); + let priced_groups: HashSet<_> = group_prices.keys().cloned().collect(); + + // Calculate full cost prices from candidate assets, skipping any groups already covered by existing assets + let cand_group_prices = calculate_candidate_asset_full_prices( + activity_keys_for_candidates, + markets_to_price, + existing_prices, + &priced_groups, + year, + commodities, + ); + + // Merge existing and candidate group prices + group_prices.extend(cand_group_prices); + + // Expand selection-level prices to individual time slices and add to the main prices map + existing_prices.extend_selection_prices(&group_prices, time_slice_info); +} + +/// Calculate full cost prices for existing assets using a weighted average across time slices for +/// seasonal/annual commodities, and a weighted average across assets according to output (with a +/// backup weight based on potential output if there is zero activity across the selection). +fn calculate_existing_asset_full_average_prices<'a, I>( + activity_for_existing: I, + annual_activities: &HashMap, + markets_to_price: &HashSet<(CommodityID, RegionID)>, + existing_prices: &CommodityPrices, + year: u32, + commodities: &CommodityMap, +) -> impl Iterator +where + I: Iterator, { // Accumulator map to collect full costs from existing assets. Collects a weighted average // for each (commodity, region, ts selection), across all contributing assets, weighted @@ -1082,87 +1197,9 @@ fn add_full_cost_average_prices<'a, I, J>( } // For each group, finalise weighted averages - let group_prices: IndexMap<_, MoneyPerFlow> = existing_accum + existing_accum .into_iter() .filter_map(|(key, accum)| accum.finalise().map(|v| (key, v))) - .collect(); - - // Accumulator map to collect marginal costs from candidate assets. For each (commodity, region, - // ts selection), this maps each candidate to a weighted average of the full costs for that - // commodity across all time slices in the selection, weighted by activity limits. - let mut cand_accum: IndexMap< - (CommodityID, RegionID, TimeSliceSelection), - IndexMap, - > = IndexMap::new(); - - // Iterate over candidate assets (assuming full utilization) - for (asset, time_slice) in activity_keys_for_candidates { - let region_id = asset.region_id(); - - // Get activity limits: used to weight marginal costs for seasonal/annual commodities - let activity_limit = *asset - .get_activity_limits_for_selection(&TimeSliceSelection::Single(time_slice.clone())) - .end(); - - // Iterate over the marginal costs for commodities we need prices for - 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())), - ) { - // Get the time slice selection according to the commodity's time slice level - let ts_selection = commodities[&commodity_id] - .time_slice_level - .containing_selection(time_slice); - - // Skip groups already covered by existing assets - if group_prices.contains_key(&( - commodity_id.clone(), - region_id.clone(), - ts_selection.clone(), - )) { - continue; - } - - // Get/calculate fixed costs per flow for this asset (assume full utilisation) - let annual_fixed_costs_per_flow = - annual_fixed_costs.entry(asset.clone()).or_insert_with(|| { - asset.get_annual_fixed_costs_per_flow( - *asset - .get_activity_limits_for_selection(&TimeSliceSelection::Annual) - .end(), - ) - }); - - // Accumulate full costs for this group, weighted by the activity limit - cand_accum - .entry((commodity_id.clone(), region_id.clone(), ts_selection)) - .or_default() - .entry(asset.clone()) - .or_default() - .add( - marginal_cost + *annual_fixed_costs_per_flow, - Dimensionless(activity_limit.value()), - ); - } - } - - // For each group, finalise per-candidate weighted averages then reduce to the min across candidates - let cand_group_prices = 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)) - }); - - // Merge existing and candidate group prices - let mut all_group_prices = group_prices; - all_group_prices.extend(cand_group_prices); - - // Expand selection-level prices to individual time slices and add to the main prices map - existing_prices.extend_selection_prices(&all_group_prices, time_slice_info); } #[cfg(test)] From c80dca9f2cc1fb8135a000ffadc0d58eb21b12ea Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Wed, 18 Mar 2026 17:08:24 +0000 Subject: [PATCH 2/8] Unify functions for full/marginal costs --- src/simulation/prices.rs | 447 +++++++++++++-------------------------- 1 file changed, 150 insertions(+), 297 deletions(-) diff --git a/src/simulation/prices.rs b/src/simulation/prices.rs index 7ea9cbeb..277aa860 100644 --- a/src/simulation/prices.rs +++ b/src/simulation/prices.rs @@ -487,25 +487,28 @@ fn add_marginal_cost_prices<'a, I, J>( J: Iterator, { // Calculate marginal cost prices from existing assets - let mut group_prices: IndexMap<_, _> = calculate_existing_asset_marginal_prices( + let mut group_prices: IndexMap<_, _> = calculate_existing_asset_prices( activity_for_existing, markets_to_price, existing_prices, year, commodities, + &PricingStrategy::MarginalCost, + None::<&HashMap>, ) .collect(); let priced_groups: HashSet<_> = group_prices.keys().cloned().collect(); // Calculate marginal cost prices from candidate assets, skipping any groups already covered by // existing assets - let cand_group_prices = calculate_candidate_asset_marginal_prices( + let cand_group_prices = calculate_candidate_asset_prices( activity_keys_for_candidates, markets_to_price, existing_prices, &priced_groups, year, commodities, + &PricingStrategy::MarginalCost, ); // Merge existing and candidate group prices @@ -515,21 +518,38 @@ fn add_marginal_cost_prices<'a, I, J>( existing_prices.extend_selection_prices(&group_prices, time_slice_info); } -/// Calculate marginal cost prices using existing assets, taking a weighted average across time -/// slices for seasonal/annual commodities, and taking the max across assets for each -/// commodity/region/selection. -fn calculate_existing_asset_marginal_prices<'a, I>( +/// Calculate prices using existing assets, taking a weighted average across time slices for +/// seasonal/annual commodities, and taking the max across assets for each commodity/region/selection. +/// +/// # Arguments +/// +/// * `activity_for_existing` - Iterator over (asset, time slice, activity) tuples for existing assets +/// * `markets_to_price` - Set of (commodity, region) pairs to price +/// * `existing_prices` - Current commodity prices (used for marginal cost filtering) +/// * `year` - Year for which prices are being calculated +/// * `commodities` - Commodity map +/// * `pricing_strategy` - Pricing strategy (determines whether to include fixed costs) +/// * `annual_activities` - Optional annual activities (required for full cost pricing) +fn calculate_existing_asset_prices<'a, I>( activity_for_existing: I, markets_to_price: &HashSet<(CommodityID, RegionID)>, existing_prices: &CommodityPrices, year: u32, commodities: &CommodityMap, -) -> impl Iterator + pricing_strategy: &PricingStrategy, + annual_activities: Option<&HashMap>, +) -> impl Iterator + 'a where I: Iterator, { - // Accumulator map to collect marginal costs from existing assets. For each (commodity, region, - // ts selection), this maps each asset to a weighted average of the marginal costs for that + // Validate supported strategies, and require annual activities for FullCost pricing. + assert!(matches!( + (pricing_strategy, annual_activities), + (PricingStrategy::MarginalCost, _) | (PricingStrategy::FullCost, Some(_)) + ),); + + // Accumulator map to collect costs from existing assets. For each (commodity, region, + // ts selection), this maps each asset to a weighted average of the costs for that // commodity across all time slices in the selection, weighted by activity (using activity // limits as a backup weight if there is zero activity across the selection). The granularity of // the selection depends on the time slice level of the commodity (i.e. individual, season, year). @@ -538,10 +558,21 @@ where IndexMap, > = IndexMap::new(); + // Cache of annual fixed costs per flow for each asset (only used for Full cost pricing) + let mut annual_fixed_costs: HashMap<_, _> = HashMap::new(); + // Iterate over existing assets and their activities for (asset, time_slice, activity) in activity_for_existing { let region_id = asset.region_id(); + // When using full cost pricing, skip assets with zero activity across the year, since + // we cannot calculate a fixed cost per flow. + let annual_activity = matches!(pricing_strategy, PricingStrategy::FullCost) + .then(|| annual_activities.unwrap()[asset]); + if annual_activity.is_some_and(|annual_activity| annual_activity < Activity::EPSILON) { + continue; + } + // Get activity limits: used as a backup weight if no activity across the group let activity_limit = *asset .get_activity_limits_for_selection(&TimeSliceSelection::Single(time_slice.clone())) @@ -559,15 +590,28 @@ where .time_slice_level .containing_selection(time_slice); - // Accumulate marginal cost for this asset, weighted by activity (using the activity - // limit as a backup weight) + // Calculate total cost (marginal + fixed if applicable) + 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!(), + }; + + // Accumulate cost for this asset, weighted by activity (using the activity limit + // as a backup weight) existing_accum .entry((commodity_id.clone(), region_id.clone(), ts_selection)) .or_default() .entry(asset.clone()) .or_default() .add( - marginal_cost, + total_cost, Dimensionless(activity.value()), Dimensionless(activity_limit.value()), ); @@ -584,21 +628,31 @@ where }) } -/// Calculate marginal cost prices using candidate assets, taking a weighted average across time +/// Calculate candidate-asset prices (marginal or full), taking a weighted average across time /// slices for seasonal/annual commodities, and taking the min across assets for each /// commodity/region/selection. Only groups not already covered by existing assets are considered. -fn calculate_candidate_asset_marginal_prices<'a, I>( +fn calculate_candidate_asset_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 where I: Iterator, { - // Accumulator map to collect marginal costs from candidate assets. Similar to existing_accum, + // Validate the supported strategy values. + assert!(matches!( + pricing_strategy, + PricingStrategy::MarginalCost | PricingStrategy::FullCost + )); + + // Cache of annual fixed costs per flow for each asset (only used for Full cost pricing) + let mut annual_fixed_costs: HashMap<_, _> = HashMap::new(); + + // Accumulator map to collect costs from candidate assets. Similar to existing_accum, // but costs are weighted according to activity limits (i.e. assuming full utilisation). let mut cand_accum: IndexMap< (CommodityID, RegionID, TimeSliceSelection), @@ -635,13 +689,30 @@ where continue; } - // Accumulate marginal cost for this candidate asset, weighted by the activity limit + // Calculate total cost (marginal + fixed if applicable) + 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( + *asset + .get_activity_limits_for_selection(&TimeSliceSelection::Annual) + .end(), + ) + }); + marginal_cost + *annual_fixed_costs_per_flow + } + PricingStrategy::MarginalCost => marginal_cost, + _ => unreachable!(), + }; + + // Accumulate cost for this candidate asset, weighted by the activity limit cand_accum .entry((commodity_id.clone(), region_id.clone(), ts_selection)) .or_default() .entry(asset.clone()) .or_default() - .add(marginal_cost, Dimensionless(activity_limit.value())); + .add(total_cost, Dimensionless(activity_limit.value())); } } @@ -676,24 +747,28 @@ fn add_marginal_cost_average_prices<'a, I, J>( J: Iterator, { // Calculate marginal cost prices from existing assets - let mut group_prices = calculate_existing_asset_marginal_average_prices( + let mut group_prices: IndexMap<_, _> = calculate_existing_asset_average_prices( activity_for_existing, markets_to_price, existing_prices, year, commodities, - ); + &PricingStrategy::MarginalCost, + None::<&HashMap>, + ) + .collect(); let priced_groups: HashSet<_> = group_prices.keys().cloned().collect(); // Calculate marginal cost prices from candidate assets, skipping any groups already covered by // existing assets - let cand_group_prices = calculate_candidate_asset_marginal_prices( + let cand_group_prices = calculate_candidate_asset_prices( activity_keys_for_candidates, markets_to_price, existing_prices, &priced_groups, year, commodities, + &PricingStrategy::MarginalCost, ); // Merge existing and candidate group prices @@ -703,20 +778,30 @@ fn add_marginal_cost_average_prices<'a, I, J>( existing_prices.extend_selection_prices(&group_prices, time_slice_info); } -/// Calculate marginal cost prices for existing assets using a weighted average across time slices -/// for seasonal/annual commodities, and a weighted average across assets according to output (with -/// a backup weight based on potential output if there is zero activity across the selection). -fn calculate_existing_asset_marginal_average_prices<'a, I>( +/// Calculate average prices for existing assets using a weighted average across time slices for +/// seasonal/annual commodities, and a weighted average across assets according to output (with a +/// backup weight based on potential output if there is zero activity across the selection). +/// +/// `FullCost` adds annual fixed costs per flow and skips assets with zero annual activity. +fn calculate_existing_asset_average_prices<'a, I>( activity_for_existing: I, markets_to_price: &HashSet<(CommodityID, RegionID)>, existing_prices: &CommodityPrices, year: u32, commodities: &CommodityMap, -) -> IndexMap<(CommodityID, RegionID, TimeSliceSelection), MoneyPerFlow> + pricing_strategy: &PricingStrategy, + annual_activities: Option<&HashMap>, +) -> impl Iterator + 'a where I: Iterator, { - // Accumulator map to collect marginal costs from existing assets. Collects a weighted average + // Validate supported strategies, and require annual activities for FullCost pricing. + assert!(matches!( + (pricing_strategy, annual_activities), + (PricingStrategy::MarginalCost, _) | (PricingStrategy::FullCost, Some(_)) + )); + + // Accumulator map to collect costs from existing assets. Collects a weighted average // for each (commodity, region, ts selection), across all contributing assets, weighted // according to output (with a backup weight based on potential output if there is zero // activity across the selection). The granularity of the selection depends on the time slice @@ -726,10 +811,21 @@ where WeightedAverageBackupAccumulator, > = IndexMap::new(); + // Cache of annual fixed costs per flow for each asset (only used for Full cost pricing) + let mut annual_fixed_costs: HashMap<_, _> = HashMap::new(); + // Iterate over existing assets and their activities for (asset, time_slice, activity) in activity_for_existing { let region_id = asset.region_id(); + // When using full cost pricing, skip assets with zero annual activity, since we cannot + // calculate a fixed cost per flow. + let annual_activity = matches!(pricing_strategy, PricingStrategy::FullCost) + .then(|| annual_activities.unwrap()[asset]); + if annual_activity.is_some_and(|annual_activity| annual_activity < Activity::EPSILON) { + continue; + } + // Get activity limits: used to calculate backup potential-output weights. let activity_limit = *asset .get_activity_limits_for_selection(&TimeSliceSelection::Single(time_slice.clone())) @@ -747,7 +843,20 @@ where .time_slice_level .containing_selection(time_slice); - // Marginal costs will be weighted by output (activity * coefficient) + // Calculate total cost (marginal + fixed if applicable) + 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!(), + }; + + // Costs will be weighted by output (activity * coefficient) let output_coeff = asset .get_flow(&commodity_id) .expect("Commodity should be an output flow for this asset") @@ -755,7 +864,7 @@ where let output_weight = Dimensionless((activity * output_coeff).value()); let backup_output_weight = Dimensionless((activity_limit * output_coeff).value()); - // Accumulate marginal cost for this group, weighted by output with a backup + // Accumulate cost for this group, weighted by output with a backup // potential-output weight. existing_accum .entry(( @@ -764,7 +873,7 @@ where time_slice_selection, )) .or_default() - .add(marginal_cost, output_weight, backup_output_weight); + .add(total_cost, output_weight, backup_output_weight); } } @@ -772,7 +881,6 @@ where existing_accum .into_iter() .filter_map(|(key, accum)| accum.finalise().map(|v| (key, v))) - .collect() } /// Calculate annual activities for each asset by summing across all time slices @@ -848,7 +956,7 @@ where /// * `markets_to_price` - Set of markets to calculate full cost prices for /// * `commodities` - Map of all commodities (used to look up each commodity's `time_slice_level`) /// * `time_slice_info` - Time slice information (used to expand groups to individual time slices) -#[allow(clippy::too_many_arguments, clippy::too_many_lines)] +#[allow(clippy::too_many_arguments)] fn add_full_cost_prices<'a, I, J>( activity_for_existing: I, activity_keys_for_candidates: J, @@ -863,26 +971,28 @@ fn add_full_cost_prices<'a, I, J>( J: Iterator, { // Calculate full cost prices from existing assets - let mut group_prices: IndexMap<_, _> = calculate_existing_asset_full_prices( + let mut group_prices: IndexMap<_, _> = calculate_existing_asset_prices( activity_for_existing, - annual_activities, markets_to_price, existing_prices, year, commodities, + &PricingStrategy::FullCost, + Some(annual_activities), ) .collect(); let priced_groups: HashSet<_> = group_prices.keys().cloned().collect(); // Calculate full cost prices from candidate assets, skipping any groups already covered by // existing assets - let cand_group_prices = calculate_candidate_asset_full_prices( + let cand_group_prices = calculate_candidate_asset_prices( activity_keys_for_candidates, markets_to_price, existing_prices, &priced_groups, year, commodities, + &PricingStrategy::FullCost, ); // Merge existing and candidate group prices @@ -892,178 +1002,6 @@ fn add_full_cost_prices<'a, I, J>( existing_prices.extend_selection_prices(&group_prices, time_slice_info); } -/// Calculate full cost prices using existing assets, taking a weighted average across time -/// slices for seasonal/annual commodities, and taking the max across assets for each -/// commodity/region/selection. -fn calculate_existing_asset_full_prices<'a, I>( - activity_for_existing: I, - annual_activities: &HashMap, - markets_to_price: &HashSet<(CommodityID, RegionID)>, - existing_prices: &CommodityPrices, - year: u32, - commodities: &CommodityMap, -) -> impl Iterator -where - I: Iterator, -{ - // Accumulator map to collect full costs from existing assets. For each (commodity, region, - // ts selection), this maps each asset to a weighted average of the full costs for that - // commodity across all time slices in the selection, weighted by activity (using activity - // limits as a backup weight if there is zero activity across the selection). The granularity of - // the selection depends on the time slice level of the commodity (i.e. individual, season, year). - let mut existing_accum: IndexMap< - (CommodityID, RegionID, TimeSliceSelection), - IndexMap, - > = IndexMap::new(); - - // Cache of annual fixed costs per flow for each asset, to avoid recalculating - let mut annual_fixed_costs: HashMap<_, _> = HashMap::new(); - - // Iterate over existing assets and their activities - for (asset, time_slice, activity) in activity_for_existing { - let annual_activity = annual_activities[asset]; - let region_id = asset.region_id(); - - // If annual activity is zero, we can't calculate a capital cost per flow, so skip this - // asset. - if annual_activity < Activity::EPSILON { - continue; - } - - // Get activity limits: used as a backup weight if no activity across the group - let activity_limit = *asset - .get_activity_limits_for_selection(&TimeSliceSelection::Single(time_slice.clone())) - .end(); - - // Iterate over the marginal costs for commodities we need prices for - 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())), - ) { - // Get the time slice selection according to the commodity's time slice level - let ts_selection = commodities[&commodity_id] - .time_slice_level - .containing_selection(time_slice); - - // Get/calculate fixed costs per flow for this asset - let annual_fixed_costs_per_flow = annual_fixed_costs - .entry(asset.clone()) - .or_insert_with(|| asset.get_annual_fixed_costs_per_flow(annual_activity)); - - // Accumulate full cost for this asset, weighted by activity (using the activity limit - // as a backup weight) - existing_accum - .entry((commodity_id.clone(), region_id.clone(), ts_selection)) - .or_default() - .entry(asset.clone()) - .or_default() - .add( - marginal_cost + *annual_fixed_costs_per_flow, - Dimensionless(activity.value()), - Dimensionless(activity_limit.value()), - ); - } - } - - // For each group, finalise per-asset weighted averages then reduce to the max across assets - 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)) - }) -} - -/// Calculate full cost prices using candidate assets, taking a weighted average across time slices -/// for seasonal/annual commodities, and taking the min across assets for each -/// commodity/region/selection. Only groups not already covered by existing assets are considered. -fn calculate_candidate_asset_full_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, -) -> impl Iterator -where - I: Iterator, -{ - // Cache of annual fixed costs per flow for each asset, to avoid recalculating - let mut annual_fixed_costs: HashMap<_, _> = HashMap::new(); - - // Accumulator map to collect full costs from candidate assets. Similar to existing_accum, but - // costs are weighted according to activity limits (i.e. assuming full utilisation). - let mut cand_accum: IndexMap< - (CommodityID, RegionID, TimeSliceSelection), - IndexMap, - > = IndexMap::new(); - - // Iterate over candidate assets (assuming full utilization) - for (asset, time_slice) in activity_keys_for_candidates { - let region_id = asset.region_id(); - - // Get activity limits: used to weight marginal costs for seasonal/annual commodities - let activity_limit = *asset - .get_activity_limits_for_selection(&TimeSliceSelection::Single(time_slice.clone())) - .end(); - - // Iterate over the marginal costs for commodities we need prices for - 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())), - ) { - // Get the time slice selection according to the commodity's time slice level - let ts_selection = commodities[&commodity_id] - .time_slice_level - .containing_selection(time_slice); - - // Skip groups already covered by existing assets - if priced_groups.contains(&( - commodity_id.clone(), - region_id.clone(), - ts_selection.clone(), - )) { - continue; - } - - // Get/calculate fixed costs per flow for this asset (assume full utilisation) - let annual_fixed_costs_per_flow = - annual_fixed_costs.entry(asset.clone()).or_insert_with(|| { - asset.get_annual_fixed_costs_per_flow( - *asset - .get_activity_limits_for_selection(&TimeSliceSelection::Annual) - .end(), - ) - }); - - // Accumulate full cost for this candidate asset, weighted by the activity limit - cand_accum - .entry((commodity_id.clone(), region_id.clone(), ts_selection)) - .or_default() - .entry(asset.clone()) - .or_default() - .add( - marginal_cost + *annual_fixed_costs_per_flow, - Dimensionless(activity_limit.value()), - ); - } - } - - // For each group, finalise per-candidate weighted averages then reduce to the min across candidates - 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)) - }) -} - /// Calculate full cost prices for a set of commodities using a load-weighted average across /// assets and add to an existing prices map. /// @@ -1072,7 +1010,7 @@ where /// /// Candidate assets are treated the same way as in `calculate_full_cost_prices` (i.e. take the min /// across candidate assets). -#[allow(clippy::too_many_arguments, clippy::too_many_lines)] +#[allow(clippy::too_many_arguments)] fn add_full_cost_average_prices<'a, I, J>( activity_for_existing: I, activity_keys_for_candidates: J, @@ -1087,25 +1025,27 @@ fn add_full_cost_average_prices<'a, I, J>( J: Iterator, { // Calculate full cost prices from existing assets - let mut group_prices: IndexMap<_, _> = calculate_existing_asset_full_average_prices( + let mut group_prices: IndexMap<_, _> = calculate_existing_asset_average_prices( activity_for_existing, - annual_activities, markets_to_price, existing_prices, year, commodities, + &PricingStrategy::FullCost, + Some(annual_activities), ) .collect(); let priced_groups: HashSet<_> = group_prices.keys().cloned().collect(); // Calculate full cost prices from candidate assets, skipping any groups already covered by existing assets - let cand_group_prices = calculate_candidate_asset_full_prices( + let cand_group_prices = calculate_candidate_asset_prices( activity_keys_for_candidates, markets_to_price, existing_prices, &priced_groups, year, commodities, + &PricingStrategy::FullCost, ); // Merge existing and candidate group prices @@ -1115,93 +1055,6 @@ fn add_full_cost_average_prices<'a, I, J>( existing_prices.extend_selection_prices(&group_prices, time_slice_info); } -/// Calculate full cost prices for existing assets using a weighted average across time slices for -/// seasonal/annual commodities, and a weighted average across assets according to output (with a -/// backup weight based on potential output if there is zero activity across the selection). -fn calculate_existing_asset_full_average_prices<'a, I>( - activity_for_existing: I, - annual_activities: &HashMap, - markets_to_price: &HashSet<(CommodityID, RegionID)>, - existing_prices: &CommodityPrices, - year: u32, - commodities: &CommodityMap, -) -> impl Iterator -where - I: Iterator, -{ - // Accumulator map to collect full costs from existing assets. Collects a weighted average - // for each (commodity, region, ts selection), across all contributing assets, weighted - // according to output (with a backup weight based on potential output if there is zero - // activity across the selection). The granularity of the selection depends on the time slice - // level of the commodity (i.e. individual, season, year). - let mut existing_accum: IndexMap< - (CommodityID, RegionID, TimeSliceSelection), - WeightedAverageBackupAccumulator, - > = IndexMap::new(); - - // Cache of annual fixed costs per flow for each asset, to avoid recalculating - let mut annual_fixed_costs: HashMap<_, _> = HashMap::new(); - - // Iterate over existing assets and their activities - for (asset, time_slice, activity) in activity_for_existing { - let annual_activity = annual_activities[asset]; - let region_id = asset.region_id(); - - // If annual activity is zero, we can't calculate a capital cost per flow, so skip this - // asset. - if annual_activity < Activity::EPSILON { - continue; - } - - // Get activity limits: used to calculate backup potential-output weights. - let activity_limit = *asset - .get_activity_limits_for_selection(&TimeSliceSelection::Single(time_slice.clone())) - .end(); - - // Iterate over the marginal costs for commodities we need prices for - 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())), - ) { - // Get the time slice selection according to the commodity's time slice level - let ts_selection = commodities[&commodity_id] - .time_slice_level - .containing_selection(time_slice); - - // Full costs will be weighted by output (activity * coefficient) - let output_coeff = asset - .get_flow(&commodity_id) - .expect("Commodity should be an output flow for this asset") - .coeff; - let output_weight = Dimensionless((activity * output_coeff).value()); - let backup_output_weight = Dimensionless((activity_limit * output_coeff).value()); - - // Get/calculate fixed costs per flow for this asset - let annual_fixed_costs_per_flow = annual_fixed_costs - .entry(asset.clone()) - .or_insert_with(|| asset.get_annual_fixed_costs_per_flow(annual_activity)); - - // Accumulate full costs (marginal cost + fixed cost per flow), weighted by output - // with a backup potential-output weight. - existing_accum - .entry((commodity_id.clone(), region_id.clone(), ts_selection)) - .or_default() - .add( - marginal_cost + *annual_fixed_costs_per_flow, - output_weight, - backup_output_weight, - ); - } - } - - // For each group, finalise weighted averages - existing_accum - .into_iter() - .filter_map(|(key, accum)| accum.finalise().map(|v| (key, v))) -} - #[cfg(test)] mod tests { use super::*; From 9aa44e6534af7a84be267a369e3ff217cd66499a Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Thu, 19 Mar 2026 15:24:56 +0000 Subject: [PATCH 3/8] Better docstrings --- src/simulation/prices.rs | 103 +++++++++++++++++++++++++++++---------- 1 file changed, 76 insertions(+), 27 deletions(-) diff --git a/src/simulation/prices.rs b/src/simulation/prices.rs index 87acb74a..e53e0678 100644 --- a/src/simulation/prices.rs +++ b/src/simulation/prices.rs @@ -165,7 +165,7 @@ pub fn calculate_prices(model: &Model, solution: &Solution, year: u32) -> Result // Add prices for full cost commodities 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()) + iter_annual_activities(solution.iter_activity_for_existing()) }); add_full_cost_prices( solution.iter_activity_for_existing(), @@ -182,7 +182,7 @@ pub fn calculate_prices(model: &Model, solution: &Solution, year: u32) -> Result // Add prices for full average commodities 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()) + iter_annual_activities(solution.iter_activity_for_existing()) }); add_full_cost_average_prices( solution.iter_activity_for_existing(), @@ -487,7 +487,7 @@ fn add_marginal_cost_prices<'a, I, J>( J: Iterator, { // Calculate marginal cost prices from existing assets - let mut group_prices: IndexMap<_, _> = calculate_existing_asset_prices( + let mut group_prices: IndexMap<_, _> = iter_existing_asset_max_prices( activity_for_existing, markets_to_price, existing_prices, @@ -501,7 +501,7 @@ fn add_marginal_cost_prices<'a, I, J>( // Calculate marginal cost prices from candidate assets, skipping any groups already covered by // existing assets - let cand_group_prices = calculate_candidate_asset_prices( + let cand_group_prices = iter_candidate_asset_min_prices( activity_keys_for_candidates, markets_to_price, existing_prices, @@ -518,19 +518,29 @@ fn add_marginal_cost_prices<'a, I, J>( existing_prices.extend_selection_prices(&group_prices, time_slice_info); } -/// Calculate prices using existing assets, taking a weighted average across time slices for -/// seasonal/annual commodities, and taking the max across assets for each commodity/region/selection. +/// Calculate prices as the maximum cost across existing assets, using either a marginal cost or +/// full cost strategy (depending on `pricing_strategy`). Prices are given for each commodity in +/// the granularity of the commodity's time slice level. For seasonal/annual commodities, this +/// involves taking a weighted average across time slices for each asset according to activity +/// (with a backup weight based on potential activity if there is zero activity across the +/// selection, and omitting prices in the extreme case of zero potential activity). /// /// # Arguments /// /// * `activity_for_existing` - Iterator over (asset, time slice, activity) tuples for existing assets -/// * `markets_to_price` - Set of (commodity, region) pairs to price -/// * `existing_prices` - Current commodity prices (used for marginal cost filtering) +/// * `markets_to_price` - Set of (commodity, region) pairs to attempt to price +/// * `existing_prices` - Current commodity prices (used to calculate marginal costs) /// * `year` - Year for which prices are being calculated /// * `commodities` - Commodity map -/// * `pricing_strategy` - Pricing strategy (determines whether to include fixed costs) +/// * `pricing_strategy` - Pricing strategy, either `MarginalCost` or `FullCost` /// * `annual_activities` - Optional annual activities (required for full cost pricing) -fn calculate_existing_asset_prices<'a, I>( +/// +/// # Returns +/// +/// An iterator of ((commodity, region, time slice selection), price) tuples for the calculated +/// prices. This will include all (commodity, region) combinations in `markets_to_price` for +/// time slice selections where a price could be determined. +fn iter_existing_asset_max_prices<'a, I>( activity_for_existing: I, markets_to_price: &HashSet<(CommodityID, RegionID)>, existing_prices: &CommodityPrices, @@ -628,10 +638,32 @@ where }) } -/// Calculate candidate-asset prices (marginal or full), taking a weighted average across time -/// slices for seasonal/annual commodities, and taking the min across assets for each -/// commodity/region/selection. Only groups not already covered by existing assets are considered. -fn calculate_candidate_asset_prices<'a, I>( +/// Calculate prices as the minimum cost across candidate assets, using either a marginal cost or +/// full cost strategy (depending on `pricing_strategy`). Prices are given for each commodity in +/// the granularity of the commodity's time slice level. For seasonal/annual commodities, this +/// involves taking a weighted average across time slices for each asset according to potential +/// activity (i.e. the upper activity limit), omitting prices in the extreme case of zero potential +/// activity (Note: this should NOT happen as validation should ensure there is at least one +/// candidate that can provide a price in each timeslice for which a price could be required). +/// Costs for candidates are calculated assuming full utilisation. +/// +/// # Arguments +/// +/// * `activity_keys_for_candidates` - Iterator over (asset, time slice) tuples for candidate assets +/// * `markets_to_price` - Set of (commodity, region) pairs to attempt to price +/// * `existing_prices` - Current commodity prices (used to calculate marginal costs) +/// * `priced_groups` - Set of (commodity, region, time slice selection) groups that have already +/// been prices using existing assets, so should be skipped when looking at candidates +/// * `year` - Year for which prices are being calculated +/// * `commodities` - Commodity map +/// * `pricing_strategy` - Pricing strategy, either `MarginalCost` or `FullCost` +/// +/// # Returns +/// +/// An iterator of ((commodity, region, time slice selection), price) tuples for the calculated +/// prices. This will include all (commodity, region) combinations in `markets_to_price` for +/// time slice selections not covered by `priced_groups`, and for which a price could be determined +fn iter_candidate_asset_min_prices<'a, I>( activity_keys_for_candidates: I, markets_to_price: &HashSet<(CommodityID, RegionID)>, existing_prices: &CommodityPrices, @@ -747,7 +779,7 @@ fn add_marginal_cost_average_prices<'a, I, J>( J: Iterator, { // Calculate marginal cost prices from existing assets - let mut group_prices: IndexMap<_, _> = calculate_existing_asset_average_prices( + let mut group_prices: IndexMap<_, _> = iter_existing_asset_average_prices( activity_for_existing, markets_to_price, existing_prices, @@ -761,7 +793,7 @@ fn add_marginal_cost_average_prices<'a, I, J>( // Calculate marginal cost prices from candidate assets, skipping any groups already covered by // existing assets - let cand_group_prices = calculate_candidate_asset_prices( + let cand_group_prices = iter_candidate_asset_min_prices( activity_keys_for_candidates, markets_to_price, existing_prices, @@ -778,12 +810,29 @@ fn add_marginal_cost_average_prices<'a, I, J>( existing_prices.extend_selection_prices(&group_prices, time_slice_info); } -/// Calculate average prices for existing assets using a weighted average across time slices for -/// seasonal/annual commodities, and a weighted average across assets according to output (with a -/// backup weight based on potential output if there is zero activity across the selection). +/// Calculate prices as the load-weighted average cost across existing assets, using either a +/// marginal cost or full cost strategy (depending on `pricing_strategy`). Prices are given for each +/// commodity in the granularity of the commodity's time slice level. For seasonal/annual +/// commodities, this involves taking a weighted average across time slices for each asset according +/// to activity (with a backup weight based on potential activity if there is zero activity across +/// the selection, and omitting prices in the extreme case of zero potential activity). +/// +/// # Arguments +/// +/// * `activity_for_existing` - Iterator over (asset, time slice, activity) tuples for existing assets +/// * `markets_to_price` - Set of (commodity, region) pairs to attempt to price +/// * `existing_prices` - Current commodity prices (used to calculate marginal costs) +/// * `year` - Year for which prices are being calculated +/// * `commodities` - Commodity map +/// * `pricing_strategy` - Pricing strategy, either `MarginalCost` or `FullCost` +/// * `annual_activities` - Optional annual activities (required for full cost pricing) +/// +/// # Returns /// -/// `FullCost` adds annual fixed costs per flow and skips assets with zero annual activity. -fn calculate_existing_asset_average_prices<'a, I>( +/// An iterator of ((commodity, region, time slice selection), price) tuples for the calculated +/// prices. This will include all (commodity, region) combinations in `markets_to_price` for +/// time slice selections where a price could be determined. +fn iter_existing_asset_average_prices<'a, I>( activity_for_existing: I, markets_to_price: &HashSet<(CommodityID, RegionID)>, existing_prices: &CommodityPrices, @@ -883,8 +932,8 @@ where .filter_map(|(key, accum)| accum.finalise().map(|v| (key, v))) } -/// Calculate annual activities for each asset by summing across all time slices -fn calculate_annual_activities<'a, I>(activities: I) -> HashMap +/// Iterate over annual activities for each asset by summing across all time slices +fn iter_annual_activities<'a, I>(activities: I) -> HashMap where I: IntoIterator, { @@ -971,7 +1020,7 @@ fn add_full_cost_prices<'a, I, J>( J: Iterator, { // Calculate full cost prices from existing assets - let mut group_prices: IndexMap<_, _> = calculate_existing_asset_prices( + let mut group_prices: IndexMap<_, _> = iter_existing_asset_max_prices( activity_for_existing, markets_to_price, existing_prices, @@ -985,7 +1034,7 @@ fn add_full_cost_prices<'a, I, J>( // Calculate full cost prices from candidate assets, skipping any groups already covered by // existing assets - let cand_group_prices = calculate_candidate_asset_prices( + let cand_group_prices = iter_candidate_asset_min_prices( activity_keys_for_candidates, markets_to_price, existing_prices, @@ -1025,7 +1074,7 @@ fn add_full_cost_average_prices<'a, I, J>( J: Iterator, { // Calculate full cost prices from existing assets - let mut group_prices: IndexMap<_, _> = calculate_existing_asset_average_prices( + let mut group_prices: IndexMap<_, _> = iter_existing_asset_average_prices( activity_for_existing, markets_to_price, existing_prices, @@ -1038,7 +1087,7 @@ fn add_full_cost_average_prices<'a, I, J>( let priced_groups: HashSet<_> = group_prices.keys().cloned().collect(); // Calculate full cost prices from candidate assets, skipping any groups already covered by existing assets - let cand_group_prices = calculate_candidate_asset_prices( + let cand_group_prices = iter_candidate_asset_min_prices( activity_keys_for_candidates, markets_to_price, existing_prices, From 1649eea8a4eb5fcaa53a3fdab19693a709227279 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Thu, 19 Mar 2026 15:36:42 +0000 Subject: [PATCH 4/8] Remove outdated references to shadow prices --- src/simulation/prices.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/simulation/prices.rs b/src/simulation/prices.rs index e53e0678..5aa10f7f 100644 --- a/src/simulation/prices.rs +++ b/src/simulation/prices.rs @@ -441,7 +441,7 @@ fn add_scarcity_adjusted_prices<'a, I>( /// - Variable operating cost: 5 per unit activity /// - Production levy on C: 3 per unit flow /// - Production levy on D: 4 per unit flow -/// - Shadow price of A: 1 per unit flow +/// - Price of A: 1 per unit flow /// /// Then: /// - Generic activity cost per activity = (1 + 5 + 4) = 10 @@ -969,7 +969,7 @@ where /// - Variable operating cost: 5 per unit activity /// - Production levy on C: 3 per unit flow /// - Production levy on D: 4 per unit flow -/// - Shadow price of A: 1 per unit flow +/// - Price of A: 1 per unit flow /// /// If capacity is 4 and annual activity is 2: /// - Annual capital + fixed operating cost per activity = (2.5 * 4) / 2 = 5 @@ -1290,7 +1290,7 @@ mod tests { Asset::new_candidate(Rc::new(process), region_id.clone(), Capacity(1.0), 2015u32) .unwrap(); let asset_ref = AssetRef::from(asset); - let shadow_prices = + 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())); @@ -1303,7 +1303,7 @@ mod tests { let existing = vec![(&asset_ref, &time_slice, Activity(1.0))]; let candidates = Vec::new(); - let mut prices = shadow_prices.clone(); + let mut prices = existing_prices.clone(); add_marginal_cost_prices( existing.into_iter(), candidates.into_iter(), @@ -1373,7 +1373,7 @@ mod tests { Asset::new_candidate(Rc::new(process), region_id.clone(), Capacity(4.0), 2015u32) .unwrap(); let asset_ref = AssetRef::from(asset); - let shadow_prices = + 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())); @@ -1389,7 +1389,7 @@ mod tests { let mut annual_activities = HashMap::new(); annual_activities.insert(asset_ref.clone(), Activity(2.0)); - let mut prices = shadow_prices.clone(); + let mut prices = existing_prices.clone(); add_full_cost_prices( existing.into_iter(), candidates.into_iter(), From 16ee32002ceb7fb64f55b01a5b9729dd788e889d Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Thu, 19 Mar 2026 15:58:47 +0000 Subject: [PATCH 5/8] Revert change to calculate_annual_activities --- src/simulation/prices.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/simulation/prices.rs b/src/simulation/prices.rs index 5aa10f7f..30d7a98c 100644 --- a/src/simulation/prices.rs +++ b/src/simulation/prices.rs @@ -165,7 +165,7 @@ pub fn calculate_prices(model: &Model, solution: &Solution, year: u32) -> Result // Add prices for full cost commodities if let Some(fullcost_set) = pricing_sets.get(&PricingStrategy::FullCost) { let annual_activities = annual_activities.get_or_insert_with(|| { - iter_annual_activities(solution.iter_activity_for_existing()) + calculate_annual_activities(solution.iter_activity_for_existing()) }); add_full_cost_prices( solution.iter_activity_for_existing(), @@ -182,7 +182,7 @@ pub fn calculate_prices(model: &Model, solution: &Solution, year: u32) -> Result // Add prices for full average commodities if let Some(full_avg_set) = pricing_sets.get(&PricingStrategy::FullCostAverage) { let annual_activities = annual_activities.get_or_insert_with(|| { - iter_annual_activities(solution.iter_activity_for_existing()) + calculate_annual_activities(solution.iter_activity_for_existing()) }); add_full_cost_average_prices( solution.iter_activity_for_existing(), @@ -932,8 +932,8 @@ where .filter_map(|(key, accum)| accum.finalise().map(|v| (key, v))) } -/// Iterate over annual activities for each asset by summing across all time slices -fn iter_annual_activities<'a, I>(activities: I) -> HashMap +/// Calculate annual activities for each asset by summing across all time slices +fn calculate_annual_activities<'a, I>(activities: I) -> HashMap where I: IntoIterator, { From c66970a2aeeb36670204c31553c94ccb2739e0d5 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Fri, 20 Mar 2026 11:43:25 +0000 Subject: [PATCH 6/8] Address copilot suggestions --- src/simulation/prices.rs | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/simulation/prices.rs b/src/simulation/prices.rs index 4065ca36..7e4a648b 100644 --- a/src/simulation/prices.rs +++ b/src/simulation/prices.rs @@ -556,7 +556,7 @@ where assert!(matches!( (pricing_strategy, annual_activities), (PricingStrategy::MarginalCost, _) | (PricingStrategy::FullCost, Some(_)) - ),); + )); // Accumulator map to collect costs from existing assets. For each (commodity, region, // ts selection), this maps each asset to a weighted average of the costs for that @@ -653,7 +653,7 @@ where /// * `markets_to_price` - Set of (commodity, region) pairs to attempt to price /// * `existing_prices` - Current commodity prices (used to calculate marginal costs) /// * `priced_groups` - Set of (commodity, region, time slice selection) groups that have already -/// been prices using existing assets, so should be skipped when looking at candidates +/// been priced using existing assets, so should be skipped when looking at candidates /// * `year` - Year for which prices are being calculated /// * `commodities` - Commodity map /// * `pricing_strategy` - Pricing strategy, either `MarginalCost` or `FullCost` @@ -684,6 +684,9 @@ where // Cache of annual fixed costs per flow for each asset (only used for Full cost pricing) let mut annual_fixed_costs: HashMap<_, _> = HashMap::new(); + // Cache of annual activity limits for each asset (only used for Full cost pricing) + let mut annual_activity_limits: HashMap<_, _> = HashMap::new(); + // Accumulator map to collect costs from candidate assets. Similar to existing_accum, // but costs are weighted according to activity limits (i.e. assuming full utilisation). let mut cand_accum: IndexMap< @@ -695,6 +698,22 @@ where for (asset, time_slice) in activity_keys_for_candidates { let region_id = asset.region_id(); + // When using full cost pricing, skip assets with a zero upper limit on annual activity, + // since we cannot calculate a fixed cost per flow. + 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; + } + // Get activity limits: used to weight marginal costs for seasonal/annual commodities let activity_limit = *asset .get_activity_limits_for_selection(&TimeSliceSelection::Single(time_slice.clone())) @@ -724,13 +743,11 @@ where // Calculate total cost (marginal + fixed if applicable) let total_cost = match pricing_strategy { PricingStrategy::FullCost => { + // Get fixed costs assuming full utilisation (i.e. using the activity limit) + // Input-stage validation should ensure that this limit is never zero let annual_fixed_costs_per_flow = annual_fixed_costs.entry(asset.clone()).or_insert_with(|| { - asset.get_annual_fixed_costs_per_flow( - *asset - .get_activity_limits_for_selection(&TimeSliceSelection::Annual) - .end(), - ) + asset.get_annual_fixed_costs_per_flow(annual_activity_limit.unwrap()) }); marginal_cost + *annual_fixed_costs_per_flow } From 95f2083521b7c681b33913063744ef392ae67029 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Fri, 20 Mar 2026 11:56:13 +0000 Subject: [PATCH 7/8] Make WeightedAverageAccumulator generic --- src/simulation/prices.rs | 66 +++++++++++++++++++++++----------------- 1 file changed, 38 insertions(+), 28 deletions(-) diff --git a/src/simulation/prices.rs b/src/simulation/prices.rs index 7e4a648b..f07683de 100644 --- a/src/simulation/prices.rs +++ b/src/simulation/prices.rs @@ -6,32 +6,37 @@ 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, MoneyPerActivity, MoneyPerFlow, Year}; +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; /// Weighted average accumulator for `MoneyPerFlow` prices. #[derive(Clone, Copy, Debug)] -struct WeightedAverageAccumulator { +struct WeightedAverageAccumulator { /// The numerator of the weighted average, i.e. the sum of value * weight across all entries. numerator: MoneyPerFlow, /// The denominator of the weighted average, i.e. the sum of weights across all entries. denominator: Dimensionless, + /// Marker to bind this accumulator to the configured weight unit type. + _weight_type: PhantomData, } -impl Default for WeightedAverageAccumulator { +impl Default for WeightedAverageAccumulator { fn default() -> Self { Self { numerator: MoneyPerFlow(0.0), denominator: Dimensionless(0.0), + _weight_type: PhantomData, } } } -impl WeightedAverageAccumulator { +impl WeightedAverageAccumulator { /// Add a weighted value to the accumulator. - fn add(&mut self, value: MoneyPerFlow, weight: Dimensionless) { + fn add(&mut self, value: MoneyPerFlow, weight: W) { + let weight = Dimensionless(weight.value()); self.numerator += value * weight; self.denominator += weight; } @@ -45,17 +50,26 @@ impl WeightedAverageAccumulator { } /// Weighted average accumulator with a backup weighting path for `MoneyPerFlow` prices. -#[derive(Clone, Copy, Debug, Default)] -struct WeightedAverageBackupAccumulator { +#[derive(Clone, Copy, Debug)] +struct WeightedAverageBackupAccumulator { /// Primary weighted average path. - primary: WeightedAverageAccumulator, + primary: WeightedAverageAccumulator, /// Backup weighted average path. - backup: WeightedAverageAccumulator, + backup: WeightedAverageAccumulator, +} + +impl Default for WeightedAverageBackupAccumulator { + fn default() -> Self { + Self { + primary: WeightedAverageAccumulator::::default(), + backup: WeightedAverageAccumulator::::default(), + } + } } -impl WeightedAverageBackupAccumulator { +impl WeightedAverageBackupAccumulator { /// Add a weighted value to the accumulator with a backup weight. - fn add(&mut self, value: MoneyPerFlow, weight: Dimensionless, backup_weight: Dimensionless) { + fn add(&mut self, value: MoneyPerFlow, weight: W, backup_weight: W) { self.primary.add(value, weight); self.backup.add(value, backup_weight); } @@ -565,7 +579,7 @@ where // the selection depends on the time slice level of the commodity (i.e. individual, season, year). let mut existing_accum: IndexMap< (CommodityID, RegionID, TimeSliceSelection), - IndexMap, + IndexMap>, > = IndexMap::new(); // Cache of annual fixed costs per flow for each asset (only used for Full cost pricing) @@ -620,11 +634,7 @@ where .or_default() .entry(asset.clone()) .or_default() - .add( - total_cost, - Dimensionless(activity.value()), - Dimensionless(activity_limit.value()), - ); + .add(total_cost, activity, activity_limit); } } @@ -691,7 +701,7 @@ where // but costs are weighted according to activity limits (i.e. assuming full utilisation). let mut cand_accum: IndexMap< (CommodityID, RegionID, TimeSliceSelection), - IndexMap, + IndexMap>, > = IndexMap::new(); // Iterate over candidate assets (assuming full utilisation) @@ -761,7 +771,7 @@ where .or_default() .entry(asset.clone()) .or_default() - .add(total_cost, Dimensionless(activity_limit.value())); + .add(total_cost, activity_limit); } } @@ -874,7 +884,7 @@ where // level of the commodity (i.e. individual, season, year). let mut existing_accum: IndexMap< (CommodityID, RegionID, TimeSliceSelection), - WeightedAverageBackupAccumulator, + WeightedAverageBackupAccumulator, > = IndexMap::new(); // Cache of annual fixed costs per flow for each asset (only used for Full cost pricing) @@ -927,8 +937,8 @@ where .get_flow(&commodity_id) .expect("Commodity should be an output flow for this asset") .coeff; - let output_weight = Dimensionless((activity * output_coeff).value()); - let backup_output_weight = Dimensionless((activity_limit * output_coeff).value()); + let output_weight = activity * output_coeff; + let backup_output_weight = activity_limit * output_coeff; // Accumulate cost for this group, weighted by output with a backup // potential-output weight. @@ -1424,14 +1434,14 @@ mod tests { #[test] fn weighted_average_accumulator_single_value() { - let mut accum = WeightedAverageAccumulator::default(); + let mut accum = WeightedAverageAccumulator::::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::default(); + let mut accum = WeightedAverageAccumulator::::default(); accum.add(MoneyPerFlow(100.0), Dimensionless(1.0)); accum.add(MoneyPerFlow(200.0), Dimensionless(2.0)); // (100*1 + 200*2) / (1+2) = 500/3 ≈ 166.667 @@ -1441,13 +1451,13 @@ mod tests { #[test] fn weighted_average_accumulator_zero_weight() { - let accum = WeightedAverageAccumulator::default(); + let accum = WeightedAverageAccumulator::::default(); assert_eq!(accum.finalise(), None); } #[test] fn weighted_average_backup_accumulator_primary_preferred() { - let mut accum = WeightedAverageBackupAccumulator::default(); + let mut accum = WeightedAverageBackupAccumulator::::default(); accum.add(MoneyPerFlow(100.0), Dimensionless(3.0), Dimensionless(1.0)); accum.add(MoneyPerFlow(200.0), Dimensionless(1.0), Dimensionless(1.0)); // Primary is non-zero, use it: (100*3 + 200*1) / (3+1) = 125 @@ -1457,7 +1467,7 @@ mod tests { #[test] fn weighted_average_backup_accumulator_fallback() { - let mut accum = WeightedAverageBackupAccumulator::default(); + let mut accum = WeightedAverageBackupAccumulator::::default(); accum.add(MoneyPerFlow(100.0), Dimensionless(0.0), Dimensionless(2.0)); accum.add(MoneyPerFlow(200.0), Dimensionless(0.0), Dimensionless(2.0)); // Primary is zero, fallback to backup: (100*2 + 200*2) / (2+2) = 150 @@ -1466,7 +1476,7 @@ mod tests { #[test] fn weighted_average_backup_accumulator_both_zero() { - let accum = WeightedAverageBackupAccumulator::default(); + let accum = WeightedAverageBackupAccumulator::::default(); assert_eq!(accum.finalise(), None); } } From b6364c765fcaff2b04d57d3e0910bf59648fac0f Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Tue, 24 Mar 2026 16:12:10 +0000 Subject: [PATCH 8/8] Apply suggestions from @alexdewar --- src/graph/validate.rs | 4 ++-- src/process.rs | 18 +++++++-------- src/simulation/prices.rs | 50 +++++++++++++++++++++++++--------------- 3 files changed, 42 insertions(+), 30 deletions(-) diff --git a/src/graph/validate.rs b/src/graph/validate.rs index 8ddc4e50..fd5f01c0 100644 --- a/src/graph/validate.rs +++ b/src/graph/validate.rs @@ -68,11 +68,11 @@ fn prepare_commodities_graph_for_validation( filtered_graph } -/// Checks if a process can be active for a particular timeslice in a given year and region +/// Checks if a process can be active for a particular time slice in a given year and region /// /// It considers all commission years that can lead to a running process in the target region and /// year, accounting for the process lifetime, and then checks if, for any of those, the process -/// is active in the required timeslice. In other words, this checks if there is the _possibility_ +/// is active in the required time slice. In other words, this checks if there is the _possibility_ /// of having an active process, although there is no guarantee of that happening since it depends /// on the investment. fn can_be_active( diff --git a/src/process.rs b/src/process.rs index 0de0bd4e..f54f3c7f 100644 --- a/src/process.rs +++ b/src/process.rs @@ -290,7 +290,7 @@ impl ActivityLimits { let lower = *ts_limit.start(); let mut upper = *ts_limit.end(); - // If there's a seasonal/annual limit, we must cap the timeslice limit to ensure that it + // If there's a seasonal/annual limit, we must cap the time slice limit to ensure that it // doesn't exceed the upper bound of the season/year if let Some(seasonal_limit) = self.seasonal_limits.get(&time_slice.season) { upper = upper.min(*seasonal_limit.end()); @@ -356,7 +356,7 @@ impl ActivityLimits { /// Iterate over all limits /// - /// This first iterates over all individual timeslice limits, followed by seasonal limits (if + /// This first iterates over all individual time slice limits, followed by seasonal limits (if /// any), and finally the annual limit (if any). pub fn iter_limits( &self, @@ -1030,10 +1030,10 @@ mod tests { fn new_with_full_availability(time_slice_info2: TimeSliceInfo) { let limits = ActivityLimits::new_with_full_availability(&time_slice_info2); - // Each timeslice from the info should be present in the limits + // Each time slice from the info should be present in the limits for (ts_id, ts_len) in time_slice_info2.iter() { let l = limits.get_limit_for_time_slice(ts_id); - // Lower bound should be zero and upper bound equal to timeslice length + // Lower bound should be zero and upper bound equal to time slice length assert_eq!(*l.start(), Dimensionless(0.0)); assert_eq!(*l.end(), Dimensionless(ts_len.value())); } @@ -1048,7 +1048,7 @@ mod tests { fn new_from_limits_with_seasonal_limit_applied(time_slice_info2: TimeSliceInfo) { let mut limits = HashMap::new(); - // Set a seasonal upper limit that is stricter than the sum of timeslices + // Set a seasonal upper limit that is stricter than the sum of time slices limits.insert( TimeSliceSelection::Season("winter".into()), Dimensionless(0.0)..=Dimensionless(0.01), @@ -1056,7 +1056,7 @@ mod tests { let result = ActivityLimits::new_from_limits(&limits, &time_slice_info2).unwrap(); - // Each timeslice upper bound should be capped by the seasonal upper bound (0.01) + // Each time slice upper bound should be capped by the seasonal upper bound (0.01) for (ts_id, _ts_len) in time_slice_info2.iter() { let ts_limit = result.get_limit_for_time_slice(ts_id); assert_eq!(*ts_limit.end(), Dimensionless(0.01)); @@ -1071,7 +1071,7 @@ mod tests { fn new_from_limits_with_annual_limit_applied(time_slice_info2: TimeSliceInfo) { let mut limits = HashMap::new(); - // Set an annual upper limit that is stricter than the sum of timeslices + // Set an annual upper limit that is stricter than the sum of time slices limits.insert( TimeSliceSelection::Annual, Dimensionless(0.0)..=Dimensionless(0.01), @@ -1079,7 +1079,7 @@ mod tests { let result = ActivityLimits::new_from_limits(&limits, &time_slice_info2).unwrap(); - // Each timeslice upper bound should be capped by the annual upper bound (0.01) + // Each time slice upper bound should be capped by the annual upper bound (0.01) for (ts_id, _ts_len) in time_slice_info2.iter() { let ts_limit = result.get_limit_for_time_slice(ts_id); assert_eq!(*ts_limit.end(), Dimensionless(0.01)); @@ -1098,7 +1098,7 @@ mod tests { fn new_from_limits_missing_timeslices_error(time_slice_info2: TimeSliceInfo) { let mut limits = HashMap::new(); - // Add a single timeslice limit but do not provide limits for all timeslices + // Add a single time slice limit but do not provide limits for all time slices let first_ts = time_slice_info2.iter().next().unwrap().0.clone(); limits.insert( TimeSliceSelection::Single(first_ts), diff --git a/src/simulation/prices.rs b/src/simulation/prices.rs index f07683de..13d74ef8 100644 --- a/src/simulation/prices.rs +++ b/src/simulation/prices.rs @@ -508,7 +508,7 @@ fn add_marginal_cost_prices<'a, I, J>( year, commodities, &PricingStrategy::MarginalCost, - None::<&HashMap>, + /*annual_activities=*/ None, ) .collect(); let priced_groups: HashSet<_> = group_prices.keys().cloned().collect(); @@ -567,10 +567,17 @@ where I: Iterator, { // Validate supported strategies, and require annual activities for FullCost pricing. - assert!(matches!( - (pricing_strategy, annual_activities), - (PricingStrategy::MarginalCost, _) | (PricingStrategy::FullCost, Some(_)) - )); + 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"), + } // Accumulator map to collect costs from existing assets. For each (commodity, region, // ts selection), this maps each asset to a weighted average of the costs for that @@ -583,7 +590,7 @@ where > = IndexMap::new(); // Cache of annual fixed costs per flow for each asset (only used for Full cost pricing) - let mut annual_fixed_costs: HashMap<_, _> = HashMap::new(); + let mut annual_fixed_costs = HashMap::new(); // Iterate over existing assets and their activities for (asset, time_slice, activity) in activity_for_existing { @@ -591,8 +598,7 @@ where // When using full cost pricing, skip assets with zero activity across the year, since // we cannot calculate a fixed cost per flow. - let annual_activity = matches!(pricing_strategy, PricingStrategy::FullCost) - .then(|| annual_activities.unwrap()[asset]); + let annual_activity = annual_activities.map(|activities| activities[asset]); if annual_activity.is_some_and(|annual_activity| annual_activity < Activity::EPSILON) { continue; } @@ -654,7 +660,7 @@ where /// involves taking a weighted average across time slices for each asset according to potential /// activity (i.e. the upper activity limit), omitting prices in the extreme case of zero potential /// activity (Note: this should NOT happen as validation should ensure there is at least one -/// candidate that can provide a price in each timeslice for which a price could be required). +/// candidate that can provide a price in each time slice for which a price could be required). /// Costs for candidates are calculated assuming full utilisation. /// /// # Arguments @@ -692,10 +698,10 @@ where )); // Cache of annual fixed costs per flow for each asset (only used for Full cost pricing) - let mut annual_fixed_costs: HashMap<_, _> = HashMap::new(); + let mut annual_fixed_costs = HashMap::new(); // Cache of annual activity limits for each asset (only used for Full cost pricing) - let mut annual_activity_limits: HashMap<_, _> = HashMap::new(); + let mut annual_activity_limits = HashMap::new(); // Accumulator map to collect costs from candidate assets. Similar to existing_accum, // but costs are weighted according to activity limits (i.e. assuming full utilisation). @@ -813,7 +819,7 @@ fn add_marginal_cost_average_prices<'a, I, J>( year, commodities, &PricingStrategy::MarginalCost, - None::<&HashMap>, + /*annual_activities=*/ None, ) .collect(); let priced_groups: HashSet<_> = group_prices.keys().cloned().collect(); @@ -872,10 +878,17 @@ where I: Iterator, { // Validate supported strategies, and require annual activities for FullCost pricing. - assert!(matches!( - (pricing_strategy, annual_activities), - (PricingStrategy::MarginalCost, _) | (PricingStrategy::FullCost, Some(_)) - )); + 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"), + } // Accumulator map to collect costs from existing assets. Collects a weighted average // for each (commodity, region, ts selection), across all contributing assets, weighted @@ -888,7 +901,7 @@ where > = IndexMap::new(); // Cache of annual fixed costs per flow for each asset (only used for Full cost pricing) - let mut annual_fixed_costs: HashMap<_, _> = HashMap::new(); + let mut annual_fixed_costs = HashMap::new(); // Iterate over existing assets and their activities for (asset, time_slice, activity) in activity_for_existing { @@ -896,8 +909,7 @@ where // When using full cost pricing, skip assets with zero annual activity, since we cannot // calculate a fixed cost per flow. - let annual_activity = matches!(pricing_strategy, PricingStrategy::FullCost) - .then(|| annual_activities.unwrap()[asset]); + let annual_activity = annual_activities.map(|activities| activities[asset]); if annual_activity.is_some_and(|annual_activity| annual_activity < Activity::EPSILON) { continue; }