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::{chain, iproduct};
21use std::cell::Cell;
22use std::collections::HashMap;
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    /// Activity for each candidate asset
267    pub fn iter_activity_for_candidates(
268        &self,
269    ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, Activity)> {
270        let cols = &self.solution.columns()[self.variables.candidate_asset_var_idx.clone()];
271        self.variables
272            .activity_var_keys()
273            .skip(self.variables.candidate_asset_var_idx.start)
274            .zip(cols.iter())
275            .map(|((asset, time_slice), &value)| (asset, time_slice, Activity(value)))
276    }
277
278    /// Iterate over the keys for activity for each candidate asset
279    pub fn iter_activity_keys_for_candidates(
280        &self,
281    ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID)> {
282        self.iter_activity_for_candidates()
283            .map(|(asset, time_slice, _activity)| (asset, time_slice))
284    }
285
286    /// Iterate over unmet demand
287    pub fn iter_unmet_demand(
288        &self,
289    ) -> impl Iterator<Item = (&CommodityID, &RegionID, &TimeSliceID, Flow)> {
290        self.variables
291            .unmet_demand_vars
292            .keys()
293            .zip(self.solution.columns()[self.variables.unmet_demand_var_idx.clone()].iter())
294            .map(|((commodity_id, region_id, time_slice), flow)| {
295                (commodity_id, region_id, time_slice, Flow(*flow))
296            })
297    }
298
299    /// Iterate over capacity values
300    ///
301    /// Will return `AssetCapacity::Continuous` or `AssetCapacity::Discrete` depending on whether
302    /// the asset has a defined unit size.
303    pub fn iter_capacity(&self) -> impl Iterator<Item = (&AssetRef, AssetCapacity)> {
304        self.variables
305            .capacity_vars
306            .keys()
307            .zip(self.solution.columns()[self.variables.capacity_var_idx.clone()].iter())
308            .map(|(asset, capacity_var)| {
309                // If the asset has a defined unit size, the capacity variable represents number of
310                // units, otherwise it represents absolute capacity
311                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
312                let asset_capacity = if let Some(unit_size) = asset.unit_size() {
313                    AssetCapacity::Discrete(capacity_var.round() as u32, unit_size)
314                } else {
315                    AssetCapacity::Continuous(Capacity(*capacity_var))
316                };
317                (asset, asset_capacity)
318            })
319    }
320
321    /// Keys and dual values for commodity balance constraints.
322    pub fn iter_commodity_balance_duals(
323        &self,
324    ) -> impl Iterator<Item = (&CommodityID, &RegionID, &TimeSliceID, MoneyPerFlow)> {
325        // Each commodity balance constraint applies to a particular time slice
326        // selection (depending on time slice level). Where this covers multiple time slices,
327        // we return the same dual for each individual time slice.
328        self.constraint_keys
329            .commodity_balance_keys
330            .zip_duals(self.solution.dual_rows())
331            .flat_map(|((commodity_id, region_id, ts_selection), price)| {
332                ts_selection
333                    .iter(self.time_slice_info)
334                    .map(move |(ts, _)| (commodity_id, region_id, ts, price))
335            })
336    }
337
338    /// Keys and dual values for activity constraints.
339    ///
340    /// Note: if there are any flexible capacity assets, these will have two duals with identical
341    /// keys, and there will be no way to distinguish between them in the resulting iterator.
342    /// Recommended for now only to use this function when there are no flexible capacity assets.
343    ///
344    /// Also note: this excludes seasonal and annual constraints. Recommended for now not to use
345    /// this for models that include seasonal or annual availability constraints.
346    pub fn iter_activity_duals(
347        &self,
348    ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, MoneyPerActivity)> {
349        self.constraint_keys
350            .activity_keys
351            .zip_duals(self.solution.dual_rows())
352            .filter(|&((_asset, ts_selection), _dual)| {
353                matches!(ts_selection, TimeSliceSelection::Single(_))
354            })
355            .map(|((asset, ts_selection), dual)| {
356                // `unwrap` is safe here because we just matched Single(_)
357                let (time_slice, _) = ts_selection.iter(self.time_slice_info).next().unwrap();
358                (asset, time_slice, dual)
359            })
360    }
361
362    /// Keys and values for column duals.
363    pub fn iter_column_duals(
364        &self,
365    ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, MoneyPerActivity)> {
366        self.variables
367            .activity_var_keys()
368            .zip(self.solution.dual_columns())
369            .map(|((asset, time_slice), dual)| (asset, time_slice, MoneyPerActivity(*dual)))
370    }
371}
372
373/// Defines the possible errors that can occur when running the solver
374#[derive(Debug, Clone)]
375pub enum ModelError {
376    /// The model definition is incoherent.
377    ///
378    /// Users should not be able to trigger this error.
379    Incoherent(HighsStatus),
380    /// An optimal solution could not be found
381    NonOptimal(HighsModelStatus),
382}
383
384impl fmt::Display for ModelError {
385    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
386        match self {
387            ModelError::Incoherent(status) => write!(f, "Incoherent model: {status:?}"),
388            ModelError::NonOptimal(status) => {
389                write!(f, "Could not find optimal result: {status:?}")
390            }
391        }
392    }
393}
394
395impl Error for ModelError {}
396
397/// Try to solve the model, returning an error if the model is incoherent or result is non-optimal
398pub fn solve_optimal(model: highs::Model) -> Result<highs::SolvedModel, ModelError> {
399    let solved = model.try_solve().map_err(ModelError::Incoherent)?;
400
401    match solved.status() {
402        HighsModelStatus::Optimal => Ok(solved),
403        status => Err(ModelError::NonOptimal(status)),
404    }
405}
406
407/// Filter prices data to only include prices for markets not being balanced
408///
409/// Markets being balanced (i.e. with commodity balance constraints) will have prices calculated
410/// internally by the solver, so we need to remove them to prevent double-counting.
411fn filter_input_prices(
412    input_prices: &CommodityPrices,
413    markets_to_balance: &[(CommodityID, RegionID)],
414) -> CommodityPrices {
415    input_prices
416        .iter()
417        .filter(|(commodity_id, region_id, _, _)| {
418            !markets_to_balance
419                .iter()
420                .any(|(c, r)| c == *commodity_id && r == *region_id)
421        })
422        .collect()
423}
424
425/// Get the parent for each asset, if it has one, or itself.
426///
427/// Child assets are converted to their parents and non-divisible assets are returned as is. Each
428/// parent asset is returned only once.
429///
430/// If only a subset of a parent's children are present in `assets`, a new parent asset representing
431/// a portion of the total capacity will be created. This will have the same hash as the original
432/// parent.
433fn get_parent_or_self(assets: &[AssetRef]) -> Vec<AssetRef> {
434    let mut child_counts: IndexMap<&AssetRef, u32> = IndexMap::new();
435    let mut out = Vec::new();
436
437    for asset in assets {
438        if let Some(parent) = asset.parent() {
439            // For child assets, keep count of number of children per parent
440            *child_counts.entry(parent).or_default() += 1;
441        } else {
442            // Non-divisible assets can be returned as is
443            out.push(asset.clone());
444        }
445    }
446
447    for (parent, child_count) in child_counts {
448        // Convert to an object representing the appropriate portion of the parent's capacity
449        out.push(parent.make_partial_parent(child_count));
450    }
451
452    out
453}
454
455/// Provides the interface for running the dispatch optimisation.
456///
457/// The run will attempt to meet unmet demand: if the solver reports infeasibility
458/// the implementation will rerun including unmet-demand variables to identify offending
459/// markets and provide a clearer error message.
460///
461/// For a detailed description, please see the [dispatch optimisation formulation][1].
462///
463/// [1]: https://energysystemsmodellinglab.github.io/MUSE2/model/dispatch_optimisation.html
464pub struct DispatchRun<'model, 'run> {
465    model: &'model Model,
466    existing_assets: &'run [AssetRef],
467    flexible_capacity_assets: &'run [AssetRef],
468    capacity_limits: Option<&'run HashMap<AssetRef, AssetCapacity>>,
469    candidate_assets: &'run [AssetRef],
470    markets_to_balance: &'run [(CommodityID, RegionID)],
471    input_prices: Option<&'run CommodityPrices>,
472    year: u32,
473    capacity_margin: f64,
474}
475
476impl<'model, 'run> DispatchRun<'model, 'run> {
477    /// Create a new [`DispatchRun`] for the specified model and assets for a given year
478    pub fn new(model: &'model Model, assets: &'run [AssetRef], year: u32) -> Self {
479        Self {
480            model,
481            existing_assets: assets,
482            flexible_capacity_assets: &[],
483            capacity_limits: None,
484            candidate_assets: &[],
485            markets_to_balance: &[],
486            input_prices: None,
487            year,
488            capacity_margin: 0.0,
489        }
490    }
491
492    /// Include the specified flexible capacity assets in the dispatch run
493    pub fn with_flexible_capacity_assets(
494        self,
495        flexible_capacity_assets: &'run [AssetRef],
496        capacity_limits: Option<&'run HashMap<AssetRef, AssetCapacity>>,
497        capacity_margin: f64,
498    ) -> Self {
499        Self {
500            flexible_capacity_assets,
501            capacity_limits,
502            capacity_margin,
503            ..self
504        }
505    }
506
507    /// Include the specified candidate assets in the dispatch run
508    pub fn with_candidates(self, candidate_assets: &'run [AssetRef]) -> Self {
509        Self {
510            candidate_assets,
511            ..self
512        }
513    }
514
515    /// Only apply commodity balance constraints to the specified subset of markets
516    pub fn with_market_balance_subset(
517        self,
518        markets_to_balance: &'run [(CommodityID, RegionID)],
519    ) -> Self {
520        assert!(!markets_to_balance.is_empty());
521
522        Self {
523            markets_to_balance,
524            ..self
525        }
526    }
527
528    /// Explicitly provide prices for certain input commodities
529    pub fn with_input_prices(self, input_prices: &'run CommodityPrices) -> Self {
530        Self {
531            input_prices: Some(input_prices),
532            ..self
533        }
534    }
535
536    /// Perform the dispatch optimisation.
537    ///
538    /// # Arguments
539    ///
540    /// * `run_description` - Which dispatch run for the current year this is
541    /// * `writer` - For saving output data
542    ///
543    /// # Returns
544    ///
545    /// A solution containing new commodity flows for assets and prices for (some) commodities or an
546    /// error.
547    pub fn run(&self, run_description: &str, writer: &mut DataWriter) -> Result<Solution<'model>> {
548        // If the user provided no markets to balance, we use all of them
549        let all_markets: Vec<_>;
550        let markets_to_balance = if self.markets_to_balance.is_empty() {
551            all_markets = self.model.iter_markets().collect();
552            &all_markets
553        } else {
554            self.markets_to_balance
555        };
556
557        // Select prices for markets not being balanced
558        let input_prices_owned = self
559            .input_prices
560            .map(|prices| filter_input_prices(prices, markets_to_balance));
561        let input_prices = input_prices_owned.as_ref();
562
563        // Try running dispatch. If it fails because the model is infeasible, it is likely that this
564        // is due to unmet demand, in this case, we rerun dispatch including extra variables to
565        // track the unmet demand so we can report the offending markets to users
566        match self.run_without_unmet_demand_variables(markets_to_balance, input_prices) {
567            Ok(solution) => {
568                // Normal successful run: write debug info and return
569                writer.write_dispatch_debug_info(self.year, run_description, &solution)?;
570                Ok(solution)
571            }
572            Err(ModelError::NonOptimal(HighsModelStatus::Infeasible)) => {
573                // Re-run including unmet demand variables so we can record detailed unmet-demand
574                // debug output before returning an error to the caller.
575                let solution = self
576                    .run_internal(
577                        markets_to_balance,
578                        /*allow_unmet_demand=*/ true,
579                        input_prices,
580                    )
581                    .expect("Failed to run dispatch to calculate unmet demand");
582
583                // Write debug CSVs to help diagnosis
584                writer.write_dispatch_debug_info(self.year, run_description, &solution)?;
585
586                // Collect markets with unmet demand from the solution
587                let markets: IndexSet<_> = solution
588                    .iter_unmet_demand()
589                    .filter(|(_, _, _, flow)| *flow > Flow(0.0))
590                    .map(|(commodity_id, region_id, _, _)| {
591                        (commodity_id.clone(), region_id.clone())
592                    })
593                    .collect();
594
595                ensure!(
596                    !markets.is_empty(),
597                    "Model is infeasible, but there was no unmet demand"
598                );
599
600                bail!(
601                    "The solver has indicated that the problem is infeasible, probably because \
602                    the supplied assets could not meet the required demand. Demand was not met \
603                    for the following markets: {}",
604                    format_items_with_cap(markets)
605                )
606            }
607            Err(err) => Err(err)?,
608        }
609    }
610
611    /// Run dispatch without unmet demand variables
612    fn run_without_unmet_demand_variables(
613        &self,
614        markets_to_balance: &[(CommodityID, RegionID)],
615        input_prices: Option<&CommodityPrices>,
616    ) -> Result<Solution<'model>, ModelError> {
617        self.run_internal(
618            markets_to_balance,
619            /*allow_unmet_demand=*/ false,
620            input_prices,
621        )
622    }
623
624    /// Run dispatch to balance the specified markets, optionally including unmet demand variables
625    fn run_internal(
626        &self,
627        markets_to_balance: &[(CommodityID, RegionID)],
628        allow_unmet_demand: bool,
629        input_prices: Option<&CommodityPrices>,
630    ) -> Result<Solution<'model>, ModelError> {
631        let parent_assets = get_parent_or_self(self.existing_assets);
632
633        // Set up problem
634        let mut problem = Problem::default();
635        let mut variables = VariableMap::new_with_activity_vars(
636            &mut problem,
637            self.model,
638            input_prices,
639            &parent_assets,
640            self.candidate_assets,
641            self.year,
642        );
643
644        // If unmet demand is enabled for this dispatch run (and is allowed by the model param) then
645        // we add variables representing unmet demand for all markets being balanced
646        if allow_unmet_demand {
647            variables.add_unmet_demand_variables(&mut problem, self.model, markets_to_balance);
648        }
649
650        // Check flexible capacity assets is a subset of existing assets
651        for asset in self.flexible_capacity_assets {
652            assert!(
653                parent_assets.contains(asset),
654                "Flexible capacity assets must be a subset of existing assets. Offending asset: {asset:?}"
655            );
656        }
657
658        // Add capacity variables for flexible capacity assets
659        if !self.flexible_capacity_assets.is_empty() {
660            variables.capacity_var_idx = add_capacity_variables(
661                &mut problem,
662                &mut variables.capacity_vars,
663                self.flexible_capacity_assets,
664                self.capacity_limits,
665                self.capacity_margin,
666            );
667        }
668
669        // Add constraints
670        let all_assets = chain(parent_assets.iter(), self.candidate_assets.iter());
671        let constraint_keys = add_model_constraints(
672            &mut problem,
673            &variables,
674            self.model,
675            &all_assets,
676            markets_to_balance,
677            self.year,
678        );
679
680        // Solve model
681        let solution = solve_optimal(problem.optimise(Sense::Minimise))?;
682
683        let solution = Solution {
684            solution: solution.get_solution(),
685            variables,
686            time_slice_info: &self.model.time_slice_info,
687            constraint_keys,
688            flow_map: Cell::default(),
689            objective_value: Money(solution.objective_value()),
690        };
691        solution.flow_map.set(Some(create_flow_map(
692            self.existing_assets,
693            &self.model.time_slice_info,
694            solution.iter_activity(),
695        )));
696        Ok(solution)
697    }
698}
699
700/// Add variables to the optimisation problem.
701///
702/// # Arguments
703///
704/// * `problem` - The optimisation problem
705/// * `variables` - The map of asset variables
706/// * `time_slice_info` - Information about assets
707/// * `input_prices` - Optional explicit prices for input commodities
708/// * `assets` - Assets to include
709/// * `year` - Current milestone year
710fn add_activity_variables(
711    problem: &mut Problem,
712    variables: &mut ActivityVariableMap,
713    time_slice_info: &TimeSliceInfo,
714    input_prices: Option<&CommodityPrices>,
715    assets: &[AssetRef],
716    year: u32,
717) -> Range<usize> {
718    // This line **must** come before we add more variables
719    let start = problem.num_cols();
720
721    for (asset, time_slice) in iproduct!(assets.iter(), time_slice_info.iter_ids()) {
722        let coeff = calculate_activity_coefficient(asset, year, time_slice, input_prices);
723        let var = problem.add_column(coeff.value(), 0.0..);
724        let key = (asset.clone(), time_slice.clone());
725        let existing = variables.insert(key, var).is_some();
726        assert!(!existing, "Duplicate entry for var");
727    }
728
729    start..problem.num_cols()
730}
731
732fn add_capacity_variables(
733    problem: &mut Problem,
734    variables: &mut CapacityVariableMap,
735    assets: &[AssetRef],
736    capacity_limits: Option<&HashMap<AssetRef, AssetCapacity>>,
737    capacity_margin: f64,
738) -> Range<usize> {
739    // This line **must** come before we add more variables
740    let start = problem.num_cols();
741
742    for asset in assets {
743        // Can only have flexible capacity for `Selected` assets
744        assert!(
745            matches!(asset.state(), AssetState::Selected { .. }),
746            "Flexible capacity can only be assigned to `Selected` type assets. Offending asset: {asset:?}"
747        );
748
749        let current_capacity = asset.capacity();
750        let coeff = calculate_capacity_coefficient(asset);
751
752        // Retrieve capacity limit if provided
753        let capacity_limit = capacity_limits.and_then(|limits| limits.get(asset));
754
755        // Sanity check: make sure capacity_limit is compatible with current_capacity
756        if let Some(limit) = capacity_limit {
757            assert!(
758                matches!(
759                    (current_capacity, limit),
760                    (AssetCapacity::Continuous(_), AssetCapacity::Continuous(_))
761                        | (AssetCapacity::Discrete(_, _), AssetCapacity::Discrete(_, _))
762                ),
763                "Incompatible capacity types for asset capacity limit"
764            );
765        }
766
767        // Add a capacity variable for each asset
768        // Bounds are calculated based on current capacity with wiggle-room defined by
769        // `capacity_margin`, and limited by `capacity_limit` if provided.
770        let var = match current_capacity {
771            AssetCapacity::Continuous(cap) => {
772                // Continuous capacity: capacity variable represents total capacity
773                let lower = ((1.0 - capacity_margin) * cap.value()).max(0.0);
774                let mut upper = (1.0 + capacity_margin) * cap.value();
775                if let Some(limit) = capacity_limit {
776                    upper = upper.min(limit.total_capacity().value());
777                }
778                problem.add_column(coeff.value(), lower..=upper)
779            }
780            AssetCapacity::Discrete(units, unit_size) => {
781                // Discrete capacity: capacity variable represents number of units
782                let lower = ((1.0 - capacity_margin) * units as f64).max(0.0);
783                let mut upper = (1.0 + capacity_margin) * units as f64;
784                if let Some(limit) = capacity_limit {
785                    upper = upper.min(limit.n_units().unwrap() as f64);
786                }
787                problem.add_integer_column((coeff * unit_size).value(), lower..=upper)
788            }
789        };
790
791        let existing = variables.insert(asset.clone(), var).is_some();
792        assert!(!existing, "Duplicate entry for var");
793    }
794
795    start..problem.num_cols()
796}
797
798/// Calculate the cost coefficient for an activity variable.
799///
800/// Normally, the cost coefficient is the same as the asset's operating costs for the given year and
801/// time slice. If `input_prices` is provided then those prices are added to the flow costs for the
802/// relevant commodities, if they are input flows for the asset.
803///
804/// # Arguments
805///
806/// * `asset` - The asset to calculate the coefficient for
807/// * `year` - The current milestone year
808/// * `time_slice` - The time slice to which this coefficient applies
809/// * `input_prices` - Optional map of prices to include for input commodities
810///
811/// # Returns
812///
813/// The cost coefficient to be used for the relevant decision variable.
814fn calculate_activity_coefficient(
815    asset: &Asset,
816    year: u32,
817    time_slice: &TimeSliceID,
818    input_prices: Option<&CommodityPrices>,
819) -> MoneyPerActivity {
820    let opex = asset.get_operating_cost(year, time_slice);
821    if let Some(prices) = input_prices {
822        opex + asset.get_input_cost_from_prices(prices, time_slice)
823    } else {
824        opex
825    }
826}
827
828/// Calculate the cost coefficient for a capacity variable (for flexible capacity assets only).
829///
830/// This includes both the annual fixed operating cost and the annual capital cost.
831fn calculate_capacity_coefficient(asset: &AssetRef) -> MoneyPerCapacity {
832    let param = asset.process_parameter();
833    let annual_fixed_operating_cost = param.fixed_operating_cost * Year(1.0);
834    annual_fixed_operating_cost
835        + annual_capital_cost(param.capital_cost, param.lifetime, param.discount_rate)
836}