From 31e6586c173931a8c9d2d93aefe87ab286dd1278 Mon Sep 17 00:00:00 2001 From: gnacho Date: Fri, 4 Sep 2026 16:15:19 +0200 Subject: [PATCH 1/6] feat(day-series): fill inverter curve gaps from backup power sensor Add optional backupPowerId/backupPowerUnit per inverter in the topology. When a 5-minute bucket has no row for the primary power sensor, buildDaySeries uses the backup sensor instead and scales that stretch to the day real total (daily row for past days, live KPI for today), marking the curve estimated. Nightly consolidation regenerates yesterday so its curve is rescaled once the daily row is written. LEGACY topology sets Fox backup to the manufacturer cloud power sensor. --- app/src/components/TopologyEditor.tsx | 24 +++++ app/src/data/types.ts | 3 + app/src/i18n/locales/en/translation.json | 1 + app/src/i18n/locales/es/translation.json | 1 + app/src/i18n/locales/zh-CN/translation.json | 1 + server/src/install.js | 6 ++ server/src/solar.js | 109 ++++++++++++++++++-- server/test/backfill.test.js | 91 +++++++++++++++- shared/schemas.js | 4 + 9 files changed, 229 insertions(+), 11 deletions(-) diff --git a/app/src/components/TopologyEditor.tsx b/app/src/components/TopologyEditor.tsx index feb1989..19115a0 100644 --- a/app/src/components/TopologyEditor.tsx +++ b/app/src/components/TopologyEditor.tsx @@ -164,6 +164,28 @@ export function TopologyEditor({ open, onOpenChange, install }: Props) { /> + +
+ kv.inv(i, { backupPowerId: e.target.value })} + /> + +
+
inv.powerId).filter(Boolean) + const invIds = t.inverters.flatMap((inv) => [inv.powerId, inv.backupPowerId].filter(Boolean)) const ids = [...invIds, ...t.consumption.powerIds] if (t.battery.enabled) { if (t.battery.powerId) ids.push(t.battery.powerId) @@ -274,16 +274,34 @@ async function getDaySeriesUncached(ha, dateStr, db) { types: ['mean'], }) - return buildDaySeries(stats, dateStr, db) + // HOY aún no tiene fila `daily`: el total de referencia para escalar el + // tramo rellenado por respaldo es el KPI en vivo del energyId (la UM del + // día). Para días pasados buildDaySeries lee la fila daily directamente. + const liveTargets = {} + const isToday = !dateStr || dateStr === todayStr() + if (isToday) { + for (const inv of t.inverters) { + if (inv.backupPowerId && inv.energyId) liveTargets[inv.key] = entityNum(ha, inv.energyId) + } + } + + return buildDaySeries(stats, dateStr, db, liveTargets) } // Construye los puntos de la curva de un día a partir de las estadísticas // 5-minutos ({sensorId: [{start, mean}]}). Reutilizable para la curva en vivo // (HAOS) y para el backfill desde una fuente externa (backup de HAOS). El // heurístico de curva estimada (Solis sin state_class) también aplica aquí. -function buildDaySeries(stats, dateStr, db) { +// +// Respaldo por inversor (issue 124): si en un bucket no hay fila del sensor +// de potencia principal (unavailable), se usa `backupPowerId` del mismo +// inversor. El tramo rellenado se ESCALA al total diario real del inversor +// (fila `daily` para días pasados; `liveTargets` para HOY, que aún no tiene +// fila), para que la curva integre el mismo total que la fuente profunda. +// Si se usó respaldo en algún bucket, la curva se marca `estimated`. +function buildDaySeries(stats, dateStr, db, liveTargets) { const t = getInstall() - const invIds = t.inverters.map((inv) => inv.powerId).filter(Boolean) + const invIds = t.inverters.flatMap((inv) => [inv.powerId, inv.backupPowerId].filter(Boolean)) const ids = [...invIds, ...t.consumption.powerIds] if (t.battery.enabled) { if (t.battery.powerId) ids.push(t.battery.powerId) @@ -305,12 +323,23 @@ function buildDaySeries(stats, dateStr, db) { let prevSoc = null const minutes = [...buckets.keys()].sort((a, b) => a - b) const consDiv = t.consumption.powerUnit === 'W' ? 1000 : 1 - for (const min of minutes) { + const backupIdx = {} // inv.key -> índices de points que usaron respaldo + minutes.forEach((min, pointIdx) => { const b = buckets.get(min) let production = 0 const perInv = {} for (const inv of t.inverters) { - let kw = num(b[inv.powerId]) / (inv.powerUnit === 'W' ? 1000 : 1) + let kw = 0 + const raw = b[inv.powerId] + if (raw !== undefined && raw !== null) { + kw = num(raw) / (inv.powerUnit === 'W' ? 1000 : 1) + } else if (inv.backupPowerId) { + const backRaw = b[inv.backupPowerId] + if (backRaw !== undefined && backRaw !== null) { + kw = num(backRaw) / ((inv.backupPowerUnit || 'kW') === 'W' ? 1000 : 1) + ;(backupIdx[inv.key] ||= []).push(pointIdx) + } + } if (kw < 0.02) kw = 0 perInv[inv.key] = kw production += kw @@ -349,11 +378,69 @@ function buildDaySeries(stats, dateStr, db) { soc: round1(soc), grid: round3(grid), }) - } + }) const dtH = 5 / 60 const sumInv = (key) => points.reduce((acc, p) => acc + (p.inverters[key] || 0) * dtH, 0) + const dayKey = dateStr || todayStr() let estimated = false + // Escalado de tramos rellenados por respaldo al total diario real. El total + // de referencia es la fila daily del día si existe; para HOY (sin fila aún) + // lo aporta `liveTargets` (KPI en vivo del energyId). Solo se escala el + // tramo con respaldo: la parte medida por el sensor principal se respeta. + const backupKeys = Object.keys(backupIdx) + if (backupKeys.length && points.length) { + estimated = true + let row = null + if (db) { + row = db.prepare('SELECT solis_kwh, fox_kwh, inverters_kwh FROM daily WHERE date = ?').get(dayKey) + } + for (const inv of t.inverters) { + const idxs = backupIdx[inv.key] || [] + if (!idxs.length) continue + let target = liveTargets && liveTargets[inv.key] > 0 ? liveTargets[inv.key] : 0 + if (!(target > 0) && row) { + if (row.inverters_kwh) { + try { + const ik = JSON.parse(row.inverters_kwh) + target = ik[inv.key] || 0 + } catch {} + } + if (!(target > 0)) { + const invIdx = t.inverters.findIndex((x) => x.key === inv.key) + if (invIdx === 0) target = row.solis_kwh || 0 + else if (invIdx === 1) target = row.fox_kwh || 0 + } + } + if (!(target > 0)) continue + const idxSet = new Set(idxs) + let sumReal = 0 + let sumBackup = 0 + points.forEach((p, i) => { + const kw = p.inverters[inv.key] || 0 + if (idxSet.has(i)) sumBackup += kw + else sumReal += kw + }) + sumReal *= dtH + sumBackup *= dtH + if (sumBackup <= 0) continue + const gap = target - (sumReal + sumBackup) + if (gap < 0.05) continue // ya cuadra o la parte real supera el total + const factor = (target - sumReal) / sumBackup + const compat = inv.key === t.inverters[0]?.key ? 'solis' : inv.key === t.inverters[1]?.key ? 'fox' : null + for (const i of idxs) { + const p = points[i] + const v = (p.inverters[inv.key] || 0) * factor + p.inverters[inv.key] = round3(v) + if (compat === 'solis') p.solis = p.inverters[inv.key] + if (compat === 'fox') p.fox = p.inverters[inv.key] + p.production = round3(t.inverters.reduce((acc, x) => acc + (p.inverters[x.key] || 0), 0)) + let grid = p.consumption - p.production + p.batteryPower + if (Math.abs(grid) < 0.03) grid = 0 + p.grid = round3(grid) + } + } + } // Estimación de curva: si el inversor 0 no tiene statistics pero el total // diario sí, se escala la curva del resto (fallback típico de sensores sin // state_class). Se mantiene el heurístico original Solis→Fox. @@ -363,7 +450,7 @@ function buildDaySeries(stats, dateStr, db) { const sum0 = sumInv(inv0Key) const sum1 = inv1Key ? sumInv(inv1Key) : 0 if (sum0 < 0.5 && sum1 > 1 && inv0Key && inv1Key) { - const row = db.prepare('SELECT solis_kwh, fox_kwh FROM daily WHERE date = ?').get(dateStr || todayStr()) + const row = db.prepare('SELECT solis_kwh, fox_kwh FROM daily WHERE date = ?').get(dayKey) if (row && row.solis_kwh > 1 && sum1 > 1) { const ratio = row.solis_kwh / sum1 for (const p of points) { @@ -436,9 +523,13 @@ export async function backfillDaySeries(ha, db, backupFilePath) { const cursor = new Date(firstDaily + 'T00:00:00') const end = new Date() end.setDate(end.getDate() + 1) + // Ayer se regenera SIEMPRE aunque tenga fila: la consolidación nocturna + // acaba de escribir su fila `daily` real y la curva persistida en vivo (con + // huecos por respaldo sin consolidar) debe re-escalarse a ese total. + const yesterdayKey = dateKey(addDays(new Date(), -1)) for (const key of eachDayKey(cursor, end)) { - if (done.has(key)) continue + if (done.has(key) && key !== yesterdayKey) continue const keyDate = new Date(key + 'T00:00:00') const fromBackup = backup && Object.keys(sliceStatsByDay(backup, key)).length > 0 if (!fromBackup && keyDate < haosFloor) continue diff --git a/server/test/backfill.test.js b/server/test/backfill.test.js index 74a960d..8bd762d 100644 --- a/server/test/backfill.test.js +++ b/server/test/backfill.test.js @@ -10,7 +10,7 @@ process.env.AUTH_PASS = 'testpass' process.env.DATA_DIR = '/tmp/helios-bf-datadir' const { openDb } = await import('../src/db.js') -const { backfillHistory, backfillDaySeries, todayStr } = await import('../src/solar.js') +const { backfillHistory, backfillDaySeries, todayStr, getDaySeries } = await import('../src/solar.js') const { _setForTests, LEGACY_TOPOLOGY } = await import('../src/install.js') function makeHa(stats) { @@ -89,7 +89,6 @@ describe('backfillDaySeries — curvas persistidas en day_series (issue #114)', }) it('getDaySeries sirve días pasados desde day_series sin tocar HAOS', async () => { - const { getDaySeries } = await import('../src/solar.js') const dir = mkdtempSync(join(tmpdir(), 'helios-bf-')) const db = openDb(dir) const points = [ @@ -110,3 +109,91 @@ describe('backfillDaySeries — curvas persistidas en day_series (issue #114)', rmSync(dir, { recursive: true, force: true }) }) }) + +describe('backup power source per inverter (issue #124)', () => { + afterEach(() => _setForTests(LEGACY_TOPOLOGY)) + + // Día pasado SIN fila en day_series: getDaySeries computa desde HAOS y usa + // la fila `daily` del día como total de referencia para escalar el respaldo. + it('rellena el hueco del sensor principal con backupPowerId y escala al total daily', async () => { + const dir = mkdtempSync(join(tmpdir(), 'helios-bf-')) + const db = openDb(dir) + // LEGACY: fox.powerId = almacen_pinza_power_b (W), fox.backupPowerId = + // foxess_solar_power (kW). El día tiene 2 tramos de 1 h: 07:00-08:00 SOLO + // en el backup (hueco de la pinza) y 10:00-11:00 SOLO en la pinza. + const hourBuckets = (iso, mean) => + Array.from({ length: 12 }, (_, i) => ({ + start: new Date(new Date(iso).getTime() + i * 5 * 60000).toISOString(), + mean, + })) + const stats = { + 'sensor.foxess_solar_power': hourBuckets('2026-07-20T07:00:00Z', 1.5), + 'sensor.almacen_pinza_power_b': hourBuckets('2026-07-20T10:00:00Z', 2000), + } + const ha = { + statisticsDuringPeriod: vi.fn(async () => stats), + getState: vi.fn(), + } + // Total real del Fox del día según la fuente profunda (pinza lifetime): + // 2 kWh medidos + 1.7 kWh en el hueco (el backup crudo da 1.5, ~4% bajo). + db.prepare( + "INSERT INTO daily (date, production_kwh, solis_kwh, fox_kwh) VALUES ('2026-07-20', 3.7, 0, 3.7)" + ).run() + + const res = await getDaySeries(ha, '2026-07-20', db) + expect(res.estimated).toBe(true) + + // El hueco (07:00Z local) usa el backup: fox = 1.5 kW escalado por + // (3.7 - 2.0) / 1.5 = 1.133 → ~1.7 kW. La parte de la pinza (10:00Z) NO se + // toca: fox = 2.0 kW. Los labels son hora LOCAL del runner. + const lab = (iso) => { + const d = new Date(iso) + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` + } + const p07 = res.points.find((p) => p.label === lab('2026-07-20T07:00:00Z')) + const p10 = res.points.find((p) => p.label === lab('2026-07-20T10:00:00Z')) + expect(p07.fox).toBeCloseTo(1.7, 1) + expect(p10.fox).toBeCloseTo(2.0, 1) + + // La curva integra el total daily real del Fox. + const dtH = 5 / 60 + const integ = res.points.reduce((acc, p) => acc + p.fox * dtH, 0) + expect(integ).toBeCloseTo(3.7, 1) + + db.close() + rmSync(dir, { recursive: true, force: true }) + }) + + it('sin backupPowerId no inventa datos (comportamiento previo)', async () => { + const dir = mkdtempSync(join(tmpdir(), 'helios-bf-')) + const db = openDb(dir) + const t = { + ...LEGACY_TOPOLOGY, + inverters: LEGACY_TOPOLOGY.inverters.map((inv, i) => + i === 1 ? { ...inv, backupPowerId: '', backupPowerUnit: 'kW' } : inv + ), + } + _setForTests(t) + const stats = { + 'sensor.almacen_pinza_power_b': [{ start: '2026-07-21T10:00:00Z', mean: 2000 }], + } + const ha = { statisticsDuringPeriod: vi.fn(async () => stats), getState: vi.fn() } + db.prepare( + "INSERT INTO daily (date, production_kwh, solis_kwh, fox_kwh) VALUES ('2026-07-21', 2.0, 0, 2.0)" + ).run() + + const res = await getDaySeries(ha, '2026-07-21', db) + expect(res.estimated).toBe(false) + const lab = (iso) => { + const d = new Date(iso) + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` + } + const p10 = res.points.find((p) => p.label === lab('2026-07-21T10:00:00Z')) + expect(p10.fox).toBeCloseTo(2.0, 1) + // Solo existe el bucket de la pinza: sin hueco rellenado por backup. + expect(res.points.some((p) => p.label === lab('2026-07-21T07:00:00Z'))).toBe(false) + + db.close() + rmSync(dir, { recursive: true, force: true }) + }) +}) diff --git a/shared/schemas.js b/shared/schemas.js index 4f3ea01..1a2390f 100644 --- a/shared/schemas.js +++ b/shared/schemas.js @@ -58,6 +58,10 @@ export function createSchemas(z) { batteryKwh: z.number().min(0).optional().default(0), powerId: z.string().optional().default(''), powerUnit: z.enum(['kW', 'W']).optional().default('kW'), + // Fuente secundaria de potencia para rellenar huecos de la curva del + // día cuando el sensor principal está unavailable (issue 124). + backupPowerId: z.string().optional().default(''), + backupPowerUnit: z.enum(['kW', 'W']).optional().default('kW'), energyId: z.string().optional().default(''), energyAcc: z.enum(['sum', 'state']).optional().default('state'), energyCap: z.number().min(0).optional().default(100), From 6342a8fb77dc776e7d53e4b4debb9c7beaea631b Mon Sep 17 00:00:00 2001 From: gnacho Date: Fri, 4 Sep 2026 16:32:46 +0200 Subject: [PATCH 2/6] feat(day-series): fill consumption gap from previous-day pattern When every consumption meter drops at once (Zigbee outage), no redundant sensor measures house consumption: inverter familyLoad ignores bypass loads and a balance from inverter production/red is wrong with parallel inverters. Fill the gap with the previous day 5-minute shape, scaled so the day consumption integral matches the real energy-counter total (live counters for today, daily row for past days). Marks the curve estimated. --- server/src/solar.js | 59 +++++++++++++++++++++++++++++++++++- server/test/backfill.test.js | 54 +++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/server/src/solar.js b/server/src/solar.js index 47a77f3..4b541aa 100644 --- a/server/src/solar.js +++ b/server/src/solar.js @@ -283,6 +283,15 @@ async function getDaySeriesUncached(ha, dateStr, db) { for (const inv of t.inverters) { if (inv.backupPowerId && inv.energyId) liveTargets[inv.key] = entityNum(ha, inv.energyId) } + // Consumo: el total real de HOY sale de los contadores de energía (los + // medidores de potencia pueden haber caído y los contadores saltan al + // volver). Sirve de target para rellenar huecos con el patrón del día + // anterior (issue 125). + const baseline = await ensureConsumptionBaseline(ha, db).catch(() => null) + const consToday = consumptionTodayFromCounters(ha, baseline) + if (consToday !== null && consToday !== undefined && consToday > 0) { + liveTargets.consumption = consToday + } } return buildDaySeries(stats, dateStr, db, liveTargets) @@ -324,6 +333,8 @@ function buildDaySeries(stats, dateStr, db, liveTargets) { const minutes = [...buckets.keys()].sort((a, b) => a - b) const consDiv = t.consumption.powerUnit === 'W' ? 1000 : 1 const backupIdx = {} // inv.key -> índices de points que usaron respaldo + const consGapIdx = [] // índices de points sin NINGÚN medidor de consumo + const consMeterIds = t.consumption.powerIds || [] minutes.forEach((min, pointIdx) => { const b = buckets.get(min) let production = 0 @@ -344,7 +355,11 @@ function buildDaySeries(stats, dateStr, db, liveTargets) { perInv[inv.key] = kw production += kw } - const consumption = t.consumption.powerIds.reduce((acc, id) => acc + num(b[id]) / consDiv, 0) + // Hueco de consumo: el bucket existe (otros sensores reportan) pero + // NINGÚN medidor de consumo tiene fila (cayeron todos a la vez). + const consGap = consMeterIds.length > 0 && consMeterIds.every((id) => b[id] === undefined || b[id] === null) + if (consGap) consGapIdx.push(pointIdx) + const consumption = consMeterIds.reduce((acc, id) => acc + num(b[id]) / consDiv, 0) let batteryPower = 0 if (t.battery.enabled) { @@ -441,6 +456,48 @@ function buildDaySeries(stats, dateStr, db, liveTargets) { } } } + // Hueco de consumo (issue 125): si todos los medidores cayeron a la vez y no + // hay sensor redundante que mida el consumo, se rellena el tramo con la FORMA + // del día anterior (mismos buckets 5-min), escalada para que la integral del + // consumo del día cuadre con el total real (contadores de energía). El + // consumo exacto del hueco es incognoscible, pero la curva queda continua y + // el área diaria correcta. La curva se marca estimated. + if (db && consGapIdx.length && points.length) { + let consTarget = liveTargets && liveTargets.consumption > 0 ? liveTargets.consumption : 0 + if (!(consTarget > 0)) { + const rowC = db.prepare('SELECT consumption_kwh FROM daily WHERE date = ?').get(dayKey) + if (rowC && rowC.consumption_kwh > 0) consTarget = rowC.consumption_kwh + } + const prevKey = dateKey(addDays(new Date(dayKey + 'T00:00:00'), -1)) + const prevRow = consTarget > 0 ? getDaySeriesRow(db, prevKey) : null + if (prevRow) { + const prevPts = JSON.parse(prevRow.points_json) + const prevByT = new Map(prevPts.map((p) => [p.t, p.consumption || 0])) + const gapSet = new Set(consGapIdx) + let realSum = 0 // consumo integrado FUERA del hueco (medidores reales) + let patSum = 0 // patrón del día anterior integrado DENTRO del hueco + points.forEach((p, i) => { + if (gapSet.has(i)) patSum += prevByT.get(p.t) || 0 + else realSum += p.consumption || 0 + }) + realSum *= dtH + patSum *= dtH + const deficit = consTarget - realSum + if (patSum > 0.01 && deficit > 0.05) { + const factor = deficit / patSum + for (const i of consGapIdx) { + const p = points[i] + const pat = prevByT.get(p.t) + if (!pat) continue + p.consumption = round3(pat * factor) + let grid = p.consumption - p.production + p.batteryPower + if (Math.abs(grid) < 0.03) grid = 0 + p.grid = round3(grid) + } + estimated = true + } + } + } // Estimación de curva: si el inversor 0 no tiene statistics pero el total // diario sí, se escala la curva del resto (fallback típico de sensores sin // state_class). Se mantiene el heurístico original Solis→Fox. diff --git a/server/test/backfill.test.js b/server/test/backfill.test.js index 8bd762d..36ad2ee 100644 --- a/server/test/backfill.test.js +++ b/server/test/backfill.test.js @@ -197,3 +197,57 @@ describe('backup power source per inverter (issue #124)', () => { rmSync(dir, { recursive: true, force: true }) }) }) + +describe('consumption gap filled from previous-day pattern (issue 125)', () => { + afterEach(() => _setForTests(LEGACY_TOPOLOGY)) + + it('rellena un hueco sin NINGÚN medidor con la forma del día anterior escalada', async () => { + const dir = mkdtempSync(join(tmpdir(), 'helios-bf-')) + const db = openDb(dir) + + const hourBuckets = (iso, mean) => + Array.from({ length: 12 }, (_, i) => ({ + start: new Date(new Date(iso).getTime() + i * 5 * 60000).toISOString(), + mean, + })) + // Solis (inv0) produce 07-08 y 10-11 (los buckets existen). + const stats = { + 'sensor.solis_potencia_actual': [ + ...hourBuckets('2026-07-22T07:00:00Z', 1.5), + ...hourBuckets('2026-07-22T10:00:00Z', 2.0), + ], + } + // 07-08 NO tiene medidores de consumo (hueco); 10-11 los 3 miden 1000 W. + for (const id of ['sensor.medidor_respaldo_power', 'sensor.vivienda_medidor_power', 'sensor.almacen_pinza_power_a']) { + stats[id] = hourBuckets('2026-07-22T10:00:00Z', 1000) + } + const ha = { statisticsDuringPeriod: vi.fn(async () => stats), getState: vi.fn() } + + // Fila daily del día: consumo real total 3.5 kWh (3 medidos + 0.5 hueco). + db.prepare( + "INSERT INTO daily (date, production_kwh, consumption_kwh) VALUES ('2026-07-22', 5, 3.5)" + ).run() + + // Patrón del día anterior: el hueco (07-08) consumía 0.5 kW de forma. + const prevPts = [ + ...hourBuckets('2026-07-21T07:00:00Z', 500).map((r, i) => { + const d = new Date(r.start) + return { t: d.getHours() * 60 + d.getMinutes(), label: '07:00', consumption: 0.5 } + }), + ] + db.prepare( + `INSERT INTO day_series (date, points_json, estimated, source, updated_at) + VALUES ('2026-07-21', ?, 0, 'haos', ?)` + ).run(JSON.stringify(prevPts), Date.now()) + + const res = await getDaySeries(ha, '2026-07-22', db) + expect(res.estimated).toBe(true) + + const dtH = 5 / 60 + const integ = res.points.reduce((acc, p) => acc + (p.consumption || 0) * dtH, 0) + expect(integ).toBeCloseTo(3.5, 1) + + db.close() + rmSync(dir, { recursive: true, force: true }) + }) +}) From 8bdd9850df5cca3b26d90f9d0f795b927c255e18 Mon Sep 17 00:00:00 2001 From: gnacho Date: Fri, 4 Sep 2026 16:50:26 +0200 Subject: [PATCH 3/6] fix(api): send no-cache on all /api responses Day-series and KPI endpoints returned no Cache-Control, so the browser could serve stale responses from heuristic cache after a data rebuild or deploy (user kept seeing the old gap curve although the server had the rebuilt points). Mark every /api/* response no-cache. --- server/src/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/server/src/index.js b/server/src/index.js index 6f90e87..a57b6fa 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -804,6 +804,7 @@ app.use('*', async (c, next) => { await next() const path = c.req.path if (path.startsWith('/assets/')) c.header('Cache-Control', 'public, max-age=31536000, immutable') + else if (path.startsWith('/api/')) c.header('Cache-Control', 'no-cache') else if (path === '/' || path === '/index.html' || path === '/sw.js' || path === '/manifest.webmanifest') { c.header('Cache-Control', 'no-cache') } From 4996ddc5ed5af0ed0532781c06cb7299dc551210 Mon Sep 17 00:00:00 2001 From: gnacho Date: Fri, 4 Sep 2026 16:51:44 +0200 Subject: [PATCH 4/6] fix(api): register no-cache middleware before routes Hono middlewares only apply to routes registered after them. The cache middleware lived after app.route('/api', guarded), so /api responses had no Cache-Control and the browser could serve stale day-series data from heuristic cache. Move it to the top of the app so every /api and shell response is revalidated. --- server/src/index.js | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/server/src/index.js b/server/src/index.js index a57b6fa..c5df7f9 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -112,6 +112,19 @@ ha.start() const app = new Hono() +// Cache HTTP: los bundles llevan hash en el nombre (inmutables); el shell +// (index.html, sw.js, manifest) y TODA la API SIEMPRE se revalidan para que un +// deploy o un recálculo de datos se vea al refrescar (sin esto el navegador +// puede servir la respuesta vieja de /api/* por caché heurística). +app.use('*', async (c, next) => { + await next() + const path = c.req.path + if (path.startsWith('/assets/')) c.header('Cache-Control', 'public, max-age=31536000, immutable') + else if (path.startsWith('/api/') || path === '/' || path === '/index.html' || path === '/sw.js' || path === '/manifest.webmanifest') { + c.header('Cache-Control', 'no-cache') + } +}) + app.get('/health', (c) => { let dbOk = true try { @@ -796,20 +809,6 @@ app.onError((err, c) => { return c.json({ error: 'error interno' }, 500) }) -// Cache HTTP: los bundles llevan hash en el nombre (inmutables); el shell -// (index.html, sw.js, manifest) SIEMPRE se revalida para que un deploy se vea -// al refrescar (sin esto el navegador puede servir HTML viejo por caché -// heurística y el usuario ver una app antigua tras un deploy). -app.use('*', async (c, next) => { - await next() - const path = c.req.path - if (path.startsWith('/assets/')) c.header('Cache-Control', 'public, max-age=31536000, immutable') - else if (path.startsWith('/api/')) c.header('Cache-Control', 'no-cache') - else if (path === '/' || path === '/index.html' || path === '/sw.js' || path === '/manifest.webmanifest') { - c.header('Cache-Control', 'no-cache') - } -}) - app.use('/*', serveStatic({ root: config.staticDir })) app.get('*', (c) => { From 80383936796557e06e1aead4d701dc56cf0c60fa Mon Sep 17 00:00:00 2001 From: gnacho Date: Fri, 4 Sep 2026 17:01:08 +0200 Subject: [PATCH 5/6] fix(day-series): do not scale today consumption gap from live counters Energy counters update slowly and freeze while measured power keeps growing, so the live target shrank the gap fill on every recalculation (user saw 0.17-0.24 kW instead of the previous-day shape). For today (no consolidated daily row yet) fill the gap with the previous-day pattern unscaled and let the nightly consolidation rescale it against the stable daily total when regenerating yesterday. --- server/src/solar.js | 78 ++++++++++++++++++------------------ server/test/backfill.test.js | 35 ++++++++++++++++ 2 files changed, 73 insertions(+), 40 deletions(-) diff --git a/server/src/solar.js b/server/src/solar.js index 4b541aa..c41c5e2 100644 --- a/server/src/solar.js +++ b/server/src/solar.js @@ -283,15 +283,12 @@ async function getDaySeriesUncached(ha, dateStr, db) { for (const inv of t.inverters) { if (inv.backupPowerId && inv.energyId) liveTargets[inv.key] = entityNum(ha, inv.energyId) } - // Consumo: el total real de HOY sale de los contadores de energía (los - // medidores de potencia pueden haber caído y los contadores saltan al - // volver). Sirve de target para rellenar huecos con el patrón del día - // anterior (issue 125). - const baseline = await ensureConsumptionBaseline(ha, db).catch(() => null) - const consToday = consumptionTodayFromCounters(ha, baseline) - if (consToday !== null && consToday !== undefined && consToday > 0) { - liveTargets.consumption = consToday - } + // NOTA: el consumo de HOY NO se escala contra los contadores en vivo: los + // contadores de energía actualizan lento y se quedan congelados mientras + // la potencia medida sigue creciendo, lo que encoge el relleno del hueco + // en cada recálculo. Para HOY se usa el patrón del día anterior tal cual + // (estimated); la consolidación nocturna regenera "ayer" y lo re-escala al + // total `daily` consolidado (issue 125). } return buildDaySeries(stats, dateStr, db, liveTargets) @@ -458,44 +455,45 @@ function buildDaySeries(stats, dateStr, db, liveTargets) { } // Hueco de consumo (issue 125): si todos los medidores cayeron a la vez y no // hay sensor redundante que mida el consumo, se rellena el tramo con la FORMA - // del día anterior (mismos buckets 5-min), escalada para que la integral del - // consumo del día cuadre con el total real (contadores de energía). El - // consumo exacto del hueco es incognoscible, pero la curva queda continua y - // el área diaria correcta. La curva se marca estimated. + // del día anterior (mismos buckets 5-min). El consumo exacto del hueco es + // incognoscible: para días pasados se escala a la fila daily consolidada + // (total real del día); para HOY (sin fila aún) se usa el patrón tal cual y + // la consolidación nocturna lo re-escala al regenerar ayer. La curva se marca + // estimated. if (db && consGapIdx.length && points.length) { - let consTarget = liveTargets && liveTargets.consumption > 0 ? liveTargets.consumption : 0 - if (!(consTarget > 0)) { - const rowC = db.prepare('SELECT consumption_kwh FROM daily WHERE date = ?').get(dayKey) - if (rowC && rowC.consumption_kwh > 0) consTarget = rowC.consumption_kwh - } + let consTarget = 0 + const rowC = db.prepare('SELECT consumption_kwh FROM daily WHERE date = ?').get(dayKey) + if (rowC && rowC.consumption_kwh > 0) consTarget = rowC.consumption_kwh const prevKey = dateKey(addDays(new Date(dayKey + 'T00:00:00'), -1)) - const prevRow = consTarget > 0 ? getDaySeriesRow(db, prevKey) : null + const prevRow = getDaySeriesRow(db, prevKey) if (prevRow) { const prevPts = JSON.parse(prevRow.points_json) const prevByT = new Map(prevPts.map((p) => [p.t, p.consumption || 0])) const gapSet = new Set(consGapIdx) - let realSum = 0 // consumo integrado FUERA del hueco (medidores reales) - let patSum = 0 // patrón del día anterior integrado DENTRO del hueco - points.forEach((p, i) => { - if (gapSet.has(i)) patSum += prevByT.get(p.t) || 0 - else realSum += p.consumption || 0 - }) - realSum *= dtH - patSum *= dtH - const deficit = consTarget - realSum - if (patSum > 0.01 && deficit > 0.05) { - const factor = deficit / patSum - for (const i of consGapIdx) { - const p = points[i] - const pat = prevByT.get(p.t) - if (!pat) continue - p.consumption = round3(pat * factor) - let grid = p.consumption - p.production + p.batteryPower - if (Math.abs(grid) < 0.03) grid = 0 - p.grid = round3(grid) - } - estimated = true + let factor = null + if (consTarget > 0) { + let realSum = 0 // consumo integrado FUERA del hueco (medidores reales) + let patSum = 0 // patrón del día anterior integrado DENTRO del hueco + points.forEach((p, i) => { + if (gapSet.has(i)) patSum += prevByT.get(p.t) || 0 + else realSum += p.consumption || 0 + }) + realSum *= dtH + patSum *= dtH + const deficit = consTarget - realSum + if (patSum > 0.01 && deficit > 0.05) factor = deficit / patSum + } + if (factor === null || factor <= 0) factor = 1 // HOY: patrón sin escalar + for (const i of consGapIdx) { + const p = points[i] + const pat = prevByT.get(p.t) + if (!pat) continue + p.consumption = round3(pat * factor) + let grid = p.consumption - p.production + p.batteryPower + if (Math.abs(grid) < 0.03) grid = 0 + p.grid = round3(grid) } + estimated = true } } // Estimación de curva: si el inversor 0 no tiene statistics pero el total diff --git a/server/test/backfill.test.js b/server/test/backfill.test.js index 36ad2ee..6b9e0dc 100644 --- a/server/test/backfill.test.js +++ b/server/test/backfill.test.js @@ -250,4 +250,39 @@ describe('consumption gap filled from previous-day pattern (issue 125)', () => { db.close() rmSync(dir, { recursive: true, force: true }) }) + + it('HOY sin fila daily: rellena el hueco con el patrón del día anterior sin escalar', async () => { + const dir = mkdtempSync(join(tmpdir(), 'helios-bf-')) + const db = openDb(dir) + + const hourBuckets = (iso, mean) => + Array.from({ length: 12 }, (_, i) => ({ + start: new Date(new Date(iso).getTime() + i * 5 * 60000).toISOString(), + mean, + })) + const stats = { + 'sensor.solis_potencia_actual': hourBuckets('2026-07-23T07:00:00Z', 1.5), + } + // Hueco: 07-08 sin NINGÚN medidor. Sin fila daily (caso HOY en vivo). + const ha = { statisticsDuringPeriod: vi.fn(async () => stats), getState: vi.fn() } + + const prevPts = [ + ...hourBuckets('2026-07-22T07:00:00Z', 500).map((r) => { + const d = new Date(r.start) + return { t: d.getHours() * 60 + d.getMinutes(), label: '07:00', consumption: 0.5 } + }), + ] + db.prepare( + `INSERT INTO day_series (date, points_json, estimated, source, updated_at) + VALUES ('2026-07-22', ?, 0, 'haos', ?)` + ).run(JSON.stringify(prevPts), Date.now()) + + const res = await getDaySeries(ha, '2026-07-23', db) + expect(res.estimated).toBe(true) + const p = res.points.find((x) => x.consumption > 0) + expect(p.consumption).toBeCloseTo(0.5, 1) + + db.close() + rmSync(dir, { recursive: true, force: true }) + }) }) From 55549ac44bbc88e7acaf07e73686e4478a2a9ace Mon Sep 17 00:00:00 2001 From: gnacho Date: Sat, 19 Sep 2026 22:31:02 +0200 Subject: [PATCH 6/6] feat(topology): expose backup power sensor fields in the topology editor --- app/src/components/TopologyEditor.tsx | 26 +++++++++++---------- app/src/i18n/locales/en/translation.json | 3 ++- app/src/i18n/locales/es/translation.json | 3 ++- app/src/i18n/locales/zh-CN/translation.json | 3 ++- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/app/src/components/TopologyEditor.tsx b/app/src/components/TopologyEditor.tsx index 19115a0..6f306d3 100644 --- a/app/src/components/TopologyEditor.tsx +++ b/app/src/components/TopologyEditor.tsx @@ -165,18 +165,20 @@ export function TopologyEditor({ open, onOpenChange, install }: Props) { -
- kv.inv(i, { backupPowerId: e.target.value })} - /> + kv.inv(i, { backupPowerId: e.target.value })} + /> + +
+ -
- + +