Gap-Filling: Why Every (Combo, Day) Has 96 Rows¶
All customer-facing 15-minute power volume views guarantee exactly 96 rows per day per combination key, regardless of whether underlying trades exist for that day. This page explains why the contract exists, how it's implemented, and what it means for your queries.
The contract¶
For every (combo, delivery_date_utc) pair where the combo has ever appeared in the data:
- The view emits 96 rows (one per 15-minute UTC period:
00:00,00:15, …,23:45). - Rows with no underlying trade data return
0for all volume and price columns. - Period boundaries are continuous: each period's
end_utcequals the next period'sbegin_utc.
This is enforced by two custom dbt tests on every customer-facing view:
| Test | What it asserts |
|---|---|
period_coverage_96 |
Each (combo, delivery_date_utc) has exactly 96 rows. Detects missing periods. |
period_continuity |
Each day's 96 rows span 00:00 → 23:45 UTC in unbroken 15-minute increments. Detects DST or array-index alignment errors. |
The combo key varies by view — business_case + grid for the business-case grain, plus deal_id + dim_deal_attribute_id on deal-grain variants, plus BU and index name on floating variants.
What "0" means¶
In a gap-filled row, every numeric column is 0, not NULL:
delivery_date_utc | begin_utc | grid | business_case | volume_buy_mw | volume_sell_mw | buy_vwap_price_eur_per_mwh
2026-05-15 | 2026-05-15 02:15 UTC| TenneT | Intraday | 0 | 0 | 0
2026-05-15 | 2026-05-15 02:30 UTC| TenneT | Intraday | 45.2 | 0 | 87.3
2026-05-15 | 2026-05-15 02:45 UTC| TenneT | Intraday | 0 | 0 | 0
0 is gap data, not 'zero trading'
A 0 in volume_buy_mw can mean two things and the view does not distinguish them:
- The combo traded that day, just not in this 15-minute slot.
- There was no trade at all for this combo on this day.
If you care about the distinction, count rows with non-zero volume, or compare against a known-active combo list.
The same rule applies to buy_vwap_price_eur_per_mwh / sell_vwap_price_eur_per_mwh / power_index_price_eur_per_mwh — they're 0 on gap rows.
How it's implemented¶
The customer-facing view is never the storage layer. The data lives in a paired core_*__real incremental table; the view does the gap-fill on top.
Two-layer architecture¶
core_ewe_volume_pwr_by_business_case__real ← INCREMENTAL TABLE (sparse 96-element arrays)
│
│ unroll arrays per period + cross-join dim_period
▼
fct_ewe_volume_pwr_by_business_case ← VIEW (dense, 96 rows/day/combo)
The __real intermediate stores one row per (combo, delivery_date_utc) with a 96-element Snowflake ARRAY(NUMBER) for volumes and a parallel array for volume-weighted prices. Element [i] holds the value for period i (where i = minutes_since_midnight / 15, so [0] is 00:00-UTC, [95] is 23:45-UTC).
The view materialises the dense (96 row) form at query time via a CROSS JOIN against dim_period from numera-core, indexed into the array by period_idx:
-- Excerpt from fct_ewe_volume_pwr_by_business_case
with periods as (
select
start_date,
start_date_time_utc,
(datediff('minute', start_date::timestamp_ntz, start_date_time_utc::timestamp_ntz) / 15)::int as period_idx
from {{ numera_core.ref('dim_period') }}
),
dim_combos as (
select distinct business_case, power_control_area
from {{ numera_core.ref('core_ewe_volume_pwr_by_business_case__real') }}
)
select
dp.start_date as delivery_date_utc,
...
NVL(SUM(IFF(f.buy_sell = 'Buy', ABS(f.volume_array[dp.period_idx]), NULL)), 0) * 4 as volume_buy_mw,
...
from periods dp
cross join dim_combos dc
left join core_ewe_volume_pwr_by_business_case__real f
on f.delivery_date_utc = dp.start_date
and f.business_case = dc.business_case
and f.power_control_area = dc.power_control_area
group by dp.start_date, dp.start_date_time_utc, dp.period_idx, dc.business_case, dc.power_control_area
Three things make this work:
dim_combosdistinct-selects every combo the data has ever seen. This pins the combo dimension to actual values.cross join dim_combospairs every period with every known combo — producing the dense grid.left joinon__realbrings in real volumes where they exist, leaves NULLs where they don't.NVL(…, 0)collapses NULLs to0.
Why we can't materialise the view¶
We tried. The gap-filled views were promoted to materialised tables. The first-build query plan exploded:
fct_ewe_volume_pwr_by_business_case_unfiltered: ~60 M rows projected, ~80% zero-fill.fct_ewe_volume_pwr_by_business_case: ~11.79 M rows projected, ~88% zero-fill.
First-build queries timed out and the deployment was cancelled mid-flight. The view-form contract is now stable: the sparse __real intermediate is materialised and physically scanned cheaply; the dense view-form is recomputed per query but is only ever computed for the date range the user asks for.
This is why filtering on delivery_date_utc or begin_utc is mandatory — without it, the view computes the dense grid for every active combo over the full dim_period range (2020-01-01 onward).
Where the same pattern lives¶
| Layer | View(s) | Intermediate (storage) |
|---|---|---|
| EWE wrappers | fct_ewe_volume_pwr_by_business_case[_deal][_unfiltered][_with_bu_unfiltered], fct_ewe_volume_pwr_floating_by_business_case[_deal], fct_volume_pwr_quarter_fixed_price[_deal] |
core_ewe_volume_pwr_*__real, core_volume_pwr_quarter_*__real |
| Numera-core generic | fct_volume_pwr_15m[_deal], fct_volume_pwr_floating_15m[_deal] |
fct_volume_pwr_15m_agg, fct_volume_pwr_floating_15m_agg (the agg is the sparse 96-array store; the view does the unroll) |
The gap-fill semantic is identical across both layers. Migrating from EWE views to numera-core views preserves the row count and 96-period contract.
Related¶
- Concepts — what the combo and period columns mean
- Architecture & lineage — full pipeline from Endur → views
- Filter and aggregate efficiently — why partition pruning matters here