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