Skip to content

Commit 9d26bd5

Browse files
Fix treatment of early retirement in myopic mode (#340)
* [pre-commit.ci] pre-commit autoupdate updates: - [github.com/astral-sh/uv-pre-commit: 0.11.19 → 0.11.21](astral-sh/uv-pre-commit@0.11.19...0.11.21) - [github.com/astral-sh/ruff-pre-commit: v0.15.16 → v0.15.17](astral-sh/ruff-pre-commit@v0.15.16...v0.15.17) * [pre-commit.ci] pre-commit autoupdate updates: - [github.com/astral-sh/uv-pre-commit: 0.11.21 → 0.11.23](astral-sh/uv-pre-commit@0.11.21...0.11.23) - [github.com/astral-sh/ruff-pre-commit: v0.15.17 → v0.15.18](astral-sh/ruff-pre-commit@v0.15.17...v0.15.18) * Load early retirements from previous planning periods Signed-off-by: Davey Elder <iandavidelder@gmail.com> * Adjust existing capacity for past early retirement in myopic Signed-off-by: Davey Elder <iandavidelder@gmail.com> * Fix handling of existing capacity for p0 Signed-off-by: Davey Elder <iandavidelder@gmail.com> * Pull lifetimes data for previously retired existing capacities for accounting purposes Signed-off-by: Davey Elder <iandavidelder@gmail.com> * Actually load growthrate constraints Signed-off-by: Davey Elder <iandavidelder@gmail.com> * Add a broad stress test for myopic that includes survival curves, early retirement, and growth rate constraints all together Signed-off-by: Davey Elder <iandavidelder@gmail.com> * Update test set hashes Signed-off-by: Davey Elder <iandavidelder@gmail.com> * Improve custom loader filtering for lifetime data Signed-off-by: Davey Elder <iandavidelder@gmail.com> * Update existing capacity check to specifically look for tiny dropped capacities Signed-off-by: Davey Elder <iandavidelder@gmail.com> * Try to fix python 3.12 type error Signed-off-by: Davey Elder <iandavidelder@gmail.com> * Check if output retirement table exists Signed-off-by: Davey Elder <iandavidelder@gmail.com> --------- Signed-off-by: Davey Elder <iandavidelder@gmail.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 2bde478 commit 9d26bd5

19 files changed

Lines changed: 622 additions & 50 deletions

.pre-commit-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
repos:
22
- repo: https://github.com/astral-sh/uv-pre-commit
33
# uv version.
4-
rev: 0.11.19
4+
rev: 0.11.23
55
hooks:
66
# Dependency management
77
- id: uv-lock
@@ -47,7 +47,7 @@ repos:
4747
# Python Linting & Formatting with Ruff
4848
- repo: https://github.com/astral-sh/ruff-pre-commit
4949
# Ruff version.
50-
rev: "v0.15.16"
50+
rev: "v0.15.18"
5151
hooks:
5252
- id: ruff
5353
name: ruff (linter)

temoa/components/capacity.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from deprecated import deprecated
1919
from pyomo.environ import value
2020

21-
from .utils import get_capacity_factor
21+
from .utils import get_adjusted_existing_capacity, get_capacity_factor
2222

2323
if TYPE_CHECKING:
2424
from temoa.core.model import TemoaModel
@@ -292,7 +292,9 @@ def annual_retirement_constraint(
292292
# Exact EOL. No v_capacity or v_retired_capacity for this period.
293293
if p == model.time_optimize.first():
294294
# Must be existing capacity. Apply survival curve to existing cap
295-
cap_begin = model.existing_capacity[r, t, v] * model.lifetime_survival_curve[r, p, t, v]
295+
cap_begin = get_adjusted_existing_capacity(model, r, t, v) * value(
296+
model.lifetime_survival_curve[r, p, t, v]
297+
)
296298
else:
297299
# Get previous capacity and continue survival curve
298300
p_prev = model.time_optimize.prev(p)
@@ -545,8 +547,9 @@ def adjusted_capacity_constraint(
545547
the time when that retirement occurred (treated here as at the beginning of each period).
546548
"""
547549

550+
built_capacity: ExprLike
548551
if v in model.time_exist:
549-
built_capacity = value(model.existing_capacity[r, t, v])
552+
built_capacity = get_adjusted_existing_capacity(model, r, t, v)
550553
else:
551554
built_capacity = model.v_new_capacity[r, t, v]
552555

temoa/components/limits.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@
2020

2121
import temoa.components.geography as geography
2222
import temoa.components.technology as technology
23-
from temoa.components.utils import Operator, get_variable_efficiency, operator_expression
23+
from temoa.components.utils import (
24+
Operator,
25+
get_adjusted_existing_capacity,
26+
get_variable_efficiency,
27+
operator_expression,
28+
)
2429

2530
if TYPE_CHECKING:
2631
from pyomo.core import Expression
@@ -1043,11 +1048,10 @@ def limit_growth_capacity(
10431048

10441049
if p == model.time_optimize.first():
10451050
# First future period. Grab available capacity in last existing period
1046-
# Adjust in-line for past PLF because we are constraining available capacity
10471051
p_prev = model.time_exist.last()
10481052
capacity_prev = sum(
1049-
value(model.existing_capacity[_r, _t, _v])
1050-
* min(1.0, (_v + value(model.lifetime_process[_r, _t, _v]) - p_prev) / (p - p_prev))
1053+
get_adjusted_existing_capacity(model, _r, _t, _v)
1054+
* value(model.process_life_frac[_r, p_prev, _t, _v])
10511055
for _r, _t, _v in model.existing_capacity.sparse_keys()
10521056
if _r in regions
10531057
and _t in techs

temoa/components/technology.py

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717

1818
from pyomo.environ import value
1919

20+
from temoa.components.utils import get_adjusted_existing_capacity
21+
2022
if TYPE_CHECKING:
2123
from collections.abc import Iterable
2224

@@ -52,15 +54,31 @@ def model_process_life_indices(
5254
the periods in which a process is active, distinct from TechLifeFracIndices that
5355
returns indices only for processes that EOL mid-period.
5456
"""
55-
return model.active_activity_rptv
57+
indices = {
58+
(r, model.time_exist.last(), t, v) for r, t, v in model.existing_capacity.sparse_keys()
59+
}
60+
indices = indices | model.active_activity_rptv
61+
62+
return indices
63+
64+
65+
def lifetime_tech_indices(model: TemoaModel) -> set[tuple[Region, Technology]]:
66+
"""
67+
Based on the efficiency parameter's indices, this function returns the set of
68+
process indices that may be specified in the lifetime_tech parameter.
69+
"""
70+
indices = {(r, t) for r, _, t, _, _ in set(model.efficiency.sparse_keys())}
71+
indices = indices | {(r, t) for r, t, _ in model.existing_capacity.sparse_keys()}
72+
73+
return indices
5674

5775

5876
def lifetime_process_indices(model: TemoaModel) -> set[tuple[Region, Technology, Vintage]]:
5977
"""
6078
Based on the efficiency parameter's indices, this function returns the set of
6179
process indices that may be specified in the lifetime_process parameter.
6280
"""
63-
indices = {(r, t, v) for r, i, t, v, o in set(model.efficiency.sparse_keys())}
81+
indices = {(r, t, v) for r, _, t, v, _ in set(model.efficiency.sparse_keys())}
6482
indices = indices | set(model.existing_capacity.sparse_keys())
6583

6684
return indices
@@ -424,14 +442,18 @@ def check_existing_capacity(model: TemoaModel) -> None:
424442
)
425443
logger.warning(msg)
426444
continue
427-
if t not in model.tech_all:
445+
adjusted_cap = get_adjusted_existing_capacity(model, r, t, v)
446+
if adjusted_cap <= 0.0:
447+
# Was retired in a previous period (myopic mode)
428448
continue
449+
p = model.time_optimize.first()
429450
life = value(model.lifetime_process[r, t, v])
430-
if (r, t, v) not in model.process_periods and v + life > model.time_optimize.first():
451+
if (r, t, v) not in model.process_periods and v + life > p:
452+
surviving_cap = adjusted_cap * value(model.lifetime_survival_curve[r, p, t, v])
431453
msg = (
432-
f'Existing capacity {r, t, v} with lifetime {life} and capacity {cap} '
433-
'should extend into future periods but it is not in process periods. '
434-
'Was it included in the Efficiency table?'
454+
f'Existing capacity {r, t, v} with lifetime {life} and surviving capacity '
455+
f'{surviving_cap} should extend into future periods but is not an active '
456+
'process. It may be missing from the Efficiency table or have too little '
457+
'capacity to output and carry forward if running in myopic mode.'
435458
)
436-
logger.error(msg)
437-
raise ValueError(msg)
459+
logger.warning(msg)

temoa/components/time.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -184,18 +184,18 @@ def init_set_time_optimize(model: TemoaModel) -> list[int]:
184184
return sorted(model.time_future)[:-1]
185185

186186

187-
def init_set_vintage_exist(model: TemoaModel) -> list[int]:
188-
"""Initializes the `vintage_exist` set."""
189-
return sorted(model.time_exist)
190-
191-
192187
def init_set_vintage_optimize(model: TemoaModel) -> list[int]:
193188
"""Initializes the `vintage_optimize` set."""
194189
return sorted(model.time_optimize)
195190

196191

197192
def param_period_length(model: TemoaModel, p: Period) -> int:
198193
"""Rule to calculate the length of each optimization period in years."""
194+
if model.time_exist and p == model.time_exist.last():
195+
# Need this for one specific use case (capacity growth constraints)
196+
return model.time_future.first() - model.time_exist.last()
197+
elif p in model.time_exist:
198+
return -1 # Period length is not defined for existing periods except the last
199199
periods: list[int] = sorted(model.time_future)
200200
i: int = periods.index(p)
201201
return periods[i + 1] - periods[i]

temoa/components/utils.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,20 @@ def get_capacity_factor(
8787
if model.is_capacity_factor_process[r, t, v]:
8888
return value(model.capacity_factor_process[r, s, d, t, v])
8989
return value(model.capacity_factor_tech[r, s, d, t])
90+
91+
92+
def get_adjusted_existing_capacity(
93+
model: TemoaModel, r: Region, t: Technology, v: Vintage
94+
) -> float:
95+
"""
96+
Returns the built existing capacity adjusted for any early retirements.
97+
98+
Needed for early retirements in myopic mode. Takes into account survival curves
99+
and any early retirements that may have occurred prior to this planning step.
100+
"""
101+
capacity_adjustment = sum(
102+
value(model.retired_existing_capacity[r, _p, t, v])
103+
/ (value(model.lifetime_survival_curve[r, _p, t, v]) or 1)
104+
for _p in model.time_exist
105+
)
106+
return value(model.existing_capacity[r, t, v]) - capacity_adjustment

temoa/core/model.py

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from pyomo.core import BuildCheck, Set, Var
1515
from pyomo.environ import (
1616
AbstractModel,
17+
Any,
1718
BuildAction,
1819
Constraint,
1920
Integers,
@@ -212,9 +213,9 @@ def __init__(self, *args: object, **kwargs: object) -> None:
212213
ordered=True, initialize=time.init_set_time_optimize, within=self.time_future
213214
)
214215
# Define time period vintages to track capacity installation
215-
self.vintage_exist = Set(ordered=True, initialize=time.init_set_vintage_exist)
216+
self.vintage_exist = Set(ordered=True)
216217
self.vintage_optimize = Set(ordered=True, initialize=time.init_set_vintage_optimize)
217-
self.vintage_all = Set(initialize=self.time_exist | self.time_optimize)
218+
self.vintage_all = Set(initialize=self.vintage_exist | self.time_optimize)
218219
# Perform some basic validation on the specified time periods.
219220
self.validate_time = BuildAction(rule=time.validate_time)
220221

@@ -335,7 +336,9 @@ def __init__(self, *args: object, **kwargs: object) -> None:
335336
# Define time-related parameters
336337
# Basic period construction
337338
self.time_sequencing = Set() # How do states carry between time segments?
338-
self.period_length = Param(self.time_optimize, initialize=time.param_period_length)
339+
self.period_length = Param(
340+
self.time_optimize | self.time_exist, initialize=time.param_period_length
341+
)
339342
self.days_per_period = Param(domain=PositiveReals, default=365.0)
340343
self.time_of_day_hours = Param(self.time_of_day, domain=PositiveReals, default=1.0)
341344
self.segment_fraction_per_season = Param(self.time_season)
@@ -383,6 +386,10 @@ def __init__(self, *args: object, **kwargs: object) -> None:
383386
self.capacity_to_activity = Param(self.regional_indices, self.tech_all, default=1)
384387

385388
self.existing_capacity = Param(self.regional_indices, self.tech_exist, self.vintage_exist)
389+
# This is needed to handle past retirements in myopic mode. Maybe it will find other uses.
390+
self.retired_existing_capacity = Param(
391+
self.regional_indices, self.time_exist, self.tech_exist, self.vintage_exist, default=0
392+
)
386393

387394
# Dev Note: The below is temporarily useful for passing down to validator to find
388395
# set violations
@@ -431,9 +438,8 @@ def __init__(self, *args: object, **kwargs: object) -> None:
431438
default=1,
432439
)
433440

434-
self.lifetime_tech = Param(
435-
self.regional_indices, self.tech_all, default=TemoaModel.default_lifetime_tech
436-
)
441+
self.lifetime_tech_rt = Set(dimen=2, initialize=technology.lifetime_tech_indices)
442+
self.lifetime_tech = Param(self.lifetime_tech_rt, default=TemoaModel.default_lifetime_tech)
437443

438444
self.lifetime_process_rtv = Set(dimen=3, initialize=technology.lifetime_process_indices)
439445
self.lifetime_process = Param(
@@ -443,7 +449,7 @@ def __init__(self, *args: object, **kwargs: object) -> None:
443449
self.lifetime_survival_curve = Param(
444450
self.regional_indices,
445451
Integers,
446-
self.tech_all,
452+
self.tech_all | self.tech_exist,
447453
self.vintage_all,
448454
default=technology.get_default_survival,
449455
validate=validate_0to1,
@@ -621,22 +627,22 @@ def __init__(self, *args: object, **kwargs: object) -> None:
621627
)
622628

623629
self.limit_growth_capacity = Param(
624-
self.regional_global_indices, self.tech_or_group, self.operator
630+
self.regional_global_indices, self.tech_or_group, self.operator, within=Any
625631
)
626632
self.limit_degrowth_capacity = Param(
627-
self.regional_global_indices, self.tech_or_group, self.operator
633+
self.regional_global_indices, self.tech_or_group, self.operator, within=Any
628634
)
629635
self.limit_growth_new_capacity = Param(
630-
self.regional_global_indices, self.tech_or_group, self.operator
636+
self.regional_global_indices, self.tech_or_group, self.operator, within=Any
631637
)
632638
self.limit_degrowth_new_capacity = Param(
633-
self.regional_global_indices, self.tech_or_group, self.operator
639+
self.regional_global_indices, self.tech_or_group, self.operator, within=Any
634640
)
635641
self.limit_growth_new_capacity_delta = Param(
636-
self.regional_global_indices, self.tech_or_group, self.operator
642+
self.regional_global_indices, self.tech_or_group, self.operator, within=Any
637643
)
638644
self.limit_degrowth_new_capacity_delta = Param(
639-
self.regional_global_indices, self.tech_or_group, self.operator
645+
self.regional_global_indices, self.tech_or_group, self.operator, within=Any
640646
)
641647

642648
self.limit_emission_constraint_rpe = Set(

temoa/data_io/component_manifest.py

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,14 @@ def build_manifest(model: TemoaModel) -> list[LoadItem]:
288288
is_period_filtered=False, # Custom loader handles all logic
289289
is_table_required=False,
290290
),
291+
LoadItem(
292+
component=model.retired_existing_capacity,
293+
table='output_retired_capacity',
294+
columns=['region', 'period', 'tech', 'vintage', 'cap_early'],
295+
custom_loader_name='_load_retired_existing_capacity',
296+
is_period_filtered=False, # Custom loader handles all logic
297+
is_table_required=False,
298+
),
291299
LoadItem(
292300
component=model.cost_invest,
293301
table='cost_invest',
@@ -419,26 +427,23 @@ def build_manifest(model: TemoaModel) -> list[LoadItem]:
419427
component=model.lifetime_tech,
420428
table='lifetime_tech',
421429
columns=['region', 'tech', 'lifetime'],
422-
validator_name='viable_rt',
423-
validation_map=(0, 1),
430+
custom_loader_name='_load_lifetime_tech',
424431
is_period_filtered=False,
425432
is_table_required=False,
426433
),
427434
LoadItem(
428435
component=model.lifetime_process,
429436
table='lifetime_process',
430437
columns=['region', 'tech', 'vintage', 'lifetime'],
431-
validator_name='viable_rtv',
432-
validation_map=(0, 1, 2),
438+
custom_loader_name='_load_lifetime_process',
433439
is_period_filtered=False,
434440
is_table_required=False,
435441
),
436442
LoadItem(
437443
component=model.lifetime_survival_curve,
438444
table='lifetime_survival_curve',
439445
columns=['region', 'period', 'tech', 'vintage', 'fraction'],
440-
validator_name='viable_rtv',
441-
validation_map=(0, 2, 3),
446+
custom_loader_name='_load_lifetime_survival_curve',
442447
is_period_filtered=False,
443448
is_table_required=False,
444449
),
@@ -649,6 +654,66 @@ def build_manifest(model: TemoaModel) -> list[LoadItem]:
649654
validation_map=(0, 1, 2),
650655
is_table_required=False,
651656
),
657+
LoadItem(
658+
component=model.limit_growth_capacity,
659+
table='limit_growth_capacity',
660+
columns=['region', 'tech_or_group', 'operator', 'rate', 'seed'],
661+
index_length=3,
662+
validator_name='viable_rt',
663+
validation_map=(0, 1),
664+
is_period_filtered=False,
665+
is_table_required=False,
666+
),
667+
LoadItem(
668+
component=model.limit_growth_new_capacity,
669+
table='limit_growth_new_capacity',
670+
columns=['region', 'tech_or_group', 'operator', 'rate', 'seed'],
671+
index_length=3,
672+
validator_name='viable_rt',
673+
validation_map=(0, 1),
674+
is_period_filtered=False,
675+
is_table_required=False,
676+
),
677+
LoadItem(
678+
component=model.limit_growth_new_capacity_delta,
679+
table='limit_growth_new_capacity_delta',
680+
columns=['region', 'tech_or_group', 'operator', 'rate', 'seed'],
681+
index_length=3,
682+
validator_name='viable_rt',
683+
validation_map=(0, 1),
684+
is_period_filtered=False,
685+
is_table_required=False,
686+
),
687+
LoadItem(
688+
component=model.limit_degrowth_capacity,
689+
table='limit_degrowth_capacity',
690+
columns=['region', 'tech_or_group', 'operator', 'rate', 'seed'],
691+
index_length=3,
692+
validator_name='viable_rt',
693+
validation_map=(0, 1),
694+
is_period_filtered=False,
695+
is_table_required=False,
696+
),
697+
LoadItem(
698+
component=model.limit_degrowth_new_capacity,
699+
table='limit_degrowth_new_capacity',
700+
columns=['region', 'tech_or_group', 'operator', 'rate', 'seed'],
701+
index_length=3,
702+
validator_name='viable_rt',
703+
validation_map=(0, 1),
704+
is_period_filtered=False,
705+
is_table_required=False,
706+
),
707+
LoadItem(
708+
component=model.limit_degrowth_new_capacity_delta,
709+
table='limit_degrowth_new_capacity_delta',
710+
columns=['region', 'tech_or_group', 'operator', 'rate', 'seed'],
711+
index_length=3,
712+
validator_name='viable_rt',
713+
validation_map=(0, 1),
714+
is_period_filtered=False,
715+
is_table_required=False,
716+
),
652717
LoadItem(
653718
component=model.limit_resource,
654719
table='limit_resource',

0 commit comments

Comments
 (0)