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::{chain, iproduct};
21use std::cell::Cell;
22use std::collections::HashMap;
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_for_candidates(
268 &self,
269 ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, Activity)> {
270 let cols = &self.solution.columns()[self.variables.candidate_asset_var_idx.clone()];
271 self.variables
272 .activity_var_keys()
273 .skip(self.variables.candidate_asset_var_idx.start)
274 .zip(cols.iter())
275 .map(|((asset, time_slice), &value)| (asset, time_slice, Activity(value)))
276 }
277
278 pub fn iter_activity_keys_for_candidates(
280 &self,
281 ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID)> {
282 self.iter_activity_for_candidates()
283 .map(|(asset, time_slice, _activity)| (asset, time_slice))
284 }
285
286 pub fn iter_unmet_demand(
288 &self,
289 ) -> impl Iterator<Item = (&CommodityID, &RegionID, &TimeSliceID, Flow)> {
290 self.variables
291 .unmet_demand_vars
292 .keys()
293 .zip(self.solution.columns()[self.variables.unmet_demand_var_idx.clone()].iter())
294 .map(|((commodity_id, region_id, time_slice), flow)| {
295 (commodity_id, region_id, time_slice, Flow(*flow))
296 })
297 }
298
299 pub fn iter_capacity(&self) -> impl Iterator<Item = (&AssetRef, AssetCapacity)> {
304 self.variables
305 .capacity_vars
306 .keys()
307 .zip(self.solution.columns()[self.variables.capacity_var_idx.clone()].iter())
308 .map(|(asset, capacity_var)| {
309 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
312 let asset_capacity = if let Some(unit_size) = asset.unit_size() {
313 AssetCapacity::Discrete(capacity_var.round() as u32, unit_size)
314 } else {
315 AssetCapacity::Continuous(Capacity(*capacity_var))
316 };
317 (asset, asset_capacity)
318 })
319 }
320
321 pub fn iter_commodity_balance_duals(
323 &self,
324 ) -> impl Iterator<Item = (&CommodityID, &RegionID, &TimeSliceID, MoneyPerFlow)> {
325 self.constraint_keys
329 .commodity_balance_keys
330 .zip_duals(self.solution.dual_rows())
331 .flat_map(|((commodity_id, region_id, ts_selection), price)| {
332 ts_selection
333 .iter(self.time_slice_info)
334 .map(move |(ts, _)| (commodity_id, region_id, ts, price))
335 })
336 }
337
338 pub fn iter_activity_duals(
347 &self,
348 ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, MoneyPerActivity)> {
349 self.constraint_keys
350 .activity_keys
351 .zip_duals(self.solution.dual_rows())
352 .filter(|&((_asset, ts_selection), _dual)| {
353 matches!(ts_selection, TimeSliceSelection::Single(_))
354 })
355 .map(|((asset, ts_selection), dual)| {
356 let (time_slice, _) = ts_selection.iter(self.time_slice_info).next().unwrap();
358 (asset, time_slice, dual)
359 })
360 }
361
362 pub fn iter_column_duals(
364 &self,
365 ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, MoneyPerActivity)> {
366 self.variables
367 .activity_var_keys()
368 .zip(self.solution.dual_columns())
369 .map(|((asset, time_slice), dual)| (asset, time_slice, MoneyPerActivity(*dual)))
370 }
371}
372
373#[derive(Debug, Clone)]
375pub enum ModelError {
376 Incoherent(HighsStatus),
380 NonOptimal(HighsModelStatus),
382}
383
384impl fmt::Display for ModelError {
385 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
386 match self {
387 ModelError::Incoherent(status) => write!(f, "Incoherent model: {status:?}"),
388 ModelError::NonOptimal(status) => {
389 write!(f, "Could not find optimal result: {status:?}")
390 }
391 }
392 }
393}
394
395impl Error for ModelError {}
396
397pub fn solve_optimal(model: highs::Model) -> Result<highs::SolvedModel, ModelError> {
399 let solved = model.try_solve().map_err(ModelError::Incoherent)?;
400
401 match solved.status() {
402 HighsModelStatus::Optimal => Ok(solved),
403 status => Err(ModelError::NonOptimal(status)),
404 }
405}
406
407fn filter_input_prices(
412 input_prices: &CommodityPrices,
413 markets_to_balance: &[(CommodityID, RegionID)],
414) -> CommodityPrices {
415 input_prices
416 .iter()
417 .filter(|(commodity_id, region_id, _, _)| {
418 !markets_to_balance
419 .iter()
420 .any(|(c, r)| c == *commodity_id && r == *region_id)
421 })
422 .collect()
423}
424
425fn get_parent_or_self(assets: &[AssetRef]) -> Vec<AssetRef> {
434 let mut child_counts: IndexMap<&AssetRef, u32> = IndexMap::new();
435 let mut out = Vec::new();
436
437 for asset in assets {
438 if let Some(parent) = asset.parent() {
439 *child_counts.entry(parent).or_default() += 1;
441 } else {
442 out.push(asset.clone());
444 }
445 }
446
447 for (parent, child_count) in child_counts {
448 out.push(parent.make_partial_parent(child_count));
450 }
451
452 out
453}
454
455pub struct DispatchRun<'model, 'run> {
465 model: &'model Model,
466 existing_assets: &'run [AssetRef],
467 flexible_capacity_assets: &'run [AssetRef],
468 capacity_limits: Option<&'run HashMap<AssetRef, AssetCapacity>>,
469 candidate_assets: &'run [AssetRef],
470 markets_to_balance: &'run [(CommodityID, RegionID)],
471 input_prices: Option<&'run CommodityPrices>,
472 year: u32,
473 capacity_margin: f64,
474}
475
476impl<'model, 'run> DispatchRun<'model, 'run> {
477 pub fn new(model: &'model Model, assets: &'run [AssetRef], year: u32) -> Self {
479 Self {
480 model,
481 existing_assets: assets,
482 flexible_capacity_assets: &[],
483 capacity_limits: None,
484 candidate_assets: &[],
485 markets_to_balance: &[],
486 input_prices: None,
487 year,
488 capacity_margin: 0.0,
489 }
490 }
491
492 pub fn with_flexible_capacity_assets(
494 self,
495 flexible_capacity_assets: &'run [AssetRef],
496 capacity_limits: Option<&'run HashMap<AssetRef, AssetCapacity>>,
497 capacity_margin: f64,
498 ) -> Self {
499 Self {
500 flexible_capacity_assets,
501 capacity_limits,
502 capacity_margin,
503 ..self
504 }
505 }
506
507 pub fn with_candidates(self, candidate_assets: &'run [AssetRef]) -> Self {
509 Self {
510 candidate_assets,
511 ..self
512 }
513 }
514
515 pub fn with_market_balance_subset(
517 self,
518 markets_to_balance: &'run [(CommodityID, RegionID)],
519 ) -> Self {
520 assert!(!markets_to_balance.is_empty());
521
522 Self {
523 markets_to_balance,
524 ..self
525 }
526 }
527
528 pub fn with_input_prices(self, input_prices: &'run CommodityPrices) -> Self {
530 Self {
531 input_prices: Some(input_prices),
532 ..self
533 }
534 }
535
536 pub fn run(&self, run_description: &str, writer: &mut DataWriter) -> Result<Solution<'model>> {
548 let all_markets: Vec<_>;
550 let markets_to_balance = if self.markets_to_balance.is_empty() {
551 all_markets = self.model.iter_markets().collect();
552 &all_markets
553 } else {
554 self.markets_to_balance
555 };
556
557 let input_prices_owned = self
559 .input_prices
560 .map(|prices| filter_input_prices(prices, markets_to_balance));
561 let input_prices = input_prices_owned.as_ref();
562
563 match self.run_without_unmet_demand_variables(markets_to_balance, input_prices) {
567 Ok(solution) => {
568 writer.write_dispatch_debug_info(self.year, run_description, &solution)?;
570 Ok(solution)
571 }
572 Err(ModelError::NonOptimal(HighsModelStatus::Infeasible)) => {
573 let solution = self
576 .run_internal(
577 markets_to_balance,
578 true,
579 input_prices,
580 )
581 .expect("Failed to run dispatch to calculate unmet demand");
582
583 writer.write_dispatch_debug_info(self.year, run_description, &solution)?;
585
586 let markets: IndexSet<_> = solution
588 .iter_unmet_demand()
589 .filter(|(_, _, _, flow)| *flow > Flow(0.0))
590 .map(|(commodity_id, region_id, _, _)| {
591 (commodity_id.clone(), region_id.clone())
592 })
593 .collect();
594
595 ensure!(
596 !markets.is_empty(),
597 "Model is infeasible, but there was no unmet demand"
598 );
599
600 bail!(
601 "The solver has indicated that the problem is infeasible, probably because \
602 the supplied assets could not meet the required demand. Demand was not met \
603 for the following markets: {}",
604 format_items_with_cap(markets)
605 )
606 }
607 Err(err) => Err(err)?,
608 }
609 }
610
611 fn run_without_unmet_demand_variables(
613 &self,
614 markets_to_balance: &[(CommodityID, RegionID)],
615 input_prices: Option<&CommodityPrices>,
616 ) -> Result<Solution<'model>, ModelError> {
617 self.run_internal(
618 markets_to_balance,
619 false,
620 input_prices,
621 )
622 }
623
624 fn run_internal(
626 &self,
627 markets_to_balance: &[(CommodityID, RegionID)],
628 allow_unmet_demand: bool,
629 input_prices: Option<&CommodityPrices>,
630 ) -> Result<Solution<'model>, ModelError> {
631 let parent_assets = get_parent_or_self(self.existing_assets);
632
633 let mut problem = Problem::default();
635 let mut variables = VariableMap::new_with_activity_vars(
636 &mut problem,
637 self.model,
638 input_prices,
639 &parent_assets,
640 self.candidate_assets,
641 self.year,
642 );
643
644 if allow_unmet_demand {
647 variables.add_unmet_demand_variables(&mut problem, self.model, markets_to_balance);
648 }
649
650 for asset in self.flexible_capacity_assets {
652 assert!(
653 parent_assets.contains(asset),
654 "Flexible capacity assets must be a subset of existing assets. Offending asset: {asset:?}"
655 );
656 }
657
658 if !self.flexible_capacity_assets.is_empty() {
660 variables.capacity_var_idx = add_capacity_variables(
661 &mut problem,
662 &mut variables.capacity_vars,
663 self.flexible_capacity_assets,
664 self.capacity_limits,
665 self.capacity_margin,
666 );
667 }
668
669 let all_assets = chain(parent_assets.iter(), self.candidate_assets.iter());
671 let constraint_keys = add_model_constraints(
672 &mut problem,
673 &variables,
674 self.model,
675 &all_assets,
676 markets_to_balance,
677 self.year,
678 );
679
680 let solution = solve_optimal(problem.optimise(Sense::Minimise))?;
682
683 let solution = Solution {
684 solution: solution.get_solution(),
685 variables,
686 time_slice_info: &self.model.time_slice_info,
687 constraint_keys,
688 flow_map: Cell::default(),
689 objective_value: Money(solution.objective_value()),
690 };
691 solution.flow_map.set(Some(create_flow_map(
692 self.existing_assets,
693 &self.model.time_slice_info,
694 solution.iter_activity(),
695 )));
696 Ok(solution)
697 }
698}
699
700fn add_activity_variables(
711 problem: &mut Problem,
712 variables: &mut ActivityVariableMap,
713 time_slice_info: &TimeSliceInfo,
714 input_prices: Option<&CommodityPrices>,
715 assets: &[AssetRef],
716 year: u32,
717) -> Range<usize> {
718 let start = problem.num_cols();
720
721 for (asset, time_slice) in iproduct!(assets.iter(), time_slice_info.iter_ids()) {
722 let coeff = calculate_activity_coefficient(asset, year, time_slice, input_prices);
723 let var = problem.add_column(coeff.value(), 0.0..);
724 let key = (asset.clone(), time_slice.clone());
725 let existing = variables.insert(key, var).is_some();
726 assert!(!existing, "Duplicate entry for var");
727 }
728
729 start..problem.num_cols()
730}
731
732fn add_capacity_variables(
733 problem: &mut Problem,
734 variables: &mut CapacityVariableMap,
735 assets: &[AssetRef],
736 capacity_limits: Option<&HashMap<AssetRef, AssetCapacity>>,
737 capacity_margin: f64,
738) -> Range<usize> {
739 let start = problem.num_cols();
741
742 for asset in assets {
743 assert!(
745 matches!(asset.state(), AssetState::Selected { .. }),
746 "Flexible capacity can only be assigned to `Selected` type assets. Offending asset: {asset:?}"
747 );
748
749 let current_capacity = asset.capacity();
750 let coeff = calculate_capacity_coefficient(asset);
751
752 let capacity_limit = capacity_limits.and_then(|limits| limits.get(asset));
754
755 if let Some(limit) = capacity_limit {
757 assert!(
758 matches!(
759 (current_capacity, limit),
760 (AssetCapacity::Continuous(_), AssetCapacity::Continuous(_))
761 | (AssetCapacity::Discrete(_, _), AssetCapacity::Discrete(_, _))
762 ),
763 "Incompatible capacity types for asset capacity limit"
764 );
765 }
766
767 let var = match current_capacity {
771 AssetCapacity::Continuous(cap) => {
772 let lower = ((1.0 - capacity_margin) * cap.value()).max(0.0);
774 let mut upper = (1.0 + capacity_margin) * cap.value();
775 if let Some(limit) = capacity_limit {
776 upper = upper.min(limit.total_capacity().value());
777 }
778 problem.add_column(coeff.value(), lower..=upper)
779 }
780 AssetCapacity::Discrete(units, unit_size) => {
781 let lower = ((1.0 - capacity_margin) * units as f64).max(0.0);
783 let mut upper = (1.0 + capacity_margin) * units as f64;
784 if let Some(limit) = capacity_limit {
785 upper = upper.min(limit.n_units().unwrap() as f64);
786 }
787 problem.add_integer_column((coeff * unit_size).value(), lower..=upper)
788 }
789 };
790
791 let existing = variables.insert(asset.clone(), var).is_some();
792 assert!(!existing, "Duplicate entry for var");
793 }
794
795 start..problem.num_cols()
796}
797
798fn calculate_activity_coefficient(
815 asset: &Asset,
816 year: u32,
817 time_slice: &TimeSliceID,
818 input_prices: Option<&CommodityPrices>,
819) -> MoneyPerActivity {
820 let opex = asset.get_operating_cost(year, time_slice);
821 if let Some(prices) = input_prices {
822 opex + asset.get_input_cost_from_prices(prices, time_slice)
823 } else {
824 opex
825 }
826}
827
828fn calculate_capacity_coefficient(asset: &AssetRef) -> MoneyPerCapacity {
832 let param = asset.process_parameter();
833 let annual_fixed_operating_cost = param.fixed_operating_cost * Year(1.0);
834 annual_fixed_operating_cost
835 + annual_capital_cost(param.capital_cost, param.lifetime, param.discount_rate)
836}