@golemio/energetics 2.0.0-dev.2954231424 → 2.0.0-dev.2962537365

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -177,7 +177,14 @@ INSERT INTO enapo_ppas_distribution_invoice_device (device_serial_number, invoic
177
177
  -- combustion_heat = 0 means "not supplied", not a real coefficient: value_kwh must be null here,
178
178
  -- not 0, because 0 would be charted as a real reading. April, so it does not disturb June.
179
179
  ('GAS-CH-1', 'DI-CH-001', '9900001', '2026-04-01', '2026-04-30', 'G4', 'ZIZWC', 'STANDARD',
180
- 300.0, 'M3', 0.0, 0.0, 0.0, 300.0);
180
+ 300.0, 'M3', 0.0, 0.0, 0.0, 300.0),
181
+ -- A month without consumption, as PPAS bills it (27ZG100Z00382385 in summer): no consumption at all,
182
+ -- zero Nm3 and zero kWh. It is 0 m3 and 0 kWh, not missing data.
183
+ ('GAS-CH-1', 'DI-CH-001', '9900001', '2026-08-01', '2026-08-31', 'G4', 'ZIZWC', 'STANDARD',
184
+ NULL, 'M3', 0.0, 1.0043, 10.55, 0.0),
185
+ -- No consumption and no Nm3 or kWh either: nothing says the month was zero, so it stays missing.
186
+ ('GAS-CH-1', 'DI-CH-001', '9900001', '2026-09-01', '2026-09-30', 'G4', 'ZIZWC', 'STANDARD',
187
+ NULL, 'M3', NULL, 1.0043, 10.55, NULL);
181
188
 
182
189
 
183
190
  -- =================================================================================================
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ var dbm;
4
+ var type;
5
+ var seed;
6
+ var fs = require('fs');
7
+ var path = require('path');
8
+ var Promise;
9
+
10
+ /**
11
+ * We receive the dbmigrate dependency from dbmigrate initially.
12
+ * This enables us to not have to rely on NODE_PATH.
13
+ */
14
+ exports.setup = function(options, seedLink) {
15
+ dbm = options.dbmigrate;
16
+ type = dbm.dataType;
17
+ seed = seedLink;
18
+ Promise = options.Promise;
19
+ };
20
+
21
+ exports.up = function(db) {
22
+ var filePath = path.join(__dirname, 'sqls', '20261019090000-enapo-consumption-history-invoice-zero-up.sql');
23
+ return new Promise( function( resolve, reject ) {
24
+ fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
25
+ if (err) return reject(err);
26
+ console.log('received data: ' + data);
27
+
28
+ resolve(data);
29
+ });
30
+ })
31
+ .then(function(data) {
32
+ return db.runSql(data);
33
+ });
34
+ };
35
+
36
+ exports.down = function(db) {
37
+ var filePath = path.join(__dirname, 'sqls', '20261019090000-enapo-consumption-history-invoice-zero-down.sql');
38
+ return new Promise( function( resolve, reject ) {
39
+ fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
40
+ if (err) return reject(err);
41
+ console.log('received data: ' + data);
42
+
43
+ resolve(data);
44
+ });
45
+ })
46
+ .then(function(data) {
47
+ return db.runSql(data);
48
+ });
49
+ };
50
+
51
+ exports._meta = {
52
+ "version": 1
53
+ };
@@ -0,0 +1,492 @@
1
+ DROP MATERIALIZED VIEW IF EXISTS mv_enapo_om_consumption;
2
+
3
+ CREATE MATERIALIZED VIEW mv_enapo_om_consumption AS
4
+ WITH bounds AS (
5
+ SELECT
6
+ ((date_trunc('month', now() AT TIME ZONE 'Europe/Prague') - interval '48 months')
7
+ AT TIME ZONE 'Europe/Prague') AS month_from_tstz,
8
+ (date_trunc('month', now() AT TIME ZONE 'Europe/Prague') - interval '48 months') AS month_from,
9
+ (date_trunc('month', now() AT TIME ZONE 'Europe/Prague')
10
+ AT TIME ZONE 'Europe/Prague') AS month_to_tstz,
11
+ date_trunc('month', now() AT TIME ZONE 'Europe/Prague') AS month_to
12
+ ),
13
+
14
+ point AS (
15
+ SELECT DISTINCT gid, place_id, id_type, commodity
16
+ FROM v_enapo_building_consumption_points
17
+ ),
18
+
19
+ electricity_metered AS (
20
+ SELECT
21
+ em.id AS place_id,
22
+ date_trunc('month', em."timestamp" AT TIME ZONE 'Europe/Prague')::date AS period_start,
23
+ sum(em.value) FILTER (WHERE em.var = 'EActi_VT_NT_DaySum') AS total_kwh,
24
+ sum(em.value) FILTER (WHERE em.var = 'EActi_VT_DaySum') AS vt_kwh,
25
+ sum(em.value) FILTER (WHERE em.var = 'EActi_NT_DaySum') AS nt_kwh,
26
+ -- Coverage is the max day-count across the three vars, not just the total's.
27
+ GREATEST(
28
+ count(*) FILTER (WHERE em.var = 'EActi_VT_NT_DaySum'),
29
+ count(*) FILTER (WHERE em.var = 'EActi_VT_DaySum'),
30
+ count(*) FILTER (WHERE em.var = 'EActi_NT_DaySum')
31
+ ) AS days_covered
32
+ FROM enapo_measurements em
33
+ CROSS JOIN bounds b
34
+ WHERE em.source = 'predi_input'
35
+ AND em.var IN ('EActi_VT_NT_DaySum', 'EActi_VT_DaySum', 'EActi_NT_DaySum')
36
+ AND em."timestamp" >= b.month_from_tstz
37
+ AND em."timestamp" < b.month_to_tstz
38
+ GROUP BY em.id, date_trunc('month', em."timestamp" AT TIME ZONE 'Europe/Prague')::date
39
+ ),
40
+
41
+ gas_core_month AS (
42
+ -- Cumulative 'core' reading (m3) at the first measured hour of each first-of-month day, kept per
43
+ -- (id, place_id): an id can carry several meters, one per PPAS place id, whose diffs are summed.
44
+ -- Not per device_serial_number - the integration stamps every refetched hour with the serial AVE
45
+ -- lists at fetch time, so the serial is unreliable for history and would split one meter in two.
46
+ SELECT DISTINCT ON (em.id, em.place_id, date_trunc('month', em."timestamp" AT TIME ZONE 'Europe/Prague'))
47
+ em.id,
48
+ em.place_id,
49
+ date_trunc('month', em."timestamp" AT TIME ZONE 'Europe/Prague')::date AS month_start,
50
+ em.value
51
+ FROM enapo_measurements em
52
+ CROSS JOIN bounds b
53
+ WHERE em.source = 'ppas_ave_api'
54
+ AND em.var = 'core'
55
+ AND (em."timestamp" AT TIME ZONE 'Europe/Prague')::date
56
+ = date_trunc('month', em."timestamp" AT TIME ZONE 'Europe/Prague')::date
57
+ AND em."timestamp" >= b.month_from_tstz
58
+ AND em."timestamp" < (b.month_to_tstz + interval '1 month')
59
+ ORDER BY em.id, em.place_id, date_trunc('month', em."timestamp" AT TIME ZONE 'Europe/Prague'), em."timestamp"
60
+ ),
61
+ gas_metered AS (
62
+ SELECT
63
+ cur.id AS place_id,
64
+ cur.month_start AS period_start,
65
+ -- A meter reading only grows, so a negative month total means a meter was exchanged or reset:
66
+ -- the month stays empty. Checked on the total, so a tiny drop on a small meter does not empty it.
67
+ (CASE WHEN sum(nxt.value - cur.value) < 0 THEN NULL ELSE sum(nxt.value - cur.value) END) AS m3_sum,
68
+ ((cur.month_start + interval '1 month')::date - cur.month_start) AS days_covered
69
+ FROM gas_core_month cur
70
+ INNER JOIN gas_core_month nxt
71
+ ON nxt.id = cur.id
72
+ AND nxt.place_id = cur.place_id
73
+ AND nxt.month_start = (cur.month_start + interval '1 month')::date
74
+ GROUP BY cur.id, cur.month_start
75
+ ),
76
+
77
+ distribution_device AS (
78
+ SELECT DISTINCT ON (ins.eic, d.place_id, d.device_serial_number, d.date_from, d.date_to, d.kind, d.unit)
79
+ ins.eic,
80
+ d.place_id,
81
+ d.invoice_id,
82
+ d.date_from,
83
+ d.date_to,
84
+ d.kind,
85
+ d.unit,
86
+ d.consumption,
87
+ d.normometers_nm3,
88
+ d.gas_consumption_kwh,
89
+ d.combustion_heat
90
+ FROM enapo_ppas_distribution_invoice_device d
91
+ INNER JOIN enapo_ppas_distribution_invoice i
92
+ ON i.id = d.invoice_id
93
+ INNER JOIN enapo_ppas_distribution_installation ins
94
+ ON ins.invoice_id = d.invoice_id
95
+ AND ins.customer_id = i.customer_id
96
+ AND ins.place_id = d.place_id
97
+ WHERE COALESCE(i.is_canceled, false) = false
98
+ AND d.date_to >= d.date_from
99
+ AND d.consumption IS NOT NULL
100
+ AND d.kind IN ('ZIZWC', 'ZIABN3')
101
+ AND d.reading_type = 'STANDARD'
102
+ AND NOT EXISTS (
103
+ SELECT 1 FROM enapo_ppas_distribution_invoice s
104
+ WHERE s.preceding_id = i.id AND COALESCE(s.is_canceled, false) = false
105
+ )
106
+ ORDER BY ins.eic, d.place_id, d.device_serial_number, d.date_from, d.date_to, d.kind, d.unit,
107
+ i.facts_doc_date DESC, i.id DESC
108
+ ),
109
+ commercial_device AS (
110
+ SELECT DISTINCT ON (ins.eic, d.place_id, d.device_serial_number, d.date_from, d.date_to, d.kind, d.unit)
111
+ ins.eic,
112
+ d.place_id,
113
+ d.invoice_id,
114
+ d.date_from,
115
+ d.date_to,
116
+ d.kind,
117
+ d.unit,
118
+ d.consumption,
119
+ d.normometers_nm3,
120
+ d.gas_consumption_kwh,
121
+ d.combustion_heat
122
+ FROM enapo_ppas_commercial_invoice_device d
123
+ INNER JOIN enapo_ppas_commercial_invoice i
124
+ ON i.id = d.invoice_id
125
+ INNER JOIN enapo_ppas_commercial_invoice_installations ins
126
+ ON ins.invoice_id = d.invoice_id
127
+ AND ins.customer_id = i.customer_id
128
+ AND ins.place_id = d.place_id
129
+ WHERE COALESCE(i.is_canceled, false) = false
130
+ AND d.date_to >= d.date_from
131
+ AND d.consumption IS NOT NULL
132
+ AND d.reading_type = 'STANDARD'
133
+ AND NOT EXISTS (
134
+ SELECT 1 FROM enapo_ppas_commercial_invoice s
135
+ WHERE s.preceding_id = i.id AND COALESCE(s.is_canceled, false) = false
136
+ )
137
+ ORDER BY ins.eic, d.place_id, d.device_serial_number, d.date_from, d.date_to, d.kind, d.unit,
138
+ i.facts_doc_date DESC, i.id DESC
139
+ ),
140
+
141
+ invoice_device_month AS (
142
+ SELECT
143
+ 'ppas_distribution_invoice' AS source,
144
+ d.eic AS place_id,
145
+ d.invoice_id,
146
+ gs.month::date AS period_start,
147
+ CASE WHEN d.unit = 'KWH' THEN NULL ELSE d.consumption END * o.share AS volume,
148
+ CASE WHEN d.unit = 'NM3' THEN 'Nm3' ELSE 'm3' END AS volume_unit,
149
+ d.normometers_nm3 * o.share AS nm3,
150
+ NULLIF(d.gas_consumption_kwh, 0) * o.share AS kwh,
151
+ (o.share < 1) AS is_estimated,
152
+ o.overlap_days AS days_covered
153
+ FROM distribution_device d
154
+ CROSS JOIN bounds b
155
+ CROSS JOIN LATERAL generate_series(
156
+ date_trunc('month', d.date_from::timestamp),
157
+ date_trunc('month', d.date_to::timestamp),
158
+ interval '1 month'
159
+ ) gs(month)
160
+ CROSS JOIN LATERAL (
161
+ SELECT
162
+ ov.overlap_days,
163
+ ov.overlap_days::numeric / NULLIF(d.date_to - d.date_from + 1, 0) AS share
164
+ FROM (
165
+ SELECT LEAST(d.date_to, (gs.month + interval '1 month - 1 day')::date)
166
+ - GREATEST(d.date_from, gs.month::date) + 1 AS overlap_days
167
+ ) ov
168
+ ) o
169
+ WHERE gs.month >= b.month_from AND gs.month < b.month_to
170
+
171
+ UNION ALL
172
+
173
+ SELECT
174
+ 'ppas_commercial_invoice',
175
+ d.eic,
176
+ d.invoice_id,
177
+ gs.month::date,
178
+ CASE WHEN d.unit = 'KWH' THEN NULL ELSE d.consumption END * o.share,
179
+ CASE WHEN d.unit = 'NM3' THEN 'Nm3' ELSE 'm3' END,
180
+ d.normometers_nm3 * o.share,
181
+ NULLIF(d.gas_consumption_kwh, 0) * o.share,
182
+ (o.share < 1),
183
+ o.overlap_days
184
+ FROM commercial_device d
185
+ CROSS JOIN bounds b
186
+ CROSS JOIN LATERAL generate_series(
187
+ date_trunc('month', d.date_from::timestamp),
188
+ date_trunc('month', d.date_to::timestamp),
189
+ interval '1 month'
190
+ ) gs(month)
191
+ CROSS JOIN LATERAL (
192
+ SELECT
193
+ ov.overlap_days,
194
+ ov.overlap_days::numeric / NULLIF(d.date_to - d.date_from + 1, 0) AS share
195
+ FROM (
196
+ SELECT LEAST(d.date_to, (gs.month + interval '1 month - 1 day')::date)
197
+ - GREATEST(d.date_from, gs.month::date) + 1 AS overlap_days
198
+ ) ov
199
+ ) o
200
+ WHERE gs.month >= b.month_from AND gs.month < b.month_to
201
+ ),
202
+ invoice_month AS (
203
+ SELECT
204
+ source,
205
+ place_id,
206
+ period_start,
207
+ sum(volume) AS volume,
208
+ max(volume_unit) AS volume_unit,
209
+ sum(nm3) AS nm3,
210
+ sum(kwh) AS kwh,
211
+ bool_or(is_estimated) AS is_estimated,
212
+ -- Clamp to the month's length: sum() would exceed it when meters are billed in parallel.
213
+ LEAST(
214
+ sum(days_covered),
215
+ (period_start + interval '1 month')::date - period_start
216
+ ) AS days_covered,
217
+ array_agg(DISTINCT invoice_id::text ORDER BY invoice_id::text) AS invoice_ids
218
+ FROM invoice_device_month
219
+ GROUP BY source, place_id, period_start
220
+ ),
221
+ invoice_year AS (
222
+ SELECT
223
+ source,
224
+ place_id,
225
+ date_trunc('year', period_start)::date AS period_start,
226
+ array_agg(DISTINCT invoice_id::text ORDER BY invoice_id::text) AS invoice_ids
227
+ FROM invoice_device_month
228
+ GROUP BY source, place_id, date_trunc('year', period_start)::date
229
+ ),
230
+
231
+ porsenna_period AS (
232
+ SELECT *
233
+ FROM (
234
+ SELECT
235
+ d.identifier AS place_id,
236
+ c.commodity,
237
+ c."var" AS period_type,
238
+ f_enapo_safe_period(c."var", c."period") AS period_start,
239
+ c.value,
240
+ c.unit,
241
+ c."count" AS days_covered
242
+ FROM enapo_porsenna_consumption c
243
+ INNER JOIN enapo_porsenna_devices d
244
+ ON d.source = c.source AND d.id = c.device_id
245
+ WHERE d.identifier IS NOT NULL
246
+ AND (d."level" IS NULL OR d."level" = 'main')
247
+ AND c.commodity IS NOT NULL
248
+ AND c."var" IN ('yearly', 'monthly')
249
+ ) parsed
250
+ WHERE parsed.period_start IS NOT NULL
251
+ ),
252
+
253
+ eb_month AS (
254
+ -- Energy Broker history, full calendar months only: a period that is longer or does not start on
255
+ -- the 1st needs the pro-rata methodology, which is a separate step. One EAN/EIC can appear under
256
+ -- two idodbmistoom with identical values (a re-registered OM), so one row per month is kept instead
257
+ -- of summing them into a double. The highest idodbmisto_rspotrebyfa stands in for the newest row: the
258
+ -- one-off import has no timestamp, and in production the duplicates carry identical values.
259
+ SELECT DISTINCT ON (eb.ean_eic, eb.komodita, eb.datum_od)
260
+ eb.ean_eic AS place_id,
261
+ CASE eb.komodita WHEN 1 THEN 'electricity' WHEN 2 THEN 'gas' END AS commodity,
262
+ eb.datum_od AS period_start,
263
+ eb.spotreba_mwh * 1000 AS value_kwh,
264
+ eb.datum_do - eb.datum_od + 1 AS days_covered
265
+ FROM enapo_eb_consumption eb
266
+ CROSS JOIN bounds b
267
+ WHERE eb.spotreba_mwh IS NOT NULL
268
+ AND eb.datum_od = date_trunc('month', eb.datum_od)::date
269
+ AND eb.datum_do = (date_trunc('month', eb.datum_od) + interval '1 month - 1 day')::date
270
+ AND eb.datum_od >= b.month_from
271
+ AND eb.datum_od < b.month_to
272
+ ORDER BY eb.ean_eic, eb.komodita, eb.datum_od, eb.idodbmisto_rspotrebyfa DESC
273
+ ),
274
+
275
+ live_monthly AS (
276
+ SELECT
277
+ p.gid,
278
+ p.place_id,
279
+ p.id_type,
280
+ p.commodity,
281
+ 'predi_input'::varchar(50) AS source,
282
+ 'monthly'::varchar(10) AS period_type,
283
+ e.period_start,
284
+ COALESCE(e.total_kwh, e.vt_kwh + e.nt_kwh, e.vt_kwh, e.nt_kwh) AS value,
285
+ 'kWh'::varchar(10) AS unit,
286
+ COALESCE(e.total_kwh, e.vt_kwh + e.nt_kwh, e.vt_kwh, e.nt_kwh) AS value_kwh,
287
+ 'EActi_VT_NT_DaySum'::varchar(50) AS internal_source_var,
288
+ e.days_covered,
289
+ false AS is_estimated,
290
+ '{}'::text[] AS invoice_ids
291
+ FROM point p
292
+ INNER JOIN electricity_metered e ON e.place_id = p.place_id
293
+ WHERE p.commodity = 'electricity'
294
+
295
+ UNION ALL
296
+
297
+ SELECT
298
+ p.gid,
299
+ p.place_id,
300
+ p.id_type,
301
+ p.commodity,
302
+ 'ppas_ave_api',
303
+ 'monthly',
304
+ g.period_start,
305
+ g.m3_sum,
306
+ 'm3',
307
+ -- No conversion inputs for metered gas, but no consumption is 0 kWh whatever the conversion.
308
+ CASE WHEN g.m3_sum = 0 THEN 0 END,
309
+ 'core',
310
+ g.days_covered,
311
+ false,
312
+ '{}'::text[]
313
+ FROM point p
314
+ INNER JOIN gas_metered g ON g.place_id = p.place_id
315
+ WHERE p.commodity = 'gas'
316
+
317
+ UNION ALL
318
+
319
+ SELECT
320
+ p.gid,
321
+ p.place_id,
322
+ p.id_type,
323
+ p.commodity,
324
+ i.source,
325
+ 'monthly',
326
+ i.period_start,
327
+ COALESCE(i.volume, i.nm3::numeric),
328
+ CASE WHEN i.volume IS NOT NULL THEN i.volume_unit ELSE 'Nm3' END,
329
+ i.kwh,
330
+ NULL,
331
+ i.days_covered,
332
+ i.is_estimated,
333
+ i.invoice_ids
334
+ FROM point p
335
+ INNER JOIN invoice_month i ON i.place_id = p.place_id
336
+ WHERE p.commodity = 'gas'
337
+ ),
338
+
339
+ monthly AS (
340
+ SELECT * FROM live_monthly
341
+
342
+ UNION ALL
343
+
344
+ SELECT
345
+ p.gid,
346
+ p.place_id,
347
+ p.id_type,
348
+ p.commodity,
349
+ 'energy_broker',
350
+ 'monthly',
351
+ e.period_start,
352
+ e.value_kwh,
353
+ 'kWh',
354
+ e.value_kwh,
355
+ NULL,
356
+ e.days_covered,
357
+ false,
358
+ '{}'::text[]
359
+ FROM point p
360
+ INNER JOIN eb_month e ON e.place_id = p.place_id AND e.commodity = p.commodity
361
+ -- A live source that read the whole month in kWh wins, so the overlap is not charted twice. A pro-rata
362
+ -- invoice or a partially covered month does not win: the EB figure is billed for the whole month. Nor
363
+ -- does a volume-only row (the PPAS gas meter): EB is the only kWh for that month, which the combined
364
+ -- kWh series needs - unless the meter read no consumption, which is 0 kWh and wins.
365
+ WHERE NOT EXISTS (
366
+ SELECT 1
367
+ FROM live_monthly l
368
+ WHERE l.place_id = e.place_id
369
+ AND l.commodity = e.commodity
370
+ AND l.period_start = e.period_start
371
+ AND l.value_kwh IS NOT NULL
372
+ AND NOT l.is_estimated
373
+ AND l.days_covered >= (e.period_start + interval '1 month')::date - e.period_start
374
+ )
375
+ ),
376
+
377
+ yearly_rollup AS (
378
+ SELECT
379
+ gid,
380
+ place_id,
381
+ id_type,
382
+ commodity,
383
+ source,
384
+ 'yearly'::varchar(10) AS period_type,
385
+ date_trunc('year', period_start)::date AS period_start,
386
+ sum(value) AS value,
387
+ unit,
388
+ -- count(*) = count(value_kwh) is an all-non-null check: only sum value_kwh when every month has one.
389
+ CASE WHEN count(*) = count(value_kwh) THEN sum(value_kwh) END AS value_kwh,
390
+ max(internal_source_var) AS internal_source_var,
391
+ sum(days_covered) AS days_covered,
392
+ bool_or(is_estimated) AS is_estimated
393
+ FROM monthly m
394
+ GROUP BY gid, place_id, id_type, commodity, source, date_trunc('year', period_start)::date, unit
395
+ ),
396
+ yearly AS (
397
+ SELECT
398
+ r.gid,
399
+ r.place_id,
400
+ r.id_type,
401
+ r.commodity,
402
+ r.source,
403
+ r.period_type,
404
+ r.period_start,
405
+ r.value,
406
+ r.unit,
407
+ r.value_kwh,
408
+ r.internal_source_var,
409
+ r.days_covered,
410
+ r.is_estimated,
411
+ COALESCE(iy.invoice_ids, '{}'::text[]) AS invoice_ids
412
+ FROM yearly_rollup r
413
+ LEFT JOIN invoice_year iy
414
+ ON iy.source = r.source AND iy.place_id = r.place_id AND iy.period_start = r.period_start
415
+
416
+ UNION ALL
417
+
418
+ SELECT
419
+ p.gid,
420
+ p.place_id,
421
+ p.id_type,
422
+ p.commodity,
423
+ 'porsenna',
424
+ 'yearly',
425
+ pp.period_start,
426
+ pp.value,
427
+ pp.unit,
428
+ CASE WHEN lower(pp.unit) = 'kwh' THEN pp.value ELSE NULL END,
429
+ NULL,
430
+ pp.days_covered,
431
+ false,
432
+ '{}'::text[]
433
+ FROM point p
434
+ INNER JOIN porsenna_period pp
435
+ ON pp.place_id = p.place_id AND pp.commodity = p.commodity
436
+ WHERE pp.period_type = 'yearly'
437
+ )
438
+
439
+ SELECT
440
+ gid::varchar AS gid,
441
+ place_id::varchar(50) AS place_id,
442
+ max(id_type)::varchar(10) AS id_type,
443
+ commodity::varchar(20) AS commodity,
444
+ source::varchar(50) AS source,
445
+ period_type::varchar(10) AS period_type,
446
+ period_start,
447
+ sum(value)::numeric(30, 6) AS value,
448
+ unit::varchar(10) AS unit,
449
+ (CASE WHEN count(*) = count(value_kwh) THEN sum(value_kwh) END)::numeric(30, 6) AS value_kwh,
450
+ max(internal_source_var)::varchar(50) AS internal_source_var,
451
+ COALESCE(max(invoice_ids), '{}'::text[]) AS invoice_ids,
452
+ max(days_covered)::int AS coverage_count,
453
+ (
454
+ CASE period_type
455
+ WHEN 'yearly' THEN (make_date(extract(year FROM period_start)::int + 1, 1, 1) - period_start)
456
+ ELSE (period_start + interval '1 month')::date - period_start
457
+ END
458
+ )::int AS expected_count,
459
+ bool_or(is_estimated) AS is_estimated
460
+ FROM (
461
+ SELECT * FROM monthly
462
+ UNION ALL
463
+ SELECT * FROM yearly
464
+ ) all_periods
465
+ WHERE value IS NOT NULL
466
+ GROUP BY gid, place_id, commodity, source, unit, period_type, period_start;
467
+
468
+
469
+ COMMENT ON MATERIALIZED VIEW mv_enapo_om_consumption IS
470
+ 'Monthly and yearly consumption per consumption point per source, for the last 48 months, behind the DeD building detail endpoint. One row per (gid, place_id, commodity, source, unit, period_type, period_start). Refreshed by EnapoConsumptionHistoryRefreshTask (integration engine).';
471
+
472
+ COMMENT ON COLUMN mv_enapo_om_consumption.place_id IS
473
+ 'EAN / EIC / OM code, as in enapo_gid_mapping.place_id - NOT enapo_measurements.place_id, which holds a PPAS-internal place id. Measurements are joined through enapo_measurements.id.';
474
+ COMMENT ON COLUMN mv_enapo_om_consumption.value IS
475
+ 'Consumption for the period in `unit`. Electricity is kWh (the upstream T1_Wh_total naming is wrong); metered gas is m3, the difference between the cumulative `core` reading on the first day of the next month and the first day of the current month; invoiced gas follows the invoice unit; Porsenna carries its own unit; Energy Broker (enapo_eb_consumption, full calendar months only, suppressed where a live source read the whole month) is kWh converted from MWh for both electricity and gas.';
476
+ COMMENT ON COLUMN mv_enapo_om_consumption.value_kwh IS
477
+ 'Derived comparable energy: for gas, normometers_nm3 * combustion_heat (verified exact). NULL when the conversion inputs are missing - `value` + `unit` stay authoritative. NULL for metered gas (no normometers_nm3 input), except 0 when no gas was consumed.';
478
+ COMMENT ON COLUMN mv_enapo_om_consumption.internal_source_var IS
479
+ 'Which enapo_measurements.var produced the row; NULL for invoice and Porsenna rows. Provenance for debugging, not part of the public response.';
480
+ COMMENT ON COLUMN mv_enapo_om_consumption.invoice_ids IS
481
+ 'Billing documents the row was allocated from, sorted and deduplicated; empty for metered and Porsenna rows. A period can be allocated from several invoices (a yearly roll-up usually is), which is why it is an array.';
482
+ COMMENT ON COLUMN mv_enapo_om_consumption.coverage_count IS
483
+ 'Days of the period that carry data. Days for every commodity and both period types, so it is comparable with expected_count.';
484
+ COMMENT ON COLUMN mv_enapo_om_consumption.expected_count IS 'Days in the period (28-31, or 365/366).';
485
+ COMMENT ON COLUMN mv_enapo_om_consumption.is_estimated IS
486
+ 'True when the value was allocated pro rata from a billing period spanning more than one month, rather than read for the period itself.';
487
+
488
+ CREATE UNIQUE INDEX mv_enapo_om_consumption_uidx
489
+ ON mv_enapo_om_consumption (gid, place_id, commodity, source, unit, period_type, period_start);
490
+
491
+ CREATE INDEX mv_enapo_om_consumption_gid_period_idx
492
+ ON mv_enapo_om_consumption (gid, period_type, period_start);
@@ -0,0 +1,495 @@
1
+ DROP MATERIALIZED VIEW IF EXISTS mv_enapo_om_consumption;
2
+
3
+ CREATE MATERIALIZED VIEW mv_enapo_om_consumption AS
4
+ WITH bounds AS (
5
+ SELECT
6
+ ((date_trunc('month', now() AT TIME ZONE 'Europe/Prague') - interval '48 months')
7
+ AT TIME ZONE 'Europe/Prague') AS month_from_tstz,
8
+ (date_trunc('month', now() AT TIME ZONE 'Europe/Prague') - interval '48 months') AS month_from,
9
+ (date_trunc('month', now() AT TIME ZONE 'Europe/Prague')
10
+ AT TIME ZONE 'Europe/Prague') AS month_to_tstz,
11
+ date_trunc('month', now() AT TIME ZONE 'Europe/Prague') AS month_to
12
+ ),
13
+
14
+ point AS (
15
+ SELECT DISTINCT gid, place_id, id_type, commodity
16
+ FROM v_enapo_building_consumption_points
17
+ ),
18
+
19
+ electricity_metered AS (
20
+ SELECT
21
+ em.id AS place_id,
22
+ date_trunc('month', em."timestamp" AT TIME ZONE 'Europe/Prague')::date AS period_start,
23
+ sum(em.value) FILTER (WHERE em.var = 'EActi_VT_NT_DaySum') AS total_kwh,
24
+ sum(em.value) FILTER (WHERE em.var = 'EActi_VT_DaySum') AS vt_kwh,
25
+ sum(em.value) FILTER (WHERE em.var = 'EActi_NT_DaySum') AS nt_kwh,
26
+ -- Coverage is the max day-count across the three vars, not just the total's.
27
+ GREATEST(
28
+ count(*) FILTER (WHERE em.var = 'EActi_VT_NT_DaySum'),
29
+ count(*) FILTER (WHERE em.var = 'EActi_VT_DaySum'),
30
+ count(*) FILTER (WHERE em.var = 'EActi_NT_DaySum')
31
+ ) AS days_covered
32
+ FROM enapo_measurements em
33
+ CROSS JOIN bounds b
34
+ WHERE em.source = 'predi_input'
35
+ AND em.var IN ('EActi_VT_NT_DaySum', 'EActi_VT_DaySum', 'EActi_NT_DaySum')
36
+ AND em."timestamp" >= b.month_from_tstz
37
+ AND em."timestamp" < b.month_to_tstz
38
+ GROUP BY em.id, date_trunc('month', em."timestamp" AT TIME ZONE 'Europe/Prague')::date
39
+ ),
40
+
41
+ gas_core_month AS (
42
+ -- Cumulative 'core' reading (m3) at the first measured hour of each first-of-month day, kept per
43
+ -- (id, place_id): an id can carry several meters, one per PPAS place id, whose diffs are summed.
44
+ -- Not per device_serial_number - the integration stamps every refetched hour with the serial AVE
45
+ -- lists at fetch time, so the serial is unreliable for history and would split one meter in two.
46
+ SELECT DISTINCT ON (em.id, em.place_id, date_trunc('month', em."timestamp" AT TIME ZONE 'Europe/Prague'))
47
+ em.id,
48
+ em.place_id,
49
+ date_trunc('month', em."timestamp" AT TIME ZONE 'Europe/Prague')::date AS month_start,
50
+ em.value
51
+ FROM enapo_measurements em
52
+ CROSS JOIN bounds b
53
+ WHERE em.source = 'ppas_ave_api'
54
+ AND em.var = 'core'
55
+ AND (em."timestamp" AT TIME ZONE 'Europe/Prague')::date
56
+ = date_trunc('month', em."timestamp" AT TIME ZONE 'Europe/Prague')::date
57
+ AND em."timestamp" >= b.month_from_tstz
58
+ AND em."timestamp" < (b.month_to_tstz + interval '1 month')
59
+ ORDER BY em.id, em.place_id, date_trunc('month', em."timestamp" AT TIME ZONE 'Europe/Prague'), em."timestamp"
60
+ ),
61
+ gas_metered AS (
62
+ SELECT
63
+ cur.id AS place_id,
64
+ cur.month_start AS period_start,
65
+ -- A meter reading only grows, so a negative month total means a meter was exchanged or reset:
66
+ -- the month stays empty. Checked on the total, so a tiny drop on a small meter does not empty it.
67
+ (CASE WHEN sum(nxt.value - cur.value) < 0 THEN NULL ELSE sum(nxt.value - cur.value) END) AS m3_sum,
68
+ ((cur.month_start + interval '1 month')::date - cur.month_start) AS days_covered
69
+ FROM gas_core_month cur
70
+ INNER JOIN gas_core_month nxt
71
+ ON nxt.id = cur.id
72
+ AND nxt.place_id = cur.place_id
73
+ AND nxt.month_start = (cur.month_start + interval '1 month')::date
74
+ GROUP BY cur.id, cur.month_start
75
+ ),
76
+
77
+ distribution_device AS (
78
+ SELECT DISTINCT ON (ins.eic, d.place_id, d.device_serial_number, d.date_from, d.date_to, d.kind, d.unit)
79
+ ins.eic,
80
+ d.place_id,
81
+ d.invoice_id,
82
+ d.date_from,
83
+ d.date_to,
84
+ d.kind,
85
+ d.unit,
86
+ -- PPAS bills a month without consumption as an empty consumption with zero Nm3 and kWh.
87
+ COALESCE(d.consumption, 0) AS consumption,
88
+ d.normometers_nm3,
89
+ d.gas_consumption_kwh,
90
+ d.combustion_heat
91
+ FROM enapo_ppas_distribution_invoice_device d
92
+ INNER JOIN enapo_ppas_distribution_invoice i
93
+ ON i.id = d.invoice_id
94
+ INNER JOIN enapo_ppas_distribution_installation ins
95
+ ON ins.invoice_id = d.invoice_id
96
+ AND ins.customer_id = i.customer_id
97
+ AND ins.place_id = d.place_id
98
+ WHERE COALESCE(i.is_canceled, false) = false
99
+ AND d.date_to >= d.date_from
100
+ AND (d.consumption IS NOT NULL OR (d.normometers_nm3 = 0 AND d.gas_consumption_kwh = 0))
101
+ AND d.kind IN ('ZIZWC', 'ZIABN3')
102
+ AND d.reading_type = 'STANDARD'
103
+ AND NOT EXISTS (
104
+ SELECT 1 FROM enapo_ppas_distribution_invoice s
105
+ WHERE s.preceding_id = i.id AND COALESCE(s.is_canceled, false) = false
106
+ )
107
+ ORDER BY ins.eic, d.place_id, d.device_serial_number, d.date_from, d.date_to, d.kind, d.unit,
108
+ i.facts_doc_date DESC, i.id DESC
109
+ ),
110
+ commercial_device AS (
111
+ SELECT DISTINCT ON (ins.eic, d.place_id, d.device_serial_number, d.date_from, d.date_to, d.kind, d.unit)
112
+ ins.eic,
113
+ d.place_id,
114
+ d.invoice_id,
115
+ d.date_from,
116
+ d.date_to,
117
+ d.kind,
118
+ d.unit,
119
+ -- PPAS bills a month without consumption as an empty consumption with zero Nm3 and kWh.
120
+ COALESCE(d.consumption, 0) AS consumption,
121
+ d.normometers_nm3,
122
+ d.gas_consumption_kwh,
123
+ d.combustion_heat
124
+ FROM enapo_ppas_commercial_invoice_device d
125
+ INNER JOIN enapo_ppas_commercial_invoice i
126
+ ON i.id = d.invoice_id
127
+ INNER JOIN enapo_ppas_commercial_invoice_installations ins
128
+ ON ins.invoice_id = d.invoice_id
129
+ AND ins.customer_id = i.customer_id
130
+ AND ins.place_id = d.place_id
131
+ WHERE COALESCE(i.is_canceled, false) = false
132
+ AND d.date_to >= d.date_from
133
+ AND (d.consumption IS NOT NULL OR (d.normometers_nm3 = 0 AND d.gas_consumption_kwh = 0))
134
+ AND d.reading_type = 'STANDARD'
135
+ AND NOT EXISTS (
136
+ SELECT 1 FROM enapo_ppas_commercial_invoice s
137
+ WHERE s.preceding_id = i.id AND COALESCE(s.is_canceled, false) = false
138
+ )
139
+ ORDER BY ins.eic, d.place_id, d.device_serial_number, d.date_from, d.date_to, d.kind, d.unit,
140
+ i.facts_doc_date DESC, i.id DESC
141
+ ),
142
+
143
+ invoice_device_month AS (
144
+ SELECT
145
+ 'ppas_distribution_invoice' AS source,
146
+ d.eic AS place_id,
147
+ d.invoice_id,
148
+ gs.month::date AS period_start,
149
+ CASE WHEN d.unit = 'KWH' THEN NULL ELSE d.consumption END * o.share AS volume,
150
+ CASE WHEN d.unit = 'NM3' THEN 'Nm3' ELSE 'm3' END AS volume_unit,
151
+ d.normometers_nm3 * o.share AS nm3,
152
+ -- A zero kWh is a missing conversion input unless nothing was consumed: no consumption is 0 kWh.
153
+ CASE WHEN d.consumption = 0 THEN 0 ELSE NULLIF(d.gas_consumption_kwh, 0) END * o.share AS kwh,
154
+ (o.share < 1) AS is_estimated,
155
+ o.overlap_days AS days_covered
156
+ FROM distribution_device d
157
+ CROSS JOIN bounds b
158
+ CROSS JOIN LATERAL generate_series(
159
+ date_trunc('month', d.date_from::timestamp),
160
+ date_trunc('month', d.date_to::timestamp),
161
+ interval '1 month'
162
+ ) gs(month)
163
+ CROSS JOIN LATERAL (
164
+ SELECT
165
+ ov.overlap_days,
166
+ ov.overlap_days::numeric / NULLIF(d.date_to - d.date_from + 1, 0) AS share
167
+ FROM (
168
+ SELECT LEAST(d.date_to, (gs.month + interval '1 month - 1 day')::date)
169
+ - GREATEST(d.date_from, gs.month::date) + 1 AS overlap_days
170
+ ) ov
171
+ ) o
172
+ WHERE gs.month >= b.month_from AND gs.month < b.month_to
173
+
174
+ UNION ALL
175
+
176
+ SELECT
177
+ 'ppas_commercial_invoice',
178
+ d.eic,
179
+ d.invoice_id,
180
+ gs.month::date,
181
+ CASE WHEN d.unit = 'KWH' THEN NULL ELSE d.consumption END * o.share,
182
+ CASE WHEN d.unit = 'NM3' THEN 'Nm3' ELSE 'm3' END,
183
+ d.normometers_nm3 * o.share,
184
+ CASE WHEN d.consumption = 0 THEN 0 ELSE NULLIF(d.gas_consumption_kwh, 0) END * o.share,
185
+ (o.share < 1),
186
+ o.overlap_days
187
+ FROM commercial_device d
188
+ CROSS JOIN bounds b
189
+ CROSS JOIN LATERAL generate_series(
190
+ date_trunc('month', d.date_from::timestamp),
191
+ date_trunc('month', d.date_to::timestamp),
192
+ interval '1 month'
193
+ ) gs(month)
194
+ CROSS JOIN LATERAL (
195
+ SELECT
196
+ ov.overlap_days,
197
+ ov.overlap_days::numeric / NULLIF(d.date_to - d.date_from + 1, 0) AS share
198
+ FROM (
199
+ SELECT LEAST(d.date_to, (gs.month + interval '1 month - 1 day')::date)
200
+ - GREATEST(d.date_from, gs.month::date) + 1 AS overlap_days
201
+ ) ov
202
+ ) o
203
+ WHERE gs.month >= b.month_from AND gs.month < b.month_to
204
+ ),
205
+ invoice_month AS (
206
+ SELECT
207
+ source,
208
+ place_id,
209
+ period_start,
210
+ sum(volume) AS volume,
211
+ max(volume_unit) AS volume_unit,
212
+ sum(nm3) AS nm3,
213
+ sum(kwh) AS kwh,
214
+ bool_or(is_estimated) AS is_estimated,
215
+ -- Clamp to the month's length: sum() would exceed it when meters are billed in parallel.
216
+ LEAST(
217
+ sum(days_covered),
218
+ (period_start + interval '1 month')::date - period_start
219
+ ) AS days_covered,
220
+ array_agg(DISTINCT invoice_id::text ORDER BY invoice_id::text) AS invoice_ids
221
+ FROM invoice_device_month
222
+ GROUP BY source, place_id, period_start
223
+ ),
224
+ invoice_year AS (
225
+ SELECT
226
+ source,
227
+ place_id,
228
+ date_trunc('year', period_start)::date AS period_start,
229
+ array_agg(DISTINCT invoice_id::text ORDER BY invoice_id::text) AS invoice_ids
230
+ FROM invoice_device_month
231
+ GROUP BY source, place_id, date_trunc('year', period_start)::date
232
+ ),
233
+
234
+ porsenna_period AS (
235
+ SELECT *
236
+ FROM (
237
+ SELECT
238
+ d.identifier AS place_id,
239
+ c.commodity,
240
+ c."var" AS period_type,
241
+ f_enapo_safe_period(c."var", c."period") AS period_start,
242
+ c.value,
243
+ c.unit,
244
+ c."count" AS days_covered
245
+ FROM enapo_porsenna_consumption c
246
+ INNER JOIN enapo_porsenna_devices d
247
+ ON d.source = c.source AND d.id = c.device_id
248
+ WHERE d.identifier IS NOT NULL
249
+ AND (d."level" IS NULL OR d."level" = 'main')
250
+ AND c.commodity IS NOT NULL
251
+ AND c."var" IN ('yearly', 'monthly')
252
+ ) parsed
253
+ WHERE parsed.period_start IS NOT NULL
254
+ ),
255
+
256
+ eb_month AS (
257
+ -- Energy Broker history, full calendar months only: a period that is longer or does not start on
258
+ -- the 1st needs the pro-rata methodology, which is a separate step. One EAN/EIC can appear under
259
+ -- two idodbmistoom with identical values (a re-registered OM), so one row per month is kept instead
260
+ -- of summing them into a double. The highest idodbmisto_rspotrebyfa stands in for the newest row: the
261
+ -- one-off import has no timestamp, and in production the duplicates carry identical values.
262
+ SELECT DISTINCT ON (eb.ean_eic, eb.komodita, eb.datum_od)
263
+ eb.ean_eic AS place_id,
264
+ CASE eb.komodita WHEN 1 THEN 'electricity' WHEN 2 THEN 'gas' END AS commodity,
265
+ eb.datum_od AS period_start,
266
+ eb.spotreba_mwh * 1000 AS value_kwh,
267
+ eb.datum_do - eb.datum_od + 1 AS days_covered
268
+ FROM enapo_eb_consumption eb
269
+ CROSS JOIN bounds b
270
+ WHERE eb.spotreba_mwh IS NOT NULL
271
+ AND eb.datum_od = date_trunc('month', eb.datum_od)::date
272
+ AND eb.datum_do = (date_trunc('month', eb.datum_od) + interval '1 month - 1 day')::date
273
+ AND eb.datum_od >= b.month_from
274
+ AND eb.datum_od < b.month_to
275
+ ORDER BY eb.ean_eic, eb.komodita, eb.datum_od, eb.idodbmisto_rspotrebyfa DESC
276
+ ),
277
+
278
+ live_monthly AS (
279
+ SELECT
280
+ p.gid,
281
+ p.place_id,
282
+ p.id_type,
283
+ p.commodity,
284
+ 'predi_input'::varchar(50) AS source,
285
+ 'monthly'::varchar(10) AS period_type,
286
+ e.period_start,
287
+ COALESCE(e.total_kwh, e.vt_kwh + e.nt_kwh, e.vt_kwh, e.nt_kwh) AS value,
288
+ 'kWh'::varchar(10) AS unit,
289
+ COALESCE(e.total_kwh, e.vt_kwh + e.nt_kwh, e.vt_kwh, e.nt_kwh) AS value_kwh,
290
+ 'EActi_VT_NT_DaySum'::varchar(50) AS internal_source_var,
291
+ e.days_covered,
292
+ false AS is_estimated,
293
+ '{}'::text[] AS invoice_ids
294
+ FROM point p
295
+ INNER JOIN electricity_metered e ON e.place_id = p.place_id
296
+ WHERE p.commodity = 'electricity'
297
+
298
+ UNION ALL
299
+
300
+ SELECT
301
+ p.gid,
302
+ p.place_id,
303
+ p.id_type,
304
+ p.commodity,
305
+ 'ppas_ave_api',
306
+ 'monthly',
307
+ g.period_start,
308
+ g.m3_sum,
309
+ 'm3',
310
+ -- No conversion inputs for metered gas, but no consumption is 0 kWh whatever the conversion.
311
+ CASE WHEN g.m3_sum = 0 THEN 0 END,
312
+ 'core',
313
+ g.days_covered,
314
+ false,
315
+ '{}'::text[]
316
+ FROM point p
317
+ INNER JOIN gas_metered g ON g.place_id = p.place_id
318
+ WHERE p.commodity = 'gas'
319
+
320
+ UNION ALL
321
+
322
+ SELECT
323
+ p.gid,
324
+ p.place_id,
325
+ p.id_type,
326
+ p.commodity,
327
+ i.source,
328
+ 'monthly',
329
+ i.period_start,
330
+ COALESCE(i.volume, i.nm3::numeric),
331
+ CASE WHEN i.volume IS NOT NULL THEN i.volume_unit ELSE 'Nm3' END,
332
+ i.kwh,
333
+ NULL,
334
+ i.days_covered,
335
+ i.is_estimated,
336
+ i.invoice_ids
337
+ FROM point p
338
+ INNER JOIN invoice_month i ON i.place_id = p.place_id
339
+ WHERE p.commodity = 'gas'
340
+ ),
341
+
342
+ monthly AS (
343
+ SELECT * FROM live_monthly
344
+
345
+ UNION ALL
346
+
347
+ SELECT
348
+ p.gid,
349
+ p.place_id,
350
+ p.id_type,
351
+ p.commodity,
352
+ 'energy_broker',
353
+ 'monthly',
354
+ e.period_start,
355
+ e.value_kwh,
356
+ 'kWh',
357
+ e.value_kwh,
358
+ NULL,
359
+ e.days_covered,
360
+ false,
361
+ '{}'::text[]
362
+ FROM point p
363
+ INNER JOIN eb_month e ON e.place_id = p.place_id AND e.commodity = p.commodity
364
+ -- A live source that read the whole month in kWh wins, so the overlap is not charted twice. A pro-rata
365
+ -- invoice or a partially covered month does not win: the EB figure is billed for the whole month. Nor
366
+ -- does a volume-only row (the PPAS gas meter): EB is the only kWh for that month, which the combined
367
+ -- kWh series needs - unless the meter read no consumption, which is 0 kWh and wins.
368
+ WHERE NOT EXISTS (
369
+ SELECT 1
370
+ FROM live_monthly l
371
+ WHERE l.place_id = e.place_id
372
+ AND l.commodity = e.commodity
373
+ AND l.period_start = e.period_start
374
+ AND l.value_kwh IS NOT NULL
375
+ AND NOT l.is_estimated
376
+ AND l.days_covered >= (e.period_start + interval '1 month')::date - e.period_start
377
+ )
378
+ ),
379
+
380
+ yearly_rollup AS (
381
+ SELECT
382
+ gid,
383
+ place_id,
384
+ id_type,
385
+ commodity,
386
+ source,
387
+ 'yearly'::varchar(10) AS period_type,
388
+ date_trunc('year', period_start)::date AS period_start,
389
+ sum(value) AS value,
390
+ unit,
391
+ -- count(*) = count(value_kwh) is an all-non-null check: only sum value_kwh when every month has one.
392
+ CASE WHEN count(*) = count(value_kwh) THEN sum(value_kwh) END AS value_kwh,
393
+ max(internal_source_var) AS internal_source_var,
394
+ sum(days_covered) AS days_covered,
395
+ bool_or(is_estimated) AS is_estimated
396
+ FROM monthly m
397
+ GROUP BY gid, place_id, id_type, commodity, source, date_trunc('year', period_start)::date, unit
398
+ ),
399
+ yearly AS (
400
+ SELECT
401
+ r.gid,
402
+ r.place_id,
403
+ r.id_type,
404
+ r.commodity,
405
+ r.source,
406
+ r.period_type,
407
+ r.period_start,
408
+ r.value,
409
+ r.unit,
410
+ r.value_kwh,
411
+ r.internal_source_var,
412
+ r.days_covered,
413
+ r.is_estimated,
414
+ COALESCE(iy.invoice_ids, '{}'::text[]) AS invoice_ids
415
+ FROM yearly_rollup r
416
+ LEFT JOIN invoice_year iy
417
+ ON iy.source = r.source AND iy.place_id = r.place_id AND iy.period_start = r.period_start
418
+
419
+ UNION ALL
420
+
421
+ SELECT
422
+ p.gid,
423
+ p.place_id,
424
+ p.id_type,
425
+ p.commodity,
426
+ 'porsenna',
427
+ 'yearly',
428
+ pp.period_start,
429
+ pp.value,
430
+ pp.unit,
431
+ CASE WHEN lower(pp.unit) = 'kwh' THEN pp.value ELSE NULL END,
432
+ NULL,
433
+ pp.days_covered,
434
+ false,
435
+ '{}'::text[]
436
+ FROM point p
437
+ INNER JOIN porsenna_period pp
438
+ ON pp.place_id = p.place_id AND pp.commodity = p.commodity
439
+ WHERE pp.period_type = 'yearly'
440
+ )
441
+
442
+ SELECT
443
+ gid::varchar AS gid,
444
+ place_id::varchar(50) AS place_id,
445
+ max(id_type)::varchar(10) AS id_type,
446
+ commodity::varchar(20) AS commodity,
447
+ source::varchar(50) AS source,
448
+ period_type::varchar(10) AS period_type,
449
+ period_start,
450
+ sum(value)::numeric(30, 6) AS value,
451
+ unit::varchar(10) AS unit,
452
+ (CASE WHEN count(*) = count(value_kwh) THEN sum(value_kwh) END)::numeric(30, 6) AS value_kwh,
453
+ max(internal_source_var)::varchar(50) AS internal_source_var,
454
+ COALESCE(max(invoice_ids), '{}'::text[]) AS invoice_ids,
455
+ max(days_covered)::int AS coverage_count,
456
+ (
457
+ CASE period_type
458
+ WHEN 'yearly' THEN (make_date(extract(year FROM period_start)::int + 1, 1, 1) - period_start)
459
+ ELSE (period_start + interval '1 month')::date - period_start
460
+ END
461
+ )::int AS expected_count,
462
+ bool_or(is_estimated) AS is_estimated
463
+ FROM (
464
+ SELECT * FROM monthly
465
+ UNION ALL
466
+ SELECT * FROM yearly
467
+ ) all_periods
468
+ WHERE value IS NOT NULL
469
+ GROUP BY gid, place_id, commodity, source, unit, period_type, period_start;
470
+
471
+
472
+ COMMENT ON MATERIALIZED VIEW mv_enapo_om_consumption IS
473
+ 'Monthly and yearly consumption per consumption point per source, for the last 48 months, behind the DeD building detail endpoint. One row per (gid, place_id, commodity, source, unit, period_type, period_start). Refreshed by EnapoConsumptionHistoryRefreshTask (integration engine).';
474
+
475
+ COMMENT ON COLUMN mv_enapo_om_consumption.place_id IS
476
+ 'EAN / EIC / OM code, as in enapo_gid_mapping.place_id - NOT enapo_measurements.place_id, which holds a PPAS-internal place id. Measurements are joined through enapo_measurements.id.';
477
+ COMMENT ON COLUMN mv_enapo_om_consumption.value IS
478
+ 'Consumption for the period in `unit`. Electricity is kWh (the upstream T1_Wh_total naming is wrong); metered gas is m3, the difference between the cumulative `core` reading on the first day of the next month and the first day of the current month; invoiced gas follows the invoice unit; Porsenna carries its own unit; Energy Broker (enapo_eb_consumption, full calendar months only, suppressed where a live source read the whole month) is kWh converted from MWh for both electricity and gas.';
479
+ COMMENT ON COLUMN mv_enapo_om_consumption.value_kwh IS
480
+ 'Derived comparable energy: for gas, normometers_nm3 * combustion_heat (verified exact). NULL when the conversion inputs are missing - `value` + `unit` stay authoritative. NULL for metered gas (no normometers_nm3 input). Always 0 when no gas was consumed, whatever the conversion inputs.';
481
+ COMMENT ON COLUMN mv_enapo_om_consumption.internal_source_var IS
482
+ 'Which enapo_measurements.var produced the row; NULL for invoice and Porsenna rows. Provenance for debugging, not part of the public response.';
483
+ COMMENT ON COLUMN mv_enapo_om_consumption.invoice_ids IS
484
+ 'Billing documents the row was allocated from, sorted and deduplicated; empty for metered and Porsenna rows. A period can be allocated from several invoices (a yearly roll-up usually is), which is why it is an array.';
485
+ COMMENT ON COLUMN mv_enapo_om_consumption.coverage_count IS
486
+ 'Days of the period that carry data. Days for every commodity and both period types, so it is comparable with expected_count.';
487
+ COMMENT ON COLUMN mv_enapo_om_consumption.expected_count IS 'Days in the period (28-31, or 365/366).';
488
+ COMMENT ON COLUMN mv_enapo_om_consumption.is_estimated IS
489
+ 'True when the value was allocated pro rata from a billing period spanning more than one month, rather than read for the period itself.';
490
+
491
+ CREATE UNIQUE INDEX mv_enapo_om_consumption_uidx
492
+ ON mv_enapo_om_consumption (gid, place_id, commodity, source, unit, period_type, period_start);
493
+
494
+ CREATE INDEX mv_enapo_om_consumption_gid_period_idx
495
+ ON mv_enapo_om_consumption (gid, period_type, period_start);
@@ -1,3 +1,4 @@
1
+ import { ISimpleConfig } from "@golemio/core/dist/helpers/configuration/ISimpleConfig";
1
2
  import { AbstractRouter } from "@golemio/core/dist/helpers/routing/AbstractRouter";
2
3
  export declare class V2EnoBuildingsRouter extends AbstractRouter {
3
4
  private controller;
@@ -6,6 +7,8 @@ export declare class V2EnoBuildingsRouter extends AbstractRouter {
6
7
  private staleWhileRevalidate;
7
8
  private searchMaxAge;
8
9
  private searchStaleWhileRevalidate;
9
- constructor();
10
+ constructor(config: ISimpleConfig);
10
11
  protected initRoutes(): void;
12
+ /** The detail is cached for hours by default, so an invalid value falls back to it rather than to the core's minute. */
13
+ private static parseMaxAge;
11
14
  }
@@ -8,6 +8,10 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
8
8
  var __metadata = (this && this.__metadata) || function (k, v) {
9
9
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
10
  };
11
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
12
+ return function (target, key) { decorator(target, key, paramIndex); }
13
+ };
14
+ var V2EnoBuildingsRouter_1;
11
15
  Object.defineProperty(exports, "__esModule", { value: true });
12
16
  exports.V2EnoBuildingsRouter = void 0;
13
17
  const ConsumptionHistory_1 = require("../../constants/ConsumptionHistory");
@@ -16,19 +20,21 @@ const Links_1 = require("../../constants/Links");
16
20
  const RouteVersion_1 = require("../../constants/RouteVersion");
17
21
  const Di_1 = require("../../ioc/Di");
18
22
  const ModuleContainerToken_1 = require("../../ioc/ModuleContainerToken");
23
+ const CoreToken_1 = require("@golemio/core/dist/helpers/ioc/CoreToken");
19
24
  const AbstractRouter_1 = require("@golemio/core/dist/helpers/routing/AbstractRouter");
20
25
  const Validation_1 = require("@golemio/core/dist/output-gateway/Validation");
21
26
  const ioc_1 = require("@golemio/core/dist/output-gateway/ioc");
22
27
  const express_validator_1 = require("@golemio/core/dist/shared/express-validator");
23
28
  const tsyringe_1 = require("@golemio/core/dist/shared/tsyringe");
24
- let V2EnoBuildingsRouter = class V2EnoBuildingsRouter extends AbstractRouter_1.AbstractRouter {
25
- constructor() {
29
+ const DEFAULT_MAX_AGE = 6 * 60 * 60; // 6 hours
30
+ let V2EnoBuildingsRouter = V2EnoBuildingsRouter_1 = class V2EnoBuildingsRouter extends AbstractRouter_1.AbstractRouter {
31
+ constructor(config) {
26
32
  super(RouteVersion_1.RouteVersion.v2, Links_1.DED_BUILDING_PATH);
27
- this.maxAge = 6 * 60 * 60; // 6 hours
28
33
  this.staleWhileRevalidate = 60; // 1 minute
29
34
  // Suggestions must follow ingest closely, so the search is cached far more briefly than the detail.
30
35
  this.searchMaxAge = 60; // 1 minute
31
36
  this.searchStaleWhileRevalidate = 30; // 30 seconds
37
+ this.maxAge = V2EnoBuildingsRouter_1.parseMaxAge(config.getValue("env.ENERGETICS_DED_BUILDING_CACHE_MAX_AGE", String(DEFAULT_MAX_AGE)));
32
38
  this.controller = Di_1.OGEnergeticsContainer.resolve(ModuleContainerToken_1.ModuleContainerToken.EnoBuildingsController);
33
39
  this.cacheHeaderMiddleware = Di_1.OGEnergeticsContainer.resolve(ioc_1.ContainerToken.CacheHeaderMiddleware);
34
40
  this.initRoutes();
@@ -37,10 +43,16 @@ let V2EnoBuildingsRouter = class V2EnoBuildingsRouter extends AbstractRouter_1.A
37
43
  this.router.get("/", (0, express_validator_1.query)("q").exists().isString().not().isArray().trim().isLength({ min: 1, max: DedSearchWeights_1.DED_SEARCH_QUERY_MAX_LENGTH }), (0, express_validator_1.query)("limit").optional().isInt({ min: 1, max: DedSearchWeights_1.DED_SEARCH_LIMIT_MAX }).toInt(), Validation_1.checkErrors, this.cacheHeaderMiddleware.getMiddleware(this.searchMaxAge, this.searchStaleWhileRevalidate), this.controller.search);
38
44
  this.router.get("/:gid", (0, express_validator_1.param)("gid").exists().isString().not().isArray().trim(), (0, express_validator_1.query)("months").optional().isInt({ min: 1, max: ConsumptionHistory_1.CONSUMPTION_HISTORY_MONTHS_MAX }).toInt(), Validation_1.checkErrors, this.cacheHeaderMiddleware.getMiddleware(this.maxAge, this.staleWhileRevalidate), this.controller.getOne);
39
45
  }
46
+ /** The detail is cached for hours by default, so an invalid value falls back to it rather than to the core's minute. */
47
+ static parseMaxAge(value) {
48
+ const maxAge = Number(value);
49
+ return Number.isInteger(maxAge) && maxAge > 0 ? maxAge : DEFAULT_MAX_AGE;
50
+ }
40
51
  };
41
52
  exports.V2EnoBuildingsRouter = V2EnoBuildingsRouter;
42
- exports.V2EnoBuildingsRouter = V2EnoBuildingsRouter = __decorate([
53
+ exports.V2EnoBuildingsRouter = V2EnoBuildingsRouter = V2EnoBuildingsRouter_1 = __decorate([
43
54
  (0, tsyringe_1.injectable)(),
44
- __metadata("design:paramtypes", [])
55
+ __param(0, (0, tsyringe_1.inject)(CoreToken_1.CoreToken.SimpleConfig)),
56
+ __metadata("design:paramtypes", [Object])
45
57
  ], V2EnoBuildingsRouter);
46
58
  //# sourceMappingURL=V2EnoBuildingsRouter.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"V2EnoBuildingsRouter.js","sourceRoot":"","sources":["../../../../src/output-gateway/routers/v2/V2EnoBuildingsRouter.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2EAAkF;AAClF,uEAAmG;AACnG,iDAAwD;AACxD,+DAA0D;AAE1D,qCAAmD;AACnD,yEAAoE;AACpE,sFAAmF;AAEnF,6EAA2E;AAC3E,+DAAuE;AACvE,mFAA2E;AAC3E,iEAAgE;AAGzD,IAAM,oBAAoB,GAA1B,MAAM,oBAAqB,SAAQ,+BAAc;IASpD;QACI,KAAK,CAAC,2BAAY,CAAC,EAAE,EAAE,yBAAiB,CAAC,CAAC;QAPtC,WAAM,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,CAAC,CAAC,UAAU;QAChC,yBAAoB,GAAG,EAAE,CAAC,CAAC,WAAW;QAC9C,oGAAoG;QAC5F,iBAAY,GAAG,EAAE,CAAC,CAAC,WAAW;QAC9B,+BAA0B,GAAG,EAAE,CAAC,CAAC,aAAa;QAIlD,IAAI,CAAC,UAAU,GAAG,0BAAqB,CAAC,OAAO,CAAyB,2CAAoB,CAAC,sBAAsB,CAAC,CAAC;QACrH,IAAI,CAAC,qBAAqB,GAAG,0BAAqB,CAAC,OAAO,CAAwB,oBAAc,CAAC,qBAAqB,CAAC,CAAC;QACxH,IAAI,CAAC,UAAU,EAAE,CAAC;IACtB,CAAC;IAES,UAAU;QAChB,IAAI,CAAC,MAAM,CAAC,GAAG,CACX,GAAG,EACH,IAAA,yBAAK,EAAC,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,GAAG,EAAE,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,CAAC,QAAQ,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,8CAA2B,EAAE,CAAC,EAC5G,IAAA,yBAAK,EAAC,OAAO,CAAC,CAAC,QAAQ,EAAE,CAAC,KAAK,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,uCAAoB,EAAE,CAAC,CAAC,KAAK,EAAE,EAC9E,wBAAW,EACX,IAAI,CAAC,qBAAqB,CAAC,aAAa,CAAC,IAAI,CAAC,YAAY,EAAE,IAAI,CAAC,0BAA0B,CAAC,EAC5F,IAAI,CAAC,UAAU,CAAC,MAAM,CACzB,CAAC;QAEF,IAAI,CAAC,MAAM,CAAC,GAAG,CACX,OAAO,EACP,IAAA,yBAAK,EAAC,KAAK,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,GAAG,EAAE,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,EACvD,IAAA,yBAAK,EAAC,QAAQ,CAAC,CAAC,QAAQ,EAAE,CAAC,KAAK,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,mDAA8B,EAAE,CAAC,CAAC,KAAK,EAAE,EACzF,wBAAW,EACX,IAAI,CAAC,qBAAqB,CAAC,aAAa,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,oBAAoB,CAAC,EAChF,IAAI,CAAC,UAAU,CAAC,MAAM,CACzB,CAAC;IACN,CAAC;CACJ,CAAA;AAnCY,oDAAoB;+BAApB,oBAAoB;IADhC,IAAA,qBAAU,GAAE;;GACA,oBAAoB,CAmChC"}
1
+ {"version":3,"file":"V2EnoBuildingsRouter.js","sourceRoot":"","sources":["../../../../src/output-gateway/routers/v2/V2EnoBuildingsRouter.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,2EAAkF;AAClF,uEAAmG;AACnG,iDAAwD;AACxD,+DAA0D;AAE1D,qCAAmD;AACnD,yEAAoE;AAEpE,wEAAqE;AACrE,sFAAmF;AAEnF,6EAA2E;AAC3E,+DAAuE;AACvE,mFAA2E;AAC3E,iEAAwE;AAExE,MAAM,eAAe,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,CAAC,CAAC,UAAU;AAGxC,IAAM,oBAAoB,4BAA1B,MAAM,oBAAqB,SAAQ,+BAAc;IASpD,YAA4C,MAAqB;QAC7D,KAAK,CAAC,2BAAY,CAAC,EAAE,EAAE,yBAAiB,CAAC,CAAC;QANtC,yBAAoB,GAAG,EAAE,CAAC,CAAC,WAAW;QAC9C,oGAAoG;QAC5F,iBAAY,GAAG,EAAE,CAAC,CAAC,WAAW;QAC9B,+BAA0B,GAAG,EAAE,CAAC,CAAC,aAAa;QAIlD,IAAI,CAAC,MAAM,GAAG,sBAAoB,CAAC,WAAW,CAC1C,MAAM,CAAC,QAAQ,CAAS,2CAA2C,EAAE,MAAM,CAAC,eAAe,CAAC,CAAC,CAChG,CAAC;QACF,IAAI,CAAC,UAAU,GAAG,0BAAqB,CAAC,OAAO,CAAyB,2CAAoB,CAAC,sBAAsB,CAAC,CAAC;QACrH,IAAI,CAAC,qBAAqB,GAAG,0BAAqB,CAAC,OAAO,CAAwB,oBAAc,CAAC,qBAAqB,CAAC,CAAC;QACxH,IAAI,CAAC,UAAU,EAAE,CAAC;IACtB,CAAC;IAES,UAAU;QAChB,IAAI,CAAC,MAAM,CAAC,GAAG,CACX,GAAG,EACH,IAAA,yBAAK,EAAC,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,GAAG,EAAE,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,CAAC,QAAQ,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,8CAA2B,EAAE,CAAC,EAC5G,IAAA,yBAAK,EAAC,OAAO,CAAC,CAAC,QAAQ,EAAE,CAAC,KAAK,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,uCAAoB,EAAE,CAAC,CAAC,KAAK,EAAE,EAC9E,wBAAW,EACX,IAAI,CAAC,qBAAqB,CAAC,aAAa,CAAC,IAAI,CAAC,YAAY,EAAE,IAAI,CAAC,0BAA0B,CAAC,EAC5F,IAAI,CAAC,UAAU,CAAC,MAAM,CACzB,CAAC;QAEF,IAAI,CAAC,MAAM,CAAC,GAAG,CACX,OAAO,EACP,IAAA,yBAAK,EAAC,KAAK,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,GAAG,EAAE,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,EACvD,IAAA,yBAAK,EAAC,QAAQ,CAAC,CAAC,QAAQ,EAAE,CAAC,KAAK,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,mDAA8B,EAAE,CAAC,CAAC,KAAK,EAAE,EACzF,wBAAW,EACX,IAAI,CAAC,qBAAqB,CAAC,aAAa,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,oBAAoB,CAAC,EAChF,IAAI,CAAC,UAAU,CAAC,MAAM,CACzB,CAAC;IACN,CAAC;IAED,wHAAwH;IAChH,MAAM,CAAC,WAAW,CAAC,KAAa;QACpC,MAAM,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;QAC7B,OAAO,MAAM,CAAC,SAAS,CAAC,MAAM,CAAC,IAAI,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,eAAe,CAAC;IAC7E,CAAC;CACJ,CAAA;AA5CY,oDAAoB;+BAApB,oBAAoB;IADhC,IAAA,qBAAU,GAAE;IAUI,WAAA,IAAA,iBAAM,EAAC,qBAAS,CAAC,YAAY,CAAC,CAAA;;GATlC,oBAAoB,CA4ChC"}
@@ -718,7 +718,7 @@ Task přegeneruje materializovaný pohled `mv_enapo_om_consumption`, ze kterého
718
718
  - prod `0 45 3 * * *`
719
719
  - samotná definice cronu je vedena mimo tento repozitář (deployment konfigurace)
720
720
  - spouštění na základě událostí
721
- - žádné, a to záměrně: [EnapoPpasTask](#task-enapoppastask) stahuje po pětidenních intervalech a spouštěl by desítky refreshů za jeden běh, přičemž endpoint stejně garantuje svěžest jen na 6 hodin (cache hlavičky)
721
+ - žádné, a to záměrně: [EnapoPpasTask](#task-enapoppastask) stahuje po pětidenních intervalech a spouštěl by desítky refreshů za jeden běh, přičemž endpoint stejně garantuje svěžest jen na dobu cache (hlavička `s-maxage`, výchozích 6 hodin; per env ji nastavuje proměnná `ENERGETICS_DED_BUILDING_CACHE_MAX_AGE` v sekundách, na OG se předává přes `extraEnvVars` v infrastruktuře, neplatná hodnota zachová výchozí). Jde o sdílenou cache (CDN), takže ji z prohlížeče obejít nejde
722
722
  - data modely
723
723
  - [EnapoConsumptionHistoryIndexRepository](../src/integration-engine/enapo/repositories/EnapoConsumptionHistoryIndexRepository.ts) — `REFRESH MATERIALIZED VIEW CONCURRENTLY energetics.mv_enapo_om_consumption`, kontrakt [IEnapoConsumptionHistoryIndexRepository](../src/integration-engine/enapo/repositories/interfaces/IEnapoConsumptionHistoryIndexRepository.ts)
724
724
  - funkce
@@ -808,6 +808,7 @@ Další ověřené vlastnosti dat, na které agregace spoléhá:
808
808
  - `ZSCBM3` (M3, 49 EIC) — nese 92 % objemu `ZIZWC` z devítiny jeho EIC; vyloučeno jako bezpečnější strana otevřené otázky (chybějící řada se vrací jako `null`, zdvojené číslo by se tvářilo jako platné)
809
809
  - sloupec `consumption` je denominovaný podle `unit` (M3 / NM3 / KWH), **není to vždy m³**
810
810
  - `combustion_heat` a `volume_coefficient` s hodnotou 0 znamenají „nedodáno“, ne nulu — bez `NULLIF` by se z odvozených kWh stala nula, která se na grafu kreslí jako skutečný odečet
811
+ - měsíc **bez spotřeby** PPAS fakturuje s prázdným `consumption` a nulovými `normometers_nm3` i `gas_consumption_kwh` (explicitní `consumption = 0` se v datech nevyskytuje; ověřeno 2026-10-19, ~5 600 takových řádků). `mv_enapo_om_consumption` takový řádek bere jako 0 m³ a 0 kWh — nulová spotřeba je 0 kWh bez ohledu na přepočet, takže tu `NULLIF` neplatí. Řádek bez `consumption` i bez Nm³/kWh (všechno `NULL`) nic neříká a zůstává chybějícím údajem
811
812
  - existují **duplicitní faktury bez vazby přes `preceding_id`** nesoucí bajtově shodné řádky pro totéž EIC a období; deduplikace proto musí jít podle obsahu řádku `(eic, place_id, device_serial_number, date_from, date_to, kind, reading_type, unit)`, nikoli podle návaznosti faktur
812
813
  - `mv_enapo_om_consumption` zatím bere jen `reading_type = 'STANDARD'`. Protože CORRECTION je storno (viz výše, negativní consumptionKwh), znamená to, že se storna neodečítají a spotřeba je **nadhodnocená o ~0,68 %**. Vědomé zjednodušení první verze: znaménko nese pouze `gas_consumption_kwh`, `consumption` zůstává pozitivní, takže naivní součet by v jednom sloupci odečítal a ve druhém přičítal
813
814
  - formát dat
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@golemio/energetics",
3
- "version": "2.0.0-dev.2954231424",
3
+ "version": "2.0.0-dev.2962537365",
4
4
  "description": "Golemio Energetics Module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",