muse2/simulation/
optimisation.rs

1//! Code for performing dispatch optimisation.
2//!
3//! This is used to calculate commodity flows and prices.
4use crate::asset::{Asset, AssetID, AssetPool};
5use crate::commodity::{BalanceType, CommodityID};
6use crate::model::Model;
7use crate::process::ProcessFlow;
8use crate::region::RegionID;
9use crate::time_slice::{TimeSliceID, TimeSliceInfo};
10use anyhow::{anyhow, Result};
11use highs::{HighsModelStatus, RowProblem as Problem, Sense};
12use indexmap::IndexMap;
13
14mod constraints;
15use constraints::{add_asset_constraints, CapacityConstraintKeys, CommodityBalanceConstraintKeys};
16
17/// A decision variable in the optimisation
18///
19/// Note that this type does **not** include the value of the variable; it just refers to a
20/// particular column of the problem.
21type Variable = highs::Col;
22
23/// A map for easy lookup of variables in the problem.
24///
25/// The entries are ordered (see [`IndexMap`]).
26///
27/// We use this data structure for two things:
28///
29/// 1. In order define constraints for the optimisation
30/// 2. To keep track of the combination of parameters that each variable corresponds to, for when we
31///    are reading the results of the optimisation.
32#[derive(Default)]
33pub struct VariableMap(IndexMap<(AssetID, CommodityID, TimeSliceID), Variable>);
34
35impl VariableMap {
36    /// Get the [`Variable`] corresponding to the given parameters.
37    fn get(
38        &self,
39        asset_id: AssetID,
40        commodity_id: &CommodityID,
41        time_slice: &TimeSliceID,
42    ) -> Variable {
43        let key = (asset_id, commodity_id.clone(), time_slice.clone());
44
45        *self
46            .0
47            .get(&key)
48            .expect("No variable found for given params")
49    }
50}
51
52/// The solution to the dispatch optimisation problem
53pub struct Solution<'a> {
54    solution: highs::Solution,
55    variables: VariableMap,
56    time_slice_info: &'a TimeSliceInfo,
57    commodity_balance_constraint_keys: CommodityBalanceConstraintKeys,
58    capacity_constraint_keys: CapacityConstraintKeys,
59}
60
61impl Solution<'_> {
62    /// Iterate over the newly calculated commodity flows for assets.
63    ///
64    /// Note that this only includes commodity flows which relate to assets, so not every commodity
65    /// in the simulation will necessarily be represented.
66    ///
67    /// # Returns
68    ///
69    /// An iterator of tuples containing an asset ID, commodity, time slice and flow.
70    pub fn iter_commodity_flows_for_assets(
71        &self,
72    ) -> impl Iterator<Item = (AssetID, &CommodityID, &TimeSliceID, f64)> {
73        self.variables
74            .0
75            .keys()
76            .zip(self.solution.columns().iter().copied())
77            .map(|((asset_id, commodity_id, time_slice), flow)| {
78                (*asset_id, commodity_id, time_slice, flow)
79            })
80    }
81
82    /// Keys and dual values for commodity balance constraints.
83    pub fn iter_commodity_balance_duals(
84        &self,
85    ) -> impl Iterator<Item = (&CommodityID, &RegionID, &TimeSliceID, f64)> {
86        // Each commodity balance constraint applies to a particular time slice
87        // selection (depending on time slice level). Where this covers multiple timeslices,
88        // we return the same dual for each individual timeslice.
89        self.commodity_balance_constraint_keys
90            .iter()
91            .zip(self.solution.dual_rows())
92            .flat_map(|((commodity_id, region_id, ts_selection), price)| {
93                self.time_slice_info
94                    .iter_selection(ts_selection)
95                    .map(move |(ts, _)| (commodity_id, region_id, ts, *price))
96            })
97    }
98
99    /// Keys and dual values for capacity constraints.
100    pub fn iter_capacity_duals(&self) -> impl Iterator<Item = (AssetID, &TimeSliceID, f64)> {
101        self.capacity_constraint_keys
102            .iter()
103            .zip(
104                self.solution.dual_rows()[self.commodity_balance_constraint_keys.len()..]
105                    .iter()
106                    .copied(),
107            )
108            .map(|((asset_id, time_slice), dual)| (*asset_id, time_slice, dual))
109    }
110}
111
112/// Perform the dispatch optimisation.
113///
114/// For a detailed description, please see the [dispatch optimisation formulation][1].
115///
116/// [1]: https://energysystemsmodellinglab.github.io/MUSE_2.0/dispatch_optimisation.html
117///
118/// # Arguments
119///
120/// * `model` - The model
121/// * `assets` - The asset pool
122/// * `year` - Current milestone year
123///
124/// # Returns
125///
126/// A solution containing new commodity flows for assets and prices for (some) commodities.
127pub fn perform_dispatch_optimisation<'a>(
128    model: &'a Model,
129    assets: &AssetPool,
130    year: u32,
131) -> Result<Solution<'a>> {
132    // Set up problem
133    let mut problem = Problem::default();
134    let variables = add_variables(&mut problem, model, assets, year);
135
136    // Add constraints
137    let (commodity_balance_constraint_keys, capacity_constraint_keys) =
138        add_asset_constraints(&mut problem, &variables, model, assets, year);
139
140    // Solve problem
141    let mut highs_model = problem.optimise(Sense::Minimise);
142
143    // **HACK**: Dump output of HiGHS solver to stdout. Among other things, this includes the
144    // objective value for the solution. Sadly it doesn't go via our logger, so this information
145    // will not be included in the log file.
146    //
147    // Should be removed when we write the objective value to output data properly. See:
148    //   https://github.com/EnergySystemsModellingLab/MUSE_2.0/issues/428
149    enable_highs_logging(&mut highs_model);
150
151    // Solve model
152    let solution = highs_model.solve();
153    match solution.status() {
154        HighsModelStatus::Optimal => Ok(Solution {
155            solution: solution.get_solution(),
156            variables,
157            time_slice_info: &model.time_slice_info,
158            commodity_balance_constraint_keys,
159            capacity_constraint_keys,
160        }),
161        status => Err(anyhow!("Could not solve: {status:?}")),
162    }
163}
164
165/// Enable logging for the HiGHS solver
166fn enable_highs_logging(model: &mut highs::Model) {
167    // **HACK**: Skip this step if logging is disabled (e.g. when running tests)
168    if let Ok(log_level) = std::env::var("MUSE2_LOG_LEVEL") {
169        if log_level.eq_ignore_ascii_case("off") {
170            return;
171        }
172    }
173
174    model.set_option("log_to_console", true);
175    model.set_option("output_flag", true);
176}
177
178/// Add variables to the optimisation problem.
179///
180/// # Arguments
181///
182/// * `problem` - The optimisation problem
183/// * `model` - The model
184/// * `assets` - The asset pool
185/// * `year` - Current milestone year
186///
187/// # Returns
188///
189/// A [`VariableMap`] with the problem's variables as values.
190fn add_variables(
191    problem: &mut Problem,
192    model: &Model,
193    assets: &AssetPool,
194    year: u32,
195) -> VariableMap {
196    let mut variables = VariableMap::default();
197
198    for asset in assets.iter() {
199        for flow in asset.process.flows.iter() {
200            for time_slice in model.time_slice_info.iter_ids() {
201                let coeff = calculate_cost_coefficient(asset, flow, year, time_slice);
202
203                // var's value must be <= 0 for inputs and >= 0 for outputs
204                let var = if flow.flow < 0.0 {
205                    problem.add_column(coeff, ..=0.0)
206                } else {
207                    problem.add_column(coeff, 0.0..)
208                };
209
210                let key = (asset.id, flow.commodity.id.clone(), time_slice.clone());
211                let existing = variables.0.insert(key, var).is_some();
212                assert!(!existing, "Duplicate entry for var");
213            }
214        }
215    }
216
217    variables
218}
219
220/// Calculate the cost coefficient for a decision variable
221fn calculate_cost_coefficient(
222    asset: &Asset,
223    flow: &ProcessFlow,
224    year: u32,
225    time_slice: &TimeSliceID,
226) -> f64 {
227    // Cost per unit flow
228    let mut coeff = flow.flow_cost;
229
230    // Only applies if commodity is PAC
231    if flow.is_pac {
232        coeff += asset
233            .process
234            .parameters
235            .get(&(asset.region_id.clone(), asset.commission_year))
236            .unwrap()
237            .variable_operating_cost
238    }
239
240    // If there is a user-provided cost for this commodity, include it
241    if !flow.commodity.costs.is_empty() {
242        let cost = flow
243            .commodity
244            .costs
245            .get(&(asset.region_id.clone(), year, time_slice.clone()))
246            .unwrap();
247        let apply_cost = match cost.balance_type {
248            BalanceType::Net => true,
249            BalanceType::Consumption => flow.flow < 0.0,
250            BalanceType::Production => flow.flow > 0.0,
251        };
252
253        if apply_cost {
254            coeff += cost.value;
255        }
256    }
257
258    // If flow is negative (representing an input), we multiply by -1 to ensure impact of
259    // coefficient on objective function is a positive cost
260    if flow.flow > 0.0 {
261        coeff
262    } else {
263        -coeff
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use crate::commodity::{Commodity, CommodityCost, CommodityCostMap, CommodityType, DemandMap};
271    use crate::process::{
272        FlowType, Process, ProcessEnergyLimitsMap, ProcessParameter, ProcessParameterMap,
273    };
274    use crate::time_slice::TimeSliceLevel;
275    use float_cmp::assert_approx_eq;
276    use std::collections::HashSet;
277    use std::rc::Rc;
278
279    fn get_cost_coeff_args(
280        flow: f64,
281        is_pac: bool,
282        costs: CommodityCostMap,
283    ) -> (Asset, ProcessFlow) {
284        let process_param = Rc::new(ProcessParameter {
285            capital_cost: 5.0,
286            fixed_operating_cost: 2.0,
287            variable_operating_cost: 1.0,
288            lifetime: 5,
289            discount_rate: 0.9,
290            capacity_to_activity: 1.0,
291        });
292        let mut process_parameter_map = ProcessParameterMap::new();
293        process_parameter_map.insert(("GBR".into(), 2010), process_param.clone());
294        process_parameter_map.insert(("GBR".into(), 2020), process_param.clone());
295        let commodity = Rc::new(Commodity {
296            id: "commodity1".into(),
297            description: "Some description".into(),
298            kind: CommodityType::InputCommodity,
299            time_slice_level: TimeSliceLevel::Annual,
300            costs,
301            demand: DemandMap::new(),
302        });
303        let flow = ProcessFlow {
304            process_id: "id1".into(),
305            commodity: Rc::clone(&commodity),
306            flow,
307            flow_type: FlowType::Fixed,
308            flow_cost: 1.0,
309            is_pac,
310        };
311        let process = Rc::new(Process {
312            id: "process1".into(),
313            description: "Description".into(),
314            years: 2010..=2020,
315            energy_limits: ProcessEnergyLimitsMap::new(),
316            flows: vec![flow.clone()],
317            parameters: process_parameter_map,
318            regions: HashSet::from([RegionID("GBR".into())]),
319        });
320        let asset = Asset::new(
321            "agent1".into(),
322            Rc::clone(&process),
323            "GBR".into(),
324            1.0,
325            2010,
326        )
327        .unwrap();
328
329        (asset, flow)
330    }
331
332    #[test]
333    fn test_calculate_cost_coefficient() {
334        let time_slice = TimeSliceID {
335            season: "winter".into(),
336            time_of_day: "day".into(),
337        };
338
339        macro_rules! check_coeff {
340            ($flow:expr, $is_pac:expr, $costs:expr, $expected:expr) => {
341                let (asset, flow) = get_cost_coeff_args($flow, $is_pac, $costs);
342                assert_approx_eq!(
343                    f64,
344                    calculate_cost_coefficient(&asset, &flow, 2010, &time_slice),
345                    $expected
346                );
347            };
348        }
349
350        // not PAC, no commodity cost
351        check_coeff!(1.0, false, CommodityCostMap::new(), 1.0);
352        check_coeff!(-1.0, false, CommodityCostMap::new(), -1.0);
353
354        // PAC, no commodity cost
355        check_coeff!(1.0, true, CommodityCostMap::new(), 2.0);
356        check_coeff!(-1.0, true, CommodityCostMap::new(), -2.0);
357
358        // not PAC, commodity cost for output
359        let cost = CommodityCost {
360            balance_type: BalanceType::Production,
361            value: 2.0,
362        };
363        let mut costs = CommodityCostMap::new();
364        costs.insert(("GBR".into(), 2010, time_slice.clone()), cost);
365        check_coeff!(1.0, false, costs.clone(), 3.0);
366        check_coeff!(-1.0, false, costs, -1.0);
367
368        // not PAC, commodity cost for output and input
369        let cost = CommodityCost {
370            balance_type: BalanceType::Net,
371            value: 2.0,
372        };
373        let mut costs = CommodityCostMap::new();
374        costs.insert(("GBR".into(), 2010, time_slice.clone()), cost);
375        check_coeff!(1.0, false, costs.clone(), 3.0);
376        check_coeff!(-1.0, false, costs, -3.0);
377
378        // not PAC, commodity cost for input
379        let cost = CommodityCost {
380            balance_type: BalanceType::Consumption,
381            value: 2.0,
382        };
383        let mut costs = CommodityCostMap::new();
384        costs.insert(("GBR".into(), 2010, time_slice.clone()), cost);
385        check_coeff!(1.0, false, costs.clone(), 1.0);
386        check_coeff!(-1.0, false, costs, -3.0);
387
388        // PAC, commodity cost for output
389        let cost = CommodityCost {
390            balance_type: BalanceType::Production,
391            value: 2.0,
392        };
393        let mut costs = CommodityCostMap::new();
394        costs.insert(("GBR".into(), 2010, time_slice.clone()), cost);
395        check_coeff!(1.0, true, costs.clone(), 4.0);
396        check_coeff!(-1.0, true, costs, -2.0);
397    }
398}