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::{AssetIterator, AssetRef};
4use crate::commodity::{BalanceType, CommodityID, CommodityType};
5use crate::model::Model;
6use crate::process::FlowDirection;
7use crate::region::RegionID;
8use crate::time_slice::{Season, TimeSliceInfo, TimeSliceSelection};
9use crate::units::{Flow, MoneyPerCapacityPerYear, UnitType, Year};
10use highs::RowProblem as Problem;
11use indexmap::IndexMap;
12use std::collections::{HashMap, HashSet};
13
14/// Corresponding variables for a constraint along with the row offset in the solution
15pub struct KeysWithOffset<T> {
16    /// Row offset in the solver's row ordering corresponding to the first key in `keys`.
17    ///
18    /// This offset is used to index into the solver duals vector when mapping dual
19    /// values back to the stored `keys`.
20    offset: usize,
21    /// Keys for each constraint row. The number of keys equals the number of rows
22    /// covered starting at `offset`.
23    keys: Vec<T>,
24}
25
26impl<T> KeysWithOffset<T> {
27    /// Zip the keys with the corresponding dual values in the solution, accounting for the offset.
28    ///
29    /// The returned iterator yields pairs of `(key, dual)` where `dual` is wrapped in the
30    /// unit type `U: UnitType`. The method asserts that the provided `duals` slice contains
31    /// at least `offset + keys.len()` elements.
32    pub fn zip_duals<'a, U>(&'a self, duals: &'a [f64]) -> impl Iterator<Item = (&'a T, U)>
33    where
34        U: UnitType,
35    {
36        assert!(
37            self.offset + self.keys.len() <= duals.len(),
38            "Bad constraint keys: dual rows out of range"
39        );
40
41        self.keys
42            .iter()
43            .zip(duals[self.offset..].iter().copied().map(U::new))
44    }
45}
46
47/// Indicates the commodity ID and time slice selection covered by each commodity balance constraint
48pub type CommodityBalanceKeys = KeysWithOffset<(CommodityID, RegionID, TimeSliceSelection)>;
49
50/// Indicates the asset ID and time slice covered by each activity constraint
51pub type ActivityKeys = KeysWithOffset<(AssetRef, TimeSliceSelection)>;
52
53/// Map containing the seasonal peak variables for each (asset, season) pair
54type SeasonalPeakVariableMap = IndexMap<(AssetRef, Season), highs::Col>;
55
56/// Map containing the annual peak variables for each asset
57type AnnualPeakVariableMap = IndexMap<AssetRef, highs::Col>;
58
59/// The keys for different constraints
60pub struct ConstraintKeys {
61    /// Keys for commodity balance constraints
62    pub commodity_balance_keys: CommodityBalanceKeys,
63    /// Keys for activity constraints
64    pub activity_keys: ActivityKeys,
65}
66
67/// Add constraints for the dispatch model.
68///
69/// Note: the ordering of constraints is important, as the dual values of the constraints must later
70/// be retrieved to calculate commodity prices.
71///
72/// # Arguments
73///
74/// * `problem` - The optimisation problem
75/// * `variables` - The variables in the problem
76/// * `model` - The model
77/// * `assets` - The asset pool
78/// * `markets_to_balance` - The subset of markets to apply balance constraints to
79/// * `year` - Current milestone year
80///
81/// # Returns
82///
83/// Keys for the different constraints.
84#[allow(clippy::too_many_arguments)]
85pub fn add_model_constraints<'a, I>(
86    problem: &mut Problem,
87    variables: &VariableMap,
88    model: &'a Model,
89    assets: &I,
90    markets_to_balance: &'a [(CommodityID, RegionID)],
91    year: u32,
92    candidate_assets: &'a [AssetRef],
93    include_commodity_constraints: bool,
94) -> ConstraintKeys
95where
96    I: Iterator<Item = &'a AssetRef> + Clone + 'a,
97{
98    let commodity_balance_keys = add_commodity_balance_constraints(
99        problem,
100        variables,
101        model,
102        assets,
103        markets_to_balance,
104        year,
105        candidate_assets,
106    );
107
108    if include_commodity_constraints {
109        add_commodity_constraints(problem, variables, model, assets, year);
110    }
111
112    let activity_keys =
113        add_activity_constraints(problem, variables, &model.time_slice_info, assets.clone());
114
115    add_utilisation_peak_constraints(problem, model, assets.clone(), variables);
116
117    add_equal_utilisation_constraints(problem, variables, &model.time_slice_info, assets.clone());
118
119    // Return constraint keys
120    ConstraintKeys {
121        commodity_balance_keys,
122        activity_keys,
123    }
124}
125
126/// Add explicit production and consumption constraints for commodities.
127fn add_commodity_constraints<'a, I>(
128    problem: &mut Problem,
129    variables: &VariableMap,
130    model: &'a Model,
131    assets: &I,
132    year: u32,
133) where
134    I: Iterator<Item = &'a AssetRef> + Clone + 'a,
135{
136    // Commodity constraints are indexed by milestone year, so commodities without a constraint
137    // for this year do not contribute any rows.
138    for commodity in model.commodities.values() {
139        let Some(constraints) = commodity.constraints.get(&year) else {
140            continue;
141        };
142
143        // For each constraint, collect the relevant flows, and add the constraint as a row in the
144        // problem.
145        for constraint in constraints {
146            // Select the direction of flows to collect. Production constraints constrain the sum
147            // of output flows, while consumption constraints constrain the sum of input flows.
148            let flow_direction = match constraint.balance_type {
149                BalanceType::Production => FlowDirection::Output,
150                BalanceType::Consumption => FlowDirection::Input,
151                BalanceType::Net => unreachable!("Net commodity constraints are invalid"),
152            };
153
154            // Collect all relevant flows for this constraint. Each constraint covers a region,
155            // commodity and time slice selection, so we need to collect all input or output flows
156            // for the commodity for assets in the region, and for every time slice in the selection.
157            let terms = assets
158                .clone()
159                .filter_region(&constraint.region_id)
160                .flows_for_commodity(&commodity.id)
161                .filter(|(_, flow)| flow.direction() == flow_direction)
162                .flat_map(|(asset, flow)| {
163                    // Use the absolute value of the flow coefficient, as input flows are stored as
164                    // negative values, but constraints are always expressed in terms of positive
165                    // magnitudes.
166                    let coefficient = flow.coeff.abs().value();
167                    constraint.ts_selection.iter(&model.time_slice_info).map(
168                        move |(time_slice, _)| {
169                            (variables.get_activity_var(asset, time_slice), coefficient)
170                        },
171                    )
172                })
173                .collect::<Vec<_>>();
174
175            // Apply the configured inclusive lower and upper limits to the sum of the terms.
176            problem.add_row(
177                constraint.limits.start().value()..=constraint.limits.end().value(),
178                terms,
179            );
180        }
181    }
182}
183
184/// Add seasonal and annual utilisation peak constraints to the problem.
185fn add_utilisation_peak_constraints<'a, I>(
186    problem: &mut Problem,
187    model: &Model,
188    assets: I,
189    variables: &VariableMap,
190) where
191    I: Iterator<Item = &'a AssetRef> + Clone,
192{
193    let has_seasonal_penalty =
194        model.parameters.seasonal_utilisation_penalty > MoneyPerCapacityPerYear(0.0);
195    let has_annual_penalty =
196        model.parameters.annual_utilisation_penalty > MoneyPerCapacityPerYear(0.0);
197
198    // If neither penalties are applied, we don't need to add any variables and constraints
199    if !has_seasonal_penalty && !has_annual_penalty {
200        return;
201    }
202
203    // So long as either penalty is applied, we need to add seasonal peak variables and constraints
204    let seasonal_peak_vars = add_seasonal_peak_variables(problem, model, assets.clone());
205    add_seasonal_peak_constraints(
206        problem,
207        variables,
208        &model.time_slice_info,
209        &seasonal_peak_vars,
210    );
211
212    // If the annual penalty is applied, we also need to add annual peak variables and constraints
213    if has_annual_penalty {
214        let annual_peak_vars = add_annual_peak_variables(problem, model, assets);
215        add_annual_peak_constraints(
216            problem,
217            &model.time_slice_info,
218            &annual_peak_vars,
219            &seasonal_peak_vars,
220        );
221    }
222}
223
224/// Add seasonal peak variables to the problem for each (asset, season) pair.
225fn add_seasonal_peak_variables<'a, I>(
226    problem: &mut Problem,
227    model: &Model,
228    assets: I,
229) -> SeasonalPeakVariableMap
230where
231    I: Iterator<Item = &'a AssetRef>,
232{
233    let mut seasonal_peak_vars = SeasonalPeakVariableMap::new();
234    for asset in assets {
235        for (season, duration) in &model.time_slice_info.seasons {
236            // Scale penalty by season duration
237            let col_factor = model.parameters.seasonal_utilisation_penalty * *duration;
238            let variable = problem.add_column(col_factor.value(), 0.0..);
239            seasonal_peak_vars.insert((asset.clone(), season.clone()), variable);
240        }
241    }
242    seasonal_peak_vars
243}
244
245/// Add annual peak variables to the problem for each asset.
246fn add_annual_peak_variables<'a, I>(
247    problem: &mut Problem,
248    model: &Model,
249    assets: I,
250) -> AnnualPeakVariableMap
251where
252    I: Iterator<Item = &'a AssetRef>,
253{
254    // Penalty is applied over the whole year, so scale by 1 year
255    let col_factor = model.parameters.annual_utilisation_penalty * Year(1.0);
256    assets
257        .map(|asset| {
258            let variable = problem.add_column(col_factor.value(), 0.0..);
259            (asset.clone(), variable)
260        })
261        .collect()
262}
263
264/// Add constraints linking seasonal peak variables to activity variables for each (asset, season) pair.
265fn add_seasonal_peak_constraints(
266    problem: &mut Problem,
267    variables: &VariableMap,
268    time_slice_info: &TimeSliceInfo,
269    seasonal_peak_vars: &SeasonalPeakVariableMap,
270) {
271    for ((asset, season), &peak_variable) in seasonal_peak_vars {
272        let activity_per_capacity = asset.process().capacity_to_activity;
273        let season_selection = TimeSliceSelection::Season(season.clone());
274        for (time_slice, ts_length) in season_selection.iter(time_slice_info) {
275            let time_slice_fraction = ts_length / Year(1.0);
276            let activity_per_capacity_in_time_slice = activity_per_capacity * time_slice_fraction;
277            let capacity_required_per_activity = 1.0 / activity_per_capacity_in_time_slice.value();
278
279            // One unit of capacity supports `activity_per_capacity_in_time_slice` activity in
280            // this time slice. The peak variable therefore measures the capacity required by
281            // the activity in the time slice.
282            problem.add_row(
283                0.0..,
284                [
285                    (peak_variable, 1.0),
286                    (
287                        variables.get_activity_var(asset, time_slice),
288                        -capacity_required_per_activity,
289                    ),
290                ],
291            );
292        }
293    }
294}
295
296/// Add constraints linking seasonal peak variables to annual peak variables for each asset.
297fn add_annual_peak_constraints(
298    problem: &mut Problem,
299    time_slice_info: &TimeSliceInfo,
300    annual_peak_vars: &AnnualPeakVariableMap,
301    seasonal_peak_vars: &SeasonalPeakVariableMap,
302) {
303    for (asset, &annual_peak_variable) in annual_peak_vars {
304        for season in time_slice_info.seasons.keys() {
305            let seasonal_peak_variable = seasonal_peak_vars
306                .get(&(asset.clone(), season.clone()))
307                .expect("Missing seasonal peak variable for annual peak constraint");
308            problem.add_row(
309                0.0..,
310                [(annual_peak_variable, 1.0), (*seasonal_peak_variable, -1.0)],
311            );
312        }
313    }
314}
315
316/// Add asset-level input-output commodity balances.
317///
318/// These constraints fix the supply-demand balance for the whole system.
319///
320/// See description in [the dispatch optimisation documentation][1].
321///
322/// Returns a `CommodityBalanceKeys` where `offset` is the row index of the first
323/// commodity-balance constraint added to `problem` and `keys` lists the
324/// `(commodity, region, time_selection)` entries in the same order as the rows.
325///
326#[doc = concat!("[1]: ", crate::docs_url!("model/dispatch_optimisation.html#commodity-balance-constraints"))]
327fn add_commodity_balance_constraints<'a, I>(
328    problem: &mut Problem,
329    variables: &VariableMap,
330    model: &'a Model,
331    assets: &I,
332    markets_to_balance: &'a [(CommodityID, RegionID)],
333    year: u32,
334    candidate_assets: &'a [AssetRef],
335) -> CommodityBalanceKeys
336where
337    I: Iterator<Item = &'a AssetRef> + Clone + 'a,
338{
339    // Row offset in problem. This line **must** come before we add more constraints.
340    // It denotes the index in the solver's row ordering that corresponds to the first
341    // commodity-balance row added below and is used later to slice the duals array.
342    let offset = problem.num_rows();
343
344    let mut keys = Vec::new();
345    let mut terms = Vec::new();
346    for (commodity_id, region_id) in markets_to_balance {
347        let commodity = &model.commodities[commodity_id];
348        if !matches!(
349            commodity.kind,
350            CommodityType::SupplyEqualsDemand | CommodityType::ServiceDemand
351        ) {
352            continue;
353        }
354
355        for ts_selection in model
356            .time_slice_info
357            .iter_selections_at_level(commodity.time_slice_level)
358        {
359            for (asset, flow) in assets
360                .clone()
361                .filter_region(region_id)
362                .flows_for_commodity(commodity_id)
363            {
364                // If the commodity has a time slice level of season/annual, the constraint will
365                // cover multiple time slices
366                for (time_slice, _) in ts_selection.iter(&model.time_slice_info) {
367                    let var = variables.get_activity_var(asset, time_slice);
368                    terms.push((var, flow.coeff.value()));
369                }
370            }
371
372            // It is possible that a commodity may not be produced or consumed by anything in a
373            // given milestone year, in which case it doesn't make sense to add a commodity
374            // balance constraint
375            if terms.is_empty() {
376                continue;
377            }
378
379            // Also include unmet demand variables if required
380            if !variables.unmet_demand_var_idx.is_empty() {
381                for (time_slice, _) in ts_selection.iter(&model.time_slice_info) {
382                    let var = variables.get_unmet_demand_var(commodity_id, region_id, time_slice);
383                    terms.push((var, 1.0));
384                }
385            }
386
387            // Add a small epsilon to the lower bound to force some dispatch by candidate assets,
388            // ensuring they receive a nonzero shadow price.
389            let epsilon = candidate_balance_epsilon(
390                candidate_assets,
391                region_id,
392                commodity_id,
393                &ts_selection,
394                model.parameters.commodity_balance_epsilon,
395            );
396
397            // For SVD commodities, the lower bound is the exogenous demand (or epsilon if larger).
398            // For SED commodities, the lower bound is just epsilon.
399            let min = match commodity.kind {
400                CommodityType::ServiceDemand => {
401                    commodity.demand[&(region_id.clone(), year, ts_selection.clone())].max(epsilon)
402                }
403                _ => epsilon,
404            };
405
406            // Consume collected terms into a row. `terms.drain(..)` ensures the vector is
407            // emptied for the next selection.
408            problem.add_row(min.value().., terms.drain(..));
409            keys.push((
410                commodity_id.clone(),
411                region_id.clone(),
412                ts_selection.clone(),
413            ));
414        }
415    }
416
417    CommodityBalanceKeys { offset, keys }
418}
419
420/// Calculate the epsilon to add to the lower bound of a commodity balance constraint, to force
421/// some dispatch by candidate assets so they receive a nonzero shadow price.
422///
423/// Returns `epsilon` if the total maximum output from candidate assets in `region_id` for
424/// `commodity_id` in `ts_selection` exceeds `epsilon`, otherwise returns zero (to avoid making
425/// the balance constraint infeasible).
426fn candidate_balance_epsilon(
427    candidate_assets: &[AssetRef],
428    region_id: &RegionID,
429    commodity_id: &CommodityID,
430    ts_selection: &TimeSliceSelection,
431    epsilon: Flow,
432) -> Flow {
433    let max_candidate_output: Flow = candidate_assets
434        .iter()
435        .filter_region(region_id)
436        .flat_map(|a| {
437            let max_activity = *a.get_activity_limits_for_selection(ts_selection).end();
438            a.iter_output_flows()
439                .filter(|flow| &flow.commodity.id == commodity_id)
440                .map(move |flow| flow.coeff * max_activity)
441        })
442        .sum();
443    if max_candidate_output > epsilon {
444        epsilon
445    } else {
446        Flow(0.0)
447    }
448}
449
450/// Add constraints on the activity of different assets.
451///
452/// This ensures that assets do not exceed their specified capacity and availability for each time
453/// slice.
454///
455/// See description in [the dispatch optimisation documentation][1].
456///
457/// Returns an `ActivityKeys` where `offset` is the row index of the first
458/// activity constraint added and `keys` enumerates the `(asset, time_selection)`
459/// entries in the same row order. Note that for flexible-capacity assets two rows
460/// (upper and lower bounds) are added per selection; in that case the same key is
461/// stored twice to match the solver ordering.
462///
463#[doc = concat!("[1]: ", crate::docs_url!("model/dispatch_optimisation.html#asset-activity-limits"))]
464fn add_activity_constraints<'a, I>(
465    problem: &mut Problem,
466    variables: &VariableMap,
467    time_slice_info: &TimeSliceInfo,
468    assets: I,
469) -> ActivityKeys
470where
471    I: Iterator<Item = &'a AssetRef> + 'a,
472{
473    // Row offset in problem. This line **must** come before we add more constraints.
474    // It denotes the index into the solver's row ordering for the first activity constraint
475    // added below and is used when mapping duals back to assets/time selections.
476    let offset = problem.num_rows();
477
478    let mut keys = Vec::new();
479    let capacity_vars: IndexMap<&AssetRef, highs::Col> = variables.iter_capacity_vars().collect();
480
481    // Create constraints for each asset
482    for asset in assets {
483        if let Some(&capacity_var) = capacity_vars.get(asset) {
484            // Asset with flexible capacity
485            for (ts_selection, limits) in asset.iter_activity_per_capacity_limits() {
486                let mut upper_limit = limits.end().value();
487                let mut lower_limit = limits.start().value();
488
489                // The capacity variable represents number of tranches, so we need to multiply the
490                // per-capacity limits by the tranche size.
491                let tranche_size = asset.capacity().tranche_size();
492                upper_limit *= tranche_size.value();
493                lower_limit *= tranche_size.value();
494
495                // Collect capacity and activity terms
496                // We have a single capacity term, and activity terms for all time slices in the selection
497                let mut terms_upper = vec![(capacity_var, -upper_limit)];
498                let mut terms_lower = vec![(capacity_var, -lower_limit)];
499                for (time_slice, _) in ts_selection.iter(time_slice_info) {
500                    let var = variables.get_activity_var(asset, time_slice);
501                    terms_upper.push((var, 1.0));
502                    terms_lower.push((var, 1.0));
503                }
504
505                // Upper bound: sum(activity) - (capacity * upper_limit_per_capacity) ≤ 0
506                problem.add_row(..=0.0, &terms_upper);
507
508                // Lower bound: sum(activity) - (capacity * lower_limit_per_capacity) ≥ 0
509                problem.add_row(0.0.., &terms_lower);
510
511                // Store keys for retrieving duals later.
512                // TODO: a bit of a hack pushing identical keys twice. Safe for now so long as we don't
513                // use the activity duals for anything important when using flexible capacity assets.
514                keys.push((asset.clone(), ts_selection.clone()));
515                keys.push((asset.clone(), ts_selection.clone()));
516            }
517        } else {
518            // Fixed-capacity asset: simple absolute activity limits.
519            for (ts_selection, limits) in asset.iter_activity_limits() {
520                let limits = limits.start().value()..=limits.end().value();
521
522                // Collect activity terms for the time slices in this selection
523                let terms = ts_selection
524                    .iter(time_slice_info)
525                    .map(|(time_slice, _)| (variables.get_activity_var(asset, time_slice), 1.0))
526                    .collect::<Vec<_>>();
527
528                // Constraint: sum of activities in selection within limits
529                problem.add_row(limits, &terms);
530
531                // Store keys for retrieving duals later.
532                keys.push((asset.clone(), ts_selection.clone()));
533            }
534        }
535    }
536
537    ActivityKeys { offset, keys }
538}
539
540/// Groups assets that have equivalent dispatch properties.
541///
542/// Assets are first bucketed by `dispatch_equivalence_hash()` to avoid unnecessary pairwise
543/// comparisons. Within each hash bucket, assets are compared using `is_dispatch_equivalent()`,
544/// which is the authoritative check for equivalence. This also handles hash collisions correctly.
545///
546/// The caller must ensure that `assets` contains only assets eligible for equal-utilisation
547/// constraints (i.e. flexible-capacity assets have already been filtered out).
548fn group_dispatch_equivalent_assets<'a, I>(assets: I) -> Vec<Vec<&'a AssetRef>>
549where
550    I: Iterator<Item = &'a AssetRef>,
551{
552    // Group assets by comparing each one with the first asset in each group. The hash index
553    // avoids comparing an asset with groups that cannot contain an equivalent asset, while the
554    // exact comparison preserves correctness in the event of hash collisions.
555    let mut asset_groups: Vec<Vec<&AssetRef>> = Vec::new();
556    let mut group_indices: HashMap<u64, Vec<usize>> = HashMap::new();
557    for asset in assets {
558        let hash = asset.dispatch_equivalence_hash();
559
560        // Only groups with the same hash can possibly match.
561        let candidate_groups = group_indices.get(&hash);
562
563        // Find a group whose representative is actually equivalent.
564        let matching_group = candidate_groups
565            .into_iter()
566            .flatten()
567            .find(|&&group_index| asset_groups[group_index][0].is_dispatch_equivalent(asset));
568
569        if let Some(group_index) = matching_group {
570            asset_groups[*group_index].push(asset);
571        } else {
572            let group_index = asset_groups.len();
573            asset_groups.push(vec![asset]);
574            group_indices.entry(hash).or_default().push(group_index);
575        }
576    }
577
578    asset_groups
579}
580
581/// Add constraints requiring dispatch-equivalent assets to have equal utilisation in each time
582/// slice.
583///
584/// Flexible-capacity assets are excluded because their maximum activity depends on a decision
585/// variable. The constraints added here are not included in [`ConstraintKeys`], as their duals
586/// are not currently used.
587fn add_equal_utilisation_constraints<'a, I>(
588    problem: &mut Problem,
589    variables: &VariableMap,
590    time_slice_info: &TimeSliceInfo,
591    assets: I,
592) where
593    I: Iterator<Item = &'a AssetRef> + 'a,
594{
595    // Identify flexible-capacity assets so we can exclude them from the constraints
596    let flexible_assets: HashSet<_> = variables
597        .iter_capacity_vars()
598        .map(|(asset, _)| asset)
599        .collect();
600
601    let asset_groups =
602        group_dispatch_equivalent_assets(assets.filter(|asset| !flexible_assets.contains(asset)));
603
604    // For each group of assets, add constraints to force equal utilisation in each time slice
605    // This is done by anchoring each asset to the first asset in the group (-> (n-1) constraints
606    // for a group of n assets)
607    for assets in asset_groups {
608        let Some((reference_asset, others)) = assets.split_first() else {
609            continue;
610        };
611
612        let reference_max = reference_asset.max_activity().value();
613
614        for asset in others {
615            let asset_max = asset.max_activity().value();
616
617            for time_slice in time_slice_info.iter_ids() {
618                // Constraint: (act_a * max_b) - (act_b * max_a) = 0
619                problem.add_row(
620                    0.0..=0.0,
621                    [
622                        (variables.get_activity_var(asset, time_slice), reference_max),
623                        (
624                            variables.get_activity_var(reference_asset, time_slice),
625                            -asset_max,
626                        ),
627                    ],
628                );
629            }
630        }
631    }
632}
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637    use crate::asset::{Asset, AssetCapacity};
638    use crate::commodity::Commodity;
639    use crate::fixture::{asset, process, process_flows_map, svd_commodity};
640    use crate::process::Process;
641    use crate::process::{FlowType, ProcessFlow};
642    use crate::time_slice::TimeSliceSelection;
643    use crate::units::{Capacity, FlowPerActivity, MoneyPerActivity, MoneyPerFlow};
644    use indexmap::indexmap;
645    use rstest::rstest;
646    use std::sync::Arc;
647
648    #[rstest]
649    // Max candidate output (2.0) < epsilon (10.0) → zero (guard prevents infeasibility)
650    #[case(10.0, 0.0)]
651    // Max candidate output (2.0) > epsilon (1.0) → epsilon returned
652    #[case(1.0, 1.0)]
653    fn candidate_balance_epsilon_works(
654        #[case] epsilon: f64,
655        #[case] expected: f64,
656        svd_commodity: Commodity,
657        mut process: Process,
658    ) {
659        let commodity_rc = Arc::new(svd_commodity);
660
661        // Add an output flow for the commodity to the process. With capacity 2.0, cap2act 1.0,
662        // and full availability over a single annual time slice, max_candidate_output = 2.0.
663        let flow = ProcessFlow {
664            commodity: Arc::clone(&commodity_rc),
665            coeff: FlowPerActivity(1.0),
666            kind: FlowType::Fixed,
667            cost: MoneyPerFlow(0.0),
668        };
669        process.flows = process_flows_map(
670            process.regions.clone(),
671            Arc::new(indexmap! { commodity_rc.id.clone() => flow }),
672        );
673
674        let result = candidate_balance_epsilon(
675            &[AssetRef::from(asset(process))],
676            &"GBR".into(),
677            &commodity_rc.id,
678            &TimeSliceSelection::Annual,
679            Flow(epsilon),
680        );
681        assert_eq!(result, Flow(expected));
682    }
683
684    #[test]
685    fn groups_no_assets() {
686        assert!(group_dispatch_equivalent_assets(std::iter::empty()).is_empty());
687    }
688
689    #[rstest]
690    fn groups_equivalent_assets(asset: Asset) {
691        let mut equivalent = asset.clone();
692        equivalent.set_capacity(AssetCapacity::single(Capacity(3.0)));
693        let assets = [AssetRef::from(asset), AssetRef::from(equivalent)];
694
695        let groups = group_dispatch_equivalent_assets(assets.iter());
696
697        assert_eq!(groups.len(), 1);
698        assert_eq!(groups[0].len(), 2);
699    }
700
701    #[rstest]
702    fn groups_non_equivalent_assets_are_separate(asset: Asset, mut process: Process) {
703        Arc::make_mut(process.parameters.get_mut(&("GBR".into(), 2015)).unwrap())
704            .variable_operating_cost = MoneyPerActivity(1.0);
705        let different = Asset::new_ready(
706            "agent1".into(),
707            Arc::new(process),
708            "GBR".into(),
709            AssetCapacity::single(Capacity(2.0)),
710            2015,
711        )
712        .unwrap();
713        let assets = [AssetRef::from(asset), AssetRef::from(different)];
714
715        let groups = group_dispatch_equivalent_assets(assets.iter());
716
717        assert_eq!(groups.len(), 2);
718        assert_eq!(groups[0].len(), 1);
719        assert_eq!(groups[1].len(), 1);
720    }
721}