1use crate::asset::{Asset, AssetCapacity, AssetRef, AssetState};
5use crate::commodity::CommodityID;
6use crate::finance::annual_capital_cost;
7use crate::input::format_items_with_cap;
8use crate::model::Model;
9use crate::output::DataWriter;
10use crate::region::RegionID;
11use crate::simulation::CommodityPrices;
12use crate::time_slice::{TimeSliceID, TimeSliceInfo, TimeSliceSelection};
13use crate::units::{
14 Activity, Capacity, Dimensionless, Flow, Money, MoneyPerActivity, MoneyPerCapacity,
15 MoneyPerFlow, Year,
16};
17use anyhow::{Result, bail, ensure};
18use highs::{HighsModelStatus, HighsStatus, RowProblem as Problem, Sense};
19use indexmap::{IndexMap, IndexSet};
20use itertools::{Itertools, chain, iproduct};
21use std::cell::Cell;
22use std::collections::{HashMap, HashSet};
23use std::error::Error;
24use std::fmt;
25use std::ops::Range;
26
27mod constraints;
28use constraints::{ConstraintKeys, add_model_constraints};
29
30pub type FlowMap = IndexMap<(AssetRef, CommodityID, TimeSliceID), Flow>;
32
33type Variable = highs::Col;
38
39type ActivityVariableMap = IndexMap<(AssetRef, TimeSliceID), Variable>;
41
42type CapacityVariableMap = IndexMap<AssetRef, Variable>;
44
45type UnmetDemandVariableMap = IndexMap<(CommodityID, RegionID, TimeSliceID), Variable>;
47
48pub struct VariableMap {
58 activity_vars: ActivityVariableMap,
59 existing_asset_var_idx: Range<usize>,
60 candidate_asset_var_idx: Range<usize>,
61 capacity_vars: CapacityVariableMap,
62 capacity_var_idx: Range<usize>,
63 unmet_demand_vars: UnmetDemandVariableMap,
64 unmet_demand_var_idx: Range<usize>,
65}
66
67impl VariableMap {
68 fn new_with_activity_vars(
79 problem: &mut Problem,
80 model: &Model,
81 input_prices: Option<&CommodityPrices>,
82 existing_assets: &[AssetRef],
83 candidate_assets: &[AssetRef],
84 year: u32,
85 ) -> Self {
86 let mut activity_vars = ActivityVariableMap::new();
87 let existing_asset_var_idx = add_activity_variables(
88 problem,
89 &mut activity_vars,
90 &model.time_slice_info,
91 input_prices,
92 existing_assets,
93 year,
94 );
95 let candidate_asset_var_idx = add_activity_variables(
96 problem,
97 &mut activity_vars,
98 &model.time_slice_info,
99 input_prices,
100 candidate_assets,
101 year,
102 );
103
104 Self {
105 activity_vars,
106 existing_asset_var_idx,
107 candidate_asset_var_idx,
108 capacity_vars: CapacityVariableMap::new(),
109 capacity_var_idx: Range::default(),
110 unmet_demand_vars: UnmetDemandVariableMap::default(),
111 unmet_demand_var_idx: Range::default(),
112 }
113 }
114
115 fn add_unmet_demand_variables(
123 &mut self,
124 problem: &mut Problem,
125 model: &Model,
126 markets_to_allow_unmet_demand: &[(CommodityID, RegionID)],
127 ) {
128 assert!(!markets_to_allow_unmet_demand.is_empty());
129
130 let start = problem.num_cols();
132
133 let voll = model.parameters.value_of_lost_load;
135 self.unmet_demand_vars.extend(
136 iproduct!(
137 markets_to_allow_unmet_demand.iter(),
138 model.time_slice_info.iter_ids()
139 )
140 .map(|((commodity_id, region_id), time_slice)| {
141 let key = (commodity_id.clone(), region_id.clone(), time_slice.clone());
142 let var = problem.add_column(voll.value(), 0.0..);
143 (key, var)
144 }),
145 );
146
147 self.unmet_demand_var_idx = start..problem.num_cols();
148 }
149
150 fn get_activity_var(&self, asset: &AssetRef, time_slice: &TimeSliceID) -> Variable {
152 let key = (asset.clone(), time_slice.clone());
153
154 *self
155 .activity_vars
156 .get(&key)
157 .expect("No asset variable found for given params")
158 }
159
160 fn get_unmet_demand_var(
162 &self,
163 commodity_id: &CommodityID,
164 region_id: &RegionID,
165 time_slice: &TimeSliceID,
166 ) -> Variable {
167 *self
168 .unmet_demand_vars
169 .get(&(commodity_id.clone(), region_id.clone(), time_slice.clone()))
170 .expect("No unmet demand variable for given params")
171 }
172
173 fn activity_var_keys(&self) -> indexmap::map::Keys<'_, (AssetRef, TimeSliceID), Variable> {
175 self.activity_vars.keys()
176 }
177
178 fn iter_capacity_vars(&self) -> impl Iterator<Item = (&AssetRef, Variable)> {
180 self.capacity_vars.iter().map(|(asset, var)| (asset, *var))
181 }
182}
183
184fn create_flow_map<'a>(
186 existing_assets: &[AssetRef],
187 time_slice_info: &TimeSliceInfo,
188 activity: impl IntoIterator<Item = (&'a AssetRef, &'a TimeSliceID, Activity)>,
189) -> FlowMap {
190 let mut flows = FlowMap::new();
193 for (asset, time_slice, activity) in activity {
194 let n_units = Dimensionless(asset.num_children().unwrap_or(1) as f64);
195 for flow in asset.iter_flows() {
196 let flow_key = (asset.clone(), flow.commodity.id.clone(), time_slice.clone());
197 let flow_value = activity * flow.coeff / n_units;
198 flows.insert(flow_key, flow_value);
199 }
200 }
201
202 for asset in existing_assets {
204 if let Some(parent) = asset.parent() {
205 for commodity_id in asset.iter_flows().map(|flow| &flow.commodity.id) {
206 for time_slice in time_slice_info.iter_ids() {
207 let flow = flows[&(parent.clone(), commodity_id.clone(), time_slice.clone())];
208 flows.insert(
209 (asset.clone(), commodity_id.clone(), time_slice.clone()),
210 flow,
211 );
212 }
213 }
214 }
215 }
216
217 flows.retain(|(asset, _, _), _| !asset.is_parent());
219
220 flows
221}
222
223#[allow(clippy::struct_field_names)]
225pub struct Solution<'a> {
226 solution: highs::Solution,
227 variables: VariableMap,
228 time_slice_info: &'a TimeSliceInfo,
229 constraint_keys: ConstraintKeys,
230 flow_map: Cell<Option<FlowMap>>,
231 pub objective_value: Money,
233}
234
235impl Solution<'_> {
236 pub fn create_flow_map(&self) -> FlowMap {
243 self.flow_map.take().expect("Flow map already created")
244 }
245
246 pub fn iter_activity(&self) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, Activity)> {
248 self.variables
249 .activity_var_keys()
250 .zip(self.solution.columns())
251 .map(|((asset, time_slice), activity)| (asset, time_slice, Activity(*activity)))
252 }
253
254 pub fn iter_activity_for_existing(
256 &self,
257 ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, Activity)> {
258 let cols = &self.solution.columns()[self.variables.existing_asset_var_idx.clone()];
259 self.variables
260 .activity_var_keys()
261 .skip(self.variables.existing_asset_var_idx.start)
262 .zip(cols.iter())
263 .map(|((asset, time_slice), &value)| (asset, time_slice, Activity(value)))
264 }
265
266 pub fn iter_activity_keys_for_existing(
268 &self,
269 ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID)> {
270 self.iter_activity_for_existing()
271 .map(|(asset, time_slice, _activity)| (asset, time_slice))
272 }
273
274 pub fn iter_activity_for_candidates(
276 &self,
277 ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, Activity)> {
278 let cols = &self.solution.columns()[self.variables.candidate_asset_var_idx.clone()];
279 self.variables
280 .activity_var_keys()
281 .skip(self.variables.candidate_asset_var_idx.start)
282 .zip(cols.iter())
283 .map(|((asset, time_slice), &value)| (asset, time_slice, Activity(value)))
284 }
285
286 pub fn iter_activity_keys_for_candidates(
288 &self,
289 ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID)> {
290 self.iter_activity_for_candidates()
291 .map(|(asset, time_slice, _activity)| (asset, time_slice))
292 }
293
294 pub fn iter_unmet_demand(
296 &self,
297 ) -> impl Iterator<Item = (&CommodityID, &RegionID, &TimeSliceID, Flow)> {
298 self.variables
299 .unmet_demand_vars
300 .keys()
301 .zip(self.solution.columns()[self.variables.unmet_demand_var_idx.clone()].iter())
302 .map(|((commodity_id, region_id, time_slice), flow)| {
303 (commodity_id, region_id, time_slice, Flow(*flow))
304 })
305 }
306
307 pub fn iter_capacity(&self) -> impl Iterator<Item = (&AssetRef, AssetCapacity)> {
312 self.variables
313 .capacity_vars
314 .keys()
315 .zip(self.solution.columns()[self.variables.capacity_var_idx.clone()].iter())
316 .map(|(asset, capacity_var)| {
317 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
320 let asset_capacity = if let Some(unit_size) = asset.unit_size() {
321 AssetCapacity::Discrete(capacity_var.round() as u32, unit_size)
322 } else {
323 AssetCapacity::Continuous(Capacity(*capacity_var))
324 };
325 (asset, asset_capacity)
326 })
327 }
328
329 pub fn iter_commodity_balance_duals(
331 &self,
332 ) -> impl Iterator<Item = (&CommodityID, &RegionID, &TimeSliceID, MoneyPerFlow)> {
333 self.constraint_keys
337 .commodity_balance_keys
338 .zip_duals(self.solution.dual_rows())
339 .flat_map(|((commodity_id, region_id, ts_selection), price)| {
340 ts_selection
341 .iter(self.time_slice_info)
342 .map(move |(ts, _)| (commodity_id, region_id, ts, price))
343 })
344 }
345
346 pub fn iter_activity_duals(
355 &self,
356 ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, MoneyPerActivity)> {
357 self.constraint_keys
358 .activity_keys
359 .zip_duals(self.solution.dual_rows())
360 .filter(|&((_asset, ts_selection), _dual)| {
361 matches!(ts_selection, TimeSliceSelection::Single(_))
362 })
363 .map(|((asset, ts_selection), dual)| {
364 let (time_slice, _) = ts_selection.iter(self.time_slice_info).next().unwrap();
366 (asset, time_slice, dual)
367 })
368 }
369
370 pub fn iter_column_duals(
372 &self,
373 ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, MoneyPerActivity)> {
374 self.variables
375 .activity_var_keys()
376 .zip(self.solution.dual_columns())
377 .map(|((asset, time_slice), dual)| (asset, time_slice, MoneyPerActivity(*dual)))
378 }
379}
380
381#[derive(Debug, Clone)]
383pub enum ModelError {
384 Incoherent(HighsStatus),
388 NonOptimal(HighsModelStatus),
390}
391
392impl fmt::Display for ModelError {
393 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
394 match self {
395 ModelError::Incoherent(status) => write!(f, "Incoherent model: {status:?}"),
396 ModelError::NonOptimal(status) => {
397 write!(f, "Could not find optimal result: {status:?}")
398 }
399 }
400 }
401}
402
403impl Error for ModelError {}
404
405pub fn solve_optimal(model: highs::Model) -> Result<highs::SolvedModel, ModelError> {
407 let solved = model.try_solve().map_err(ModelError::Incoherent)?;
408
409 match solved.status() {
410 HighsModelStatus::Optimal => Ok(solved),
411 status => Err(ModelError::NonOptimal(status)),
412 }
413}
414
415fn filter_input_prices(
420 input_prices: &CommodityPrices,
421 markets_to_balance: &[(CommodityID, RegionID)],
422) -> CommodityPrices {
423 input_prices
424 .iter()
425 .filter(|(commodity_id, region_id, _, _)| {
426 !markets_to_balance
427 .iter()
428 .any(|(c, r)| c == *commodity_id && r == *region_id)
429 })
430 .collect()
431}
432
433fn get_parent_or_self(assets: &[AssetRef]) -> impl Iterator<Item = AssetRef> {
438 let mut parents = HashSet::new();
439 assets
440 .iter()
441 .filter_map(move |asset| match asset.parent() {
442 Some(parent) => parents.insert(parent.clone()).then_some(parent),
443 None => Some(asset),
444 })
445 .cloned()
446}
447
448pub struct DispatchRun<'model, 'run> {
458 model: &'model Model,
459 existing_assets: &'run [AssetRef],
460 flexible_capacity_assets: &'run [AssetRef],
461 capacity_limits: Option<&'run HashMap<AssetRef, AssetCapacity>>,
462 candidate_assets: &'run [AssetRef],
463 markets_to_balance: &'run [(CommodityID, RegionID)],
464 input_prices: Option<&'run CommodityPrices>,
465 year: u32,
466 capacity_margin: f64,
467}
468
469impl<'model, 'run> DispatchRun<'model, 'run> {
470 pub fn new(model: &'model Model, assets: &'run [AssetRef], year: u32) -> Self {
472 Self {
473 model,
474 existing_assets: assets,
475 flexible_capacity_assets: &[],
476 capacity_limits: None,
477 candidate_assets: &[],
478 markets_to_balance: &[],
479 input_prices: None,
480 year,
481 capacity_margin: 0.0,
482 }
483 }
484
485 pub fn with_flexible_capacity_assets(
487 self,
488 flexible_capacity_assets: &'run [AssetRef],
489 capacity_limits: Option<&'run HashMap<AssetRef, AssetCapacity>>,
490 capacity_margin: f64,
491 ) -> Self {
492 Self {
493 flexible_capacity_assets,
494 capacity_limits,
495 capacity_margin,
496 ..self
497 }
498 }
499
500 pub fn with_candidates(self, candidate_assets: &'run [AssetRef]) -> Self {
502 Self {
503 candidate_assets,
504 ..self
505 }
506 }
507
508 pub fn with_market_balance_subset(
510 self,
511 markets_to_balance: &'run [(CommodityID, RegionID)],
512 ) -> Self {
513 assert!(!markets_to_balance.is_empty());
514
515 Self {
516 markets_to_balance,
517 ..self
518 }
519 }
520
521 pub fn with_input_prices(self, input_prices: &'run CommodityPrices) -> Self {
523 Self {
524 input_prices: Some(input_prices),
525 ..self
526 }
527 }
528
529 pub fn run(&self, run_description: &str, writer: &mut DataWriter) -> Result<Solution<'model>> {
541 let all_markets: Vec<_>;
543 let markets_to_balance = if self.markets_to_balance.is_empty() {
544 all_markets = self.model.iter_markets().collect();
545 &all_markets
546 } else {
547 self.markets_to_balance
548 };
549
550 let input_prices_owned = self
552 .input_prices
553 .map(|prices| filter_input_prices(prices, markets_to_balance));
554 let input_prices = input_prices_owned.as_ref();
555
556 match self.run_without_unmet_demand_variables(markets_to_balance, input_prices) {
560 Ok(solution) => {
561 writer.write_dispatch_debug_info(self.year, run_description, &solution)?;
563 Ok(solution)
564 }
565 Err(ModelError::NonOptimal(HighsModelStatus::Infeasible)) => {
566 let solution = self
569 .run_internal(
570 markets_to_balance,
571 true,
572 input_prices,
573 )
574 .expect("Failed to run dispatch to calculate unmet demand");
575
576 writer.write_dispatch_debug_info(self.year, run_description, &solution)?;
578
579 let markets: IndexSet<_> = solution
581 .iter_unmet_demand()
582 .filter(|(_, _, _, flow)| *flow > Flow(0.0))
583 .map(|(commodity_id, region_id, _, _)| {
584 (commodity_id.clone(), region_id.clone())
585 })
586 .collect();
587
588 ensure!(
589 !markets.is_empty(),
590 "Model is infeasible, but there was no unmet demand"
591 );
592
593 bail!(
594 "The solver has indicated that the problem is infeasible, probably because \
595 the supplied assets could not meet the required demand. Demand was not met \
596 for the following markets: {}",
597 format_items_with_cap(markets)
598 )
599 }
600 Err(err) => Err(err)?,
601 }
602 }
603
604 fn run_without_unmet_demand_variables(
606 &self,
607 markets_to_balance: &[(CommodityID, RegionID)],
608 input_prices: Option<&CommodityPrices>,
609 ) -> Result<Solution<'model>, ModelError> {
610 self.run_internal(
611 markets_to_balance,
612 false,
613 input_prices,
614 )
615 }
616
617 fn run_internal(
619 &self,
620 markets_to_balance: &[(CommodityID, RegionID)],
621 allow_unmet_demand: bool,
622 input_prices: Option<&CommodityPrices>,
623 ) -> Result<Solution<'model>, ModelError> {
624 let parent_assets = get_parent_or_self(self.existing_assets).collect_vec();
625
626 let mut problem = Problem::default();
628 let mut variables = VariableMap::new_with_activity_vars(
629 &mut problem,
630 self.model,
631 input_prices,
632 &parent_assets,
633 self.candidate_assets,
634 self.year,
635 );
636
637 if allow_unmet_demand {
640 variables.add_unmet_demand_variables(&mut problem, self.model, markets_to_balance);
641 }
642
643 for asset in self.flexible_capacity_assets {
645 assert!(
646 parent_assets.contains(asset),
647 "Flexible capacity assets must be a subset of existing assets. Offending asset: {asset:?}"
648 );
649 }
650
651 if !self.flexible_capacity_assets.is_empty() {
653 variables.capacity_var_idx = add_capacity_variables(
654 &mut problem,
655 &mut variables.capacity_vars,
656 self.flexible_capacity_assets,
657 self.capacity_limits,
658 self.capacity_margin,
659 );
660 }
661
662 let all_assets = chain(parent_assets.iter(), self.candidate_assets.iter());
664 let constraint_keys = add_model_constraints(
665 &mut problem,
666 &variables,
667 self.model,
668 &all_assets,
669 markets_to_balance,
670 self.year,
671 );
672
673 let solution = solve_optimal(problem.optimise(Sense::Minimise))?;
675
676 let solution = Solution {
677 solution: solution.get_solution(),
678 variables,
679 time_slice_info: &self.model.time_slice_info,
680 constraint_keys,
681 flow_map: Cell::default(),
682 objective_value: Money(solution.objective_value()),
683 };
684 solution.flow_map.set(Some(create_flow_map(
685 self.existing_assets,
686 &self.model.time_slice_info,
687 solution.iter_activity(),
688 )));
689 Ok(solution)
690 }
691}
692
693fn add_activity_variables(
704 problem: &mut Problem,
705 variables: &mut ActivityVariableMap,
706 time_slice_info: &TimeSliceInfo,
707 input_prices: Option<&CommodityPrices>,
708 assets: &[AssetRef],
709 year: u32,
710) -> Range<usize> {
711 let start = problem.num_cols();
713
714 for (asset, time_slice) in iproduct!(assets.iter(), time_slice_info.iter_ids()) {
715 let coeff = calculate_activity_coefficient(asset, year, time_slice, input_prices);
716 let var = problem.add_column(coeff.value(), 0.0..);
717 let key = (asset.clone(), time_slice.clone());
718 let existing = variables.insert(key, var).is_some();
719 assert!(!existing, "Duplicate entry for var");
720 }
721
722 start..problem.num_cols()
723}
724
725fn add_capacity_variables(
726 problem: &mut Problem,
727 variables: &mut CapacityVariableMap,
728 assets: &[AssetRef],
729 capacity_limits: Option<&HashMap<AssetRef, AssetCapacity>>,
730 capacity_margin: f64,
731) -> Range<usize> {
732 let start = problem.num_cols();
734
735 for asset in assets {
736 assert!(
738 matches!(asset.state(), AssetState::Selected { .. }),
739 "Flexible capacity can only be assigned to `Selected` type assets. Offending asset: {asset:?}"
740 );
741
742 let current_capacity = asset.capacity();
743 let coeff = calculate_capacity_coefficient(asset);
744
745 let capacity_limit = capacity_limits.and_then(|limits| limits.get(asset));
747
748 if let Some(limit) = capacity_limit {
750 assert!(
751 matches!(
752 (current_capacity, limit),
753 (AssetCapacity::Continuous(_), AssetCapacity::Continuous(_))
754 | (AssetCapacity::Discrete(_, _), AssetCapacity::Discrete(_, _))
755 ),
756 "Incompatible capacity types for asset capacity limit"
757 );
758 }
759
760 let var = match current_capacity {
764 AssetCapacity::Continuous(cap) => {
765 let lower = ((1.0 - capacity_margin) * cap.value()).max(0.0);
767 let mut upper = (1.0 + capacity_margin) * cap.value();
768 if let Some(limit) = capacity_limit {
769 upper = upper.min(limit.total_capacity().value());
770 }
771 problem.add_column(coeff.value(), lower..=upper)
772 }
773 AssetCapacity::Discrete(units, unit_size) => {
774 let lower = ((1.0 - capacity_margin) * units as f64).max(0.0);
776 let mut upper = (1.0 + capacity_margin) * units as f64;
777 if let Some(limit) = capacity_limit {
778 upper = upper.min(limit.n_units().unwrap() as f64);
779 }
780 problem.add_integer_column((coeff * unit_size).value(), lower..=upper)
781 }
782 };
783
784 let existing = variables.insert(asset.clone(), var).is_some();
785 assert!(!existing, "Duplicate entry for var");
786 }
787
788 start..problem.num_cols()
789}
790
791fn calculate_activity_coefficient(
808 asset: &Asset,
809 year: u32,
810 time_slice: &TimeSliceID,
811 input_prices: Option<&CommodityPrices>,
812) -> MoneyPerActivity {
813 let opex = asset.get_operating_cost(year, time_slice);
814 if let Some(prices) = input_prices {
815 opex + asset.get_input_cost_from_prices(prices, time_slice)
816 } else {
817 opex
818 }
819}
820
821fn calculate_capacity_coefficient(asset: &AssetRef) -> MoneyPerCapacity {
825 let param = asset.process_parameter();
826 let annual_fixed_operating_cost = param.fixed_operating_cost * Year(1.0);
827 annual_fixed_operating_cost
828 + annual_capital_cost(param.capital_cost, param.lifetime, param.discount_rate)
829}