Skip to main content

resource_investment_scheduling/
resource_investment_scheduling.rs

1//! Optimal resource investment and scheduling of tests for new product development.
2//!
3//! This example implements the ten-test instance in Section 5 of:
4//!
5//! Maravelias, C. T., & Grossmann, I. E. (2004).
6//! Optimal resource investment and scheduling of tests for new product development.
7//! Computers & Chemical Engineering, 28(6–7), 1021–1038.
8//! <https://doi.org/10.1016/j.compchemeng.2003.09.019>
9//!
10//! Two products share scientists and experimental equipment.
11//! Tests may use an existing unit, a newly acquired unit, or an
12//! outsourced (dummy) unit. The model decides the test schedule,
13//! resource assignments, and whether and when to acquire the two
14//! new units. Its objective is expected discounted income minus
15//! expected discounted testing and investment costs.
16//!
17//! This is the fixed-duration, one-unit-per-category specialization of the
18//! paper's strengthened model M*. It uses the FIS precedence closure, the
19//! disaggregated discounting formulation (26)-(29), and valid sequencing
20//! cuts.
21//!
22//! The paper does not list the discounting grid used for Section 5. Here the
23//! exponent is approximated at 0.1-wide points from 0 to -1.7, which covers the
24//! feasible range for the 100-month horizon.
25//!
26//! Run with HiGHS:
27//!
28//! ```text
29//! cargo run -p oximo --example resource_investment_scheduling
30//! ```
31
32use 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
46// All monetary values are in $10,000.
47const 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
231// Table 5 values converted from US$1,000 to the model's US$10,000 units.
232struct 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    // Lambda^1, lambda^2, bar-lambda^2, and lambda^3 in model M*.
303    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    // (1): each product completes after all of its tests.
333    constraint!(model, finish[test in tests],
334        variables.start_time[test] + TESTS[test].duration
335            <= variables.completion_time[TESTS[test].product]);
336
337    // (3), (4), and (4*): precedes[test, other] implies non-overlap. Every
338    // test in this instance is eligible for every resource category, so the
339    // cross-product form (4*) covers every pair belonging to different products.
340    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    // (2) plus FIS: fix direct and transitively implied technological precedences.
346    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    // Valid cycle and transitivity cuts strengthen the sequencing relaxation.
357    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    // (7)-(8): N_min = N_max = 1, so every test uses exactly one scientist
374    // group and one equipment unit.
375    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    // (6) and (10): new units must be installed before an assigned test starts.
381    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    // (9): in-house units have unit capacity.
395    // Dummy outsourcing units A3 and B3 deliberately have unlimited capacity.
396    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    // (11), (14), and (26)-(29): a test is conducted only if every test
419    // scheduled before it in the same product succeeds. The probability in
420    // the exponent therefore belongs to the preceding test, not the current one.
421    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    // (13) and (17): discount each installed unit's acquisition cost.
456    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    // (21) and (22): $5M maximum income, reduced after months 24 and 48.
485    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    // (18) and (20): expected discounted fixed, utilization, and installation costs.
496    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    // (23): maximize net present value.
513    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    // Model coefficients are in $10,000.
677    // Table 5 reports money in $1,000.
678    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}