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