muse2/simulation/investment/appraisal/
optimisation.rs

1//! Optimisation problem for investment tools.
2use super::DemandMap;
3use super::ObjectiveCoefficients;
4use super::constraints::{
5    add_activity_constraints, add_capacity_constraint, add_demand_constraints,
6};
7use crate::asset::{AssetCapacity, AssetRef};
8use crate::commodity::Commodity;
9use crate::simulation::optimisation::solve_optimal;
10use crate::time_slice::{TimeSliceID, TimeSliceInfo};
11use crate::units::{Activity, Capacity, Flow};
12use anyhow::Result;
13use highs::{RowProblem as Problem, Sense};
14use indexmap::IndexMap;
15
16/// A decision variable in the optimisation
17///
18/// This alias represents a column created in the `highs` solver. Callers rely on the order
19/// in which columns are added to the problem when extracting solution values.
20pub type Variable = highs::Col;
21
22/// Map storing variables for the optimisation problem
23struct VariableMap {
24    /// Capacity variable.
25    ///
26    /// This represents absolute capacity for indivisible assets and number of units for
27    /// divisible assets.
28    capacity_var: Variable,
29    /// Activity variables in each time slice
30    activity_vars: IndexMap<TimeSliceID, Variable>,
31    /// Unmet demand variables
32    unmet_demand_vars: IndexMap<TimeSliceID, Variable>,
33}
34
35impl VariableMap {
36    /// Creates a new variable map by adding variables to the optimisation problem.
37    ///
38    /// # Arguments
39    /// * `problem` - The optimisation problem to add variables to
40    /// * `cost_coefficients` - Objective function coefficients for each variable
41    ///
42    /// # Returns
43    /// A new `VariableMap` containing all created decision variables
44    fn add_to_problem(
45        problem: &mut Problem,
46        cost_coefficients: &ObjectiveCoefficients,
47        capacity_unit_size: Option<Capacity>,
48    ) -> Self {
49        // Create capacity variable with its associated cost
50        let capacity_coefficient = cost_coefficients.capacity_coefficient.value();
51        let capacity_var = match capacity_unit_size {
52            Some(unit_size) => {
53                // Divisible asset: capacity variable represents number of units
54                problem.add_integer_column(capacity_coefficient * unit_size.value(), 0.0..)
55            }
56            None => {
57                // Indivisible asset: capacity variable represents total capacity
58                problem.add_column(capacity_coefficient, 0.0..)
59            }
60        };
61
62        // Create activity variables for each time slice
63        let mut activity_vars = IndexMap::new();
64        for (time_slice, cost) in &cost_coefficients.activity_coefficients {
65            let var = problem.add_column(cost.value(), 0.0..);
66            activity_vars.insert(time_slice.clone(), var);
67        }
68
69        // Create unmet demand variables for each time slice
70        let mut unmet_demand_vars = IndexMap::new();
71        for time_slice in cost_coefficients.activity_coefficients.keys() {
72            let var = problem.add_column(cost_coefficients.unmet_demand_coefficient.value(), 0.0..);
73            unmet_demand_vars.insert(time_slice.clone(), var);
74        }
75
76        Self {
77            capacity_var,
78            activity_vars,
79            unmet_demand_vars,
80        }
81    }
82}
83
84/// Map containing optimisation results and coefficients
85pub struct ResultsMap {
86    /// Capacity variable
87    pub capacity: AssetCapacity,
88    /// Activity variables in each time slice
89    pub activity: IndexMap<TimeSliceID, Activity>,
90    /// Unmet demand variables
91    pub unmet_demand: DemandMap,
92}
93
94/// Adds constraints to the problem.
95fn add_constraints(
96    problem: &mut Problem,
97    asset: &AssetRef,
98    max_capacity: Option<AssetCapacity>,
99    commodity: &Commodity,
100    variables: &VariableMap,
101    demand: &DemandMap,
102    time_slice_info: &TimeSliceInfo,
103) {
104    add_capacity_constraint(problem, asset, max_capacity, variables.capacity_var);
105    add_activity_constraints(
106        problem,
107        asset,
108        variables.capacity_var,
109        &variables.activity_vars,
110        time_slice_info,
111    );
112    add_demand_constraints(
113        problem,
114        asset,
115        commodity,
116        time_slice_info,
117        demand,
118        &variables.activity_vars,
119        &variables.unmet_demand_vars,
120    );
121}
122
123/// Performs optimisation for an asset, given the coefficients and demand.
124///
125/// Will either maximise or minimise the objective function, depending on the `sense` parameter.
126/// The optimisation will use continuous or integer capacity variables depending on whether the
127/// asset has a defined unit size.
128pub fn perform_optimisation(
129    asset: &AssetRef,
130    max_capacity: Option<AssetCapacity>,
131    commodity: &Commodity,
132    coefficients: &ObjectiveCoefficients,
133    demand: &DemandMap,
134    time_slice_info: &TimeSliceInfo,
135    sense: Sense,
136) -> Result<ResultsMap> {
137    // Create problem and add variables
138    let mut problem = Problem::default();
139    let variables = VariableMap::add_to_problem(&mut problem, coefficients, asset.unit_size());
140
141    // Add constraints
142    add_constraints(
143        &mut problem,
144        asset,
145        max_capacity,
146        commodity,
147        &variables,
148        demand,
149        time_slice_info,
150    );
151
152    // Solve model
153    let solution = solve_optimal(problem.optimise(sense))?.get_solution();
154    let solution_values = solution.columns();
155    Ok(ResultsMap {
156        // If the asset has a defined unit size, the capacity variable represents number of units,
157        // otherwise it represents absolute capacity
158        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
159        capacity: match asset.unit_size() {
160            Some(unit_size) => {
161                AssetCapacity::Discrete(solution_values[0].round() as u32, unit_size)
162            }
163            None => AssetCapacity::Continuous(Capacity::new(solution_values[0])),
164        },
165        // The mapping below assumes the column ordering documented on `VariableMap::add_to_problem`:
166        // index 0 = capacity, next `n` entries = activities (in the same key order as
167        // `cost_coefficients.activity_coefficients`), remaining entries = unmet demand.
168        activity: variables
169            .activity_vars
170            .keys()
171            .zip(solution_values[1..].iter())
172            .map(|(time_slice, &value)| (time_slice.clone(), Activity::new(value)))
173            .collect(),
174        unmet_demand: variables
175            .unmet_demand_vars
176            .keys()
177            .zip(solution_values[variables.activity_vars.len() + 1..].iter())
178            .map(|(time_slice, &value)| (time_slice.clone(), Flow::new(value)))
179            .collect(),
180    })
181}