1use crate::time_slice::TimeSliceID;
3use crate::units::{Activity, Capacity, Dimensionless, Money, MoneyPerActivity, MoneyPerCapacity};
4use indexmap::IndexMap;
5use serde::Serialize;
6
7pub 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
22pub 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#[derive(Debug, Clone, Copy, Serialize)]
35pub struct ProfitabilityIndex {
36 pub total_annualised_surplus: Money,
38 pub annualised_fixed_cost: Money,
40}
41
42impl ProfitabilityIndex {
43 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
53pub 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 let annualised_fixed_cost = annual_fixed_cost * capacity;
62
63 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
76pub fn lcox(
78 capacity: Capacity,
79 annual_fixed_cost: MoneyPerCapacity,
80 activity: &IndexMap<TimeSliceID, Activity>,
81 activity_costs: &IndexMap<TimeSliceID, MoneyPerActivity>,
82) -> MoneyPerActivity {
83 let annualised_fixed_cost = annual_fixed_cost * capacity;
85
86 let mut total_activity_costs = Money(0.0);
88 let mut total_activity = Activity(0.0);
89 for (time_slice, activity) in activity {
90 let activity_cost = activity_costs[time_slice];
91 total_activity += *activity;
92 total_activity_costs += activity_cost * *activity;
93 }
94
95 (annualised_fixed_cost + total_activity_costs) / total_activity
96}
97
98#[cfg(test)]
99#[allow(clippy::unreadable_literal)]
100mod tests {
101 use super::*;
102 use crate::time_slice::TimeSliceID;
103 use float_cmp::assert_approx_eq;
104 use indexmap::indexmap;
105 use rstest::rstest;
106
107 #[rstest]
108 #[case(0, 0.05, 0.0)] #[case(10, 0.0, 0.1)] #[case(10, 0.05, 0.1295045749654567)]
111 #[case(5, 0.03, 0.2183545714005762)]
112 fn capital_recovery_factor_works(
113 #[case] lifetime: u32,
114 #[case] discount_rate: f64,
115 #[case] expected: f64,
116 ) {
117 let result = capital_recovery_factor(lifetime, Dimensionless(discount_rate));
118 assert_approx_eq!(f64, result.0, expected, epsilon = 1e-10);
119 }
120
121 #[rstest]
122 #[case(1000.0, 10, 0.05, 129.5045749654567)]
123 #[case(500.0, 5, 0.03, 109.17728570028798)]
124 #[case(1000.0, 0, 0.05, 0.0)] #[case(2000.0, 20, 0.0, 100.0)] fn annual_capital_cost_works(
127 #[case] capital_cost: f64,
128 #[case] lifetime: u32,
129 #[case] discount_rate: f64,
130 #[case] expected: f64,
131 ) {
132 let expected = MoneyPerCapacity(expected);
133 let result = annual_capital_cost(
134 MoneyPerCapacity(capital_cost),
135 lifetime,
136 Dimensionless(discount_rate),
137 );
138 assert_approx_eq!(MoneyPerCapacity, result, expected, epsilon = 1e-8);
139 }
140
141 #[rstest]
142 #[case(
143 100.0, 50.0,
144 vec![("winter", "day", 10.0), ("summer", "night", 15.0)],
145 vec![("winter", "day", 30.0), ("summer", "night", 20.0)],
146 0.12 )]
148 #[case(
149 50.0, 100.0,
150 vec![("q1", "peak", 5.0)],
151 vec![("q1", "peak", 40.0)],
152 0.04 )]
154 fn profitability_index_works(
155 #[case] capacity: f64,
156 #[case] annual_fixed_cost: f64,
157 #[case] activity_data: Vec<(&str, &str, f64)>,
158 #[case] surplus_data: Vec<(&str, &str, f64)>,
159 #[case] expected: f64,
160 ) {
161 let activity = activity_data
162 .into_iter()
163 .map(|(season, time_of_day, value)| {
164 (
165 TimeSliceID {
166 season: season.into(),
167 time_of_day: time_of_day.into(),
168 },
169 Activity(value),
170 )
171 })
172 .collect();
173
174 let activity_surpluses = surplus_data
175 .into_iter()
176 .map(|(season, time_of_day, value)| {
177 (
178 TimeSliceID {
179 season: season.into(),
180 time_of_day: time_of_day.into(),
181 },
182 MoneyPerActivity(value),
183 )
184 })
185 .collect();
186
187 let result = profitability_index(
188 Capacity(capacity),
189 MoneyPerCapacity(annual_fixed_cost),
190 &activity,
191 &activity_surpluses,
192 );
193
194 assert_approx_eq!(Dimensionless, result.value(), Dimensionless(expected));
195 }
196
197 #[test]
198 fn profitability_index_zero_activity() {
199 let capacity = Capacity(100.0);
200 let annual_fixed_cost = MoneyPerCapacity(50.0);
201 let activity = indexmap! {};
202 let activity_surpluses = indexmap! {};
203
204 let result =
205 profitability_index(capacity, annual_fixed_cost, &activity, &activity_surpluses);
206 assert_eq!(result.value(), Dimensionless(0.0));
207 }
208
209 #[test]
210 #[should_panic(expected = "Annualised fixed cost cannot be zero")]
211 fn profitability_index_panics_on_zero_cost() {
212 let result = profitability_index(
213 Capacity(0.0),
214 MoneyPerCapacity(100.0),
215 &indexmap! {},
216 &indexmap! {},
217 );
218 result.value();
219 }
220
221 #[rstest]
222 #[case(
223 100.0, 50.0,
224 vec![("winter", "day", 10.0), ("summer", "night", 20.0)],
225 vec![("winter", "day", 5.0), ("summer", "night", 3.0)],
226 170.33333333333334 )]
228 #[case(
229 50.0, 100.0,
230 vec![("winter", "day", 25.0)],
231 vec![("winter", "day", 0.0)],
232 200.0 )]
234 fn lcox_works(
235 #[case] capacity: f64,
236 #[case] annual_fixed_cost: f64,
237 #[case] activity_data: Vec<(&str, &str, f64)>,
238 #[case] cost_data: Vec<(&str, &str, f64)>,
239 #[case] expected: f64,
240 ) {
241 let activity = activity_data
242 .into_iter()
243 .map(|(season, time_of_day, value)| {
244 (
245 TimeSliceID {
246 season: season.into(),
247 time_of_day: time_of_day.into(),
248 },
249 Activity(value),
250 )
251 })
252 .collect();
253
254 let activity_costs = cost_data
255 .into_iter()
256 .map(|(season, time_of_day, value)| {
257 (
258 TimeSliceID {
259 season: season.into(),
260 time_of_day: time_of_day.into(),
261 },
262 MoneyPerActivity(value),
263 )
264 })
265 .collect();
266
267 let result = lcox(
268 Capacity(capacity),
269 MoneyPerCapacity(annual_fixed_cost),
270 &activity,
271 &activity_costs,
272 );
273
274 let expected = MoneyPerActivity(expected);
275 assert_approx_eq!(MoneyPerActivity, result, expected);
276 }
277}