1use super::DemandMap;
3use crate::agent::ObjectiveType;
4use crate::asset::{AssetCapacity, AssetRef};
5use crate::commodity::Commodity;
6use crate::finance::{ProfitabilityIndex, lcox, profitability_index};
7use crate::model::Model;
8use crate::time_slice::TimeSliceID;
9use crate::units::{Activity, Money, MoneyPerActivity, MoneyPerCapacity};
10use anyhow::Result;
11use costs::annual_fixed_cost;
12use erased_serde::Serialize as ErasedSerialize;
13use indexmap::IndexMap;
14use serde::Serialize;
15use std::any::Any;
16use std::cmp::Ordering;
17
18pub mod coefficients;
19mod constraints;
20mod costs;
21mod optimisation;
22use coefficients::ObjectiveCoefficients;
23use float_cmp::approx_eq;
24use float_cmp::{ApproxEq, F64Margin};
25use optimisation::perform_optimisation;
26
27fn compare_approx<T>(a: T, b: T) -> Ordering
40where
41 T: Copy + PartialOrd + ApproxEq<Margin = F64Margin>,
42{
43 if a.approx_eq(b, F64Margin::default()) {
44 Ordering::Equal
45 } else {
46 a.partial_cmp(&b).expect("Cannot compare NaN values")
47 }
48}
49
50pub struct AppraisalOutput {
52 pub asset: AssetRef,
54 pub capacity: AssetCapacity,
56 pub activity: IndexMap<TimeSliceID, Activity>,
58 pub unmet_demand: DemandMap,
60 pub metric: Box<dyn MetricTrait>,
62 pub coefficients: ObjectiveCoefficients,
64 pub demand: DemandMap,
66}
67
68impl AppraisalOutput {
69 pub fn compare_metric(&self, other: &Self) -> Ordering {
78 assert!(
79 !(self.metric.value().is_nan() || other.metric.value().is_nan()),
80 "Appraisal metric cannot be NaN"
81 );
82 self.metric.compare(other.metric.as_ref())
83 }
84}
85
86pub trait MetricTrait: ComparableMetric + ErasedSerialize {}
88erased_serde::serialize_trait_object!(MetricTrait);
89
90pub trait ComparableMetric: Any + Send + Sync {
95 fn value(&self) -> f64;
97
98 fn compare(&self, other: &dyn ComparableMetric) -> Ordering;
108
109 fn as_any(&self) -> &dyn Any;
111}
112
113#[derive(Debug, Clone, Serialize)]
118pub struct LCOXMetric {
119 pub cost: MoneyPerActivity,
121}
122
123impl LCOXMetric {
124 pub fn new(cost: MoneyPerActivity) -> Self {
126 Self { cost }
127 }
128}
129
130impl ComparableMetric for LCOXMetric {
131 fn value(&self) -> f64 {
132 self.cost.value()
133 }
134
135 fn compare(&self, other: &dyn ComparableMetric) -> Ordering {
136 let other = other
137 .as_any()
138 .downcast_ref::<Self>()
139 .expect("Cannot compare metrics of different types");
140
141 compare_approx(self.cost, other.cost)
142 }
143
144 fn as_any(&self) -> &dyn Any {
145 self
146 }
147}
148
149impl MetricTrait for LCOXMetric {}
151
152#[derive(Debug, Clone, Serialize)]
154pub struct NPVMetric(ProfitabilityIndex);
155
156impl NPVMetric {
157 pub fn new(profitability_index: ProfitabilityIndex) -> Self {
159 Self(profitability_index)
160 }
161
162 fn is_zero_fixed_cost(&self) -> bool {
164 approx_eq!(Money, self.0.annualised_fixed_cost, Money(0.0))
165 }
166}
167
168impl ComparableMetric for NPVMetric {
169 fn value(&self) -> f64 {
170 if self.is_zero_fixed_cost() {
171 self.0.total_annualised_surplus.value()
172 } else {
173 self.0.value().value()
174 }
175 }
176
177 fn compare(&self, other: &dyn ComparableMetric) -> Ordering {
181 let other = other
182 .as_any()
183 .downcast_ref::<Self>()
184 .expect("Cannot compare metrics of different types");
185
186 match (self.is_zero_fixed_cost(), other.is_zero_fixed_cost()) {
188 (true, true) => {
190 let self_surplus = self.0.total_annualised_surplus;
191 let other_surplus = other.0.total_annualised_surplus;
192 compare_approx(other_surplus, self_surplus)
193 }
194 (false, false) => {
196 let self_pi = self.0.value();
197 let other_pi = other.0.value();
198 compare_approx(other_pi, self_pi)
199 }
200 (true, false) => Ordering::Less,
202 (false, true) => Ordering::Greater,
203 }
204 }
205
206 fn as_any(&self) -> &dyn Any {
207 self
208 }
209}
210
211impl MetricTrait for NPVMetric {}
213
214fn calculate_lcox(
224 model: &Model,
225 asset: &AssetRef,
226 max_capacity: Option<AssetCapacity>,
227 commodity: &Commodity,
228 coefficients: &ObjectiveCoefficients,
229 demand: &DemandMap,
230) -> Result<AppraisalOutput> {
231 let results = perform_optimisation(
232 asset,
233 max_capacity,
234 commodity,
235 coefficients,
236 demand,
237 &model.time_slice_info,
238 highs::Sense::Minimise,
239 )?;
240
241 let cost_index = lcox(
242 results.capacity.total_capacity(),
243 coefficients.capacity_coefficient,
244 &results.activity,
245 &coefficients.activity_coefficients,
246 );
247
248 Ok(AppraisalOutput {
249 asset: asset.clone(),
250 capacity: results.capacity,
251 activity: results.activity,
252 unmet_demand: results.unmet_demand,
253 metric: Box::new(LCOXMetric::new(cost_index)),
254 coefficients: coefficients.clone(),
255 demand: demand.clone(),
256 })
257}
258
259fn calculate_npv(
265 model: &Model,
266 asset: &AssetRef,
267 max_capacity: Option<AssetCapacity>,
268 commodity: &Commodity,
269 coefficients: &ObjectiveCoefficients,
270 demand: &DemandMap,
271) -> Result<AppraisalOutput> {
272 let results = perform_optimisation(
273 asset,
274 max_capacity,
275 commodity,
276 coefficients,
277 demand,
278 &model.time_slice_info,
279 highs::Sense::Maximise,
280 )?;
281
282 let annual_fixed_cost = annual_fixed_cost(asset);
283 assert!(
284 annual_fixed_cost >= MoneyPerCapacity(0.0),
285 "The current NPV calculation does not support negative annual fixed costs"
286 );
287
288 let profitability_index = profitability_index(
289 results.capacity.total_capacity(),
290 annual_fixed_cost,
291 &results.activity,
292 &coefficients.activity_coefficients,
293 );
294
295 Ok(AppraisalOutput {
296 asset: asset.clone(),
297 capacity: results.capacity,
298 activity: results.activity,
299 unmet_demand: results.unmet_demand,
300 metric: Box::new(NPVMetric::new(profitability_index)),
301 coefficients: coefficients.clone(),
302 demand: demand.clone(),
303 })
304}
305
306pub fn appraise_investment(
313 model: &Model,
314 asset: &AssetRef,
315 max_capacity: Option<AssetCapacity>,
316 commodity: &Commodity,
317 objective_type: &ObjectiveType,
318 coefficients: &ObjectiveCoefficients,
319 demand: &DemandMap,
320) -> Result<AppraisalOutput> {
321 let appraisal_method = match objective_type {
322 ObjectiveType::LevelisedCostOfX => calculate_lcox,
323 ObjectiveType::NetPresentValue => calculate_npv,
324 };
325 appraisal_method(model, asset, max_capacity, commodity, coefficients, demand)
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use crate::finance::ProfitabilityIndex;
332 use crate::units::{Money, MoneyPerActivity};
333 use rstest::rstest;
334
335 #[rstest]
337 #[case(10.0, 10.0, Ordering::Equal, "equal_costs")]
338 #[case(5.0, 10.0, Ordering::Less, "first_lower_cost_is_better")]
339 #[case(10.0, 5.0, Ordering::Greater, "second_lower_cost_is_better")]
340 fn lcox_metric_comparison(
341 #[case] cost1: f64,
342 #[case] cost2: f64,
343 #[case] expected: Ordering,
344 #[case] description: &str,
345 ) {
346 let metric1 = LCOXMetric::new(MoneyPerActivity(cost1));
347 let metric2 = LCOXMetric::new(MoneyPerActivity(cost2));
348
349 assert_eq!(
350 metric1.compare(&metric2),
351 expected,
352 "Failed comparison for case: {description}"
353 );
354 }
355
356 #[rstest]
358 #[case(100.0, 0.0, 50.0, 0.0, Ordering::Less, "both_zero_afc_first_better")]
360 #[case(
361 50.0,
362 0.0,
363 100.0,
364 0.0,
365 Ordering::Greater,
366 "both_zero_afc_second_better"
367 )]
368 #[case(100.0, 0.0, 100.0, 0.0, Ordering::Equal, "both_zero_afc_equal")]
369 #[case(
371 100.0,
372 1e-10,
373 50.0,
374 1e-10,
375 Ordering::Less,
376 "both_approx_zero_afc_first_better"
377 )]
378 #[case(
379 100.0,
380 1e-10,
381 200.0,
382 50.0,
383 Ordering::Less,
384 "approx_zero_afc_beats_nonzero"
385 )]
386 #[case(
387 200.0,
388 50.0,
389 100.0,
390 1e-10,
391 Ordering::Greater,
392 "nonzero_afc_loses_to_approx_zero"
393 )]
394 #[case(
396 200.0,
397 100.0,
398 150.0,
399 100.0,
400 Ordering::Less,
401 "both_nonzero_afc_first_better"
402 )]
403 #[case(
404 150.0,
405 100.0,
406 200.0,
407 100.0,
408 Ordering::Greater,
409 "both_nonzero_afc_second_better"
410 )]
411 #[case(200.0, 100.0, 200.0, 100.0, Ordering::Equal, "both_nonzero_afc_equal")]
412 #[case(
414 10.0,
415 0.0,
416 1000.0,
417 100.0,
418 Ordering::Less,
419 "first_zero_afc_beats_second_nonzero_afc"
420 )]
421 #[case(
422 10.0,
423 1e-10,
424 1000.0,
425 100.0,
426 Ordering::Less,
427 "first_approx_zero_afc_beats_second_nonzero_afc"
428 )]
429 #[case(
430 1000.0,
431 100.0,
432 10.0,
433 0.0,
434 Ordering::Greater,
435 "second_zero_afc_beats_first_nonzero_afc"
436 )]
437 #[case(
438 1000.0,
439 100.0,
440 10.0,
441 1e-10,
442 Ordering::Greater,
443 "second_nonzero_afc_beats_first_approx_zero_afc"
444 )]
445 fn npv_metric_comparison(
446 #[case] surplus1: f64,
447 #[case] fixed_cost1: f64,
448 #[case] surplus2: f64,
449 #[case] fixed_cost2: f64,
450 #[case] expected: Ordering,
451 #[case] description: &str,
452 ) {
453 let metric1 = NPVMetric::new(ProfitabilityIndex {
454 total_annualised_surplus: Money(surplus1),
455 annualised_fixed_cost: Money(fixed_cost1),
456 });
457 let metric2 = NPVMetric::new(ProfitabilityIndex {
458 total_annualised_surplus: Money(surplus2),
459 annualised_fixed_cost: Money(fixed_cost2),
460 });
461
462 assert_eq!(
463 metric1.compare(&metric2),
464 expected,
465 "Failed comparison for case: {description}"
466 );
467 }
468}