1use std::time::Duration;
33
34use oximo::prelude::*;
35use oximo::solvers::Highs;
36
37const N_TESTS: usize = 10;
38const N_RESOURCES: usize = 6;
39const N_PRODUCTS: usize = 2;
40const HORIZON: f64 = 100.0;
41const DISCOUNT_RATE: f64 = 0.0075;
42const MAX_INCOME: f64 = 500.0;
43const INCOME_BREAKPOINTS: [f64; 2] = [24.0, 48.0];
44const MONTHLY_INCOME_LOSS: [f64; 2] = [8.0, 5.0];
45
46const RESOURCE_NAMES: [&str; N_RESOURCES] = ["A1", "A2", "A3", "B1", "B2", "B3"];
48const SCIENTIST_RESOURCES: [usize; 3] = [0, 1, 2];
49const EQUIPMENT_RESOURCES: [usize; 3] = [3, 4, 5];
50const NEW_RESOURCES: [usize; 2] = [1, 4];
51const IN_HOUSE_RESOURCES: [usize; 4] = [0, 1, 3, 4];
52const INSTALLATION_COST: [f64; 2] = [20.0, 30.0];
53
54struct TestData {
55 product: usize,
56 duration: f64,
57 success_probability: f64,
58 fixed_cost: f64,
59 resource_cost: [f64; N_RESOURCES],
60 predecessors: &'static [usize],
61}
62
63const TESTS: [TestData; N_TESTS] = [
64 TestData {
65 product: 0,
66 duration: 12.0,
67 success_probability: 1.0,
68 fixed_cost: 10.0,
69 resource_cost: [5.0, 5.0, 20.0, 6.0, 5.0, 20.0],
70 predecessors: &[],
71 },
72 TestData {
73 product: 0,
74 duration: 13.0,
75 success_probability: 0.7,
76 fixed_cost: 15.0,
77 resource_cost: [4.0, 5.0, 16.0, 5.0, 5.0, 22.0],
78 predecessors: &[0],
79 },
80 TestData {
81 product: 0,
82 duration: 12.0,
83 success_probability: 0.95,
84 fixed_cost: 20.0,
85 resource_cost: [6.0, 6.0, 18.0, 3.0, 3.0, 15.0],
86 predecessors: &[1],
87 },
88 TestData {
89 product: 0,
90 duration: 20.0,
91 success_probability: 1.0,
92 fixed_cost: 40.0,
93 resource_cost: [10.0, 8.0, 30.0, 2.0, 2.0, 12.0],
94 predecessors: &[],
95 },
96 TestData {
97 product: 0,
98 duration: 18.0,
99 success_probability: 1.0,
100 fixed_cost: 60.0,
101 resource_cost: [3.0, 3.0, 15.0, 5.0, 5.0, 30.0],
102 predecessors: &[],
103 },
104 TestData {
105 product: 0,
106 duration: 15.0,
107 success_probability: 0.6,
108 fixed_cost: 20.0,
109 resource_cost: [8.0, 8.0, 22.0, 10.0, 10.0, 35.0],
110 predecessors: &[2, 4],
111 },
112 TestData {
113 product: 1,
114 duration: 8.0,
115 success_probability: 1.0,
116 fixed_cost: 20.0,
117 resource_cost: [5.0, 5.0, 22.0, 2.0, 2.0, 16.0],
118 predecessors: &[],
119 },
120 TestData {
121 product: 1,
122 duration: 15.0,
123 success_probability: 0.8,
124 fixed_cost: 30.0,
125 resource_cost: [6.0, 9.0, 24.0, 6.0, 5.0, 20.0],
126 predecessors: &[6],
127 },
128 TestData {
129 product: 1,
130 duration: 21.0,
131 success_probability: 1.0,
132 fixed_cost: 60.0,
133 resource_cost: [5.0, 2.0, 18.0, 4.0, 4.0, 18.0],
134 predecessors: &[],
135 },
136 TestData {
137 product: 1,
138 duration: 17.0,
139 success_probability: 0.7,
140 fixed_cost: 40.0,
141 resource_cost: [4.0, 4.0, 12.0, 6.0, 6.0, 24.0],
142 predecessors: &[7, 8],
143 },
144];
145
146#[derive(Clone, Copy)]
147enum AcquisitionCase {
148 Disabled,
149 Allowed,
150}
151
152impl AcquisitionCase {
153 const fn name(self) -> &'static str {
154 match self {
155 Self::Disabled => "Case 1: acquisition disabled",
156 Self::Allowed => "Case 2: acquisition allowed",
157 }
158 }
159
160 const fn allows_acquisition(self) -> bool {
161 matches!(self, Self::Allowed)
162 }
163}
164
165struct ModelSets {
166 tests: Set<usize>,
167 resources: Set<usize>,
168 products: Set<usize>,
169 grid_points: Set<usize>,
170 income_segments: Set<usize>,
171 new_units: Set<usize>,
172}
173
174impl ModelSets {
175 fn new(grid_size: usize) -> Self {
176 Self {
177 tests: Set::range(0..N_TESTS),
178 resources: Set::range(0..N_RESOURCES),
179 products: Set::range(0..N_PRODUCTS),
180 grid_points: Set::range(0..grid_size),
181 income_segments: Set::range(0..INCOME_BREAKPOINTS.len()),
182 new_units: Set::range(0..NEW_RESOURCES.len()),
183 }
184 }
185}
186
187struct DiscountGrid {
188 exponent: Vec<f64>,
189 factor: Vec<f64>,
190}
191
192impl DiscountGrid {
193 fn section_5() -> Self {
194 let exponent: Vec<f64> = (0..=17).map(|point| -0.1 * f64::from(point)).collect();
195 let factor = exponent.iter().map(|value| value.exp()).collect();
196 Self { exponent, factor }
197 }
198
199 fn len(&self) -> usize {
200 self.exponent.len()
201 }
202}
203
204struct ModelVariables<'a> {
205 start_time: IndexedVar<'a, usize>,
206 completion_time: IndexedVar<'a, usize>,
207 precedes: IndexedVar<'a, (usize, usize)>,
208 uses_resource: IndexedVar<'a, (usize, usize)>,
209 installed: IndexedVar<'a, usize>,
210 installation_time: IndexedVar<'a, usize>,
211 income_excess_time: IndexedVar<'a, (usize, usize)>,
212 lambda_fixed: IndexedVar<'a, (usize, usize)>,
213 lambda_used: IndexedVar<'a, (usize, usize, usize)>,
214 lambda_unused: IndexedVar<'a, (usize, usize, usize)>,
215 lambda_install: IndexedVar<'a, (usize, usize)>,
216}
217
218struct CaseSummary {
219 npv: f64,
220 completion: [f64; N_PRODUCTS],
221 income: [f64; N_PRODUCTS],
222 fixed_cost: [f64; N_PRODUCTS],
223 resource_cost: [f64; N_PRODUCTS],
224 installation_cost: f64,
225 installed: [bool; NEW_RESOURCES.len()],
226 installation_time: [f64; NEW_RESOURCES.len()],
227 start_time: [f64; N_TESTS],
228 uses_resource: [[bool; N_RESOURCES]; N_TESTS],
229}
230
231struct PaperCase {
233 completion: [f64; N_PRODUCTS],
234 income: [f64; N_PRODUCTS],
235 fixed_cost: [f64; N_PRODUCTS],
236 resource_cost: [f64; N_PRODUCTS],
237 installation_cost: f64,
238 npv: f64,
239}
240
241const PAPER_CASES: [PaperCase; 2] = [
242 PaperCase {
243 completion: [52.0, 40.0],
244 income: [256.0, 372.0],
245 fixed_cost: [119.68, 134.56],
246 resource_cost: [135.86, 91.41],
247 installation_cost: 0.0,
248 npv: 146.49,
249 },
250 PaperCase {
251 completion: [52.0, 40.0],
252 income: [256.0, 372.0],
253 fixed_cost: [119.68, 134.56],
254 resource_cost: [82.68, 66.01],
255 installation_cost: 50.0,
256 npv: 175.07,
257 },
258];
259
260fn product_tests(product: usize) -> std::ops::Range<usize> {
261 match product {
262 0 => 0..6,
263 1 => 6..10,
264 _ => unreachable!("there are exactly two products"),
265 }
266}
267
268fn technological_precedence_closure() -> [[bool; N_TESTS]; N_TESTS] {
269 let mut precedes = [[false; N_TESTS]; N_TESTS];
270 for (test, data) in TESTS.iter().enumerate() {
271 for &predecessor in data.predecessors {
272 precedes[predecessor][test] = true;
273 }
274 }
275 for middle in 0..N_TESTS {
276 for before in 0..N_TESTS {
277 for after in 0..N_TESTS {
278 precedes[before][after] |= precedes[before][middle] && precedes[middle][after];
279 }
280 }
281 }
282 precedes
283}
284
285fn declare_model_variables<'a>(model: &'a Model, sets: &ModelSets) -> ModelVariables<'a> {
286 let tests = sets.tests.clone();
287 let resources = sets.resources.clone();
288 let products = sets.products.clone();
289 let grid_points = sets.grid_points.clone();
290 let income_segments = sets.income_segments.clone();
291 let new_units = sets.new_units.clone();
292
293 variable!(model, 0.0 <= start_time[test in tests] <= HORIZON);
294 variable!(model, 0.0 <= completion_time[product in products] <= HORIZON);
295 variable!(model, precedes[test in tests, other in tests if test != other], Bin);
296 variable!(model, uses_resource[test in tests, resource in resources], Bin);
297 variable!(model, installed[unit in new_units], Bin);
298 variable!(model, 0.0 <= installation_time[unit in new_units] <= HORIZON);
299 variable!(model,
300 income_excess_time[product in products, segment in income_segments] >= 0.0);
301
302 variable!(model, lambda_fixed[test in tests, point in grid_points] >= 0.0);
304 variable!(model,
305 lambda_used[test in tests, resource in resources, point in grid_points] >= 0.0);
306 variable!(model,
307 lambda_unused[test in tests, resource in resources, point in grid_points] >= 0.0);
308 variable!(model, lambda_install[unit in new_units, point in grid_points] >= 0.0);
309
310 ModelVariables {
311 start_time,
312 completion_time,
313 precedes,
314 uses_resource,
315 installed,
316 installation_time,
317 income_excess_time,
318 lambda_fixed,
319 lambda_used,
320 lambda_unused,
321 lambda_install,
322 }
323}
324
325fn add_timing_and_sequencing_constraints(
326 model: &Model,
327 sets: &ModelSets,
328 variables: &ModelVariables<'_>,
329) {
330 let tests = sets.tests.clone();
331
332 constraint!(model, finish[test in tests],
334 variables.start_time[test] + TESTS[test].duration
335 <= variables.completion_time[TESTS[test].product]);
336
337 constraint!(model, ordered[test in tests, other in tests if test != other],
341 variables.start_time[test] + TESTS[test].duration
342 <= variables.start_time[other]
343 + HORIZON * (1.0 - variables.precedes[test, other]));
344
345 let technological_precedence = technological_precedence_closure();
347 for (before, row) in technological_precedence.iter().enumerate() {
348 for (after, &is_precedence) in row.iter().enumerate() {
349 if is_precedence {
350 model.fix(variables.precedes[(before, after)], 1.0);
351 model.fix(variables.precedes[(after, before)], 0.0);
352 }
353 }
354 }
355
356 constraint!(model, no_two_cycle[test in tests, other in tests if test < other],
358 variables.precedes[test, other] + variables.precedes[other, test] <= 1.0);
359 constraint!(model, transitive[test in tests, middle in tests, other in tests
360 if test != middle && middle != other && test != other],
361 variables.precedes[test, middle] + variables.precedes[middle, other]
362 - variables.precedes[test, other] <= 1.0);
363}
364
365fn add_resource_constraints(
366 model: &Model,
367 sets: &ModelSets,
368 variables: &ModelVariables<'_>,
369 case: AcquisitionCase,
370) {
371 let tests = sets.tests.clone();
372
373 constraint!(model, scientist[test in tests],
376 sum!(variables.uses_resource[test, resource] for resource in SCIENTIST_RESOURCES) == 1.0);
377 constraint!(model, equipment[test in tests],
378 sum!(variables.uses_resource[test, resource] for resource in EQUIPMENT_RESOURCES) == 1.0);
379
380 for (unit, &resource) in NEW_RESOURCES.iter().enumerate() {
382 if !case.allows_acquisition() {
383 model.fix(variables.installed[unit], 0.0);
384 }
385 for test in 0..N_TESTS {
386 constraint!(model, variables.uses_resource[test, resource] <= variables.installed[unit]);
387 constraint!(model,
388 variables.start_time[test]
389 >= variables.installation_time[unit]
390 - HORIZON * (1.0 - variables.uses_resource[test, resource]));
391 }
392 }
393
394 for &resource in &IN_HOUSE_RESOURCES {
397 for test in 0..N_TESTS {
398 for other in (test + 1)..N_TESTS {
399 constraint!(model,
400 variables.uses_resource[test, resource]
401 + variables.uses_resource[other, resource]
402 - variables.precedes[test, other]
403 - variables.precedes[other, test]
404 <= 1.0);
405 }
406 }
407 }
408}
409
410fn add_discounting_constraints(
411 model: &Model,
412 sets: &ModelSets,
413 variables: &ModelVariables<'_>,
414 discount_grid: &DiscountGrid,
415) {
416 let grid_points = sets.grid_points.clone();
417
418 for (test, test_data) in TESTS.iter().enumerate() {
422 let same_product = product_tests(test_data.product);
423 constraint!(
424 model,
425 sum!(variables.lambda_fixed[test, point] for point in grid_points) == 1.0
426 );
427 constraint!(
428 model,
429 sum!(discount_grid.exponent[point] * variables.lambda_fixed[test, point]
430 for point in grid_points)
431 == -DISCOUNT_RATE * variables.start_time[test]
432 + sum!(
433 TESTS[other].success_probability.ln()
434 * variables.precedes[other, test]
435 for other in same_product if other != test
436 )
437 );
438
439 for resource in 0..N_RESOURCES {
440 constraint!(model,
441 sum!(variables.lambda_used[test, resource, point] for point in grid_points)
442 == variables.uses_resource[test, resource]);
443 constraint!(model,
444 sum!(variables.lambda_unused[test, resource, point] for point in grid_points)
445 == 1.0 - variables.uses_resource[test, resource]);
446 for point in 0..discount_grid.len() {
447 constraint!(model,
448 variables.lambda_fixed[test, point]
449 == variables.lambda_used[test, resource, point]
450 + variables.lambda_unused[test, resource, point]);
451 }
452 }
453 }
454
455 for unit in 0..NEW_RESOURCES.len() {
457 constraint!(
458 model,
459 sum!(variables.lambda_install[unit, point] for point in grid_points)
460 == variables.installed[unit]
461 );
462 constraint!(
463 model,
464 sum!(discount_grid.exponent[point] * variables.lambda_install[unit, point]
465 for point in grid_points)
466 == -DISCOUNT_RATE * variables.installation_time[unit]
467 );
468 }
469}
470
471fn set_npv_objective(
472 model: &Model,
473 sets: &ModelSets,
474 variables: &ModelVariables<'_>,
475 discount_grid: &DiscountGrid,
476) {
477 let tests = sets.tests.clone();
478 let resources = sets.resources.clone();
479 let products = sets.products.clone();
480 let grid_points = sets.grid_points.clone();
481 let income_segments = sets.income_segments.clone();
482 let new_units = sets.new_units.clone();
483
484 constraint!(model,
486 income_segment[product in products, segment in income_segments],
487 variables.income_excess_time[product, segment]
488 >= variables.completion_time[product] - INCOME_BREAKPOINTS[segment]);
489
490 let income = 2.0 * MAX_INCOME
491 - sum!(MONTHLY_INCOME_LOSS[segment]
492 * variables.income_excess_time[product, segment]
493 for product in products, segment in income_segments);
494
495 let fixed_cost = sum!(
497 TESTS[test].fixed_cost * discount_grid.factor[point]
498 * variables.lambda_fixed[test, point]
499 for test in tests, point in grid_points
500 );
501 let resource_cost = sum!(
502 TESTS[test].resource_cost[resource] * discount_grid.factor[point]
503 * variables.lambda_used[test, resource, point]
504 for test in tests, resource in resources, point in grid_points
505 );
506 let installation_cost = sum!(
507 INSTALLATION_COST[unit] * discount_grid.factor[point]
508 * variables.lambda_install[unit, point]
509 for unit in new_units, point in grid_points
510 );
511
512 objective!(model, Max, income - fixed_cost - resource_cost - installation_cost);
514}
515
516fn summarize_solution(
517 result: &SolverResult,
518 variables: &ModelVariables<'_>,
519 discount_grid: &DiscountGrid,
520) -> CaseSummary {
521 let value = |expr| result.value_of(expr).unwrap_or(0.0);
522 let completion = std::array::from_fn(|product| value(variables.completion_time[product]));
523 let mut income_value = [MAX_INCOME; N_PRODUCTS];
524 let mut fixed_cost_value = [0.0; N_PRODUCTS];
525 let mut resource_cost_value = [0.0; N_PRODUCTS];
526 for product in 0..N_PRODUCTS {
527 for (segment, &monthly_loss) in MONTHLY_INCOME_LOSS.iter().enumerate() {
528 income_value[product] -=
529 monthly_loss * value(variables.income_excess_time[(product, segment)]);
530 }
531 for test in product_tests(product) {
532 for point in 0..discount_grid.len() {
533 fixed_cost_value[product] += TESTS[test].fixed_cost
534 * discount_grid.factor[point]
535 * value(variables.lambda_fixed[(test, point)]);
536 for resource in 0..N_RESOURCES {
537 resource_cost_value[product] += TESTS[test].resource_cost[resource]
538 * discount_grid.factor[point]
539 * value(variables.lambda_used[(test, resource, point)]);
540 }
541 }
542 }
543 }
544 let installation_cost_value = (0..NEW_RESOURCES.len())
545 .flat_map(|unit| (0..discount_grid.len()).map(move |point| (unit, point)))
546 .map(|(unit, point)| {
547 INSTALLATION_COST[unit]
548 * discount_grid.factor[point]
549 * value(variables.lambda_install[(unit, point)])
550 })
551 .sum();
552 let installed = std::array::from_fn(|unit| value(variables.installed[unit]) > 0.5);
553 let installation_time = std::array::from_fn(|unit| value(variables.installation_time[unit]));
554 let start_time = std::array::from_fn(|test| value(variables.start_time[test]));
555 let uses_resource = std::array::from_fn(|test| {
556 std::array::from_fn(|resource| value(variables.uses_resource[(test, resource)]) > 0.5)
557 });
558
559 CaseSummary {
560 npv: result.objective().unwrap_or(0.0),
561 completion,
562 income: income_value,
563 fixed_cost: fixed_cost_value,
564 resource_cost: resource_cost_value,
565 installation_cost: installation_cost_value,
566 installed,
567 installation_time,
568 start_time,
569 uses_resource,
570 }
571}
572
573fn print_case(case: AcquisitionCase, result: &SolverResult, summary: &CaseSummary) {
574 let name = case.name();
575 println!("\n{name}");
576 println!("{}", "=".repeat(name.len()));
577 println!("Status: {:?}", result.termination);
578 if let Some(gap) = result.gap {
579 println!("MIP gap: {:.2}%", 100.0 * gap);
580 }
581 if result.termination != TerminationStatus::Optimal {
582 println!("The schedule below is a feasible incumbent, not a certified optimum.");
583 }
584 println!("NPV: ${:.4} million", result.objective().unwrap_or(0.0) / 100.0);
585 println!(
586 "Completion: P1 = {:.1} months, P2 = {:.1} months",
587 summary.completion[0], summary.completion[1]
588 );
589 for (unit, &resource) in NEW_RESOURCES.iter().enumerate() {
590 if summary.installed[unit] {
591 println!(
592 "Acquire {} at month {:.1}",
593 RESOURCE_NAMES[resource], summary.installation_time[unit]
594 );
595 }
596 }
597
598 println!(
599 "\n{:<6} {:<7} {:>7} {:>7} {:<10} {:<10}",
600 "Test", "Product", "Start", "Finish", "Scientists", "Equipment"
601 );
602 println!("{}", "-".repeat(58));
603 let mut schedule: Vec<(usize, f64)> = summary.start_time.iter().copied().enumerate().collect();
604 schedule.sort_by(|left, right| left.1.total_cmp(&right.1));
605 for (test, start_value) in schedule {
606 let scientist = SCIENTIST_RESOURCES
607 .into_iter()
608 .find(|&resource| summary.uses_resource[test][resource])
609 .unwrap();
610 let equipment = EQUIPMENT_RESOURCES
611 .into_iter()
612 .find(|&resource| summary.uses_resource[test][resource])
613 .unwrap();
614 println!(
615 "{:<6} P{:<6} {:>7.1} {:>7.1} {:<10} {:<10}",
616 test + 1,
617 TESTS[test].product + 1,
618 start_value,
619 start_value + TESTS[test].duration,
620 RESOURCE_NAMES[scientist],
621 RESOURCE_NAMES[equipment]
622 );
623 }
624}
625
626fn solve_case(case: AcquisitionCase) -> Result<CaseSummary, Box<dyn std::error::Error>> {
627 let discount_grid = DiscountGrid::section_5();
628 let sets = ModelSets::new(discount_grid.len());
629 let model = Model::new(case.name());
630 let variables = declare_model_variables(&model, &sets);
631
632 add_timing_and_sequencing_constraints(&model, &sets, &variables);
633 add_resource_constraints(&model, &sets, &variables, case);
634 add_discounting_constraints(&model, &sets, &variables, &discount_grid);
635 set_npv_objective(&model, &sets, &variables, &discount_grid);
636
637 let options =
638 HighsOptions::default().time_limit(Duration::from_secs(320)).mip_gap(0.01).verbose(false);
639 let result = Highs.solve(&model, &options)?;
640 if !result.has_solution() {
641 return Err(format!(
642 "{}: HiGHS terminated with {:?} and returned no feasible schedule",
643 case.name(),
644 result.termination
645 )
646 .into());
647 }
648
649 let summary = summarize_solution(&result, &variables, &discount_grid);
650 print_case(case, &result, &summary);
651 Ok(summary)
652}
653
654fn print_product_row(
655 label: &str,
656 model_case_1: [f64; N_PRODUCTS],
657 paper_case_1: [f64; N_PRODUCTS],
658 model_case_2: [f64; N_PRODUCTS],
659 paper_case_2: [f64; N_PRODUCTS],
660 scale: f64,
661) {
662 println!(
663 "{label:<24}{:>8.1}{:>8.1}{:>8.1}{:>8.1}{:>8.1}{:>8.1}{:>8.1}{:>8.1}",
664 model_case_1[0] * scale,
665 paper_case_1[0] * scale,
666 model_case_1[1] * scale,
667 paper_case_1[1] * scale,
668 model_case_2[0] * scale,
669 paper_case_2[0] * scale,
670 model_case_2[1] * scale,
671 paper_case_2[1] * scale
672 );
673}
674
675fn print_comparison(cases: &[CaseSummary; 2]) {
676 const TO_PAPER_UNITS: f64 = 10.0;
679
680 println!("\nComparison with Table 5");
681 println!("{}", "=".repeat(88));
682 println!("{:<24}{:^32}{:^32}", "", "Case 1", "Case 2");
683 println!("{:<24}{:^16}{:^16}{:^16}{:^16}", "", "P1", "P2", "P1", "P2");
684 println!(
685 "{:<24}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}",
686 "", "Model", "Paper", "Model", "Paper", "Model", "Paper", "Model", "Paper"
687 );
688 println!("{}", "-".repeat(88));
689
690 print_product_row(
691 "Completion time",
692 cases[0].completion,
693 PAPER_CASES[0].completion,
694 cases[1].completion,
695 PAPER_CASES[1].completion,
696 1.0,
697 );
698 print_product_row(
699 "Income (US$ 1,000)",
700 cases[0].income,
701 PAPER_CASES[0].income,
702 cases[1].income,
703 PAPER_CASES[1].income,
704 TO_PAPER_UNITS,
705 );
706 print_product_row(
707 "CT1 (US$ 1,000)",
708 cases[0].fixed_cost,
709 PAPER_CASES[0].fixed_cost,
710 cases[1].fixed_cost,
711 PAPER_CASES[1].fixed_cost,
712 TO_PAPER_UNITS,
713 );
714 print_product_row(
715 "CT2 (US$ 1,000)",
716 cases[0].resource_cost,
717 PAPER_CASES[0].resource_cost,
718 cases[1].resource_cost,
719 PAPER_CASES[1].resource_cost,
720 TO_PAPER_UNITS,
721 );
722 println!(
723 "{:<24}{:>8}{:>8}{:>8}{:>8}{:>8.1}{:>8.1}{:>8}{:>8}",
724 "CT3 (US$ 1,000)",
725 "-",
726 "-",
727 "",
728 "",
729 cases[1].installation_cost * TO_PAPER_UNITS,
730 PAPER_CASES[1].installation_cost * TO_PAPER_UNITS,
731 "",
732 ""
733 );
734 println!(
735 "{:<24}{:>8.1}{:>8.1}{:>8}{:>8}{:>8.1}{:>8.1}{:>8}{:>8}",
736 "NPV (US$ 1,000)",
737 cases[0].npv * TO_PAPER_UNITS,
738 PAPER_CASES[0].npv * TO_PAPER_UNITS,
739 "",
740 "",
741 cases[1].npv * TO_PAPER_UNITS,
742 PAPER_CASES[1].npv * TO_PAPER_UNITS,
743 "",
744 ""
745 );
746}
747
748fn main() -> Result<(), Box<dyn std::error::Error>> {
749 let no_acquisition = solve_case(AcquisitionCase::Disabled)?;
750 let acquisition = solve_case(AcquisitionCase::Allowed)?;
751 print_comparison(&[no_acquisition, acquisition]);
752 Ok(())
753}