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::{Context, Result, anyhow, bail, ensure};
18use highs::{HighsModelStatus, 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 for flow in asset.iter_flows() {
195 let flow_key = (asset.clone(), flow.commodity.id.clone(), time_slice.clone());
196 let flow_value = activity * flow.coeff;
197 flows.insert(flow_key, flow_value);
198 }
199 }
200
201 for asset in existing_assets {
203 if let Some(parent) = asset.parent() {
204 let n_units = Dimensionless(parent.num_children().unwrap() as f64);
205 for time_slice in time_slice_info.iter_ids() {
206 for commodity_id in asset.iter_flows().map(|flow| &flow.commodity.id) {
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 / n_units,
211 );
212 }
213 }
214 }
215 }
216
217 flows
218}
219
220#[allow(clippy::struct_field_names)]
222pub struct Solution<'a> {
223 solution: highs::Solution,
224 variables: VariableMap,
225 time_slice_info: &'a TimeSliceInfo,
226 constraint_keys: ConstraintKeys,
227 flow_map: Cell<Option<FlowMap>>,
228 pub objective_value: Money,
230}
231
232impl Solution<'_> {
233 pub fn create_flow_map(&self) -> FlowMap {
240 self.flow_map.take().expect("Flow map already created")
241 }
242
243 pub fn iter_activity(&self) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, Activity)> {
245 self.variables
246 .activity_var_keys()
247 .zip(self.solution.columns())
248 .map(|((asset, time_slice), activity)| (asset, time_slice, Activity(*activity)))
249 }
250
251 pub fn iter_activity_for_existing(
253 &self,
254 ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, Activity)> {
255 let cols = &self.solution.columns()[self.variables.existing_asset_var_idx.clone()];
256 self.variables
257 .activity_var_keys()
258 .skip(self.variables.existing_asset_var_idx.start)
259 .zip(cols.iter())
260 .map(|((asset, time_slice), &value)| (asset, time_slice, Activity(value)))
261 }
262
263 pub fn iter_activity_for_candidates(
265 &self,
266 ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, Activity)> {
267 let cols = &self.solution.columns()[self.variables.candidate_asset_var_idx.clone()];
268 self.variables
269 .activity_var_keys()
270 .skip(self.variables.candidate_asset_var_idx.start)
271 .zip(cols.iter())
272 .map(|((asset, time_slice), &value)| (asset, time_slice, Activity(value)))
273 }
274
275 pub fn iter_activity_keys_for_candidates(
277 &self,
278 ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID)> {
279 self.iter_activity_for_candidates()
280 .map(|(asset, time_slice, _activity)| (asset, time_slice))
281 }
282
283 pub fn iter_unmet_demand(
285 &self,
286 ) -> impl Iterator<Item = (&CommodityID, &RegionID, &TimeSliceID, Flow)> {
287 self.variables
288 .unmet_demand_vars
289 .keys()
290 .zip(self.solution.columns()[self.variables.unmet_demand_var_idx.clone()].iter())
291 .map(|((commodity_id, region_id, time_slice), flow)| {
292 (commodity_id, region_id, time_slice, Flow(*flow))
293 })
294 }
295
296 pub fn iter_capacity(&self) -> impl Iterator<Item = (&AssetRef, AssetCapacity)> {
301 self.variables
302 .capacity_vars
303 .keys()
304 .zip(self.solution.columns()[self.variables.capacity_var_idx.clone()].iter())
305 .map(|(asset, capacity_var)| {
306 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
309 let asset_capacity = if let Some(unit_size) = asset.unit_size() {
310 AssetCapacity::Discrete(capacity_var.round() as u32, unit_size)
311 } else {
312 AssetCapacity::Continuous(Capacity(*capacity_var))
313 };
314 (asset, asset_capacity)
315 })
316 }
317
318 pub fn iter_commodity_balance_duals(
320 &self,
321 ) -> impl Iterator<Item = (&CommodityID, &RegionID, &TimeSliceID, MoneyPerFlow)> {
322 self.constraint_keys
326 .commodity_balance_keys
327 .zip_duals(self.solution.dual_rows())
328 .flat_map(|((commodity_id, region_id, ts_selection), price)| {
329 ts_selection
330 .iter(self.time_slice_info)
331 .map(move |(ts, _)| (commodity_id, region_id, ts, price))
332 })
333 }
334
335 pub fn iter_activity_duals(
344 &self,
345 ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, MoneyPerActivity)> {
346 self.constraint_keys
347 .activity_keys
348 .zip_duals(self.solution.dual_rows())
349 .filter(|&((_asset, ts_selection), _dual)| {
350 matches!(ts_selection, TimeSliceSelection::Single(_))
351 })
352 .map(|((asset, ts_selection), dual)| {
353 let (time_slice, _) = ts_selection.iter(self.time_slice_info).next().unwrap();
355 (asset, time_slice, dual)
356 })
357 }
358
359 pub fn iter_column_duals(
361 &self,
362 ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, MoneyPerActivity)> {
363 self.variables
364 .activity_var_keys()
365 .zip(self.solution.dual_columns())
366 .map(|((asset, time_slice), dual)| (asset, time_slice, MoneyPerActivity(*dual)))
367 }
368}
369
370#[derive(Debug)]
372pub enum ModelError {
373 NonOptimal(HighsModelStatus),
375 Other(anyhow::Error),
377}
378
379impl From<anyhow::Error> for ModelError {
380 fn from(value: anyhow::Error) -> Self {
381 Self::Other(value)
382 }
383}
384
385impl ModelError {
386 pub fn into_anyhow(self) -> anyhow::Error {
388 match self {
389 ModelError::NonOptimal(status) => anyhow!("Could not find optimal result: {status:?}"),
390 ModelError::Other(error) => error,
391 }
392 }
393}
394
395impl fmt::Display for ModelError {
396 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
397 match self {
398 ModelError::NonOptimal(status) => {
399 write!(f, "Could not find optimal result: {status:?}")
400 }
401 ModelError::Other(error) => error.fmt(f),
402 }
403 }
404}
405
406impl Error for ModelError {
407 fn source(&self) -> Option<&(dyn Error + 'static)> {
408 match self {
409 ModelError::NonOptimal(_) => None,
410 ModelError::Other(error) => Some(error.as_ref()),
411 }
412 }
413}
414
415pub fn apply_highs_options_from_toml(
417 model: &mut highs::Model,
418 options: &toml::Table,
419) -> Result<()> {
420 macro_rules! try_set_opt {
422 ($option:expr, $value:expr) => {{
423 model
424 .try_set_option($option.as_str(), $value)
425 .map_err(|_| anyhow!("Invalid option name or value"))?;
426
427 Ok(())
428 }};
429 }
430
431 for (option, value) in options {
433 match value {
434 toml::Value::String(value) => try_set_opt!(option, value.as_str()),
435 toml::Value::Integer(value) => match i32::try_from(*value) {
436 Ok(value) => try_set_opt!(option, value),
437 Err(_) => Err(anyhow!("Value out of range")),
438 },
439 toml::Value::Float(value) => try_set_opt!(option, *value),
440 toml::Value::Boolean(value) => try_set_opt!(option, *value),
441 _ => Err(anyhow!("HiGHS options cannot have this type")),
442 }
443 .with_context(|| format!("Failed to set option \"{option}\" to value \"{value}\""))?;
444 }
445
446 Ok(())
447}
448
449pub fn solve_optimal(model: highs::Model) -> Result<highs::SolvedModel, ModelError> {
451 let solved = model
452 .try_solve()
453 .map_err(|status| anyhow!("Incoherent model: {status:?}"))?;
454
455 match solved.status() {
456 HighsModelStatus::Optimal => Ok(solved),
457 status => Err(ModelError::NonOptimal(status)),
458 }
459}
460
461fn filter_input_prices(
466 input_prices: &CommodityPrices,
467 markets_to_balance: &[(CommodityID, RegionID)],
468) -> CommodityPrices {
469 input_prices
470 .iter()
471 .filter(|(commodity_id, region_id, _, _)| {
472 !markets_to_balance
473 .iter()
474 .any(|(c, r)| c == *commodity_id && r == *region_id)
475 })
476 .collect()
477}
478
479fn get_parent_or_self(assets: &[AssetRef]) -> Vec<AssetRef> {
488 let mut child_counts: IndexMap<&AssetRef, u32> = IndexMap::new();
489 let mut out = Vec::new();
490
491 for asset in assets {
492 if let Some(parent) = asset.parent() {
493 *child_counts.entry(parent).or_default() += 1;
495 } else {
496 out.push(asset.clone());
498 }
499 }
500
501 for (parent, child_count) in child_counts {
502 out.push(parent.make_partial_parent(child_count));
504 }
505
506 out
507}
508
509#[must_use = "Must call run() method on DispatchRun struct"]
519pub struct DispatchRun<'model, 'run> {
520 model: &'model Model,
521 existing_assets: &'run [AssetRef],
522 flexible_capacity_assets: &'run [AssetRef],
523 capacity_limits: Option<&'run HashMap<AssetRef, AssetCapacity>>,
524 candidate_assets: &'run [AssetRef],
525 markets_to_balance: &'run [(CommodityID, RegionID)],
526 input_prices: Option<&'run CommodityPrices>,
527 year: u32,
528 capacity_margin: Dimensionless,
529}
530
531impl<'model, 'run> DispatchRun<'model, 'run> {
532 pub fn new(model: &'model Model, assets: &'run [AssetRef], year: u32) -> Self {
534 Self {
535 model,
536 existing_assets: assets,
537 flexible_capacity_assets: &[],
538 capacity_limits: None,
539 candidate_assets: &[],
540 markets_to_balance: &[],
541 input_prices: None,
542 year,
543 capacity_margin: Dimensionless(0.0),
544 }
545 }
546
547 pub fn with_flexible_capacity_assets(
549 self,
550 flexible_capacity_assets: &'run [AssetRef],
551 capacity_limits: Option<&'run HashMap<AssetRef, AssetCapacity>>,
552 capacity_margin: Dimensionless,
553 ) -> Self {
554 Self {
555 flexible_capacity_assets,
556 capacity_limits,
557 capacity_margin,
558 ..self
559 }
560 }
561
562 pub fn with_candidates(self, candidate_assets: &'run [AssetRef]) -> Self {
564 Self {
565 candidate_assets,
566 ..self
567 }
568 }
569
570 pub fn with_market_balance_subset(
572 self,
573 markets_to_balance: &'run [(CommodityID, RegionID)],
574 ) -> Self {
575 assert!(!markets_to_balance.is_empty());
576
577 Self {
578 markets_to_balance,
579 ..self
580 }
581 }
582
583 pub fn with_input_prices(self, input_prices: &'run CommodityPrices) -> Self {
585 Self {
586 input_prices: Some(input_prices),
587 ..self
588 }
589 }
590
591 pub fn run(&self, run_description: &str, writer: &mut DataWriter) -> Result<Solution<'model>> {
603 let all_markets: Vec<_>;
605 let markets_to_balance = if self.markets_to_balance.is_empty() {
606 all_markets = self.model.iter_markets().collect();
607 &all_markets
608 } else {
609 self.markets_to_balance
610 };
611
612 let input_prices_owned = self
614 .input_prices
615 .map(|prices| filter_input_prices(prices, markets_to_balance));
616 let input_prices = input_prices_owned.as_ref();
617
618 match self.run_without_unmet_demand_variables(markets_to_balance, input_prices) {
622 Ok(solution) => {
623 writer.write_dispatch_debug_info(self.year, run_description, &solution)?;
625 Ok(solution)
626 }
627 Err(ModelError::NonOptimal(HighsModelStatus::Infeasible)) => {
628 let solution = self
631 .run_internal(
632 markets_to_balance,
633 true,
634 input_prices,
635 )
636 .expect("Failed to run dispatch to calculate unmet demand");
637
638 writer.write_dispatch_debug_info(self.year, run_description, &solution)?;
640
641 let markets: IndexSet<_> = solution
643 .iter_unmet_demand()
644 .filter(|(_, _, _, flow)| *flow > Flow(0.0))
645 .map(|(commodity_id, region_id, _, _)| {
646 (commodity_id.clone(), region_id.clone())
647 })
648 .collect();
649
650 ensure!(
651 !markets.is_empty(),
652 "Model is infeasible, but there was no unmet demand"
653 );
654
655 bail!(
656 "The solver has indicated that the problem is infeasible, probably because \
657 the supplied assets could not meet the required demand. Demand was not met \
658 for the following markets: {}",
659 format_items_with_cap(markets)
660 );
661 }
662 Err(err) => Err(err.into_anyhow()),
663 }
664 }
665
666 fn run_without_unmet_demand_variables(
668 &self,
669 markets_to_balance: &[(CommodityID, RegionID)],
670 input_prices: Option<&CommodityPrices>,
671 ) -> Result<Solution<'model>, ModelError> {
672 self.run_internal(
673 markets_to_balance,
674 false,
675 input_prices,
676 )
677 }
678
679 fn run_internal(
681 &self,
682 markets_to_balance: &[(CommodityID, RegionID)],
683 allow_unmet_demand: bool,
684 input_prices: Option<&CommodityPrices>,
685 ) -> Result<Solution<'model>, ModelError> {
686 let parent_assets = get_parent_or_self(self.existing_assets);
687
688 let mut problem = Problem::default();
690 let mut variables = VariableMap::new_with_activity_vars(
691 &mut problem,
692 self.model,
693 input_prices,
694 &parent_assets,
695 self.candidate_assets,
696 self.year,
697 );
698
699 if allow_unmet_demand {
702 variables.add_unmet_demand_variables(&mut problem, self.model, markets_to_balance);
703 }
704
705 for asset in self.flexible_capacity_assets {
707 assert!(
708 parent_assets.contains(asset),
709 "Flexible capacity assets must be a subset of existing assets. Offending asset: {asset:?}"
710 );
711 }
712
713 if !self.flexible_capacity_assets.is_empty() {
715 variables.capacity_var_idx = add_capacity_variables(
716 &mut problem,
717 &mut variables.capacity_vars,
718 self.flexible_capacity_assets,
719 self.capacity_limits,
720 self.capacity_margin,
721 );
722 }
723
724 let all_assets = chain(parent_assets.iter(), self.candidate_assets.iter());
726 let constraint_keys = add_model_constraints(
727 &mut problem,
728 &variables,
729 self.model,
730 &all_assets,
731 markets_to_balance,
732 self.year,
733 );
734
735 let mut model = problem.optimise(Sense::Minimise);
737 apply_highs_options_from_toml(&mut model, &self.model.parameters.highs.dispatch_options)
738 .context("Failed to apply custom HiGHS options to dispatch optimisation")?;
739 let solution = solve_optimal(model)?;
740
741 let solution = Solution {
742 solution: solution.get_solution(),
743 variables,
744 time_slice_info: &self.model.time_slice_info,
745 constraint_keys,
746 flow_map: Cell::default(),
747 objective_value: Money(solution.objective_value()),
748 };
749 solution.flow_map.set(Some(create_flow_map(
750 self.existing_assets,
751 &self.model.time_slice_info,
752 solution.iter_activity(),
753 )));
754 Ok(solution)
755 }
756}
757
758fn add_activity_variables(
769 problem: &mut Problem,
770 variables: &mut ActivityVariableMap,
771 time_slice_info: &TimeSliceInfo,
772 input_prices: Option<&CommodityPrices>,
773 assets: &[AssetRef],
774 year: u32,
775) -> Range<usize> {
776 let start = problem.num_cols();
778
779 for (asset, time_slice) in iproduct!(assets.iter(), time_slice_info.iter_ids()) {
780 let coeff = calculate_activity_coefficient(asset, year, time_slice, input_prices);
781 let var = problem.add_column(coeff.value(), 0.0..);
782 let key = (asset.clone(), time_slice.clone());
783 let existing = variables.insert(key, var).is_some();
784 assert!(!existing, "Duplicate entry for var");
785 }
786
787 start..problem.num_cols()
788}
789
790fn add_capacity_variables(
791 problem: &mut Problem,
792 variables: &mut CapacityVariableMap,
793 assets: &[AssetRef],
794 capacity_limits: Option<&HashMap<AssetRef, AssetCapacity>>,
795 capacity_margin: Dimensionless,
796) -> Range<usize> {
797 let capacity_margin = capacity_margin.value();
798
799 let start = problem.num_cols();
801
802 for asset in assets {
803 assert!(
805 matches!(asset.state(), AssetState::Selected { .. }),
806 "Flexible capacity can only be assigned to `Selected` type assets. Offending asset: {asset:?}"
807 );
808
809 let current_capacity = asset.capacity();
810 let coeff = calculate_capacity_coefficient(asset);
811
812 let capacity_limit = capacity_limits.and_then(|limits| limits.get(asset));
814
815 if let Some(limit) = capacity_limit {
817 assert!(
818 matches!(
819 (current_capacity, limit),
820 (AssetCapacity::Continuous(_), AssetCapacity::Continuous(_))
821 | (AssetCapacity::Discrete(_, _), AssetCapacity::Discrete(_, _))
822 ),
823 "Incompatible capacity types for asset capacity limit"
824 );
825 }
826
827 let var = match current_capacity {
831 AssetCapacity::Continuous(cap) => {
832 let lower = ((1.0 - capacity_margin) * cap.value()).max(0.0);
834 let mut upper = (1.0 + capacity_margin) * cap.value();
835 if let Some(limit) = capacity_limit {
836 upper = upper.min(limit.total_capacity().value());
837 }
838 problem.add_column(coeff.value(), lower..=upper)
839 }
840 AssetCapacity::Discrete(units, unit_size) => {
841 let lower = ((1.0 - capacity_margin) * units as f64).max(0.0);
843 let mut upper = (1.0 + capacity_margin) * units as f64;
844 if let Some(limit) = capacity_limit {
845 upper = upper.min(limit.n_units().unwrap() as f64);
846 }
847 problem.add_integer_column((coeff * unit_size).value(), lower..=upper)
848 }
849 };
850
851 let existing = variables.insert(asset.clone(), var).is_some();
852 assert!(!existing, "Duplicate entry for var");
853 }
854
855 start..problem.num_cols()
856}
857
858fn calculate_activity_coefficient(
875 asset: &Asset,
876 year: u32,
877 time_slice: &TimeSliceID,
878 input_prices: Option<&CommodityPrices>,
879) -> MoneyPerActivity {
880 let opex = asset.get_operating_cost(year, time_slice);
881 if let Some(prices) = input_prices {
882 opex + asset.get_input_cost_from_prices(prices, time_slice)
883 } else {
884 opex
885 }
886}
887
888fn calculate_capacity_coefficient(asset: &AssetRef) -> MoneyPerCapacity {
892 let param = asset.process_parameter();
893 let annual_fixed_operating_cost = param.fixed_operating_cost * Year(1.0);
894 annual_fixed_operating_cost
895 + annual_capital_cost(param.capital_cost, param.lifetime, param.discount_rate)
896}