muse2/simulation/
prices.rs

1//! Code for updating the simulation state.
2use crate::asset::AssetRef;
3use crate::commodity::CommodityID;
4use crate::model::{Model, PricingStrategy};
5use crate::process::ProcessFlow;
6use crate::region::RegionID;
7use crate::simulation::optimisation::Solution;
8use crate::time_slice::{TimeSliceID, TimeSliceInfo};
9use crate::units::{Dimensionless, MoneyPerActivity, MoneyPerFlow};
10use indexmap::IndexMap;
11use itertools::iproduct;
12use std::collections::{BTreeMap, HashMap};
13
14/// A map of reduced costs for different assets in different time slices
15///
16/// This is the system cost associated with one unit of activity (MoneyPerActivity) for each asset
17/// in each time slice.
18///
19/// For candidate assets this is calculated directly from the activity variable duals.
20///
21/// For existing assets this is calculated from the operating cost and the revenue from flows.
22///
23/// These may be used in the investment algorithm, depending on the appraisal method, to compare the
24/// cost effectiveness of different potential investment decisions.
25#[derive(Default, Clone)]
26pub struct ReducedCosts(IndexMap<(AssetRef, TimeSliceID), MoneyPerActivity>);
27
28impl ReducedCosts {
29    /// Get the reduced cost for the specified asset and time slice
30    ///
31    /// If no reduced cost is found for the asset, the reduced cost is returned for the relevant
32    /// candidate asset. This can occur the first year an asset is commissioned, or if an asset
33    /// was not selected in an earlier iteration of the ironing out loop.
34    pub fn get(&self, asset: &AssetRef, time_slice: &TimeSliceID) -> MoneyPerActivity {
35        *self
36            .0
37            .get(&(asset.clone(), time_slice.clone()))
38            .unwrap_or_else(|| &self.0[&(asset.as_candidate(None).into(), time_slice.clone())])
39    }
40
41    /// Extend the reduced costs map
42    pub fn extend<T>(&mut self, iter: T)
43    where
44        T: IntoIterator<Item = ((AssetRef, TimeSliceID), MoneyPerActivity)>,
45    {
46        self.0.extend(iter);
47    }
48
49    /// Iterate over the map
50    pub fn iter(&self) -> impl Iterator<Item = (&(AssetRef, TimeSliceID), &MoneyPerActivity)> {
51        self.0.iter()
52    }
53
54    /// Iterate mutably over the map
55    pub fn iter_mut(
56        &mut self,
57    ) -> impl Iterator<Item = (&(AssetRef, TimeSliceID), &mut MoneyPerActivity)> {
58        self.0.iter_mut()
59    }
60}
61
62impl FromIterator<((AssetRef, TimeSliceID), MoneyPerActivity)> for ReducedCosts {
63    fn from_iter<T>(iter: T) -> Self
64    where
65        T: IntoIterator<Item = ((AssetRef, TimeSliceID), MoneyPerActivity)>,
66    {
67        ReducedCosts(iter.into_iter().collect())
68    }
69}
70
71impl IntoIterator for ReducedCosts {
72    type Item = ((AssetRef, TimeSliceID), MoneyPerActivity);
73    type IntoIter = indexmap::map::IntoIter<(AssetRef, TimeSliceID), MoneyPerActivity>;
74
75    fn into_iter(self) -> Self::IntoIter {
76        self.0.into_iter()
77    }
78}
79
80impl From<IndexMap<(AssetRef, TimeSliceID), MoneyPerActivity>> for ReducedCosts {
81    fn from(map: IndexMap<(AssetRef, TimeSliceID), MoneyPerActivity>) -> Self {
82        ReducedCosts(map)
83    }
84}
85
86/// Calculate commodity prices and reduced costs for assets.
87///
88/// Note that the behaviour will be different depending on the [`PricingStrategy`] the user has
89/// selected.
90///
91/// # Arguments
92///
93/// * `model` - The model
94/// * `solution` - Solution to dispatch optimisation
95/// * `existing_assets` - Existing assets
96/// * `year` - Current milestone year
97pub fn calculate_prices_and_reduced_costs(
98    model: &Model,
99    solution: &Solution,
100    existing_assets: &[AssetRef],
101    year: u32,
102) -> (CommodityPrices, ReducedCosts) {
103    let mut prices = CommodityPrices::default();
104    let mut reduced_costs = ReducedCosts::default();
105
106    let shadow_prices = CommodityPrices::from_iter(solution.iter_commodity_balance_duals());
107    let reduced_costs_for_candidates: ReducedCosts = solution
108        .iter_reduced_costs_for_candidates()
109        .map(|(asset, time_slice, cost)| ((asset.clone(), time_slice.clone()), cost))
110        .collect();
111
112    let (new_prices, reduced_costs_for_candidates) = match model.parameters.pricing_strategy {
113        // Use raw shadow prices and reduced costs
114        PricingStrategy::ShadowPrices => (
115            shadow_prices.with_levies(model, year),
116            reduced_costs_for_candidates,
117        ),
118        // Adjust prices for scarcity and then remove this adjustment from reduced costs
119        PricingStrategy::ScarcityAdjusted => {
120            let adjusted_prices = shadow_prices
121                .clone()
122                .with_scarcity_adjustment(solution.iter_activity_duals())
123                .with_levies(model, year);
124            let unadjusted_prices = shadow_prices.with_levies(model, year);
125            let mut reduced_costs_for_candidates = reduced_costs_for_candidates;
126
127            // Remove adjustment
128            remove_scarcity_influence_from_candidate_reduced_costs(
129                &mut reduced_costs_for_candidates,
130                &adjusted_prices,
131                &unadjusted_prices,
132            );
133
134            (adjusted_prices, reduced_costs_for_candidates)
135        }
136    };
137
138    // Use old prices for any commodities for which price is missing
139    prices.extend(new_prices);
140
141    // Add new reduced costs, using old values if not provided
142    reduced_costs.extend(reduced_costs_for_candidates);
143    reduced_costs.extend(reduced_costs_for_existing(
144        &model.time_slice_info,
145        existing_assets,
146        &prices,
147        year,
148    ));
149
150    (prices, reduced_costs)
151}
152
153/// A map relating commodity ID + region + time slice to current price (endogenous)
154#[derive(Default, Clone)]
155pub struct CommodityPrices(BTreeMap<(CommodityID, RegionID, TimeSliceID), MoneyPerFlow>);
156
157impl CommodityPrices {
158    /// Add prices based on levies/incentives.
159    ///
160    /// If a commodity already has a price based on the previous dual-based calculation, we choose
161    /// the higher of the two.
162    ///
163    /// # Arguments
164    ///
165    /// * `model` - The model
166    /// * `year` - The milestone year of interest
167    fn with_levies(mut self, model: &Model, year: u32) -> Self {
168        for (region_id, time_slice) in
169            iproduct!(model.iter_regions(), model.time_slice_info.iter_ids())
170        {
171            let levy_key = (region_id.clone(), year, time_slice.clone());
172            for commodity in model.commodities.values() {
173                if let Some(levy) = commodity.levies.get(&levy_key) {
174                    let key = (commodity.id.clone(), region_id.clone(), time_slice.clone());
175                    self.0
176                        .entry(key)
177                        .and_modify(|price| *price = price.max(levy.value))
178                        .or_insert(levy.value);
179                }
180            }
181        }
182
183        self
184    }
185
186    /// Remove the impact of scarcity on prices.
187    ///
188    /// # Arguments
189    ///
190    /// * `activity_duals` - Value of activity duals from solution
191    fn with_scarcity_adjustment<'a, I>(mut self, activity_duals: I) -> Self
192    where
193        I: Iterator<Item = (&'a AssetRef, &'a TimeSliceID, MoneyPerActivity)>,
194    {
195        let highest_duals = get_highest_activity_duals(activity_duals);
196
197        // Add the highest activity dual for each commodity/region/timeslice to each commodity
198        // balance dual
199        for (key, highest) in highest_duals.iter() {
200            if let Some(price) = self.0.get_mut(key) {
201                // highest is in units of MoneyPerActivity, but this is correct according to Adam
202                *price += MoneyPerFlow(highest.value());
203            }
204        }
205
206        self
207    }
208
209    /// Extend the prices map, possibly overwriting values
210    pub fn extend<T>(&mut self, iter: T)
211    where
212        T: IntoIterator<Item = ((CommodityID, RegionID, TimeSliceID), MoneyPerFlow)>,
213    {
214        self.0.extend(iter);
215    }
216
217    /// Insert a price for the given commodity, region and time slice
218    pub fn insert(
219        &mut self,
220        commodity_id: &CommodityID,
221        region_id: &RegionID,
222        time_slice: &TimeSliceID,
223        price: MoneyPerFlow,
224    ) {
225        let key = (commodity_id.clone(), region_id.clone(), time_slice.clone());
226        self.0.insert(key, price);
227    }
228
229    /// Iterate over the map.
230    ///
231    /// # Returns
232    ///
233    /// An iterator of tuples containing commodity ID, region ID, time slice and price.
234    pub fn iter(
235        &self,
236    ) -> impl Iterator<Item = (&CommodityID, &RegionID, &TimeSliceID, MoneyPerFlow)> {
237        self.0
238            .iter()
239            .map(|((commodity_id, region_id, ts), price)| (commodity_id, region_id, ts, *price))
240    }
241
242    /// Get the price for the specified commodity for a given region and time slice
243    pub fn get(
244        &self,
245        commodity_id: &CommodityID,
246        region_id: &RegionID,
247        time_slice: &TimeSliceID,
248    ) -> Option<MoneyPerFlow> {
249        self.0
250            .get(&(commodity_id.clone(), region_id.clone(), time_slice.clone()))
251            .copied()
252    }
253
254    /// Check if prices are within relative tolerance of another price set
255    ///
256    /// Both objects must have exactly the same set of keys, otherwise it will panic.
257    pub fn within_tolerance(&self, other: &Self, tolerance: Dimensionless) -> bool {
258        for (key, &price) in &self.0 {
259            let other_price = other.0[key];
260            let abs_diff = (price - other_price).abs();
261
262            // Special case: last price was zero
263            if price == MoneyPerFlow(0.0) {
264                // Current price is zero but other price is nonzero
265                if other_price != MoneyPerFlow(0.0) {
266                    return false;
267                }
268            // Check if price is within tolerance
269            } else if abs_diff / price.abs() > tolerance {
270                return false;
271            }
272        }
273        true
274    }
275}
276
277impl<'a> FromIterator<(&'a CommodityID, &'a RegionID, &'a TimeSliceID, MoneyPerFlow)>
278    for CommodityPrices
279{
280    fn from_iter<I>(iter: I) -> Self
281    where
282        I: IntoIterator<Item = (&'a CommodityID, &'a RegionID, &'a TimeSliceID, MoneyPerFlow)>,
283    {
284        let map = iter
285            .into_iter()
286            .map(|(commodity_id, region_id, time_slice, price)| {
287                (
288                    (commodity_id.clone(), region_id.clone(), time_slice.clone()),
289                    price,
290                )
291            })
292            .collect();
293        CommodityPrices(map)
294    }
295}
296
297impl IntoIterator for CommodityPrices {
298    type Item = ((CommodityID, RegionID, TimeSliceID), MoneyPerFlow);
299    type IntoIter =
300        std::collections::btree_map::IntoIter<(CommodityID, RegionID, TimeSliceID), MoneyPerFlow>;
301
302    fn into_iter(self) -> Self::IntoIter {
303        self.0.into_iter()
304    }
305}
306
307fn get_highest_activity_duals<'a, I>(
308    activity_duals: I,
309) -> HashMap<(CommodityID, RegionID, TimeSliceID), MoneyPerActivity>
310where
311    I: Iterator<Item = (&'a AssetRef, &'a TimeSliceID, MoneyPerActivity)>,
312{
313    // Calculate highest activity dual for each commodity/region/timeslice
314    let mut highest_duals = HashMap::new();
315    for (asset, time_slice, dual) in activity_duals {
316        // Iterate over all output flows
317        for flow in asset.iter_flows().filter(|flow| flow.is_output()) {
318            // Update the highest dual for this commodity/timeslice
319            highest_duals
320                .entry((
321                    flow.commodity.id.clone(),
322                    asset.region_id().clone(),
323                    time_slice.clone(),
324                ))
325                .and_modify(|current_dual| {
326                    if dual > *current_dual {
327                        *current_dual = dual;
328                    }
329                })
330                .or_insert(dual);
331        }
332    }
333
334    highest_duals
335}
336
337/// Remove the effect of scarcity on candidate assets' reduced costs
338fn remove_scarcity_influence_from_candidate_reduced_costs(
339    reduced_costs: &mut ReducedCosts,
340    adjusted_prices: &CommodityPrices,
341    unadjusted_prices: &CommodityPrices,
342) {
343    for ((asset, time_slice), cost) in reduced_costs.iter_mut() {
344        *cost += asset
345            .iter_flows()
346            .map(|flow| {
347                get_scarcity_adjustment(
348                    flow,
349                    asset.region_id(),
350                    time_slice,
351                    adjusted_prices,
352                    unadjusted_prices,
353                )
354            })
355            .sum();
356    }
357}
358
359/// Get the scarcity adjustment for the given flow/region/time slice combination.
360///
361/// The return value may be negative.
362fn get_scarcity_adjustment(
363    flow: &ProcessFlow,
364    region_id: &RegionID,
365    time_slice: &TimeSliceID,
366    adjusted_prices: &CommodityPrices,
367    unadjusted_prices: &CommodityPrices,
368) -> MoneyPerActivity {
369    let adjusted = adjusted_prices
370        .get(&flow.commodity.id, region_id, time_slice)
371        .expect("No adjusted price found");
372    let unadjusted = unadjusted_prices
373        .get(&flow.commodity.id, region_id, time_slice)
374        .expect("No unadjusted price found");
375    flow.coeff * (unadjusted - adjusted)
376}
377
378/// Calculate reduced costs for existing assets
379fn reduced_costs_for_existing<'a>(
380    time_slice_info: &'a TimeSliceInfo,
381    assets: &'a [AssetRef],
382    prices: &'a CommodityPrices,
383    year: u32,
384) -> impl Iterator<Item = ((AssetRef, TimeSliceID), MoneyPerActivity)> + 'a {
385    iproduct!(assets, time_slice_info.iter_ids()).map(move |(asset, time_slice)| {
386        let operating_cost = asset.get_operating_cost(year, time_slice);
387        let revenue_from_flows = asset
388            .iter_flows()
389            .map(|flow| {
390                flow.coeff
391                    * prices
392                        .get(&flow.commodity.id, asset.region_id(), time_slice)
393                        .unwrap()
394            })
395            .sum();
396        let reduced_cost = operating_cost - revenue_from_flows;
397
398        ((asset.clone(), time_slice.clone()), reduced_cost)
399    })
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405    use crate::commodity::CommodityID;
406    use crate::fixture::{asset, assets, process, time_slice};
407    use crate::process::Process;
408    use crate::region::RegionID;
409    use crate::time_slice::TimeSliceID;
410    use indexmap::indexmap;
411    use rstest::rstest;
412
413    #[rstest]
414    fn test_get_reduced_cost(process: Process, time_slice: TimeSliceID) {
415        let asset_pool = assets(asset(process));
416        let asset = asset_pool.as_slice().first().unwrap();
417
418        // Create reduced costs with only the candidate version
419        let candidate = asset.as_candidate(None);
420        let mut reduced_costs = ReducedCosts::from(indexmap! {
421            (candidate.into(), time_slice.clone()) => MoneyPerActivity(42.0)
422        });
423
424        // Should fallback to candidate when asset not found
425        let result = reduced_costs.get(asset, &time_slice);
426        assert_eq!(result, MoneyPerActivity(42.0));
427
428        // Add a reduced cost for the asset
429        reduced_costs.extend(indexmap! {
430            (asset.clone(), time_slice.clone()) => MoneyPerActivity(100.0)
431        });
432
433        // Now should return the asset's reduced cost
434        let result = reduced_costs.get(asset, &time_slice);
435        assert_eq!(result, MoneyPerActivity(100.0));
436    }
437
438    #[rstest]
439    #[case(MoneyPerFlow(100.0), MoneyPerFlow(100.0), Dimensionless(0.0), true)] // exactly equal
440    #[case(MoneyPerFlow(100.0), MoneyPerFlow(105.0), Dimensionless(0.1), true)] // within tolerance
441    #[case(MoneyPerFlow(-100.0), MoneyPerFlow(-105.0), Dimensionless(0.1), true)] // within tolerance, both negative
442    #[case(MoneyPerFlow(0.0), MoneyPerFlow(0.0), Dimensionless(0.1), true)] // both zero
443    #[case(MoneyPerFlow(100.0), MoneyPerFlow(105.0), Dimensionless(0.01), false)] // difference bigger than tolerance
444    #[case(MoneyPerFlow(100.0), MoneyPerFlow(-105.0), Dimensionless(0.1), false)] // comparing positive and negative prices
445    #[case(MoneyPerFlow(0.0), MoneyPerFlow(10.0), Dimensionless(0.1), false)] // comparing zero and positive
446    #[case(MoneyPerFlow(0.0), MoneyPerFlow(-10.0), Dimensionless(0.1), false)] // comparing zero and negative
447    #[case(MoneyPerFlow(10.0), MoneyPerFlow(0.0), Dimensionless(0.1), false)] // comparing positive and zero
448    #[case(MoneyPerFlow(-10.0), MoneyPerFlow(0.0), Dimensionless(0.1), false)] // comparing negative and zero
449    fn test_within_tolerance_scenarios(
450        #[case] price1: MoneyPerFlow,
451        #[case] price2: MoneyPerFlow,
452        #[case] tolerance: Dimensionless,
453        #[case] expected: bool,
454    ) {
455        let mut prices1 = CommodityPrices::default();
456        let mut prices2 = CommodityPrices::default();
457
458        let commodity = CommodityID::new("test_commodity");
459        let region = RegionID::new("test_region");
460        let time_slice: TimeSliceID = "summer.day".into();
461
462        prices1.insert(&commodity, &region, &time_slice, price1);
463        prices2.insert(&commodity, &region, &time_slice, price2);
464
465        assert_eq!(prices1.within_tolerance(&prices2, tolerance), expected);
466    }
467}