Skip to main content

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