Skip to content

EIA profile-based powerplant age imputation#31

Open
ddahawkins-TUDelft wants to merge 7 commits into
modelblocks-org:mainfrom
ddahawkins-TUDelft:feature/deterministic-age-imputation
Open

EIA profile-based powerplant age imputation#31
ddahawkins-TUDelft wants to merge 7 commits into
modelblocks-org:mainfrom
ddahawkins-TUDelft:feature/deterministic-age-imputation

Conversation

@ddahawkins-TUDelft

Copy link
Copy Markdown

Fixes #

Summary of changes in this pull request

Adds a deterministic alternative to group-average powerplant age imputation.

The new method:

  • derives annual commissioning weights from positive changes in reference capacity stock;
  • preserves observed commissioning years;
  • allocates missing-capacity plants against the remaining target profile;
  • respects technology lifetime constraints;
  • writes row-level and annual profile diagnostics as Parquet;
  • produces diagnostic plots comparing observed, imputed, and target profiles.

Validation

Tested on:

  • MEX wind, solar, and fossil datasets;
  • all European categories;
  • multi-country diagnostic profile outputs.

Notes

Powerplant categories are harmonised with the reference statistics categories for:

  • bioenergybiomass and waste
  • fossilfossil fuels

Reviewer checklist

  • There are no pip dependencies in the module's environment files (workflow/envs/).
  • All rules use pathvars (e.g., <results>) in their inputs and outputs.
  • The integration test-suite is successful, including:
    • pre-commit.ci tests pass.
    • tests pass for all relevant OS configurations (linux, osx, windows).
  • Module documentation is up-to-date, including:
    • INTERFACE.yaml mentions all relevant pathvars and wildcards.
    • README.md describes how to use the module and has the necessary citations.

@irm-codebase irm-codebase self-requested a review July 8, 2026 13:25
@ddahawkins-TUDelft

Copy link
Copy Markdown
Author

Adding comment. The new method is only applied to assets with the 'operating' status. It is not yet applied to historical (retired) nor future statuses (pre-construction, construction, announcements). Once you are happy with the methodology as a concept, I can extend it as these other categories will require slightly different target profiles e.g. a linear forecast for the future ones.

Comment on lines +28 to +31
# Harmonise powerplant categories with the category names used by the
# annual reference-capacity dataset.
REFERENCE_CATEGORY_MAP = {"fossil": "fossil fuels", "bioenergy": "biomass and waste"}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is a duplicate of EIA_CAT_MAPPING in scripts/_utils.py.
Please use that mapping (should work just fine in all cases).

Comment on lines 20 to 27
HISTORICAL = {"operating", "retired"}
CURRENT = {"operating"}
SCENARIO_MAP = {
"historical": HISTORICAL,
"construction": HISTORICAL | {"construction"},
"pre_construction": HISTORICAL | {"construction", "pre-construction"},
"announced": HISTORICAL | {"construction", "pre-construction", "announced"},
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We should only have one source of truth in the code.
CURRENT has some overlap with HISTORICAL.

We likely need a better data structure to handle this.

Comment on lines +590 to +608
def _save_age_imputation_diagnostics(
diagnostics: pd.DataFrame, output_path_str: str
) -> None:
"""Save row-level age-imputation diagnostics if requested."""
output_path = Path(output_path_str)
output_path.parent.mkdir(parents=True, exist_ok=True)
diagnostics.to_parquet(output_path, index=False)


def _save_age_profile_diagnostics(
diagnostics: pd.DataFrame, output_path_str: str | None
) -> None:
"""Save annual age-imputation profile diagnostics."""
if output_path_str is None:
return

output_path = Path(output_path_str)
output_path.parent.mkdir(parents=True, exist_ok=True)
diagnostics.to_parquet(output_path, index=False)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Both of these are unnecessary.

  • Snakemake is in charge of doing mkdir.
  • main() should be in charge of all file creation decisions.

return result


def _impute_remaining_start_years(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We should consider whether the "group_average" method is even justified any more. Looking at the results, there should be no situation in which this would be preferable to the capacity profile. If so, we should just replace with method == "drop" for over-conservative cases.

Can be a separate issue / PR.

Comment on lines +428 to +437
if method == "group_average":
return (_impute_start_years_by_group_average(prepared_df), pd.DataFrame())

if method == "capacity_profile":
return _impute_start_years_by_capacity_profile(
prepared_df,
reference_capacity_df=reference_capacity_df,
lifetimes=lifetimes,
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Are the returned diagnostics really necessary?
A lot of code is dedicated / branching is dedicated to them.

group_average is even producing a dummy to comply with them, suggesting an anti-pattern.

Comment on lines 703 to +706
if relocated_gdf.empty:
imputed = relocated_gdf
_save_age_imputation_diagnostics(pd.DataFrame(), age_imputation_output_path)
_save_age_profile_diagnostics(pd.DataFrame(), age_profile_output_path)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Move this check to main and remove the functions.
That function "owns" file creation. If we must keep diagnostics, just make impute return a tuple.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Seems like the plotting functions do not respect this either, but we should strive to do this to keep the code tidier.

Comment on lines +746 to +754
diagnostics = _build_age_imputation_diagnostics(
original_df=original,
aged_df=imputed,
status_final=status_final,
start_year_source_type=start_year_source_type,
end_year_source_type=end_year_source_type,
lifetimes=lifetimes,
start_year_imputation_method=start_year_imputation_method,
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

These diagnostics might be more justified than the profile ones, but we should still consider if they are really needed. They are resulting in a lot of duplicated data and extended lines of code. It's OK to save some diagnostics, but only the minimum necessary.

Comment on lines +928 to +929
countries = sorted(profile_df["country_id"].dropna().unique())
n_countries = len(countries)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

dropna is not necessary (and may silently let issues pass)

Suggested change
countries = sorted(profile_df["country_id"].dropna().unique())
n_countries = len(countries)
countries = sorted(profile_df["country_id"].unique())
n_countries = len(countries)

Comment on lines +934 to +935
observed_color = cmap(0)
imputed_color = cmap(1)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

You can just remove this and instead use color=tab20:blue or color=tab20:cyan below.

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