Gap-fill s lineární interpolací v PostgreSQL
Článek o gap-fill s lineární interpolací v PostgreSQL — tři varianty (klasický PG, PG 16+ s IGNORE NULLS, TimescaleDB) s ukázkami a doporučením, kdy co použít.
Časová řada s pravidelnými intervaly (např. teplotní senzor měřící každých 5 minut) v praxi často obsahuje díry. Tento článek ukazuje, jak chybějící body dopočítat lineární interpolací mezi sousedy ve třech variantách: klasický PostgreSQL, PostgreSQL 16+/18 a TimescaleDB.
Vzorová data
CREATE TABLE readings (
time timestamptz PRIMARY KEY,
temperature numeric
);
INSERT INTO readings (time, temperature) VALUES
('2024-01-15 10:00+00', 20.0),
('2024-01-15 10:05+00', 21.0),
-- 10:10 chybí
-- 10:15 chybí
('2024-01-15 10:20+00', 24.0),
('2024-01-15 10:25+00', 25.0);
Cílem je vyplnit 10:10 a 10:15 lineárně mezi 21,0 a 24,0 — výsledek by měl být 22,0 a 23,0.
1. Klasický PostgreSQL (libovolná verze)
Strategie:
- Vygenerovat kompletní řadu přes
generate_series. LEFT JOINna původní data — chybějící body jsouNULL.- Pro každý
NULLnajít nejbližší předchozí a následující známou hodnotu pomocí carry-forward partition triku. - Lineárně interpolovat.
WITH grid AS (
SELECT generate_series(
'2024-01-15 10:00+00'::timestamptz,
'2024-01-15 10:25+00'::timestamptz,
interval '5 minutes'
) AS time
),
joined AS (
SELECT g.time, r.temperature
FROM grid g
LEFT JOIN readings r USING (time)
),
flagged AS (
SELECT time, temperature,
SUM((temperature IS NOT NULL)::int)
OVER (ORDER BY time ROWS UNBOUNDED PRECEDING) AS bgrp,
SUM((temperature IS NOT NULL)::int)
OVER (ORDER BY time DESC ROWS UNBOUNDED PRECEDING) AS fgrp
FROM joined
),
neighbours AS (
SELECT time, temperature,
FIRST_VALUE(temperature) OVER (PARTITION BY bgrp ORDER BY time) AS prev_y,
FIRST_VALUE(time) OVER (PARTITION BY bgrp ORDER BY time) AS prev_t,
FIRST_VALUE(temperature) OVER (PARTITION BY fgrp ORDER BY time DESC) AS next_y,
FIRST_VALUE(time) OVER (PARTITION BY fgrp ORDER BY time DESC) AS next_t
FROM flagged
)
SELECT
time,
CASE
WHEN temperature IS NOT NULL THEN temperature
WHEN prev_y IS NOT NULL AND next_y IS NOT NULL
THEN prev_y + (next_y - prev_y)
* EXTRACT(epoch FROM (time - prev_t))
/ EXTRACT(epoch FROM (next_t - prev_t))
END AS temperature
FROM neighbours
ORDER BY time;
Jak funguje bgrp/fgrp: kumulativní součet IS NOT NULL zepředu (resp. zezadu) vytvoří partition, ve které všechny NULL řádky po posledním známém bodu sdílí stejné číslo. FIRST_VALUE v takové partition vrátí předchozí (resp. následující) známou hodnotu.
2. PostgreSQL 16+ (a 18) — IGNORE NULLS
Od PostgreSQL 16 (a tedy i v PG 17/18) je k dispozici standardní SQL klauzule IGNORE NULLS pro LAG, LEAD, FIRST_VALUE, LAST_VALUE a NTH_VALUE. Tím se zbavíme nutnosti mít bgrp/fgrp:
WITH grid AS (
SELECT generate_series(
'2024-01-15 10:00+00'::timestamptz,
'2024-01-15 10:25+00'::timestamptz,
interval '5 minutes'
) AS time
),
joined AS (
SELECT g.time, r.temperature
FROM grid g
LEFT JOIN readings r USING (time)
),
neighbours AS (
SELECT
time,
temperature,
LAG(temperature) IGNORE NULLS OVER (ORDER BY time) AS prev_y,
LAG(CASE WHEN temperature IS NOT NULL THEN time END)
IGNORE NULLS OVER (ORDER BY time) AS prev_t,
LEAD(temperature) IGNORE NULLS OVER (ORDER BY time) AS next_y,
LEAD(CASE WHEN temperature IS NOT NULL THEN time END)
IGNORE NULLS OVER (ORDER BY time) AS next_t
FROM joined
)
SELECT
time,
CASE
WHEN temperature IS NOT NULL THEN temperature
WHEN prev_y IS NOT NULL AND next_y IS NOT NULL
THEN prev_y + (next_y - prev_y)
* EXTRACT(epoch FROM (time - prev_t))
/ EXTRACT(epoch FROM (next_t - prev_t))
END AS temperature
FROM neighbours
ORDER BY time;
Pozor na detail: LAG(time) IGNORE NULLS skutečně dělá to, že přeskočí řádky, kde je time NULL — ale time v naší tabulce nikdy NULL není. Proto musíme time zabalit do CASE WHEN temperature IS NOT NULL THEN time END, aby se IGNORE NULLS chovalo podle temperature.
I přes tuto drobnost je dotaz výrazně čitelnější než bgrp/fgrp verze.
3. TimescaleDB — time_bucket_gapfill + interpolate
S extenzí TimescaleDB stačí jeden dotaz:
SELECT
time_bucket_gapfill('5 minutes', time) AS bucket,
interpolate(avg(temperature)) AS temperature
FROM readings
WHERE time >= '2024-01-15 10:00+00'
AND time <= '2024-01-15 10:25+00'
GROUP BY bucket
ORDER BY bucket;
time_bucket_gapfill vygeneruje kompletní řadu 5-minutových bucketů a interpolate() doplní chybějící body lineární interpolací mezi sousedy.
Výhody:
- žádný
WITHřetěz, žádné okenní funkce - přirozeně funguje s
GROUP BY(např. persensor_id) - existuje i
locf()pro variantu last observation carried forward (pro skokové stavové hodnoty)
Omezení:
- vyžaduje TimescaleDB extenzi
WHERE time BETWEEN ... AND ...je povinné — gapfill potřebuje znát hranice
Kdy co použít
| Situace | Doporučení |
|---|---|
| PostgreSQL ≤ 15 | Carry-forward partition trik (kapitola 1) |
| PostgreSQL 16+ / 18 | IGNORE NULLS (kapitola 2) — čitelnější |
| TimescaleDB k dispozici | time_bucket_gapfill + interpolate (kapitola 3) |
Lineární interpolace je vhodná pro veličiny, které se mezi měřeními mění spojitě (teplota, tlak, vlhkost, hladina). Pro skokové stavové signály (online/offline příznak, kategoriální stav) volte radši LOCF - lineární interpolace v těchto případech nedává smysl.
Nezapomeňte že - interpolovaná hodnota není naměřená hodnota. U chybějících dat ve větším rozsahu (ručně nastavený limit, např. > 60 minut) raději mezeru nechte prázdnou než dopočítávat hodnotu s krajních hodnot velkého rozsahu, kde se data mohou chovat nelineárně.