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 an annual profitability index based on capacity and activity.
32pub fn profitability_index(
33    capacity: Capacity,
34    annual_fixed_cost: MoneyPerCapacity,
35    activity: &IndexMap<TimeSliceID, Activity>,
36    activity_surpluses: &IndexMap<TimeSliceID, MoneyPerActivity>,
37) -> Dimensionless {
38    // Calculate the annualised fixed costs
39    let annualised_fixed_cost = annual_fixed_cost * capacity;
40
41    // Calculate the total annualised surplus
42    let mut total_annualised_surplus = Money(0.0);
43    for (time_slice, activity) in activity {
44        let activity_surplus = activity_surpluses[time_slice];
45        total_annualised_surplus += activity_surplus * *activity;
46    }
47
48    total_annualised_surplus / annualised_fixed_cost
49}
50
51/// Calculates annual LCOX based on capacity and activity.
52pub fn lcox(
53    capacity: Capacity,
54    annual_fixed_cost: MoneyPerCapacity,
55    activity: &IndexMap<TimeSliceID, Activity>,
56    activity_costs: &IndexMap<TimeSliceID, MoneyPerActivity>,
57) -> MoneyPerActivity {
58    // Calculate the annualised fixed costs
59    let annualised_fixed_cost = annual_fixed_cost * capacity;
60
61    // Calculate the total activity costs
62    let mut total_activity_costs = Money(0.0);
63    let mut total_activity = Activity(0.0);
64    for (time_slice, activity) in activity {
65        let activity_cost = activity_costs[time_slice];
66        total_activity += *activity;
67        total_activity_costs += activity_cost * *activity;
68    }
69
70    (annualised_fixed_cost + total_activity_costs) / total_activity
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use crate::time_slice::TimeSliceID;
77    use float_cmp::assert_approx_eq;
78    use indexmap::indexmap;
79    use rstest::rstest;
80
81    #[rstest]
82    #[case(0, 0.05, 0.0)] // Edge case: lifetime==0
83    #[case(10, 0.0, 0.1)] // Other edge case: discount_rate==0
84    #[case(10, 0.05, 0.1295045749654567)]
85    #[case(5, 0.03, 0.2183545714005762)]
86    fn test_capital_recovery_factor(
87        #[case] lifetime: u32,
88        #[case] discount_rate: f64,
89        #[case] expected: f64,
90    ) {
91        let result = capital_recovery_factor(lifetime, Dimensionless(discount_rate));
92        assert_approx_eq!(f64, result.0, expected, epsilon = 1e-10);
93    }
94
95    #[rstest]
96    #[case(1000.0, 10, 0.05, 129.5045749654567)]
97    #[case(500.0, 5, 0.03, 109.17728570028798)]
98    #[case(1000.0, 0, 0.05, 0.0)] // Zero lifetime
99    #[case(2000.0, 20, 0.0, 100.0)] // Zero discount rate
100    fn test_annual_capital_cost(
101        #[case] capital_cost: f64,
102        #[case] lifetime: u32,
103        #[case] discount_rate: f64,
104        #[case] expected: f64,
105    ) {
106        let expected = MoneyPerCapacity(expected);
107        let result = annual_capital_cost(
108            MoneyPerCapacity(capital_cost),
109            lifetime,
110            Dimensionless(discount_rate),
111        );
112        assert_approx_eq!(MoneyPerCapacity, result, expected, epsilon = 1e-8);
113    }
114
115    #[rstest]
116    #[case(
117        100.0, 50.0,
118        vec![("winter", "day", 10.0), ("summer", "night", 15.0)],
119        vec![("winter", "day", 30.0), ("summer", "night", 20.0)],
120        0.12 // Expected PI: (10*30 + 15*20) / (100*50) = 600/5000 = 0.12
121    )]
122    #[case(
123        50.0, 100.0,
124        vec![("q1", "peak", 5.0)],
125        vec![("q1", "peak", 40.0)],
126        0.04 // Expected PI: (5*40) / (50*100) = 200/5000 = 0.04
127    )]
128    #[case(
129        0.0, 100.0,
130        vec![("winter", "day", 10.0)],
131        vec![("winter", "day", 50.0)],
132        f64::INFINITY // Zero capacity case
133    )]
134    fn test_profitability_index(
135        #[case] capacity: f64,
136        #[case] annual_fixed_cost: f64,
137        #[case] activity_data: Vec<(&str, &str, f64)>,
138        #[case] surplus_data: Vec<(&str, &str, f64)>,
139        #[case] expected: f64,
140    ) {
141        let activity = activity_data
142            .into_iter()
143            .map(|(season, time_of_day, value)| {
144                (
145                    TimeSliceID {
146                        season: season.into(),
147                        time_of_day: time_of_day.into(),
148                    },
149                    Activity(value),
150                )
151            })
152            .collect();
153
154        let activity_surpluses = surplus_data
155            .into_iter()
156            .map(|(season, time_of_day, value)| {
157                (
158                    TimeSliceID {
159                        season: season.into(),
160                        time_of_day: time_of_day.into(),
161                    },
162                    MoneyPerActivity(value),
163                )
164            })
165            .collect();
166
167        let result = profitability_index(
168            Capacity(capacity),
169            MoneyPerCapacity(annual_fixed_cost),
170            &activity,
171            &activity_surpluses,
172        );
173
174        assert_approx_eq!(Dimensionless, result, Dimensionless(expected));
175    }
176
177    #[test]
178    fn test_profitability_index_zero_activity() {
179        let capacity = Capacity(100.0);
180        let annual_fixed_cost = MoneyPerCapacity(50.0);
181        let activity = indexmap! {};
182        let activity_surpluses = indexmap! {};
183
184        let result =
185            profitability_index(capacity, annual_fixed_cost, &activity, &activity_surpluses);
186        assert_eq!(result, Dimensionless(0.0));
187    }
188
189    #[rstest]
190    #[case(
191        100.0, 50.0,
192        vec![("winter", "day", 10.0), ("summer", "night", 20.0)],
193        vec![("winter", "day", 5.0), ("summer", "night", 3.0)],
194        170.33333333333334 // (100*50 + 10*5 + 20*3) / (10+20) = 5110/30
195    )]
196    #[case(
197        50.0, 100.0,
198        vec![("winter", "day", 25.0)],
199        vec![("winter", "day", 0.0)],
200        200.0 // (50*100 + 25*0) / 25 = 5000/25
201    )]
202    fn test_lcox(
203        #[case] capacity: f64,
204        #[case] annual_fixed_cost: f64,
205        #[case] activity_data: Vec<(&str, &str, f64)>,
206        #[case] cost_data: Vec<(&str, &str, f64)>,
207        #[case] expected: f64,
208    ) {
209        let activity = activity_data
210            .into_iter()
211            .map(|(season, time_of_day, value)| {
212                (
213                    TimeSliceID {
214                        season: season.into(),
215                        time_of_day: time_of_day.into(),
216                    },
217                    Activity(value),
218                )
219            })
220            .collect();
221
222        let activity_costs = cost_data
223            .into_iter()
224            .map(|(season, time_of_day, value)| {
225                (
226                    TimeSliceID {
227                        season: season.into(),
228                        time_of_day: time_of_day.into(),
229                    },
230                    MoneyPerActivity(value),
231                )
232            })
233            .collect();
234
235        let result = lcox(
236            Capacity(capacity),
237            MoneyPerCapacity(annual_fixed_cost),
238            &activity,
239            &activity_costs,
240        );
241
242        let expected = MoneyPerActivity(expected);
243        assert_approx_eq!(MoneyPerActivity, result, expected);
244    }
245}