muse2/simulation/
prices.rs

1//! Code for calculating commodity prices used by the simulation.
2use crate::asset::AssetRef;
3use crate::commodity::{CommodityID, PricingStrategy};
4use crate::model::Model;
5use crate::region::RegionID;
6use crate::simulation::optimisation::Solution;
7use crate::time_slice::{TimeSliceID, TimeSliceInfo, TimeSliceSelection};
8use crate::units::{Activity, Dimensionless, MoneyPerActivity, MoneyPerFlow, Year};
9use anyhow::Result;
10use indexmap::IndexMap;
11use std::collections::{HashMap, HashSet};
12
13/// Calculate commodity prices.
14///
15/// Calculate prices for each commodity/region/time-slice according to the commodity's configured
16/// `PricingStrategy`.
17///
18/// # Arguments
19///
20/// * `model` - The model
21/// * `solution` - Solution to dispatch optimisation
22/// * `year` - The year for which prices are being calculated
23///
24/// # Returns
25///
26/// A `CommodityPrices` mapping `(commodity, region, time_slice)` to `MoneyPerFlow` representing
27/// endogenous prices computed from the optimisation solution.
28pub fn calculate_prices(model: &Model, solution: &Solution, year: u32) -> Result<CommodityPrices> {
29    // Collect shadow prices for all SED/SVD commodities
30    let shadow_prices = CommodityPrices::from_iter(solution.iter_commodity_balance_duals());
31
32    // Set up empty prices map
33    let mut result = CommodityPrices::default();
34
35    // Get investment order for the year - prices will be calculated in the reverse of this order
36    let investment_order = &model.investment_order[&year];
37
38    // Iterate over investment sets in reverse order. Markets within the same set can be priced
39    // simultaneously, since they are independent (apart from Cycle sets when using the "marginal"
40    // and "full" strategies, which get flagged at the validation stage).
41    for investment_set in investment_order.iter().rev() {
42        // Partition markets by pricing strategy into a map keyed by `PricingStrategy`.
43        // For now, commodities use a single strategy for all regions, but this may change in the future.
44        let mut pricing_sets = HashMap::new();
45        for (commodity_id, region_id) in investment_set.iter_markets() {
46            let commodity = &model.commodities[commodity_id];
47            if commodity.pricing_strategy == PricingStrategy::Unpriced {
48                continue;
49            }
50            pricing_sets
51                .entry(&commodity.pricing_strategy)
52                .or_insert_with(HashSet::new)
53                .insert((commodity_id.clone(), region_id.clone()));
54        }
55
56        // Add prices for shadow-priced commodities
57        if let Some(shadow_set) = pricing_sets.get(&PricingStrategy::Shadow) {
58            for (commodity_id, region_id, time_slice) in shadow_prices.keys() {
59                if shadow_set.contains(&(commodity_id.clone(), region_id.clone())) {
60                    let price = shadow_prices
61                        .get(commodity_id, region_id, time_slice)
62                        .unwrap();
63                    result.insert(commodity_id, region_id, time_slice, price);
64                }
65            }
66        }
67
68        // Add prices for scarcity-adjusted commodities
69        if let Some(scarcity_set) = pricing_sets.get(&PricingStrategy::ScarcityAdjusted) {
70            let scarcity_prices = calculate_scarcity_adjusted_prices(
71                solution.iter_activity_duals(),
72                &shadow_prices,
73                scarcity_set,
74            );
75            result.extend(scarcity_prices);
76        }
77
78        // Add prices for marginal cost commodities
79        if let Some(marginal_set) = pricing_sets.get(&PricingStrategy::MarginalCost) {
80            let marginal_cost_prices = calculate_marginal_cost_prices(
81                solution.iter_activity_keys_for_existing(),
82                solution.iter_activity_keys_for_candidates(),
83                &result,
84                year,
85                marginal_set,
86            );
87            result.extend(marginal_cost_prices);
88        }
89
90        // Add prices for full cost commodities
91        if let Some(fullcost_set) = pricing_sets.get(&PricingStrategy::FullCost) {
92            let annual_activities =
93                calculate_annual_activities(solution.iter_activity_for_existing());
94            let full_cost_prices = calculate_full_cost_prices(
95                solution.iter_activity_keys_for_existing(),
96                solution.iter_activity_keys_for_candidates(),
97                &annual_activities,
98                &result,
99                year,
100                fullcost_set,
101            );
102            result.extend(full_cost_prices);
103        }
104    }
105
106    // Return the completed prices map
107    Ok(result)
108}
109
110/// A map relating commodity ID + region + time slice to current price (endogenous)
111#[derive(Default, Clone)]
112pub struct CommodityPrices(IndexMap<(CommodityID, RegionID, TimeSliceID), MoneyPerFlow>);
113
114impl CommodityPrices {
115    /// Insert a price for the given commodity, region and time slice
116    pub fn insert(
117        &mut self,
118        commodity_id: &CommodityID,
119        region_id: &RegionID,
120        time_slice: &TimeSliceID,
121        price: MoneyPerFlow,
122    ) {
123        let key = (commodity_id.clone(), region_id.clone(), time_slice.clone());
124        self.0.insert(key, price);
125    }
126
127    /// Extend the prices map, panic if any key already exists
128    pub fn extend<T>(&mut self, iter: T)
129    where
130        T: IntoIterator<Item = ((CommodityID, RegionID, TimeSliceID), MoneyPerFlow)>,
131    {
132        for (key, price) in iter {
133            let existing = self.0.insert(key.clone(), price).is_some();
134            assert!(!existing, "Key {key:?} already exists in the map");
135        }
136    }
137
138    /// Iterate over the map.
139    ///
140    /// # Returns
141    ///
142    /// An iterator of tuples containing commodity ID, region ID, time slice and price.
143    pub fn iter(
144        &self,
145    ) -> impl Iterator<Item = (&CommodityID, &RegionID, &TimeSliceID, MoneyPerFlow)> {
146        self.0
147            .iter()
148            .map(|((commodity_id, region_id, ts), price)| (commodity_id, region_id, ts, *price))
149    }
150
151    /// Get the price for the specified commodity for a given region and time slice
152    pub fn get(
153        &self,
154        commodity_id: &CommodityID,
155        region_id: &RegionID,
156        time_slice: &TimeSliceID,
157    ) -> Option<MoneyPerFlow> {
158        self.0
159            .get(&(commodity_id.clone(), region_id.clone(), time_slice.clone()))
160            .copied()
161    }
162
163    /// Iterate over the price map's keys
164    pub fn keys(
165        &self,
166    ) -> indexmap::map::Keys<'_, (CommodityID, RegionID, TimeSliceID), MoneyPerFlow> {
167        self.0.keys()
168    }
169
170    /// Calculate time slice-weighted average prices for each commodity-region pair
171    ///
172    /// This method aggregates prices across time slices by weighting each price
173    /// by the duration of its time slice, providing a more representative annual average.
174    ///
175    /// Note: this assumes that all time slices are present for each commodity-region pair, and
176    /// that all time slice lengths sum to 1. This is not checked by this method.
177    fn time_slice_weighted_averages(
178        &self,
179        time_slice_info: &TimeSliceInfo,
180    ) -> HashMap<(CommodityID, RegionID), MoneyPerFlow> {
181        let mut weighted_prices = HashMap::new();
182
183        for ((commodity_id, region_id, time_slice_id), price) in &self.0 {
184            // NB: Time slice fractions will sum to one
185            let weight = time_slice_info.time_slices[time_slice_id] / Year(1.0);
186            let key = (commodity_id.clone(), region_id.clone());
187            weighted_prices
188                .entry(key)
189                .and_modify(|v| *v += *price * weight)
190                .or_insert_with(|| *price * weight);
191        }
192
193        weighted_prices
194    }
195
196    /// Check if time slice-weighted average prices are within relative tolerance of another price
197    /// set.
198    ///
199    /// This method calculates time slice-weighted average prices for each commodity-region pair
200    /// and compares them. Both objects must have exactly the same set of commodity-region pairs,
201    /// otherwise it will panic.
202    ///
203    /// Additionally, this method assumes that all time slices are present for each commodity-region
204    /// pair, and that all time slice lengths sum to 1. This is not checked by this method.
205    pub fn within_tolerance_weighted(
206        &self,
207        other: &Self,
208        tolerance: Dimensionless,
209        time_slice_info: &TimeSliceInfo,
210    ) -> bool {
211        let self_averages = self.time_slice_weighted_averages(time_slice_info);
212        let other_averages = other.time_slice_weighted_averages(time_slice_info);
213
214        for (key, &price) in &self_averages {
215            let other_price = other_averages[key];
216            let abs_diff = (price - other_price).abs();
217
218            // Special case: last price was zero
219            if price == MoneyPerFlow(0.0) {
220                // Current price is zero but other price is nonzero
221                if other_price != MoneyPerFlow(0.0) {
222                    return false;
223                }
224            // Check if price is within tolerance
225            } else if abs_diff / price.abs() > tolerance {
226                return false;
227            }
228        }
229        true
230    }
231}
232
233impl<'a> FromIterator<(&'a CommodityID, &'a RegionID, &'a TimeSliceID, MoneyPerFlow)>
234    for CommodityPrices
235{
236    fn from_iter<I>(iter: I) -> Self
237    where
238        I: IntoIterator<Item = (&'a CommodityID, &'a RegionID, &'a TimeSliceID, MoneyPerFlow)>,
239    {
240        let map = iter
241            .into_iter()
242            .map(|(commodity_id, region_id, time_slice, price)| {
243                (
244                    (commodity_id.clone(), region_id.clone(), time_slice.clone()),
245                    price,
246                )
247            })
248            .collect();
249        CommodityPrices(map)
250    }
251}
252
253impl IntoIterator for CommodityPrices {
254    type Item = ((CommodityID, RegionID, TimeSliceID), MoneyPerFlow);
255    type IntoIter = indexmap::map::IntoIter<(CommodityID, RegionID, TimeSliceID), MoneyPerFlow>;
256
257    fn into_iter(self) -> Self::IntoIter {
258        self.0.into_iter()
259    }
260}
261
262/// Calculate scarcity-adjusted prices for a set of commodities.
263///
264/// # Arguments
265///
266/// * `activity_duals` - Iterator over activity duals from optimisation solution
267/// * `shadow_prices` - Shadow prices for all commodities
268/// * `markets_to_price` - Set of markets to calculate scarcity-adjusted prices for
269///
270/// # Returns
271///
272/// A map of scarcity-adjusted prices for the specified markets in all time slices
273fn calculate_scarcity_adjusted_prices<'a, I>(
274    activity_duals: I,
275    shadow_prices: &CommodityPrices,
276    markets_to_price: &HashSet<(CommodityID, RegionID)>,
277) -> HashMap<(CommodityID, RegionID, TimeSliceID), MoneyPerFlow>
278where
279    I: Iterator<Item = (&'a AssetRef, &'a TimeSliceID, MoneyPerActivity)>,
280{
281    // Calculate highest activity dual for each commodity/region/time slice
282    let mut highest_duals = HashMap::new();
283    for (asset, time_slice, dual) in activity_duals {
284        let region_id = asset.region_id();
285
286        // Iterate over the output flows of this asset
287        // Only consider flows for commodities we are pricing
288        for flow in asset.iter_output_flows().filter(|flow| {
289            markets_to_price.contains(&(flow.commodity.id.clone(), region_id.clone()))
290        }) {
291            // Update the highest dual for this commodity/time slice
292            highest_duals
293                .entry((
294                    flow.commodity.id.clone(),
295                    region_id.clone(),
296                    time_slice.clone(),
297                ))
298                .and_modify(|current_dual| {
299                    if dual > *current_dual {
300                        *current_dual = dual;
301                    }
302                })
303                .or_insert(dual);
304        }
305    }
306
307    // Add this to the shadow price for each commodity/region/time slice
308    let mut scarcity_prices = HashMap::new();
309    for ((commodity, region, time_slice), highest_dual) in &highest_duals {
310        // There should always be a shadow price for commodities we are considering here, so it
311        // should be safe to unwrap
312        let shadow_price = shadow_prices.get(commodity, region, time_slice).unwrap();
313        // highest_dual is in units of MoneyPerActivity, and shadow_price is in MoneyPerFlow, but
314        // this is correct according to Adam
315        let scarcity_price = shadow_price + MoneyPerFlow(highest_dual.value());
316        scarcity_prices.insert(
317            (commodity.clone(), region.clone(), time_slice.clone()),
318            scarcity_price,
319        );
320    }
321
322    scarcity_prices
323}
324
325/// Calculate marginal cost prices for a set of commodities.
326///
327/// This pricing strategy aims to incorporate the marginal cost of commodity production into the price.
328///
329/// For a given asset, a marginal cost can be calculated for each SED/SVD output, which is the sum of:
330/// - Generic activity costs: Activity-related costs not tied to a specific SED/SVD output
331///   (variable operating costs, cost of purchasing inputs, plus all levies and flow costs not
332///   associated with specific SED/SVD outputs). These are shared over all SED/SVD
333///   outputs according to their flow coefficients.
334/// - Commodity-specific activity costs: flow costs/levies for the specific SED/SVD output.
335///
336/// ---
337///
338/// For example, consider an asset A(SED) -> B(SED) + 2C(SED) + D(OTH), with the following costs:
339/// - Variable operating cost: 5 per unit activity
340/// - Production levy on C: 3 per unit flow
341/// - Production levy on D: 4 per unit flow
342/// - Shadow price of A: 1 per unit flow
343///
344/// Then:
345/// - Generic activity cost per activity = (1 + 5 + 4) = 10
346/// - Generic activity cost per SED/SVD output = 10 / (1 + 2) = 3.333
347/// - Marginal cost of B = 3.333
348/// - Marginal cost of C = 3.333 + 3 = 6.333
349///
350/// ---
351///
352/// For each region, the price in each time slice is taken from the installed asset with the highest
353/// marginal cost. If there are no producers of the commodity in that region (in particular, this
354/// may occur when there's no demand for the commodity), then candidate assets are considered: we
355/// take the price from the candidate asset with the _lowest_ marginal cost, assuming full
356/// utilisation (i.e. the single candidate asset that would be most competitive if a small amount of
357/// demand was added).
358///
359/// Note: this should be similar to the "shadow price" strategy, which is also based on marginal
360/// costs of the most expensive producer, but may be more successful in cases where there are
361/// multiple SED/SVD outputs per asset.
362///
363/// # Arguments
364///
365/// * `activity_keys_for_existing` - Iterator over activity keys from optimisation solution for
366///   existing assets
367/// * `activity_keys_for_candidates` - Iterator over activity keys from optimisation solution for
368///   candidate assets
369/// * `upstream_prices` - Prices for commodities upstream of the ones we are calculating prices for
370/// * `year` - The year for which prices are being calculated
371/// * `markets_to_price` - Set of markets to calculate marginal prices for
372///
373/// # Returns
374///
375/// A map of marginal cost prices for the specified markets in all time slices
376fn calculate_marginal_cost_prices<'a, I, J>(
377    activity_keys_for_existing: I,
378    activity_keys_for_candidates: J,
379    upstream_prices: &CommodityPrices,
380    year: u32,
381    markets_to_price: &HashSet<(CommodityID, RegionID)>,
382) -> HashMap<(CommodityID, RegionID, TimeSliceID), MoneyPerFlow>
383where
384    I: Iterator<Item = (&'a AssetRef, &'a TimeSliceID)>,
385    J: Iterator<Item = (&'a AssetRef, &'a TimeSliceID)>,
386{
387    let mut prices: HashMap<(CommodityID, RegionID, TimeSliceID), MoneyPerFlow> = HashMap::new();
388
389    // Start by looking at existing assets
390    // Calculate highest marginal cost for each commodity/region/time slice
391    // Keep track of keys with prices - missing keys will be handled by candidates later
392    let mut priced_by_existing = HashSet::new();
393    for (asset, time_slice) in activity_keys_for_existing {
394        let region_id = asset.region_id();
395
396        // Iterate over all the SED/SVD marginal costs for commodities we need prices for
397        for (commodity_id, marginal_cost) in asset.iter_marginal_costs_with_filter(
398            upstream_prices,
399            year,
400            time_slice,
401            |commodity_id: &CommodityID| {
402                markets_to_price.contains(&(commodity_id.clone(), region_id.clone()))
403            },
404        ) {
405            // Update the highest cost for this commodity/region/time slice
406            let key = (commodity_id.clone(), region_id.clone(), time_slice.clone());
407            prices
408                .entry(key.clone())
409                .and_modify(|c| *c = c.max(marginal_cost))
410                .or_insert(marginal_cost);
411            priced_by_existing.insert(key);
412        }
413    }
414
415    // Next, look at candidate assets for any markets not covered by existing assets
416    // For these, we take the _lowest_ marginal cost
417    for (asset, time_slice) in activity_keys_for_candidates {
418        let region_id = asset.region_id();
419
420        // Only consider markets not already priced by existing assets
421        let should_process = |cid: &CommodityID| {
422            markets_to_price.contains(&(cid.clone(), region_id.clone()))
423                && !priced_by_existing.contains(&(
424                    cid.clone(),
425                    region_id.clone(),
426                    time_slice.clone(),
427                ))
428        };
429
430        // Iterate over all the SED/SVD marginal costs for markets we need prices for
431        for (commodity_id, marginal_cost) in asset.iter_marginal_costs_with_filter(
432            upstream_prices,
433            year,
434            time_slice,
435            |cid: &CommodityID| should_process(cid),
436        ) {
437            // Update the _lowest_ cost for this commodity/region/time slice
438            let key = (commodity_id.clone(), region_id.clone(), time_slice.clone());
439            prices
440                .entry(key.clone())
441                .and_modify(|c| *c = c.min(marginal_cost))
442                .or_insert(marginal_cost);
443        }
444    }
445
446    // Return the calculated marginal prices
447    prices
448}
449
450/// Calculate annual activities for each asset by summing across all time slices
451fn calculate_annual_activities<'a, I>(activities: I) -> HashMap<AssetRef, Activity>
452where
453    I: IntoIterator<Item = (&'a AssetRef, &'a TimeSliceID, Activity)>,
454{
455    activities
456        .into_iter()
457        .map(|(asset, _ts, activity)| (asset.clone(), activity))
458        .fold(HashMap::new(), |mut acc, (asset, activity)| {
459            acc.entry(asset)
460                .and_modify(|e| *e += activity)
461                .or_insert(activity);
462            acc
463        })
464}
465
466/// Calculate full cost prices for a set of commodities.
467///
468/// This pricing strategy aims to incorporate the full cost of commodity production into the price.
469///
470/// For a given asset, a full cost can be calculated for each SED/SVD output, which is the sum of:
471/// - Annual capital costs/fixed operating costs: Calculated based on the capacity of the asset
472///   and the total annual output. If an asset has multiple SED/SVD outputs, then these costs are
473///   shared equally over all outputs according to their flow coefficients.
474/// - Generic activity costs: Activity-related costs not tied to a specific SED/SVD output
475///   (variable operating costs, cost of purchasing inputs, plus all levies and flow costs not
476///   associated with specific SED/SVD outputs). As above, these are shared over all SED/SVD
477///   outputs according to their flow coefficients.
478/// - Commodity-specific activity costs: flow costs/levies for the specific SED/SVD output.
479///
480/// ---
481///
482/// For example, consider an asset A(SED) -> B(SED) + 2C(SED) + D(OTH), with the following costs:
483/// - Annual capital cost + fixed operating cost: 2.5 per unit capacity
484/// - Variable operating cost: 5 per unit activity
485/// - Production levy on C: 3 per unit flow
486/// - Production levy on D: 4 per unit flow
487/// - Shadow price of A: 1 per unit flow
488///
489/// If capacity is 4 and annual activity is 2:
490/// - Annual capital + fixed operating cost per activity = (2.5 * 4) / 2 = 5
491/// - Annual capital + fixed operating cost per SED/SVD output = 5 / (1 + 2) = 1.666
492/// - Generic activity cost per activity = (1 + 5 + 4) = 10
493/// - Generic activity cost per SED/SVD output = 10 / (1 + 2) = 3.333
494/// - Full cost of B = 1.666 + 3.333 = 5.0
495/// - Full cost of C = 1.666 + 3.333 + 3 = 8.0
496///
497/// ---
498///
499/// For each region, the price in each time slice is taken from the installed asset with the highest
500/// full cost (excluding assets with zero annual activity, as the full cost of these as calculated
501/// above would be infinite). If there are no producers of the commodity in that region (in
502/// particular, this may occur when there's no demand for the commodity), then candidate assets are
503/// considered: we take the price from the candidate asset with the _lowest_ full cost, assuming
504/// maximum possible dispatch (i.e. the single candidate asset that would be most competitive if a
505/// small amount of demand was added).
506///
507/// # Arguments
508///
509/// * `activity_keys_for_existing` - Iterator over activity keys from optimisation solution for
510///   existing assets
511/// * `activity_keys_for_candidates` - Iterator over activity keys from optimisation solution for
512///   candidate assets
513/// * `annual_activities` - Map of annual activities for each asset computed by
514///   `calculate_annual_activities`. This only needs to include existing assets.
515/// * `upstream_prices` - Prices for commodities upstream of the ones we are calculating prices for
516/// * `year` - The year for which prices are being calculated
517/// * `markets_to_price` - Set of markets to calculate full cost prices for
518///
519/// # Returns
520///
521/// A map of full cost prices for the specified markets in all time slices
522fn calculate_full_cost_prices<'a, I, J>(
523    activity_keys_for_existing: I,
524    activity_keys_for_candidates: J,
525    annual_activities: &HashMap<AssetRef, Activity>,
526    upstream_prices: &CommodityPrices,
527    year: u32,
528    markets_to_price: &HashSet<(CommodityID, RegionID)>,
529) -> HashMap<(CommodityID, RegionID, TimeSliceID), MoneyPerFlow>
530where
531    I: Iterator<Item = (&'a AssetRef, &'a TimeSliceID)>,
532    J: Iterator<Item = (&'a AssetRef, &'a TimeSliceID)>,
533{
534    let mut prices: HashMap<(CommodityID, RegionID, TimeSliceID), MoneyPerFlow> = HashMap::new();
535
536    // Start by looking at existing assets
537    // Calculate highest full cost for each commodity/region/time slice
538    // Keep track of keys with prices - missing keys will be handled by candidates later
539    let mut annual_fixed_costs_cache = HashMap::new();
540    let mut priced_by_existing = HashSet::new();
541    for (asset, time_slice) in activity_keys_for_existing {
542        let annual_activity = annual_activities[asset];
543        let region_id = asset.region_id();
544
545        // If annual activity is zero, we can't calculate a capital cost per flow, so skip this
546        // asset.
547        if annual_activity < Activity::EPSILON {
548            continue;
549        }
550
551        // Only proceed if the asset produces at least one commodity we need prices for
552        if !asset
553            .iter_output_flows()
554            .any(|flow| markets_to_price.contains(&(flow.commodity.id.clone(), region_id.clone())))
555        {
556            continue;
557        }
558
559        // Calculate/cache annual fixed costs for this asset
560        let annual_fixed_costs_per_flow = *annual_fixed_costs_cache
561            .entry(asset.clone())
562            .or_insert_with(|| asset.get_annual_fixed_costs_per_flow(annual_activity));
563
564        // Iterate over all the SED/SVD marginal costs for commodities we need prices for
565        for (commodity_id, marginal_cost) in asset.iter_marginal_costs_with_filter(
566            upstream_prices,
567            year,
568            time_slice,
569            |cid: &CommodityID| markets_to_price.contains(&(cid.clone(), region_id.clone())),
570        ) {
571            // Add annual fixed costs per flow to marginal cost to get full cost
572            let marginal_cost = marginal_cost + annual_fixed_costs_per_flow;
573
574            // Update the highest cost for this commodity/region/time slice
575            let key = (commodity_id.clone(), region_id.clone(), time_slice.clone());
576            prices
577                .entry(key.clone())
578                .and_modify(|c| *c = c.max(marginal_cost))
579                .or_insert(marginal_cost);
580            priced_by_existing.insert(key);
581        }
582    }
583
584    // Next, look at candidate assets for any markets not covered by existing assets
585    // For these we assume full utilisation, and take the _lowest_ full cost
586    for (asset, time_slice) in activity_keys_for_candidates {
587        let region_id = asset.region_id();
588
589        // Only consider markets not already priced by existing assets
590        let should_process = |cid: &CommodityID| {
591            markets_to_price.contains(&(cid.clone(), region_id.clone()))
592                && !priced_by_existing.contains(&(
593                    cid.clone(),
594                    region_id.clone(),
595                    time_slice.clone(),
596                ))
597        };
598
599        // Only proceed if the asset produces at least one commodity we need prices for
600        if !asset
601            .iter_output_flows()
602            .any(|flow| should_process(&flow.commodity.id))
603        {
604            continue;
605        }
606
607        // Calculate/cache annual fixed cost per flow for this asset assuming full dispatch
608        // (bound by the activity limits of the asset)
609        let annual_fixed_costs_per_flow = *annual_fixed_costs_cache
610            .entry(asset.clone())
611            .or_insert_with(|| {
612                asset.get_annual_fixed_costs_per_flow(
613                    *asset
614                        .get_activity_limits_for_selection(&TimeSliceSelection::Annual)
615                        .end(),
616                )
617            });
618
619        // Iterate over all the SED/SVD marginal costs for markets we need prices for
620        for (commodity_id, marginal_cost) in asset.iter_marginal_costs_with_filter(
621            upstream_prices,
622            year,
623            time_slice,
624            |cid: &CommodityID| should_process(cid),
625        ) {
626            // Add annual fixed costs per flow to marginal cost to get full cost
627            let full_cost = marginal_cost + annual_fixed_costs_per_flow;
628
629            // Update the _lowest_ cost for this commodity/region/time slice
630            let key = (commodity_id.clone(), region_id.clone(), time_slice.clone());
631            prices
632                .entry(key)
633                .and_modify(|c| *c = c.min(full_cost))
634                .or_insert(full_cost);
635        }
636    }
637
638    // Return the calculated full cost prices
639    prices
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645    use crate::asset::Asset;
646    use crate::asset::AssetRef;
647    use crate::commodity::{Commodity, CommodityID};
648    use crate::fixture::{
649        commodity_id, other_commodity, region_id, sed_commodity, time_slice, time_slice_info,
650    };
651    use crate::process::{ActivityLimits, FlowType, Process, ProcessFlow, ProcessParameter};
652    use crate::region::RegionID;
653    use crate::time_slice::TimeSliceID;
654    use crate::units::ActivityPerCapacity;
655    use crate::units::{
656        Activity, Capacity, Dimensionless, FlowPerActivity, MoneyPerActivity, MoneyPerCapacity,
657        MoneyPerCapacityPerYear, MoneyPerFlow,
658    };
659    use indexmap::{IndexMap, IndexSet};
660    use rstest::rstest;
661    use std::collections::{HashMap, HashSet};
662    use std::rc::Rc;
663
664    fn build_process_flow(commodity: &Commodity, coeff: f64, cost: MoneyPerFlow) -> ProcessFlow {
665        ProcessFlow {
666            commodity: Rc::new(commodity.clone()),
667            coeff: FlowPerActivity(coeff),
668            kind: FlowType::Fixed,
669            cost,
670        }
671    }
672
673    #[allow(clippy::too_many_arguments)]
674    fn build_process(
675        flows: IndexMap<CommodityID, ProcessFlow>,
676        region_id: &RegionID,
677        year: u32,
678        time_slice_info: &TimeSliceInfo,
679        variable_operating_cost: MoneyPerActivity,
680        fixed_operating_cost: MoneyPerCapacityPerYear,
681        capital_cost: MoneyPerCapacity,
682        lifetime: u32,
683        discount_rate: Dimensionless,
684    ) -> Process {
685        let mut process_flows_map = HashMap::new();
686        process_flows_map.insert((region_id.clone(), year), Rc::new(flows));
687
688        let mut process_parameter_map = HashMap::new();
689        let proc_param = ProcessParameter {
690            capital_cost,
691            fixed_operating_cost,
692            variable_operating_cost,
693            lifetime,
694            discount_rate,
695        };
696        process_parameter_map.insert((region_id.clone(), year), Rc::new(proc_param));
697
698        let mut activity_limits_map = HashMap::new();
699        activity_limits_map.insert(
700            (region_id.clone(), year),
701            Rc::new(ActivityLimits::new_with_full_availability(time_slice_info)),
702        );
703
704        let regions: IndexSet<RegionID> = IndexSet::from([region_id.clone()]);
705
706        Process {
707            id: "p1".into(),
708            description: "test process".into(),
709            years: 2010..=2020,
710            activity_limits: activity_limits_map,
711            flows: process_flows_map,
712            parameters: process_parameter_map,
713            regions,
714            primary_output: None,
715            capacity_to_activity: ActivityPerCapacity(1.0),
716            investment_constraints: HashMap::new(),
717            unit_size: None,
718        }
719    }
720
721    fn assert_price_approx(
722        prices: &HashMap<(CommodityID, RegionID, TimeSliceID), MoneyPerFlow>,
723        commodity: &CommodityID,
724        region: &RegionID,
725        time_slice: &TimeSliceID,
726        expected: MoneyPerFlow,
727    ) {
728        let p = prices[&(commodity.clone(), region.clone(), time_slice.clone())];
729        assert!((p - expected).abs() < MoneyPerFlow::EPSILON);
730    }
731
732    #[rstest]
733    #[case(MoneyPerFlow(100.0), MoneyPerFlow(100.0), Dimensionless(0.0), true)] // exactly equal
734    #[case(MoneyPerFlow(100.0), MoneyPerFlow(105.0), Dimensionless(0.1), true)] // within tolerance
735    #[case(MoneyPerFlow(-100.0), MoneyPerFlow(-105.0), Dimensionless(0.1), true)] // within tolerance, both negative
736    #[case(MoneyPerFlow(0.0), MoneyPerFlow(0.0), Dimensionless(0.1), true)] // both zero
737    #[case(MoneyPerFlow(100.0), MoneyPerFlow(105.0), Dimensionless(0.01), false)] // difference bigger than tolerance
738    #[case(MoneyPerFlow(100.0), MoneyPerFlow(-105.0), Dimensionless(0.1), false)] // comparing positive and negative prices
739    #[case(MoneyPerFlow(0.0), MoneyPerFlow(10.0), Dimensionless(0.1), false)] // comparing zero and positive
740    #[case(MoneyPerFlow(0.0), MoneyPerFlow(-10.0), Dimensionless(0.1), false)] // comparing zero and negative
741    #[case(MoneyPerFlow(10.0), MoneyPerFlow(0.0), Dimensionless(0.1), false)] // comparing positive and zero
742    #[case(MoneyPerFlow(-10.0), MoneyPerFlow(0.0), Dimensionless(0.1), false)] // comparing negative and zero
743    fn within_tolerance_scenarios(
744        #[case] price1: MoneyPerFlow,
745        #[case] price2: MoneyPerFlow,
746        #[case] tolerance: Dimensionless,
747        #[case] expected: bool,
748        time_slice_info: TimeSliceInfo,
749        time_slice: TimeSliceID,
750    ) {
751        let mut prices1 = CommodityPrices::default();
752        let mut prices2 = CommodityPrices::default();
753
754        // Set up two price sets for a single commodity/region/time slice
755        let commodity = CommodityID::new("test_commodity");
756        let region = RegionID::new("test_region");
757        prices1.insert(&commodity, &region, &time_slice, price1);
758        prices2.insert(&commodity, &region, &time_slice, price2);
759
760        assert_eq!(
761            prices1.within_tolerance_weighted(&prices2, tolerance, &time_slice_info),
762            expected
763        );
764    }
765
766    #[rstest]
767    fn time_slice_weighted_averages(
768        commodity_id: CommodityID,
769        region_id: RegionID,
770        time_slice_info: TimeSliceInfo,
771        time_slice: TimeSliceID,
772    ) {
773        let mut prices = CommodityPrices::default();
774
775        // Insert a price
776        prices.insert(&commodity_id, &region_id, &time_slice, MoneyPerFlow(100.0));
777
778        let averages = prices.time_slice_weighted_averages(&time_slice_info);
779
780        // With single time slice (duration=1.0), average should equal the price
781        assert_eq!(averages[&(commodity_id, region_id)], MoneyPerFlow(100.0));
782    }
783
784    #[rstest]
785    fn marginal_cost_example(
786        sed_commodity: Commodity,
787        other_commodity: Commodity,
788        region_id: RegionID,
789        time_slice_info: TimeSliceInfo,
790        time_slice: TimeSliceID,
791    ) {
792        // Use the same setup as in the docstring example for marginal cost pricing
793        let mut a = sed_commodity.clone();
794        a.id = "A".into();
795        let mut b = sed_commodity.clone();
796        b.id = "B".into();
797        let mut c = sed_commodity.clone();
798        c.id = "C".into();
799        let mut d = other_commodity.clone();
800        d.id = "D".into();
801
802        let mut flows = IndexMap::new();
803        flows.insert(
804            a.id.clone(),
805            build_process_flow(&a, -1.0, MoneyPerFlow(0.0)),
806        );
807        flows.insert(b.id.clone(), build_process_flow(&b, 1.0, MoneyPerFlow(0.0)));
808        flows.insert(c.id.clone(), build_process_flow(&c, 2.0, MoneyPerFlow(3.0)));
809        flows.insert(d.id.clone(), build_process_flow(&d, 1.0, MoneyPerFlow(4.0)));
810
811        let process = build_process(
812            flows,
813            &region_id,
814            2015u32,
815            &time_slice_info,
816            MoneyPerActivity(5.0),        // variable operating cost
817            MoneyPerCapacityPerYear(0.0), // fixed operating cost
818            MoneyPerCapacity(0.0),        // capital cost
819            5,                            // lifetime
820            Dimensionless(1.0),           // discount rate
821        );
822
823        let asset =
824            Asset::new_candidate(Rc::new(process), region_id.clone(), Capacity(1.0), 2015u32)
825                .unwrap();
826        let asset_ref = AssetRef::from(asset);
827        let shadow_prices =
828            CommodityPrices::from_iter(vec![(&a.id, &region_id, &time_slice, MoneyPerFlow(1.0))]);
829        let mut markets = HashSet::new();
830        markets.insert((b.id.clone(), region_id.clone()));
831        markets.insert((c.id.clone(), region_id.clone()));
832
833        let existing = vec![(&asset_ref, &time_slice)];
834        let candidates = Vec::new();
835
836        let prices = calculate_marginal_cost_prices(
837            existing.into_iter(),
838            candidates.into_iter(),
839            &shadow_prices,
840            2015u32,
841            &markets,
842        );
843
844        assert_price_approx(
845            &prices,
846            &b.id,
847            &region_id,
848            &time_slice,
849            MoneyPerFlow(10.0 / 3.0),
850        );
851        assert_price_approx(
852            &prices,
853            &c.id,
854            &region_id,
855            &time_slice,
856            MoneyPerFlow(10.0 / 3.0 + 3.0),
857        );
858    }
859
860    #[rstest]
861    fn full_cost_example(
862        sed_commodity: Commodity,
863        other_commodity: Commodity,
864        region_id: RegionID,
865        time_slice_info: TimeSliceInfo,
866        time_slice: TimeSliceID,
867    ) {
868        // Use the same setup as in the docstring example for full cost pricing
869        let mut a = sed_commodity.clone();
870        a.id = "A".into();
871        let mut b = sed_commodity.clone();
872        b.id = "B".into();
873        let mut c = sed_commodity.clone();
874        c.id = "C".into();
875        let mut d = other_commodity.clone();
876        d.id = "D".into();
877
878        let mut flows = IndexMap::new();
879        flows.insert(
880            a.id.clone(),
881            build_process_flow(&a, -1.0, MoneyPerFlow(0.0)),
882        );
883        flows.insert(b.id.clone(), build_process_flow(&b, 1.0, MoneyPerFlow(0.0)));
884        flows.insert(c.id.clone(), build_process_flow(&c, 2.0, MoneyPerFlow(3.0)));
885        flows.insert(d.id.clone(), build_process_flow(&d, 1.0, MoneyPerFlow(4.0)));
886
887        let process = build_process(
888            flows,
889            &region_id,
890            2015u32,
891            &time_slice_info,
892            MoneyPerActivity(5.0),        // variable operating cost
893            MoneyPerCapacityPerYear(1.0), //  fixed operating cost
894            MoneyPerCapacity(1.5),        // capital cost per capacity so annualised=1.5
895            1,                            // lifetime so annualised = capital_cost
896            Dimensionless(0.0),           // discount rate
897        );
898
899        let asset =
900            Asset::new_candidate(Rc::new(process), region_id.clone(), Capacity(4.0), 2015u32)
901                .unwrap();
902        let asset_ref = AssetRef::from(asset);
903        let shadow_prices =
904            CommodityPrices::from_iter(vec![(&a.id, &region_id, &time_slice, MoneyPerFlow(1.0))]);
905        let mut markets = HashSet::new();
906        markets.insert((b.id.clone(), region_id.clone()));
907        markets.insert((c.id.clone(), region_id.clone()));
908
909        let existing = vec![(&asset_ref, &time_slice)];
910        let candidates = Vec::new();
911
912        let mut annual_activities = HashMap::new();
913        annual_activities.insert(asset_ref.clone(), Activity(2.0));
914
915        let prices = calculate_full_cost_prices(
916            existing.into_iter(),
917            candidates.into_iter(),
918            &annual_activities,
919            &shadow_prices,
920            2015u32,
921            &markets,
922        );
923
924        assert_price_approx(&prices, &b.id, &region_id, &time_slice, MoneyPerFlow(5.0));
925        assert_price_approx(&prices, &c.id, &region_id, &time_slice, MoneyPerFlow(8.0));
926    }
927}