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::{AssetCapacity, AssetRef, AssetState};
5use crate::commodity::Commodity;
6use crate::time_slice::{TimeSliceID, TimeSliceInfo};
7use crate::units::Flow;
8use highs::RowProblem as Problem;
9use indexmap::IndexMap;
10
11/// Adds a capacity constraint to the problem.
12///
13/// The behaviour depends on whether the asset is commissioned or a candidate:
14/// - For a commissioned asset, the capacity is fixed.
15/// - For a candidate asset, the capacity is variable between zero and an upper bound.
16pub fn add_capacity_constraint(
17    problem: &mut Problem,
18    asset: &AssetRef,
19    max_capacity: AssetCapacity,
20    capacity_var: Variable,
21) {
22    let capacity_limit = match max_capacity {
23        AssetCapacity::Continuous(cap) => cap.value(),
24        AssetCapacity::Discrete(units, _) => units as f64,
25    };
26
27    let bounds = match asset.state() {
28        AssetState::Commissioned { .. } => {
29            // Fixed capacity for commissioned assets
30            capacity_limit..=capacity_limit
31        }
32        AssetState::Candidate => {
33            // Variable capacity between 0 and max for candidate assets
34            0.0..=capacity_limit
35        }
36        _ => panic!(
37            "add_capacity_constraint should only be called with Commissioned or Candidate assets"
38        ),
39    };
40    problem.add_row(bounds, [(capacity_var, 1.0)]);
41}
42
43/// Adds activity constraints to the problem.
44///
45/// Constrains the activity variables to be within the asset's activity limits.
46///
47/// The behaviour depends on whether the asset is commissioned or a candidate:
48/// - For a commissioned asset, the activity limits have fixed bounds based on the asset's (fixed)
49///   capacity.
50/// - For a candidate asset, the activity limits depend on the capacity of the asset, which is
51///   itself variable. The constraints are therefore applied to both the capacity and activity
52///   variables. We need separate constraints for the upper and lower bounds.
53pub fn add_activity_constraints(
54    problem: &mut Problem,
55    asset: &AssetRef,
56    capacity_var: Variable,
57    activity_vars: &IndexMap<TimeSliceID, Variable>,
58    time_slice_info: &TimeSliceInfo,
59) {
60    match asset.state() {
61        AssetState::Commissioned { .. } => {
62            add_activity_constraints_for_existing(problem, asset, activity_vars, time_slice_info);
63        }
64        AssetState::Candidate => {
65            add_activity_constraints_for_candidate(
66                problem,
67                asset,
68                capacity_var,
69                activity_vars,
70                time_slice_info,
71            );
72        }
73        _ => panic!(
74            "add_activity_constraints should only be called with Commissioned or Candidate assets"
75        ),
76    }
77}
78
79fn add_activity_constraints_for_existing(
80    problem: &mut Problem,
81    asset: &AssetRef,
82    activity_vars: &IndexMap<TimeSliceID, Variable>,
83    time_slice_info: &TimeSliceInfo,
84) {
85    for (ts_selection, limits) in asset.iter_activity_limits() {
86        let limits = limits.start().value()..=limits.end().value();
87
88        // Collect activity terms for the time slices in this selection
89        let terms = ts_selection
90            .iter(time_slice_info)
91            .map(|(time_slice, _)| (*activity_vars.get(time_slice).unwrap(), 1.0))
92            .collect::<Vec<_>>();
93
94        // Constraint: sum of activities in selection within limits
95        problem.add_row(limits, &terms);
96    }
97}
98
99fn add_activity_constraints_for_candidate(
100    problem: &mut Problem,
101    asset: &AssetRef,
102    capacity_var: Variable,
103    activity_vars: &IndexMap<TimeSliceID, Variable>,
104    time_slice_info: &TimeSliceInfo,
105) {
106    for (ts_selection, limits) in asset.iter_activity_per_capacity_limits() {
107        let mut upper_limit = limits.end().value();
108        let mut lower_limit = limits.start().value();
109
110        // If the asset capacity is discrete, the capacity variable represents number of
111        // units, so we need to multiply the per-capacity limits by the unit size.
112        if let AssetCapacity::Discrete(_, unit_size) = asset.capacity() {
113            upper_limit *= unit_size.value();
114            lower_limit *= unit_size.value();
115        }
116
117        // Collect capacity and activity terms
118        // We have a single capacity term, and activity terms for all time slices in the selection
119        let mut terms_upper = vec![(capacity_var, -upper_limit)];
120        let mut terms_lower = vec![(capacity_var, -lower_limit)];
121        for (time_slice, _) in ts_selection.iter(time_slice_info) {
122            let var = *activity_vars.get(time_slice).unwrap();
123            terms_upper.push((var, 1.0));
124            terms_lower.push((var, 1.0));
125        }
126
127        // Upper bound: sum(activity) - (capacity * upper_limit_per_capacity) ≤ 0
128        problem.add_row(..=0.0, &terms_upper);
129
130        // Lower bound: sum(activity) - (capacity * lower_limit_per_capacity) ≥ 0
131        problem.add_row(0.0.., &terms_lower);
132    }
133}
134
135/// Adds demand constraints to the problem.
136///
137/// Constrains supply to be less than or equal to demand. This is implemented as an equality
138/// across each time-slice selection: supply (activity terms, scaled by flow coefficients) plus
139/// the `unmet_demand` variables equals the total demand for that selection, so non-negative
140/// `unmet_demand` enforces supply ≤ demand. The selections follow the commodity's balance level.
141pub fn add_demand_constraints(
142    problem: &mut Problem,
143    asset: &AssetRef,
144    commodity: &Commodity,
145    time_slice_info: &TimeSliceInfo,
146    demand: &DemandMap,
147    activity_vars: &IndexMap<TimeSliceID, Variable>,
148    unmet_demand_vars: &IndexMap<TimeSliceID, Variable>,
149) {
150    for ts_selection in time_slice_info.iter_selections_at_level(commodity.time_slice_level) {
151        let mut demand_for_ts_selection = Flow(0.0);
152        let mut terms = Vec::new();
153        for (time_slice, _) in ts_selection.iter(time_slice_info) {
154            demand_for_ts_selection += demand[time_slice];
155            let flow_coeff = asset.get_flow(&commodity.id).unwrap().coeff;
156            terms.push((activity_vars[time_slice], flow_coeff.value()));
157            terms.push((unmet_demand_vars[time_slice], 1.0));
158        }
159        problem.add_row(
160            demand_for_ts_selection.value()..=demand_for_ts_selection.value(),
161            terms,
162        );
163    }
164}