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}