Beta — Data under validation. Values may contain errors.
Back to docs

Changelog

Add-in versions and notable platform updates. Older fixes live in the git log.

Platform update

2026-09-25

Fixed

  • **Per-unit generation no longer loses readings on the autumn clock-change Sundays for units ENTSO-E re-registered.** In May 2026 ENTSO-E issued a new EIC for 47 production units, and on the four Sundays daylight saving time has ended since 2022 some of those units have readings stored under both codes. On those days only one of the two codes was kept and the other's readings were dropped; the two are now stitched into a single timeline, one reading per instant. `ED.UNIT` and `ED.UNIT.RANGE` change on **18 unit-days**, all of them on those Sundays: 12 recover energy (**+96.33 MWh net**) — the largest is MALA1 on 2023-10-29 (UTC day), **619.55 → 708.80 MWh, +14.4 %**; CAMGI20 gains 13.5 MWh and SAGU1 loses 9.8 MWh on 2025-10-26; the rest move by under 1.1 MWh — and the other 6 gain an hour of coverage, at 0 MW, that was missing. No other day changes.
  • **`ED.UPS` picks up the same correction, and 52 more units now show their company and zone.** Five of the 410 catalogue rows change: four move their Avg MW by 1.4 MW or less (CAMGI20 84.8 → 85.0, MALA1 45.3 → 46.7, SAGU1 135.3 → 135.1, SBO3 110.9 → 111.0) and PBCN2 only moves its `coverage_pct` in `/api/data/units`, 0.445 → 0.446. Separately, the catalogue now links each unit to the settlement registry through its most recent EIC — the one the registry knows — so 52 rows that showed no company or zone in `ED.UPS` now show both, plus a BRP in the `/api/data/units` response. No figure changes because of that link.
  • **The imbalance prices `imbalance_price_up` and `imbalance_price_down` no longer store REE's provisional value — from May 2026 `imbalance_price_down` was 0 in most quarter-hours.** During the day REE publishes a provisional imbalance price, with the downward price left at 0 until the day is closed, and the nightly ingest read each day for the last time before that close. From May 2026 that provisional snapshot is what was stored: `imbalance_price_down` is 0 in **79.7 % of the quarter-hours of May, 98.8 % of June, 99.4 % of July, 83.9 % of August and 95.0 % of September** so far, against 0.03-0.10 % in the settled prices of May to August. `imbalance_price_up` was off as well: over the 135 complete days stored that way from 2026-04-30 to 2026-09-23, the quarter-hours that differ miss the settled price by **36.74 EUR/MWh on average (up) and 109.93 EUR/MWh (down)**, and up reads above down in 50-76 % of the quarter-hours where the settled prices do so in 3-9 %. Monthly means, stored → settled: June up 64.53 → 54.85 and down 0.05 → 85.29 EUR/MWh; July 67.60 → 79.18 and 0.12 → 114.98; August 96.04 → 91.00 and 15.50 → 127.24. One example: 2026-09-20 00:00 read 172.3 up / 0 down, where the settled prices are 134.37 / 172.21. Most of March and April 2026, plus 11 days in May and August, held each day's close from before REE's monthly settlement revisions: over those 70 days, 70 % (up) and 77 % (down) of the quarter-hours differ from the settled value, by 8.83 and 6.93 EUR/MWh on average. 2026-06-09 also had only 24 of its 96 quarter-hours. This reaches `ED.GET` and `ED.RANGE` on both metrics and `ED.PCT.AT.PRICE` with either of them as `price`.

Changed

  • **Imbalance prices are now read from the closed series and appear about two days after the day they describe.** The two metrics now come from the series REE publishes once a day is closed (ESIOS 763 and 764), which is identical, quarter-hour by quarter-hour, to the one used before for every closed day since 2014 — checked over the whole history — but carries no value for a day that is not closed yet. The most recent day is therefore normally the day before yesterday (two to three days of delay) where there used to be a provisional value; a hole there is a day REE has not closed, not a 0. REE's monthly settlement revisions (the first about 8-11 days into the following month, the second three months later) now get re-read every week, so a month keeps moving slightly until they are in. The stored history from March 2026 onward is re-ingested from the closed series. Name, unit (EUR/MWh), zone (Spanish peninsular system only) and meaning do not change; the catalogue now lists both, and the other two imbalance series, as 15-min, which they have been since 2024-12-01 (hourly before).

Platform change only: no add-in version bump, no signature change, no re-side-load needed. Catalogue figures pick up the next nightly rebuild. The imbalance-price correction is entirely server-side and reaches every Excel as the re-ingest completes; recalculate any sheet that cached May-September 2026 values.

8.7.8.0

2026-09-10Latest

Fixed

  • **Four more matrix functions stop handing a raw `null` to the sheet: `ED.OMIE.BIDS`, `ED.CURTAIL.ECON`, `ED.OFFER.STRATEGY` and `ED.OFFER.CURVE`.** Each carried its own copy of the cell-formatting loop, and each only touched a cell that was already a number, so anything the API reported as empty travelled to the spill untouched — the same defect 8.7.7.0 fixed in `ED.OFFER.HEATMAP`, in the four functions it left behind. The one that bites today is `ED.OMIE.BIDS`: `cleared_price_avg_eur_mwh` has nothing to average whenever a technology cleared none of what it offered, which is **331,159 of the 3,421,501 hourly slots on record (9.68 %)** — 60.9 % of the fuel-oil thermal slots, 48.6 % of coal, 45.2 % of pumped storage. Over a 12-month hourly window across every technology that is **11,126 empty cells over 119,226 rows**; monthly it is 105 buckets of 3,870, and annually 3 (fuel-oil thermal in 2020, in the three zones: 878.4 MWh offered, none cleared). The second case is `tech="interconnect"`, one of the five examples in the function's own help: MIEU/MIP/MIE have no offered side in OMIE — **not one `status='O'` row in 153,250,473** — so `clearing_rate_pct` and `offered_price_avg_eur_mwh` come back empty in **100 % of their rows**, two cells of every ten. For `ED.CURTAIL.ECON` and `ED.OFFER.STRATEGY` this is the contract rather than a case seen in the wild: no bucket on record leaves their nullable columns without a value today (0 of 871,907 curtailment slots, 0 of 153,250,473 offered rows). All four now share one formatter, checked cell by cell against the loops they replace — the only difference on screen is the empty cell where a raw null used to land. Empty cells: a value the API reports as null spills as an empty cell, never as 0, because the source published no number for it. A 0 in the matrix is measured data, not a gap. =N(cell) reads a blank back as 0 if a formula needs it.
  • **`ED.OFFER.CURVE` with `agg=7` over a range that predates the data no longer answers eight fabricated zeros — this one is server-side and applies to every Excel as soon as it is deployed.** The total variant of the pre-aggregated query is a single aggregate row with no `GROUP BY`, and an aggregate over zero rows is not an empty result: the database returns one row with every sum empty. The route turned each of those into a `0`, so `=ED.OFFER.CURVE("solar", "2017-01-01", "2018-01-01", 7, "es")` came back as a `TOTAL` row of **eight zeros and `n_slots` 0**, announced as one row of data — indistinguishable from a technology that genuinely offered nothing, and past every no-data guard. Any whole-year `agg=7` window entirely before **2018**, the first year the pre-aggregate covers, was in that class. The query now drops that row, the response reports zero rows, and the function answers `No data for this range.` A bin that does come back empty now travels as an empty cell instead of a 0.

Changed

  • **With `headers=1`, a window with no data now answers `No data for this range.` instead of a lone row of column names.** The four functions above only checked whether the payload was empty, and with a header row requested it never is — the header alone spilled as if it were the answer. They read the row count now, exactly as `ED.OFFER.HEATMAP` has since 8.7.7.0. `=ED.OFFER.STRATEGY("interconnect", …)` is in that class **100 % of the time**: this function reads the offered side, and MIEU/MIP/MIE have none. This is the only change to the shape of a spill.
  • **A formula that averages or sums one of those columns will move.** A blank is skipped by `PROMEDIO`/`AVERAGE`, where a 0 was not: `=PROMEDIO(G2:G100)` over the cleared-price column of an hourly `coal` window used to be dragged down by every slot that cleared nothing. That is the correction, not a regression — but it is a number on the sheet that changes. Wrap the cell in `=N(cell)` to get the old reading back where a formula really wants a 0.

Minor bump 8.7.7.0 → 8.7.8.0. No signature changed. Re-side-load the add-in to pick up the new `ED.OMIE.BIDS`, `ED.CURTAIL.ECON`, `ED.OFFER.STRATEGY`, `ED.OFFER.CURVE` and `ED.PCT.AT.PRICE` descriptions. The two fixes reach you by different routes: the empty-cell one is entirely in the add-in, so a workbook still running 8.7.7.0 keeps receiving the raw null until you re-side-load, while the `ED.OFFER.CURVE` `agg=7` one is entirely in the API. `ED.PCT.AT.PRICE` changes documentation only: its help text described a SUM-versus-AVG choice the endpoint no longer makes.

8.7.7.0

2026-09-07Latest

Fixed

  • **`ED.OFFER.HEATMAP` no longer hands a raw `null` to the sheet for a cell the technology did not bid in.** The 24×12 matrix is built by pivoting one row per (hour, month) the pre-aggregate holds, and every pair with no row travelled to the spill as a raw `null` — this is the one matrix function that never went through the shared cell formatter. It is not a corner case: the default call, `=ED.OFFER.HEATMAP()`, comes back today with **168 of its 288 cells empty** — June to August are still inside OMIE's ~90-day publication lag, September is the month in progress, and October to December have not happened yet — and over the whole addressable catalogue it is **37,456 of 124,416 cells (30.1 %)**. What makes that dangerous is that a measured `0` is the modal value of this dataset: `p50_price` is exactly 0 in **27.5 %** of the cells the table publishes (the must-run bid of a nuclear or a hydro) and `pct_negative` is 0 in **51.8 %** of them, so a null rendered as a zero is indistinguishable from the most common real reading. `=ED.OFFER.HEATMAP("hybrid", 2024, "es")` is the extreme: **286 of its 288 cells would read as 0.00** — 238 measured, 48 fabricated — and nothing in the sheet could tell them apart. The three examples in the function's own help (solar 2025, wind 2024 Iberian, nuclear 2025) are complete years, which is why this went unseen since the function shipped in May. Empty cells: a cell the technology did not bid in, or a month the data has not reached yet, comes back as an empty cell, never as 0. A 0 in the matrix is a measured bid: solar and nuclear often bid at exactly 0 EUR/MWh, and pct_negative is 0 in about half of all cells. =N(cell) reads a blank back as 0 if a formula needs it.
  • **`ED.PCT.AT.PRICE` no longer counts an hour whose volume or price series published only part of it — server-side, no add-in change.** The calculation summed whatever rows a civil hour happened to hold, so three quarters of an hour entered as a whole hour: it weighed less than its share in the ratio, and with `unit="mwh"` it came back as a real number that was simply too small — **2026-08-08 08:00, `program_pbf_ccgt`: 2 213.9 MWh built out of 3 of the 4 quarters**. That hour now leaves the calculation altogether: an empty cell at hourly aggregation, where it was the whole bucket, and one hour fewer in the day, the month or the year. ESIOS omits the quarters where the programme is ~nil, so the hours affected carry far less volume than the complete ones and the annual figures barely move (`program_pbf_solar_pv` at ≤ 0 EUR/MWh over 2026: **30.133 % → 30.134 %**), while the monthly ones do: `program_pbf_ccgt` at ≥ 100 EUR/MWh goes **78.68 % → 75.10 %** in May 2026 (−3.58 pp) and −2.66 pp in April 2026, and `program_p48_turb_bombeo` at ≤ 0 goes from **0.276 % to 0.226 %** over 2025. The cost is concentrated in those same programmes — **8.5 % of the hours of `program_p48_turb_bombeo` over 2025 and 10.7 % over 2026, and 11.4 % of those of `program_pbf_ccgt` over 2026** leave the calculation, against 0.02-0.15 % across the whole catalogue — and in the two programmes measured day by day, `program_p48_turb_bombeo` and `program_pbf_solar_pv` over 2025-2026, **not one daily bucket is emptied**: the worst day still keeps 6 and 12 usable hours respectively. Two more corrections travel with it. The repeated civil hour of the October DST change holds two UTC hours, and for a series stored in MW the hour used to report half the energy that flowed through it (`gen_wind`, **2025-10-26 02:00: 7 401 MWh where 14 802 were delivered**). And an hour with volume but no published price used to vanish inside an inner join without leaving a trace — with `volume="gen_solar_pv"` and `price="intraday_s3_price"` that was **3 052 of that series' 5 944 hours of 2026** — where it is now counted as seen and not used, and reported in the new `coverage` field of the REST response together with a `gap_note`. The price side of the rule costs nothing in Iberia: in twelve years neither ES nor PT has published a single partial hour in any of the eight accepted price series. Coverage: an hour counts only when both the volume series and the price series published every slot that hour has at their own cadence for that day (hourly, 15-min, 10-min or 5-min, read from the data, never from a date), and an hour with volume but no price is seen but not used. A bucket with rows and no covered hour is an empty cell, or #N/A when agg=7 collapses the range, in every unit. A measured 0 is untouched: over covered hours, a bucket where none met the threshold still returns 0 % / 0 MWh / 0 hours.

Changed

  • **A tech and year with no bids at all now answers `No data for this range.` instead of an empty 24×13 block.** The endpoint builds all 24 rows even when its query matched nothing, so the add-in's own no-data guard never fired: **99 of the 432 tech × zone × year combinations** — every `interconnect` call, which by construction has no offered side in the pre-aggregate, plus `bess`, `bombeo`, `hybrid` and `renewable_other` before 2024 — spilled 288 empty cells without saying why. This is the only change to the shape of the spill.
  • **The cell format now follows the `unit` the API sends, not a table keyed on the metric name.** Prices keep their two decimals, `mwh_offered` its thousands-separated integer and `pct_negative` its `0.0%` — checked value by value, nothing on screen moves — but a metric added to the endpoint, or a unit changed there, no longer needs a matching edit inside the add-in to be formatted correctly.
  • **A correction to the 8.7.4.0 entry below.** It called the formatter's `null` handling a latent change because "no other endpoint currently sends one". That was true of the six functions sharing that formatter, but `/api/data/omie/offer-heatmap` had been sending `null` cells since it shipped on 2026-05-19 — it simply was not one of the six. It is now.
  • **`ED.PCT.AT.PRICE` answers 400 for any zone other than `ES` and `PT`.** The endpoint never actually validated `zone`, so a call naming a non-Iberian one came back with numbers no page had ever documented. Iberia is also the only place the coverage rule above is safe: ESIOS and OMIE publish every slot even when the value repeats the previous one, while the ENTSO-E feed the other zones come from drops the repeated point and our ingest does not fill it back in — the rule would read a flat price as a missing one and move the figure by ~3 pp for a defect of our own (DE over 2026, `gen_solar_pv` at ≤ 0 EUR/MWh: 25.93 % → 22.92 %). That forward-fill is tracked on its own; lifting the gate is a one-line change once it lands.

Minor bump 8.7.6.0 → 8.7.7.0. No signature changed. Re-side-load the add-in to pick up the new `ED.OFFER.HEATMAP` and `ED.PCT.AT.PRICE` descriptions — the only two this bundle changes. The two fixes reach you by different routes: the heatmap one is entirely in the add-in, so nothing improves there until you re-side-load and a workbook still on 8.7.6.0 keeps receiving the raw null, while the `ED.PCT.AT.PRICE` one is entirely in the API and applies to every Excel as soon as it is deployed.

8.7.6.0

2026-09-05Latest

Fixed

  • **`ED.PCT.AT.PRICE` no longer answers `0 %` for hours it has no volume for.** The share is the qualifying volume divided by the total volume of the bucket, and the function used to return `0` whenever that denominator was 0 — a number no formula can tell apart from a measured "none of this volume was at that price". That happens in a bucket whose published volume rows are all 0 (what a night hour of a solar metric looks like where the source publishes zeros rather than nothing) and, because they are signed net balances, in any `xborder_*` bucket whose flows cancel out or add up below zero: a total volume of zero or negative has no defined share. Those buckets are an empty cell now, and `#N/A` when `agg=7` (the default) collapses the range to a single number.
  • **A window with no rows at all is now empty in every unit, `"mwh"` and `"hours"` included.** Asking for a date the data has not reached, or for a metric that does not publish for that zone, used to answer `0 hours` / `0 MWh` — a count of a window we hold nothing for. It now answers `#N/A` (`agg=7`) or an empty cell. A measured 0 is untouched and is the common case: over rows that exist, a bucket where none of them met the threshold still returns `0 %` / `0 MWh` / `0 hours`. Same no-fabricated-zero contract as `ED.RANGE` (ADR-029), `ED.CAPACITY.RANGE` (8.7.4.0) and `ED.CURTAIL.RANGE` (8.7.5.0). If a formula relied on the old `0`, wrap the scalar call — `=IFNA(ED.PCT.AT.PRICE(...), 0)`; inside a spill the hole is a blank cell rather than an error, so `=N(cell)` is what reads it back as 0.
  • **`ED.CURTAIL.RANGE` no longer counts an hour whose series published only part of it — server-side, no add-in change.** ESIOS publishes these programs hourly on some days and in 15-min slots on others, and it regularly publishes only some of the quarters of a night hour. The calculation summed whatever rows existed, so a whole hour of PBF was compared against a fraction of an hour of P48 and the difference came out as curtailment: **2 718 of the 2 925 MWh of PBF in the 203 hours affected since 2025-10**, up to 100 % of a single hour (2026-03-13 06:00 was 407.75 MWh / 99.4 % and is an empty cell now), plus a fabricated **0 %** whenever the missing quarters were on the PBF side. An hour now counts only if every series it needs published that hour in full at its own cadence for that civil day — the cadence is read from the data and not from a date, so the day PBF went back to publishing hourly (2025-12-31) keeps its 24 hours of real curtailment — and the repeated civil hour of the October DST change needs twice as many rows, because it holds two UTC hours. With `tech="vre"` each technology is judged on its own PBF/P48 pair, so a solar gap no longer travels inside a complete wind total (3 542 MWh reported that way in 2026). Daily and monthly figures barely move — 2026 solar 16.334 % → 16.330 %, vre 17.326 % → 17.322 % — the hourly series is where it shows. Nothing to re-side-load for this one: the change is entirely in the API.

Changed

  • **`ED.BESS.RANGE` and `ED.PCT.AT.PRICE` surface a missing total as `#N/A`.** They were the last two range functions whose `agg=7` branch handed the API's value straight to the cell formatter, so a `null` would have been wrapped in a formatted number and rendered as something that reads like a measurement. `ED.PCT.AT.PRICE` needs that guard from this release on (see above); for `ED.BESS.RANGE` it is prevention — `/api/data/bess/range` answers an empty series rather than a null total, so nothing it returns today changes. With these two, every range function that returns a scalar through this path behaves the same way: `#N/A` for a total with no value, an empty cell for a hole inside a spill. (`ED.PROGRAM` in its default mix mode is the exception — it reads a different endpoint, which returns a matrix even for a total.)

Minor bump 8.7.5.0 → 8.7.6.0. No signature changed, and no value moves other than the holes described above. Re-side-load the add-in to pick up the new `ED.PCT.AT.PRICE` and `ED.BESS.RANGE` descriptions — the only two this bundle changes — and so that a total with nothing to compute answers `#N/A` rather than a formatted null.

8.7.5.0

2026-09-05Latest

Added

  • **`coverage` on `/api/data/curtail/range`**: one `[bucket, hours_used, hours_seen]` row per bucket, in the same order as `values` and with no header row of its own — so with `headers=1` it is `values[i+1]` that pairs with `coverage[i]` — plus a `gap_note` spelling out what an empty value means. Purely additive — `values` keeps its two columns (period and value), so the shape of the spill in Excel is unchanged.

Fixed

  • **`ED.CURTAIL.RANGE` no longer reports curtailment for an hour the source never published.** The calculation reads two series per hour — PBF and P48 for `type="technical"`, the renewable forecast and PBF for `type="economic"` — and a missing row was being read as a zero of that series. An hour with a PBF and no P48 came back as **100 % curtailment**, an hour with a forecast and no day-ahead match came back the same way, and the mirror cases produced a fabricated **0 %**. Each bucket is now computed only over the hours that carry both of the series it needs: the rest leave the numerator and the denominator alike, and a bucket with no usable hour comes back as an empty cell instead of a number — `#N/A` when `agg=7` (or `"T"`) collapses the range to a single scalar. A published `0` is still a measurement: a P48 of 0 means the whole PBF was curtailed, and that hour still counts. A period the source published nothing at all for is still left out of the spill, as before.

Changed

  • **Economic curtailment for April 2025 moves from 8.21 % to 1.61 %.** The 2025-04-28/29 blackout had no day-ahead match at all, so the old calculation read the entire renewable forecast of those hours as curtailed: the 29th was published as **100 % economic curtailment** — the opposite of what happened, there was no market — and the 28th as 64.99 %. The 29th is now a gap, and the 28th, computed over the 12 hours that do have a match, is 0.00 %. If you copied the old April 2025 figure into a model, it changes.
  • **Technical curtailment barely moves in the totals, but the hourly series does.** Over 2026 so far, solar goes from 16.34 % to 16.33 % of PBF and wind does not move at all — wind has not missed a single hour in three years. What changes is the fine grain: ESIOS stops publishing P48 solar for the night hours when production is ~nil (**497 such hours in 2026, every one of them between 21:00 and 06:00**, 7.13 MWh of PBF on average), and each of them used to read 100 %. On 2026-03-11, for example, the first six hours of the day were reported as fully curtailed and are now empty.

Minor bump 8.7.4.0 → 8.7.5.0. No signature changed, and no value moves other than the gaps described above. Re-side-load the add-in to pick up the new `ED.CURTAIL.RANGE` description — the only one this bundle changes — and so that a total with no covered hour answers `#N/A` rather than an empty scalar.

8.7.4.0

2026-09-02Latest

Fixed

  • **A CCAA with no published row is no longer reported as `0 MW`.** `ED.CAPACITY.RANGE(tech, …, "all")` filled every (period, CCAA) pair it had no row for with `0` — a fabricated measurement, and one no formula could tell apart from a region that really publishes 0 MW (that case exists: `other_renewable` in Melilla reports 0 every month). Those cells now come back empty (`null` in the REST payload, an empty cell in the MCP reply) — the same no-fabricated-zero contract `ED.RANGE` has had since the ADR-029 fix and `ED.UNIT.RANGE` since 8.7.3.0, which those two keep by answering `#N/A` or dropping the row rather than by blanking a cell. Measured on the current data: **1,924 fabricated cells over the full history, 39 of them inside the default two-year window** (87 counting back to 2024-01, where the REST endpoint's own default start sits). The most visible case is coal — Aragón, Castilla-La Mancha, Castilla y León and Galicia stop publishing a `coal` figure once their plants close, so that column goes from a long run of `0` to blank from that month onwards. A `0` in the matrix is now a published value; a small published value can still round to `0` on screen (the first real wind figure for Comunidad de Madrid is 0.003 MW). If a formula relied on the old `0`, wrap the cell — `=N(cell)` reads a blank back as 0.

Changed

  • `ED.BESS.RANGE`, `ED.RANGE`, `ED.UNIT.RANGE`, `ED.CURTAIL.RANGE`, `ED.PCT.AT.PRICE` and `ED.PROGRAM` share the formatter this fix touched: a cell with no value inside a spill is now shown blank instead of letting a raw `null` through to the sheet. This is a latent change — no other endpoint currently sends one, so what these six return today is unchanged.

Minor bump 8.7.3.0 → 8.7.4.0. No signature changed, and no value moves other than the `region="all"` gaps described above. Re-side-load the add-in to pick up the new `ED.CAPACITY.RANGE` description — the only one this bundle changes.

Platform update

2026-09-02

Added

  • **`refreshed_at`** on `/api/data/units`: the instant of the nightly rebuild the rows come from (ISO-8601 UTC), or `null` when that response was computed live from the raw rows — which is every response until the rebuild is switched on. Purely additive: no existing field changes name, position or type, and the `ED.UPS` spill is unchanged.

Changed

  • **The unit catalogue is moving from a per-call aggregation to a nightly rebuild.** `ED.UPS` and the `/data/units` page aggregate the 3.2 M raw ENTSO-E A73 rows on each request — 11.6 s with no filter, 4.2 s for a single technology. About 9 s of that predates 8.7.3.0; the run-length correction shipped there, which weights every published point by how long it was in force, added the remaining ~2.6 s. That computation moves to a job that runs once a night in the database and writes a 405-row table the site reads in **milliseconds**. The rebuild performs the same duration-weighted computation, and every run re-checks a set of reference figures from that correction and flags any drift. Until the nightly rebuild is switched on in production the list is still computed on demand, so the timings above are still the ones you get and `refreshed_at` (below) comes back `null`. Once it is on, the figures can trail the raw feed by up to a day, which is inside the publication lag of A73 itself.
  • **Filtering the unit list no longer changes the figures in it, and for eleven rows that means the figure changes.** Both filters on `/api/data/units` — `?tech=`, which is what `=ED.UPS("nuclear")` sends, and `?q=` — used to be applied to the raw samples before the average was computed, so a filtered call could disagree with the same unit's row in the unfiltered list. They now select which rows come back and nothing else. For `?tech=` this only matters where one plant identifier has published under more than one technology label: dropping the other label's samples left the sample before them in force for longer. Measured on the current data, 11 of the 405 rows move — the largest is the La Muela pumped-hydro unit (`MUEL`), 53.7 → 49.2 MW (−8 %); two more hydro rows move by 4.5 % and 3 %, and the remaining eight by less than 1.5 %. Every other row of a `?tech=` call comes back exactly as it did before, and a filtered figure now always matches the one the full list shows. `?q=` changes in the same way and in one more — see the next entry.
  • `?q=` on `/api/data/units` searches **rows** for the same reason. It used to be pushed into the aggregation, so searching by a unit's former name computed "Avg MW" over that name's rows alone — and, because the same condition also reached the day counter, `days`, the first and last date and `coverage_pct` covered only the days that carried that name. All of them now describe the whole unit, whichever name matched. The search itself also narrows: it now matches the name the list shows for a unit — the row's own `up_name`, which is the alphabetically greatest of the names that unit has carried and so is not always its most recent one — or its code. Any other name the unit has carried no longer brings that unit's row back. Measured on the current data, 103 of the 405 rows have carried more than one name, and in 25 of them the name shown is an earlier one rather than the current one: the solar unit `18W000000000I7A6` shows PIZARRO (carried in 2022), not FVCEDIL (2024), so `?q=pizarro` is what brings that row back and `?q=fvcedil` no longer does — that name is now the displayed name of a different unit (`18W000000000RHZ1`), whose rows are what the search returns instead. `ED.UPS` never used it.

Platform change only: no add-in version bump, no signature change, no re-side-load needed. `ED.UPS()` returns the same six columns in the same order, and an unfiltered call returns the same values it did before — only `ED.UPS("<tech>")`, for the eleven rows described above, shows a different "Avg MW".

8.7.3.0

2026-09-02Latest

Added

  • **`coverage_pct` (0-1)** on the REST responses of `/api/data/unit`, `/api/data/unit/range` (per window and per bucket) and `/api/data/units`: the share of the requested period actually backed by published data. It makes the gaps auditable — expect 1.000 for recent data, ~0.958 between 2025-05-31 and 2026-04-12 (hour 23 UTC was never collected) and ~0.240 before 2025-05-31 (an upstream resolution bug compressed each day into its first ~6 hours). A gap stays a gap with a low `coverage_pct` instead of becoming a plausible-looking number.

Fixed

  • **Empty windows no longer answer `0`.** `ED.UNIT.RANGE(up, a, b, 7)` over a range with no data returned `0 MW` — a fabricated measurement. It now returns `#N/A`, the same contract `ED.RANGE` has had since the ADR-029 fix. `ED.UPS` leaves the cell blank instead of showing `0`.
  • `ED.UNIT` no longer reads the legacy civil-time text column, so its day and hour windows are correct across the DST changes (a 23-hour or 25-hour day is now 23 or 25 hours), and malformed `date`/`hour` arguments answer a clear error instead of a server fault.

Changed

  • **Per-unit generation is read differently, and the numbers move.** ENTSO-E publishes A73 run-length encoded (`curveType` A03): a 15-min position missing from the document means *the same value as the previous point*, not zero and not "no data" — a plant holding 985 MW all day may publish two points, not 96. `ED.UNIT`, `ED.UNIT.RANGE` and `ED.UPS` now weight every published value by how long it was in force instead of averaging the stored points as equal samples. Measured bias removed: **+119 % on gas, +104 % on solar, up to +777 % on a single combined cycle**.
  • **MW and MWh now agree.** The old `unit="MWh"` path collapsed each hour to an average and summed, silently dropping every hour that published no point (**−74.5 %** of the energy on one wind farm) — so the same series read ×2-×8 apart depending on the unit you asked for. Energy is now the integral of the same curve the MW figure describes.
  • **Plants that publish one EIC per turbine are summed, not averaged.** Some combined cycles report each gas/steam turbine under its own EIC; these used to be averaged, reading a plant at roughly a third of its output. EICs that simply replace one another over time are still stitched into a single timeline.
  • **`ED.UPS` re-orders.** The list is sorted by the corrected average MW, so units move — the ones that moved most are exactly the ones that were most over-reported.
  • `ED.UNIT.RANGE` with `agg=0`/`RAW` or `agg=1`/`H` now returns the real 15-min / hourly grid, reconstructed from the run-length document (96 slots a day, not the ~44 stored change points), with the carry applied per EIC.

Minor bump 8.7.2.0 → 8.7.3.0. No signature changed, so existing formulas keep working — but the values they return for per-unit generation change, upwards or downwards depending on the plant, and long-run averages in `ED.UPS` change the most. Re-side-load the add-in to pick up the new descriptions. Only `unit_generation` (ENTSO-E A73) is affected; prices, system-wide generation, demand and every other series are untouched.

8.7.2.0

2026-05-20

Added

  • Internal: `vitest` guard test (`src/functions/__tests__/matrix-functions.test.ts`) parses `functions.ts` via the TypeScript compiler API and asserts every `@customfunction` declaring `@dimensionality matrix` in its JSDoc is present in both `matrixFunctions` Sets in `webpack.config.js`. Prevents the recurring bug pattern that produced 8.6.0.0 (`OFFER.CURVE`) and 8.6.0.1 (`DAYAHEAD.RANGE`) hotfixes.

Fixed

  • Matrix dimensionality for **six functions** that were silently mis-registered as scalars in `webpack.config.js`'s `matrixFunctions` set: `ED.UP.SEARCH`, `ED.OWNERS`, `ED.REER.AWARDS`, `ED.OMIE.SEGMENTS`, `ED.GRIDACCESS`, `ED.GRIDACCESS.PORTAL`. Excel may previously have rendered these as `#N/A` or `#VALUE!` instead of spilling the full matrix. Re-side-load the add-in to pick up the fix.
  • Dev/prod drift on the `matrixFunctions` set — `BESS.RANGE`, `UNIT.RANGE`, `UPS`, `CAPACITY.RANGE` were registered for production builds but missing from the dev-server interceptor. Both sets are now identical (24 functions) and guarded by an automated test.

Minor bump 8.7.1.0 → 8.7.2.0 (fix release + new internal CI infra). The six fixed functions all return 2-D ranges (search results, owner registries, REER auction awards, OMIE segments, REE grid-access trajectories) — their backends were correct all along; only the metadata bit was missing. Manifest bump forces Excel to re-pull `functions.json` so the corrected dimensionality activates.

8.7.1.0

2026-05-19

Changed

  • **Breaking** — `ED.OFFER.CURVE(...)` with `agg=1` now returns **24 rows hour-of-day Madrid (00..23)** aggregated across the date range, replacing the previous 'hour timestamp' semantic (8760 rows / year, ~1.4M cells with tech='all' — impractical in Excel). New shape mirrors ED.OFFER.HEATMAP's hour-of-day dimension. ADR-011.
  • `=ED.OFFER.CURVE(;;;1;;1)` (default range + agg=1 + headers) now lands on pre-aggregated table `omie_offer_curve_year_tech_hora` (10,368 rows: 9y × 3 zones × 16 techs × 24h) → <100 ms vs ~8 s live. Live fallback for sub-natural-year boundaries computes hour-of-day on the fly with `EXTRACT(HOUR FROM ts_utc AT TIME ZONE 'Europe/Madrid')`.

Patch bump 8.7.0.0 → 8.7.1.0 forces Excel to re-pull functions.json so the new `agg=1` description appears in IntelliSense. No existing JSDoc example used `agg=1`, so the break is theoretical for callers — but a re-side-load is needed.

8.7.0.0

2026-05-19Latest

Added

  • Pre-aggregated table `omie_offer_curve_year_tech` (432 rows: 9y × 3 zones × 16 techs incl. interconnect; ~150 KB). Endpoint routes to pre-agg when `agg ∈ {6, 7}` AND boundaries align to natural Madrid year; otherwise live SQL. Default call `=ED.OFFER.CURVE()` drops from ~8 s to <200 ms.
  • Aliases `all` / `vre` / `thermal` sum bins across sub-techs in the route layer (bins are additive — unlike F3 percentiles). `n_slots` for these aliases may double-count slots when multiple sub-techs offer in the same slot; documented in `methodology_note`.

Changed

  • **Breaking-ish** — `ED.OFFER.CURVE()` default range when `agg ∈ {6, 7}` (annual / total) is now **last completed natural year (Madrid)**, replacing the previous '12 months rolling from first-day-after-last-published-month'. Explicit `start/end` args still respected. Other agg levels (hourly, daily, monthly, quarterly, semiannual) keep the 12-month rolling default. ADR-009.

Minor bump 8.6.0.x → 8.7.0.0 communicates the default-behavior change. Existing explicit calls (`start=YYYY-01-01, end=YYYY+N-01-01, agg=6`) unchanged in result.

8.6.0.1

2026-05-19

Fixed

  • ED.DAYAHEAD.RANGE matrix dimensionality — versions 8.4.0.0 through 8.6.0.0 shipped with `DAYAHEAD.RANGE` accidentally registered as a scalar (`result: {type: 'number'}`) in `webpack.config.js` `matrixFunctions` set, even though the canonical `OMIE.RANGE` alias was correctly registered as matrix. Excel was returning `#N/A` instead of spilling the price-range matrix. Now correctly registered. Re-side-load the add-in to pick up the fix. (Workaround until re-side-load: use `=ED.OMIE.RANGE(...)` — the deprecated alias delegates to the same backend with the same signature.)

Patch release. Manifest 8.6.0.0 → 8.6.0.1 forces Excel to re-pull functions.json so the corrected dimensionality activates.

8.6.0.0

2026-05-19Latest

Added

  • ED.OFFER.HEATMAP([tech], [year], [zone], [metric], [headers]) — OMIE day-ahead bid-price heatmap as a 24×12 matrix (hours × months) for a single tech / year / zone. Killer Dec 2025 ES sample: **solar h9-12 p50 = -10 €/MWh (floor)**, **solar h20-21 p50 ≈ +280 €/MWh (evening scarcity)**. Closes the ADR-003 OMIE offer-* trio (STRATEGY + CURVE + HEATMAP). Pre-aggregation table `omie_offer_heatmap_year_tech` (8 years × 15 sub-techs × 3 zones × 24h × 12m = 80k rows) refreshed monthly; sub-ms read latency.

Fixed

  • ED.OFFER.CURVE matrix dimensionality — version 8.5.0.0 shipped with `OFFER.CURVE` accidentally registered as a scalar (`result: {type: 'number'}`) in `webpack.config.js` `matrixFunctions` set. Excel was returning only the first cell instead of the full 11-col histogram. Now correctly registered as matrix. Re-side-load the add-in to pick up the fix.

Minor (additive) bump: no existing function signatures change. Manifest 8.5.0.0 → 8.6.0.0 forces Excel to refresh the function set so the new ED.OFFER.HEATMAP appears in IntelliSense and the ED.OFFER.CURVE matrix-spill fix activates.

8.5.0.0

2026-05-18

Added

  • ED.OFFER.CURVE([tech], [start], [end], [agg], [zone], [headers]) — OMIE day-ahead aggregate offer-curve histogram split across 8 price tiers: `<-10`, `-10..0`, `0..5`, `5..20`, `20..50`, `50..100`, `100..180`, `≥180` €/MWh. Reveals 'merit order interna' per tech. Dec 2025 ES sample: **solar 99% in ≤20 €/MWh, gas 79% in >100 €/MWh, wind spread across all bins**. Same tech enum as ED.OFFER.STRATEGY / ED.OMIE.BIDS.

Minor (additive) bump: no existing function signatures change. Manifest 8.4.0.0 → 8.5.0.0 forces Excel to refresh the function set so the new ED.OFFER.CURVE appears in IntelliSense.

8.4.0.0

2026-05-18Latest

Added

  • ED.DAYAHEAD([date], [hour], [quarter], [zone], [tz]) — canonical multi-zone day-ahead spot price function. Replaces ED.OMIE (kept as deprecated alias, no warning, same signature). Now serves 21 bidding zones: 6 pre-existing (ES, PT, FR, DE, BE, NL) + 15 new via ADR-008 — 8 CET (AT, CH, PL, HU, CZ, SK, SI, HR) + 7 Italian (IT_NORD, IT_CNOR, IT_CSUD, IT_SUD, IT_CALA, IT_SARD, IT_SICI).
  • ED.DAYAHEAD.RANGE([start], [end], [agg], [zone], [headers], [noDate], [tz]) — range variant of ED.DAYAHEAD with same multi-zone coverage.
  • 15 EU bidding zones live in production with historical backfill 2014-2024 (1.57M rows ingested 2026-05-18 via ENTSO-E A44, daily-forward updates continue automatically).
  • ENTSO-E parser hardening: `classPos=1` filter ensures deterministic price selection when multiple parallel SDAC curves coexist (Italian cluster scenario).

Changed

  • ED.OMIE / ED.OMIE.RANGE are now documented as deprecated aliases. Calls keep working unchanged — no breakage. Migrate at your own pace to ED.DAYAHEAD / ED.DAYAHEAD.RANGE for clarity (the only difference is the name; zone defaults to ES on both).

Minor bump: zero breaking changes. Manifest 8.3.0.0 → 8.4.0.0 forces Excel to expose the new canonical function in IntelliSense. The non-OMIE zones (FR/DE/BE/NL/AT/CH/PL/HU/CZ/SK/SI/HR/IT_*) require pre-existing backfill — on-demand fetch for historical dates not yet implemented (returns 404 for non-backfilled past dates outside the ingested range; daily-forward always works).

8.3.0.0

2026-05-17

Added

  • ED.OFFER.STRATEGY([tech], [start], [end], [agg], [zone], [headers]) — OMIE day-ahead offer-strategy split by technology across 4 price tiers: `mwh_neg` (<0 €/MWh, must-run/hedge), `mwh_zero` (=0 €/MWh, exact price-taker), `mwh_pos_real` (0..180, competitive bids), `mwh_safety` (≥180, near-cap safety bids). Plus headline `pct_neg` and `pct_safety`. Killer Dec 2025 ES insight: **solar 88% negative, gas/nuclear 30-50% safety**. Same tech enum as ED.OMIE.BIDS (18 values).

Minor (additive) bump: no existing function signatures change. Manifest 8.2.0.0 → 8.3.0.0 forces Excel to refresh the function set so the new ED.OFFER.STRATEGY appears in IntelliSense.

8.2.0.0

2026-05-12

Added

  • ED.OMIE.BIDS([tech], [start], [end], [agg], [zone], [headers]) — raw OMIE day-ahead bidding behavior by technology. Companion to ED.CURTAIL.ECON with NO curtailment claim; surfaces offered + cleared volumes, weighted prices, clearing rate, and slot marginal across the full tech spectrum (renewables + flexible thermal + interconnects). Tech enum open to 18 values: `solar`, `wind`, `vre`, `hydro`, `bombeo`, `bess`, `hybrid`, `nuclear`, `coal`, `thermal_gas`, `thermal_fueloil`, `thermal` (alias), `biomass`, `cogen`, `autoprod`, `all` (default), and the special case `interconnect` (MIEU/MIP/MIE only, tech bucket = 'interconnect' literal).

Minor (additive) bump: no existing function signatures change. Manifest 8.1.0.0 → 8.2.0.0 forces Excel to refresh the function set so the new ED.OMIE.BIDS appears in IntelliSense.

8.1.0.0

2026-05-12

Added

  • ED.CURTAIL.ECON(tech, [start], [end], [agg], [zone], [headers]) — strict OMIE-based economic curtailment for must-run renewables (solar/wind/vre). Per-slot marginal-price filter: `Σ(offered side=V, price ≤ slot_marginal) − Σ(cleared side=V)`, grouped by tech via `uof_tech_map`. Excludes interconnects (MIEU/MIP/MIE). Flexible techs (thermal_gas/nuclear/hydro/coal/cogen/bombeo/bess) return 400 with an explanatory message — their unmatched offered is strategic bidding, not curtailment.

Minor (additive) bump: no existing function signatures change. Manifest 8.0.0.3 → 8.1.0.0 forces Excel to refresh the function set so the new ED.CURTAIL.ECON appears in IntelliSense.

8.0.0.0

2026-05-05Latest

Added

  • ED.CAPTURE / ED.CAPTURE.RANGE unit parameter: "" (default, EUR/MWh) | "pct" (capture rate %, gen-weighted, the AFRY/EMBER number).
  • ED.CURTAIL.RANGE — renewable curtailment time series. type=economic (forecast vs Σ PBF, VRE only) or type=technical (PBF vs P48, default). unit=mwh|pct.
  • ED.PCT.AT.PRICE — share of a volume metric (gen_*, demand_*, xborder_*, program_pbf_*, program_p48_*) whose hours fall on one side of a price threshold. Use program_pbf_* for the ex-ante OMIE-schedule view (matches ED.CAPTURE method=1) or gen_* for the realized post-curtailment view.

Changed

  • BREAKING: ED.CAPTURE signature reorders to ED.CAPTURE(tech, [date], [zone], [unit], [method], [tz]) — `unit` inserted between `zone` and `method`. Calls that passed `method` or `tz` positionally need an empty slot for the new param. v7 `=ED.CAPTURE(1, date, "ES", 1)` becomes v8 `=ED.CAPTURE(1, date, "ES", , 1)`.
  • BREAKING: ED.CAPTURE.RANGE signature reorders to ED.CAPTURE.RANGE(tech, [start], [end], [agg], [unit], [zone], [headers], [noDate], [method], [tz]) — `unit` inserted between `agg` and `zone`. v7 `=ED.CAPTURE.RANGE(1, A1, A2, 3, "ES", 1, 0, 1)` becomes v8 `=ED.CAPTURE.RANGE(1, A1, A2, 3, , "ES", 1, 0, 1)`.

The bump is BREAKING for users who passed method/tz positionally on capture functions. Manifest version goes to 8.0.0.0 to force Excel to refresh the function definitions. Calls that only used the first three args of ED.CAPTURE (or first four of ED.CAPTURE.RANGE) are unaffected.

7.1.1.0

2026-04-25

Added

  • BE and NL day-ahead spot prices wired into the daily cron via ENTSO-E A44.
  • PT intraday prices recovered via geo_id=1 fallback (sessions s1/s2/s3).
  • unit_generation table migrated to ts_utc PK; per-unit fall-DST 02:XX rows no longer collide.

Fixed

  • DST handling overhaul: every hourly metric now reports the full 8760/8784 CET-fixed buckets per year. Previously the fall-DST 02:XX hour collided with the second 02:XX (CET) and got dropped on insert.
  • Server-side primary key migrated to (ts_utc, metric, zone) so duplicate civil times can coexist; readers using tz=cet/utc see exactly one row per UTC hour.
  • Spanish DST archive recovered: ~5,700 mis-labelled rows patched + 24 fall/spring DST days re-ingested from OMIE, ESIOS and ENTSO-E with DST-aware parsers.

Cosmetic manifest bump; bundle byte-identical to 7.1.0.0 — safe to re-load without breaking sheets.

7.1.0.0

2026-04-24

Added

  • Optional tz parameter on every time-series function: 0/madrid (default, DST-aware) | 1/cet (fixed UTC+1) | 2/utc.

Changed

  • Web /api/data and /api/prices accept the same tz=… query string for parity with the Excel layer.

7.0.x

2026-04-21

Added

  • ED.CAPTURE / ED.CAPTURE.RANGE method parameter: 0=realized (default, matches publications) | 1=PBF (ex-ante, ES only).
  • 5 metals commodities via Yahoo Finance: gold (GC=F), silver (SI=F), copper (HG=F), platinum (PL=F), palladium (PA=F).
  • Slack alerting on daily-ingest sanity-check failures.

Fixed

  • Capture price now uses PBF for current-day fallback when realized data hasn't published yet.
  • 60 ESIOS scheduled-program metrics (PBF/PVP/P48 × 20 techs) seeded back to 2014.

6.5.x

2026-04-15

Added

  • noDate parameter on every *.RANGE function (1 = omit the date column from the spill array).
  • 5-min in-memory cache for repeated cell evaluations (avoids re-fetch when inserting Excel columns).
  • /docs/install: full sideloading guide for Excel Web, Windows Desktop, and Mac.

Fixed

  • Admin Data panel OK/Stale/Missing counters now match the filter-button counts (both compute via getEffectiveStatus client-side).
  • ED.RANGE for monthly aggregates now correctly aggregates by Madrid civil month boundary instead of UTC.

6.0.x

2026-04-12

Added

  • Stripe checkout, webhook, and customer portal (test mode) — 5 plan tiers (free/standard/pro/premium/dev).
  • OilPrice API for Dubai Crude (replaces FRED, discontinued Feb 2026).
  • Yahoo Finance TTF=F for EU gas (replaces FRED).
  • Per-API-key rate limiting (3 windows: rpm/rpd/rpMonth) on public data API.

Fixed

  • Admin license listing collapsed N+1 queries into single listUsers() + Set lookup.
  • API error responses no longer leak raw error.message; logged server-side instead.

5.0.x

2026-04-05

Added

  • Refactored to 9 unified function families (was 20+ scattered).
  • ED.METRICS() spill array listing every available metric with unit and description.
  • Auth (Supabase) + plan-based metric access control via DB.

Earlier

2026-03

Initial milestone: =ED.OMIE() returning real OMIE day-ahead prices in Excel. Capture price (PV/Wind), generation by tech, basic commodities. See git log for the full list.