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(
80 capacity: Capacity,
81 annual_fixed_cost: MoneyPerCapacity,
82 activity: &IndexMap<TimeSliceID, Activity>,
83 activity_costs: &IndexMap<TimeSliceID, MoneyPerActivity>,
84) -> Option<MoneyPerActivity> {
85 let annualised_fixed_cost = annual_fixed_cost * capacity;
87
88 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)] #[case(10, 0.0, 0.1)] #[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)] #[case(2000.0, 20, 0.0, 100.0)] 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 )]
151 #[case(
152 50.0, 100.0,
153 vec![("q1", "peak", 5.0)],
154 vec![("q1", "peak", 40.0)],
155 0.04 )]
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) )]
231 #[case(
232 50.0, 100.0,
233 vec![("winter", "day", 25.0)],
234 vec![("winter", "day", 0.0)],
235 Some(200.0) )]
237 #[case(
238 50.0, 100.0,
239 vec![("winter", "day", 0.0)],
240 vec![("winter", "day", 0.0)],
241 None )]
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}