36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243 | class GeneratorFormulation(VariableFormulation[GeneratorVariables]):
"""Generators: power limits, available capacity, startup and shutdown, minimum up and down time, ramping."""
dimension = GENERATOR
entity_type = Generator
power_name: ClassVar[str] = f"{GENERATOR}_power"
status_name: ClassVar[str] = f"{GENERATOR}_status"
startup_name: ClassVar[str] = f"{GENERATOR}_startup"
shutdown_name: ClassVar[str] = f"{GENERATOR}_shutdown"
def __init__(self, generators: Sequence[Generator], context: ModelContext) -> None:
"""Initialize with the generators of the system.
Args:
generators: The generators, at least one.
context: The shared indexing of the problem.
"""
super().__init__(generators, context)
self.arrays = vectorize(GeneratorArrays, generators, self.coordinates)
self.commitment_arrays = vectorize(
CommitmentArrays,
[generator.commitment for generator in generators],
self.coordinates,
)
self.states = generator_state_arrays(generators, self.coordinates, context)
self.commitment = CommitmentFormulation(self.coordinates, self.states.commitment, context)
self.available_capacity = context.profiles(
AvailableCapacityProfile,
generators,
self.coordinates,
lambda profile: profile.values,
default=np.inf,
)
@classmethod
def build(cls, inputs: FormulationInputs) -> Self | None:
"""Return the formulation of the generators, or None if there are none."""
generators = inputs.of_type(Generator)
return cls(generators, inputs.context) if generators else None
def _create_variables(self, model: linopy.Model) -> GeneratorVariables:
"""Add power (MW, non-negative) and the binary status, startup and shutdown indicators."""
coords = self.context.variable_coords(self.coordinates)
return GeneratorVariables(
power=model.add_variables(name=self.power_name, coords=coords, lower=0.0),
commitment=self.commitment.create_variables(model),
)
@constraint
def _get_generator_max_power_constraint(self) -> ModelConstraint:
"""Generator power limit constraint.
This constraint ensures that each generator's power output does not
exceed its nominal power capacity.
"""
return ModelConstraint(
constraint=self.variables.power - self.variables.commitment.status * self.arrays.nominal_power <= 0,
name="generator_max_power_constraint",
)
@constraint
def _get_generator_status_constraint(self) -> ModelConstraint:
"""When on, output is at least a small fraction (1e-5) of nominal power, so `status` = 1 means it produces."""
epsilon = 1e-5 * self.arrays.nominal_power
return ModelConstraint(
constraint=self.variables.power >= self.variables.commitment.status * epsilon,
name="generator_status_constraint",
)
@constraint
def _get_generator_startup_lower_bound_constraint(self) -> ModelConstraint:
"""Startup indicator active when turning generator on, from its initial status at t=0."""
return self.commitment.startup_lower_bound_constraint(self.variables.commitment)
@constraint
def _get_generator_startup_upper_bound_1_constraint(self) -> ModelConstraint:
"""Startup indicator bounded by current status."""
return self.commitment.startup_upper_bound_1_constraint(self.variables.commitment)
@constraint
def _get_generator_startup_upper_bound_2_constraint(self) -> ModelConstraint:
"""Startup indicator bounded by previous status."""
return self.commitment.startup_upper_bound_2_constraint(self.variables.commitment)
@constraint
def _get_generator_shutdown_lower_bound_constraint(self) -> ModelConstraint:
"""Shutdown indicator active when turning generator off, from its initial status at t=0."""
return self.commitment.shutdown_lower_bound_constraint(self.variables.commitment)
@constraint
def _get_generator_shutdown_upper_bound_1_constraint(self) -> ModelConstraint:
"""Shutdown indicator bounded by previous status."""
return self.commitment.shutdown_upper_bound_1_constraint(self.variables.commitment)
@constraint
def _get_generator_shutdown_upper_bound_2_constraint(self) -> ModelConstraint:
"""Shutdown indicator bounded by current status."""
return self.commitment.shutdown_upper_bound_2_constraint(self.variables.commitment)
@constraint
def _get_min_uptime_constraint(self) -> list[ModelConstraint]:
"""Once started, a generator stays on for its minimum up time, counting time on before the horizon."""
return self.commitment.min_uptime_constraints(self.variables.commitment)
@constraint
def _get_min_downtime_constraint(self) -> list[ModelConstraint]:
"""Once stopped, a generator stays off for its minimum down time, counting time off before the horizon."""
return self.commitment.min_downtime_constraints(self.variables.commitment)
@constraint
def _get_initial_min_uptime_constraint(self) -> list[ModelConstraint]:
"""A generator on for less than its minimum up time before the horizon does not shut down at t=0."""
return self.commitment.initial_min_uptime_constraint(self.variables.commitment)
@constraint
def _get_initial_min_downtime_constraint(self) -> list[ModelConstraint]:
"""A generator off for less than its minimum down time before the horizon does not start at t=0."""
return self.commitment.initial_min_downtime_constraint(self.variables.commitment)
@constraint
def _get_min_power_constraint(self) -> ModelConstraint:
"""When on, output is at least `min_power`."""
return ModelConstraint(
constraint=self.variables.power >= self.arrays.min_power * self.variables.commitment.status,
name="generator_min_power_constraint",
)
@constraint
def _get_max_ramp_up_constraint(self) -> ModelConstraint:
"""Output rises by at most `ramp_up` per hour times the step length, from the initial power at t=0.
Unlimited when unset.
"""
max_ramp_up = (self.arrays.ramp_up * self.context.timestep_hours).fillna(self.arrays.nominal_power)
ramp = self.variables.power - self._previous_power()
return ModelConstraint(
constraint=ramp.to_constraint("<=", max_ramp_up),
name="generator_max_ramp_up_constraint",
)
@constraint
def _get_max_ramp_down_constraint(self) -> ModelConstraint:
"""Output falls by at most `ramp_down` per hour times the step length, from the initial power at t=0.
Unlimited when unset.
"""
max_ramp_down = (self.arrays.ramp_down * self.context.timestep_hours).fillna(self.arrays.nominal_power)
ramp = self._previous_power() - self.variables.power
return ModelConstraint(
constraint=ramp.to_constraint("<=", max_ramp_down),
name="generator_max_ramp_down_constraint",
)
@constraint
def _get_final_power_constraint(self) -> list[ModelConstraint]:
"""Output at the last step equals the final power, for the generators that have a final state."""
has_final_power = self.states.final_power.notnull()
if not has_final_power.any():
return []
last_step = {TIME: self.context.time.labels[-1]}
with_final_power = {GENERATOR: has_final_power}
expression = self.variables.power.sel(last_step).sel(with_final_power) - self.states.final_power.sel(
with_final_power,
)
return [
ModelConstraint(
constraint=expression.to_constraint("=", 0),
name="generator_final_power_constraint",
),
]
@constraint
def _get_available_capacity_constraint(self) -> ModelConstraint:
"""Power is capped by the scenario's available capacity profile (unbounded without one)."""
return ModelConstraint(
constraint=self.variables.power <= self.available_capacity,
name="available_capacity_constraint",
)
def _previous_power(self) -> linopy.LinearExpression:
"""Return the power one step earlier, the initial power at t=0."""
return time_shifted(self.variables.power, 1, outside_value=self.states.initial_power)
def power_injection(self) -> linopy.LinearExpression:
"""Return the total generator output, per scenario and time."""
injection: linopy.LinearExpression = self.variables.power.sum(GENERATOR)
return injection
def profit(self) -> linopy.LinearExpression:
"""Return minus the generation cost per scenario: variable cost per MWh plus startup and shutdown costs."""
cost = self.variables.power * self.context.timestep_hours * self.arrays.variable_cost + self.commitment.costs(
self.variables.commitment,
self.commitment_arrays,
)
profit: linopy.LinearExpression = -cost.sum([TIME, GENERATOR])
return profit
def dispatch(self, solution: xr.Dataset) -> GeneratorDispatch:
"""Return the power, status, startups and shutdowns of the generators."""
data = xr.Dataset(
{
"power": solution[self.power_name],
"status": solution[self.status_name],
"startup": solution[self.startup_name],
"shutdown": solution[self.shutdown_name],
},
)
return GeneratorDispatch(data, GENERATOR)
|