Skip to content

Fix interaction between survival curves and early retirement#294

Merged
ParticularlyPythonicBS merged 3 commits intoTemoaProject:unstablefrom
idelder:fix/survival_curve_retirements
Apr 8, 2026
Merged

Fix interaction between survival curves and early retirement#294
ParticularlyPythonicBS merged 3 commits intoTemoaProject:unstablefrom
idelder:fix/survival_curve_retirements

Conversation

@idelder
Copy link
Copy Markdown
Collaborator

@idelder idelder commented Apr 8, 2026

  1. Update the check in annual_retirement_constraint for survival_curve case
  2. Consolidate and improve start and end of life validation on survival curves to prevent divide-by-zero edge cases

Summary by CodeRabbit

  • Bug Fixes

    • Improved capacity retirement accounting to accurately track end-of-life capacity across periods
    • Enhanced demand distribution validation with stricter enforcement and error detection
    • Refined technology survival curve validation logic for improved calculation accuracy
  • Refactor

    • Streamlined demand distribution construction and initialization process

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 8, 2026

Warning

Rate limit exceeded

@idelder has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 0 minutes and 56 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 0 minutes and 56 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7da63b79-dc84-4308-bc36-8a3dabbb4eb1

📥 Commits

Reviewing files that changed from the base of the PR and between 78ff2ad and bd791d0.

📒 Files selected for processing (2)
  • temoa/components/capacity.py
  • temoa/components/technology.py

Walkthrough

This PR refines validation logic and streamlines code across three components: tightening vintage-based conditions for retirement accounting, consolidating survival curve endpoint validation, and simplifying demand distribution construction by removing unused imports and eliminating a global fill strategy.

Changes

Cohort / File(s) Summary
Retirement Accounting
temoa/components/capacity.py
Added vintage conditions (v < p_next and v < p) to narrow when retired capacity is added to period-end and period-beginning capacity adjustments.
Demand Distribution Refactor
temoa/components/commodities.py
Removed unused imports (sys, itemgetter) and replaced index extraction helpers with direct model.demand_specific_distribution access; eliminated global fill strategy and refined validation to check sum-to-1 constraint and detect over-defined keys.
Survival Curve Validation
temoa/components/technology.py
Consolidated four endpoint conditions (start/end period alignment and survival fractions) into single validation block; replaced period collection membership check with setdefault pattern; removed granular lifetime alignment and monotonicity checks.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested labels

bugfix, refactor

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Fix interaction between survival curves and early retirement' directly and clearly summarizes the main changes, which focus on correcting how retired capacity interacts with survival curves in the retirement constraint logic.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@temoa/components/technology.py`:
- Around line 247-258: The current validation only checks
lifetime_survival_curve at the start (p_first) and end (eol_year) but allows
zeros in intermediate years, which later can cause divide-by-zero in
adjusted_capacity_constraint when dividing v_retired_capacity by
lifetime_survival_curve; update the check in the validation block around
lifetime_survival_curve to also iterate all intermediate vintage years between
p_first and eol_year and assert value(model.lifetime_survival_curve[r, y, t, v])
> 0 for each y (or raise the same ValueError with a clear message referencing
lifetime_survival_curve, v_retired_capacity, and adjusted_capacity_constraint)
so any zero before eol_year is rejected.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 120e411b-c43b-4cb6-a655-aa8a9e419ac9

📥 Commits

Reviewing files that changed from the base of the PR and between 3ec2512 and 78ff2ad.

📒 Files selected for processing (3)
  • temoa/components/capacity.py
  • temoa/components/commodities.py
  • temoa/components/technology.py

Comment on lines +247 to 258
if (
p_first != v
or p_last != eol_year
or value(model.lifetime_survival_curve[r, v, t, v]) != 1
or value(model.lifetime_survival_curve[r, eol_year, t, v]) != 0
):
msg = (
'lifetime_survival_curve must be defined starting in the vintage period. Must '
f'define ({r}, >{v}<, {t}, {v})'
'lifetime_survival_curve must be defined as 1 at start and 0 at end of life. Must '
f'define ({r}, >{v}<, {t}, {v}) = 1 and ({r}, >{eol_year}<, {t}, {v}) = 0.'
)
logger.error(msg)
raise ValueError(msg)
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Reject zero survival fractions before end-of-life.

This check only validates the endpoints, so a curve that reaches 0 before eol_year and stays there still passes. adjusted_capacity_constraint() later divides v_retired_capacity by lifetime_survival_curve[r, S_p, t, v], so any eligible early-retirement period at or after that first zero still hits the divide-by-zero path this PR is trying to eliminate.

🛠️ Suggested validation hardening
         if (
             p_first != v
             or p_last != eol_year
             or value(model.lifetime_survival_curve[r, v, t, v]) != 1
             or value(model.lifetime_survival_curve[r, eol_year, t, v]) != 0
         ):
             msg = (
                 'lifetime_survival_curve must be defined as 1 at start and 0 at end of life. Must '
                 f'define ({r}, >{v}<, {t}, {v}) = 1 and ({r}, >{eol_year}<, {t}, {v}) = 0.'
             )
             logger.error(msg)
             raise ValueError(msg)
+
+        for p_chk in periods_rtv[:-1]:
+            if value(model.lifetime_survival_curve[r, p_chk, t, v]) <= 0:
+                msg = (
+                    'lifetime_survival_curve must stay strictly positive before end of life. '
+                    f'Found non-positive value at {(r, p_chk, t, v)}.'
+                )
+                logger.error(msg)
+                raise ValueError(msg)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@temoa/components/technology.py` around lines 247 - 258, The current
validation only checks lifetime_survival_curve at the start (p_first) and end
(eol_year) but allows zeros in intermediate years, which later can cause
divide-by-zero in adjusted_capacity_constraint when dividing v_retired_capacity
by lifetime_survival_curve; update the check in the validation block around
lifetime_survival_curve to also iterate all intermediate vintage years between
p_first and eol_year and assert value(model.lifetime_survival_curve[r, y, t, v])
> 0 for each y (or raise the same ValueError with a clear message referencing
lifetime_survival_curve, v_retired_capacity, and adjusted_capacity_constraint)
so any zero before eol_year is rejected.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lifetime_survival_curve has a default value of zero so it will always be zero outside the lifetime. I don't believe this case would ever come up

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh wait I see okay there is a check that logs as info but should be an error

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

Let me look at the actual constraint to verify whether it already guards against zero survival fractions.


🏁 Script executed:

#!/bin/bash
# Look at adjusted_capacity_constraint and retired_capacity_variable_indices in capacity.py
rg -n "adjusted_capacity_constraint|retired_capacity_variable_indices|lifetime_survival_curve" temoa/components/capacity.py -A 5 -B 2

Length of output: 2771


@idelder, I understand the argument about the default — years outside the lifetime will always be 0 by default, and the constraint conditions (S_p + period_length ≤ eol_year) correctly exclude those. My concern is about years inside the lifetime (strictly between v and eol_year) where the curve could legally reach 0 before eol_year and still pass every current validation check.

The two relevant division sites found in capacity.py are:

Line 322 (inside annual_retirement_constraint):

/ value(model.lifetime_survival_curve[r, p, t, v])

Line 557 (inside adjusted_capacity_constraint):

/ value(model.lifetime_survival_curve[r, S_p, t, v])  # relative survival since then

with the guard v < S_p <= p and S_p + period_length[S_p] <= eol_year.

A concrete scenario that passes all current validation but triggers the divide-by-zero:

Year lifetime_survival_curve[r, year, t, v]
v 1.0
v+3 0.0
v+5 (= eol_year) 0.0
  • p_first == v ✓, p_last == eol_year ✓, starts at 1 ✓, ends at 0 ✓, monotonically non-increasing ✓ — all checks pass.
  • If an optimization period starts at v+3 with period_length = 2, then S_p + period_length = v+5 = eol_year ≤ eol_year — the loop condition is satisfied.
  • Line 557 then divides v_retired_capacity[r, v+3, t, v] by lifetime_survival_curve[r, v+3, t, v] = 0.0ZeroDivisionError.

The proposed fix (rejecting any non-positive value in periods_rtv[:-1]) is the minimal safeguard:

for p_chk in periods_rtv[:-1]:
    if value(model.lifetime_survival_curve[r, p_chk, t, v]) <= 0:
        msg = (
            'lifetime_survival_curve must stay strictly positive before end of life. '
            f'Found non-positive value at {(r, p_chk, t, v)}.'
        )
        logger.error(msg)
        raise ValueError(msg)

This runs after interpolation has filled in all intermediate years, so it catches both explicitly-set zeros and interpolated-to-zero values before the model ever runs.


🧠 Learnings used
Learnt from: idelder
Repo: TemoaProject/temoa PR: 279
File: temoa/components/capacity.py:620-628
Timestamp: 2026-03-17T22:51:03.116Z
Learning: In TemoaProject/temoa `temoa/components/capacity.py`, `create_capacity_and_retirement_sets` must NOT call `value(model.existing_capacity[r, t, v])` as a guard on processes that arrived via `efficiency.sparse_iterkeys()` only, because those processes may have no explicit entry in `existing_capacity` and the access would throw a KeyError/Pyomo error. The upstream `populate_core_dictionaries` in `temoa/components/technology.py` already raises a hard `ValueError` for efficiency-based processes with zero/non-positive existing capacity, so by the time `create_capacity_and_retirement_sets` runs, any `(r, t, v)` in `model.process_periods` with `v in time_exist` is guaranteed to have a valid positive capacity. Do not add a redundant `existing_capacity` guard inside `create_capacity_and_retirement_sets`.

Learnt from: idelder
Repo: TemoaProject/temoa PR: 292
File: temoa/components/capacity.py:335-341
Timestamp: 2026-04-08T19:39:58.846Z
Learning: In TemoaProject/temoa `temoa/components/capacity.py`, `adjusted_capacity_constraint` contains a bug where the early-retirement summation uses a strict `<` guard (`S_p < v + lifetime - period_length[S_p]`, i.e., `S_p_end < eol_year`) instead of `<=`. This means decision retirements in the final pre-EOL period (where `p_end == eol_year`) are never subtracted from `v_capacity`, even though `retired_capacity_variable_indices` allows `v_retired_capacity` to be nonzero for that period (its condition uses non-strict `<=`). The strict `<` guards in `annual_retirement_constraint` (for adding retirement back to cap_begin/cap_end) mask this bug because the two errors cancel out. Both constraints must be fixed together: change both to `<=` so the boundary case is handled consistently.

Learnt from: idelder
Repo: TemoaProject/temoa PR: 279
File: temoa/components/technology.py:163-169
Timestamp: 2026-03-17T21:41:26.527Z
Learning: In TemoaProject/temoa `temoa/components/technology.py`, the two non-positive existing-capacity checks are intentionally different in severity:
1. Inside `populate_core_dictionaries` (iterating over `efficiency.sparse_iterkeys()`): any process that reaches this check has an efficiency entry and would be loaded into the optimization model, where a zero or negative existing capacity causes infeasibility — so a hard `ValueError` is correct.
2. Inside `check_existing_capacity` (iterating over all `existing_capacity.sparse_iterkeys()`): entries here may have no corresponding efficiency entry (non-process existing capacity values) and never enter the model directly — so warn-and-skip (continue) is correct.
Do not flag this difference as an inconsistency; it is intentional.

Learnt from: idelder
Repo: TemoaProject/temoa PR: 277
File: temoa/model_checking/network_model_data.py:357-361
Timestamp: 2026-03-16T18:27:06.688Z
Learning: In TemoaProject/temoa, `NetworkModelData.demand_commodities` in `temoa/model_checking/network_model_data.py` is used only as a starting-node set for the commodity network sweep, not as a validation or filter set. Having extra period keys outside the myopic window is harmless because `available_techs` is already strictly filtered to the active myopic periods; unreachable demand entries are simply ignored during the sweep. Do not flag unfiltered demand_commodities in myopic mode as a correctness issue.

Learnt from: idelder
Repo: TemoaProject/temoa PR: 269
File: temoa/components/technology.py:372-378
Timestamp: 2026-03-09T13:24:27.000Z
Learning: In temoa/components/technology.py, the defunct tech_resource set has been removed. All technologies are now subsets of tech_production, making tech_production the correct reference set for check_efficiency_indices. Do not revert validation back to tech_all; continue using tech_production as the authoritative set.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@idelder, yes — that's exactly the spot. The existing block in the monotonicity loop:

if lsc < 0.0001:
    if p != p_last:
        msg = (
            'There is no need to continue a survival curve beyond fraction ~= 0. '
            f'ignoring periods beyond {p} for ({r, t, v})'
        )
        logger.info(msg)
    continue

needs to become a hard error when p != p_last, because silently continue-ing allows the near-zero value to persist in lifetime_survival_curve for an intermediate year, which is then used as a divisor in the constraints:

if lsc < 0.0001:
    if p != p_last:
        msg = (
            'lifetime_survival_curve must stay strictly positive before end of life. '
            f'Found non-positive value at ({r}, {p}, {t}, {v}). '
            'The survival curve cannot reach ~0 before the end-of-life year.'
        )
        logger.error(msg)
        raise ValueError(msg)
    continue  # p == p_last: expected terminal zero, fine to stop here

The continue branch for p == p_last is correct and can stay as-is — reaching zero exactly at eol_year is the expected terminal condition and is already excluded by the constraint guards. Only the intermediate near-zero case needs to be a ValueError.


🧠 Learnings used
Learnt from: idelder
Repo: TemoaProject/temoa PR: 279
File: temoa/components/capacity.py:620-628
Timestamp: 2026-03-17T22:51:03.116Z
Learning: In TemoaProject/temoa `temoa/components/capacity.py`, `create_capacity_and_retirement_sets` must NOT call `value(model.existing_capacity[r, t, v])` as a guard on processes that arrived via `efficiency.sparse_iterkeys()` only, because those processes may have no explicit entry in `existing_capacity` and the access would throw a KeyError/Pyomo error. The upstream `populate_core_dictionaries` in `temoa/components/technology.py` already raises a hard `ValueError` for efficiency-based processes with zero/non-positive existing capacity, so by the time `create_capacity_and_retirement_sets` runs, any `(r, t, v)` in `model.process_periods` with `v in time_exist` is guaranteed to have a valid positive capacity. Do not add a redundant `existing_capacity` guard inside `create_capacity_and_retirement_sets`.

Learnt from: idelder
Repo: TemoaProject/temoa PR: 292
File: temoa/components/capacity.py:335-341
Timestamp: 2026-04-08T19:39:58.846Z
Learning: In TemoaProject/temoa `temoa/components/capacity.py`, `adjusted_capacity_constraint` contains a bug where the early-retirement summation uses a strict `<` guard (`S_p < v + lifetime - period_length[S_p]`, i.e., `S_p_end < eol_year`) instead of `<=`. This means decision retirements in the final pre-EOL period (where `p_end == eol_year`) are never subtracted from `v_capacity`, even though `retired_capacity_variable_indices` allows `v_retired_capacity` to be nonzero for that period (its condition uses non-strict `<=`). The strict `<` guards in `annual_retirement_constraint` (for adding retirement back to cap_begin/cap_end) mask this bug because the two errors cancel out. Both constraints must be fixed together: change both to `<=` so the boundary case is handled consistently.

Learnt from: idelder
Repo: TemoaProject/temoa PR: 279
File: temoa/components/technology.py:163-169
Timestamp: 2026-03-17T21:41:26.527Z
Learning: In TemoaProject/temoa `temoa/components/technology.py`, the two non-positive existing-capacity checks are intentionally different in severity:
1. Inside `populate_core_dictionaries` (iterating over `efficiency.sparse_iterkeys()`): any process that reaches this check has an efficiency entry and would be loaded into the optimization model, where a zero or negative existing capacity causes infeasibility — so a hard `ValueError` is correct.
2. Inside `check_existing_capacity` (iterating over all `existing_capacity.sparse_iterkeys()`): entries here may have no corresponding efficiency entry (non-process existing capacity values) and never enter the model directly — so warn-and-skip (continue) is correct.
Do not flag this difference as an inconsistency; it is intentional.

Learnt from: idelder
Repo: TemoaProject/temoa PR: 277
File: temoa/model_checking/network_model_data.py:357-361
Timestamp: 2026-03-16T18:27:06.688Z
Learning: In TemoaProject/temoa, `NetworkModelData.demand_commodities` in `temoa/model_checking/network_model_data.py` is used only as a starting-node set for the commodity network sweep, not as a validation or filter set. Having extra period keys outside the myopic window is harmless because `available_techs` is already strictly filtered to the active myopic periods; unreachable demand entries are simply ignored during the sweep. Do not flag unfiltered demand_commodities in myopic mode as a correctness issue.

Learnt from: idelder
Repo: TemoaProject/temoa PR: 281
File: temoa/components/limits.py:509-514
Timestamp: 2026-03-21T00:30:54.385Z
Learning: In TemoaProject/temoa `temoa/components/limits.py`, the `possible_activity_rptvo` RHS sum in `limit_annual_capacity_factor_constraint` intentionally includes capacity for ALL technologies in the expanded tech group (`techs`), regardless of whether each individual tech produces the constrained output commodity `o`. This is by design: the constraint compares activity to total group capacity, and it is the modeller's responsibility to be aware of which technologies contribute capacity to the RHS. Do not flag this unfiltered capacity sum as a bug.

Learnt from: idelder
Repo: TemoaProject/temoa PR: 269
File: temoa/components/technology.py:372-378
Timestamp: 2026-03-09T13:24:27.000Z
Learning: In temoa/components/technology.py, the defunct tech_resource set has been removed. All technologies are now subsets of tech_production, making tech_production the correct reference set for check_efficiency_indices. Do not revert validation back to tech_all; continue using tech_production as the authoritative set.

Learnt from: idelder
Repo: TemoaProject/temoa PR: 277
File: temoa/model_checking/commodity_network_manager.py:129-131
Timestamp: 2026-03-14T02:49:00.990Z
Learning: In the TemoaProject/temoa materials module, for end-of-life (EOL) output flows, the technology is used as the input commodity (`input_comm`). This means `edge_tuple.input_comm` equals the technology name for EOL edges. Consequently, the `rtv_eol` ViableSet in `temoa/model_checking/commodity_network_manager.py` is correctly populated as `(edge_tuple.region, edge_tuple.input_comm, edge_tuple.vintage)` — do not suggest replacing `input_comm` with `tech` here.

Learnt from: idelder
Repo: TemoaProject/temoa PR: 281
File: temoa/data_io/component_manifest.py:610-624
Timestamp: 2026-03-18T19:20:59.326Z
Learning: In TemoaProject/temoa `temoa/data_io/component_manifest.py`, the `validation_map` for share-table LoadItems (`limit_capacity_share`, `limit_new_capacity_share`) intentionally validates only the numerator/sub_group column and NOT the `super_group` (denominator) column. This is a deliberate design decision: forming a viable set covering every possible combination of tech groups would be impractical. Do not flag the absence of `super_group` validation in these share-table LoadItems as a bug or missing validation.

@idelder idelder force-pushed the fix/survival_curve_retirements branch from 78ff2ad to bd791d0 Compare April 8, 2026 22:58
@ParticularlyPythonicBS ParticularlyPythonicBS merged commit ec0e2e5 into TemoaProject:unstable Apr 8, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants