ED.PCT.AT.PRICE
Share of a volume metric whose hours fall on one side of a price threshold.
Signature
ED.PCT.AT.PRICE(volume, threshold, [op], [price], [start], [end], [zone], [agg], [unit], [headers])Description
How much of a volume metric (generation, demand, cross-border flow, programmed) was produced/consumed in hours where a price series satisfied op threshold. Generalises questions like "how much solar PV happened at ≤ 0 EUR/MWh?" or "what fraction of demand was served below 10 EUR/MWh?".
The function joins the volume and the price on the same civil hour, evaluates price <op> threshold per hour, and reduces over the requested window. Result is a share of the total volume by default (unit="pct"), the absolute energy below the threshold (unit="mwh"), or the count of qualifying hours (unit="hours").
**Realized vs ex-ante**: pass volume="gen_*" for the realized (post-curtailment) view, or volume="program_pbf_*" for the ex-ante OMIE schedule view (matches ED.CAPTURE method=1). program_p48_* gives the post-RRTT view (after technical redispatch).
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
| volume* | string | — | Volume metric. Accepted families: gen_* (e.g. gen_solar_pv, gen_wind, gen_total), demand_*, xborder_*, program_pbf_*, program_p48_*, program_phfc_*. Use program_pbf_* for the ex-ante OMIE schedule view (matches ED.CAPTURE method=1). |
| threshold* | number | — | Price threshold in EUR/MWh. Negative values are allowed (e.g. -10 for negative-price hours). |
| op | "lte" | "lt" | "gte" | "gt" | "lte" | Comparison: lte (default, ≤), lt (<), gte (≥), gt (>). Hours where the price exactly equals the threshold are included with lte/gte. |
| price | string | "dayahead" | Price series to compare against. dayahead (default), pvpc, pvpc_final, intraday_s1_price / intraday_s2_price / intraday_s3_price, imbalance_price_up, imbalance_price_down. |
| start | date | — | Start. Defaults: omit end for today (Madrid, with a small overnight cutoff), omit start for 30 days before end. |
| end | date | — | End. Omit for today. |
| zone | "ES" | "PT" | "ES" | MIBEL zone: ES (default) or PT. Any other zone answers 400. The coverage rule below assumes a source that publishes every slot even when the value repeats the previous one — true of ESIOS/OMIE, and not of the ENTSO-E feed the non-Iberian zones come from, where a missing row is usually a flat value we do know. |
| agg | 2-7 | string | 7 (total) | Aggregation is controlled by agg: 0=native, 5min/10min/15min, 1=hourly, 2=daily (default), 3=monthly, 4=quarterly, 5=semiannual, 6=annual, 7=total. Default is total (single number for the window); pass 3 for a monthly spill, etc. |
| unit | "pct" | "mwh" | "hours" | "pct" | pct (default) returns the share of total volume that satisfied the threshold (%). mwh returns the absolute energy below/above the threshold per bucket. hours returns the count of qualifying civil hours in each bucket. |
| headers | 0 | 1 | 0 | Set 1 for header row (only applies when agg returns more than one row). |
* Required.
Returns
Number when agg="total" (default), otherwise a spill array — [period, value] rows. Units: % / MWh / hours depending on unit. With the default unit="pct" a bucket that holds no volume to divide by comes back as an empty cell — the share is undefined, not 0 % — and as #N/A when agg=7 (or "T") collapses the range to a single number. A window with no row at all has nothing to count either, so mwh and hours are #N/A too. 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.
Examples
=ED.PCT.AT.PRICE("gen_solar_pv", 0, "lte", "dayahead", "2026-01-01", "2026-04-30")— % solar PV at price ≤ 0 EUR/MWh — realized (post-curtailment)=ED.PCT.AT.PRICE("program_pbf_solar_pv", 0, "lte", "dayahead", "2026-01-01", "2026-04-30")— Same question in the OMIE PBF (ex-ante) view — equivalent to ED.CAPTURE method=1=ED.PCT.AT.PRICE("gen_solar_pv", 5, "lte", "dayahead", "2026-01-01", "2026-04-30")— % solar PV at price ≤ 5 EUR/MWh (~65%)=ED.PCT.AT.PRICE("gen_solar_pv", 0, "lte", "dayahead", "2026-01-01", "2026-04-30", "ES", 7, "mwh")— Total solar PV MWh at price ≤ 0 EUR/MWh=ED.PCT.AT.PRICE("gen_solar_pv", 0, "lte", "dayahead", "2026-01-01", "2026-04-30", "ES", 3, "hours", 1)— Hours per month where dayahead ≤ 0 EUR/MWh, with headers=ED.PCT.AT.PRICE("demand_real", 100, "gte", "dayahead", "2025-01-01", "2025-12-31")— % real demand served at price ≥ 100 EUR/MWh — peak-price exposureNotes
- **Realized (
gen_*) vs ex-ante (program_pbf_*) vs post-RRTT (program_p48_*)**: each volume family gives a different denominator.gen_*reflects what actually flowed (already net of technical curtailment).program_pbf_*is the OMIE day-ahead schedule (matchesED.CAPTURE method=1).program_p48_*is post-RRTT (after Restricciones Técnicas). - **MW vs MWh aggregation**:
gen_*/demand_*/xborder_*are stored as MW power, so the energy of a civil hour is the sum of the samples that hour holds divided by the samples that series publishes per hour on that civil day — the mean power, multiplied by the UTC hours the bucket spans.program_pbf_*/program_p48_*/program_phfc_*are stored as MWh per slot and are summed directly. Both code paths ultimately produce hourly MWh consistently —unit="mwh"numbers betweengen_solar_pvandprogram_pbf_solar_pvare comparable in scale (~1-2% difference). Dividing by the day's slots rather than averaging is also what makes the repeated civil hour of the October DST change come out right: that bucket holds two UTC hours, and it used to report half the energy that flowed through it (2025-10-26 02:00,gen_wind: 7 401 MWh where 14 802 were delivered). - **Threshold operator**:
lte/gteinclude exact equality.lt/gtare strict. For "hours at ≤ 0" uselteandthreshold=0. - **
hoursunit**: counts civil hours (always 60-min buckets) where the price satisfied the operator, regardless of how much volume there was that hour — but only among the hours both series covered, so an hour whose volume or price series published just part of its slots is not counted here either (see **Gaps and coverage** below). Useful for documenting market stress separately from production. - **Gaps and coverage**: three cases come back empty (
#N/Afor theagg=7scalar, an empty cell inside a spill) rather than as a number. **(1) A bucket with no row at all**, whatever theunit: "0 hours above the threshold" over a window the source has not published a single row for is a claim about nothing, not a measurement — in practice only the total (agg=7) reaches this, because a bucket of a spill exists precisely because a row created it. **(2)unit="pct"with a total volume of 0**: the share is the qualifying volume divided by the total volume of the bucket, and there is nothing to divide by. A bucket whose total volume is zero or negative —xborder_*are signed net balances, so a net exporter adds up below zero — has no defined share and returns empty. **(3) A bucket whose rows never add up to a covered hour**: an hour is counted only when the volume series *and* the price series each published every slot that hour has at their own cadence for that civil day, so an hour holding just some of its slots — or holding volume and no price at all — leaves the numerator and the denominator alike instead of being read as a smaller number, and a bucket left with no covered hour is empty in every unit. The cadence is read from the data and not from a date (ESIOS turned the programs back to hourly for the single day 2025-12-31, and theintraday_s*prices for 2025-06-16..19), and the repeated civil hour of the October DST change needs twice as many rows because it holds two UTC hours. All three replace the fabricated zeroED.RANGEstopped emitting in ADR-029,ED.CAPACITY.RANGEin 8.7.4.0 andED.CURTAIL.RANGEin 8.7.5.0. A measured 0 is untouched: over covered hours, a bucket where none of them met the threshold returns 0 % / 0 MWh / 0 hours. The REST response carries the per-bucket detail in acoveragefield,[bucket, hours_used, hours_seen]in the same order as the values — and with no header row of its own, so underheaders=1it isvalues[i+1]that pairs withcoverage[i]— plus a top-levelgap_notespelling out what an empty value means. Withagg=7(or"T") the reply is a single number and there is nocoveragearray at all: the same two counters arrive as top-levelhours_usedandhours_seen. Both are additive:valueskeeps its two columns, so the shape of the spill in Excel is unchanged.