Skip to main content

muse2/simulation/
prices.rs

1//! Code for calculating commodity prices used by the simulation.
2//!
3#![doc = concat!("See <", crate::docs_url!("/model/prices.html"), ">")]
4use crate::asset::AssetRef;
5use crate::commodity::{CommodityID, CommodityMap, CommodityType, PricingStrategy};
6use crate::model::Model;
7use crate::region::RegionID;
8use crate::simulation::market::MarketSet;
9use crate::simulation::optimisation::Solution;
10use crate::time_slice::{TimeSliceID, TimeSliceInfo, TimeSliceSelection};
11use crate::units::{Activity, Dimensionless, Flow, MoneyPerActivity, MoneyPerFlow, UnitType, Year};
12use anyhow::Result;
13use indexmap::IndexMap;
14use std::collections::{HashMap, HashSet};
15use std::marker::PhantomData;
16
17/// Number of iterations to perform when calculating prices for cyclically-dependent markets.
18const N_CYCLE_ITERATIONS: i32 = 1;
19
20/// Weighted average accumulator for `MoneyPerFlow` prices.
21#[derive(Clone, Copy, Debug)]
22struct WeightedAverageAccumulator<W: UnitType> {
23    /// The numerator of the weighted average, i.e. the sum of value * weight across all entries.
24    numerator: MoneyPerFlow,
25    /// The denominator of the weighted average, i.e. the sum of weights across all entries.
26    denominator: Dimensionless,
27    /// Marker to bind this accumulator to the configured weight unit type.
28    _weight_type: PhantomData<W>,
29}
30
31impl<W: UnitType> Default for WeightedAverageAccumulator<W> {
32    fn default() -> Self {
33        Self {
34            numerator: MoneyPerFlow(0.0),
35            denominator: Dimensionless(0.0),
36            _weight_type: PhantomData,
37        }
38    }
39}
40
41impl<W: UnitType> WeightedAverageAccumulator<W> {
42    /// Add a weighted value to the accumulator.
43    fn add(&mut self, value: MoneyPerFlow, weight: W) {
44        let weight = Dimensionless(weight.value());
45        self.numerator += value * weight;
46        self.denominator += weight;
47    }
48
49    /// Solve the weighted average.
50    ///
51    /// Returns `None` if the denominator is zero (or close to zero)
52    fn finalise(self) -> Option<MoneyPerFlow> {
53        (self.denominator > Dimensionless::EPSILON).then(|| self.numerator / self.denominator)
54    }
55}
56
57/// Sets of prices calculated from dispatch results
58#[derive(Default)]
59pub struct Prices {
60    /// Commodity market prices calculated according to user-specified strategies
61    pub market: PriceMap,
62    /// Commodity shadow prices
63    pub shadow: PriceMap,
64    /// Commodity fallback prices calculated according to the model's `fallback_pricing_strategy`
65    pub fallback: PriceMap,
66}
67
68/// Calculate commodity prices.
69///
70/// Calculate prices for each commodity/region/time-slice according to the commodity's configured
71/// `PricingStrategy`.
72///
73/// # Arguments
74///
75/// * `model` - The model
76/// * `solution_without_candidates` - Solution to dispatch optimisation without candidates
77/// * `solution_with_candidates` - Solution to dispatch optimisation with candidates
78/// * `year` - The year for which prices are being calculated
79///
80/// # Returns
81///
82/// Market prices for commodities as well as shadow prices.
83pub fn calculate_prices(
84    model: &Model,
85    solution_without_candidates: &Solution,
86    solution_with_candidates: &Solution,
87    year: u32,
88) -> Result<Prices> {
89    // Collect shadow prices for all SED/SVD commodities
90    // These are derived from the dispatch solution _with_ candidates, so that we can get shadow
91    // prices for commodities not produced/consumed by any existing assets.
92    let shadow_prices =
93        PriceMap::from_iter(solution_with_candidates.iter_commodity_balance_duals());
94
95    // Set up empty market prices map
96    let mut market_prices = PriceMap::default();
97
98    // Set up empty fallback prices map
99    let mut fallback_prices = PriceMap::default();
100
101    // Lazily computed only if at least one FullCost market is encountered.
102    let mut annual_activities: Option<HashMap<AssetRef, Activity>> = None;
103
104    // Iterate over market sets in reverse order
105    for market_set in model.investment_order[&year].iter().rev() {
106        price_market_set(
107            market_set,
108            model,
109            solution_without_candidates,
110            solution_with_candidates,
111            year,
112            &shadow_prices,
113            &mut annual_activities,
114            &mut market_prices,
115            None,
116        );
117        if model.parameters.fallback_pricing_strategy != PricingStrategy::Unpriced {
118            price_market_set(
119                market_set,
120                model,
121                solution_without_candidates,
122                solution_with_candidates,
123                year,
124                &shadow_prices,
125                &mut annual_activities,
126                &mut fallback_prices,
127                Some(model.parameters.fallback_pricing_strategy),
128            );
129        }
130    }
131
132    Ok(Prices {
133        market: market_prices,
134        shadow: shadow_prices,
135        fallback: fallback_prices,
136    })
137}
138
139/// Calculate prices for the markets in an market set, updating `market_prices`.
140#[allow(clippy::too_many_arguments)]
141fn price_market_set(
142    market_set: &MarketSet,
143    model: &Model,
144    solution_without_candidates: &Solution,
145    solution_with_candidates: &Solution,
146    year: u32,
147    shadow_prices: &PriceMap,
148    annual_activities: &mut Option<HashMap<AssetRef, Activity>>,
149    market_prices: &mut PriceMap,
150    strategy_override: Option<PricingStrategy>,
151) {
152    match market_set {
153        MarketSet::Single(market) => {
154            price_markets(
155                model,
156                solution_without_candidates,
157                solution_with_candidates,
158                year,
159                std::slice::from_ref(market),
160                shadow_prices,
161                annual_activities,
162                market_prices,
163                strategy_override,
164            );
165        }
166        MarketSet::Cycle(markets) => {
167            price_cycle(
168                model,
169                solution_without_candidates,
170                solution_with_candidates,
171                year,
172                markets,
173                shadow_prices,
174                annual_activities,
175                market_prices,
176                strategy_override,
177            );
178        }
179        MarketSet::Layer(market_sets) => {
180            for set in market_sets {
181                price_market_set(
182                    set,
183                    model,
184                    solution_without_candidates,
185                    solution_with_candidates,
186                    year,
187                    shadow_prices,
188                    annual_activities,
189                    market_prices,
190                    strategy_override,
191                );
192            }
193        }
194    }
195}
196
197/// Calculate prices for a collection of independent markets and insert them into `market_prices`.
198///
199/// `market_prices` serves as both the source of input prices (prices of input commodities from
200/// upstream markets already computed) and the destination for newly computed prices.
201///
202/// # Arguments
203///
204/// * `model` - The model
205/// * `solution_without_candidates` - Solution to dispatch optimisation without candidate activities
206/// * `solution_with_candidates` - Solution to dispatch optimisation with candidate activities
207/// * `year` - The year for which prices are being calculated
208/// * `markets` - The markets to calculate prices for
209/// * `shadow_prices` - Shadow prices for all commodities
210/// * `annual_activities` - Optional map of annual activities for all assets, used for full cost
211///   pricing. If not provided, it will be calculated on demand if at least one market uses full
212///   cost pricing.
213/// * `market_prices` - In-progress map of calculated prices, which will be extended with the newly
214///   calculated prices for these markets
215#[allow(clippy::too_many_arguments)]
216fn price_markets(
217    model: &Model,
218    solution_without_candidates: &Solution,
219    solution_with_candidates: &Solution,
220    year: u32,
221    markets: &[(CommodityID, RegionID)],
222    shadow_prices: &PriceMap,
223    annual_activities: &mut Option<HashMap<AssetRef, Activity>>,
224    market_prices: &mut PriceMap,
225    strategy_override: Option<PricingStrategy>,
226) {
227    // Partition markets by pricing strategy into a map keyed by `PricingStrategy`.
228    let mut pricing_sets = HashMap::new();
229    for (commodity_id, region_id) in markets {
230        // If a strategy override is provided, apply it to all commodities in the markets list.
231        let strategy = if let Some(strategy) = strategy_override.as_ref() {
232            // Skip OTH commodities — they are not subject to investment decisions
233            if model.commodities[commodity_id].kind == CommodityType::Other {
234                continue;
235            }
236            strategy
237        } else {
238            // Otherwise, use the commodity's pricing strategy. For now, commodities use a single
239            // strategy for all regions, but this may change in the future.
240            let strategy = &model.commodities[commodity_id].pricing_strategy;
241            if *strategy == PricingStrategy::Unpriced {
242                continue;
243            }
244            strategy
245        };
246        pricing_sets
247            .entry(strategy)
248            .or_insert_with(HashSet::new)
249            .insert((commodity_id.clone(), region_id.clone()));
250    }
251
252    // Add prices for shadow-priced commodities
253    if let Some(shadow_set) = pricing_sets.get(&PricingStrategy::Shadow) {
254        for (commodity_id, region_id) in shadow_set {
255            for time_slice in model.time_slice_info.iter_ids() {
256                if let Some(shadow_price) = shadow_prices.get(commodity_id, region_id, time_slice) {
257                    market_prices.insert(commodity_id, region_id, time_slice, shadow_price);
258                }
259            }
260        }
261    }
262
263    // Add prices for scarcity-adjusted commodities
264    if let Some(scarcity_set) = pricing_sets.get(&PricingStrategy::ScarcityAdjusted) {
265        add_scarcity_adjusted_prices(
266            solution_with_candidates.iter_activity_duals(),
267            shadow_prices,
268            market_prices,
269            scarcity_set,
270        );
271    }
272
273    // Add prices for marginal cost commodities
274    if let Some(marginal_set) = pricing_sets.get(&PricingStrategy::MarginalCost) {
275        add_marginal_cost_prices(
276            solution_without_candidates.iter_activity_for_existing(),
277            solution_with_candidates.iter_activity_keys_for_candidates(),
278            market_prices,
279            year,
280            marginal_set,
281            &model.commodities,
282            &model.time_slice_info,
283        );
284    }
285
286    // Add prices for marginal average commodities
287    if let Some(marginal_avg_set) = pricing_sets.get(&PricingStrategy::MarginalCostAverage) {
288        add_marginal_cost_average_prices(
289            solution_without_candidates.iter_activity_for_existing(),
290            solution_with_candidates.iter_activity_keys_for_candidates(),
291            market_prices,
292            year,
293            marginal_avg_set,
294            &model.commodities,
295            &model.time_slice_info,
296        );
297    }
298
299    // Add prices for full cost commodities
300    if let Some(fullcost_set) = pricing_sets.get(&PricingStrategy::FullCost) {
301        let annual_activities = annual_activities.get_or_insert_with(|| {
302            calculate_annual_activities(solution_without_candidates.iter_activity_for_existing())
303        });
304        add_full_cost_prices(
305            solution_without_candidates.iter_activity_for_existing(),
306            solution_with_candidates.iter_activity_keys_for_candidates(),
307            annual_activities,
308            market_prices,
309            year,
310            fullcost_set,
311            &model.commodities,
312            &model.time_slice_info,
313        );
314    }
315
316    // Add prices for full average commodities
317    if let Some(full_avg_set) = pricing_sets.get(&PricingStrategy::FullCostAverage) {
318        let annual_activities = annual_activities.get_or_insert_with(|| {
319            calculate_annual_activities(solution_without_candidates.iter_activity_for_existing())
320        });
321        add_full_cost_average_prices(
322            solution_without_candidates.iter_activity_for_existing(),
323            solution_with_candidates.iter_activity_keys_for_candidates(),
324            annual_activities,
325            market_prices,
326            year,
327            full_avg_set,
328            &model.commodities,
329            &model.time_slice_info,
330        );
331    }
332}
333
334/// Calculate prices for a set of cyclically-dependent markets, updating `market_prices`.
335///
336/// Markets should be ordered in the input slice according to the direction of dependencies
337/// (downstream markets first) as solved by `order_sccs`.  Prices are calculated in the reverse of
338/// this order (i.e. upstream markets first), with an iterative loop to allow for feedback between
339/// markets.
340#[allow(clippy::too_many_arguments)]
341fn price_cycle(
342    model: &Model,
343    solution_without_candidates: &Solution,
344    solution_with_candidates: &Solution,
345    year: u32,
346    markets: &[(CommodityID, RegionID)],
347    shadow_prices: &PriceMap,
348    annual_activities: &mut Option<HashMap<AssetRef, Activity>>,
349    market_prices: &mut PriceMap,
350    strategy_override: Option<PricingStrategy>,
351) {
352    // Seed the markets with shadow prices
353    for (commodity_id, region_id) in markets {
354        for time_slice in model.time_slice_info.iter_ids() {
355            if let Some(shadow_price) = shadow_prices.get(commodity_id, region_id, time_slice) {
356                market_prices.insert(commodity_id, region_id, time_slice, shadow_price);
357            }
358        }
359    }
360
361    // Iterate over the markets for a fixed number of iterations, updating prices each time
362    for _ in 0..N_CYCLE_ITERATIONS {
363        // Price markets in reverse order (i.e. upstream markets first)
364        for market in markets.iter().rev() {
365            price_markets(
366                model,
367                solution_without_candidates,
368                solution_with_candidates,
369                year,
370                std::slice::from_ref(market),
371                shadow_prices,
372                annual_activities,
373                market_prices,
374                strategy_override,
375            );
376        }
377    }
378}
379
380/// A map relating commodity ID + region + time slice to current price (endogenous)
381#[derive(Default, Clone)]
382pub struct PriceMap(IndexMap<(CommodityID, RegionID, TimeSliceID), MoneyPerFlow>);
383
384impl PriceMap {
385    /// Insert/update a price for the given commodity, region and time slice.
386    pub fn insert(
387        &mut self,
388        commodity_id: &CommodityID,
389        region_id: &RegionID,
390        time_slice: &TimeSliceID,
391        price: MoneyPerFlow,
392    ) {
393        let key = (commodity_id.clone(), region_id.clone(), time_slice.clone());
394        self.0.insert(key, price);
395    }
396
397    /// Extend/update this map by applying each selection-level price to all time slices
398    /// contained in that selection.
399    fn extend_selection_prices(
400        &mut self,
401        group_prices: &IndexMap<(CommodityID, RegionID, TimeSliceSelection), MoneyPerFlow>,
402        time_slice_info: &TimeSliceInfo,
403    ) {
404        for ((commodity_id, region_id, selection), &selection_price) in group_prices {
405            for (time_slice_id, _) in selection.iter(time_slice_info) {
406                self.insert(commodity_id, region_id, time_slice_id, selection_price);
407            }
408        }
409    }
410
411    /// Iterate over the map.
412    ///
413    /// # Returns
414    ///
415    /// An iterator of tuples containing commodity ID, region ID, time slice and price.
416    pub fn iter(
417        &self,
418    ) -> impl Iterator<Item = (&CommodityID, &RegionID, &TimeSliceID, MoneyPerFlow)> {
419        self.0
420            .iter()
421            .map(|((commodity_id, region_id, ts), price)| (commodity_id, region_id, ts, *price))
422    }
423
424    /// Get the price for the specified commodity for a given region and time slice
425    pub fn get(
426        &self,
427        commodity_id: &CommodityID,
428        region_id: &RegionID,
429        time_slice: &TimeSliceID,
430    ) -> Option<MoneyPerFlow> {
431        self.0
432            .get(&(commodity_id.clone(), region_id.clone(), time_slice.clone()))
433            .copied()
434    }
435
436    /// Iterate over the price map's keys
437    pub fn keys(
438        &self,
439    ) -> indexmap::map::Keys<'_, (CommodityID, RegionID, TimeSliceID), MoneyPerFlow> {
440        self.0.keys()
441    }
442
443    /// Calculate time slice-weighted average prices for each commodity-region pair
444    ///
445    /// This method aggregates prices across time slices by weighting each price
446    /// by the duration of its time slice, providing a more representative annual average.
447    ///
448    /// Note: this assumes that all time slices are present for each commodity-region pair, and
449    /// that all time slice lengths sum to 1. This is not checked by this method.
450    fn time_slice_weighted_averages(
451        &self,
452        time_slice_info: &TimeSliceInfo,
453    ) -> HashMap<(CommodityID, RegionID), MoneyPerFlow> {
454        let mut weighted_prices = HashMap::new();
455
456        for ((commodity_id, region_id, time_slice_id), price) in &self.0 {
457            // NB: Time slice fractions will sum to one
458            let weight = time_slice_info.time_slices[time_slice_id] / Year(1.0);
459            let key = (commodity_id.clone(), region_id.clone());
460            weighted_prices
461                .entry(key)
462                .and_modify(|v| *v += *price * weight)
463                .or_insert_with(|| *price * weight);
464        }
465
466        weighted_prices
467    }
468
469    /// Check if time slice-weighted average prices are within relative tolerance of another price
470    /// set.
471    ///
472    /// This method calculates time slice-weighted average prices for each commodity-region pair
473    /// and compares them. Both objects must have exactly the same set of commodity-region pairs,
474    /// otherwise it will panic.
475    ///
476    /// Additionally, this method assumes that all time slices are present for each commodity-region
477    /// pair, and that all time slice lengths sum to 1. This is not checked by this method.
478    pub fn within_tolerance_weighted(
479        &self,
480        other: &Self,
481        tolerance: Dimensionless,
482        time_slice_info: &TimeSliceInfo,
483    ) -> bool {
484        let self_averages = self.time_slice_weighted_averages(time_slice_info);
485        let other_averages = other.time_slice_weighted_averages(time_slice_info);
486
487        for (key, &price) in &self_averages {
488            let other_price = other_averages[key];
489            let abs_diff = (price - other_price).abs();
490
491            // Special case: last price was zero
492            if price == MoneyPerFlow(0.0) {
493                // Current price is zero but other price is nonzero
494                if other_price != MoneyPerFlow(0.0) {
495                    return false;
496                }
497            // Check if price is within tolerance
498            } else if abs_diff / price.abs() > tolerance {
499                return false;
500            }
501        }
502        true
503    }
504}
505
506impl<'a> FromIterator<(&'a CommodityID, &'a RegionID, &'a TimeSliceID, MoneyPerFlow)> for PriceMap {
507    fn from_iter<I>(iter: I) -> Self
508    where
509        I: IntoIterator<Item = (&'a CommodityID, &'a RegionID, &'a TimeSliceID, MoneyPerFlow)>,
510    {
511        let map = iter
512            .into_iter()
513            .map(|(commodity_id, region_id, time_slice, price)| {
514                (
515                    (commodity_id.clone(), region_id.clone(), time_slice.clone()),
516                    price,
517                )
518            })
519            .collect();
520        Self(map)
521    }
522}
523
524impl IntoIterator for PriceMap {
525    type Item = ((CommodityID, RegionID, TimeSliceID), MoneyPerFlow);
526    type IntoIter = indexmap::map::IntoIter<(CommodityID, RegionID, TimeSliceID), MoneyPerFlow>;
527
528    fn into_iter(self) -> Self::IntoIter {
529        self.0.into_iter()
530    }
531}
532
533/// Calculate scarcity-adjusted prices for a set of commodities and add to an existing prices map.
534///
535/// # Arguments
536///
537/// * `activity_duals` - Iterator over activity duals from optimisation solution
538/// * `shadow_prices` - Shadow prices for all commodities
539/// * `market_prices` - Existing prices map to extend with scarcity-adjusted prices
540/// * `markets_to_price` - Set of markets to calculate scarcity-adjusted prices for
541fn add_scarcity_adjusted_prices<'a, I>(
542    activity_duals: I,
543    shadow_prices: &PriceMap,
544    market_prices: &mut PriceMap,
545    markets_to_price: &HashSet<(CommodityID, RegionID)>,
546) where
547    I: Iterator<Item = (&'a AssetRef, &'a TimeSliceID, MoneyPerActivity)>,
548{
549    // Calculate highest activity dual for each commodity/region/time slice
550    let mut highest_duals = IndexMap::new();
551    for (asset, time_slice, dual) in activity_duals {
552        let region_id = asset.region_id();
553
554        // Iterate over the output flows of this asset
555        // Only consider flows for commodities we are pricing
556        for flow in asset.iter_output_flows().filter(|flow| {
557            markets_to_price.contains(&(flow.commodity.id.clone(), region_id.clone()))
558        }) {
559            // Update the highest dual for this commodity/time slice
560            highest_duals
561                .entry((
562                    flow.commodity.id.clone(),
563                    region_id.clone(),
564                    time_slice.clone(),
565                ))
566                .and_modify(|current_dual| {
567                    if dual > *current_dual {
568                        *current_dual = dual;
569                    }
570                })
571                .or_insert(dual);
572        }
573    }
574
575    // Add this to the shadow price for each commodity/region/time slice and insert into the map
576    for ((commodity, region, time_slice), highest_dual) in &highest_duals {
577        // There should always be a shadow price for commodities we are considering here, so it
578        // should be safe to unwrap
579        let shadow_price = shadow_prices.get(commodity, region, time_slice).unwrap();
580        // highest_dual is in units of MoneyPerActivity, and shadow_price is in MoneyPerFlow, but
581        // this is correct according to Adam
582        let scarcity_price = shadow_price + MoneyPerFlow(highest_dual.value());
583        market_prices.insert(commodity, region, time_slice, scarcity_price);
584    }
585}
586
587/// Calculate marginal cost prices for a set of commodities and add to an existing prices map.
588///
589/// This pricing strategy aims to incorporate the marginal cost of commodity production into the price.
590///
591/// For a given asset, a marginal cost can be calculated for each SED/SVD output, which is the sum of:
592/// - Generic activity costs: Activity-related costs not tied to a specific SED/SVD output
593///   (variable operating costs, cost of purchasing inputs, plus all levies and flow costs not
594///   associated with specific SED/SVD outputs). These are shared over all SED/SVD
595///   outputs according to their flow coefficients.
596/// - Commodity-specific activity costs: flow costs/levies for the specific SED/SVD output.
597///
598/// ---
599///
600/// For example, consider an asset A(SED) -> B(SED) + 2C(SED) + D(OTH), with the following costs:
601/// - Variable operating cost: 5 per unit activity
602/// - Production levy on C: 3 per unit flow
603/// - Production levy on D: 4 per unit flow
604/// - Price of A: 1 per unit flow
605///
606/// Then:
607/// - Generic activity cost per activity = (1 + 5 + 4) = 10
608/// - Generic activity cost per SED/SVD output = 10 / (1 + 2) = 3.333
609/// - Marginal cost of B = 3.333
610/// - Marginal cost of C = 3.333 + 3 = 6.333
611///
612/// ---
613///
614/// For each region, the price in each time slice is taken from the installed asset with the highest
615/// marginal cost. If there are no producers of the commodity in that region (in particular, this
616/// may occur when there's no demand for the commodity), then candidate assets are considered: we
617/// take the price from the candidate asset with the _lowest_ marginal cost, assuming full
618/// utilisation (i.e. the single candidate asset that would be most competitive if a small amount of
619/// demand was added).
620///
621/// For commodities with seasonal/annual time slice levels, marginal costs are weighted by
622/// activity (or the maximum potential activity for candidates) to get a time slice-weighted average
623/// marginal cost for each asset, before taking the max across assets. Consequently, the price of
624/// these commodities is flat within each season/year.
625///
626/// # Arguments
627///
628/// * `activity_for_existing` - Iterator over `(asset, time_slice, activity)` from optimisation
629///   solution for existing assets
630/// * `activity_keys_for_candidates` - Iterator over `(asset, time_slice)` for candidate assets
631/// * `market_prices` - Existing prices to use as inputs and extend. This is expected to include
632///   prices from all markets upstream of the markets we are calculating for.
633/// * `year` - The year for which prices are being calculated
634/// * `markets_to_price` - Set of markets to calculate marginal prices for
635/// * `commodities` - Map of all commodities (used to look up each commodity's `time_slice_level`)
636/// * `time_slice_info` - Time slice information (used to expand groups to individual time slices)
637fn add_marginal_cost_prices<'a, I, J>(
638    activity_for_existing: I,
639    activity_keys_for_candidates: J,
640    market_prices: &mut PriceMap,
641    year: u32,
642    markets_to_price: &HashSet<(CommodityID, RegionID)>,
643    commodities: &CommodityMap,
644    time_slice_info: &TimeSliceInfo,
645) where
646    I: Iterator<Item = (&'a AssetRef, &'a TimeSliceID, Activity)>,
647    J: Iterator<Item = (&'a AssetRef, &'a TimeSliceID)>,
648{
649    // Calculate marginal cost prices from existing assets
650    let mut group_prices: IndexMap<_, _> = iter_existing_asset_max_prices(
651        activity_for_existing,
652        markets_to_price,
653        market_prices,
654        year,
655        commodities,
656        &PricingStrategy::MarginalCost,
657        /*annual_activities=*/ None,
658    )
659    .collect();
660    let priced_groups: HashSet<_> = group_prices.keys().cloned().collect();
661
662    // Calculate marginal cost prices from candidate assets, skipping any groups already covered by
663    // existing assets
664    let cand_group_prices = iter_candidate_asset_min_prices(
665        activity_keys_for_candidates,
666        markets_to_price,
667        market_prices,
668        &priced_groups,
669        year,
670        commodities,
671        &PricingStrategy::MarginalCost,
672    );
673
674    // Merge existing and candidate group prices
675    group_prices.extend(cand_group_prices);
676
677    // Expand selection-level prices to individual time slices and add to the main prices map
678    market_prices.extend_selection_prices(&group_prices, time_slice_info);
679}
680
681/// Calculate prices as the maximum cost across existing assets, using either a marginal cost or
682/// full cost strategy (depending on `pricing_strategy`). Prices are given for each commodity in
683/// the granularity of the commodity's time slice level. For seasonal/annual commodities, this
684/// involves taking a weighted average across time slices for each asset according to activity
685/// (with a backup weight based on potential activity if there is zero activity across the
686/// selection, and omitting prices in the extreme case of zero potential activity).
687///
688/// # Arguments
689///
690/// * `activity_for_existing` - Iterator over (asset, time slice, activity) tuples for existing assets
691/// * `markets_to_price` - Set of (commodity, region) pairs to attempt to price
692/// * `market_prices` - Current commodity prices (used to calculate marginal costs)
693/// * `year` - Year for which prices are being calculated
694/// * `commodities` - Commodity map
695/// * `pricing_strategy` - Pricing strategy, either `MarginalCost` or `FullCost`
696/// * `annual_activities` - Optional annual activities (required for full cost pricing)
697///
698/// # Returns
699///
700/// An iterator of ((commodity, region, time slice selection), price) tuples for the calculated
701/// prices. This will include all (commodity, region) combinations in `markets_to_price` for
702/// time slice selections where a price could be determined.
703fn iter_existing_asset_max_prices<'a, I>(
704    activity_for_existing: I,
705    markets_to_price: &HashSet<(CommodityID, RegionID)>,
706    market_prices: &PriceMap,
707    year: u32,
708    commodities: &CommodityMap,
709    pricing_strategy: &PricingStrategy,
710    annual_activities: Option<&HashMap<AssetRef, Activity>>,
711) -> impl Iterator<Item = ((CommodityID, RegionID, TimeSliceSelection), MoneyPerFlow)> + 'a
712where
713    I: Iterator<Item = (&'a AssetRef, &'a TimeSliceID, Activity)>,
714{
715    // Validate supported strategies, and require annual activities for FullCost pricing.
716    match pricing_strategy {
717        PricingStrategy::MarginalCost => assert!(
718            annual_activities.is_none(),
719            "Cannot provide annual_activities with marginal pricing strategy"
720        ),
721        PricingStrategy::FullCost => assert!(
722            annual_activities.is_some(),
723            "annual_activities must be provided for full pricing strategy"
724        ),
725        _ => panic!("Invalid pricing strategy"),
726    }
727
728    // Accumulator maps to collect costs from existing assets. For each (commodity, region,
729    // ts selection), these map each asset to a weighted average of the costs for that
730    // commodity across all time slices in the selection, weighted by activity (using activity
731    // limits as a backup weight if there is zero activity across the selection). The granularity of
732    // the selection depends on the time slice level of the commodity (i.e. individual, season,
733    // year).
734    let mut primary_accum: IndexMap<
735        (CommodityID, RegionID, TimeSliceSelection),
736        IndexMap<AssetRef, WeightedAverageAccumulator<Activity>>,
737    > = IndexMap::new();
738    let mut backup_accum: IndexMap<
739        (CommodityID, RegionID, TimeSliceSelection),
740        IndexMap<AssetRef, WeightedAverageAccumulator<Activity>>,
741    > = IndexMap::new();
742
743    // Cache of annual fixed costs per flow for each asset (only used for Full cost pricing)
744    let mut annual_fixed_costs = HashMap::new();
745
746    // Iterate over existing assets and their activities
747    for (asset, time_slice, activity) in activity_for_existing {
748        let region_id = asset.region_id();
749
750        // When using full cost pricing, skip assets with zero activity across the year, since
751        // we cannot calculate a fixed cost per flow.
752        let annual_activity = annual_activities.map(|activities| activities[asset]);
753        if annual_activity.is_some_and(|annual_activity| annual_activity < Activity::EPSILON) {
754            continue;
755        }
756
757        // Get activity limits: used as a backup weight if no activity across the group
758        let activity_limit = *asset
759            .get_activity_limits_for_selection(&TimeSliceSelection::Single(time_slice.clone()))
760            .end();
761
762        // Iterate over the marginal costs for commodities we need prices for
763        for (commodity_id, marginal_cost) in asset.iter_marginal_costs_with_filter(
764            market_prices,
765            year,
766            time_slice,
767            |cid: &CommodityID| markets_to_price.contains(&(cid.clone(), region_id.clone())),
768        ) {
769            // Get the time slice selection according to the commodity's time slice level
770            let ts_selection = commodities[&commodity_id]
771                .time_slice_level
772                .containing_selection(time_slice);
773
774            // Calculate total cost (marginal + fixed if applicable)
775            let total_cost = match pricing_strategy {
776                PricingStrategy::FullCost => {
777                    let annual_fixed_costs_per_flow =
778                        annual_fixed_costs.entry(asset.clone()).or_insert_with(|| {
779                            asset.get_annual_fixed_costs_per_flow(annual_activity.unwrap())
780                        });
781                    marginal_cost + *annual_fixed_costs_per_flow
782                }
783                PricingStrategy::MarginalCost => marginal_cost,
784                _ => unreachable!(),
785            };
786
787            // Accumulate cost for this asset, weighted by activity (using the activity limit
788            // as a backup weight)
789            let key = (
790                commodity_id.clone(),
791                region_id.clone(),
792                ts_selection.clone(),
793            );
794            primary_accum
795                .entry(key.clone())
796                .or_default()
797                .entry(asset.clone())
798                .or_default()
799                .add(total_cost, activity);
800            backup_accum
801                .entry(key)
802                .or_default()
803                .entry(asset.clone())
804                .or_default()
805                .add(total_cost, activity_limit);
806        }
807    }
808
809    // For each group, finalise per-asset weighted averages then take the max across assets
810    // If there is no activity across the group, use the backup weighted average based on potential
811    // activity (i.e. the upper activity limit) instead, taking the min across assets.
812    primary_accum.into_iter().filter_map(move |(key, primary)| {
813        let price = primary
814            .into_values()
815            .filter_map(WeightedAverageAccumulator::finalise)
816            .reduce(|a, b| a.max(b))
817            .or_else(|| {
818                backup_accum
819                    .swap_remove(&key)?
820                    .into_values()
821                    .filter_map(WeightedAverageAccumulator::finalise)
822                    .reduce(|a, b| a.min(b))
823            })?;
824        Some((key, price))
825    })
826}
827
828/// Calculate prices as the minimum cost across candidate assets, using either a marginal cost or
829/// full cost strategy (depending on `pricing_strategy`). Prices are given for each commodity in
830/// the granularity of the commodity's time slice level. For seasonal/annual commodities, this
831/// involves taking a weighted average across time slices for each asset according to potential
832/// activity (i.e. the upper activity limit), omitting prices in the extreme case of zero potential
833/// activity (Note: this should NOT happen as validation should ensure there is at least one
834/// candidate that can provide a price in each time slice for which a price could be required).
835/// Costs for candidates are calculated assuming full utilisation.
836///
837/// # Arguments
838///
839/// * `activity_keys_for_candidates` - Iterator over (asset, time slice) tuples for candidate assets
840/// * `markets_to_price` - Set of (commodity, region) pairs to attempt to price
841/// * `market_prices` - Current commodity prices (used to calculate marginal costs)
842/// * `priced_groups` - Set of (commodity, region, time slice selection) groups that have already
843///   been priced using existing assets, so should be skipped when looking at candidates
844/// * `year` - Year for which prices are being calculated
845/// * `commodities` - Commodity map
846/// * `pricing_strategy` - Pricing strategy, either `MarginalCost` or `FullCost`
847///
848/// # Returns
849///
850/// An iterator of ((commodity, region, time slice selection), price) tuples for the calculated
851/// prices. This will include all (commodity, region) combinations in `markets_to_price` for
852/// time slice selections not covered by `priced_groups`, and for which a price could be determined
853fn iter_candidate_asset_min_prices<'a, I>(
854    activity_keys_for_candidates: I,
855    markets_to_price: &HashSet<(CommodityID, RegionID)>,
856    market_prices: &PriceMap,
857    priced_groups: &HashSet<(CommodityID, RegionID, TimeSliceSelection)>,
858    year: u32,
859    commodities: &CommodityMap,
860    pricing_strategy: &PricingStrategy,
861) -> impl Iterator<Item = ((CommodityID, RegionID, TimeSliceSelection), MoneyPerFlow)>
862where
863    I: Iterator<Item = (&'a AssetRef, &'a TimeSliceID)>,
864{
865    // Validate the supported strategy values.
866    assert!(matches!(
867        pricing_strategy,
868        PricingStrategy::MarginalCost | PricingStrategy::FullCost
869    ));
870
871    // Cache of annual fixed costs per flow for each asset (only used for Full cost pricing)
872    let mut annual_fixed_costs = HashMap::new();
873
874    // Cache of annual activity limits for each asset (only used for Full cost pricing)
875    let mut annual_activity_limits = HashMap::new();
876
877    // Accumulator map to collect costs from candidate assets. Similar to existing_accum,
878    // but costs are weighted according to activity limits (i.e. assuming full utilisation).
879    let mut cand_accum: IndexMap<
880        (CommodityID, RegionID, TimeSliceSelection),
881        IndexMap<AssetRef, WeightedAverageAccumulator<Activity>>,
882    > = IndexMap::new();
883
884    // Iterate over candidate assets (assuming full utilisation)
885    for (asset, time_slice) in activity_keys_for_candidates {
886        let region_id = asset.region_id();
887
888        // When using full cost pricing, skip assets with a zero upper limit on annual activity,
889        // since we cannot calculate a fixed cost per flow.
890        let annual_activity_limit =
891            matches!(pricing_strategy, PricingStrategy::FullCost).then(|| {
892                *annual_activity_limits
893                    .entry(asset.clone())
894                    .or_insert_with(|| {
895                        *asset
896                            .get_activity_limits_for_selection(&TimeSliceSelection::Annual)
897                            .end()
898                    })
899            });
900        if annual_activity_limit.is_some_and(|limit| limit < Activity::EPSILON) {
901            continue;
902        }
903
904        // Get activity limits: used to weight marginal costs for seasonal/annual commodities
905        let activity_limit = *asset
906            .get_activity_limits_for_selection(&TimeSliceSelection::Single(time_slice.clone()))
907            .end();
908
909        // Iterate over the marginal costs for commodities we need prices for
910        for (commodity_id, marginal_cost) in asset.iter_marginal_costs_with_filter(
911            market_prices,
912            year,
913            time_slice,
914            |cid: &CommodityID| markets_to_price.contains(&(cid.clone(), region_id.clone())),
915        ) {
916            // Get the time slice selection according to the commodity's time slice level
917            let ts_selection = commodities[&commodity_id]
918                .time_slice_level
919                .containing_selection(time_slice);
920
921            // Skip groups already covered by existing assets
922            if priced_groups.contains(&(
923                commodity_id.clone(),
924                region_id.clone(),
925                ts_selection.clone(),
926            )) {
927                continue;
928            }
929
930            // Calculate total cost (marginal + fixed if applicable)
931            let total_cost = match pricing_strategy {
932                PricingStrategy::FullCost => {
933                    // Get fixed costs assuming full utilisation (i.e. using the activity limit)
934                    // Input-stage validation should ensure that this limit is never zero
935                    let annual_fixed_costs_per_flow =
936                        annual_fixed_costs.entry(asset.clone()).or_insert_with(|| {
937                            asset.get_annual_fixed_costs_per_flow(annual_activity_limit.unwrap())
938                        });
939                    marginal_cost + *annual_fixed_costs_per_flow
940                }
941                PricingStrategy::MarginalCost => marginal_cost,
942                _ => unreachable!(),
943            };
944
945            // Accumulate cost for this candidate asset, weighted by the activity limit
946            cand_accum
947                .entry((commodity_id.clone(), region_id.clone(), ts_selection))
948                .or_default()
949                .entry(asset.clone())
950                .or_default()
951                .add(total_cost, activity_limit);
952        }
953    }
954
955    // For each group, finalise per-candidate weighted averages then take the min across candidates
956    cand_accum.into_iter().filter_map(|(key, per_candidate)| {
957        per_candidate
958            .into_values()
959            .filter_map(WeightedAverageAccumulator::finalise)
960            .reduce(|current, value| current.min(value))
961            .map(|v| (key, v))
962    })
963}
964
965/// Calculate marginal cost prices for a set of commodities using a load-weighted average across
966/// assets and add to an existing prices map.
967///
968/// Similar to `add_marginal_cost_prices`, but takes a weighted average across assets
969/// according to output rather than taking the max.
970///
971/// Candidate assets are treated the same way as in `add_marginal_cost_prices` (i.e. take the
972/// min across candidate assets).
973fn add_marginal_cost_average_prices<'a, I, J>(
974    activity_for_existing: I,
975    activity_keys_for_candidates: J,
976    market_prices: &mut PriceMap,
977    year: u32,
978    markets_to_price: &HashSet<(CommodityID, RegionID)>,
979    commodities: &CommodityMap,
980    time_slice_info: &TimeSliceInfo,
981) where
982    I: Iterator<Item = (&'a AssetRef, &'a TimeSliceID, Activity)>,
983    J: Iterator<Item = (&'a AssetRef, &'a TimeSliceID)>,
984{
985    // Calculate marginal cost prices from existing assets
986    let mut group_prices: IndexMap<_, _> = iter_existing_asset_average_prices(
987        activity_for_existing,
988        markets_to_price,
989        market_prices,
990        year,
991        commodities,
992        &PricingStrategy::MarginalCost,
993        /*annual_activities=*/ None,
994    )
995    .collect();
996    let priced_groups: HashSet<_> = group_prices.keys().cloned().collect();
997
998    // Calculate marginal cost prices from candidate assets, skipping any groups already covered by
999    // existing assets
1000    let cand_group_prices = iter_candidate_asset_min_prices(
1001        activity_keys_for_candidates,
1002        markets_to_price,
1003        market_prices,
1004        &priced_groups,
1005        year,
1006        commodities,
1007        &PricingStrategy::MarginalCost,
1008    );
1009
1010    // Merge existing and candidate group prices
1011    group_prices.extend(cand_group_prices);
1012
1013    // Expand selection-level prices to individual time slices and add to the main prices map
1014    market_prices.extend_selection_prices(&group_prices, time_slice_info);
1015}
1016
1017/// Calculate prices as the load-weighted average cost across existing assets, using either a
1018/// marginal cost or full cost strategy (depending on `pricing_strategy`). Prices are given for each
1019/// commodity in the granularity of the commodity's time slice level. For seasonal/annual
1020/// commodities, this involves taking a weighted average across time slices for each asset according
1021/// to activity (with a backup weight based on potential activity if there is zero activity across
1022/// the selection, and omitting prices in the extreme case of zero potential activity).
1023///
1024/// # Arguments
1025///
1026/// * `activity_for_existing` - Iterator over (asset, time slice, activity) tuples for existing assets
1027/// * `markets_to_price` - Set of (commodity, region) pairs to attempt to price
1028/// * `market_prices` - Current commodity prices (used to calculate marginal costs)
1029/// * `year` - Year for which prices are being calculated
1030/// * `commodities` - Commodity map
1031/// * `pricing_strategy` - Pricing strategy, either `MarginalCost` or `FullCost`
1032/// * `annual_activities` - Optional annual activities (required for full cost pricing)
1033///
1034/// # Returns
1035///
1036/// An iterator of ((commodity, region, time slice selection), price) tuples for the calculated
1037/// prices. This will include all (commodity, region) combinations in `markets_to_price` for
1038/// time slice selections where a price could be determined.
1039fn iter_existing_asset_average_prices<'a, I>(
1040    activity_for_existing: I,
1041    markets_to_price: &HashSet<(CommodityID, RegionID)>,
1042    market_prices: &PriceMap,
1043    year: u32,
1044    commodities: &CommodityMap,
1045    pricing_strategy: &PricingStrategy,
1046    annual_activities: Option<&HashMap<AssetRef, Activity>>,
1047) -> impl Iterator<Item = ((CommodityID, RegionID, TimeSliceSelection), MoneyPerFlow)> + 'a
1048where
1049    I: Iterator<Item = (&'a AssetRef, &'a TimeSliceID, Activity)>,
1050{
1051    // Validate supported strategies, and require annual activities for FullCost pricing.
1052    match pricing_strategy {
1053        PricingStrategy::MarginalCost => assert!(
1054            annual_activities.is_none(),
1055            "Cannot provide annual_activities with marginal pricing strategy"
1056        ),
1057        PricingStrategy::FullCost => assert!(
1058            annual_activities.is_some(),
1059            "annual_activities must be provided for full pricing strategy"
1060        ),
1061        _ => panic!("Invalid pricing strategy"),
1062    }
1063
1064    // Accumulator maps to collect costs from existing assets. The primary map collects a weighted
1065    // average for each (commodity, region, ts selection), across all contributing assets, weighted
1066    // according to output. The backup map collects a weighted average for each (commodity, region,
1067    // ts selection) for each asset, weighted according to potential output (upper availability
1068    // limit). The granularity of the selection depends on the time slice level of the commodity
1069    // (i.e. individual, season, year).
1070    let mut primary_accum: IndexMap<
1071        (CommodityID, RegionID, TimeSliceSelection),
1072        WeightedAverageAccumulator<Flow>,
1073    > = IndexMap::new();
1074    let mut backup_accum: IndexMap<
1075        (CommodityID, RegionID, TimeSliceSelection),
1076        IndexMap<AssetRef, WeightedAverageAccumulator<Activity>>,
1077    > = IndexMap::new();
1078
1079    // Cache of annual fixed costs per flow for each asset (only used for Full cost pricing)
1080    let mut annual_fixed_costs = HashMap::new();
1081
1082    // Iterate over existing assets and their activities
1083    for (asset, time_slice, activity) in activity_for_existing {
1084        let region_id = asset.region_id();
1085
1086        // When using full cost pricing, skip assets with zero annual activity, since we cannot
1087        // calculate a fixed cost per flow.
1088        let annual_activity = annual_activities.map(|activities| activities[asset]);
1089        if annual_activity.is_some_and(|annual_activity| annual_activity < Activity::EPSILON) {
1090            continue;
1091        }
1092
1093        // Get activity limits: used to calculate backup potential-output weights.
1094        let activity_limit = *asset
1095            .get_activity_limits_for_selection(&TimeSliceSelection::Single(time_slice.clone()))
1096            .end();
1097
1098        // Iterate over the marginal costs for commodities we need prices for
1099        for (commodity_id, marginal_cost) in asset.iter_marginal_costs_with_filter(
1100            market_prices,
1101            year,
1102            time_slice,
1103            |cid: &CommodityID| markets_to_price.contains(&(cid.clone(), region_id.clone())),
1104        ) {
1105            // Get the time slice selection according to the commodity's time slice level
1106            let time_slice_selection = commodities[&commodity_id]
1107                .time_slice_level
1108                .containing_selection(time_slice);
1109
1110            // Calculate total cost (marginal + fixed if applicable)
1111            let total_cost = match pricing_strategy {
1112                PricingStrategy::FullCost => {
1113                    let annual_fixed_costs_per_flow =
1114                        annual_fixed_costs.entry(asset.clone()).or_insert_with(|| {
1115                            asset.get_annual_fixed_costs_per_flow(annual_activity.unwrap())
1116                        });
1117                    marginal_cost + *annual_fixed_costs_per_flow
1118                }
1119                PricingStrategy::MarginalCost => marginal_cost,
1120                _ => unreachable!(),
1121            };
1122
1123            // Costs will be weighted by output (activity * coefficient)
1124            let output_coeff = asset
1125                .get_flow(&commodity_id)
1126                .expect("Commodity should be an output flow for this asset")
1127                .coeff;
1128            let output_weight = activity * output_coeff;
1129
1130            // Accumulate cost for this group, weighted by output with a backup
1131            // potential-output weight.
1132            let key = (
1133                commodity_id.clone(),
1134                region_id.clone(),
1135                time_slice_selection.clone(),
1136            );
1137            primary_accum
1138                .entry(key.clone())
1139                .or_default()
1140                .add(total_cost, output_weight);
1141            backup_accum
1142                .entry(key)
1143                .or_default()
1144                .entry(asset.clone())
1145                .or_default()
1146                .add(total_cost, activity_limit);
1147        }
1148    }
1149
1150    // For each group, finalise weighted averages. If there is no output across the group, use the
1151    // backup weighted average based on potential output (i.e. the upper activity limit) instead,
1152    // taking the min across assets.
1153    primary_accum.into_iter().filter_map(move |(key, primary)| {
1154        let price = primary.finalise().or_else(|| {
1155            backup_accum
1156                .swap_remove(&key)?
1157                .into_values()
1158                .filter_map(WeightedAverageAccumulator::finalise)
1159                .reduce(|a, b| a.min(b))
1160        })?;
1161        Some((key, price))
1162    })
1163}
1164
1165/// Calculate annual activities for each asset by summing across all time slices
1166fn calculate_annual_activities<'a, I>(activities: I) -> HashMap<AssetRef, Activity>
1167where
1168    I: IntoIterator<Item = (&'a AssetRef, &'a TimeSliceID, Activity)>,
1169{
1170    activities
1171        .into_iter()
1172        .map(|(asset, _ts, activity)| (asset.clone(), activity))
1173        .fold(HashMap::new(), |mut acc, (asset, activity)| {
1174            acc.entry(asset)
1175                .and_modify(|e| *e += activity)
1176                .or_insert(activity);
1177            acc
1178        })
1179}
1180
1181/// Calculate full cost prices for a set of commodities and add to an existing prices map.
1182///
1183/// This pricing strategy aims to incorporate the full cost of commodity production into the price.
1184///
1185/// For a given asset, a full cost can be calculated for each SED/SVD output, which is the sum of:
1186/// - Annual capital costs/fixed operating costs: Calculated based on the capacity of the asset
1187///   and the total annual output. If an asset has multiple SED/SVD outputs, then these costs are
1188///   shared equally over all outputs according to their flow coefficients.
1189/// - Generic activity costs: Activity-related costs not tied to a specific SED/SVD output
1190///   (variable operating costs, cost of purchasing inputs, plus all levies and flow costs not
1191///   associated with specific SED/SVD outputs). As above, these are shared over all SED/SVD
1192///   outputs according to their flow coefficients.
1193/// - Commodity-specific activity costs: flow costs/levies for the specific SED/SVD output.
1194///
1195/// ---
1196///
1197/// For example, consider an asset A(SED) -> B(SED) + 2C(SED) + D(OTH), with the following costs:
1198/// - Annual capital cost + fixed operating cost: 2.5 per unit capacity
1199/// - Variable operating cost: 5 per unit activity
1200/// - Production levy on C: 3 per unit flow
1201/// - Production levy on D: 4 per unit flow
1202/// - Price of A: 1 per unit flow
1203///
1204/// If capacity is 4 and annual activity is 2:
1205/// - Annual capital + fixed operating cost per activity = (2.5 * 4) / 2 = 5
1206/// - Annual capital + fixed operating cost per SED/SVD output = 5 / (1 + 2) = 1.666
1207/// - Generic activity cost per activity = (1 + 5 + 4) = 10
1208/// - Generic activity cost per SED/SVD output = 10 / (1 + 2) = 3.333
1209/// - Full cost of B = 1.666 + 3.333 = 5.0
1210/// - Full cost of C = 1.666 + 3.333 + 3 = 8.0
1211///
1212/// ---
1213///
1214/// For each region, the price in each time slice is taken from the installed asset with the highest
1215/// full cost (excluding assets with zero annual activity, as the full cost of these as calculated
1216/// above would be infinite). If there are no producers of the commodity in that region (in
1217/// particular, this may occur when there's no demand for the commodity), then candidate assets are
1218/// considered: we take the price from the candidate asset with the _lowest_ full cost, assuming
1219/// maximum possible dispatch (i.e. the single candidate asset that would be most competitive if a
1220/// small amount of demand was added).
1221///
1222/// For commodities with seasonal/annual time slice levels, costs are weighted by activity (or the
1223/// maximum potential activity for candidates) to get a time slice-weighted average cost for each
1224/// asset, before taking the max across assets. Consequently, the price of these commodities is
1225/// flat within each season/year.
1226///
1227/// # Arguments
1228///
1229/// * `activity_for_existing` - Iterator over `(asset, time_slice, activity)` from optimisation
1230///   solution for existing assets
1231/// * `activity_keys_for_candidates` - Iterator over `(asset, time_slice)` for candidate assets
1232/// * `market_prices` - Existing prices to use as inputs and extend. This is expected to include
1233///   prices from all markets upstream of the markets we are calculating for.
1234/// * `year` - The year for which prices are being calculated
1235/// * `markets_to_price` - Set of markets to calculate full cost prices for
1236/// * `commodities` - Map of all commodities (used to look up each commodity's `time_slice_level`)
1237/// * `time_slice_info` - Time slice information (used to expand groups to individual time slices)
1238#[allow(clippy::too_many_arguments)]
1239fn add_full_cost_prices<'a, I, J>(
1240    activity_for_existing: I,
1241    activity_keys_for_candidates: J,
1242    annual_activities: &HashMap<AssetRef, Activity>,
1243    market_prices: &mut PriceMap,
1244    year: u32,
1245    markets_to_price: &HashSet<(CommodityID, RegionID)>,
1246    commodities: &CommodityMap,
1247    time_slice_info: &TimeSliceInfo,
1248) where
1249    I: Iterator<Item = (&'a AssetRef, &'a TimeSliceID, Activity)>,
1250    J: Iterator<Item = (&'a AssetRef, &'a TimeSliceID)>,
1251{
1252    // Calculate full cost prices from existing assets
1253    let mut group_prices: IndexMap<_, _> = iter_existing_asset_max_prices(
1254        activity_for_existing,
1255        markets_to_price,
1256        market_prices,
1257        year,
1258        commodities,
1259        &PricingStrategy::FullCost,
1260        Some(annual_activities),
1261    )
1262    .collect();
1263    let priced_groups: HashSet<_> = group_prices.keys().cloned().collect();
1264
1265    // Calculate full cost prices from candidate assets, skipping any groups already covered by
1266    // existing assets
1267    let cand_group_prices = iter_candidate_asset_min_prices(
1268        activity_keys_for_candidates,
1269        markets_to_price,
1270        market_prices,
1271        &priced_groups,
1272        year,
1273        commodities,
1274        &PricingStrategy::FullCost,
1275    );
1276
1277    // Merge existing and candidate group prices
1278    group_prices.extend(cand_group_prices);
1279
1280    // Expand selection-level prices to individual time slices and add to the main prices map
1281    market_prices.extend_selection_prices(&group_prices, time_slice_info);
1282}
1283
1284/// Calculate full cost prices for a set of commodities using a load-weighted average across
1285/// assets and add to an existing prices map.
1286///
1287/// Similar to `add_full_cost_prices`, but takes a weighted average across assets
1288/// according to output rather than taking the max.
1289///
1290/// Candidate assets are treated the same way as in `add_full_cost_prices` (i.e. take the min
1291/// across candidate assets).
1292#[allow(clippy::too_many_arguments)]
1293fn add_full_cost_average_prices<'a, I, J>(
1294    activity_for_existing: I,
1295    activity_keys_for_candidates: J,
1296    annual_activities: &HashMap<AssetRef, Activity>,
1297    market_prices: &mut PriceMap,
1298    year: u32,
1299    markets_to_price: &HashSet<(CommodityID, RegionID)>,
1300    commodities: &CommodityMap,
1301    time_slice_info: &TimeSliceInfo,
1302) where
1303    I: Iterator<Item = (&'a AssetRef, &'a TimeSliceID, Activity)>,
1304    J: Iterator<Item = (&'a AssetRef, &'a TimeSliceID)>,
1305{
1306    // Calculate full cost prices from existing assets
1307    let mut group_prices: IndexMap<_, _> = iter_existing_asset_average_prices(
1308        activity_for_existing,
1309        markets_to_price,
1310        market_prices,
1311        year,
1312        commodities,
1313        &PricingStrategy::FullCost,
1314        Some(annual_activities),
1315    )
1316    .collect();
1317    let priced_groups: HashSet<_> = group_prices.keys().cloned().collect();
1318
1319    // Calculate full cost prices from candidate assets, skipping any groups already covered by existing assets
1320    let cand_group_prices = iter_candidate_asset_min_prices(
1321        activity_keys_for_candidates,
1322        markets_to_price,
1323        market_prices,
1324        &priced_groups,
1325        year,
1326        commodities,
1327        &PricingStrategy::FullCost,
1328    );
1329
1330    // Merge existing and candidate group prices
1331    group_prices.extend(cand_group_prices);
1332
1333    // Expand selection-level prices to individual time slices and add to the main prices map
1334    market_prices.extend_selection_prices(&group_prices, time_slice_info);
1335}
1336
1337#[cfg(test)]
1338mod tests {
1339    use super::*;
1340    use crate::asset::Asset;
1341    use crate::asset::AssetRef;
1342    use crate::commodity::{Commodity, CommodityID, CommodityMap};
1343    use crate::fixture::{
1344        commodity_id, other_commodity, region_id, sed_commodity, time_slice, time_slice_info,
1345    };
1346    use crate::process::{ActivityLimits, FlowType, Process, ProcessFlow, ProcessParameter};
1347    use crate::region::RegionID;
1348    use crate::time_slice::TimeSliceID;
1349    use crate::units::ActivityPerCapacity;
1350    use crate::units::{
1351        Activity, Capacity, Dimensionless, FlowPerActivity, MoneyPerActivity, MoneyPerCapacity,
1352        MoneyPerCapacityPerYear, MoneyPerFlow,
1353    };
1354    use float_cmp::assert_approx_eq;
1355    use indexmap::{IndexMap, IndexSet};
1356    use rstest::rstest;
1357    use std::collections::{HashMap, HashSet};
1358    use std::rc::Rc;
1359
1360    fn build_process_flow(commodity: &Commodity, coeff: f64, cost: MoneyPerFlow) -> ProcessFlow {
1361        ProcessFlow {
1362            commodity: Rc::new(commodity.clone()),
1363            coeff: FlowPerActivity(coeff),
1364            kind: FlowType::Fixed,
1365            cost,
1366        }
1367    }
1368
1369    #[allow(clippy::too_many_arguments)]
1370    fn build_process(
1371        flows: IndexMap<CommodityID, ProcessFlow>,
1372        region_id: &RegionID,
1373        year: u32,
1374        time_slice_info: &TimeSliceInfo,
1375        variable_operating_cost: MoneyPerActivity,
1376        fixed_operating_cost: MoneyPerCapacityPerYear,
1377        capital_cost: MoneyPerCapacity,
1378        lifetime: u32,
1379        discount_rate: Dimensionless,
1380    ) -> Process {
1381        let mut process_flows_map = HashMap::new();
1382        process_flows_map.insert((region_id.clone(), year), Rc::new(flows));
1383
1384        let mut process_parameter_map = HashMap::new();
1385        let proc_param = ProcessParameter {
1386            capital_cost,
1387            fixed_operating_cost,
1388            variable_operating_cost,
1389            lifetime,
1390            discount_rate,
1391        };
1392        process_parameter_map.insert((region_id.clone(), year), Rc::new(proc_param));
1393
1394        let mut activity_limits_map = HashMap::new();
1395        activity_limits_map.insert(
1396            (region_id.clone(), year),
1397            Rc::new(ActivityLimits::new_with_full_availability(time_slice_info)),
1398        );
1399
1400        let regions: IndexSet<RegionID> = IndexSet::from([region_id.clone()]);
1401
1402        Process {
1403            id: "p1".into(),
1404            description: "test process".into(),
1405            years: 2010..=2020,
1406            activity_limits: activity_limits_map,
1407            flows: process_flows_map,
1408            parameters: process_parameter_map,
1409            regions,
1410            primary_output: None,
1411            capacity_to_activity: ActivityPerCapacity(1.0),
1412            investment_constraints: HashMap::new(),
1413            unit_size: None,
1414        }
1415    }
1416
1417    fn assert_price_approx(
1418        prices: &PriceMap,
1419        commodity: &CommodityID,
1420        region: &RegionID,
1421        time_slice: &TimeSliceID,
1422        expected: MoneyPerFlow,
1423    ) {
1424        let p = prices.get(commodity, region, time_slice).unwrap();
1425        assert_approx_eq!(MoneyPerFlow, p, expected);
1426    }
1427
1428    #[rstest]
1429    #[case(MoneyPerFlow(100.0), MoneyPerFlow(100.0), Dimensionless(0.0), true)] // exactly equal
1430    #[case(MoneyPerFlow(100.0), MoneyPerFlow(105.0), Dimensionless(0.1), true)] // within tolerance
1431    #[case(MoneyPerFlow(-100.0), MoneyPerFlow(-105.0), Dimensionless(0.1), true)] // within tolerance, both negative
1432    #[case(MoneyPerFlow(0.0), MoneyPerFlow(0.0), Dimensionless(0.1), true)] // both zero
1433    #[case(MoneyPerFlow(100.0), MoneyPerFlow(105.0), Dimensionless(0.01), false)] // difference bigger than tolerance
1434    #[case(MoneyPerFlow(100.0), MoneyPerFlow(-105.0), Dimensionless(0.1), false)] // comparing positive and negative prices
1435    #[case(MoneyPerFlow(0.0), MoneyPerFlow(10.0), Dimensionless(0.1), false)] // comparing zero and positive
1436    #[case(MoneyPerFlow(0.0), MoneyPerFlow(-10.0), Dimensionless(0.1), false)] // comparing zero and negative
1437    #[case(MoneyPerFlow(10.0), MoneyPerFlow(0.0), Dimensionless(0.1), false)] // comparing positive and zero
1438    #[case(MoneyPerFlow(-10.0), MoneyPerFlow(0.0), Dimensionless(0.1), false)] // comparing negative and zero
1439    fn within_tolerance_scenarios(
1440        #[case] price1: MoneyPerFlow,
1441        #[case] price2: MoneyPerFlow,
1442        #[case] tolerance: Dimensionless,
1443        #[case] expected: bool,
1444        time_slice_info: TimeSliceInfo,
1445        time_slice: TimeSliceID,
1446    ) {
1447        let mut prices1 = PriceMap::default();
1448        let mut prices2 = PriceMap::default();
1449
1450        // Set up two price sets for a single commodity/region/time slice
1451        let commodity = CommodityID::new("test_commodity");
1452        let region = RegionID::new("test_region");
1453        prices1.insert(&commodity, &region, &time_slice, price1);
1454        prices2.insert(&commodity, &region, &time_slice, price2);
1455
1456        assert_eq!(
1457            prices1.within_tolerance_weighted(&prices2, tolerance, &time_slice_info),
1458            expected
1459        );
1460    }
1461
1462    #[rstest]
1463    fn time_slice_weighted_averages(
1464        commodity_id: CommodityID,
1465        region_id: RegionID,
1466        time_slice_info: TimeSliceInfo,
1467        time_slice: TimeSliceID,
1468    ) {
1469        let mut prices = PriceMap::default();
1470
1471        // Insert a price
1472        prices.insert(&commodity_id, &region_id, &time_slice, MoneyPerFlow(100.0));
1473
1474        let averages = prices.time_slice_weighted_averages(&time_slice_info);
1475
1476        // With single time slice (duration=1.0), average should equal the price
1477        assert_eq!(averages[&(commodity_id, region_id)], MoneyPerFlow(100.0));
1478    }
1479
1480    #[rstest]
1481    fn marginal_cost_example(
1482        sed_commodity: Commodity,
1483        other_commodity: Commodity,
1484        region_id: RegionID,
1485        time_slice_info: TimeSliceInfo,
1486        time_slice: TimeSliceID,
1487    ) {
1488        // Use the same setup as in the docstring example for marginal cost pricing
1489        let mut a = sed_commodity.clone();
1490        a.id = "A".into();
1491        let mut b = sed_commodity.clone();
1492        b.id = "B".into();
1493        let mut c = sed_commodity.clone();
1494        c.id = "C".into();
1495        let mut d = other_commodity.clone();
1496        d.id = "D".into();
1497
1498        let mut flows = IndexMap::new();
1499        flows.insert(
1500            a.id.clone(),
1501            build_process_flow(&a, -1.0, MoneyPerFlow(0.0)),
1502        );
1503        flows.insert(b.id.clone(), build_process_flow(&b, 1.0, MoneyPerFlow(0.0)));
1504        flows.insert(c.id.clone(), build_process_flow(&c, 2.0, MoneyPerFlow(3.0)));
1505        flows.insert(d.id.clone(), build_process_flow(&d, 1.0, MoneyPerFlow(4.0)));
1506
1507        let process = build_process(
1508            flows,
1509            &region_id,
1510            2015u32,
1511            &time_slice_info,
1512            MoneyPerActivity(5.0),        // variable operating cost
1513            MoneyPerCapacityPerYear(0.0), // fixed operating cost
1514            MoneyPerCapacity(0.0),        // capital cost
1515            5,                            // lifetime
1516            Dimensionless(1.0),           // discount rate
1517        );
1518
1519        let asset =
1520            Asset::new_candidate(Rc::new(process), region_id.clone(), Capacity(1.0), 2015u32)
1521                .unwrap();
1522        let asset_ref = AssetRef::from(asset);
1523        let mut prices =
1524            PriceMap::from_iter(vec![(&a.id, &region_id, &time_slice, MoneyPerFlow(1.0))]);
1525        let mut markets = HashSet::new();
1526        markets.insert((b.id.clone(), region_id.clone()));
1527        markets.insert((c.id.clone(), region_id.clone()));
1528
1529        let mut commodities = CommodityMap::new();
1530        commodities.insert(b.id.clone(), Rc::new(b.clone()));
1531        commodities.insert(c.id.clone(), Rc::new(c.clone()));
1532
1533        let existing = vec![(&asset_ref, &time_slice, Activity(1.0))];
1534        let candidates = Vec::new();
1535
1536        add_marginal_cost_prices(
1537            existing.into_iter(),
1538            candidates.into_iter(),
1539            &mut prices,
1540            2015u32,
1541            &markets,
1542            &commodities,
1543            &time_slice_info,
1544        );
1545
1546        assert_price_approx(
1547            &prices,
1548            &b.id,
1549            &region_id,
1550            &time_slice,
1551            MoneyPerFlow(10.0 / 3.0),
1552        );
1553        assert_price_approx(
1554            &prices,
1555            &c.id,
1556            &region_id,
1557            &time_slice,
1558            MoneyPerFlow(10.0 / 3.0 + 3.0),
1559        );
1560    }
1561
1562    #[rstest]
1563    fn full_cost_example(
1564        sed_commodity: Commodity,
1565        other_commodity: Commodity,
1566        region_id: RegionID,
1567        time_slice_info: TimeSliceInfo,
1568        time_slice: TimeSliceID,
1569    ) {
1570        // Use the same setup as in the docstring example for full cost pricing
1571        let mut a = sed_commodity.clone();
1572        a.id = "A".into();
1573        let mut b = sed_commodity.clone();
1574        b.id = "B".into();
1575        let mut c = sed_commodity.clone();
1576        c.id = "C".into();
1577        let mut d = other_commodity.clone();
1578        d.id = "D".into();
1579
1580        let mut flows = IndexMap::new();
1581        flows.insert(
1582            a.id.clone(),
1583            build_process_flow(&a, -1.0, MoneyPerFlow(0.0)),
1584        );
1585        flows.insert(b.id.clone(), build_process_flow(&b, 1.0, MoneyPerFlow(0.0)));
1586        flows.insert(c.id.clone(), build_process_flow(&c, 2.0, MoneyPerFlow(3.0)));
1587        flows.insert(d.id.clone(), build_process_flow(&d, 1.0, MoneyPerFlow(4.0)));
1588
1589        let process = build_process(
1590            flows,
1591            &region_id,
1592            2015u32,
1593            &time_slice_info,
1594            MoneyPerActivity(5.0),        // variable operating cost
1595            MoneyPerCapacityPerYear(1.0), //  fixed operating cost
1596            MoneyPerCapacity(1.5),        // capital cost per capacity so annualised=1.5
1597            1,                            // lifetime so annualised = capital_cost
1598            Dimensionless(0.0),           // discount rate
1599        );
1600
1601        let asset =
1602            Asset::new_candidate(Rc::new(process), region_id.clone(), Capacity(4.0), 2015u32)
1603                .unwrap();
1604        let asset_ref = AssetRef::from(asset);
1605        let mut prices =
1606            PriceMap::from_iter(vec![(&a.id, &region_id, &time_slice, MoneyPerFlow(1.0))]);
1607        let mut markets = HashSet::new();
1608        markets.insert((b.id.clone(), region_id.clone()));
1609        markets.insert((c.id.clone(), region_id.clone()));
1610
1611        let mut commodities = CommodityMap::new();
1612        commodities.insert(b.id.clone(), Rc::new(b.clone()));
1613        commodities.insert(c.id.clone(), Rc::new(c.clone()));
1614
1615        let existing = vec![(&asset_ref, &time_slice, Activity(2.0))];
1616        let candidates = Vec::new();
1617
1618        let mut annual_activities = HashMap::new();
1619        annual_activities.insert(asset_ref.clone(), Activity(2.0));
1620
1621        add_full_cost_prices(
1622            existing.into_iter(),
1623            candidates.into_iter(),
1624            &annual_activities,
1625            &mut prices,
1626            2015u32,
1627            &markets,
1628            &commodities,
1629            &time_slice_info,
1630        );
1631
1632        assert_price_approx(&prices, &b.id, &region_id, &time_slice, MoneyPerFlow(5.0));
1633        assert_price_approx(&prices, &c.id, &region_id, &time_slice, MoneyPerFlow(8.0));
1634    }
1635
1636    #[test]
1637    fn weighted_average_accumulator_single_value() {
1638        let mut accum = WeightedAverageAccumulator::<Dimensionless>::default();
1639        accum.add(MoneyPerFlow(100.0), Dimensionless(1.0));
1640        assert_eq!(accum.finalise(), Some(MoneyPerFlow(100.0)));
1641    }
1642
1643    #[test]
1644    fn weighted_average_accumulator_different_weights() {
1645        let mut accum = WeightedAverageAccumulator::<Dimensionless>::default();
1646        accum.add(MoneyPerFlow(100.0), Dimensionless(1.0));
1647        accum.add(MoneyPerFlow(200.0), Dimensionless(2.0));
1648        // (100*1 + 200*2) / (1+2) = 500/3 ≈ 166.667
1649        let result = accum.finalise().unwrap();
1650        assert_approx_eq!(MoneyPerFlow, result, MoneyPerFlow(500.0 / 3.0));
1651    }
1652
1653    #[test]
1654    fn weighted_average_accumulator_zero_weight() {
1655        let accum = WeightedAverageAccumulator::<Dimensionless>::default();
1656        assert_eq!(accum.finalise(), None);
1657    }
1658}