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