Skip to main content

muse2/
finance.rs

1//! General functions related to finance.
2use crate::time_slice::TimeSliceID;
3use crate::units::{Activity, Capacity, Dimensionless, Money, MoneyPerActivity, MoneyPerCapacity};
4use indexmap::IndexMap;
5
6/// Calculates the capital recovery factor (CRF) for a given lifetime and discount rate.
7///
8/// The CRF is used to annualize capital costs over the lifetime of an asset.
9pub fn capital_recovery_factor(lifetime: u32, discount_rate: Dimensionless) -> Dimensionless {
10    if lifetime == 0 {
11        return Dimensionless(0.0);
12    }
13    if discount_rate == Dimensionless(0.0) {
14        return Dimensionless(1.0) / Dimensionless(lifetime as f64);
15    }
16
17    let factor = (Dimensionless(1.0) + discount_rate).powi(lifetime.try_into().unwrap());
18    (discount_rate * factor) / (factor - Dimensionless(1.0))
19}
20
21/// Calculates the annual capital cost for a process per unit of capacity
22pub fn annual_capital_cost(
23    capital_cost: MoneyPerCapacity,
24    lifetime: u32,
25    discount_rate: Dimensionless,
26) -> MoneyPerCapacity {
27    let crf = capital_recovery_factor(lifetime, discount_rate);
28    capital_cost * crf
29}
30
31/// Calculates the SNAS (Specific Net Annualised Surplus) based on capacity and activity.
32///
33/// It is just the negative of the LCOX, although, unlike LCOX, it should be called with
34/// activity costs that INCLUDE the commodity of interest.
35pub fn snas(
36    capacity: Capacity,
37    annual_fixed_cost: MoneyPerCapacity,
38    activity: &IndexMap<TimeSliceID, Activity>,
39    activity_costs: &IndexMap<TimeSliceID, MoneyPerActivity>,
40) -> Option<MoneyPerActivity> {
41    lcox(capacity, annual_fixed_cost, activity, activity_costs).map(|lcox| -lcox)
42}
43
44/// Calculates annual LCOX based on capacity and activity.
45///
46/// It should be called with activity costs that EXCLUDE the commodity of interest.
47/// If the total activity is zero, then it returns `None`, otherwise `Some` LCOX value.
48pub fn lcox(
49    capacity: Capacity,
50    annual_fixed_cost: MoneyPerCapacity,
51    activity: &IndexMap<TimeSliceID, Activity>,
52    activity_costs: &IndexMap<TimeSliceID, MoneyPerActivity>,
53) -> Option<MoneyPerActivity> {
54    // Calculate the annualised fixed costs
55    let annualised_fixed_cost = annual_fixed_cost * capacity;
56
57    // Calculate the total activity costs
58    let mut total_activity_costs = Money(0.0);
59    let mut total_activity = Activity(0.0);
60    for (time_slice, activity) in activity {
61        let activity_cost = activity_costs[time_slice];
62        total_activity += *activity;
63        total_activity_costs += activity_cost * *activity;
64    }
65
66    (total_activity > Activity(0.0))
67        .then(|| (annualised_fixed_cost + total_activity_costs) / total_activity)
68}
69
70#[cfg(test)]
71#[allow(clippy::unreadable_literal)]
72mod tests {
73    use super::*;
74    use crate::time_slice::TimeSliceID;
75    use float_cmp::assert_approx_eq;
76    use rstest::rstest;
77
78    #[rstest]
79    #[case(0, 0.05, 0.0)] // Edge case: lifetime==0
80    #[case(10, 0.0, 0.1)] // Other edge case: discount_rate==0
81    #[case(10, 0.05, 0.1295045749654567)]
82    #[case(5, 0.03, 0.2183545714005762)]
83    fn capital_recovery_factor_works(
84        #[case] lifetime: u32,
85        #[case] discount_rate: f64,
86        #[case] expected: f64,
87    ) {
88        let result = capital_recovery_factor(lifetime, Dimensionless(discount_rate));
89        assert_approx_eq!(f64, result.0, expected, epsilon = 1e-10);
90    }
91
92    #[rstest]
93    #[case(1000.0, 10, 0.05, 129.5045749654567)]
94    #[case(500.0, 5, 0.03, 109.17728570028798)]
95    #[case(1000.0, 0, 0.05, 0.0)] // Zero lifetime
96    #[case(2000.0, 20, 0.0, 100.0)] // Zero discount rate
97    fn annual_capital_cost_works(
98        #[case] capital_cost: f64,
99        #[case] lifetime: u32,
100        #[case] discount_rate: f64,
101        #[case] expected: f64,
102    ) {
103        let expected = MoneyPerCapacity(expected);
104        let result = annual_capital_cost(
105            MoneyPerCapacity(capital_cost),
106            lifetime,
107            Dimensionless(discount_rate),
108        );
109        assert_approx_eq!(MoneyPerCapacity, result, expected, epsilon = 1e-8);
110    }
111
112    #[rstest]
113    #[case(
114        100.0, 50.0,
115        vec![("winter", "day", 10.0), ("summer", "night", 20.0)],
116        vec![("winter", "day", 5.0), ("summer", "night", 3.0)],
117        Some(170.33333333333334) // (100*50 + 10*5 + 20*3) / (10+20) = 5110/30
118    )]
119    #[case(
120        50.0, 100.0,
121        vec![("winter", "day", 25.0)],
122        vec![("winter", "day", 0.0)],
123        Some(200.0) // (50*100 + 25*0) / 25 = 5000/25
124    )]
125    #[case(
126        50.0, 100.0,
127        vec![("winter", "day", 0.0)],
128        vec![("winter", "day", 0.0)],
129        None // (50*0 + 25*0) / 0 = not feasible
130    )]
131    fn lcox_works(
132        #[case] capacity: f64,
133        #[case] annual_fixed_cost: f64,
134        #[case] activity_data: Vec<(&str, &str, f64)>,
135        #[case] cost_data: Vec<(&str, &str, f64)>,
136        #[case] expected: Option<f64>,
137    ) {
138        let activity = activity_data
139            .into_iter()
140            .map(|(season, time_of_day, value)| {
141                (
142                    TimeSliceID {
143                        season: season.into(),
144                        time_of_day: time_of_day.into(),
145                    },
146                    Activity(value),
147                )
148            })
149            .collect();
150
151        let activity_costs = cost_data
152            .into_iter()
153            .map(|(season, time_of_day, value)| {
154                (
155                    TimeSliceID {
156                        season: season.into(),
157                        time_of_day: time_of_day.into(),
158                    },
159                    MoneyPerActivity(value),
160                )
161            })
162            .collect();
163
164        let result = lcox(
165            Capacity(capacity),
166            MoneyPerCapacity(annual_fixed_cost),
167            &activity,
168            &activity_costs,
169        );
170
171        let expected = expected.map(MoneyPerActivity);
172        assert_approx_eq!(Option<MoneyPerActivity>, result, expected);
173    }
174}