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, AssetCapacity, AssetRef, AssetState};
5use crate::commodity::CommodityID;
6use crate::finance::annual_capital_cost;
7use crate::input::format_items_with_cap;
8use crate::model::Model;
9use crate::output::DataWriter;
10use crate::region::RegionID;
11use crate::simulation::CommodityPrices;
12use crate::time_slice::{TimeSliceID, TimeSliceInfo, TimeSliceSelection};
13use crate::units::{
14    Activity, Capacity, Dimensionless, Flow, Money, MoneyPerActivity, MoneyPerCapacity,
15    MoneyPerFlow, Year,
16};
17use anyhow::{Result, bail, ensure};
18use highs::{HighsModelStatus, HighsStatus, RowProblem as Problem, Sense};
19use indexmap::{IndexMap, IndexSet};
20use itertools::{Itertools, chain, iproduct};
21use std::cell::Cell;
22use std::collections::{HashMap, HashSet};
23use std::error::Error;
24use std::fmt;
25use std::ops::Range;
26
27mod constraints;
28use constraints::{ConstraintKeys, add_model_constraints};
29
30/// A map of commodity flows calculated during the optimisation
31pub type FlowMap = IndexMap<(AssetRef, CommodityID, TimeSliceID), Flow>;
32
33/// A decision variable in the optimisation
34///
35/// Note that this type does **not** include the value of the variable; it just refers to a
36/// particular column of the problem.
37type Variable = highs::Col;
38
39/// The map of activity variables for assets
40type ActivityVariableMap = IndexMap<(AssetRef, TimeSliceID), Variable>;
41
42/// A map of capacity variables for assets
43type CapacityVariableMap = IndexMap<AssetRef, Variable>;
44
45/// Variables representing unmet demand for a given market
46type UnmetDemandVariableMap = IndexMap<(CommodityID, RegionID, TimeSliceID), Variable>;
47
48/// A map for easy lookup of variables in the problem.
49///
50/// The entries are ordered (see [`IndexMap`]).
51///
52/// We use this data structure for two things:
53///
54/// 1. In order define constraints for the optimisation
55/// 2. To keep track of the combination of parameters that each variable corresponds to, for when we
56///    are reading the results of the optimisation.
57pub struct VariableMap {
58    activity_vars: ActivityVariableMap,
59    existing_asset_var_idx: Range<usize>,
60    candidate_asset_var_idx: Range<usize>,
61    capacity_vars: CapacityVariableMap,
62    capacity_var_idx: Range<usize>,
63    unmet_demand_vars: UnmetDemandVariableMap,
64    unmet_demand_var_idx: Range<usize>,
65}
66
67impl VariableMap {
68    /// Create a new [`VariableMap`] and add activity variables to the problem
69    ///
70    /// # Arguments
71    ///
72    /// * `problem` - The optimisation problem
73    /// * `model` - The model
74    /// * `input_prices` - Optional explicit prices for input commodities
75    /// * `existing_assets` - The asset pool
76    /// * `candidate_assets` - Candidate assets for inclusion in active pool
77    /// * `year` - Current milestone year
78    fn new_with_activity_vars(
79        problem: &mut Problem,
80        model: &Model,
81        input_prices: Option<&CommodityPrices>,
82        existing_assets: &[AssetRef],
83        candidate_assets: &[AssetRef],
84        year: u32,
85    ) -> Self {
86        let mut activity_vars = ActivityVariableMap::new();
87        let existing_asset_var_idx = add_activity_variables(
88            problem,
89            &mut activity_vars,
90            &model.time_slice_info,
91            input_prices,
92            existing_assets,
93            year,
94        );
95        let candidate_asset_var_idx = add_activity_variables(
96            problem,
97            &mut activity_vars,
98            &model.time_slice_info,
99            input_prices,
100            candidate_assets,
101            year,
102        );
103
104        Self {
105            activity_vars,
106            existing_asset_var_idx,
107            candidate_asset_var_idx,
108            capacity_vars: CapacityVariableMap::new(),
109            capacity_var_idx: Range::default(),
110            unmet_demand_vars: UnmetDemandVariableMap::default(),
111            unmet_demand_var_idx: Range::default(),
112        }
113    }
114
115    /// Add unmet demand variables to the map and the problem
116    ///
117    /// # Arguments
118    ///
119    /// * `problem` - The optimisation problem
120    /// * `model` - The model
121    /// * `markets_to_allow_unmet_demand` - The subset of markets to add unmet demand variables for
122    fn add_unmet_demand_variables(
123        &mut self,
124        problem: &mut Problem,
125        model: &Model,
126        markets_to_allow_unmet_demand: &[(CommodityID, RegionID)],
127    ) {
128        assert!(!markets_to_allow_unmet_demand.is_empty());
129
130        // This line **must** come before we add more variables
131        let start = problem.num_cols();
132
133        // Add variables
134        let voll = model.parameters.value_of_lost_load;
135        self.unmet_demand_vars.extend(
136            iproduct!(
137                markets_to_allow_unmet_demand.iter(),
138                model.time_slice_info.iter_ids()
139            )
140            .map(|((commodity_id, region_id), time_slice)| {
141                let key = (commodity_id.clone(), region_id.clone(), time_slice.clone());
142                let var = problem.add_column(voll.value(), 0.0..);
143                (key, var)
144            }),
145        );
146
147        self.unmet_demand_var_idx = start..problem.num_cols();
148    }
149
150    /// Get the activity [`Variable`] corresponding to the given parameters.
151    fn get_activity_var(&self, asset: &AssetRef, time_slice: &TimeSliceID) -> Variable {
152        let key = (asset.clone(), time_slice.clone());
153
154        *self
155            .activity_vars
156            .get(&key)
157            .expect("No asset variable found for given params")
158    }
159
160    /// Get the unmet demand [`Variable`] corresponding to the given parameters.
161    fn get_unmet_demand_var(
162        &self,
163        commodity_id: &CommodityID,
164        region_id: &RegionID,
165        time_slice: &TimeSliceID,
166    ) -> Variable {
167        *self
168            .unmet_demand_vars
169            .get(&(commodity_id.clone(), region_id.clone(), time_slice.clone()))
170            .expect("No unmet demand variable for given params")
171    }
172
173    /// Iterate over the keys for activity variables
174    fn activity_var_keys(&self) -> indexmap::map::Keys<'_, (AssetRef, TimeSliceID), Variable> {
175        self.activity_vars.keys()
176    }
177
178    /// Iterate over capacity variables
179    fn iter_capacity_vars(&self) -> impl Iterator<Item = (&AssetRef, Variable)> {
180        self.capacity_vars.iter().map(|(asset, var)| (asset, *var))
181    }
182}
183
184/// Create a map of commodity flows for each asset's coeffs at every time slice
185fn create_flow_map<'a>(
186    existing_assets: &[AssetRef],
187    time_slice_info: &TimeSliceInfo,
188    activity: impl IntoIterator<Item = (&'a AssetRef, &'a TimeSliceID, Activity)>,
189) -> FlowMap {
190    // The decision variables represent assets' activity levels, not commodity flows. We
191    // multiply this value by the flow coeffs to get commodity flows.
192    let mut flows = FlowMap::new();
193    for (asset, time_slice, activity) in activity {
194        let n_units = Dimensionless(asset.num_children().unwrap_or(1) as f64);
195        for flow in asset.iter_flows() {
196            let flow_key = (asset.clone(), flow.commodity.id.clone(), time_slice.clone());
197            let flow_value = activity * flow.coeff / n_units;
198            flows.insert(flow_key, flow_value);
199        }
200    }
201
202    // Copy flows for each child asset
203    for asset in existing_assets {
204        if let Some(parent) = asset.parent() {
205            for commodity_id in asset.iter_flows().map(|flow| &flow.commodity.id) {
206                for time_slice in time_slice_info.iter_ids() {
207                    let flow = flows[&(parent.clone(), commodity_id.clone(), time_slice.clone())];
208                    flows.insert(
209                        (asset.clone(), commodity_id.clone(), time_slice.clone()),
210                        flow,
211                    );
212                }
213            }
214        }
215    }
216
217    // Remove all the parent assets
218    flows.retain(|(asset, _, _), _| !asset.is_parent());
219
220    flows
221}
222
223/// The solution to the dispatch optimisation problem
224#[allow(clippy::struct_field_names)]
225pub struct Solution<'a> {
226    solution: highs::Solution,
227    variables: VariableMap,
228    time_slice_info: &'a TimeSliceInfo,
229    constraint_keys: ConstraintKeys,
230    flow_map: Cell<Option<FlowMap>>,
231    /// The objective value for the solution
232    pub objective_value: Money,
233}
234
235impl Solution<'_> {
236    /// Create a map of commodity flows for each asset's coeffs at every time slice.
237    ///
238    /// Note: The flow map is actually already created and is taken from `self` when this method is
239    /// called (hence it can only be called once). The reason for this is because we need to convert
240    /// back from parent assets to child assets. We can remove this hack once we have updated all
241    /// the users of this interface to be able to handle parent assets correctly.
242    pub fn create_flow_map(&self) -> FlowMap {
243        self.flow_map.take().expect("Flow map already created")
244    }
245
246    /// Activity for all assets (existing and candidate, if present)
247    pub fn iter_activity(&self) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, Activity)> {
248        self.variables
249            .activity_var_keys()
250            .zip(self.solution.columns())
251            .map(|((asset, time_slice), activity)| (asset, time_slice, Activity(*activity)))
252    }
253
254    /// Activity for each existing asset
255    pub fn iter_activity_for_existing(
256        &self,
257    ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, Activity)> {
258        let cols = &self.solution.columns()[self.variables.existing_asset_var_idx.clone()];
259        self.variables
260            .activity_var_keys()
261            .skip(self.variables.existing_asset_var_idx.start)
262            .zip(cols.iter())
263            .map(|((asset, time_slice), &value)| (asset, time_slice, Activity(value)))
264    }
265
266    /// Iterate over the keys for activity for each existing asset
267    pub fn iter_activity_keys_for_existing(
268        &self,
269    ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID)> {
270        self.iter_activity_for_existing()
271            .map(|(asset, time_slice, _activity)| (asset, time_slice))
272    }
273
274    /// Activity for each candidate asset
275    pub fn iter_activity_for_candidates(
276        &self,
277    ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, Activity)> {
278        let cols = &self.solution.columns()[self.variables.candidate_asset_var_idx.clone()];
279        self.variables
280            .activity_var_keys()
281            .skip(self.variables.candidate_asset_var_idx.start)
282            .zip(cols.iter())
283            .map(|((asset, time_slice), &value)| (asset, time_slice, Activity(value)))
284    }
285
286    /// Iterate over the keys for activity for each candidate asset
287    pub fn iter_activity_keys_for_candidates(
288        &self,
289    ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID)> {
290        self.iter_activity_for_candidates()
291            .map(|(asset, time_slice, _activity)| (asset, time_slice))
292    }
293
294    /// Iterate over unmet demand
295    pub fn iter_unmet_demand(
296        &self,
297    ) -> impl Iterator<Item = (&CommodityID, &RegionID, &TimeSliceID, Flow)> {
298        self.variables
299            .unmet_demand_vars
300            .keys()
301            .zip(self.solution.columns()[self.variables.unmet_demand_var_idx.clone()].iter())
302            .map(|((commodity_id, region_id, time_slice), flow)| {
303                (commodity_id, region_id, time_slice, Flow(*flow))
304            })
305    }
306
307    /// Iterate over capacity values
308    ///
309    /// Will return `AssetCapacity::Continuous` or `AssetCapacity::Discrete` depending on whether
310    /// the asset has a defined unit size.
311    pub fn iter_capacity(&self) -> impl Iterator<Item = (&AssetRef, AssetCapacity)> {
312        self.variables
313            .capacity_vars
314            .keys()
315            .zip(self.solution.columns()[self.variables.capacity_var_idx.clone()].iter())
316            .map(|(asset, capacity_var)| {
317                // If the asset has a defined unit size, the capacity variable represents number of
318                // units, otherwise it represents absolute capacity
319                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
320                let asset_capacity = if let Some(unit_size) = asset.unit_size() {
321                    AssetCapacity::Discrete(capacity_var.round() as u32, unit_size)
322                } else {
323                    AssetCapacity::Continuous(Capacity(*capacity_var))
324                };
325                (asset, asset_capacity)
326            })
327    }
328
329    /// Keys and dual values for commodity balance constraints.
330    pub fn iter_commodity_balance_duals(
331        &self,
332    ) -> impl Iterator<Item = (&CommodityID, &RegionID, &TimeSliceID, MoneyPerFlow)> {
333        // Each commodity balance constraint applies to a particular time slice
334        // selection (depending on time slice level). Where this covers multiple time slices,
335        // we return the same dual for each individual time slice.
336        self.constraint_keys
337            .commodity_balance_keys
338            .zip_duals(self.solution.dual_rows())
339            .flat_map(|((commodity_id, region_id, ts_selection), price)| {
340                ts_selection
341                    .iter(self.time_slice_info)
342                    .map(move |(ts, _)| (commodity_id, region_id, ts, price))
343            })
344    }
345
346    /// Keys and dual values for activity constraints.
347    ///
348    /// Note: if there are any flexible capacity assets, these will have two duals with identical
349    /// keys, and there will be no way to distinguish between them in the resulting iterator.
350    /// Recommended for now only to use this function when there are no flexible capacity assets.
351    ///
352    /// Also note: this excludes seasonal and annual constraints. Recommended for now not to use
353    /// this for models that include seasonal or annual availability constraints.
354    pub fn iter_activity_duals(
355        &self,
356    ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, MoneyPerActivity)> {
357        self.constraint_keys
358            .activity_keys
359            .zip_duals(self.solution.dual_rows())
360            .filter(|&((_asset, ts_selection), _dual)| {
361                matches!(ts_selection, TimeSliceSelection::Single(_))
362            })
363            .map(|((asset, ts_selection), dual)| {
364                // `unwrap` is safe here because we just matched Single(_)
365                let (time_slice, _) = ts_selection.iter(self.time_slice_info).next().unwrap();
366                (asset, time_slice, dual)
367            })
368    }
369
370    /// Keys and values for column duals.
371    pub fn iter_column_duals(
372        &self,
373    ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, MoneyPerActivity)> {
374        self.variables
375            .activity_var_keys()
376            .zip(self.solution.dual_columns())
377            .map(|((asset, time_slice), dual)| (asset, time_slice, MoneyPerActivity(*dual)))
378    }
379}
380
381/// Defines the possible errors that can occur when running the solver
382#[derive(Debug, Clone)]
383pub enum ModelError {
384    /// The model definition is incoherent.
385    ///
386    /// Users should not be able to trigger this error.
387    Incoherent(HighsStatus),
388    /// An optimal solution could not be found
389    NonOptimal(HighsModelStatus),
390}
391
392impl fmt::Display for ModelError {
393    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
394        match self {
395            ModelError::Incoherent(status) => write!(f, "Incoherent model: {status:?}"),
396            ModelError::NonOptimal(status) => {
397                write!(f, "Could not find optimal result: {status:?}")
398            }
399        }
400    }
401}
402
403impl Error for ModelError {}
404
405/// Try to solve the model, returning an error if the model is incoherent or result is non-optimal
406pub fn solve_optimal(model: highs::Model) -> Result<highs::SolvedModel, ModelError> {
407    let solved = model.try_solve().map_err(ModelError::Incoherent)?;
408
409    match solved.status() {
410        HighsModelStatus::Optimal => Ok(solved),
411        status => Err(ModelError::NonOptimal(status)),
412    }
413}
414
415/// Filter prices data to only include prices for markets not being balanced
416///
417/// Markets being balanced (i.e. with commodity balance constraints) will have prices calculated
418/// internally by the solver, so we need to remove them to prevent double-counting.
419fn filter_input_prices(
420    input_prices: &CommodityPrices,
421    markets_to_balance: &[(CommodityID, RegionID)],
422) -> CommodityPrices {
423    input_prices
424        .iter()
425        .filter(|(commodity_id, region_id, _, _)| {
426            !markets_to_balance
427                .iter()
428                .any(|(c, r)| c == *commodity_id && r == *region_id)
429        })
430        .collect()
431}
432
433/// Get the parent for each asset, if it has one, or itself.
434///
435/// Child assets are converted to their parents and non-divisible assets are returned as is. Each
436/// parent asset is returned only once.
437fn get_parent_or_self(assets: &[AssetRef]) -> impl Iterator<Item = AssetRef> {
438    let mut parents = HashSet::new();
439    assets
440        .iter()
441        .filter_map(move |asset| match asset.parent() {
442            Some(parent) => parents.insert(parent.clone()).then_some(parent),
443            None => Some(asset),
444        })
445        .cloned()
446}
447
448/// Provides the interface for running the dispatch optimisation.
449///
450/// The run will attempt to meet unmet demand: if the solver reports infeasibility
451/// the implementation will rerun including unmet-demand variables to identify offending
452/// markets and provide a clearer error message.
453///
454/// For a detailed description, please see the [dispatch optimisation formulation][1].
455///
456/// [1]: https://energysystemsmodellinglab.github.io/MUSE2/model/dispatch_optimisation.html
457pub struct DispatchRun<'model, 'run> {
458    model: &'model Model,
459    existing_assets: &'run [AssetRef],
460    flexible_capacity_assets: &'run [AssetRef],
461    capacity_limits: Option<&'run HashMap<AssetRef, AssetCapacity>>,
462    candidate_assets: &'run [AssetRef],
463    markets_to_balance: &'run [(CommodityID, RegionID)],
464    input_prices: Option<&'run CommodityPrices>,
465    year: u32,
466    capacity_margin: f64,
467}
468
469impl<'model, 'run> DispatchRun<'model, 'run> {
470    /// Create a new [`DispatchRun`] for the specified model and assets for a given year
471    pub fn new(model: &'model Model, assets: &'run [AssetRef], year: u32) -> Self {
472        Self {
473            model,
474            existing_assets: assets,
475            flexible_capacity_assets: &[],
476            capacity_limits: None,
477            candidate_assets: &[],
478            markets_to_balance: &[],
479            input_prices: None,
480            year,
481            capacity_margin: 0.0,
482        }
483    }
484
485    /// Include the specified flexible capacity assets in the dispatch run
486    pub fn with_flexible_capacity_assets(
487        self,
488        flexible_capacity_assets: &'run [AssetRef],
489        capacity_limits: Option<&'run HashMap<AssetRef, AssetCapacity>>,
490        capacity_margin: f64,
491    ) -> Self {
492        Self {
493            flexible_capacity_assets,
494            capacity_limits,
495            capacity_margin,
496            ..self
497        }
498    }
499
500    /// Include the specified candidate assets in the dispatch run
501    pub fn with_candidates(self, candidate_assets: &'run [AssetRef]) -> Self {
502        Self {
503            candidate_assets,
504            ..self
505        }
506    }
507
508    /// Only apply commodity balance constraints to the specified subset of markets
509    pub fn with_market_balance_subset(
510        self,
511        markets_to_balance: &'run [(CommodityID, RegionID)],
512    ) -> Self {
513        assert!(!markets_to_balance.is_empty());
514
515        Self {
516            markets_to_balance,
517            ..self
518        }
519    }
520
521    /// Explicitly provide prices for certain input commodities
522    pub fn with_input_prices(self, input_prices: &'run CommodityPrices) -> Self {
523        Self {
524            input_prices: Some(input_prices),
525            ..self
526        }
527    }
528
529    /// Perform the dispatch optimisation.
530    ///
531    /// # Arguments
532    ///
533    /// * `run_description` - Which dispatch run for the current year this is
534    /// * `writer` - For saving output data
535    ///
536    /// # Returns
537    ///
538    /// A solution containing new commodity flows for assets and prices for (some) commodities or an
539    /// error.
540    pub fn run(&self, run_description: &str, writer: &mut DataWriter) -> Result<Solution<'model>> {
541        // If the user provided no markets to balance, we use all of them
542        let all_markets: Vec<_>;
543        let markets_to_balance = if self.markets_to_balance.is_empty() {
544            all_markets = self.model.iter_markets().collect();
545            &all_markets
546        } else {
547            self.markets_to_balance
548        };
549
550        // Select prices for markets not being balanced
551        let input_prices_owned = self
552            .input_prices
553            .map(|prices| filter_input_prices(prices, markets_to_balance));
554        let input_prices = input_prices_owned.as_ref();
555
556        // Try running dispatch. If it fails because the model is infeasible, it is likely that this
557        // is due to unmet demand, in this case, we rerun dispatch including extra variables to
558        // track the unmet demand so we can report the offending markets to users
559        match self.run_without_unmet_demand_variables(markets_to_balance, input_prices) {
560            Ok(solution) => {
561                // Normal successful run: write debug info and return
562                writer.write_dispatch_debug_info(self.year, run_description, &solution)?;
563                Ok(solution)
564            }
565            Err(ModelError::NonOptimal(HighsModelStatus::Infeasible)) => {
566                // Re-run including unmet demand variables so we can record detailed unmet-demand
567                // debug output before returning an error to the caller.
568                let solution = self
569                    .run_internal(
570                        markets_to_balance,
571                        /*allow_unmet_demand=*/ true,
572                        input_prices,
573                    )
574                    .expect("Failed to run dispatch to calculate unmet demand");
575
576                // Write debug CSVs to help diagnosis
577                writer.write_dispatch_debug_info(self.year, run_description, &solution)?;
578
579                // Collect markets with unmet demand from the solution
580                let markets: IndexSet<_> = solution
581                    .iter_unmet_demand()
582                    .filter(|(_, _, _, flow)| *flow > Flow(0.0))
583                    .map(|(commodity_id, region_id, _, _)| {
584                        (commodity_id.clone(), region_id.clone())
585                    })
586                    .collect();
587
588                ensure!(
589                    !markets.is_empty(),
590                    "Model is infeasible, but there was no unmet demand"
591                );
592
593                bail!(
594                    "The solver has indicated that the problem is infeasible, probably because \
595                    the supplied assets could not meet the required demand. Demand was not met \
596                    for the following markets: {}",
597                    format_items_with_cap(markets)
598                )
599            }
600            Err(err) => Err(err)?,
601        }
602    }
603
604    /// Run dispatch without unmet demand variables
605    fn run_without_unmet_demand_variables(
606        &self,
607        markets_to_balance: &[(CommodityID, RegionID)],
608        input_prices: Option<&CommodityPrices>,
609    ) -> Result<Solution<'model>, ModelError> {
610        self.run_internal(
611            markets_to_balance,
612            /*allow_unmet_demand=*/ false,
613            input_prices,
614        )
615    }
616
617    /// Run dispatch to balance the specified markets, optionally including unmet demand variables
618    fn run_internal(
619        &self,
620        markets_to_balance: &[(CommodityID, RegionID)],
621        allow_unmet_demand: bool,
622        input_prices: Option<&CommodityPrices>,
623    ) -> Result<Solution<'model>, ModelError> {
624        let parent_assets = get_parent_or_self(self.existing_assets).collect_vec();
625
626        // Set up problem
627        let mut problem = Problem::default();
628        let mut variables = VariableMap::new_with_activity_vars(
629            &mut problem,
630            self.model,
631            input_prices,
632            &parent_assets,
633            self.candidate_assets,
634            self.year,
635        );
636
637        // If unmet demand is enabled for this dispatch run (and is allowed by the model param) then
638        // we add variables representing unmet demand for all markets being balanced
639        if allow_unmet_demand {
640            variables.add_unmet_demand_variables(&mut problem, self.model, markets_to_balance);
641        }
642
643        // Check flexible capacity assets is a subset of existing assets
644        for asset in self.flexible_capacity_assets {
645            assert!(
646                parent_assets.contains(asset),
647                "Flexible capacity assets must be a subset of existing assets. Offending asset: {asset:?}"
648            );
649        }
650
651        // Add capacity variables for flexible capacity assets
652        if !self.flexible_capacity_assets.is_empty() {
653            variables.capacity_var_idx = add_capacity_variables(
654                &mut problem,
655                &mut variables.capacity_vars,
656                self.flexible_capacity_assets,
657                self.capacity_limits,
658                self.capacity_margin,
659            );
660        }
661
662        // Add constraints
663        let all_assets = chain(parent_assets.iter(), self.candidate_assets.iter());
664        let constraint_keys = add_model_constraints(
665            &mut problem,
666            &variables,
667            self.model,
668            &all_assets,
669            markets_to_balance,
670            self.year,
671        );
672
673        // Solve model
674        let solution = solve_optimal(problem.optimise(Sense::Minimise))?;
675
676        let solution = Solution {
677            solution: solution.get_solution(),
678            variables,
679            time_slice_info: &self.model.time_slice_info,
680            constraint_keys,
681            flow_map: Cell::default(),
682            objective_value: Money(solution.objective_value()),
683        };
684        solution.flow_map.set(Some(create_flow_map(
685            self.existing_assets,
686            &self.model.time_slice_info,
687            solution.iter_activity(),
688        )));
689        Ok(solution)
690    }
691}
692
693/// Add variables to the optimisation problem.
694///
695/// # Arguments
696///
697/// * `problem` - The optimisation problem
698/// * `variables` - The map of asset variables
699/// * `time_slice_info` - Information about assets
700/// * `input_prices` - Optional explicit prices for input commodities
701/// * `assets` - Assets to include
702/// * `year` - Current milestone year
703fn add_activity_variables(
704    problem: &mut Problem,
705    variables: &mut ActivityVariableMap,
706    time_slice_info: &TimeSliceInfo,
707    input_prices: Option<&CommodityPrices>,
708    assets: &[AssetRef],
709    year: u32,
710) -> Range<usize> {
711    // This line **must** come before we add more variables
712    let start = problem.num_cols();
713
714    for (asset, time_slice) in iproduct!(assets.iter(), time_slice_info.iter_ids()) {
715        let coeff = calculate_activity_coefficient(asset, year, time_slice, input_prices);
716        let var = problem.add_column(coeff.value(), 0.0..);
717        let key = (asset.clone(), time_slice.clone());
718        let existing = variables.insert(key, var).is_some();
719        assert!(!existing, "Duplicate entry for var");
720    }
721
722    start..problem.num_cols()
723}
724
725fn add_capacity_variables(
726    problem: &mut Problem,
727    variables: &mut CapacityVariableMap,
728    assets: &[AssetRef],
729    capacity_limits: Option<&HashMap<AssetRef, AssetCapacity>>,
730    capacity_margin: f64,
731) -> Range<usize> {
732    // This line **must** come before we add more variables
733    let start = problem.num_cols();
734
735    for asset in assets {
736        // Can only have flexible capacity for `Selected` assets
737        assert!(
738            matches!(asset.state(), AssetState::Selected { .. }),
739            "Flexible capacity can only be assigned to `Selected` type assets. Offending asset: {asset:?}"
740        );
741
742        let current_capacity = asset.capacity();
743        let coeff = calculate_capacity_coefficient(asset);
744
745        // Retrieve capacity limit if provided
746        let capacity_limit = capacity_limits.and_then(|limits| limits.get(asset));
747
748        // Sanity check: make sure capacity_limit is compatible with current_capacity
749        if let Some(limit) = capacity_limit {
750            assert!(
751                matches!(
752                    (current_capacity, limit),
753                    (AssetCapacity::Continuous(_), AssetCapacity::Continuous(_))
754                        | (AssetCapacity::Discrete(_, _), AssetCapacity::Discrete(_, _))
755                ),
756                "Incompatible capacity types for asset capacity limit"
757            );
758        }
759
760        // Add a capacity variable for each asset
761        // Bounds are calculated based on current capacity with wiggle-room defined by
762        // `capacity_margin`, and limited by `capacity_limit` if provided.
763        let var = match current_capacity {
764            AssetCapacity::Continuous(cap) => {
765                // Continuous capacity: capacity variable represents total capacity
766                let lower = ((1.0 - capacity_margin) * cap.value()).max(0.0);
767                let mut upper = (1.0 + capacity_margin) * cap.value();
768                if let Some(limit) = capacity_limit {
769                    upper = upper.min(limit.total_capacity().value());
770                }
771                problem.add_column(coeff.value(), lower..=upper)
772            }
773            AssetCapacity::Discrete(units, unit_size) => {
774                // Discrete capacity: capacity variable represents number of units
775                let lower = ((1.0 - capacity_margin) * units as f64).max(0.0);
776                let mut upper = (1.0 + capacity_margin) * units as f64;
777                if let Some(limit) = capacity_limit {
778                    upper = upper.min(limit.n_units().unwrap() as f64);
779                }
780                problem.add_integer_column((coeff * unit_size).value(), lower..=upper)
781            }
782        };
783
784        let existing = variables.insert(asset.clone(), var).is_some();
785        assert!(!existing, "Duplicate entry for var");
786    }
787
788    start..problem.num_cols()
789}
790
791/// Calculate the cost coefficient for an activity variable.
792///
793/// Normally, the cost coefficient is the same as the asset's operating costs for the given year and
794/// time slice. If `input_prices` is provided then those prices are added to the flow costs for the
795/// relevant commodities, if they are input flows for the asset.
796///
797/// # Arguments
798///
799/// * `asset` - The asset to calculate the coefficient for
800/// * `year` - The current milestone year
801/// * `time_slice` - The time slice to which this coefficient applies
802/// * `input_prices` - Optional map of prices to include for input commodities
803///
804/// # Returns
805///
806/// The cost coefficient to be used for the relevant decision variable.
807fn calculate_activity_coefficient(
808    asset: &Asset,
809    year: u32,
810    time_slice: &TimeSliceID,
811    input_prices: Option<&CommodityPrices>,
812) -> MoneyPerActivity {
813    let opex = asset.get_operating_cost(year, time_slice);
814    if let Some(prices) = input_prices {
815        opex + asset.get_input_cost_from_prices(prices, time_slice)
816    } else {
817        opex
818    }
819}
820
821/// Calculate the cost coefficient for a capacity variable (for flexible capacity assets only).
822///
823/// This includes both the annual fixed operating cost and the annual capital cost.
824fn calculate_capacity_coefficient(asset: &AssetRef) -> MoneyPerCapacity {
825    let param = asset.process_parameter();
826    let annual_fixed_operating_cost = param.fixed_operating_cost * Year(1.0);
827    annual_fixed_operating_cost
828        + annual_capital_cost(param.capital_cost, param.lifetime, param.discount_rate)
829}