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