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;
5use serde::Serialize;
6
7/// Calculates the capital recovery factor (CRF) for a given lifetime and discount rate.
8///
9/// The CRF is used to annualize capital costs over the lifetime of an asset.
10pub fn capital_recovery_factor(lifetime: u32, discount_rate: Dimensionless) -> Dimensionless {
11    if lifetime == 0 {
12        return Dimensionless(0.0);
13    }
14    if discount_rate == Dimensionless(0.0) {
15        return Dimensionless(1.0) / Dimensionless(lifetime as f64);
16    }
17
18    let factor = (Dimensionless(1.0) + discount_rate).powi(lifetime.try_into().unwrap());
19    (discount_rate * factor) / (factor - Dimensionless(1.0))
20}
21
22/// Calculates the annual capital cost for a process per unit of capacity
23pub fn annual_capital_cost(
24    capital_cost: MoneyPerCapacity,
25    lifetime: u32,
26    discount_rate: Dimensionless,
27) -> MoneyPerCapacity {
28    let crf = capital_recovery_factor(lifetime, discount_rate);
29    capital_cost * crf
30}
31
32/// Represents the profitability index of an investment
33/// in terms of its annualised components.
34#[derive(Debug, Clone, Copy, Serialize)]
35pub struct ProfitabilityIndex {
36    /// The total annualised surplus of an asset
37    pub total_annualised_surplus: Money,
38    /// The total annualised fixed cost of an asset
39    pub annualised_fixed_cost: Money,
40}
41
42impl ProfitabilityIndex {
43    /// Calculates the value of the profitability index.
44    pub fn value(&self) -> Dimensionless {
45        assert!(
46            self.annualised_fixed_cost != Money(0.0),
47            "Annualised fixed cost cannot be zero when calculating profitability index."
48        );
49        self.total_annualised_surplus / self.annualised_fixed_cost
50    }
51}
52
53/// Calculates an annual profitability index based on capacity and activity.
54pub fn profitability_index(
55    capacity: Capacity,
56    annual_fixed_cost: MoneyPerCapacity,
57    activity: &IndexMap<TimeSliceID, Activity>,
58    activity_surpluses: &IndexMap<TimeSliceID, MoneyPerActivity>,
59) -> ProfitabilityIndex {
60    // Calculate the annualised fixed costs
61    let annualised_fixed_cost = annual_fixed_cost * capacity;
62
63    // Calculate the total annualised surplus
64    let mut total_annualised_surplus = Money(0.0);
65    for (time_slice, activity) in activity {
66        let activity_surplus = activity_surpluses[time_slice];
67        total_annualised_surplus += activity_surplus * *activity;
68    }
69
70    ProfitabilityIndex {
71        total_annualised_surplus,
72        annualised_fixed_cost,
73    }
74}
75
76/// Calculates annual LCOX based on capacity and activity.
77///
78/// If the total activity is zero, then it returns `None`, otherwise `Some` LCOX value.
79pub fn lcox(
80    capacity: Capacity,
81    annual_fixed_cost: MoneyPerCapacity,
82    activity: &IndexMap<TimeSliceID, Activity>,
83    activity_costs: &IndexMap<TimeSliceID, MoneyPerActivity>,
84) -> Option<MoneyPerActivity> {
85    // Calculate the annualised fixed costs
86    let annualised_fixed_cost = annual_fixed_cost * capacity;
87
88    // Calculate the total activity costs
89    let mut total_activity_costs = Money(0.0);
90    let mut total_activity = Activity(0.0);
91    for (time_slice, activity) in activity {
92        let activity_cost = activity_costs[time_slice];
93        total_activity += *activity;
94        total_activity_costs += activity_cost * *activity;
95    }
96
97    (total_activity > Activity(0.0))
98        .then(|| (annualised_fixed_cost + total_activity_costs) / total_activity)
99}
100
101#[cfg(test)]
102#[allow(clippy::unreadable_literal)]
103mod tests {
104    use super::*;
105    use crate::time_slice::TimeSliceID;
106    use float_cmp::assert_approx_eq;
107    use indexmap::indexmap;
108    use rstest::rstest;
109
110    #[rstest]
111    #[case(0, 0.05, 0.0)] // Edge case: lifetime==0
112    #[case(10, 0.0, 0.1)] // Other edge case: discount_rate==0
113    #[case(10, 0.05, 0.1295045749654567)]
114    #[case(5, 0.03, 0.2183545714005762)]
115    fn capital_recovery_factor_works(
116        #[case] lifetime: u32,
117        #[case] discount_rate: f64,
118        #[case] expected: f64,
119    ) {
120        let result = capital_recovery_factor(lifetime, Dimensionless(discount_rate));
121        assert_approx_eq!(f64, result.0, expected, epsilon = 1e-10);
122    }
123
124    #[rstest]
125    #[case(1000.0, 10, 0.05, 129.5045749654567)]
126    #[case(500.0, 5, 0.03, 109.17728570028798)]
127    #[case(1000.0, 0, 0.05, 0.0)] // Zero lifetime
128    #[case(2000.0, 20, 0.0, 100.0)] // Zero discount rate
129    fn annual_capital_cost_works(
130        #[case] capital_cost: f64,
131        #[case] lifetime: u32,
132        #[case] discount_rate: f64,
133        #[case] expected: f64,
134    ) {
135        let expected = MoneyPerCapacity(expected);
136        let result = annual_capital_cost(
137            MoneyPerCapacity(capital_cost),
138            lifetime,
139            Dimensionless(discount_rate),
140        );
141        assert_approx_eq!(MoneyPerCapacity, result, expected, epsilon = 1e-8);
142    }
143
144    #[rstest]
145    #[case(
146        100.0, 50.0,
147        vec![("winter", "day", 10.0), ("summer", "night", 15.0)],
148        vec![("winter", "day", 30.0), ("summer", "night", 20.0)],
149        0.12 // Expected PI: (10*30 + 15*20) / (100*50) = 600/5000 = 0.12
150    )]
151    #[case(
152        50.0, 100.0,
153        vec![("q1", "peak", 5.0)],
154        vec![("q1", "peak", 40.0)],
155        0.04 // Expected PI: (5*40) / (50*100) = 200/5000 = 0.04
156    )]
157    fn profitability_index_works(
158        #[case] capacity: f64,
159        #[case] annual_fixed_cost: f64,
160        #[case] activity_data: Vec<(&str, &str, f64)>,
161        #[case] surplus_data: Vec<(&str, &str, f64)>,
162        #[case] expected: f64,
163    ) {
164        let activity = activity_data
165            .into_iter()
166            .map(|(season, time_of_day, value)| {
167                (
168                    TimeSliceID {
169                        season: season.into(),
170                        time_of_day: time_of_day.into(),
171                    },
172                    Activity(value),
173                )
174            })
175            .collect();
176
177        let activity_surpluses = surplus_data
178            .into_iter()
179            .map(|(season, time_of_day, value)| {
180                (
181                    TimeSliceID {
182                        season: season.into(),
183                        time_of_day: time_of_day.into(),
184                    },
185                    MoneyPerActivity(value),
186                )
187            })
188            .collect();
189
190        let result = profitability_index(
191            Capacity(capacity),
192            MoneyPerCapacity(annual_fixed_cost),
193            &activity,
194            &activity_surpluses,
195        );
196
197        assert_approx_eq!(Dimensionless, result.value(), Dimensionless(expected));
198    }
199
200    #[test]
201    fn profitability_index_zero_activity() {
202        let capacity = Capacity(100.0);
203        let annual_fixed_cost = MoneyPerCapacity(50.0);
204        let activity = indexmap! {};
205        let activity_surpluses = indexmap! {};
206
207        let result =
208            profitability_index(capacity, annual_fixed_cost, &activity, &activity_surpluses);
209        assert_eq!(result.value(), Dimensionless(0.0));
210    }
211
212    #[test]
213    #[should_panic(expected = "Annualised fixed cost cannot be zero")]
214    fn profitability_index_panics_on_zero_cost() {
215        let result = profitability_index(
216            Capacity(0.0),
217            MoneyPerCapacity(100.0),
218            &indexmap! {},
219            &indexmap! {},
220        );
221        result.value();
222    }
223
224    #[rstest]
225    #[case(
226        100.0, 50.0,
227        vec![("winter", "day", 10.0), ("summer", "night", 20.0)],
228        vec![("winter", "day", 5.0), ("summer", "night", 3.0)],
229        Some(170.33333333333334) // (100*50 + 10*5 + 20*3) / (10+20) = 5110/30
230    )]
231    #[case(
232        50.0, 100.0,
233        vec![("winter", "day", 25.0)],
234        vec![("winter", "day", 0.0)],
235        Some(200.0) // (50*100 + 25*0) / 25 = 5000/25
236    )]
237    #[case(
238        50.0, 100.0,
239        vec![("winter", "day", 0.0)],
240        vec![("winter", "day", 0.0)],
241        None // (50*0 + 25*0) / 0 = not feasible
242    )]
243    fn lcox_works(
244        #[case] capacity: f64,
245        #[case] annual_fixed_cost: f64,
246        #[case] activity_data: Vec<(&str, &str, f64)>,
247        #[case] cost_data: Vec<(&str, &str, f64)>,
248        #[case] expected: Option<f64>,
249    ) {
250        let activity = activity_data
251            .into_iter()
252            .map(|(season, time_of_day, value)| {
253                (
254                    TimeSliceID {
255                        season: season.into(),
256                        time_of_day: time_of_day.into(),
257                    },
258                    Activity(value),
259                )
260            })
261            .collect();
262
263        let activity_costs = cost_data
264            .into_iter()
265            .map(|(season, time_of_day, value)| {
266                (
267                    TimeSliceID {
268                        season: season.into(),
269                        time_of_day: time_of_day.into(),
270                    },
271                    MoneyPerActivity(value),
272                )
273            })
274            .collect();
275
276        let result = lcox(
277            Capacity(capacity),
278            MoneyPerCapacity(annual_fixed_cost),
279            &activity,
280            &activity_costs,
281        );
282
283        let expected = expected.map(MoneyPerActivity);
284        assert_approx_eq!(Option<MoneyPerActivity>, result, expected);
285    }
286}