Skip to main content

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}