Skip to main content

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