muse2/simulation/optimisation/
constraints.rs

1//! Code for adding constraints to the dispatch optimisation problem.
2use super::VariableMap;
3use crate::asset::{AssetCapacity, AssetIterator, AssetRef};
4use crate::commodity::{CommodityID, CommodityType};
5use crate::model::Model;
6use crate::region::RegionID;
7use crate::time_slice::{TimeSliceInfo, TimeSliceSelection};
8use crate::units::UnitType;
9use highs::RowProblem as Problem;
10use indexmap::IndexMap;
11
12/// Corresponding variables for a constraint along with the row offset in the solution
13pub struct KeysWithOffset<T> {
14    /// Row offset in the solver's row ordering corresponding to the first key in `keys`.
15    ///
16    /// This offset is used to index into the solver duals vector when mapping dual
17    /// values back to the stored `keys`.
18    offset: usize,
19    /// Keys for each constraint row. The number of keys equals the number of rows
20    /// covered starting at `offset`.
21    keys: Vec<T>,
22}
23
24impl<T> KeysWithOffset<T> {
25    /// Zip the keys with the corresponding dual values in the solution, accounting for the offset.
26    ///
27    /// The returned iterator yields pairs of `(key, dual)` where `dual` is wrapped in the
28    /// unit type `U: UnitType`. The method asserts that the provided `duals` slice contains
29    /// at least `offset + keys.len()` elements.
30    pub fn zip_duals<'a, U>(&'a self, duals: &'a [f64]) -> impl Iterator<Item = (&'a T, U)>
31    where
32        U: UnitType,
33    {
34        assert!(
35            self.offset + self.keys.len() <= duals.len(),
36            "Bad constraint keys: dual rows out of range"
37        );
38
39        self.keys
40            .iter()
41            .zip(duals[self.offset..].iter().copied().map(U::new))
42    }
43}
44
45/// Indicates the commodity ID and time slice selection covered by each commodity balance constraint
46pub type CommodityBalanceKeys = KeysWithOffset<(CommodityID, RegionID, TimeSliceSelection)>;
47
48/// Indicates the asset ID and time slice covered by each activity constraint
49pub type ActivityKeys = KeysWithOffset<(AssetRef, TimeSliceSelection)>;
50
51/// The keys for different constraints
52pub struct ConstraintKeys {
53    /// Keys for commodity balance constraints
54    pub commodity_balance_keys: CommodityBalanceKeys,
55    /// Keys for activity constraints
56    pub activity_keys: ActivityKeys,
57}
58
59/// Add constraints for the dispatch model.
60///
61/// Note: the ordering of constraints is important, as the dual values of the constraints must later
62/// be retrieved to calculate commodity prices.
63///
64/// # Arguments
65///
66/// * `problem` - The optimisation problem
67/// * `variables` - The variables in the problem
68/// * `model` - The model
69/// * `assets` - The asset pool
70/// * `markets_to_balance` - The subset of markets to apply balance constraints to
71/// * `year` - Current milestone year
72///
73/// # Returns
74///
75/// Keys for the different constraints.
76pub fn add_model_constraints<'a, I>(
77    problem: &mut Problem,
78    variables: &VariableMap,
79    model: &'a Model,
80    assets: &I,
81    markets_to_balance: &'a [(CommodityID, RegionID)],
82    year: u32,
83) -> ConstraintKeys
84where
85    I: Iterator<Item = &'a AssetRef> + Clone + 'a,
86{
87    let commodity_balance_keys = add_commodity_balance_constraints(
88        problem,
89        variables,
90        model,
91        assets,
92        markets_to_balance,
93        year,
94    );
95
96    let activity_keys =
97        add_activity_constraints(problem, variables, &model.time_slice_info, assets.clone());
98
99    // Return constraint keys
100    ConstraintKeys {
101        commodity_balance_keys,
102        activity_keys,
103    }
104}
105
106/// Add asset-level input-output commodity balances.
107///
108/// These constraints fix the supply-demand balance for the whole system.
109///
110/// See description in [the dispatch optimisation documentation][1].
111///
112/// Returns a `CommodityBalanceKeys` where `offset` is the row index of the first
113/// commodity-balance constraint added to `problem` and `keys` lists the
114/// `(commodity, region, time_selection)` entries in the same order as the rows.
115///
116/// [1]: https://energysystemsmodellinglab.github.io/MUSE2/model/dispatch_optimisation.html#commodity-balance-for--cin-mathbfcmathrmsed-
117fn add_commodity_balance_constraints<'a, I>(
118    problem: &mut Problem,
119    variables: &VariableMap,
120    model: &'a Model,
121    assets: &I,
122    markets_to_balance: &'a [(CommodityID, RegionID)],
123    year: u32,
124) -> CommodityBalanceKeys
125where
126    I: Iterator<Item = &'a AssetRef> + Clone + 'a,
127{
128    // Row offset in problem. This line **must** come before we add more constraints.
129    // It denotes the index in the solver's row ordering that corresponds to the first
130    // commodity-balance row added below and is used later to slice the duals array.
131    let offset = problem.num_rows();
132
133    let mut keys = Vec::new();
134    let mut terms = Vec::new();
135    for (commodity_id, region_id) in markets_to_balance {
136        let commodity = &model.commodities[commodity_id];
137        if !matches!(
138            commodity.kind,
139            CommodityType::SupplyEqualsDemand | CommodityType::ServiceDemand
140        ) {
141            continue;
142        }
143
144        for ts_selection in model
145            .time_slice_info
146            .iter_selections_at_level(commodity.time_slice_level)
147        {
148            for (asset, flow) in assets
149                .clone()
150                .filter_region(region_id)
151                .flows_for_commodity(commodity_id)
152            {
153                // If the commodity has a time slice level of season/annual, the constraint will
154                // cover multiple time slices
155                for (time_slice, _) in ts_selection.iter(&model.time_slice_info) {
156                    let var = variables.get_activity_var(asset, time_slice);
157                    terms.push((var, flow.coeff.value()));
158                }
159            }
160
161            // It is possible that a commodity may not be produced or consumed by anything in a
162            // given milestone year, in which case it doesn't make sense to add a commodity
163            // balance constraint
164            if terms.is_empty() {
165                continue;
166            }
167
168            // Also include unmet demand variables if required
169            if !variables.unmet_demand_var_idx.is_empty() {
170                for (time_slice, _) in ts_selection.iter(&model.time_slice_info) {
171                    let var = variables.get_unmet_demand_var(commodity_id, region_id, time_slice);
172                    terms.push((var, 1.0));
173                }
174            }
175
176            // For SED commodities, the LHS must be >=0 and for SVD commodities, it must be >=
177            // the exogenous demand supplied by the user
178            let min = if commodity.kind == CommodityType::ServiceDemand {
179                commodity.demand[&(region_id.clone(), year, ts_selection.clone())].value()
180            } else {
181                0.0
182            };
183            // Consume collected terms into a row. `terms.drain(..)` ensures the vector is
184            // emptied for the next selection.
185            problem.add_row(min.., terms.drain(..));
186            keys.push((
187                commodity_id.clone(),
188                region_id.clone(),
189                ts_selection.clone(),
190            ));
191        }
192    }
193
194    CommodityBalanceKeys { offset, keys }
195}
196
197/// Add constraints on the activity of different assets.
198///
199/// This ensures that assets do not exceed their specified capacity and availability for each time
200/// slice.
201///
202/// See description in [the dispatch optimisation documentation][1].
203///
204/// Returns an `ActivityKeys` where `offset` is the row index of the first
205/// activity constraint added and `keys` enumerates the `(asset, time_selection)`
206/// entries in the same row order. Note that for flexible-capacity assets two rows
207/// (upper and lower bounds) are added per selection; in that case the same key is
208/// stored twice to match the solver ordering.
209///
210/// [1]: https://energysystemsmodellinglab.github.io/MUSE2/model/dispatch_optimisation.html#a4-constraints-capacity--availability-for-standard-assets--a-in-mathbfastd-
211fn add_activity_constraints<'a, I>(
212    problem: &mut Problem,
213    variables: &VariableMap,
214    time_slice_info: &TimeSliceInfo,
215    assets: I,
216) -> ActivityKeys
217where
218    I: Iterator<Item = &'a AssetRef> + 'a,
219{
220    // Row offset in problem. This line **must** come before we add more constraints.
221    // It denotes the index into the solver's row ordering for the first activity constraint
222    // added below and is used when mapping duals back to assets/time selections.
223    let offset = problem.num_rows();
224
225    let mut keys = Vec::new();
226    let capacity_vars: IndexMap<&AssetRef, highs::Col> = variables.iter_capacity_vars().collect();
227
228    // Create constraints for each asset
229    for asset in assets {
230        if let Some(&capacity_var) = capacity_vars.get(asset) {
231            // Asset with flexible capacity
232            for (ts_selection, limits) in asset.iter_activity_per_capacity_limits() {
233                let mut upper_limit = limits.end().value();
234                let mut lower_limit = limits.start().value();
235
236                // If the asset capacity is discrete, the capacity variable represents number of
237                // units, so we need to multiply the per-capacity limits by the unit size.
238                if let AssetCapacity::Discrete(_, unit_size) = asset.capacity() {
239                    upper_limit *= unit_size.value();
240                    lower_limit *= unit_size.value();
241                }
242
243                // Collect capacity and activity terms
244                // We have a single capacity term, and activity terms for all time slices in the selection
245                let mut terms_upper = vec![(capacity_var, -upper_limit)];
246                let mut terms_lower = vec![(capacity_var, -lower_limit)];
247                for (time_slice, _) in ts_selection.iter(time_slice_info) {
248                    let var = variables.get_activity_var(asset, time_slice);
249                    terms_upper.push((var, 1.0));
250                    terms_lower.push((var, 1.0));
251                }
252
253                // Upper bound: sum(activity) - (capacity * upper_limit_per_capacity) ≤ 0
254                problem.add_row(..=0.0, &terms_upper);
255
256                // Lower bound: sum(activity) - (capacity * lower_limit_per_capacity) ≥ 0
257                problem.add_row(0.0.., &terms_lower);
258
259                // Store keys for retrieving duals later.
260                // TODO: a bit of a hack pushing identical keys twice. Safe for now so long as we don't
261                // use the activity duals for anything important when using flexible capacity assets.
262                keys.push((asset.clone(), ts_selection.clone()));
263                keys.push((asset.clone(), ts_selection.clone()));
264            }
265        } else {
266            // Fixed-capacity asset: simple absolute activity limits.
267            for (ts_selection, limits) in asset.iter_activity_limits() {
268                let limits = limits.start().value()..=limits.end().value();
269
270                // Collect activity terms for the time slices in this selection
271                let terms = ts_selection
272                    .iter(time_slice_info)
273                    .map(|(time_slice, _)| (variables.get_activity_var(asset, time_slice), 1.0))
274                    .collect::<Vec<_>>();
275
276                // Constraint: sum of activities in selection within limits
277                problem.add_row(limits, &terms);
278
279                // Store keys for retrieving duals later.
280                keys.push((asset.clone(), ts_selection.clone()));
281            }
282        }
283    }
284
285    ActivityKeys { offset, keys }
286}