Skip to main content

muse2/simulation/
prices.rs

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