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