1use 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
14pub struct KeysWithOffset<T> {
16 offset: usize,
21 keys: Vec<T>,
24}
25
26impl<T> KeysWithOffset<T> {
27 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
47pub type CommodityBalanceKeys = KeysWithOffset<(CommodityID, RegionID, TimeSliceSelection)>;
49
50pub type ActivityKeys = KeysWithOffset<(AssetRef, TimeSliceSelection)>;
52
53type SeasonalPeakVariableMap = IndexMap<(AssetRef, Season), highs::Col>;
55
56type AnnualPeakVariableMap = IndexMap<AssetRef, highs::Col>;
58
59pub struct ConstraintKeys {
61 pub commodity_balance_keys: CommodityBalanceKeys,
63 pub activity_keys: ActivityKeys,
65}
66
67#[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 ConstraintKeys {
121 commodity_balance_keys,
122 activity_keys,
123 }
124}
125
126fn 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 for commodity in model.commodities.values() {
139 let Some(constraints) = commodity.constraints.get(&year) else {
140 continue;
141 };
142
143 for constraint in constraints {
146 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 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 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 problem.add_row(
177 constraint.limits.start().value()..=constraint.limits.end().value(),
178 terms,
179 );
180 }
181 }
182}
183
184fn 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 !has_seasonal_penalty && !has_annual_penalty {
200 return;
201 }
202
203 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 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
224fn 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 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
245fn 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 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
264fn 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 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
296fn 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#[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 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 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 if terms.is_empty() {
376 continue;
377 }
378
379 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 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 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 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
420fn 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#[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 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 for asset in assets {
483 if let Some(&capacity_var) = capacity_vars.get(asset) {
484 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 let tranche_size = asset.capacity().tranche_size();
492 upper_limit *= tranche_size.value();
493 lower_limit *= tranche_size.value();
494
495 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 problem.add_row(..=0.0, &terms_upper);
507
508 problem.add_row(0.0.., &terms_lower);
510
511 keys.push((asset.clone(), ts_selection.clone()));
515 keys.push((asset.clone(), ts_selection.clone()));
516 }
517 } else {
518 for (ts_selection, limits) in asset.iter_activity_limits() {
520 let limits = limits.start().value()..=limits.end().value();
521
522 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 problem.add_row(limits, &terms);
530
531 keys.push((asset.clone(), ts_selection.clone()));
533 }
534 }
535 }
536
537 ActivityKeys { offset, keys }
538}
539
540fn group_dispatch_equivalent_assets<'a, I>(assets: I) -> Vec<Vec<&'a AssetRef>>
549where
550 I: Iterator<Item = &'a AssetRef>,
551{
552 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 let candidate_groups = group_indices.get(&hash);
562
563 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
581fn 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 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 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 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 #[case(10.0, 0.0)]
651 #[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 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}