Skip to content

data(nordic): per-zone carbon intensity for Swedish bidding zones SE1–SE4#1262

Open
avalyset wants to merge 1 commit into
mlco2:masterfrom
avalyset:khepri-se-per-zone-ci
Open

data(nordic): per-zone carbon intensity for Swedish bidding zones SE1–SE4#1262
avalyset wants to merge 1 commit into
mlco2:masterfrom
avalyset:khepri-se-per-zone-ci

Conversation

@avalyset

Copy link
Copy Markdown

Hi! This proposes replacing the uniform 18.0 gCO2eq/kWh placeholder currently shared by all four Swedish bidding zones in codecarbon/data/private_infra/nordic_emissions.json with per-zone, derived values. Data-only change — happy to adjust anything based on your review.

This is a companion to #1260, which does the equivalent for Norwegian zones NO1–NO5. The two PRs are independent and can be merged in either order.

What

Zone before after (gCO2eq/kWh)
SE1 (Northern Sweden) 18.0 20.6
SE2 (North-central Sweden) 18.0 20.1
SE3 (South-central Sweden, incl. Stockholm) 18.0 14.5
SE4 (Southern Sweden) 18.0 17.4

NO1–NO5 and FI are unchanged (byte-for-byte; only SE blocks + file metadata touched).

Why the placeholder is worth improving

The current 18.0 is identical across SE1–SE4 and is systematically wrong in both directions: it undervalues SE1 and SE2 (true CI ~20 gCO2eq/kWh, ~+12–15% error relative to 18.0) and overvalues SE3 (true CI ~14.5, ~−19% error). A single uniform constant cannot represent structurally different zones.

The clearest case for per-zone values

SE3 is Sweden's nuclear-dominant zone. Nuclear generation carries a lifecycle factor of ~12 gCO2eq/kWh (IPCC AR5), well below any fossil source. As a result, SE3's true CI sits at 14.5 gCO2eq/kWh — the 18.0 placeholder is roughly 19% too high. This error is structurally absent in the Norwegian zones, which are predominantly hydropower and have no nuclear-dominant zone; Norwegian CIs cluster near or above 18.0 rather than well below it.

SE1 and SE2, by contrast, give the undervaluation counterpart: their hydro-and-wind mix yields CIs of ~20 gCO2eq/kWh, making the placeholder ~12–15% too low. Together, SE3's overvaluation and SE1/SE2's undervaluation make the two-way error explicit and large — a single uniform value is wrong in opposite directions across the four Swedish zones.

Source & method

  • Source: ENTSO-E Transparency Actual Generation per Production Type (A75), per bidding zone, full year 2025.
  • Method: production-based generation-mix weighted average (ENTSO-E A75 + IPCC AR5 Annex III lifecycle factors) — each generation source weighted by its share in the annual mix and multiplied by its IPCC AR5 Annex III lifecycle emission factor.
  • This PR updates the static nordic emission factors only; it does not modify the Electricity Maps API integration (electricitymaps_api.py) or any other data path.

A point we'd like your steer on: the factor basis

We derived these with IPCC AR5 Annex III (Table A.III.2) lifecycle medians (coal 820, gas 490, hydro 24, wind 11, nuclear 12, …) rather than CodeCarbon's carbon_intensity_per_source.json. Our reasoning:

  • IPCC AR5 is a single, peer-reviewed, consistent-lifecycle basis, and it keeps these numbers identical to the upstream derivation's DOI-frozen record (so there's one citable value per zone, not two).
  • The main table difference is gas (AR5 490 vs your 743, carbon_intensity_per_source.json:9), but fossil gas is a negligible share of the Swedish mix, so the choice has minimal effect on these values — we flag it only for transparency.

That said — this is a recommendation, not a correction of your table. If you'd prefer internal consistency with carbon_intensity_per_source.json, re-deriving with your own per-source factors is a one-line change on our side; just say the word.

Honest characterization (per-zone drift notes added)

A multi-year analysis (2022–2025) is carried in the per-zone note fields:

Zone Drift Recommendation
SE1 Mix drifting (hydro→wind shift, −3.8% CI) More frequent than annual updates recommended
SE2 Stable: CI −2.4%, mix shifts <5pp Annual update sufficient
SE3 Nuclear share eroding −6.4pp; CI +7.8% (below 15% threshold) More frequent than annual updates recommended
SE4 Material drift: CI +15.2% (solar growth) More frequent than annual updates required

SE4 shows the most active drift (monotonically rising CI driven by solar growth, Solar=48 gCO2eq/kWh vs wind=11). SE2 is the only SE zone below both materiality thresholds.

Reproducibility

Every value is traceable. Derivation method, pre-registered before computation, is in the ADR chain (esp. ADR-0001 CI method); the per-zone results and multi-year drift analysis are in the repo's docs/. Frozen and citable:

Tests

The existing Nordic test (test_get_emissions_PRIVATE_INFRA_NORDIC_REGION) previously hardcoded the placeholder 0.018 for SE2; updated to 0.0201 to match the new derived value. All 9 Nordic tests pass; 8 package integrity tests pass. No other code changes.

Thanks for maintaining CodeCarbon — glad to iterate on any of this.

Replace the uniform 18.0 gCO2eq/kWh placeholder for SE1-SE4 with per-zone
production-based values (SE1 20.6, SE2 20.1, SE3 14.5, SE4 17.4) derived
from ENTSO-E generation-per-type x IPCC AR5 lifecycle factors (2025 annual
means). Adds per-zone method/source/drift notes + updated file metadata.
NO1-NO5 and FI unchanged.

Update test_get_emissions_PRIVATE_INFRA_NORDIC_REGION to reflect SE2's
new derived value (20.1 gCO2eq/kWh = 0.0201 kg/kWh). Test previously
hardcoded the placeholder 0.018; now uses the derived 2025 figure. All
9 nordic tests pass; 8 package integrity tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@avalyset
avalyset requested a review from a team as a code owner June 29, 2026 21:09
@benoit-cty

Copy link
Copy Markdown
Contributor

Thanks for your contribution.
Can you give us the exact link to review the source of the value ?

@codecov

codecov Bot commented Jun 30, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 89.68%. Comparing base (3161c53) to head (20de124).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1262      +/-   ##
==========================================
+ Coverage   89.64%   89.68%   +0.04%     
==========================================
  Files          48       48              
  Lines        4771     4771              
==========================================
+ Hits         4277     4279       +2     
+ Misses        494      492       -2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@avalyset

Copy link
Copy Markdown
Author

Hi Benoît, thanks for taking a look.

The values are derived rather than taken from a single source, so the "source" is a short chain:

Generation data: ENTSO-E Transparency, Actual Generation per Production Type (A75), per bidding zone, full year 2025. The raw series isn't committed (fetched reproducibly from the ENTSO-E API), but the exact EIC codes per zone are in ADR-0006.

Emission factors: IPCC AR5 Annex III, Table A.III.2 lifecycle medians (hydro 24, wind 11, nuclear 12, solar 48, gas 490 gCO2eq/kWh).

Method: production-based generation-mix weighted average — each source weighted by its annual share, multiplied by its factor.

The four SE values, the drift analysis, and the nine-zone table are in the README, and the whole thing is frozen and citable at https://doi.org/10.5281/zenodo.21042581.

So there's no single CSV that says "20.6" — the value is traceable through method + the two sources above. Glad to add a worked example for one zone (e.g. SE3) showing the mix × factors arithmetic explicitly if that helps.

@avalyset

avalyset commented Jul 1, 2026

Copy link
Copy Markdown
Author

Quick follow-up, @benoit-cty — I've since published a corrected version (same concept DOI, now resolves to v1.2):

https://doi.org/10.5281/zenodo.21042581

Two things this fixes for your review: the SE per-zone drift results are now included in full (docs/se-drift-results-2022-2025.md), and one correction to my earlier note — the factor source is IPCC WG III AR5 Annex III lifecycle medians (I referenced "Table A.III.2" above; the manuscript and repo now cite "Annex III" without that sub-ID). Factor values are unchanged (hydro 24, wind 11, nuclear 12, solar 48, gas 490 gCO2eq/kWh). Method in ADR-0006 (SE CI) and ADR-0007 (SE drift).

@avalyset

Copy link
Copy Markdown
Author

Hi @benoit-cty — following up on your June 30 request for the exact way to review
the source of the per-zone values. You asked for something you could check rather
than take on trust, which is fair, and I owe you the explicit SE3 arithmetic we
offered earlier.

First, the honest reason "check it in docs/" wasn't enough. The headline CI is
energy/duration-weighted over clean intervals, per ADR-0001 §4:

  • CI per interval: Σ(MW_type × factor_type) / Σ(MW_type)
  • Period average (energy-weighted): Σ(MW × factor × duration) / Σ(MW × duration)

i.e. total emissions / total energy over clean intervals.

The mix table in docs/se-drift-results-2022-2025.md shows each type's energy-weighted
share of total generation (denominator = all occurring types), so the listed material
types sum to ~94.5%. It omits the ~5.4% "Other" category (no verified factor, excluded
from CI) and the 0.0168% Fossil Gas trace (classified negligible rather than material by
src/khepri/ci.py, but included in CI at factor 490). Multiplying the shares as printed gives 13.64. The headline CI
applies the same energy weighting but normalises over the factor-carrying types, giving
14.53. Same weighting — the difference is the denominator. That's a documentation gap on
our side, not a change in the value.

SE3 (2025) — energy/duration-weighted decomposition

  • EIC: 10Y1001A1001A46L
  • Interval coverage: 100.00% (10923 / 10923 intervals)
  • Factors: IPCC AR5 Annex III lifecycle medians (per src/khepri/factors.py)
Production type Energy share AR5 factor (gCO2eq/kWh) Contribution
Nuclear 66.7463 % 12 8.0096
Wind Onshore 15.6437 % 11 1.7208
Hydro Water Reservoir 15.5638 % 24 3.7353
Solar 2.0284 % 48 0.9736
Fossil Gas 0.0178 % 490 0.0872
Total 14.5265 → 14.53

"Energy share" = each type's Σ(MW × duration) over clean intervals, normalised over
the factor-carrying (included) types. Shares are given to 4 decimal places so that
Σ(share × factor) reproduces the total; note Fossil Gas is a genuine 0.0178 % of the
factor-carrying types here (0.0168 % of total generation) — rounding it to 0.02 % would
give 14.54, not 14.5265.

Happy to provide the same decomposition for SE1, SE2, and SE4 on request; the method
is identical (SE3 is the only Swedish zone with nuclear).

@benoit-cty benoit-cty left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for providing the worked SE3 decomposition and the archived derivation. The code path, JSON loading, affected tests, and merge against current master all check out, but I think the data basis needs two changes before these factors can be shipped.

  1. The lifecycle-factor basis is materially inconsistent with CodeCarbon, not just for gas. The PR says the main table difference is gas, but carbon_intensity_per_source.json currently uses nuclear=29, wind=26, hydro=26, and natural gas=743 gCO2eq/kWh, while Khepri uses 12, 11, 24, and 490 respectively. Applying CodeCarbon's existing factors to the SE3 shares supplied in the discussion gives about 28.58 gCO2eq/kWh, versus 14.53 with the AR5 factors. Nuclear and wind dominate SE3, so this is a roughly 2x difference. Please either re-derive SE1-SE4 using CodeCarbon's factor table, or first establish/document a project-wide change of factor basis; in either case, the metadata claim that this is the same as CodeCarbon's static fallback methodology needs to be made precise.

  2. The published values do not cover all zone generation. Khepri's primary calculation removes production types without a verified factor from both numerator and denominator. The linked drift results show Other is about 15% of SE4's 2025 generation, and the SE3 explanation reports about 5.4% excluded there. CodeCarbon applies the stored number as a complete gCO2eq/kWh factor to all consumed energy, so 17.4/14.5 currently look more precise and complete than the derivation supports. Please provide a justified mapping or bounded/sensitivity result for excluded generation and select/document the value CodeCarbon should use; avoid calling the partial estimate the “true CI.”

Also, please put a resolvable, versioned provenance URL (preferably the archived v1.2 DOI, not only avalyset/khepri) in the shipped JSON metadata so future users can audit the values without finding this PR discussion.

Validation performed locally: 9 Nordic tests passed (plus 3 subtests), all 8 package-integrity tests passed, CI is green, and the PR merges cleanly with current origin/master.

@avalyset

Copy link
Copy Markdown
Author

Thanks @benoit-cty — this is a substantive review and you're right on all three points. Here's what we'll change, and one open choice for you.

1. Factor basis / metadata claim

You're right that the basis is different from carbon_intensity_per_source.json. Khepri derives the per-zone values from IPCC AR5 Annex III lifecycle medians (nuclear 12, wind 11, hydro 24, gas 490 gCO2eq/kWh), deliberately chosen so the whole dataset rests on one consistent peer-reviewed lifecycle basis and matches the DOI-frozen derivation.

The metadata wording that called this "same as CodeCarbon's static fallback methodology" was imprecise — I'll correct it. The basis is AR5, explicitly, not CodeCarbon's per-source table.

For what it's worth on the 2× concern: in the executed Nordic path the per-source table isn't actually reached — the stored per-zone values in nordic_emissions.json short-circuit it (emissions.py:254-256), so AR5-based per-zone values don't introduce a discontinuity against a live code path. That's a footnote, not a rebuttal.

I'd propose we keep AR5 and make the metadata precise rather than re-derive on the CodeCarbon table — re-deriving would contradict the published/DOI-frozen artifact. If you'd prefer internal consistency with the CC per-source table instead, that's your call as maintainer, but that's a project-wide basis change and should be documented as such, not folded silently into this PR.

2. Coverage / "true CI"

You're right — "true CI" is the wrong term for a partial estimate. We'll switch to "production-based CI" with an explicit disclosure of the excluded share.

The only excluded type occurring in the SE 2025 data is Other (ENTSO-E B20) — no verified AR5 factor, so it's removed from both numerator and denominator. Verified excluded shares:

Zone Production-based CI Excluded (Other, B20)
SE1 20.63 0.41 %
SE2 20.11 1.08 %
SE3 14.53 5.42 %
SE4 17.42 14.95 %

On sensitivity, I want to be honest about what we actually have rather than dress it up. compute_sensitivity() currently gives a single upper anchorOther set to 475 gCO2eq/kWh (a deliberately high world-average-scale proxy) — not a calibrated band. Sweeping Other from 0 to 475 brackets the estimate as:

Zone Other@0 headline Other@475
SE1 20.55 20.63 22.50
SE2 19.89 20.11 25.04
SE3 13.74 14.53 39.48
SE4 14.81 17.42 85.84

To be clear: 475 is a deliberately high proxy on the scale of a world-average grid — a conservative ceiling, not a claim about what Swedish Other actually is. SE4's upper end (85.84) illustrates the sensitivity of a 15 % unclassified share, not a real CI. A calibrated B20 proxy for Swedish bidding zones doesn't exist in the code today, so I won't present that range as a bounded interval.

So, your call on how you'd like this to land:

  • (a) ship the headline production-based CI with the disclosed excluded share above, plus this range clearly labelled as illustrative-only; or
  • (b) we build a calibrated Other/B20 proxy for the Swedish zones before merge, if you want a real bounded interval.

My recommendation is (a) now and (b) as a follow-up: a calibrated B20 proxy for the Swedish zones doesn't exist in the code yet, and promising a bounded band we don't have would defeat the point. Your call as maintainer, but that's what I'd ship.

3. Provenance

Agreed — I'll add the archived v1.2 version DOI 10.5281/zenodo.21097430 (the frozen record these values come from) to the nordic_emissions.json metadata as resolvable provenance, alongside the concept DOI 10.5281/zenodo.21042581 (all-versions) and the avalyset/khepri reference.

Summary of changes

  • Metadata: correct the basis wording to IPCC AR5 Annex III (drop the "same as CodeCarbon fallback" phrasing).
  • Terminology: "true CI" → "production-based CI", with the per-zone excluded-share disclosure.
  • Provenance: add the v1.2 version DOI 10.5281/zenodo.21097430 as the primary provenance pointer, with the concept DOI 10.5281/zenodo.21042581 as the all-versions anchor.
  • Open choice for you: illustrative range (a) vs. a calibrated Other/B20 band built before merge (b).

Thanks again for the careful read.

@benoit-cty

benoit-cty commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Sorry for the previous message it was send by an AI tool before I could review it.
Thanks a lot for your work. As @SaboniAmine just remind me, he is working on a project to use Electricity Maps data on year average for CodeCarbon so it will also have data for Sweden areas. We will compare these methodologies before merging.

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