use crate::asset::{Asset, AssetCapacity, AssetRef, AssetState};
use crate::commodity::CommodityID;
use crate::finance::annual_capital_cost;
use crate::input::format_items_with_cap;
use crate::model::Model;
use crate::output::DataWriter;
use crate::region::RegionID;
use crate::simulation::CommodityPrices;
use crate::time_slice::{TimeSliceID, TimeSliceInfo, TimeSliceSelection};
use crate::units::{
Activity, Capacity, Dimensionless, Flow, Money, MoneyPerActivity, MoneyPerCapacity,
MoneyPerFlow, Year,
};
use anyhow::{Result, bail, ensure};
use highs::{HighsModelStatus, HighsStatus, RowProblem as Problem, Sense};
use indexmap::{IndexMap, IndexSet};
use itertools::{chain, iproduct};
use std::cell::Cell;
use std::collections::HashMap;
use std::error::Error;
use std::fmt;
use std::ops::Range;
mod constraints;
use constraints::{ConstraintKeys, add_model_constraints};
pub type FlowMap = IndexMap<(AssetRef, CommodityID, TimeSliceID), Flow>;
type Variable = highs::Col;
type ActivityVariableMap = IndexMap<(AssetRef, TimeSliceID), Variable>;
type CapacityVariableMap = IndexMap<AssetRef, Variable>;
type UnmetDemandVariableMap = IndexMap<(CommodityID, RegionID, TimeSliceID), Variable>;
pub struct VariableMap {
activity_vars: ActivityVariableMap,
existing_asset_var_idx: Range<usize>,
candidate_asset_var_idx: Range<usize>,
capacity_vars: CapacityVariableMap,
capacity_var_idx: Range<usize>,
unmet_demand_vars: UnmetDemandVariableMap,
unmet_demand_var_idx: Range<usize>,
}
impl VariableMap {
fn new_with_activity_vars(
problem: &mut Problem,
model: &Model,
input_prices: Option<&CommodityPrices>,
existing_assets: &[AssetRef],
candidate_assets: &[AssetRef],
year: u32,
) -> Self {
let mut activity_vars = ActivityVariableMap::new();
let existing_asset_var_idx = add_activity_variables(
problem,
&mut activity_vars,
&model.time_slice_info,
input_prices,
existing_assets,
year,
);
let candidate_asset_var_idx = add_activity_variables(
problem,
&mut activity_vars,
&model.time_slice_info,
input_prices,
candidate_assets,
year,
);
Self {
activity_vars,
existing_asset_var_idx,
candidate_asset_var_idx,
capacity_vars: CapacityVariableMap::new(),
capacity_var_idx: Range::default(),
unmet_demand_vars: UnmetDemandVariableMap::default(),
unmet_demand_var_idx: Range::default(),
}
}
fn add_unmet_demand_variables(
&mut self,
problem: &mut Problem,
model: &Model,
markets_to_allow_unmet_demand: &[(CommodityID, RegionID)],
) {
assert!(!markets_to_allow_unmet_demand.is_empty());
let start = problem.num_cols();
let voll = model.parameters.value_of_lost_load;
self.unmet_demand_vars.extend(
iproduct!(
markets_to_allow_unmet_demand.iter(),
model.time_slice_info.iter_ids()
)
.map(|((commodity_id, region_id), time_slice)| {
let key = (commodity_id.clone(), region_id.clone(), time_slice.clone());
let var = problem.add_column(voll.value(), 0.0..);
(key, var)
}),
);
self.unmet_demand_var_idx = start..problem.num_cols();
}
fn get_activity_var(&self, asset: &AssetRef, time_slice: &TimeSliceID) -> Variable {
let key = (asset.clone(), time_slice.clone());
*self
.activity_vars
.get(&key)
.expect("No asset variable found for given params")
}
fn get_unmet_demand_var(
&self,
commodity_id: &CommodityID,
region_id: &RegionID,
time_slice: &TimeSliceID,
) -> Variable {
*self
.unmet_demand_vars
.get(&(commodity_id.clone(), region_id.clone(), time_slice.clone()))
.expect("No unmet demand variable for given params")
}
fn activity_var_keys(&self) -> indexmap::map::Keys<'_, (AssetRef, TimeSliceID), Variable> {
self.activity_vars.keys()
}
fn iter_capacity_vars(&self) -> impl Iterator<Item = (&AssetRef, Variable)> {
self.capacity_vars.iter().map(|(asset, var)| (asset, *var))
}
}
fn create_flow_map<'a>(
existing_assets: &[AssetRef],
time_slice_info: &TimeSliceInfo,
activity: impl IntoIterator<Item = (&'a AssetRef, &'a TimeSliceID, Activity)>,
) -> FlowMap {
let mut flows = FlowMap::new();
for (asset, time_slice, activity) in activity {
let n_units = Dimensionless(asset.num_children().unwrap_or(1) as f64);
for flow in asset.iter_flows() {
let flow_key = (asset.clone(), flow.commodity.id.clone(), time_slice.clone());
let flow_value = activity * flow.coeff / n_units;
flows.insert(flow_key, flow_value);
}
}
for asset in existing_assets {
if let Some(parent) = asset.parent() {
for commodity_id in asset.iter_flows().map(|flow| &flow.commodity.id) {
for time_slice in time_slice_info.iter_ids() {
let flow = flows[&(parent.clone(), commodity_id.clone(), time_slice.clone())];
flows.insert(
(asset.clone(), commodity_id.clone(), time_slice.clone()),
flow,
);
}
}
}
}
flows.retain(|(asset, _, _), _| !asset.is_parent());
flows
}
#[allow(clippy::struct_field_names)]
pub struct Solution<'a> {
solution: highs::Solution,
variables: VariableMap,
time_slice_info: &'a TimeSliceInfo,
constraint_keys: ConstraintKeys,
flow_map: Cell<Option<FlowMap>>,
pub objective_value: Money,
}
impl Solution<'_> {
pub fn create_flow_map(&self) -> FlowMap {
self.flow_map.take().expect("Flow map already created")
}
pub fn iter_activity(&self) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, Activity)> {
self.variables
.activity_var_keys()
.zip(self.solution.columns())
.map(|((asset, time_slice), activity)| (asset, time_slice, Activity(*activity)))
}
pub fn iter_activity_for_existing(
&self,
) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, Activity)> {
let cols = &self.solution.columns()[self.variables.existing_asset_var_idx.clone()];
self.variables
.activity_var_keys()
.skip(self.variables.existing_asset_var_idx.start)
.zip(cols.iter())
.map(|((asset, time_slice), &value)| (asset, time_slice, Activity(value)))
}
pub fn iter_activity_for_candidates(
&self,
) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, Activity)> {
let cols = &self.solution.columns()[self.variables.candidate_asset_var_idx.clone()];
self.variables
.activity_var_keys()
.skip(self.variables.candidate_asset_var_idx.start)
.zip(cols.iter())
.map(|((asset, time_slice), &value)| (asset, time_slice, Activity(value)))
}
pub fn iter_activity_keys_for_candidates(
&self,
) -> impl Iterator<Item = (&AssetRef, &TimeSliceID)> {
self.iter_activity_for_candidates()
.map(|(asset, time_slice, _activity)| (asset, time_slice))
}
pub fn iter_unmet_demand(
&self,
) -> impl Iterator<Item = (&CommodityID, &RegionID, &TimeSliceID, Flow)> {
self.variables
.unmet_demand_vars
.keys()
.zip(self.solution.columns()[self.variables.unmet_demand_var_idx.clone()].iter())
.map(|((commodity_id, region_id, time_slice), flow)| {
(commodity_id, region_id, time_slice, Flow(*flow))
})
}
pub fn iter_capacity(&self) -> impl Iterator<Item = (&AssetRef, AssetCapacity)> {
self.variables
.capacity_vars
.keys()
.zip(self.solution.columns()[self.variables.capacity_var_idx.clone()].iter())
.map(|(asset, capacity_var)| {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let asset_capacity = if let Some(unit_size) = asset.unit_size() {
AssetCapacity::Discrete(capacity_var.round() as u32, unit_size)
} else {
AssetCapacity::Continuous(Capacity(*capacity_var))
};
(asset, asset_capacity)
})
}
pub fn iter_commodity_balance_duals(
&self,
) -> impl Iterator<Item = (&CommodityID, &RegionID, &TimeSliceID, MoneyPerFlow)> {
self.constraint_keys
.commodity_balance_keys
.zip_duals(self.solution.dual_rows())
.flat_map(|((commodity_id, region_id, ts_selection), price)| {
ts_selection
.iter(self.time_slice_info)
.map(move |(ts, _)| (commodity_id, region_id, ts, price))
})
}
pub fn iter_activity_duals(
&self,
) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, MoneyPerActivity)> {
self.constraint_keys
.activity_keys
.zip_duals(self.solution.dual_rows())
.filter(|&((_asset, ts_selection), _dual)| {
matches!(ts_selection, TimeSliceSelection::Single(_))
})
.map(|((asset, ts_selection), dual)| {
let (time_slice, _) = ts_selection.iter(self.time_slice_info).next().unwrap();
(asset, time_slice, dual)
})
}
pub fn iter_column_duals(
&self,
) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, MoneyPerActivity)> {
self.variables
.activity_var_keys()
.zip(self.solution.dual_columns())
.map(|((asset, time_slice), dual)| (asset, time_slice, MoneyPerActivity(*dual)))
}
}
#[derive(Debug, Clone)]
pub enum ModelError {
Incoherent(HighsStatus),
NonOptimal(HighsModelStatus),
}
impl fmt::Display for ModelError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ModelError::Incoherent(status) => write!(f, "Incoherent model: {status:?}"),
ModelError::NonOptimal(status) => {
write!(f, "Could not find optimal result: {status:?}")
}
}
}
}
impl Error for ModelError {}
pub fn solve_optimal(model: highs::Model) -> Result<highs::SolvedModel, ModelError> {
let solved = model.try_solve().map_err(ModelError::Incoherent)?;
match solved.status() {
HighsModelStatus::Optimal => Ok(solved),
status => Err(ModelError::NonOptimal(status)),
}
}
fn filter_input_prices(
input_prices: &CommodityPrices,
markets_to_balance: &[(CommodityID, RegionID)],
) -> CommodityPrices {
input_prices
.iter()
.filter(|(commodity_id, region_id, _, _)| {
!markets_to_balance
.iter()
.any(|(c, r)| c == *commodity_id && r == *region_id)
})
.collect()
}
fn get_parent_or_self(assets: &[AssetRef]) -> Vec<AssetRef> {
let mut child_counts: IndexMap<&AssetRef, u32> = IndexMap::new();
let mut out = Vec::new();
for asset in assets {
if let Some(parent) = asset.parent() {
*child_counts.entry(parent).or_default() += 1;
} else {
out.push(asset.clone());
}
}
for (parent, child_count) in child_counts {
out.push(parent.make_partial_parent(child_count));
}
out
}
pub struct DispatchRun<'model, 'run> {
model: &'model Model,
existing_assets: &'run [AssetRef],
flexible_capacity_assets: &'run [AssetRef],
capacity_limits: Option<&'run HashMap<AssetRef, AssetCapacity>>,
candidate_assets: &'run [AssetRef],
markets_to_balance: &'run [(CommodityID, RegionID)],
input_prices: Option<&'run CommodityPrices>,
year: u32,
capacity_margin: f64,
}
impl<'model, 'run> DispatchRun<'model, 'run> {
pub fn new(model: &'model Model, assets: &'run [AssetRef], year: u32) -> Self {
Self {
model,
existing_assets: assets,
flexible_capacity_assets: &[],
capacity_limits: None,
candidate_assets: &[],
markets_to_balance: &[],
input_prices: None,
year,
capacity_margin: 0.0,
}
}
pub fn with_flexible_capacity_assets(
self,
flexible_capacity_assets: &'run [AssetRef],
capacity_limits: Option<&'run HashMap<AssetRef, AssetCapacity>>,
capacity_margin: f64,
) -> Self {
Self {
flexible_capacity_assets,
capacity_limits,
capacity_margin,
..self
}
}
pub fn with_candidates(self, candidate_assets: &'run [AssetRef]) -> Self {
Self {
candidate_assets,
..self
}
}
pub fn with_market_balance_subset(
self,
markets_to_balance: &'run [(CommodityID, RegionID)],
) -> Self {
assert!(!markets_to_balance.is_empty());
Self {
markets_to_balance,
..self
}
}
pub fn with_input_prices(self, input_prices: &'run CommodityPrices) -> Self {
Self {
input_prices: Some(input_prices),
..self
}
}
pub fn run(&self, run_description: &str, writer: &mut DataWriter) -> Result<Solution<'model>> {
let all_markets: Vec<_>;
let markets_to_balance = if self.markets_to_balance.is_empty() {
all_markets = self.model.iter_markets().collect();
&all_markets
} else {
self.markets_to_balance
};
let input_prices_owned = self
.input_prices
.map(|prices| filter_input_prices(prices, markets_to_balance));
let input_prices = input_prices_owned.as_ref();
match self.run_without_unmet_demand_variables(markets_to_balance, input_prices) {
Ok(solution) => {
writer.write_dispatch_debug_info(self.year, run_description, &solution)?;
Ok(solution)
}
Err(ModelError::NonOptimal(HighsModelStatus::Infeasible)) => {
let solution = self
.run_internal(
markets_to_balance,
true,
input_prices,
)
.expect("Failed to run dispatch to calculate unmet demand");
writer.write_dispatch_debug_info(self.year, run_description, &solution)?;
let markets: IndexSet<_> = solution
.iter_unmet_demand()
.filter(|(_, _, _, flow)| *flow > Flow(0.0))
.map(|(commodity_id, region_id, _, _)| {
(commodity_id.clone(), region_id.clone())
})
.collect();
ensure!(
!markets.is_empty(),
"Model is infeasible, but there was no unmet demand"
);
bail!(
"The solver has indicated that the problem is infeasible, probably because \
the supplied assets could not meet the required demand. Demand was not met \
for the following markets: {}",
format_items_with_cap(markets)
)
}
Err(err) => Err(err)?,
}
}
fn run_without_unmet_demand_variables(
&self,
markets_to_balance: &[(CommodityID, RegionID)],
input_prices: Option<&CommodityPrices>,
) -> Result<Solution<'model>, ModelError> {
self.run_internal(
markets_to_balance,
false,
input_prices,
)
}
fn run_internal(
&self,
markets_to_balance: &[(CommodityID, RegionID)],
allow_unmet_demand: bool,
input_prices: Option<&CommodityPrices>,
) -> Result<Solution<'model>, ModelError> {
let parent_assets = get_parent_or_self(self.existing_assets);
let mut problem = Problem::default();
let mut variables = VariableMap::new_with_activity_vars(
&mut problem,
self.model,
input_prices,
&parent_assets,
self.candidate_assets,
self.year,
);
if allow_unmet_demand {
variables.add_unmet_demand_variables(&mut problem, self.model, markets_to_balance);
}
for asset in self.flexible_capacity_assets {
assert!(
parent_assets.contains(asset),
"Flexible capacity assets must be a subset of existing assets. Offending asset: {asset:?}"
);
}
if !self.flexible_capacity_assets.is_empty() {
variables.capacity_var_idx = add_capacity_variables(
&mut problem,
&mut variables.capacity_vars,
self.flexible_capacity_assets,
self.capacity_limits,
self.capacity_margin,
);
}
let all_assets = chain(parent_assets.iter(), self.candidate_assets.iter());
let constraint_keys = add_model_constraints(
&mut problem,
&variables,
self.model,
&all_assets,
markets_to_balance,
self.year,
);
let solution = solve_optimal(problem.optimise(Sense::Minimise))?;
let solution = Solution {
solution: solution.get_solution(),
variables,
time_slice_info: &self.model.time_slice_info,
constraint_keys,
flow_map: Cell::default(),
objective_value: Money(solution.objective_value()),
};
solution.flow_map.set(Some(create_flow_map(
self.existing_assets,
&self.model.time_slice_info,
solution.iter_activity(),
)));
Ok(solution)
}
}
fn add_activity_variables(
problem: &mut Problem,
variables: &mut ActivityVariableMap,
time_slice_info: &TimeSliceInfo,
input_prices: Option<&CommodityPrices>,
assets: &[AssetRef],
year: u32,
) -> Range<usize> {
let start = problem.num_cols();
for (asset, time_slice) in iproduct!(assets.iter(), time_slice_info.iter_ids()) {
let coeff = calculate_activity_coefficient(asset, year, time_slice, input_prices);
let var = problem.add_column(coeff.value(), 0.0..);
let key = (asset.clone(), time_slice.clone());
let existing = variables.insert(key, var).is_some();
assert!(!existing, "Duplicate entry for var");
}
start..problem.num_cols()
}
fn add_capacity_variables(
problem: &mut Problem,
variables: &mut CapacityVariableMap,
assets: &[AssetRef],
capacity_limits: Option<&HashMap<AssetRef, AssetCapacity>>,
capacity_margin: f64,
) -> Range<usize> {
let start = problem.num_cols();
for asset in assets {
assert!(
matches!(asset.state(), AssetState::Selected { .. }),
"Flexible capacity can only be assigned to `Selected` type assets. Offending asset: {asset:?}"
);
let current_capacity = asset.capacity();
let coeff = calculate_capacity_coefficient(asset);
let capacity_limit = capacity_limits.and_then(|limits| limits.get(asset));
if let Some(limit) = capacity_limit {
assert!(
matches!(
(current_capacity, limit),
(AssetCapacity::Continuous(_), AssetCapacity::Continuous(_))
| (AssetCapacity::Discrete(_, _), AssetCapacity::Discrete(_, _))
),
"Incompatible capacity types for asset capacity limit"
);
}
let var = match current_capacity {
AssetCapacity::Continuous(cap) => {
let lower = ((1.0 - capacity_margin) * cap.value()).max(0.0);
let mut upper = (1.0 + capacity_margin) * cap.value();
if let Some(limit) = capacity_limit {
upper = upper.min(limit.total_capacity().value());
}
problem.add_column(coeff.value(), lower..=upper)
}
AssetCapacity::Discrete(units, unit_size) => {
let lower = ((1.0 - capacity_margin) * units as f64).max(0.0);
let mut upper = (1.0 + capacity_margin) * units as f64;
if let Some(limit) = capacity_limit {
upper = upper.min(limit.n_units().unwrap() as f64);
}
problem.add_integer_column((coeff * unit_size).value(), lower..=upper)
}
};
let existing = variables.insert(asset.clone(), var).is_some();
assert!(!existing, "Duplicate entry for var");
}
start..problem.num_cols()
}
fn calculate_activity_coefficient(
asset: &Asset,
year: u32,
time_slice: &TimeSliceID,
input_prices: Option<&CommodityPrices>,
) -> MoneyPerActivity {
let opex = asset.get_operating_cost(year, time_slice);
if let Some(prices) = input_prices {
opex + asset.get_input_cost_from_prices(prices, time_slice)
} else {
opex
}
}
fn calculate_capacity_coefficient(asset: &AssetRef) -> MoneyPerCapacity {
let param = asset.process_parameter();
let annual_fixed_operating_cost = param.fixed_operating_cost * Year(1.0);
annual_fixed_operating_cost
+ annual_capital_cost(param.capital_cost, param.lifetime, param.discount_rate)
}