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