gridcarbon

Notes

2026-09-01

ENTSO-E

Six traps in ENTSO-E generation data

None of them raise an exception. They return HTTP 200, a well-formed document, and a series with the right number of hours spanning the right interval — and values off by tens of percent. I found every one of them by getting it wrong first.

The ENTSO-E Transparency Platform publishes actual generation by production type for every European bidding zone, free, over a documented REST API. Point it at Germany, sum the fuels, multiply by emission factors, divide by total generation, and you have a carbon intensity number.

That last paragraph is how most people describe the job, and it is why most implementations are wrong. The traps below do not raise exceptions. They return HTTP 200, a well-formed document, a series with the right number of hours spanning the right interval — and values off by tens of percent. I found every one of them by getting it wrong first.

All the XML here is real, pulled from live A75 responses, and every number is one I measured against files I still have. Where I could not reproduce a figure from a saved response, I have cut it rather than carried it forward. You need a free Transparency Platform token to reproduce any of this yourself.

One disclosure before the technical content, because it colours the last section: gridcarbon is my own project, it is three months old, and its backend is not open source. What is public is the output and the client libraries.

§ 1Trap: omitted positions mean "same as before", not "missing"

Trap: omitted positions mean "same as before", not "missing"

Since the A75 data item moved to ENTSO-E's newer technology stack on 2025-10-23, responses come back as curveType=A03. A03 is a variable-length block: if a value does not change from the previous interval, the publisher simply omits the Point. The reader is expected to carry the last quantity forward.

This is not folklore. ENTSO-E's own EDI curve-type document — The Introduction of Different Time Series Possibilities within ETSO Electronic Documents, v1.4, section 4.3 — states it normatively: only positions where a block change occurs are provided, and the last block extends to the end of the TimeInterval. The position-to-timestamp formula in that section is exactly the one the code below implements.

Here is Swiss solar, from a real 72-hour window (CH, 2026-08-21T07:00Z to 2026-08-24T07:00Z):

<Point>
  <position>13</position>
  <quantity>0</quantity>
</Point>
<Point>
  <position>22</position>
  <quantity>14.8725</quantity>
</Point>
<Point>
  <position>23</position>
  <quantity>341.360229</quantity>
</Point>

Positions 14 through 21 are night. Solar was zero, stayed zero, so the platform said nothing.

The failure mode is not "you lose eight hours". It is worse than that, because the natural way to write this parser is to iterate the Point elements and use the loop index as the time offset:

// WRONG: array index is not position
points.forEach((pt, i) => {
  const t = periodStart + i * resolutionMs;
  record(t, psrType, pt.quantity);
});

Every point after the first gap shifts earlier, and the shift accumulates. In that same Swiss document, position 25 carries 1863.920898 MW and belongs at 2026-08-22T07:00Z. The naive parser files it at 2026-08-21T23:00Z. It reports 1.9 GW of Swiss solar at eleven at night. That series declares 48 of its 72 positions, so by the start of day three the drift is sixteen hours, and by the last declared point it is a full twenty-four — the parser has silently shifted an entire day.

Nothing about the output looks broken. I ran both parsers over the same three saved files and counted the hours where the two disagree by more than 0.1 gCO2eq/kWh:

ZoneHours emitted (correct)Hours emitted (naive)Hours differing by >0.1 gWorst hour
DE7373652026-08-24T07:00Z, 275.9 → 518.5 g (+88%)
FR7272612026-08-23T20:00Z, 38.2 → 29.0 g (−24%)
CH7272462026-08-23T11:00Z, 31.7 → 17.0 g (−46%)

Same hour count, same start and end timestamps, no gaps, no errors, no warnings. Just 65 of 73 German hours quietly wrong.

The correct loop walks positions, not points:

const nPos = Math.round((end - start) / (resMin * 60e3));
const declared = new Map<number, number>();
for (const pt of points) declared.set(pt.position, pt.quantity);

let current = NaN;
for (let pos = 1; pos <= nPos; pos++) {
  if (declared.has(pos)) current = declared.get(pos)!;
  if (!Number.isFinite(current)) continue;   // positions before the first declared point
  emit(start + (pos - 1) * resMin * 60e3, current);
}

Carry-forward runs to the end of the Period, not to the last declared point. German geothermal in that window is a single Point standing in for 290 quarter-hours:

<MktPSRType><psrType>B09</psrType></MktPSRType>
<Period>
  <timeInterval>
    <start>2026-08-21T07:00Z</start>
    <end>2026-08-24T07:30Z</end>
  </timeInterval>
  <resolution>PT15M</resolution>
  <Point>
    <position>1</position>
    <quantity>19.24709</quantity>
  </Point>
</Period>

Other real examples from the same document: B15 (other renewable) declares four points, at positions 1, 230, 253 and 257 out of 290. B20 (other) declares 61 of 290, B06 (oil) 237 of 290. Five of the sixteen German generation categories omit points, and three of those omit more than three quarters of their series.

§ 2Trap: positions restart in every Period, and a hole splits the Period

Trap: positions restart in every Period, and a hole splits the Period

A TimeSeries can contain more than one Period, and position numbering restarts at 1 in each one. Flattening all the points in a series and indexing globally will silently overwrite the first period with the second.

The reason there are multiple periods is the interesting part. When the TSO is actually missing data — as opposed to repeating a value — the platform does not leave a gap inside the block. It ends the Period and opens a new one. This is spelled out in section 5 of the same EDI curve-type document, "The handling of gaps": a gap is represented by the presence of at least two disjoint Series_Period classes within a given time series, and it applies only to curve types A03, A04 and A05. The same section carries the warning that matters most here — it must not be assumed, unless specifically agreed, that the lack of information is equivalent to assigning the value zero.

Hold on to that sentence. It describes the bug in the second half of this section exactly.

From one German response, B01 (biomass):

<Period>
  <timeInterval><start>2026-08-21T07:00Z</start><end>2026-08-22T22:00Z</end></timeInterval>
  <resolution>PT15M</resolution>
  ...156 points, positions 1-156...
</Period>
<Period>
  <timeInterval><start>2026-08-22T23:00Z</start><end>2026-08-24T07:30Z</end></timeInterval>
  <resolution>PT15M</resolution>
  ...130 points, positions restart at 1...
</Period>

One hour, 22:00Z to 23:00Z, is genuinely absent. This is how you distinguish "unchanged" from "missing" in A03, and it is why the two are not ambiguous in practice.

Do not expect to reproduce that document. I re-fetched the identical window a few hours later and every one of the sixteen German series came back as a single 290-point Period — Germany is on the list of zones that backfill, and it did. Holes are easy to find and hard to keep.

It is also a trap on the aggregation side, which is where it bit me. In that document, seven categories split at 22:00Z — biomass, gas, run-of-river, reservoir hydro, solar, waste and onshore wind — while lignite, hard coal, oil, coal-derived gas, offshore wind and the rest ran straight through as one 290-point period. So for the hour 2026-08-22T22:00Z the parser correctly emits nine categories instead of sixteen — eight once pumped storage is set aside, which is what the table below counts. The denominator falls from 40,361 MW at 21:00Z to 14,465 MW, and almost everything that dropped out was low-carbon:

Hour (UTC)Fuels in the mix (>0 MW, storage excluded)Generation in the mixIntensity
21:00Z1540,361 MW250.5 g
22:00Z814,465 MW496.3 g
23:00Z1439,629 MW266.2 g

Carrying the seven missing categories forward from 21:00Z gives 259.7 g for that hour. The published 496.3 g is 91% too high, and it is not a parse error — the parser did the right thing. The mistake is computing a generation-weighted average over a fuel set you have not verified is complete. In the language of the spec: dropping the missing fuels out of the denominator is treating the gap as zero, made silently.

This was a live defect in my own pipeline, and fixing it is the rest of this section.

The collector re-fetches a trailing 72-hour window every hour and upserts, so when the TSO backfills a hole the hour is recomputed correctly — but that only helps once the hole fills. And ENTSO-E's own notes say PL, CZ, EE, BG, FI and NO do not revise after first publication, so in those zones a hole that never fills would leave a bad value in place permanently. Re-fetching is not enough on its own.

What ships now is a completeness gate. An hour is published only if its total output is at least 10% of that zone's seven-day median and it carries at least 60% of that zone's typical fuel count. An hour that fails is not published, and if a bad value was already published it is deleted — a gate that only blocks new errors leaves the old ones sitting there, which is the same mistake in a different costume.

The obvious version of that gate is wrong twice over, and both failures cost me a release.

At a 0.4 output-ratio threshold it threw away 293 DK-1 hours: about 894 MW against a 2,500 MW median, six to eight fuels, a complete mix on a calm evening in a wind-dominated zone. That is not a broken hour, that is Denmark. Drop the threshold to 0.1 and the DK-1 hours come back — but so do Irish fragments carrying only two fuels at 12–15% of output, landing on the coal factor again. The two shapes of incompleteness are different — "only a handful of fuels reported" versus "every fuel reported but at a trickle" — and each needs its own signal. Hence both thresholds, not one.

The incident that produced the gate: on 2026-08-26 at 02:00Z and 03:00Z, Ireland published four of its usual five production types totalling 80–99 MW against a 2,384 MW norm — three percent of normal output, with peat the largest share. The weighted average landed within a few percent of 820 g, the generic coal proxy factor, against 374 g and 440 g in the hours either side. Four of five fuels looks healthy; three percent of output does not.

Measured on the live API on 2026-09-01, those two hours now read 353.4 g and 349.8 g, 2026-08-24T15:00Z and 2026-08-25T19:00Z are still holes because upstream never filled them, and /v1/intensity/latest?zone=IE returns 387.5 g. Holding a hole open is the honest output. A number that looks like a reading is not.

If you take one thing from this post, take this section. It is the trap that survives a correct parser.

§ 3Trap: resolution varies by zone and by date, sometimes inside one document

Trap: resolution varies by zone and by date, sometimes inside one document

&lt;resolution&gt; is declared per Period, and it is not a property of the zone. Zones have been migrating from hourly to quarter-hourly publication on a staggered schedule for years. ES moved 2022-05-23, FI 2023-05-21, PL 2024-06-13, CZ 2024-06-30, FR 2024-12-19, the Italian zones 2024-12-31, DK1/DK2 2025-04-09, NO1–NO5 2025-04-10, SE1–SE4 2025-12-01, SK 2026-04-28, GR 2026-05-04, SI 2026-05-19. RO went early, in January 2021. DE-LU, AT, NL, BE and HU have always been PT15M. Cyprus went to PT30M in September 2025. Portugal and Switzerland are still hourly.

One caveat on that list, since the top of this post says every number is one I measured: those switch dates are the exception. They come from ENTSO-E's resolution-change notices, not from my own boundary walks. Verify the one you depend on.

A single request spanning a switch date returns both resolutions in one document. Querying French nuclear across 2024-12-19:

in_Domain=10YFR-RTE------C&periodStart=202412170000&periodEnd=202412220000
B14  Period 2024-12-17T00:00Z -> 2024-12-19T00:00Z  PT60M  48 points
B14  Period 2024-12-19T00:00Z -> 2024-12-22T00:00Z  PT15M  219 points

One TimeSeries, one production type, two resolutions, switching exactly at midnight UTC on the documented date. So: read &lt;resolution&gt; from each Period, keep a lookup for PT15M, PT30M and PT60M, and weight by slot when you aggregate to the hour. Assuming a zone's resolution from a config table will work fine until someone backfills across a switch date.

A caveat on my own implementation: I give every slot in an hour bucket equal weight, which is exactly the time-weighted mean whenever the hour has a single resolution. Every switch I have seen lands at midnight UTC, so no hour actually straddles one — but if a zone ever switches mid-hour, that hour will be slightly wrong and I do not correct it.

§ 4Trap: inBiddingZone versus outBiddingZone, and where pumped storage goes

Trap: inBiddingZone versus outBiddingZone, and where pumped storage goes

Every TimeSeries in an A75 response carries businessType=A01, generation-side and consumption-side alike. The only thing distinguishing them is which domain element is present:

<inBiddingZone_Domain.mRID codingScheme="A01">10Y1001A1001A82H</inBiddingZone_Domain.mRID>

versus

<outBiddingZone_Domain.mRID codingScheme="A01">10Y1001A1001A82H</outBiddingZone_Domain.mRID>

in is generation. out is consumption — pumped-storage pumping, battery charging, and in some zones a few thermal categories drawing station load. In the French window I sampled there are twelve generation series and three consumption series, including B05 hard coal on the consumption side. If you match on psrType alone you will add pumping load to generation and get double-counted hydro.

if (!ts.includes("<inBiddingZone_Domain.mRID")) continue;

The harder question is what to do with B10 (pumped storage) and B25 (energy storage) on the generation side. Discharge is real electricity flowing to load, so it belongs somewhere. Three options:

  • Factor 0. Wrong, and wrong in the flattering direction. It manufactures zero-carbon electricity

out of grid electricity that was stored a few hours earlier and lost about a quarter of itself to round-trip efficiency.

  • Factor 700, the unknown-fuel default. Also wrong, and far worse than it sounds for hydro-heavy

zones. Left on the fallback path, Swiss evening hours over 2026-08-21 to 08-24 come out 2.9× to 10× too high: 19.0 g becomes 190.3 g at 2026-08-23T19:00Z, when 2.1 GW of the 8.3 GW on the bar is pumped-storage discharge.

  • Remove it from the numerator and the denominator. This is what I do. It is algebraically

equivalent to assigning storage discharge the weighted mean intensity of everything else generating in that hour, which resolves in one pass with no self-reference.

The third option is a first-order approximation, and it errs in a direction I can measure — the opposite direction to the one this section used to claim.

Storage stopped charging on night coal some years ago. In the same German window as everything above, pumping is concentrated in the midday solar trough and discharge in the evening ramp: 6.3 GW pumping at 2026-08-22T12:00Z when the published mix reads 106.9 g, 5.8 GW discharging at 2026-08-22T20:00Z when it reads 249.1 g. Over the 72 hours, 121.1 GWh went in at a charge-weighted 135.5 g and 92.5 GWh came back out at a discharge-weighted 340.4 g, against a median hour of 253.8 g. That is a 76.4% round trip, which is a useful check that the two directions are the right way round. Ninety-six percent of the pumping lands in below-median-intensity hours.

So the electricity coming off that bar in the evening really carries about 135.5 / 0.764 ≈ 177 g, not the 250–340 g my method assigns it. Excluding storage overstates evening intensity in storage-heavy hours — by 1.1% to 7.2% across the German evenings in this window, worst at 2026-08-21T18:00Z, where 454.4 g should be nearer 422 g. Electricity Maps does this properly, with flow-traced charge-hour accounting. That is better; it also needs per-hour state tracking I have not built.

One asymmetry worth knowing before you try to reproduce this: it is Germany that makes the measurement possible. The Swiss file contains no outBiddingZone B10 series at all — Switzerland publishes pumped-storage generation but not pumping — so the Swiss charge side is simply unobservable from A75, even though CH discharged 37.2 GWh in the same window.

Whichever option you pick: both numerator and denominator. Dropping storage from the numerator alone would be the worst of all worlds.

§ 5Trap: the Acknowledgement reason code is not the signal

Trap: the Acknowledgement reason code is not the signal

Ask for an interval the platform has no data for and you get a 200 with a completely different root element:

<Acknowledgement_MarketDocument
  xmlns="urn:iec62325.351:tc57wg16:451-1:acknowledgementdocument:7:0">
  <createdDateTime>2026-09-01T02:33:14Z</createdDateTime>
  <Reason>
    <code>999</code>
    <text>No matching data found for Data item AGGREGATED_GENERATION_PER_TYPE_R3
          [16.1.B&amp;C] (10YCH-SWISSGRIDZ) and interval
          2026-09-01T00:00:00Z/2026-09-01T01:00:00Z.</text>
  </Reason>
</Acknowledgement_MarketDocument>

An XML parser expecting GL_MarketDocument will either throw or, more likely, find zero TimeSeries and return an empty list that looks exactly like a successful fetch of a zone with no generation.

When you poll near real time this is the normal state of affairs, not an incident. Publication is nominally due within an hour of the market time unit closing; in practice European zones run roughly 1.5–4 hours behind, and the lag climbs steadily between hourly polls, so any exact range is only true for the minute it was measured.

As I publish this, that range is academic. ENTSO-E has been in scheduled maintenance for more than two days: its API answers HTTP 503 with an HTML page reading "Scheduled maintenance is currently underway. Please check back soon.", no ETA published, and none of the 33 European zones I cover holds a value newer than 2026-08-30T20:00Z — they run between 31 and 54 hours behind. My own endpoints still return 200 and still return all 45 zones, because each value carries the timestamp of the interval it describes rather than the time you asked for it, and /v1/status returns HTTP 503 for as long as a source is behind. Which is the practical argument for this whole section: the only honest thing a client can do with an upstream like this is report how old each number is.

Here is the part that cost me an afternoon. The reason code is not the signal. I walked the error space I could reach on 2026-08-26 and every one of these came back with &lt;code&gt;999&lt;/code&gt;:

RequestHTTPReason text (truncated)
Interval with no data yet200No matching data found for Data item …
in_Domain=NOTANEIC200No matching data found for Data item … (NOTANEIC) …
in_Domain=10ych-swissgridz (wrong case)200No matching data found …
One-month window200Timeout deadline: 5000 MILLISECONDS
documentType=ZZZ400The combination of [DOCUMENT_TYPE=ZZZ, PROCESS_TYPE=A16] is not valid…
periodStart=2026-08-25400An unexpected error occurred: Text &#x27;2026-08-25&#x27; could not be parsed at index 4
periodEnd before periodStart400End time instant is before start instant.
processType omitted400Mandatory parameter ProcessType is missing.
Two-year interval400Provided time interval … is larger than maximum allowed period &#x27;P1Y&#x27;

I have not found a documented list of which Reason codes the REST API can actually emit — ENTSO-E's published EDI code list defines a large set, including A01 for "message fully accepted" — so treat this as "do not branch on the code", not as "the code is always 999".

Branch on the HTTP status, and be careful about what you do with a 200. The nastiest row is the second: a typo'd or wrong-cased EIC returns 200 and a document indistinguishable from a quiet zone. If you are adding zones from a mapping table, assert that a zone you believe is live actually produced TimeSeries at least once, or a bad EIC will sit in your config forever looking like a zone that never publishes.

And "treat 999 on a 200 as no data" is not quite enough either. Row four is the one that bit me: a one-month window returns 200 with reason 999 and the text Timeout deadline: 5000 MILLISECONDS. The backend gave up, and a parser that maps every 999 to an empty list will record that as an empty month. The documented maximum interval is a year; the practical limit is about a week — 200 OK, roughly 112 seconds, 1.5 MB — which is why my backfill is chunked by week. Read the reason text, not just the code.

A bad token is 401 and rate limiting is 429. The limit is 400 requests per minute — historically counted per IP address and per security token, though ENTSO-E's R3 notes say IP-based banning has been dropped — and exceeding it bans you for about ten minutes, so do not retry into it.

This is the shipped code, and I am pasting it as it actually is rather than as it should be:

// in the fetch wrapper
if (!res.ok) throw new Error(`entsoe http ${res.status}`);

// in the parser
if (xml.includes("<Acknowledgement_MarketDocument")) {
  const code = tag(xml, "code");
  if (code === "999" || xml.includes("No matching data found")) return [];
  throw new Error(`entsoe acknowledgement: code=${code} text=${tag(xml, "text")}`);
}

Note the seam. The status check lives in the fetch wrapper and the acknowledgement check in the parser, so the parser called on its own cannot tell a 200/999 from a 400/999. Keep them together, or make the parser take the status as an argument. Mine does not yet.

While you are in the neighbourhood: A75 accepts at most a one-year interval per request, and periodStart/periodEnd are UTC in yyyyMMddHHmm format.

§ 6Trap: Great Britain left in 2021 and the API will not tell you

Trap: Great Britain left in 2021 and the API will not tell you

10YGB----------A is still a valid domain. Queries against it still return 200. They just return reason 999 for anything recent, because the UK stopped publishing to the Transparency Platform after Brexit and the Trade and Cooperation Agreement removed the obligation.

I walked the boundary on 2026-08-26. periodStart=202106140000 returns a real GL_MarketDocument with eleven TimeSeries at PT30M, and its document interval ends at 2021-06-14T09:00Z. 202106150000 and 202106160000 both come back as reason 999, as does the current week. That mid-morning stop looks like the last actual generation data ENTSO-E holds for GB — worth re-checking against your own window rather than taking my word for the exact minute, and not currently re-checkable at all while the platform is in maintenance.

If you need Great Britain, use Elexon BMRS or NESO's own carbon intensity API. Northern Ireland keeps appearing because it publishes as part of the all-island SEM bidding zone (10Y1001A1001A59C), which is a different thing from GB.

Two more discontinuities worth a mapping table if you backfill history: DE-LU (10Y1001A1001A82H) only exists from 2018-10-01, before which you want DE-AT-LU (10Y1001A1001A63L); and the Italian zones were restructured on 2021-01-01, retiring Brindisi, Foggia, Rossano and Priolo and adding Calabria.

§ 7The factors: which choices are defensible and which are just wrong

The factors: which choices are defensible and which are just wrong

Parsing correctly gets you a fuel mix. Turning that into gCO2eq/kWh means picking a number for each of the 25 psrType codes, and that is where most of the remaining uncertainty lives.

I use IPCC AR5 (2014) Annex III Table A.III.2 lifecycle medians — coal 820, gas 490, nuclear 12, hydro 24, onshore wind 11, offshore wind 12, utility solar 48, geothermal 38, biomass 230, ocean 17 — with oil at 650 from UK POST (2006), since AR5 has no oil row. AR5 is defensible mainly because Electricity Maps uses it, so the numbers are comparable. It is not defensible on grounds of being current: it is twelve years old and reflects a pre-2014 supply chain, and UNECE's 2022 assessment puts utility solar nearer 37 and nuclear at 5.1–6.4. If you want "most accurate" rather than "comparable", switch the whole table to UNECE. Do not mix them.

The choice I know is wrong is lignite. AR5 publishes one number for coal. I apply 820 to hard coal (B05), lignite (B02), oil shale (B07) and peat (B08) alike. Lignite actually runs about 1000–1200 gCO2/kWh, oil shale about 950–1100, and peat at least as high as lignite. So lignite-heavy zones come out too clean, and it points the wrong way: the dirtiest grids get the most flattering treatment.

I had been quoting "15–25%" for that. Re-running the 73 German hours in my saved window against a separate lignite factor, the honest numbers are smaller. Lignite averaged 13.5% of the German mix hour by hour over that window, and 11.8% of total energy:

Lignite factorDE median increaseDE range across the 73 hours
1000+8.7%5.8% – 13.3%
1100+13.5%9.0% – 20.7%
1200+18.3%12.2% – 28.1%

So roughly 10–15% at the middle of the plausible lignite band, touching 28% only at the top of that band in the single highest-lignite hour — which, fittingly, is the broken 22:00Z hour from trap 2, where lignite is 37% of the mix only because seven categories are missing. It is still the largest known directional error in the dataset; 15–25% was the ceiling quoted as if it were the central case. I previously published a matching set of Polish figures here and have cut them, because I no longer have the saved response they came from and I am not going to reprint a number I cannot re-derive. Fixing this properly means sourcing a separate lignite factor consistent with the AR5 lifecycle boundary, which I have not done.

Three smaller places where I diverge from Electricity Maps' mapping, deliberately:

  • B17 Waste at 540, not 230. Electricity Maps folds waste into biomass. European incinerators

average about 540 gCO2/kWh of fossil-origin CO2 once you count the plastics, per Zero Waste Europe (2020). Biosourced CO2 is conventionally not counted, which is a whole argument of its own. The plausible range here is something like 230–700 depending on where you draw that line.

  • B13 Marine at 17, not 700. Tidal and wave get bucketed into "unknown" upstream. AR5 has an

ocean row. Using it is straightforwardly better.

  • B15 Other renewable at 230, not 700. This category is mostly biogas, landfill gas and sewage

gas. It is renewable by definition; costing it as thermal is a fallback firing where it should not.

And a boundary condition that is easy to miss: B21 through B24 are AC link, DC link, substation and transformer. They are network asset types, not production types, and will never appear in an A75 response. They still need to be named somewhere — in my table they carry a null factor and an explicit "not a generation type" policy, which routes them out of the mix rather than into the unknown-fuel default if the code list changes under you.

One last framing point that matters more than any single factor. This is a production-based number: generation inside the bidding zone, nothing else. Imports and exports are not modelled, and neither are transmission and distribution losses — it is intensity at the generator terminal, not at your meter. For a zone that trades heavily across its borders — DK-1, AT, CH — the consumption-based intensity a user probably wants is a different number from the one this method produces. Flow tracing is the correct answer and it is a substantially harder problem than anything above.

§ 8What I am and am not offering

What I am and am not offering

To be exact about it, because the obvious next question is "show me the code": my collector is not open source, so there is no parser here for you to read. The snippets above are the whole of what I can hand you.

What is public is the output, at gridcarbon.dev, and MIT-licensed HTTP clients for that output at github.com/gridcarbon/clientsgridcarbon on PyPI and npm, both zero-dependency, and gridcarbon-mcp on npm for the MCP server. Neither the site nor that repo contains a line of A75 parsing; they are thin clients over a published API and nothing more.

All of it is young: the archive reaches back to 2026-05-24, about three months, and there is one person behind it. The values are derived, not measured. EIA, ENTSO-E and NESO do not endorse any of this.

Mostly I would just like fewer people to lose a weekend to trap 1.