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