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::PriceMap;
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::collections::HashMap;
22use std::error::Error;
23use std::ops::Range;
24
25mod constraints;
26use constraints::{ConstraintKeys, add_model_constraints};
27
28pub type FlowMap = IndexMap<(AssetRef, CommodityID, TimeSliceID), Flow>;
30
31type Variable = highs::Col;
36
37type ActivityVariableMap = IndexMap<(AssetRef, TimeSliceID), Variable>;
39
40type CapacityVariableMap = IndexMap<AssetRef, Variable>;
42
43type UnmetDemandVariableMap = IndexMap<(CommodityID, RegionID, TimeSliceID), Variable>;
45
46pub struct VariableMap {
56 activity_vars: ActivityVariableMap,
57 existing_asset_var_idx: Range<usize>,
58 candidate_asset_var_idx: Range<usize>,
59 capacity_vars: CapacityVariableMap,
60 capacity_var_idx: Range<usize>,
61 unmet_demand_vars: UnmetDemandVariableMap,
62 unmet_demand_var_idx: Range<usize>,
63}
64
65impl VariableMap {
66 fn new_with_activity_vars(
77 problem: &mut Problem,
78 model: &Model,
79 input_prices: Option<&PriceMap>,
80 existing_assets: &[AssetRef],
81 candidate_assets: &[AssetRef],
82 year: u32,
83 ) -> Self {
84 let mut activity_vars = ActivityVariableMap::new();
85 let existing_asset_var_idx = add_activity_variables(
86 problem,
87 &mut activity_vars,
88 &model.time_slice_info,
89 input_prices,
90 existing_assets,
91 year,
92 );
93 let candidate_asset_var_idx = add_activity_variables(
94 problem,
95 &mut activity_vars,
96 &model.time_slice_info,
97 input_prices,
98 candidate_assets,
99 year,
100 );
101
102 Self {
103 activity_vars,
104 existing_asset_var_idx,
105 candidate_asset_var_idx,
106 capacity_vars: CapacityVariableMap::new(),
107 capacity_var_idx: Range::default(),
108 unmet_demand_vars: UnmetDemandVariableMap::default(),
109 unmet_demand_var_idx: Range::default(),
110 }
111 }
112
113 fn add_unmet_demand_variables(
121 &mut self,
122 problem: &mut Problem,
123 model: &Model,
124 markets_to_allow_unmet_demand: &[(CommodityID, RegionID)],
125 ) {
126 assert!(!markets_to_allow_unmet_demand.is_empty());
127
128 let start = problem.num_cols();
130
131 let voll = model.parameters.value_of_lost_load;
133 self.unmet_demand_vars.extend(
134 iproduct!(
135 markets_to_allow_unmet_demand.iter(),
136 model.time_slice_info.iter_ids()
137 )
138 .map(|((commodity_id, region_id), time_slice)| {
139 let key = (commodity_id.clone(), region_id.clone(), time_slice.clone());
140 let var = problem.add_column(voll.value(), 0.0..);
141 (key, var)
142 }),
143 );
144
145 self.unmet_demand_var_idx = start..problem.num_cols();
146 }
147
148 fn get_activity_var(&self, asset: &AssetRef, time_slice: &TimeSliceID) -> Variable {
150 let key = (asset.clone(), time_slice.clone());
151
152 *self
153 .activity_vars
154 .get(&key)
155 .expect("No asset variable found for given params")
156 }
157
158 fn get_unmet_demand_var(
160 &self,
161 commodity_id: &CommodityID,
162 region_id: &RegionID,
163 time_slice: &TimeSliceID,
164 ) -> Variable {
165 *self
166 .unmet_demand_vars
167 .get(&(commodity_id.clone(), region_id.clone(), time_slice.clone()))
168 .expect("No unmet demand variable for given params")
169 }
170
171 fn activity_var_keys(&self) -> indexmap::map::Keys<'_, (AssetRef, TimeSliceID), Variable> {
173 self.activity_vars.keys()
174 }
175
176 fn iter_capacity_vars(&self) -> impl Iterator<Item = (&AssetRef, Variable)> {
178 self.capacity_vars.iter().map(|(asset, var)| (asset, *var))
179 }
180}
181
182#[allow(clippy::struct_field_names)]
184pub struct Solution<'a> {
185 solution: highs::Solution,
186 variables: VariableMap,
187 time_slice_info: &'a TimeSliceInfo,
188 constraint_keys: ConstraintKeys,
189 pub objective_value: Money,
191}
192
193impl Solution<'_> {
194 pub fn create_flow_map(&self) -> FlowMap {
196 let mut flows = FlowMap::new();
199 for (asset, time_slice, activity) in self.iter_activity_for_existing() {
200 for flow in asset.iter_flows() {
201 let flow_key = (asset.clone(), flow.commodity.id.clone(), time_slice.clone());
202 let flow_value = activity * flow.coeff;
203 flows.insert(flow_key, flow_value);
204 }
205 }
206
207 flows
208 }
209
210 pub fn iter_activity(&self) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, Activity)> {
212 self.variables
213 .activity_var_keys()
214 .zip(self.solution.columns())
215 .map(|((asset, time_slice), activity)| (asset, time_slice, Activity(*activity)))
216 }
217
218 pub fn iter_activity_for_existing(
220 &self,
221 ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, Activity)> {
222 let cols = &self.solution.columns()[self.variables.existing_asset_var_idx.clone()];
223 self.variables
224 .activity_var_keys()
225 .skip(self.variables.existing_asset_var_idx.start)
226 .zip(cols.iter())
227 .map(|((asset, time_slice), &value)| (asset, time_slice, Activity(value)))
228 }
229
230 pub fn iter_activity_for_candidates(
232 &self,
233 ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, Activity)> {
234 let cols = &self.solution.columns()[self.variables.candidate_asset_var_idx.clone()];
235 self.variables
236 .activity_var_keys()
237 .skip(self.variables.candidate_asset_var_idx.start)
238 .zip(cols.iter())
239 .map(|((asset, time_slice), &value)| (asset, time_slice, Activity(value)))
240 }
241
242 pub fn iter_activity_keys_for_candidates(
244 &self,
245 ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID)> {
246 self.iter_activity_for_candidates()
247 .map(|(asset, time_slice, _activity)| (asset, time_slice))
248 }
249
250 pub fn iter_unmet_demand(
252 &self,
253 ) -> impl Iterator<Item = (&CommodityID, &RegionID, &TimeSliceID, Flow)> {
254 self.variables
255 .unmet_demand_vars
256 .keys()
257 .zip(self.solution.columns()[self.variables.unmet_demand_var_idx.clone()].iter())
258 .map(|((commodity_id, region_id, time_slice), flow)| {
259 (commodity_id, region_id, time_slice, Flow(*flow))
260 })
261 }
262
263 pub fn iter_capacity(&self) -> impl Iterator<Item = (&AssetRef, AssetCapacity)> {
268 self.variables
269 .capacity_vars
270 .keys()
271 .zip(self.solution.columns()[self.variables.capacity_var_idx.clone()].iter())
272 .map(|(asset, capacity_var)| {
273 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
276 let asset_capacity = if let Some(unit_size) = asset.unit_size() {
277 AssetCapacity::Discrete(capacity_var.round() as u32, unit_size)
278 } else {
279 AssetCapacity::Continuous(Capacity(*capacity_var))
280 };
281 (asset, asset_capacity)
282 })
283 }
284
285 pub fn iter_commodity_balance_duals(
287 &self,
288 ) -> impl Iterator<Item = (&CommodityID, &RegionID, &TimeSliceID, MoneyPerFlow)> {
289 self.constraint_keys
293 .commodity_balance_keys
294 .zip_duals(self.solution.dual_rows())
295 .flat_map(|((commodity_id, region_id, ts_selection), price)| {
296 ts_selection
297 .iter(self.time_slice_info)
298 .map(move |(ts, _)| (commodity_id, region_id, ts, price))
299 })
300 }
301
302 pub fn iter_activity_duals(
311 &self,
312 ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, MoneyPerActivity)> {
313 self.constraint_keys
314 .activity_keys
315 .zip_duals(self.solution.dual_rows())
316 .filter(|&((_asset, ts_selection), _dual)| {
317 matches!(ts_selection, TimeSliceSelection::Single(_))
318 })
319 .map(|((asset, ts_selection), dual)| {
320 let (time_slice, _) = ts_selection.iter(self.time_slice_info).next().unwrap();
322 (asset, time_slice, dual)
323 })
324 }
325
326 pub fn iter_column_duals(
328 &self,
329 ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, MoneyPerActivity)> {
330 self.variables
331 .activity_var_keys()
332 .zip(self.solution.dual_columns())
333 .map(|((asset, time_slice), dual)| (asset, time_slice, MoneyPerActivity(*dual)))
334 }
335}
336
337#[derive(Debug, derive_more::Display, derive_more::From)]
339pub enum ModelError {
340 #[display("Could not find optimal result: {_0:?}")]
342 NonOptimal(HighsModelStatus),
343 #[display("{_0}")]
345 Other(anyhow::Error),
346}
347
348impl ModelError {
349 pub fn into_anyhow(self) -> anyhow::Error {
351 match self {
352 ModelError::NonOptimal(status) => anyhow!("Could not find optimal result: {status:?}"),
353 ModelError::Other(error) => error,
354 }
355 }
356}
357
358impl Error for ModelError {
359 fn source(&self) -> Option<&(dyn Error + 'static)> {
360 match self {
361 ModelError::NonOptimal(_) => None,
362 ModelError::Other(error) => Some(error.as_ref()),
363 }
364 }
365}
366
367pub fn apply_highs_options_from_toml(
369 model: &mut highs::Model,
370 options: &toml::Table,
371) -> Result<()> {
372 macro_rules! try_set_opt {
374 ($option:expr, $value:expr) => {{
375 model
376 .try_set_option($option.as_str(), $value)
377 .map_err(|_| anyhow!("Invalid option name or value"))?;
378
379 Ok(())
380 }};
381 }
382
383 for (option, value) in options {
385 match value {
386 toml::Value::String(value) => try_set_opt!(option, value.as_str()),
387 toml::Value::Integer(value) => match i32::try_from(*value) {
388 Ok(value) => try_set_opt!(option, value),
389 Err(_) => Err(anyhow!("Value out of range")),
390 },
391 toml::Value::Float(value) => try_set_opt!(option, *value),
392 toml::Value::Boolean(value) => try_set_opt!(option, *value),
393 _ => Err(anyhow!("HiGHS options cannot have this type")),
394 }
395 .with_context(|| format!("Failed to set option \"{option}\" to value \"{value}\""))?;
396 }
397
398 Ok(())
399}
400
401pub fn solve_optimal(model: highs::Model) -> Result<highs::SolvedModel, ModelError> {
403 let solved = model
404 .try_solve()
405 .map_err(|status| anyhow!("Incoherent model: {status:?}"))?;
406
407 match solved.status() {
408 HighsModelStatus::Optimal => Ok(solved),
409 status => Err(status.into()),
410 }
411}
412
413fn filter_input_prices(
418 input_prices: &PriceMap,
419 markets_to_balance: &[(CommodityID, RegionID)],
420) -> PriceMap {
421 input_prices
422 .iter()
423 .filter(|(commodity_id, region_id, _, _)| {
424 !markets_to_balance
425 .iter()
426 .any(|(c, r)| c == *commodity_id && r == *region_id)
427 })
428 .collect()
429}
430
431#[doc = concat!("[1]: ", crate::docs_url!("/model/dispatch_optimisation.html"))]
440#[must_use = "Must call run() method on DispatchRun struct"]
441pub struct DispatchRun<'model, 'run> {
442 model: &'model Model,
443 existing_assets: &'run [AssetRef],
444 flexible_capacity_assets: &'run [AssetRef],
445 capacity_limits: Option<&'run HashMap<AssetRef, AssetCapacity>>,
446 candidate_assets: &'run [AssetRef],
447 markets_to_balance: &'run [(CommodityID, RegionID)],
448 input_prices: Option<&'run PriceMap>,
449 year: u32,
450 capacity_margin: Dimensionless,
451}
452
453impl<'model, 'run> DispatchRun<'model, 'run> {
454 pub fn new(model: &'model Model, assets: &'run [AssetRef], year: u32) -> Self {
456 Self {
457 model,
458 existing_assets: assets,
459 flexible_capacity_assets: &[],
460 capacity_limits: None,
461 candidate_assets: &[],
462 markets_to_balance: &[],
463 input_prices: None,
464 year,
465 capacity_margin: Dimensionless(0.0),
466 }
467 }
468
469 pub fn with_flexible_capacity_assets(
471 self,
472 flexible_capacity_assets: &'run [AssetRef],
473 capacity_limits: Option<&'run HashMap<AssetRef, AssetCapacity>>,
474 capacity_margin: Dimensionless,
475 ) -> Self {
476 Self {
477 flexible_capacity_assets,
478 capacity_limits,
479 capacity_margin,
480 ..self
481 }
482 }
483
484 pub fn with_candidates(self, candidate_assets: &'run [AssetRef]) -> Self {
486 Self {
487 candidate_assets,
488 ..self
489 }
490 }
491
492 pub fn with_market_balance_subset(
494 self,
495 markets_to_balance: &'run [(CommodityID, RegionID)],
496 ) -> Self {
497 assert!(!markets_to_balance.is_empty());
498
499 Self {
500 markets_to_balance,
501 ..self
502 }
503 }
504
505 pub fn with_input_prices(self, input_prices: &'run PriceMap) -> Self {
507 Self {
508 input_prices: Some(input_prices),
509 ..self
510 }
511 }
512
513 pub fn run(&self, run_description: &str, writer: &mut DataWriter) -> Result<Solution<'model>> {
525 let all_markets: Vec<_>;
527 let markets_to_balance = if self.markets_to_balance.is_empty() {
528 all_markets = self.model.iter_markets().collect();
529 &all_markets
530 } else {
531 self.markets_to_balance
532 };
533
534 let input_prices_owned = self
536 .input_prices
537 .map(|prices| filter_input_prices(prices, markets_to_balance));
538 let input_prices = input_prices_owned.as_ref();
539
540 match self.run_without_unmet_demand_variables(markets_to_balance, input_prices) {
544 Ok(solution) => {
545 writer.write_dispatch_debug_info(self.year, run_description, &solution)?;
547 Ok(solution)
548 }
549 Err(ModelError::NonOptimal(HighsModelStatus::Infeasible)) => {
550 let solution = self
553 .run_internal(
554 markets_to_balance,
555 true,
556 input_prices,
557 )
558 .expect("Failed to run dispatch to calculate unmet demand");
559
560 writer.write_dispatch_debug_info(self.year, run_description, &solution)?;
562
563 let markets: IndexSet<_> = solution
565 .iter_unmet_demand()
566 .filter(|(_, _, _, flow)| *flow > Flow(0.0))
567 .map(|(commodity_id, region_id, _, _)| {
568 (commodity_id.clone(), region_id.clone())
569 })
570 .collect();
571
572 ensure!(
573 !markets.is_empty(),
574 "Model is infeasible, but there was no unmet demand"
575 );
576
577 bail!(
578 "The solver has indicated that the problem is infeasible, probably because \
579 the supplied assets could not meet the required demand. Demand was not met \
580 for the following markets: {}",
581 format_items_with_cap(markets)
582 );
583 }
584 Err(err) => Err(err.into_anyhow()),
585 }
586 }
587
588 fn run_without_unmet_demand_variables(
590 &self,
591 markets_to_balance: &[(CommodityID, RegionID)],
592 input_prices: Option<&PriceMap>,
593 ) -> Result<Solution<'model>, ModelError> {
594 self.run_internal(
595 markets_to_balance,
596 false,
597 input_prices,
598 )
599 }
600
601 fn run_internal(
603 &self,
604 markets_to_balance: &[(CommodityID, RegionID)],
605 allow_unmet_demand: bool,
606 input_prices: Option<&PriceMap>,
607 ) -> Result<Solution<'model>, ModelError> {
608 let mut problem = Problem::default();
610 let mut variables = VariableMap::new_with_activity_vars(
611 &mut problem,
612 self.model,
613 input_prices,
614 self.existing_assets,
615 self.candidate_assets,
616 self.year,
617 );
618
619 if allow_unmet_demand {
622 variables.add_unmet_demand_variables(&mut problem, self.model, markets_to_balance);
623 }
624
625 for asset in self.flexible_capacity_assets {
627 assert!(
628 self.existing_assets.contains(asset),
629 "Flexible capacity assets must be a subset of existing assets. Offending asset: {asset:?}"
630 );
631 }
632
633 if !self.flexible_capacity_assets.is_empty() {
635 variables.capacity_var_idx = add_capacity_variables(
636 &mut problem,
637 &mut variables.capacity_vars,
638 self.flexible_capacity_assets,
639 self.capacity_limits,
640 self.capacity_margin,
641 );
642 }
643
644 let all_assets = chain(self.existing_assets.iter(), self.candidate_assets.iter());
646 let constraint_keys = add_model_constraints(
647 &mut problem,
648 &variables,
649 self.model,
650 &all_assets,
651 markets_to_balance,
652 self.year,
653 self.candidate_assets,
654 );
655
656 let mut model = problem.optimise(Sense::Minimise);
658 apply_highs_options_from_toml(&mut model, &self.model.parameters.highs.dispatch_options)
659 .context("Failed to apply custom HiGHS options to dispatch optimisation")?;
660 let solution = solve_optimal(model)?;
661
662 Ok(Solution {
663 solution: solution.get_solution(),
664 variables,
665 time_slice_info: &self.model.time_slice_info,
666 constraint_keys,
667 objective_value: Money(solution.objective_value()),
668 })
669 }
670}
671
672fn add_activity_variables(
683 problem: &mut Problem,
684 variables: &mut ActivityVariableMap,
685 time_slice_info: &TimeSliceInfo,
686 input_prices: Option<&PriceMap>,
687 assets: &[AssetRef],
688 year: u32,
689) -> Range<usize> {
690 let start = problem.num_cols();
692
693 for (asset, time_slice) in iproduct!(assets.iter(), time_slice_info.iter_ids()) {
694 let coeff = calculate_activity_coefficient(asset, year, time_slice, input_prices);
695 let var = problem.add_column(coeff.value(), 0.0..);
696 let key = (asset.clone(), time_slice.clone());
697 let existing = variables.insert(key, var).is_some();
698 assert!(!existing, "Duplicate entry for var");
699 }
700
701 start..problem.num_cols()
702}
703
704fn add_capacity_variables(
705 problem: &mut Problem,
706 variables: &mut CapacityVariableMap,
707 assets: &[AssetRef],
708 capacity_limits: Option<&HashMap<AssetRef, AssetCapacity>>,
709 capacity_margin: Dimensionless,
710) -> Range<usize> {
711 let capacity_margin = capacity_margin.value();
712
713 let start = problem.num_cols();
715
716 for asset in assets {
717 assert!(
719 matches!(asset.state(), AssetState::Ready { .. }),
720 "Flexible capacity can only be assigned to `Ready` type assets. Offending asset: {asset:?}"
721 );
722
723 let current_capacity = asset.capacity();
724 let coeff = calculate_capacity_coefficient(asset);
725
726 let capacity_limit = capacity_limits.and_then(|limits| limits.get(asset));
728
729 if let Some(limit) = capacity_limit {
731 assert!(
732 matches!(
733 (current_capacity, limit),
734 (AssetCapacity::Continuous(_), AssetCapacity::Continuous(_))
735 | (AssetCapacity::Discrete(_, _), AssetCapacity::Discrete(_, _))
736 ),
737 "Incompatible capacity types for asset capacity limit"
738 );
739 }
740
741 let var = match current_capacity {
745 AssetCapacity::Continuous(cap) => {
746 let lower = ((1.0 - capacity_margin) * cap.value()).max(0.0);
748 let mut upper = (1.0 + capacity_margin) * cap.value();
749 if let Some(limit) = capacity_limit {
750 upper = upper.min(limit.total_capacity().value());
751 }
752 problem.add_column(coeff.value(), lower..=upper)
753 }
754 AssetCapacity::Discrete(units, unit_size) => {
755 let lower = ((1.0 - capacity_margin) * units as f64).max(0.0);
757 let mut upper = (1.0 + capacity_margin) * units as f64;
758 if let Some(limit) = capacity_limit {
759 upper = upper.min(limit.n_units().unwrap() as f64);
760 }
761 problem.add_integer_column((coeff * unit_size).value(), lower..=upper)
762 }
763 };
764
765 let existing = variables.insert(asset.clone(), var).is_some();
766 assert!(!existing, "Duplicate entry for var");
767 }
768
769 start..problem.num_cols()
770}
771
772fn calculate_activity_coefficient(
789 asset: &Asset,
790 year: u32,
791 time_slice: &TimeSliceID,
792 input_prices: Option<&PriceMap>,
793) -> MoneyPerActivity {
794 let opex = asset.get_operating_cost(year, time_slice);
795 if let Some(prices) = input_prices {
796 opex + asset.get_input_cost_from_prices(prices, time_slice)
797 } else {
798 opex
799 }
800}
801
802fn calculate_capacity_coefficient(asset: &AssetRef) -> MoneyPerCapacity {
806 let param = asset.process_parameter();
807 let annual_fixed_operating_cost = param.fixed_operating_cost * Year(1.0);
808 annual_fixed_operating_cost
809 + annual_capital_cost(param.capital_cost, param.lifetime, param.discount_rate)
810}