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::{Flow, 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 candidate_assets: &'a [AssetRef],
84) -> ConstraintKeys
85where
86 I: Iterator<Item = &'a AssetRef> + Clone + 'a,
87{
88 let commodity_balance_keys = add_commodity_balance_constraints(
89 problem,
90 variables,
91 model,
92 assets,
93 markets_to_balance,
94 year,
95 candidate_assets,
96 );
97
98 let activity_keys =
99 add_activity_constraints(problem, variables, &model.time_slice_info, assets.clone());
100
101 // Return constraint keys
102 ConstraintKeys {
103 commodity_balance_keys,
104 activity_keys,
105 }
106}
107
108/// Add asset-level input-output commodity balances.
109///
110/// These constraints fix the supply-demand balance for the whole system.
111///
112/// See description in [the dispatch optimisation documentation][1].
113///
114/// Returns a `CommodityBalanceKeys` where `offset` is the row index of the first
115/// commodity-balance constraint added to `problem` and `keys` lists the
116/// `(commodity, region, time_selection)` entries in the same order as the rows.
117///
118#[doc = concat!("[1]: ", crate::docs_url!("model/dispatch_optimisation.html#commodity-balance-for--cin-mathbfcmathrmsed-"))]
119fn add_commodity_balance_constraints<'a, I>(
120 problem: &mut Problem,
121 variables: &VariableMap,
122 model: &'a Model,
123 assets: &I,
124 markets_to_balance: &'a [(CommodityID, RegionID)],
125 year: u32,
126 candidate_assets: &'a [AssetRef],
127) -> CommodityBalanceKeys
128where
129 I: Iterator<Item = &'a AssetRef> + Clone + 'a,
130{
131 // Row offset in problem. This line **must** come before we add more constraints.
132 // It denotes the index in the solver's row ordering that corresponds to the first
133 // commodity-balance row added below and is used later to slice the duals array.
134 let offset = problem.num_rows();
135
136 let mut keys = Vec::new();
137 let mut terms = Vec::new();
138 for (commodity_id, region_id) in markets_to_balance {
139 let commodity = &model.commodities[commodity_id];
140 if !matches!(
141 commodity.kind,
142 CommodityType::SupplyEqualsDemand | CommodityType::ServiceDemand
143 ) {
144 continue;
145 }
146
147 for ts_selection in model
148 .time_slice_info
149 .iter_selections_at_level(commodity.time_slice_level)
150 {
151 for (asset, flow) in assets
152 .clone()
153 .filter_region(region_id)
154 .flows_for_commodity(commodity_id)
155 {
156 // If the commodity has a time slice level of season/annual, the constraint will
157 // cover multiple time slices
158 for (time_slice, _) in ts_selection.iter(&model.time_slice_info) {
159 let var = variables.get_activity_var(asset, time_slice);
160 terms.push((var, flow.coeff.value()));
161 }
162 }
163
164 // It is possible that a commodity may not be produced or consumed by anything in a
165 // given milestone year, in which case it doesn't make sense to add a commodity
166 // balance constraint
167 if terms.is_empty() {
168 continue;
169 }
170
171 // Also include unmet demand variables if required
172 if !variables.unmet_demand_var_idx.is_empty() {
173 for (time_slice, _) in ts_selection.iter(&model.time_slice_info) {
174 let var = variables.get_unmet_demand_var(commodity_id, region_id, time_slice);
175 terms.push((var, 1.0));
176 }
177 }
178
179 // Add a small epsilon to the lower bound to force some dispatch by candidate assets,
180 // ensuring they receive a nonzero shadow price.
181 let epsilon = candidate_balance_epsilon(
182 candidate_assets,
183 region_id,
184 commodity_id,
185 &ts_selection,
186 model.parameters.commodity_balance_epsilon,
187 );
188
189 // For SVD commodities, the lower bound is the exogenous demand (or epsilon if larger).
190 // For SED commodities, the lower bound is just epsilon.
191 let min = match commodity.kind {
192 CommodityType::ServiceDemand => {
193 commodity.demand[&(region_id.clone(), year, ts_selection.clone())].max(epsilon)
194 }
195 _ => epsilon,
196 };
197
198 // Consume collected terms into a row. `terms.drain(..)` ensures the vector is
199 // emptied for the next selection.
200 problem.add_row(min.value().., terms.drain(..));
201 keys.push((
202 commodity_id.clone(),
203 region_id.clone(),
204 ts_selection.clone(),
205 ));
206 }
207 }
208
209 CommodityBalanceKeys { offset, keys }
210}
211
212/// Calculate the epsilon to add to the lower bound of a commodity balance constraint, to force
213/// some dispatch by candidate assets so they receive a nonzero shadow price.
214///
215/// Returns `epsilon` if the total maximum output from candidate assets in `region_id` for
216/// `commodity_id` in `ts_selection` exceeds `epsilon`, otherwise returns zero (to avoid making
217/// the balance constraint infeasible).
218fn candidate_balance_epsilon(
219 candidate_assets: &[AssetRef],
220 region_id: &RegionID,
221 commodity_id: &CommodityID,
222 ts_selection: &TimeSliceSelection,
223 epsilon: Flow,
224) -> Flow {
225 let max_candidate_output: Flow = candidate_assets
226 .iter()
227 .filter_region(region_id)
228 .flat_map(|a| {
229 let max_activity = *a.get_activity_limits_for_selection(ts_selection).end();
230 a.iter_output_flows()
231 .filter(|flow| &flow.commodity.id == commodity_id)
232 .map(move |flow| flow.coeff * max_activity)
233 })
234 .sum();
235 if max_candidate_output > epsilon {
236 epsilon
237 } else {
238 Flow(0.0)
239 }
240}
241
242/// Add constraints on the activity of different assets.
243///
244/// This ensures that assets do not exceed their specified capacity and availability for each time
245/// slice.
246///
247/// See description in [the dispatch optimisation documentation][1].
248///
249/// Returns an `ActivityKeys` where `offset` is the row index of the first
250/// activity constraint added and `keys` enumerates the `(asset, time_selection)`
251/// entries in the same row order. Note that for flexible-capacity assets two rows
252/// (upper and lower bounds) are added per selection; in that case the same key is
253/// stored twice to match the solver ordering.
254///
255#[doc = concat!("[1]: ", crate::docs_url!("model/dispatch_optimisation.html#a4-constraints-capacity--availability-for-standard-assets--a-in-mathbfastd-"))]
256fn add_activity_constraints<'a, I>(
257 problem: &mut Problem,
258 variables: &VariableMap,
259 time_slice_info: &TimeSliceInfo,
260 assets: I,
261) -> ActivityKeys
262where
263 I: Iterator<Item = &'a AssetRef> + 'a,
264{
265 // Row offset in problem. This line **must** come before we add more constraints.
266 // It denotes the index into the solver's row ordering for the first activity constraint
267 // added below and is used when mapping duals back to assets/time selections.
268 let offset = problem.num_rows();
269
270 let mut keys = Vec::new();
271 let capacity_vars: IndexMap<&AssetRef, highs::Col> = variables.iter_capacity_vars().collect();
272
273 // Create constraints for each asset
274 for asset in assets {
275 if let Some(&capacity_var) = capacity_vars.get(asset) {
276 // Asset with flexible capacity
277 for (ts_selection, limits) in asset.iter_activity_per_capacity_limits() {
278 let mut upper_limit = limits.end().value();
279 let mut lower_limit = limits.start().value();
280
281 // If the asset capacity is discrete, the capacity variable represents number of
282 // units, so we need to multiply the per-capacity limits by the unit size.
283 if let AssetCapacity::Discrete(_, unit_size) = asset.capacity() {
284 upper_limit *= unit_size.value();
285 lower_limit *= unit_size.value();
286 }
287
288 // Collect capacity and activity terms
289 // We have a single capacity term, and activity terms for all time slices in the selection
290 let mut terms_upper = vec![(capacity_var, -upper_limit)];
291 let mut terms_lower = vec![(capacity_var, -lower_limit)];
292 for (time_slice, _) in ts_selection.iter(time_slice_info) {
293 let var = variables.get_activity_var(asset, time_slice);
294 terms_upper.push((var, 1.0));
295 terms_lower.push((var, 1.0));
296 }
297
298 // Upper bound: sum(activity) - (capacity * upper_limit_per_capacity) ≤ 0
299 problem.add_row(..=0.0, &terms_upper);
300
301 // Lower bound: sum(activity) - (capacity * lower_limit_per_capacity) ≥ 0
302 problem.add_row(0.0.., &terms_lower);
303
304 // Store keys for retrieving duals later.
305 // TODO: a bit of a hack pushing identical keys twice. Safe for now so long as we don't
306 // use the activity duals for anything important when using flexible capacity assets.
307 keys.push((asset.clone(), ts_selection.clone()));
308 keys.push((asset.clone(), ts_selection.clone()));
309 }
310 } else {
311 // Fixed-capacity asset: simple absolute activity limits.
312 for (ts_selection, limits) in asset.iter_activity_limits() {
313 let limits = limits.start().value()..=limits.end().value();
314
315 // Collect activity terms for the time slices in this selection
316 let terms = ts_selection
317 .iter(time_slice_info)
318 .map(|(time_slice, _)| (variables.get_activity_var(asset, time_slice), 1.0))
319 .collect::<Vec<_>>();
320
321 // Constraint: sum of activities in selection within limits
322 problem.add_row(limits, &terms);
323
324 // Store keys for retrieving duals later.
325 keys.push((asset.clone(), ts_selection.clone()));
326 }
327 }
328 }
329
330 ActivityKeys { offset, keys }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use crate::commodity::Commodity;
337 use crate::fixture::{asset, process, process_flows_map, svd_commodity};
338 use crate::process::Process;
339 use crate::process::{FlowType, ProcessFlow};
340 use crate::time_slice::TimeSliceSelection;
341 use crate::units::{FlowPerActivity, MoneyPerFlow};
342 use indexmap::indexmap;
343 use rstest::rstest;
344 use std::rc::Rc;
345
346 #[rstest]
347 // Max candidate output (2.0) < epsilon (10.0) → zero (guard prevents infeasibility)
348 #[case(10.0, 0.0)]
349 // Max candidate output (2.0) > epsilon (1.0) → epsilon returned
350 #[case(1.0, 1.0)]
351 fn candidate_balance_epsilon_works(
352 #[case] epsilon: f64,
353 #[case] expected: f64,
354 svd_commodity: Commodity,
355 mut process: Process,
356 ) {
357 let commodity_rc = Rc::new(svd_commodity);
358
359 // Add an output flow for the commodity to the process. With capacity 2.0, cap2act 1.0,
360 // and full availability over a single annual time slice, max_candidate_output = 2.0.
361 let flow = ProcessFlow {
362 commodity: Rc::clone(&commodity_rc),
363 coeff: FlowPerActivity(1.0),
364 kind: FlowType::Fixed,
365 cost: MoneyPerFlow(0.0),
366 };
367 process.flows = process_flows_map(
368 process.regions.clone(),
369 Rc::new(indexmap! { commodity_rc.id.clone() => flow }),
370 );
371
372 let result = candidate_balance_epsilon(
373 &[AssetRef::from(asset(process))],
374 &"GBR".into(),
375 &commodity_rc.id,
376 &TimeSliceSelection::Annual,
377 Flow(epsilon),
378 );
379 assert_eq!(result, Flow(expected));
380 }
381}