1use super::DemandMap;
3use crate::agent::ObjectiveType;
4use crate::asset::{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, Capacity, Money, MoneyPerActivity, MoneyPerCapacity};
10use anyhow::Result;
11use costs::annual_fixed_cost;
12use erased_serde::Serialize as ErasedSerialize;
13use indexmap::IndexMap;
14use optimisation::ResultsMap;
15use serde::Serialize;
16use std::any::Any;
17use std::cmp::Ordering;
18use std::rc::Rc;
19
20pub mod coefficients;
21mod constraints;
22mod costs;
23mod optimisation;
24use coefficients::ObjectiveCoefficients;
25use float_cmp::approx_eq;
26use float_cmp::{ApproxEq, F64Margin};
27use optimisation::perform_optimisation;
28
29fn compare_approx<T>(a: T, b: T) -> Ordering
42where
43 T: Copy + PartialOrd + ApproxEq<Margin = F64Margin>,
44{
45 if a.approx_eq(b, F64Margin::default()) {
46 Ordering::Equal
47 } else {
48 a.partial_cmp(&b).expect("Cannot compare NaN values")
49 }
50}
51
52pub struct AppraisalOutput {
54 pub asset: AssetRef,
56 pub capacity: AssetCapacity,
58 pub activity: IndexMap<TimeSliceID, Activity>,
60 pub unmet_demand: DemandMap,
62 pub metric: Option<Box<dyn MetricTrait>>,
64 pub coefficients: Rc<ObjectiveCoefficients>,
66}
67
68impl AppraisalOutput {
69 fn new<T: MetricTrait>(
71 asset: AssetRef,
72 capacity: AssetCapacity,
73 results: ResultsMap,
74 metric: Option<T>,
75 coefficients: Rc<ObjectiveCoefficients>,
76 ) -> Self {
77 Self {
78 asset,
79 capacity,
80 activity: results.activity,
81 unmet_demand: results.unmet_demand,
82 metric: metric.map(|m| Box::new(m) as Box<dyn MetricTrait>),
83 coefficients,
84 }
85 }
86 pub fn compare_metric(&self, other: &Self) -> Ordering {
95 assert!(
96 self.is_valid() && other.is_valid(),
97 "Cannot compare non-valid outputs"
98 );
99
100 self.metric
102 .as_ref()
103 .unwrap()
104 .compare(other.metric.as_ref().unwrap().as_ref())
105 }
106
107 pub fn is_valid(&self) -> bool {
112 self.metric.is_some() && self.capacity.total_capacity() > Capacity(0.0)
113 }
114}
115
116pub trait MetricTrait: ComparableMetric + ErasedSerialize {}
118erased_serde::serialize_trait_object!(MetricTrait);
119
120pub trait ComparableMetric: Any + Send + Sync {
125 fn value(&self) -> f64;
127
128 fn compare(&self, other: &dyn ComparableMetric) -> Ordering;
138
139 fn as_any(&self) -> &dyn Any;
141}
142
143#[derive(Debug, Clone, Serialize)]
148pub struct LCOXMetric {
149 pub cost: MoneyPerActivity,
151}
152
153impl LCOXMetric {
154 pub fn new(cost: MoneyPerActivity) -> Self {
156 Self { cost }
157 }
158}
159
160impl ComparableMetric for LCOXMetric {
161 fn value(&self) -> f64 {
162 self.cost.value()
163 }
164
165 fn compare(&self, other: &dyn ComparableMetric) -> Ordering {
166 let other = other
167 .as_any()
168 .downcast_ref::<Self>()
169 .expect("Cannot compare metrics of different types");
170
171 compare_approx(self.cost, other.cost)
172 }
173
174 fn as_any(&self) -> &dyn Any {
175 self
176 }
177}
178
179impl MetricTrait for LCOXMetric {}
181
182#[derive(Debug, Clone, Serialize)]
184pub struct NPVMetric(ProfitabilityIndex);
185
186impl NPVMetric {
187 pub fn new(profitability_index: ProfitabilityIndex) -> Self {
189 Self(profitability_index)
190 }
191
192 fn is_zero_fixed_cost(&self) -> bool {
194 approx_eq!(Money, self.0.annualised_fixed_cost, Money(0.0))
195 }
196}
197
198impl ComparableMetric for NPVMetric {
199 fn value(&self) -> f64 {
200 if self.is_zero_fixed_cost() {
201 self.0.total_annualised_surplus.value()
202 } else {
203 self.0.value().value()
204 }
205 }
206
207 fn compare(&self, other: &dyn ComparableMetric) -> Ordering {
211 let other = other
212 .as_any()
213 .downcast_ref::<Self>()
214 .expect("Cannot compare metrics of different types");
215
216 match (self.is_zero_fixed_cost(), other.is_zero_fixed_cost()) {
218 (true, true) => {
220 let self_surplus = self.0.total_annualised_surplus;
221 let other_surplus = other.0.total_annualised_surplus;
222 compare_approx(other_surplus, self_surplus)
223 }
224 (false, false) => {
226 let self_pi = self.0.value();
227 let other_pi = other.0.value();
228 compare_approx(other_pi, self_pi)
229 }
230 (true, false) => Ordering::Less,
232 (false, true) => Ordering::Greater,
233 }
234 }
235
236 fn as_any(&self) -> &dyn Any {
237 self
238 }
239}
240
241impl MetricTrait for NPVMetric {}
243
244fn calculate_lcox(
254 model: &Model,
255 asset: &AssetRef,
256 max_capacity: AssetCapacity,
257 commodity: &Commodity,
258 coefficients: &Rc<ObjectiveCoefficients>,
259 demand: &DemandMap,
260) -> Result<AppraisalOutput> {
261 let results = perform_optimisation(
262 model,
263 asset,
264 max_capacity,
265 commodity,
266 coefficients,
267 demand,
268 highs::Sense::Minimise,
269 )?;
270
271 let cost_index = lcox(
272 results.capacity.total_capacity(),
273 coefficients.capacity_coefficient,
274 &results.activity,
275 &coefficients.activity_coefficients,
276 );
277
278 Ok(AppraisalOutput::new(
279 asset.clone(),
280 results.capacity,
281 results,
282 cost_index.map(LCOXMetric::new),
283 coefficients.clone(),
284 ))
285}
286
287fn calculate_npv(
293 model: &Model,
294 asset: &AssetRef,
295 max_capacity: AssetCapacity,
296 commodity: &Commodity,
297 coefficients: &Rc<ObjectiveCoefficients>,
298 demand: &DemandMap,
299) -> Result<AppraisalOutput> {
300 let results = perform_optimisation(
301 model,
302 asset,
303 max_capacity,
304 commodity,
305 coefficients,
306 demand,
307 highs::Sense::Maximise,
308 )?;
309
310 let annual_fixed_cost = annual_fixed_cost(asset);
311 assert!(
312 annual_fixed_cost >= MoneyPerCapacity(0.0),
313 "The current NPV calculation does not support negative annual fixed costs"
314 );
315
316 let profitability_index = profitability_index(
317 results.capacity.total_capacity(),
318 annual_fixed_cost,
319 &results.activity,
320 &coefficients.activity_coefficients,
321 );
322
323 Ok(AppraisalOutput::new(
324 asset.clone(),
325 max_capacity,
326 results,
327 Some(NPVMetric::new(profitability_index)),
328 coefficients.clone(),
329 ))
330}
331
332pub fn appraise_investment(
339 model: &Model,
340 asset: &AssetRef,
341 max_capacity: Option<AssetCapacity>,
342 commodity: &Commodity,
343 objective_type: &ObjectiveType,
344 coefficients: &Rc<ObjectiveCoefficients>,
345 demand: &DemandMap,
346) -> Result<AppraisalOutput> {
347 let max_capacity = max_capacity.unwrap_or(asset.capacity());
348 let appraisal_method = match objective_type {
349 ObjectiveType::LevelisedCostOfX => calculate_lcox,
350 ObjectiveType::NetPresentValue => calculate_npv,
351 };
352 appraisal_method(model, asset, max_capacity, commodity, coefficients, demand)
353}
354
355fn compare_asset_fallback(asset1: &Asset, asset2: &Asset) -> Ordering {
361 (asset2.is_commissioned(), asset2.commission_year())
362 .cmp(&(asset1.is_commissioned(), asset1.commission_year()))
363}
364
365pub fn sort_and_filter_appraisal_outputs(outputs_for_opts: &mut Vec<AppraisalOutput>) {
377 outputs_for_opts.retain(AppraisalOutput::is_valid);
378 outputs_for_opts.sort_by(|output1, output2| match output1.compare_metric(output2) {
379 Ordering::Equal => compare_asset_fallback(&output1.asset, &output2.asset),
381 cmp => cmp,
382 });
383}
384
385pub fn count_equal_and_best_appraisal_outputs(outputs: &[AppraisalOutput]) -> usize {
388 if outputs.is_empty() {
389 return 0;
390 }
391 outputs[1..]
392 .iter()
393 .take_while(|output| {
394 output.compare_metric(&outputs[0]).is_eq()
395 && compare_asset_fallback(&output.asset, &outputs[0].asset).is_eq()
396 })
397 .count()
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403 use crate::agent::AgentID;
404 use crate::finance::ProfitabilityIndex;
405 use crate::fixture::{agent_id, asset, process, region_id};
406 use crate::process::Process;
407 use crate::region::RegionID;
408 use crate::units::{Money, MoneyPerActivity, MoneyPerFlow};
409 use float_cmp::assert_approx_eq;
410 use rstest::rstest;
411 use std::rc::Rc;
412
413 #[rstest]
415 #[case(10.0, 10.0, Ordering::Equal, "equal_costs")]
416 #[case(5.0, 10.0, Ordering::Less, "first_lower_cost_is_better")]
417 #[case(10.0, 5.0, Ordering::Greater, "second_lower_cost_is_better")]
418 fn lcox_metric_comparison(
419 #[case] cost1: f64,
420 #[case] cost2: f64,
421 #[case] expected: Ordering,
422 #[case] description: &str,
423 ) {
424 let metric1 = LCOXMetric::new(MoneyPerActivity(cost1));
425 let metric2 = LCOXMetric::new(MoneyPerActivity(cost2));
426
427 assert_eq!(
428 metric1.compare(&metric2),
429 expected,
430 "Failed comparison for case: {description}"
431 );
432 }
433
434 #[rstest]
436 #[case(100.0, 0.0, 50.0, 0.0, Ordering::Less, "both_zero_afc_first_better")]
438 #[case(
439 50.0,
440 0.0,
441 100.0,
442 0.0,
443 Ordering::Greater,
444 "both_zero_afc_second_better"
445 )]
446 #[case(100.0, 0.0, 100.0, 0.0, Ordering::Equal, "both_zero_afc_equal")]
447 #[case(
449 100.0,
450 1e-10,
451 50.0,
452 1e-10,
453 Ordering::Less,
454 "both_approx_zero_afc_first_better"
455 )]
456 #[case(
457 100.0,
458 1e-10,
459 200.0,
460 50.0,
461 Ordering::Less,
462 "approx_zero_afc_beats_nonzero"
463 )]
464 #[case(
465 200.0,
466 50.0,
467 100.0,
468 1e-10,
469 Ordering::Greater,
470 "nonzero_afc_loses_to_approx_zero"
471 )]
472 #[case(
474 200.0,
475 100.0,
476 150.0,
477 100.0,
478 Ordering::Less,
479 "both_nonzero_afc_first_better"
480 )]
481 #[case(
482 150.0,
483 100.0,
484 200.0,
485 100.0,
486 Ordering::Greater,
487 "both_nonzero_afc_second_better"
488 )]
489 #[case(200.0, 100.0, 200.0, 100.0, Ordering::Equal, "both_nonzero_afc_equal")]
490 #[case(
492 10.0,
493 0.0,
494 1000.0,
495 100.0,
496 Ordering::Less,
497 "first_zero_afc_beats_second_nonzero_afc"
498 )]
499 #[case(
500 10.0,
501 1e-10,
502 1000.0,
503 100.0,
504 Ordering::Less,
505 "first_approx_zero_afc_beats_second_nonzero_afc"
506 )]
507 #[case(
508 1000.0,
509 100.0,
510 10.0,
511 0.0,
512 Ordering::Greater,
513 "second_zero_afc_beats_first_nonzero_afc"
514 )]
515 #[case(
516 1000.0,
517 100.0,
518 10.0,
519 1e-10,
520 Ordering::Greater,
521 "second_nonzero_afc_beats_first_approx_zero_afc"
522 )]
523 fn npv_metric_comparison(
524 #[case] surplus1: f64,
525 #[case] fixed_cost1: f64,
526 #[case] surplus2: f64,
527 #[case] fixed_cost2: f64,
528 #[case] expected: Ordering,
529 #[case] description: &str,
530 ) {
531 let metric1 = NPVMetric::new(ProfitabilityIndex {
532 total_annualised_surplus: Money(surplus1),
533 annualised_fixed_cost: Money(fixed_cost1),
534 });
535 let metric2 = NPVMetric::new(ProfitabilityIndex {
536 total_annualised_surplus: Money(surplus2),
537 annualised_fixed_cost: Money(fixed_cost2),
538 });
539
540 assert_eq!(
541 metric1.compare(&metric2),
542 expected,
543 "Failed comparison for case: {description}"
544 );
545 }
546
547 #[rstest]
548 fn compare_assets_fallback(process: Process, region_id: RegionID, agent_id: AgentID) {
549 let process = Rc::new(process);
550 let capacity = Capacity(2.0);
551 let asset1 = Asset::new_commissioned(
552 agent_id.clone(),
553 process.clone(),
554 region_id.clone(),
555 capacity,
556 2015,
557 )
558 .unwrap();
559 let asset2 =
560 Asset::new_candidate(process.clone(), region_id.clone(), capacity, 2015).unwrap();
561 let asset3 =
562 Asset::new_commissioned(agent_id, process, region_id.clone(), capacity, 2010).unwrap();
563
564 assert!(compare_asset_fallback(&asset1, &asset1).is_eq());
565 assert!(compare_asset_fallback(&asset2, &asset2).is_eq());
566 assert!(compare_asset_fallback(&asset3, &asset3).is_eq());
567 assert!(compare_asset_fallback(&asset1, &asset2).is_lt());
568 assert!(compare_asset_fallback(&asset2, &asset1).is_gt());
569 assert!(compare_asset_fallback(&asset1, &asset3).is_lt());
570 assert!(compare_asset_fallback(&asset3, &asset1).is_gt());
571 assert!(compare_asset_fallback(&asset3, &asset2).is_lt());
572 assert!(compare_asset_fallback(&asset2, &asset3).is_gt());
573 }
574
575 fn objective_coeffs() -> Rc<ObjectiveCoefficients> {
576 Rc::new(ObjectiveCoefficients {
577 capacity_coefficient: MoneyPerCapacity(0.0),
578 activity_coefficients: IndexMap::new(),
579 unmet_demand_coefficient: MoneyPerFlow(0.0),
580 })
581 }
582
583 fn appraisal_outputs(
589 assets: Vec<Asset>,
590 metrics: Vec<Box<dyn MetricTrait>>,
591 ) -> Vec<AppraisalOutput> {
592 assert_eq!(
593 assets.len(),
594 metrics.len(),
595 "assets and metrics must have the same length"
596 );
597
598 assets
599 .into_iter()
600 .zip(metrics)
601 .map(|(asset, metric)| AppraisalOutput {
602 asset: AssetRef::from(asset),
603 capacity: AssetCapacity::Continuous(Capacity(10.0)),
604 coefficients: objective_coeffs(),
605 activity: IndexMap::new(),
606 unmet_demand: IndexMap::new(),
607 metric: Some(metric),
608 })
609 .collect()
610 }
611
612 fn appraisal_outputs_with_investment_priority_invariant_to_assets(
615 metrics: Vec<Box<dyn MetricTrait>>,
616 asset: &Asset,
617 ) -> Vec<AppraisalOutput> {
618 let assets = vec![asset.clone(); metrics.len()];
619 appraisal_outputs(assets, metrics)
620 }
621
622 #[rstest]
624 fn appraisal_sort_by_lcox_metric(asset: Asset) {
625 let metrics: Vec<Box<dyn MetricTrait>> = vec![
626 Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
627 Box::new(LCOXMetric::new(MoneyPerActivity(3.0))),
628 Box::new(LCOXMetric::new(MoneyPerActivity(7.0))),
629 ];
630
631 let mut outputs =
632 appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset);
633 sort_and_filter_appraisal_outputs(&mut outputs);
634
635 assert_approx_eq!(f64, outputs[0].metric.as_ref().unwrap().value(), 3.0); assert_approx_eq!(f64, outputs[1].metric.as_ref().unwrap().value(), 5.0);
637 assert_approx_eq!(f64, outputs[2].metric.as_ref().unwrap().value(), 7.0); }
639
640 #[rstest]
642 fn appraisal_sort_by_npv_metric(asset: Asset) {
643 let metrics: Vec<Box<dyn MetricTrait>> = vec![
644 Box::new(NPVMetric::new(ProfitabilityIndex {
645 total_annualised_surplus: Money(200.0),
646 annualised_fixed_cost: Money(100.0),
647 })),
648 Box::new(NPVMetric::new(ProfitabilityIndex {
649 total_annualised_surplus: Money(300.0),
650 annualised_fixed_cost: Money(100.0),
651 })),
652 Box::new(NPVMetric::new(ProfitabilityIndex {
653 total_annualised_surplus: Money(150.0),
654 annualised_fixed_cost: Money(100.0),
655 })),
656 ];
657
658 let mut outputs =
659 appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset);
660 sort_and_filter_appraisal_outputs(&mut outputs);
661
662 assert_approx_eq!(f64, outputs[0].metric.as_ref().unwrap().value(), 3.0); assert_approx_eq!(f64, outputs[1].metric.as_ref().unwrap().value(), 2.0);
665 assert_approx_eq!(f64, outputs[2].metric.as_ref().unwrap().value(), 1.5); }
667
668 #[rstest]
671 fn appraisal_sort_by_npv_metric_zero_afc_prioritised(asset: Asset) {
672 let metrics: Vec<Box<dyn MetricTrait>> = vec![
673 Box::new(NPVMetric::new(ProfitabilityIndex {
675 total_annualised_surplus: Money(1000.0),
676 annualised_fixed_cost: Money(100.0),
677 })),
678 Box::new(NPVMetric::new(ProfitabilityIndex {
680 total_annualised_surplus: Money(50.0),
681 annualised_fixed_cost: Money(0.0),
682 })),
683 Box::new(NPVMetric::new(ProfitabilityIndex {
685 total_annualised_surplus: Money(500.0),
686 annualised_fixed_cost: Money(50.0),
687 })),
688 ];
689
690 let mut outputs =
691 appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset);
692 sort_and_filter_appraisal_outputs(&mut outputs);
693
694 assert_approx_eq!(f64, outputs[0].metric.as_ref().unwrap().value(), 50.0); assert_approx_eq!(f64, outputs[1].metric.as_ref().unwrap().value(), 10.0); assert_approx_eq!(f64, outputs[2].metric.as_ref().unwrap().value(), 10.0); }
699
700 #[rstest]
702 #[should_panic(expected = "Cannot compare metrics of different types")]
703 fn appraisal_sort_by_mixed_metrics_panics(asset: Asset) {
704 let metrics: Vec<Box<dyn MetricTrait>> = vec![
705 Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
706 Box::new(NPVMetric::new(ProfitabilityIndex {
707 total_annualised_surplus: Money(200.0),
708 annualised_fixed_cost: Money(100.0),
709 })),
710 Box::new(LCOXMetric::new(MoneyPerActivity(3.0))),
711 ];
712
713 let mut outputs =
714 appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset);
715 sort_and_filter_appraisal_outputs(&mut outputs);
717 }
718
719 #[rstest]
721 fn appraisal_sort_by_commission_year_when_metrics_equal(
722 process: Process,
723 region_id: RegionID,
724 agent_id: AgentID,
725 ) {
726 let process_rc = Rc::new(process);
727 let capacity = Capacity(10.0);
728 let commission_years = [2015, 2020, 2010];
729
730 let assets: Vec<_> = commission_years
731 .iter()
732 .map(|&year| {
733 Asset::new_commissioned(
734 agent_id.clone(),
735 process_rc.clone(),
736 region_id.clone(),
737 capacity,
738 year,
739 )
740 .unwrap()
741 })
742 .collect();
743
744 let metrics: Vec<Box<dyn MetricTrait>> = vec![
746 Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
747 Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
748 Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
749 ];
750
751 let mut outputs = appraisal_outputs(assets, metrics);
752 sort_and_filter_appraisal_outputs(&mut outputs);
753
754 assert_eq!(outputs[0].asset.commission_year(), 2020);
756 assert_eq!(outputs[1].asset.commission_year(), 2015);
757 assert_eq!(outputs[2].asset.commission_year(), 2010);
758 }
759
760 #[rstest]
762 fn appraisal_sort_maintains_order_when_all_equal(process: Process, region_id: RegionID) {
763 let process_rc = Rc::new(process);
764 let capacity = Capacity(10.0);
765 let commission_year = 2015;
766 let agent_ids = ["agent1", "agent2", "agent3"];
767
768 let assets: Vec<_> = agent_ids
769 .iter()
770 .map(|&id| {
771 Asset::new_commissioned(
772 AgentID(id.into()),
773 process_rc.clone(),
774 region_id.clone(),
775 capacity,
776 commission_year,
777 )
778 .unwrap()
779 })
780 .collect();
781
782 let metrics: Vec<Box<dyn MetricTrait>> = vec![
783 Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
784 Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
785 Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
786 ];
787
788 let mut outputs = appraisal_outputs(assets.clone(), metrics);
789 sort_and_filter_appraisal_outputs(&mut outputs);
790
791 for (&expected_id, output) in agent_ids.iter().zip(outputs) {
793 assert_eq!(output.asset.agent_id(), Some(&AgentID(expected_id.into())));
794 }
795 }
796
797 #[rstest]
799 fn appraisal_sort_commissioned_before_uncommissioned_when_metrics_equal(
800 process: Process,
801 region_id: RegionID,
802 agent_id: AgentID,
803 ) {
804 let process_rc = Rc::new(process);
805 let capacity = Capacity(10.0);
806
807 let commissioned_asset_newer = Asset::new_commissioned(
809 agent_id.clone(),
810 process_rc.clone(),
811 region_id.clone(),
812 capacity,
813 2020,
814 )
815 .unwrap();
816
817 let commissioned_asset_older = Asset::new_commissioned(
818 agent_id.clone(),
819 process_rc.clone(),
820 region_id.clone(),
821 capacity,
822 2015,
823 )
824 .unwrap();
825
826 let candidate_asset =
827 Asset::new_candidate(process_rc.clone(), region_id.clone(), capacity, 2020).unwrap();
828
829 let assets = vec![
830 candidate_asset.clone(),
831 commissioned_asset_older.clone(),
832 candidate_asset.clone(),
833 commissioned_asset_newer.clone(),
834 ];
835
836 let metrics: Vec<Box<dyn MetricTrait>> = vec![
838 Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
839 Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
840 Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
841 Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
842 ];
843
844 let mut outputs = appraisal_outputs(assets, metrics);
845 sort_and_filter_appraisal_outputs(&mut outputs);
846
847 assert!(outputs[0].asset.is_commissioned());
849 assert!(outputs[0].asset.commission_year() == 2020);
850 assert!(outputs[1].asset.is_commissioned());
851 assert!(outputs[1].asset.commission_year() == 2015);
852
853 assert!(!outputs[2].asset.is_commissioned());
855 assert!(!outputs[3].asset.is_commissioned());
856 }
857
858 #[rstest]
860 fn appraisal_metric_is_prioritised_over_asset_properties(
861 process: Process,
862 region_id: RegionID,
863 agent_id: AgentID,
864 ) {
865 let process_rc = Rc::new(process);
866 let capacity = Capacity(10.0);
867
868 let commissioned_asset_newer = Asset::new_commissioned(
870 agent_id.clone(),
871 process_rc.clone(),
872 region_id.clone(),
873 capacity,
874 2020,
875 )
876 .unwrap();
877
878 let commissioned_asset_older = Asset::new_commissioned(
879 agent_id.clone(),
880 process_rc.clone(),
881 region_id.clone(),
882 capacity,
883 2015,
884 )
885 .unwrap();
886
887 let candidate_asset =
888 Asset::new_candidate(process_rc.clone(), region_id.clone(), capacity, 2020).unwrap();
889
890 let assets = vec![
891 candidate_asset.clone(),
892 commissioned_asset_older.clone(),
893 candidate_asset.clone(),
894 commissioned_asset_newer.clone(),
895 ];
896
897 let baseline_metric_value = 5.0;
899 let best_metric_value = baseline_metric_value - 1e-5;
900 let metrics: Vec<Box<dyn MetricTrait>> = vec![
901 Box::new(LCOXMetric::new(MoneyPerActivity(best_metric_value))),
902 Box::new(LCOXMetric::new(MoneyPerActivity(baseline_metric_value))),
903 Box::new(LCOXMetric::new(MoneyPerActivity(baseline_metric_value))),
904 Box::new(LCOXMetric::new(MoneyPerActivity(baseline_metric_value))),
905 ];
906
907 let mut outputs = appraisal_outputs(assets, metrics);
908 sort_and_filter_appraisal_outputs(&mut outputs);
909
910 assert_approx_eq!(
912 f64,
913 outputs[0].metric.as_ref().unwrap().value(),
914 best_metric_value
915 );
916 }
917
918 #[rstest]
920 fn appraisal_sort_filters_zero_capacity_outputs(asset: Asset) {
921 let metric = LCOXMetric::new(MoneyPerActivity(1.0));
922 let metrics = [
923 Box::new(metric.clone()),
924 Box::new(metric.clone()),
925 Box::new(metric),
926 ];
927
928 let mut outputs: Vec<AppraisalOutput> = metrics
930 .into_iter()
931 .map(|metric| AppraisalOutput {
932 asset: AssetRef::from(asset.clone()),
933 capacity: AssetCapacity::Continuous(Capacity(0.0)),
934 coefficients: objective_coeffs(),
935 activity: IndexMap::new(),
936 unmet_demand: IndexMap::new(),
937 metric: Some(metric),
938 })
939 .collect();
940
941 sort_and_filter_appraisal_outputs(&mut outputs);
942
943 assert_eq!(outputs.len(), 0);
945 }
946
947 #[rstest]
949 fn appraisal_sort_filters_invalid_metric(asset: Asset) {
950 let output = AppraisalOutput {
951 asset: AssetRef::from(asset),
952 capacity: AssetCapacity::Continuous(Capacity(1.0)), coefficients: objective_coeffs(),
954 activity: IndexMap::new(),
955 unmet_demand: IndexMap::new(),
956 metric: None,
957 };
958 let mut outputs = vec![output];
959
960 sort_and_filter_appraisal_outputs(&mut outputs);
961
962 assert_eq!(outputs.len(), 0);
964 }
965
966 #[rstest]
969 #[case(vec![5.0], 0, "single_element")]
970 #[case(vec![5.0, 5.0, 5.0], 2, "all_equal_returns_len_minus_one")]
971 #[case(vec![1.0, 2.0, 3.0], 0, "none_equal_to_best")]
972 #[case(vec![5.0, 5.0, 9.0], 1, "partial_equality_stops_at_first_difference")]
973 #[case(vec![5.0, 5.0, 9.0, 5.0], 1, "equality_does_not_resume_after_gap")]
974 fn count_equal_best_lcox_metric(
975 asset: Asset,
976 #[case] metric_values: Vec<f64>,
977 #[case] expected_count: usize,
978 #[case] description: &str,
979 ) {
980 let metrics: Vec<Box<dyn MetricTrait>> = metric_values
981 .into_iter()
982 .map(|v| Box::new(LCOXMetric::new(MoneyPerActivity(v))) as Box<dyn MetricTrait>)
983 .collect();
984
985 let outputs =
986 appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset);
987
988 assert_eq!(
989 count_equal_and_best_appraisal_outputs(&outputs),
990 expected_count,
991 "Failed for case: {description}"
992 );
993 }
994
995 #[test]
997 fn count_equal_best_empty_slice_returns_zero() {
998 let outputs: Vec<AppraisalOutput> = vec![];
999 assert_eq!(count_equal_and_best_appraisal_outputs(&outputs), 0);
1000 }
1001
1002 #[rstest]
1005 fn count_equal_best_equal_metric_different_fallback_returns_zero(
1006 process: Process,
1007 region_id: RegionID,
1008 agent_id: AgentID,
1009 ) {
1010 let process_rc = Rc::new(process);
1011 let capacity = Capacity(10.0);
1012
1013 let commissioned = Asset::new_commissioned(
1014 agent_id.clone(),
1015 process_rc.clone(),
1016 region_id.clone(),
1017 capacity,
1018 2020,
1019 )
1020 .unwrap();
1021 let candidate =
1022 Asset::new_candidate(process_rc.clone(), region_id.clone(), capacity, 2020).unwrap();
1023
1024 let metric_value = MoneyPerActivity(5.0);
1025 let outputs = appraisal_outputs(
1026 vec![commissioned, candidate],
1027 vec![
1028 Box::new(LCOXMetric::new(metric_value)),
1029 Box::new(LCOXMetric::new(metric_value)),
1030 ],
1031 );
1032
1033 assert_eq!(count_equal_and_best_appraisal_outputs(&outputs), 0);
1034 }
1035
1036 #[rstest]
1039 fn count_equal_best_equal_metric_and_equal_fallback_returns_one(
1040 process: Process,
1041 region_id: RegionID,
1042 agent_id: AgentID,
1043 ) {
1044 let process_rc = Rc::new(process);
1045 let capacity = Capacity(10.0);
1046 let year = 2020;
1047
1048 let asset1 = Asset::new_commissioned(
1049 agent_id.clone(),
1050 process_rc.clone(),
1051 region_id.clone(),
1052 capacity,
1053 year,
1054 )
1055 .unwrap();
1056 let asset2 = Asset::new_commissioned(
1057 agent_id.clone(),
1058 process_rc.clone(),
1059 region_id.clone(),
1060 capacity,
1061 year,
1062 )
1063 .unwrap();
1064
1065 let metric_value = MoneyPerActivity(5.0);
1066 let outputs = appraisal_outputs(
1067 vec![asset1, asset2],
1068 vec![
1069 Box::new(LCOXMetric::new(metric_value)),
1070 Box::new(LCOXMetric::new(metric_value)),
1071 ],
1072 );
1073
1074 assert_eq!(count_equal_and_best_appraisal_outputs(&outputs), 1);
1075 }
1076
1077 #[rstest]
1079 fn count_equal_best_equal_npv_metrics(asset: Asset) {
1080 let make_npv = |surplus: f64, fixed_cost: f64| {
1081 Box::new(NPVMetric::new(ProfitabilityIndex {
1082 total_annualised_surplus: Money(surplus),
1083 annualised_fixed_cost: Money(fixed_cost),
1084 })) as Box<dyn MetricTrait>
1085 };
1086
1087 let metrics = vec![
1088 make_npv(200.0, 100.0),
1089 make_npv(200.0, 100.0), make_npv(100.0, 100.0), ];
1092
1093 let outputs =
1094 appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset);
1095
1096 assert_eq!(count_equal_and_best_appraisal_outputs(&outputs), 1);
1097 }
1098}