From e47ed842845e521e42a819dacb97d92d897036e4 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Wed, 18 Mar 2026 16:43:42 +0000 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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/7] 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/7] 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/7] 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); } }