Skip to main content

muse2/simulation/investment/appraisal/
constraints.rs

1//! Constraints for the optimisation problem.
2use super::DemandMap;
3use super::optimisation::Variable;
4use crate::asset::AssetRef;
5use crate::commodity::Commodity;
6use crate::model::Model;
7use crate::time_slice::{Season, TimeSliceID, TimeSliceInfo, TimeSliceSelection};
8use crate::units::{MoneyPerCapacityPerYear, Year};
9use highs::RowProblem as Problem;
10use indexmap::IndexMap;
11
12/// Adds activity constraints to the problem.
13///
14/// Constrains the activity variables to be within the asset's activity limits.
15///
16/// The asset's per-capacity activity limits are scaled by the asset's capacity to give
17/// absolute bounds, and a single bounded constraint is added per time-slice selection covering the
18/// sum of activity in that selection.
19pub fn add_activity_constraints(
20    problem: &mut Problem,
21    asset: &AssetRef,
22    activity_vars: &IndexMap<TimeSliceID, Variable>,
23    time_slice_info: &TimeSliceInfo,
24) {
25    for (ts_selection, limits) in asset.iter_activity_limits() {
26        let limits = limits.start().value()..=limits.end().value();
27
28        // Collect activity terms for the time slices in this selection
29        let terms = ts_selection
30            .iter(time_slice_info)
31            .map(|(time_slice, _)| (activity_vars[time_slice], 1.0))
32            .collect::<Vec<_>>();
33
34        // Constraint: sum of activities in selection within limits
35        problem.add_row(limits, &terms);
36    }
37}
38
39/// Adds demand constraints to the problem.
40///
41/// Constrains supply to be less than or equal to demand. One inequality constraint is added per
42/// time-slice selection at the commodity's balance level, capping the sum of activity (scaled by
43/// flow coefficients) to the total demand for that selection.
44pub fn add_demand_constraints(
45    problem: &mut Problem,
46    asset: &AssetRef,
47    commodity: &Commodity,
48    time_slice_info: &TimeSliceInfo,
49    demand: &DemandMap,
50    activity_vars: &IndexMap<TimeSliceID, Variable>,
51) {
52    let flow_coeff = asset.get_flow(&commodity.id).unwrap().coeff;
53    for ts_selection in time_slice_info.iter_selections_at_level(commodity.time_slice_level) {
54        let demand_for_ts_selection = demand[&ts_selection];
55        let terms: Vec<_> = ts_selection
56            .iter(time_slice_info)
57            .map(|(time_slice, _)| (activity_vars[time_slice], flow_coeff.value()))
58            .collect();
59        problem.add_row(0.0..=demand_for_ts_selection.value(), terms);
60    }
61}
62
63/// Add seasonal and annual utilisation peak constraints to the problem.
64///
65/// This is almost identical to equivalent constraints in the full-system dispatch, but here we
66/// make the penalties negative as this is a maximise optimisation (whereas dispatch is a minimise
67/// optimisation).
68pub fn add_utilisation_peak_constraints(
69    problem: &mut Problem,
70    model: &Model,
71    asset: &AssetRef,
72    activity_vars: &IndexMap<TimeSliceID, Variable>,
73) {
74    let has_seasonal_penalty =
75        model.parameters.seasonal_utilisation_penalty > MoneyPerCapacityPerYear(0.0);
76    let has_annual_penalty =
77        model.parameters.annual_utilisation_penalty > MoneyPerCapacityPerYear(0.0);
78
79    // If neither penalties are applied, we don't need to add any variables and constraints
80    if !has_seasonal_penalty && !has_annual_penalty {
81        return;
82    }
83
84    // So long as either penalty is applied, we need to add seasonal peak variables and constraints
85    let seasonal_peak_vars = add_seasonal_peak_variables(problem, model);
86    add_seasonal_peak_constraints(
87        problem,
88        asset,
89        activity_vars,
90        &model.time_slice_info,
91        &seasonal_peak_vars,
92    );
93
94    // If the annual penalty is applied, we also need to add an annual peak variable and constraints
95    if has_annual_penalty {
96        let annual_peak_var = add_annual_peak_variable(problem, model);
97        add_annual_peak_constraints(
98            problem,
99            &model.time_slice_info,
100            annual_peak_var,
101            &seasonal_peak_vars,
102        );
103    }
104}
105
106/// Add seasonal peak variables to the problem.
107fn add_seasonal_peak_variables(
108    problem: &mut Problem,
109    model: &Model,
110) -> IndexMap<Season, highs::Col> {
111    let mut seasonal_peak_vars = IndexMap::new();
112    for (season, duration) in &model.time_slice_info.seasons {
113        // Scale penalty by season duration
114        // We make the penalty negative as we're doing a max optimisation
115        let col_factor = -(model.parameters.seasonal_utilisation_penalty * *duration);
116        let variable = problem.add_column(col_factor.value(), 0.0..);
117        seasonal_peak_vars.insert(season.clone(), variable);
118    }
119    seasonal_peak_vars
120}
121
122/// Add annual peak variable to the problem.
123fn add_annual_peak_variable(problem: &mut Problem, model: &Model) -> highs::Col {
124    // Penalty is applied over the whole year, so scale by 1 year
125    // We make the penalty negative as we're doing a max optimisation
126    let col_factor = -(model.parameters.annual_utilisation_penalty * Year(1.0));
127    problem.add_column(col_factor.value(), 0.0..)
128}
129
130/// Add constraints linking seasonal peak variables to activity variables for each (asset, season) pair.
131fn add_seasonal_peak_constraints(
132    problem: &mut Problem,
133    asset: &AssetRef,
134    activity_vars: &IndexMap<TimeSliceID, Variable>,
135    time_slice_info: &TimeSliceInfo,
136    seasonal_peak_vars: &IndexMap<Season, highs::Col>,
137) {
138    let activity_per_capacity = asset.process().capacity_to_activity;
139    for (season, &peak_variable) in seasonal_peak_vars {
140        for (time_slice, ts_length) in
141            TimeSliceSelection::Season(season.clone()).iter(time_slice_info)
142        {
143            let time_slice_fraction = ts_length / Year(1.0);
144            let activity_per_capacity_in_time_slice = activity_per_capacity * time_slice_fraction;
145            let capacity_required_per_activity = 1.0 / activity_per_capacity_in_time_slice.value();
146
147            // One unit of capacity supports `activity_per_capacity_in_time_slice` activity in
148            // this time slice. The peak variable therefore measures the capacity required by
149            // the activity in the time slice.
150            problem.add_row(
151                0.0..,
152                [
153                    (peak_variable, 1.0),
154                    (activity_vars[time_slice], -capacity_required_per_activity),
155                ],
156            );
157        }
158    }
159}
160
161/// Add constraints linking seasonal peak variables to annual peak variables for each asset.
162fn add_annual_peak_constraints(
163    problem: &mut Problem,
164    time_slice_info: &TimeSliceInfo,
165    annual_peak_var: highs::Col,
166    seasonal_peak_vars: &IndexMap<Season, highs::Col>,
167) {
168    for season in time_slice_info.seasons.keys() {
169        problem.add_row(
170            0.0..,
171            [(annual_peak_var, 1.0), (seasonal_peak_vars[season], -1.0)],
172        );
173    }
174}