1use super::DemandMap;
3use crate::agent::ObjectiveType;
4use crate::asset::{Asset, AssetRef};
5use crate::commodity::Commodity;
6use crate::finance::{lcox, snas};
7use crate::model::Model;
8use crate::time_slice::TimeSliceID;
9use crate::units::{Activity, 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::{ApproxEq, F64Margin};
26use optimisation::perform_optimisation;
27
28fn compare_approx<T>(a: T, b: T) -> Ordering
41where
42 T: Copy + PartialOrd + ApproxEq<Margin = F64Margin>,
43{
44 if a.approx_eq(b, F64Margin::default()) {
45 Ordering::Equal
46 } else {
47 a.partial_cmp(&b).expect("Cannot compare NaN values")
48 }
49}
50
51pub struct AppraisalOutput {
53 pub asset: AssetRef,
55 pub activity: IndexMap<TimeSliceID, Activity>,
57 pub unmet_demand: DemandMap,
59 pub metric: Option<Box<dyn MetricTrait>>,
61 pub coefficients: Rc<ObjectiveCoefficients>,
63}
64
65impl AppraisalOutput {
66 fn new<T: MetricTrait>(
68 asset: AssetRef,
69 results: ResultsMap,
70 metric: Option<T>,
71 coefficients: Rc<ObjectiveCoefficients>,
72 ) -> Self {
73 Self {
74 asset,
75 activity: results.activity,
76 unmet_demand: results.unmet_demand,
77 metric: metric.map(|m| Box::new(m) as Box<dyn MetricTrait>),
78 coefficients,
79 }
80 }
81 pub fn compare_metric(&self, other: &Self) -> Ordering {
90 let (metric1, metric2) = self
91 .metric
92 .as_deref()
93 .zip(other.metric.as_deref())
94 .expect("Cannot compare non-valid outputs");
95
96 metric1.compare(metric2)
98 }
99}
100
101pub trait MetricTrait: ComparableMetric + ErasedSerialize {}
103erased_serde::serialize_trait_object!(MetricTrait);
104
105pub trait ComparableMetric: Any + Send + Sync {
110 fn value(&self) -> f64;
112
113 fn compare(&self, other: &dyn ComparableMetric) -> Ordering;
123
124 fn as_any(&self) -> &dyn Any;
126}
127
128#[derive(Debug, Clone, Serialize)]
133pub struct LCOXMetric {
134 pub cost: MoneyPerActivity,
136}
137
138impl LCOXMetric {
139 pub fn new(cost: MoneyPerActivity) -> Self {
141 Self { cost }
142 }
143}
144
145impl ComparableMetric for LCOXMetric {
146 fn value(&self) -> f64 {
147 self.cost.value()
148 }
149
150 fn compare(&self, other: &dyn ComparableMetric) -> Ordering {
151 let other = other
152 .as_any()
153 .downcast_ref::<Self>()
154 .expect("Cannot compare metrics of different types");
155
156 compare_approx(self.cost, other.cost)
157 }
158
159 fn as_any(&self) -> &dyn Any {
160 self
161 }
162}
163
164impl MetricTrait for LCOXMetric {}
166
167#[derive(Debug, Clone, Serialize)]
172pub struct NPVMetric {
173 pub snas: MoneyPerActivity,
175}
176
177impl NPVMetric {
178 pub fn new(snas: MoneyPerActivity) -> Self {
180 Self { snas }
181 }
182}
183
184impl ComparableMetric for NPVMetric {
185 fn value(&self) -> f64 {
186 self.snas.value()
187 }
188
189 fn compare(&self, other: &dyn ComparableMetric) -> Ordering {
190 let other = other
191 .as_any()
192 .downcast_ref::<Self>()
193 .expect("Cannot compare metrics of different types");
194
195 compare_approx(other.snas, self.snas)
196 }
197
198 fn as_any(&self) -> &dyn Any {
199 self
200 }
201}
202
203impl MetricTrait for NPVMetric {}
205
206fn calculate_lcox(
216 model: &Model,
217 asset: &AssetRef,
218 commodity: &Commodity,
219 coefficients: &Rc<ObjectiveCoefficients>,
220 demand: &DemandMap,
221) -> Result<AppraisalOutput> {
222 let results = perform_optimisation(model, asset, commodity, coefficients, demand)?;
223
224 let cost_index = lcox(
225 asset.capacity().total_capacity(),
226 annual_fixed_cost(asset),
227 &results.activity,
228 &coefficients.market_costs,
229 );
230
231 Ok(AppraisalOutput::new(
232 asset.clone(),
233 results,
234 cost_index.map(LCOXMetric::new),
235 coefficients.clone(),
236 ))
237}
238
239fn calculate_npv(
245 model: &Model,
246 asset: &AssetRef,
247 commodity: &Commodity,
248 coefficients: &Rc<ObjectiveCoefficients>,
249 demand: &DemandMap,
250) -> Result<AppraisalOutput> {
251 let results = perform_optimisation(model, asset, commodity, coefficients, demand)?;
252
253 let annual_fixed_cost = annual_fixed_cost(asset);
254 assert!(
255 annual_fixed_cost >= MoneyPerCapacity(0.0),
256 "The current NPV calculation does not support negative annual fixed costs"
257 );
258
259 let snas = snas(
260 asset.capacity().total_capacity(),
261 annual_fixed_cost,
262 &results.activity,
263 &coefficients.market_costs,
264 );
265
266 Ok(AppraisalOutput::new(
267 asset.clone(),
268 results,
269 snas.map(NPVMetric::new),
270 coefficients.clone(),
271 ))
272}
273
274pub fn appraise_investment(
281 model: &Model,
282 asset: &AssetRef,
283 commodity: &Commodity,
284 objective_type: &ObjectiveType,
285 coefficients: &Rc<ObjectiveCoefficients>,
286 demand: &DemandMap,
287) -> Result<AppraisalOutput> {
288 let appraisal_method = match objective_type {
289 ObjectiveType::LevelisedCostOfX => calculate_lcox,
290 ObjectiveType::NetPresentValue => calculate_npv,
291 };
292 appraisal_method(model, asset, commodity, coefficients, demand)
293}
294
295fn compare_asset_fallback(asset1: &Asset, asset2: &Asset) -> Ordering {
301 (asset2.is_commissioned(), asset2.commission_year())
302 .cmp(&(asset1.is_commissioned(), asset1.commission_year()))
303}
304
305pub fn sort_and_filter_appraisal_outputs(outputs: &mut Vec<AppraisalOutput>) -> usize {
319 let old_len = outputs.len();
320 outputs.retain(|output| output.metric.is_some());
321 let num_nonfeasible = old_len - outputs.len();
322
323 outputs.sort_by(|output1, output2| match output1.compare_metric(output2) {
324 Ordering::Equal => compare_asset_fallback(&output1.asset, &output2.asset),
326 cmp => cmp,
327 });
328
329 num_nonfeasible
330}
331
332pub fn count_equal_and_best_appraisal_outputs(outputs: &[AppraisalOutput]) -> usize {
335 if outputs.is_empty() {
336 return 0;
337 }
338 outputs[1..]
339 .iter()
340 .take_while(|output| {
341 output.compare_metric(&outputs[0]).is_eq()
342 && compare_asset_fallback(&output.asset, &outputs[0].asset).is_eq()
343 })
344 .count()
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350 use crate::agent::AgentID;
351 use crate::fixture::{agent_id, asset, process, region_id};
352 use crate::process::Process;
353 use crate::region::RegionID;
354 use crate::units::{Capacity, MoneyPerActivity};
355 use float_cmp::assert_approx_eq;
356 use rstest::rstest;
357 use std::rc::Rc;
358
359 #[rstest]
361 #[case(10.0, 10.0, Ordering::Equal, "equal_costs")]
362 #[case(5.0, 10.0, Ordering::Less, "first_lower_cost_is_better")]
363 #[case(10.0, 5.0, Ordering::Greater, "second_lower_cost_is_better")]
364 fn lcox_metric_comparison(
365 #[case] cost1: f64,
366 #[case] cost2: f64,
367 #[case] expected: Ordering,
368 #[case] description: &str,
369 ) {
370 let metric1 = LCOXMetric::new(MoneyPerActivity(cost1));
371 let metric2 = LCOXMetric::new(MoneyPerActivity(cost2));
372
373 assert_eq!(
374 metric1.compare(&metric2),
375 expected,
376 "Failed comparison for case: {description}"
377 );
378 }
379
380 #[rstest]
382 #[case(10.0, 10.0, Ordering::Equal, "equal_costs")]
383 #[case(5.0, 10.0, Ordering::Greater, "second_higher_metric_is_better")]
384 #[case(10.0, 5.0, Ordering::Less, "first_higher_metric_is_better")]
385 fn npv_metric_comparison(
386 #[case] cost1: f64,
387 #[case] cost2: f64,
388 #[case] expected: Ordering,
389 #[case] description: &str,
390 ) {
391 let metric1 = NPVMetric::new(MoneyPerActivity(cost1));
392 let metric2 = NPVMetric::new(MoneyPerActivity(cost2));
393
394 assert_eq!(
395 metric1.compare(&metric2),
396 expected,
397 "Failed comparison for case: {description}"
398 );
399 }
400
401 #[rstest]
402 fn compare_assets_fallback(process: Process, region_id: RegionID, agent_id: AgentID) {
403 let process = Rc::new(process);
404 let capacity = Capacity(2.0);
405 let asset1 = Asset::new_commissioned(
406 agent_id.clone(),
407 process.clone(),
408 region_id.clone(),
409 capacity,
410 2015,
411 )
412 .unwrap();
413 let asset2 =
414 Asset::new_candidate(process.clone(), region_id.clone(), capacity, 2015).unwrap();
415 let asset3 =
416 Asset::new_commissioned(agent_id, process, region_id.clone(), capacity, 2010).unwrap();
417
418 assert!(compare_asset_fallback(&asset1, &asset1).is_eq());
419 assert!(compare_asset_fallback(&asset2, &asset2).is_eq());
420 assert!(compare_asset_fallback(&asset3, &asset3).is_eq());
421 assert!(compare_asset_fallback(&asset1, &asset2).is_lt());
422 assert!(compare_asset_fallback(&asset2, &asset1).is_gt());
423 assert!(compare_asset_fallback(&asset1, &asset3).is_lt());
424 assert!(compare_asset_fallback(&asset3, &asset1).is_gt());
425 assert!(compare_asset_fallback(&asset3, &asset2).is_lt());
426 assert!(compare_asset_fallback(&asset2, &asset3).is_gt());
427 }
428
429 fn objective_coeffs() -> Rc<ObjectiveCoefficients> {
430 Rc::new(ObjectiveCoefficients {
431 activity_coefficients: IndexMap::new(),
432 market_costs: IndexMap::new(),
433 })
434 }
435
436 fn appraisal_outputs(
442 assets: Vec<Asset>,
443 metrics: Vec<Box<dyn MetricTrait>>,
444 ) -> Vec<AppraisalOutput> {
445 assert_eq!(
446 assets.len(),
447 metrics.len(),
448 "assets and metrics must have the same length"
449 );
450
451 assets
452 .into_iter()
453 .zip(metrics)
454 .map(|(asset, metric)| AppraisalOutput {
455 asset: AssetRef::from(asset),
456 coefficients: objective_coeffs(),
457 activity: IndexMap::new(),
458 unmet_demand: IndexMap::new(),
459 metric: Some(metric),
460 })
461 .collect()
462 }
463
464 fn appraisal_outputs_with_investment_priority_invariant_to_assets(
467 metrics: Vec<Box<dyn MetricTrait>>,
468 asset: &Asset,
469 ) -> Vec<AppraisalOutput> {
470 let assets = vec![asset.clone(); metrics.len()];
471 appraisal_outputs(assets, metrics)
472 }
473
474 #[rstest]
476 fn appraisal_sort_by_lcox_metric(asset: Asset) {
477 let metrics: Vec<Box<dyn MetricTrait>> = vec![
478 Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
479 Box::new(LCOXMetric::new(MoneyPerActivity(3.0))),
480 Box::new(LCOXMetric::new(MoneyPerActivity(7.0))),
481 ];
482
483 let mut outputs =
484 appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset);
485 sort_and_filter_appraisal_outputs(&mut outputs);
486
487 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);
489 assert_approx_eq!(f64, outputs[2].metric.as_ref().unwrap().value(), 7.0); }
491
492 #[rstest]
494 fn appraisal_sort_by_npv_metric(asset: Asset) {
495 let metrics: Vec<Box<dyn MetricTrait>> = vec![
496 Box::new(NPVMetric::new(MoneyPerActivity(5.0))),
497 Box::new(NPVMetric::new(MoneyPerActivity(3.0))),
498 Box::new(NPVMetric::new(MoneyPerActivity(7.0))),
499 ];
500
501 let mut outputs =
502 appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset);
503 sort_and_filter_appraisal_outputs(&mut outputs);
504
505 assert_approx_eq!(f64, outputs[0].metric.as_ref().unwrap().value(), 7.0); assert_approx_eq!(f64, outputs[1].metric.as_ref().unwrap().value(), 5.0);
507 assert_approx_eq!(f64, outputs[2].metric.as_ref().unwrap().value(), 3.0); }
509
510 #[rstest]
512 #[should_panic(expected = "Cannot compare metrics of different types")]
513 fn appraisal_sort_by_mixed_metrics_panics(asset: Asset) {
514 let metrics: Vec<Box<dyn MetricTrait>> = vec![
515 Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
516 Box::new(NPVMetric::new(MoneyPerActivity(3.0))),
517 Box::new(LCOXMetric::new(MoneyPerActivity(3.0))),
518 ];
519
520 let mut outputs =
521 appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset);
522 sort_and_filter_appraisal_outputs(&mut outputs);
524 }
525
526 #[rstest]
528 fn appraisal_sort_by_commission_year_when_metrics_equal(
529 process: Process,
530 region_id: RegionID,
531 agent_id: AgentID,
532 ) {
533 let process_rc = Rc::new(process);
534 let capacity = Capacity(10.0);
535 let commission_years = [2015, 2020, 2010];
536
537 let assets: Vec<_> = commission_years
538 .iter()
539 .map(|&year| {
540 Asset::new_commissioned(
541 agent_id.clone(),
542 process_rc.clone(),
543 region_id.clone(),
544 capacity,
545 year,
546 )
547 .unwrap()
548 })
549 .collect();
550
551 let metrics: Vec<Box<dyn MetricTrait>> = vec![
553 Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
554 Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
555 Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
556 ];
557
558 let mut outputs = appraisal_outputs(assets, metrics);
559 sort_and_filter_appraisal_outputs(&mut outputs);
560
561 assert_eq!(outputs[0].asset.commission_year(), 2020);
563 assert_eq!(outputs[1].asset.commission_year(), 2015);
564 assert_eq!(outputs[2].asset.commission_year(), 2010);
565 }
566
567 #[rstest]
569 fn appraisal_sort_maintains_order_when_all_equal(process: Process, region_id: RegionID) {
570 let process_rc = Rc::new(process);
571 let capacity = Capacity(10.0);
572 let commission_year = 2015;
573 let agent_ids = ["agent1", "agent2", "agent3"];
574
575 let assets: Vec<_> = agent_ids
576 .iter()
577 .map(|&id| {
578 Asset::new_commissioned(
579 AgentID(id.into()),
580 process_rc.clone(),
581 region_id.clone(),
582 capacity,
583 commission_year,
584 )
585 .unwrap()
586 })
587 .collect();
588
589 let metrics: Vec<Box<dyn MetricTrait>> = vec![
590 Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
591 Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
592 Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
593 ];
594
595 let mut outputs = appraisal_outputs(assets.clone(), metrics);
596 sort_and_filter_appraisal_outputs(&mut outputs);
597
598 for (&expected_id, output) in agent_ids.iter().zip(outputs) {
600 assert_eq!(output.asset.agent_id(), Some(&AgentID(expected_id.into())));
601 }
602 }
603
604 #[rstest]
606 fn appraisal_sort_commissioned_before_uncommissioned_when_metrics_equal(
607 process: Process,
608 region_id: RegionID,
609 agent_id: AgentID,
610 ) {
611 let process_rc = Rc::new(process);
612 let capacity = Capacity(10.0);
613
614 let commissioned_asset_newer = Asset::new_commissioned(
616 agent_id.clone(),
617 process_rc.clone(),
618 region_id.clone(),
619 capacity,
620 2020,
621 )
622 .unwrap();
623
624 let commissioned_asset_older = Asset::new_commissioned(
625 agent_id.clone(),
626 process_rc.clone(),
627 region_id.clone(),
628 capacity,
629 2015,
630 )
631 .unwrap();
632
633 let candidate_asset =
634 Asset::new_candidate(process_rc.clone(), region_id.clone(), capacity, 2020).unwrap();
635
636 let assets = vec![
637 candidate_asset.clone(),
638 commissioned_asset_older.clone(),
639 candidate_asset.clone(),
640 commissioned_asset_newer.clone(),
641 ];
642
643 let metrics: Vec<Box<dyn MetricTrait>> = vec![
645 Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
646 Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
647 Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
648 Box::new(LCOXMetric::new(MoneyPerActivity(5.0))),
649 ];
650
651 let mut outputs = appraisal_outputs(assets, metrics);
652 sort_and_filter_appraisal_outputs(&mut outputs);
653
654 assert!(outputs[0].asset.is_commissioned());
656 assert_eq!(outputs[0].asset.commission_year(), 2020);
657 assert!(outputs[1].asset.is_commissioned());
658 assert_eq!(outputs[1].asset.commission_year(), 2015);
659
660 assert!(!outputs[2].asset.is_commissioned());
662 assert!(!outputs[3].asset.is_commissioned());
663 }
664
665 #[rstest]
667 fn appraisal_metric_is_prioritised_over_asset_properties(
668 process: Process,
669 region_id: RegionID,
670 agent_id: AgentID,
671 ) {
672 let process_rc = Rc::new(process);
673 let capacity = Capacity(10.0);
674
675 let commissioned_asset_newer = Asset::new_commissioned(
677 agent_id.clone(),
678 process_rc.clone(),
679 region_id.clone(),
680 capacity,
681 2020,
682 )
683 .unwrap();
684
685 let commissioned_asset_older = Asset::new_commissioned(
686 agent_id.clone(),
687 process_rc.clone(),
688 region_id.clone(),
689 capacity,
690 2015,
691 )
692 .unwrap();
693
694 let candidate_asset =
695 Asset::new_candidate(process_rc.clone(), region_id.clone(), capacity, 2020).unwrap();
696
697 let assets = vec![
698 candidate_asset.clone(),
699 commissioned_asset_older.clone(),
700 candidate_asset.clone(),
701 commissioned_asset_newer.clone(),
702 ];
703
704 let baseline_metric_value = 5.0;
706 let best_metric_value = baseline_metric_value - 1e-5;
707 let metrics: Vec<Box<dyn MetricTrait>> = vec![
708 Box::new(LCOXMetric::new(MoneyPerActivity(best_metric_value))),
709 Box::new(LCOXMetric::new(MoneyPerActivity(baseline_metric_value))),
710 Box::new(LCOXMetric::new(MoneyPerActivity(baseline_metric_value))),
711 Box::new(LCOXMetric::new(MoneyPerActivity(baseline_metric_value))),
712 ];
713
714 let mut outputs = appraisal_outputs(assets, metrics);
715 sort_and_filter_appraisal_outputs(&mut outputs);
716
717 assert_approx_eq!(
719 f64,
720 outputs[0].metric.as_ref().unwrap().value(),
721 best_metric_value
722 );
723 }
724
725 #[rstest]
727 fn appraisal_sort_filters_invalid_metric(asset: Asset) {
728 let output = AppraisalOutput {
729 asset: AssetRef::from(asset),
730 coefficients: objective_coeffs(),
731 activity: IndexMap::new(),
732 unmet_demand: IndexMap::new(),
733 metric: None,
734 };
735 let mut outputs = vec![output];
736
737 sort_and_filter_appraisal_outputs(&mut outputs);
738
739 assert_eq!(outputs.len(), 0);
741 }
742
743 #[rstest]
746 #[case(vec![5.0], 0, "single_element")]
747 #[case(vec![5.0, 5.0, 5.0], 2, "all_equal_returns_len_minus_one")]
748 #[case(vec![1.0, 2.0, 3.0], 0, "none_equal_to_best")]
749 #[case(vec![5.0, 5.0, 9.0], 1, "partial_equality_stops_at_first_difference")]
750 #[case(vec![5.0, 5.0, 9.0, 5.0], 1, "equality_does_not_resume_after_gap")]
751 fn count_equal_best_lcox_metric(
752 asset: Asset,
753 #[case] metric_values: Vec<f64>,
754 #[case] expected_count: usize,
755 #[case] description: &str,
756 ) {
757 let metrics: Vec<Box<dyn MetricTrait>> = metric_values
758 .into_iter()
759 .map(|v| Box::new(LCOXMetric::new(MoneyPerActivity(v))) as Box<dyn MetricTrait>)
760 .collect();
761
762 let outputs =
763 appraisal_outputs_with_investment_priority_invariant_to_assets(metrics, &asset);
764
765 assert_eq!(
766 count_equal_and_best_appraisal_outputs(&outputs),
767 expected_count,
768 "Failed for case: {description}"
769 );
770 }
771
772 #[test]
774 fn count_equal_best_empty_slice_returns_zero() {
775 let outputs: Vec<AppraisalOutput> = vec![];
776 assert_eq!(count_equal_and_best_appraisal_outputs(&outputs), 0);
777 }
778
779 #[rstest]
782 fn count_equal_best_equal_metric_different_fallback_returns_zero(
783 process: Process,
784 region_id: RegionID,
785 agent_id: AgentID,
786 ) {
787 let process_rc = Rc::new(process);
788 let capacity = Capacity(10.0);
789
790 let commissioned = Asset::new_commissioned(
791 agent_id.clone(),
792 process_rc.clone(),
793 region_id.clone(),
794 capacity,
795 2020,
796 )
797 .unwrap();
798 let candidate =
799 Asset::new_candidate(process_rc.clone(), region_id.clone(), capacity, 2020).unwrap();
800
801 let metric_value = MoneyPerActivity(5.0);
802 let outputs = appraisal_outputs(
803 vec![commissioned, candidate],
804 vec![
805 Box::new(LCOXMetric::new(metric_value)),
806 Box::new(LCOXMetric::new(metric_value)),
807 ],
808 );
809
810 assert_eq!(count_equal_and_best_appraisal_outputs(&outputs), 0);
811 }
812
813 #[rstest]
816 fn count_equal_best_equal_metric_and_equal_fallback_returns_one(
817 process: Process,
818 region_id: RegionID,
819 agent_id: AgentID,
820 ) {
821 let process_rc = Rc::new(process);
822 let capacity = Capacity(10.0);
823 let year = 2020;
824
825 let asset1 = Asset::new_commissioned(
826 agent_id.clone(),
827 process_rc.clone(),
828 region_id.clone(),
829 capacity,
830 year,
831 )
832 .unwrap();
833 let asset2 = Asset::new_commissioned(
834 agent_id.clone(),
835 process_rc.clone(),
836 region_id.clone(),
837 capacity,
838 year,
839 )
840 .unwrap();
841
842 let metric_value = MoneyPerActivity(5.0);
843 let outputs = appraisal_outputs(
844 vec![asset1, asset2],
845 vec![
846 Box::new(LCOXMetric::new(metric_value)),
847 Box::new(LCOXMetric::new(metric_value)),
848 ],
849 );
850
851 assert_eq!(count_equal_and_best_appraisal_outputs(&outputs), 1);
852 }
853}