Skip to content

Commit 1a0da45

Browse files
committed
Add unit commitment extension
Signed-off-by: Davey Elder <iandavidelder@gmail.com>
1 parent c797b1d commit 1a0da45

18 files changed

Lines changed: 1652 additions & 8 deletions

File tree

docs/source/extensions.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,3 +246,4 @@ as new extensions are added.
246246
extensions/growth_rates
247247
extensions/discrete_capacity
248248
extensions/eos
249+
extensions/unit_commitment
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
.. _extension-unit-commitment:
2+
3+
Unit Commitment
4+
===============
5+
6+
The **unit_commitment** extension adds commitment-level operational constraints
7+
to selected technologies: online/started/stopped unit accounting, minimum
8+
output floors, maximum output ceilings, and minimum up-time and down-time windows.
9+
It also supports discounted startup costs and startup emissions and input flows.
10+
11+
It is disabled by default and enabled per run through configuration:
12+
13+
.. code-block:: toml
14+
15+
extensions = ["unit_commitment"]
16+
17+
Conceptual Model
18+
----------------
19+
20+
The core idea is that the continuous capacity variable :math:`\textbf{CAP}_{r,p,t,v}` is
21+
*notionally divided* into discrete units, each of size :math:`UC_{r,t}`. The number of
22+
units available is therefore :math:`\textbf{CAP}_{r,p,t,v} / UC_{r,t}`. Crucially,
23+
:math:`\textbf{CAP}` itself remains a standard linear (continuous) Pyomo variable — the
24+
extension does not change its domain. In practice, because the UC constraints tie output
25+
directly to the integer count of online units, the optimiser will typically land on a
26+
solution where :math:`\textbf{CAP}` is an integer multiple of :math:`UC_{r,t}`, but this
27+
is a consequence of the optimisation rather than an explicit constraint.
28+
29+
The three UC variables — :math:`\textbf{UCN}` (online), :math:`\textbf{UCST}` (started),
30+
and :math:`\textbf{UCSP}` (stopped) — count whole units and are promoted to non-negative
31+
integers by default (MIP). In **linearized mode** (:code:`linearized = 1`), these
32+
variables are kept continuous. This is equivalent to imagining that :math:`UC_{r,t}` is
33+
infinitesimally small: the commitment variables become fractional occupancy factors rather
34+
than discrete unit counts, and the min-output bound acts as a simple lower capacity-factor
35+
constraint without any integer requirements. Linearized mode is useful for reducing solve
36+
time.
37+
38+
Integration with Core Constraints
39+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
40+
41+
For some existing core constraints, the extension overrides a utility function —
42+
:func:`~temoa.components.utils.get_available_output` — that computes
43+
the maximum available output for a process in a given time slice. For UC technologies,
44+
this function returns a commitment-aware expression based on online units rather than total
45+
installed capacity. Because this function is used throughout the core model, the following
46+
constraints automatically become UC-aware for registered technologies with no further changes:
47+
48+
- :code:`capacity_constraint` — output bounded by online capacity
49+
- :code:`storage_charge_rate_constraint`, :code:`storage_discharge_rate_constraint`,
50+
:code:`storage_throughput_constraint` — storage rates scale with online units
51+
- :code:`reserve_margin_constraint` (dynamic mode only) — reserve contribution from online units only
52+
53+
Ramp constraints are the only exception: they require explicit knowledge of
54+
started/stopped units to compute the ramp envelope and are therefore fully replaced by
55+
UC-aware versions for technologies that appear in both :code:`unit_commitment` and the
56+
ramp tables.
57+
58+
.. autofunction:: temoa.components.utils.available_output_base
59+
60+
.. autofunction:: temoa.extensions.unit_commitment.components.commitment.uc_available_output
61+
62+
.. warning::
63+
64+
**Annual technologies are incompatible with unit commitment.** Technologies
65+
in the :code:`tech_annual` set have no time-slice flexibility and cannot be
66+
committed; the model will raise an error if any annual technology appears in
67+
the :code:`unit_commitment` table.
68+
69+
.. warning::
70+
71+
**Integer vs. linear relaxation.** By default all three UC variables
72+
(:math:`\textbf{UCN}`, :math:`\textbf{UCST}`, :math:`\textbf{UCSP}`) are
73+
promoted to non-negative integers, turning the problem into a MIP. Set
74+
:code:`linearized = 1` in the :code:`unit_commitment` table for a
75+
particular technology to keep those variables continuous (LP relaxation).
76+
77+
Overview
78+
--------
79+
80+
.. list-table::
81+
:header-rows: 1
82+
:widths: 28 38 34
83+
84+
* - Table
85+
- Purpose
86+
- Key interaction
87+
* - :code:`unit_commitment`
88+
- Core UC parameters (unit size, output fractions, up/down times).
89+
- Enables UC constraints; overrides available output calculation for UC techs.
90+
Commitment results are written to :code:`output_unit_commitment`.
91+
* - :code:`unit_commitment_startup_cost`
92+
- Direct startup cost per unit of capacity started.
93+
- Added to variable costs.
94+
* - :code:`unit_commitment_startup_emissions`
95+
- Emissions produced at startup per unit of capacity started.
96+
- Added to :code:`limit_emission_constraint` totals, emission
97+
costs and :code:`output_emission` table.
98+
* - :code:`unit_commitment_startup_input`
99+
- Input commodity consumed at startup per unit of capacity started.
100+
- Added to consumption in :code:`commodity_balance_constraint` and
101+
results in :code:`output_flow_in`.
102+
103+
Parameters
104+
----------
105+
106+
unit_commitment
107+
~~~~~~~~~~~~~~~
108+
109+
:math:`{UC}_{r \in R,\, t \in T}`
110+
111+
The primary UC table. Each row registers a technology (or technology group)
112+
as subject to unit-commitment constraints and specifies all operational
113+
parameters. Exactly one row per :math:`(r, t)` pair is required.
114+
115+
.. csv-table::
116+
:header: "Column", "Symbol", "Default", "Description"
117+
:widths: 24, 22, 10, 44
118+
119+
":code:`region`", ":math:`r`", "—", "region label"
120+
":code:`tech`", ":math:`t`", "—", "technology name"
121+
":code:`unit_capacity`", ":math:`UC_{r,t}`", "—", "nameplate capacity per discrete unit (same units as :code:`v_capacity`); **required**"
122+
":code:`min_output_fraction`", ":math:`\underline{f}_{r,t}`", "0", "minimum output as a fraction of available nameplate output per online unit; :math:`[0, 1]`"
123+
":code:`max_output_fraction`", ":math:`\overline{f}_{r,t}`", "1", "maximum output as a fraction of available nameplate output per online unit; :math:`(0, 1]`"
124+
":code:`min_up_time_hours`", ":math:`T^{up}_{r,t}`", "0", "minimum number of consecutive hours a unit must remain online after starting"
125+
":code:`min_down_time_hours`", ":math:`T^{dn}_{r,t}`", "0", "minimum number of consecutive hours a unit must remain offline after stopping"
126+
":code:`linearized`", "—", "0", "if 1, UC variables are kept continuous (LP relaxation); if 0, they are promoted to non-negative integers (MIP)"
127+
128+
unit_commitment_startup_cost
129+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
130+
131+
:math:`{UCSC}_{r \in R,\, t \in T}`
132+
133+
Optional. Specifies a cost incurred each time a unit is started, expressed
134+
per unit of capacity. The total startup cost in a timeslice :math:`(s, d)` is:
135+
136+
.. math::
137+
138+
\text{startup cost} = \textbf{UCST}_{r,p,s,d,t,v} \cdot UC_{r,t} \cdot UCSC_{r,t}
139+
140+
.. csv-table::
141+
:header: "Column", "Symbol", "Description"
142+
:widths: 22, 20, 58
143+
144+
":code:`region`", ":math:`r`", "region label"
145+
":code:`tech`", ":math:`t`", "technology name"
146+
":code:`cost_per_cap`", ":math:`UCSC_{r,t}`", "startup cost per unit of capacity (same currency units as :code:`cost_invest`)"
147+
148+
unit_commitment_startup_emissions
149+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
150+
151+
:math:`{UCSE}_{r \in R,\, e \in C^e,\, t \in T}`
152+
153+
Optional. Specifies an emission produced at startup per unit of capacity
154+
started. Startup emissions are summed into the :code:`limit_emission_constraint`
155+
alongside normal activity-based emissions.
156+
157+
.. csv-table::
158+
:header: "Column", "Symbol", "Description"
159+
:widths: 22, 20, 58
160+
161+
":code:`region`", ":math:`r`", "region label"
162+
":code:`emis_comm`", ":math:`e`", "emission commodity"
163+
":code:`tech`", ":math:`t`", "technology name"
164+
":code:`emis_per_cap`", ":math:`UCSE_{r,e,t}`", "emission per unit of capacity started (same units as :code:`emission_activity`)"
165+
166+
unit_commitment_startup_input
167+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
168+
169+
:math:`{UCSI}_{r \in R,\, i \in C^p,\, t \in T}`
170+
171+
Optional. Specifies a physical commodity consumed at startup per unit of
172+
capacity started. Startup inputs are summed into the
173+
:code:`commodity_balance_constraint` alongside normal flow-based consumption.
174+
175+
.. csv-table::
176+
:header: "Column", "Symbol", "Description"
177+
:widths: 22, 20, 58
178+
179+
":code:`region`", ":math:`r`", "region label"
180+
":code:`input_comm`", ":math:`i`", "input commodity"
181+
":code:`tech`", ":math:`t`", "technology name"
182+
":code:`input_per_cap`", ":math:`UCSI_{r,i,t}`", "input commodity consumed per unit of capacity started"
183+
184+
.. note::
185+
186+
These inputs are **not** considered in network checking. If no other process produces or consumes the
187+
input commodity in this region and period, it may be removed from the model and lead to errors.
188+
189+
Decision Variables
190+
------------------
191+
192+
Three UC decision variables are added per :math:`(r, p, s, d, t, v)` index.
193+
By default they are non-negative integers (MIP); set :code:`linearized = 1`
194+
to relax them to continuous non-negative reals.
195+
196+
.. csv-table::
197+
:header: "Variable", "Domain", "Description"
198+
:widths: 36, 16, 48
199+
200+
":math:`\textbf{UCN}_{r,p,s,d,t,v}` (:code:`v_uc_online`)", ":math:`\mathbb{Z}_{\ge 0}`", "number of units online at the start of timeslice :math:`(s,d)`"
201+
":math:`\textbf{UCST}_{r,p,s,d,t,v}` (:code:`v_uc_started`)", ":math:`\mathbb{Z}_{\ge 0}`", "number of units that start up during timeslice :math:`(s,d)`"
202+
":math:`\textbf{UCSP}_{r,p,s,d,t,v}` (:code:`v_uc_stopped`)", ":math:`\mathbb{Z}_{\ge 0}`", "number of units that shut down during timeslice :math:`(s,d)`"
203+
204+
Constraints
205+
-----------
206+
207+
Unit Count Bounds
208+
~~~~~~~~~~~~~~~~~
209+
210+
.. autofunction:: temoa.extensions.unit_commitment.components.commitment.uc_online_upper_constraint
211+
212+
.. autofunction:: temoa.extensions.unit_commitment.components.commitment.uc_started_upper_tightening_constraint
213+
214+
.. autofunction:: temoa.extensions.unit_commitment.components.commitment.uc_stopped_upper_tightening_constraint
215+
216+
Commitment Transition
217+
~~~~~~~~~~~~~~~~~~~~~
218+
219+
.. autofunction:: temoa.extensions.unit_commitment.components.commitment.uc_transition_constraint
220+
221+
Output Bounds
222+
~~~~~~~~~~~~~
223+
224+
The minimum output constraint enforces a floor on generation per online unit.
225+
The maximum output (capacity) constraint is handled by the standard core-model
226+
:code:`capacity_constraint` — the extension overrides
227+
:func:`~temoa.components.utils.get_available_output` so that available output
228+
is based on online units rather than total installed capacity, requiring no
229+
separate replacement constraint.
230+
231+
.. autofunction:: temoa.extensions.unit_commitment.components.commitment.uc_min_output_constraint
232+
233+
Minimum Up/Down Time
234+
~~~~~~~~~~~~~~~~~~~~
235+
236+
.. autofunction:: temoa.extensions.unit_commitment.components.commitment.uc_min_up_time_constraint
237+
238+
.. autofunction:: temoa.extensions.unit_commitment.components.commitment.uc_min_down_time_constraint
239+
240+
Ramp Constraints
241+
~~~~~~~~~~~~~~~~
242+
243+
When a technology appears in both the :code:`unit_commitment` table and the
244+
:code:`ramp_up_hourly` / :code:`ramp_down_hourly` tables, the standard core-model
245+
ramp constraints (:code:`ramp_up_constraint`, :code:`ramp_down_constraint`) are
246+
**replaced** by UC-aware versions for that technology. Non-UC technologies retain
247+
the original core-model ramp constraints unchanged.
248+
249+
The core ramp constraint bounds the change in hourly activity against a fraction of
250+
total installed capacity. The UC version replaces this with a commitment-aware
251+
expression: only online units contribute to the ramp envelope, and the operating
252+
point assumed for units that start or stop during the transition adds or removes
253+
headroom according to the effective minimum output fraction. Units are assumed to
254+
start and stop at their minimum viable output level, taken as the larger of their
255+
:code:`min_output_fraction` and :code:`ramp_up_fraction` / :code:`ramp_down_fraction`.
256+
257+
.. autofunction:: temoa.extensions.unit_commitment.components.commitment.uc_ramp_up_constraint
258+
259+
.. autofunction:: temoa.extensions.unit_commitment.components.commitment.uc_ramp_down_constraint
260+
261+
Objective Contributions
262+
-----------------------
263+
264+
.. autofunction:: temoa.extensions.unit_commitment.components.startup.append_startup_costs
265+
266+
Output Table
267+
------------
268+
269+
Results are written to the :code:`output_unit_commitment` table with one row per
270+
:math:`(scenario, region, period, season, tod, tech, vintage)`:
271+
272+
.. csv-table::
273+
:header: "Column", "Description"
274+
:widths: 22, 78
275+
276+
":code:`online_cap`", "capacity online at the start of the timeslice (:math:`\textbf{UCN} \cdot UC_{r,t}`)"
277+
":code:`start_cap`", "capacity started during the timeslice (:math:`\textbf{UCST} \cdot UC_{r,t}`)"
278+
":code:`stop_cap`", "capacity stopped during the timeslice (:math:`\textbf{UCSP} \cdot UC_{r,t}`)"
279+
280+
.. note::
281+
282+
**Starts and stops take effect in the following time slice.** The transition
283+
constraint links :math:`\textbf{UCST}_{s,d}` and :math:`\textbf{UCSP}_{s,d}` to the
284+
*change* in online count between :math:`(s,d)` and its successor
285+
:math:`(s_{next}, d_{next})`. A unit started during slice :math:`(s,d)` therefore
286+
first appears as online — and is first able to produce output — in
287+
:math:`(s_{next}, d_{next})`. Equivalently, a unit stopped during :math:`(s,d)` is
288+
still counted as online for that slice and goes offline from
289+
:math:`(s_{next}, d_{next})` onward.

temoa/_internal/table_data_puller.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,11 @@ def poll_flow_results(model: TemoaModel, epsilon: float = 1e-5) -> dict[FI, dict
246246
continue
247247
res[fi][FlowType.OUT] = flow
248248

249+
if 'unit_commitment' in model.enabled_extensions:
250+
import temoa.extensions.unit_commitment.core.data_puller as uc
251+
252+
uc.poll_startup_input_results(model, res, epsilon)
253+
249254
return res
250255

251256

@@ -494,6 +499,10 @@ def poll_cost_results(
494499
import temoa.extensions.economies_of_scale.core.data_puller as eos
495500

496501
eos.poll_costs(model, exchange_costs, entries, p_0_true, epsilon)
502+
if 'unit_commitment' in model.enabled_extensions:
503+
import temoa.extensions.unit_commitment.core.data_puller as uc
504+
505+
uc.poll_startup_cost_results(model, exchange_costs, entries, p_0_true, epsilon)
497506

498507
exchange_entries = exchange_costs.get_entries()
499508
return entries, exchange_entries
@@ -558,6 +567,11 @@ def poll_emissions(
558567
* model.emission_activity[r, e, i, t, v, o]
559568
)
560569

570+
if 'unit_commitment' in model.enabled_extensions:
571+
from temoa.extensions.unit_commitment.core import data_puller as uc
572+
573+
uc.poll_emission_results(model, flows)
574+
561575
# gather costs
562576
ud_costs: dict[tuple[Region, Period, Technology, Vintage], float] = defaultdict(float)
563577
d_costs: dict[tuple[Region, Period, Technology, Vintage], float] = defaultdict(float)

temoa/_internal/table_writer.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,16 @@ def write_capacity_tables(self, model: TemoaModel, iteration: int | None = None)
483483
cap_data = poll_capacity_results(model=model, epsilon=self.output_threshold_capacity)
484484
self._insert_capacity_results(cap_data=cap_data, iteration=iteration)
485485

486+
if 'unit_commitment' in model.enabled_extensions:
487+
import temoa.extensions.unit_commitment.core.data_puller as uc_puller
488+
489+
uc_puller.write_uc_results(
490+
model=model,
491+
writer=self,
492+
iteration=iteration,
493+
epsilon=self.output_threshold_capacity,
494+
)
495+
486496
def _insert_capacity_results(self, cap_data: CapData, iteration: int | None) -> None:
487497
if self.tech_sectors is None:
488498
raise RuntimeError('tech sectors not available... code error')

temoa/components/commodities.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,11 @@ def commodity_balance_constraint(
425425
for s_o in model.process_outputs_by_input[r, p, s_t, s_v, c]
426426
)
427427

428+
if 'unit_commitment' in model.enabled_extensions:
429+
from temoa.extensions.unit_commitment.components import startup
430+
431+
consumed += startup.uc_startup_input_rpsdc(model, r, p, s, d, c)
432+
428433
if (r, p, c) in model.capacity_consumption_techs:
429434
# Consumed by building capacity
430435
# Assume evenly distributed over a year
@@ -573,6 +578,11 @@ def annual_commodity_balance_constraint(
573578
for s_o in model.process_outputs_by_input[r, p, s_t, s_v, c]
574579
)
575580

581+
if 'unit_commitment' in model.enabled_extensions:
582+
from temoa.extensions.unit_commitment.components import startup
583+
584+
consumed += startup.uc_startup_input_rpc(model, r, p, c)
585+
576586
if (r, p, c) in model.capacity_consumption_techs:
577587
# Consumed by building capacity
578588
# Assume evenly distributed over a year

temoa/components/limits.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,13 @@ def limit_emission_constraint(
795795
if (reg, p, S_t, S_v) in model.process_inputs
796796
)
797797

798+
if 'unit_commitment' in model.enabled_extensions:
799+
from temoa.extensions.unit_commitment.components import startup
800+
801+
process_emissions += quicksum(
802+
startup.uc_startup_emissions_rpe(model, reg, p, e) for reg in regions
803+
)
804+
798805
embodied_emissions = quicksum(
799806
model.v_new_capacity[reg, t, v]
800807
* value(model.emission_embodied[reg, e, t, v])

0 commit comments

Comments
 (0)