From f448e681041d155a0cc887d400e868387f318590 Mon Sep 17 00:00:00 2001 From: gnacho Date: Sat, 3 Oct 2026 10:27:22 +0200 Subject: [PATCH 1/2] fix(solar): suppress derived grid when meter inputs are missing (#135) With HAOS connected, unavailable consumption meters collapsed the derived consumption to 0 and the bucket formula grid=consumption-production+battery fabricated impossible night exports (real case 26-Sep: -3.4 kW while the battery discharged and both meters were down). - buildDaySeries: grid=null for buckets missing any consumption meter or the battery power sensor; the issue-125 pattern fill still recomputes grid from the estimated consumption and marks the day estimated - computeLive: consumption=null + 'consumo' warning alert when any consumption meter is down; grid=null when the grid source entity is down (no sign-flip/import fallback on fabricated zeros) - export buildDaySeries for tests; 4 new red-green tests (115/115) --- server/src/solar.js | 37 +++++++++++++--- server/test/solar.test.js | 88 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 118 insertions(+), 7 deletions(-) diff --git a/server/src/solar.js b/server/src/solar.js index c41c5e2..fe402c7 100644 --- a/server/src/solar.js +++ b/server/src/solar.js @@ -83,6 +83,11 @@ export function computeLive(ha) { invLive.push({ key: inv.key, name: inv.name, kw: round3(kw) }) } const consumption = consumptionKw(ha) + // Medidores de consumo caídos con HAOS conectado (issue 135): el consumo + // derivado no es fiable → null + alerta. Un 0 fabricaría exportaciones + // imposibles al alimentar la curva del día. + const isDown = (e) => e === undefined || e.state === 'unavailable' || e.state === 'unknown' + const consDown = ha.connected && (t.consumption.powerIds || []).some((id) => isDown(ha.getState(id))) const b = t.battery const batMag = b.enabled ? entityNum(ha, b.powerId) : 0 @@ -109,18 +114,29 @@ export function computeLive(ha) { } } + // Fuente de red caída con HAOS conectado (issue 135): grid → null en vez + // de un 0 espurio que los fallbacks convertirían en import/export inventado. + const gridDown = + ha.connected && + ((t.grid.mode === 'attrs' && t.grid.attrsId && isDown(ha.getState(t.grid.attrsId))) || + (t.grid.mode !== 'attrs' && t.grid.sensorId && isDown(ha.getState(t.grid.sensorId))) || + (t.grid.mode !== 'attrs' && + !t.grid.sensorId && + ((t.grid.importId && isDown(ha.getState(t.grid.importId))) || (t.grid.exportId && isDown(ha.getState(t.grid.exportId)))))) + const sun = ha.getState(t.sun) const weather = ha.getState(t.weather) const elevation = num(sun?.attributes?.elevation) // Sanity check: de noche sin sol, no puede haber export solar - if (elevation < 0 && grid < -0.03 && production < 0.02) grid = -grid + if (!gridDown && elevation < 0 && grid < -0.03 && production < 0.02) grid = -grid // Fallback: si no hay datos de grid pero hay consumo y no hay producción, estimar import - if (grid === 0 && consumption > 0.1 && production < 0.02) grid = consumption + if (!gridDown && grid === 0 && consumption > 0.1 && production < 0.02) grid = consumption const alerts = [] if (!ha.connected) alerts.push({ id: 'haos', severity: 'critical', text: 'Sin conexión con Home Assistant' }) + if (consDown) alerts.push({ id: 'consumo', severity: 'warning', text: 'Medidores de consumo sin datos' }) // Estado del inversor: desde statusAttrsId (opcional). Si no hay sensor con // esos atributos, no hay alerta de inversor/scraper (genérico). Textos sin @@ -145,13 +161,13 @@ export function computeLive(ha) { const inv1 = invLive[1] || { kw: 0 } return { production: round3(production), - consumption: round3(consumption), + consumption: consDown ? null : round3(consumption), respaldoKw: round3(entityNum(ha, t.consumption.respaldoId, t.consumption.powerUnit === 'W' ? 1000 : 1)), noRespaldadaKw: round3(entityNum(ha, t.consumption.noRespaldadaId, t.consumption.powerUnit === 'W' ? 1000 : 1)), batteryPower: round3(batteryPower), batteryStatus: batState || 'Desconocido', soc: round1(soc), - grid: round3(grid), + grid: gridDown ? null : round3(grid), solis: round3(inv0.kw), fox: round3(inv1.kw), inverters: invLive, @@ -305,7 +321,7 @@ async function getDaySeriesUncached(ha, dateStr, db) { // (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) { +export function buildDaySeries(stats, dateStr, db, liveTargets) { const t = getInstall() const invIds = t.inverters.flatMap((inv) => [inv.powerId, inv.backupPowerId].filter(Boolean)) const ids = [...invIds, ...t.consumption.powerIds] @@ -373,6 +389,15 @@ function buildDaySeries(stats, dateStr, db, liveTargets) { let grid = consumption - production + batteryPower if (Math.abs(grid) < 0.03) grid = 0 + // Issue 135: si falta CUALQUIER entrada de la fórmula en el bucket + // (medidores de consumo o sensor de batería caídos), el grid derivado no + // es fiable → se suprime (null) en vez de computar con ceros, que de + // noche fabrica exportaciones imposibles. Si el hueco de consumo se + // rellena después (issue 125), el grid se recompute con el consumo + // estimado y el día queda marcado `estimated`. + const consMissing = consMeterIds.some((id) => b[id] === undefined || b[id] === null) + const batMissing = t.battery.enabled && t.battery.powerId && (b[t.battery.powerId] === undefined || b[t.battery.powerId] === null) + if (consMissing || batMissing) grid = null const hh = String(Math.floor(min / 60)).padStart(2, '0') const mm = String(min % 60).padStart(2, '0') @@ -388,7 +413,7 @@ function buildDaySeries(stats, dateStr, db, liveTargets) { consumption: round3(consumption), batteryPower: round3(batteryPower), soc: round1(soc), - grid: round3(grid), + grid: grid === null ? null : round3(grid), }) }) diff --git a/server/test/solar.test.js b/server/test/solar.test.js index 48d3808..f6f8bc7 100644 --- a/server/test/solar.test.js +++ b/server/test/solar.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, afterEach } from 'vitest' -import { computeLive, cachedCollector } from '../src/solar.js' +import { computeLive, cachedCollector, buildDaySeries } from '../src/solar.js' import { _setForTests } from '../src/install.js' import { LEGACY_TOPOLOGY, GENERIC_TOPOLOGY } from '../src/install.js' @@ -100,6 +100,92 @@ describe('computeLive — topología genérica (issue #37)', () => { }) }) +describe('issue #135 — grid derivado con entradas caídas', () => { + // HA conectado con la topología LEGACY (3 medidores de consumo en W, + // batería con powerId/socId, grid por attrs del shim). + function haLegacy(overrides = {}) { + const map = { + 'sensor.solis_potencia_actual': { state: '0' }, + 'sensor.almacen_pinza_power_b': { state: '0' }, + 'sensor.medidor_respaldo_power': { state: '500' }, + 'sensor.vivienda_medidor_power': { state: '200' }, + 'sensor.almacen_pinza_power_a': { state: '50' }, + 'sensor.solis_bateria_potencia': { state: '0' }, + 'sensor.solis_bateria_soc': { state: '50' }, + 'sensor.solis_bateria_estado': { state: 'Descargando' }, + 'sensor.solis_scraper': { state: 'ok', attributes: { currentGridPower: 0.7, gridDirection: 'import' } }, + 'sun.sun': { state: 'below_horizon', attributes: { elevation: -20 } }, + ...overrides, + } + return { connected: true, getState: (id) => map[id] } + } + + it('computeLive: un medidor de consumo caído → consumption null + alerta, grid intacto', () => { + _setForTests(LEGACY_TOPOLOGY) + const live = computeLive(haLegacy({ 'sensor.medidor_respaldo_power': { state: 'unavailable' } })) + expect(live.consumption).toBeNull() + expect(live.alerts.some((a) => a.id === 'consumo' && a.severity === 'warning')).toBe(true) + expect(live.grid).toBeCloseTo(0.7, 2) // la fuente de red sigue viva + }) + + it('computeLive: fuente de red (attrs) caída → grid null, sin fallback inventado', () => { + _setForTests(LEGACY_TOPOLOGY) + const live = computeLive(haLegacy({ 'sensor.solis_scraper': { state: 'unavailable', attributes: {} } })) + expect(live.grid).toBeNull() + }) + + // Buckets de noche que reproducen el caso real del 26-Sep: medidores de + // consumo caídos y batería descargando 3,3 kW. Antes: grid -3,3 kW + // (exportación imposible de noche). Ahora: null. + it('buildDaySeries: bucket sin medidores de consumo → grid null (no export imposible)', () => { + _setForTests(LEGACY_TOPOLOGY) + const stats = { + 'sensor.medidor_respaldo_power': [ + { start: '2026-09-26T19:55:00', mean: 500 }, + { start: '2026-09-26T20:05:00', mean: 500 }, + ], + 'sensor.vivienda_medidor_power': [ + { start: '2026-09-26T19:55:00', mean: 200 }, + { start: '2026-09-26T20:05:00', mean: 200 }, + ], + 'sensor.almacen_pinza_power_a': [ + { start: '2026-09-26T19:55:00', mean: 50 }, + { start: '2026-09-26T20:05:00', mean: 50 }, + ], + 'sensor.solis_bateria_potencia': [ + { start: '2026-09-26T19:55:00', mean: 3.3 }, + { start: '2026-09-26T20:00:00', mean: 3.3 }, + { start: '2026-09-26T20:05:00', mean: 3.3 }, + ], + 'sensor.solis_bateria_soc': [ + { start: '2026-09-26T19:55:00', mean: 50 }, + { start: '2026-09-26T20:00:00', mean: 49 }, + { start: '2026-09-26T20:05:00', mean: 49 }, + ], + } + const { points } = buildDaySeries(stats, '2026-09-26', null, {}) + const p1955 = points.find((p) => p.label === '19:55') + const p2000 = points.find((p) => p.label === '20:00') + const p2005 = points.find((p) => p.label === '20:05') + expect(p1955.grid).toBeCloseTo(0.75, 2) // todo presente: 0,75 kW de import + expect(p2000.grid).toBeNull() // medidores caídos + batería descargando: antes -3,3 kW + expect(p2005.grid).toBeCloseTo(0.75, 2) // medidores recuperados + }) + + it('buildDaySeries: falta UN medidor de consumo (hueco parcial) → grid null', () => { + _setForTests(LEGACY_TOPOLOGY) + const stats = { + 'sensor.medidor_respaldo_power': [{ start: '2026-09-26T21:00:00', mean: 500 }], + 'sensor.almacen_pinza_power_a': [{ start: '2026-09-26T21:00:00', mean: 50 }], + 'sensor.solis_bateria_potencia': [{ start: '2026-09-26T21:00:00', mean: 0 }], + 'sensor.solis_bateria_soc': [{ start: '2026-09-26T21:00:00', mean: 50 }], + } + const { points } = buildDaySeries(stats, '2026-09-26', null, {}) + expect(points).toHaveLength(1) + expect(points[0].grid).toBeNull() // consumo infrarreportado (0,55 en vez de 0,75): no fiable + }) +}) + describe('cachedCollector', () => { it('single-flight: N llamadas concurrentes = 1 ejecución', async () => { let calls = 0 From 54236df4efe95187d22d668d78c9cf87daa2baa5 Mon Sep 17 00:00:00 2001 From: gnacho Date: Sat, 3 Oct 2026 10:30:01 +0200 Subject: [PATCH 2/2] fix(app): render null grid/consumption as no-data instead of crashing (#135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the server guard: PowerPoint.grid and LivePower.grid / consumption are now nullable. fmtKw renders an em dash for null, the day-KPI integration skips null grid buckets, LivePowerStrip and EnergyFlowDiagram show '—' with neutral flows when a meter is down. --- app/src/components/EnergyFlowDiagram.tsx | 27 +++++++++++++++--------- app/src/components/LivePowerStrip.tsx | 16 ++++++++------ app/src/data/EnergyDataProvider.tsx | 6 ++++-- app/src/data/types.ts | 9 +++++--- app/src/lib/format.ts | 5 +++-- 5 files changed, 39 insertions(+), 24 deletions(-) diff --git a/app/src/components/EnergyFlowDiagram.tsx b/app/src/components/EnergyFlowDiagram.tsx index f2bca2f..8471ec5 100644 --- a/app/src/components/EnergyFlowDiagram.tsx +++ b/app/src/components/EnergyFlowDiagram.tsx @@ -131,12 +131,13 @@ export default function EnergyFlowDiagram({ live, className }: EnergyFlowDiagram const { t } = useTranslation(); const uid = useId().replace(/[:]/g, ''); - const fvToHome = Math.min(live.production, live.consumption); + const cons = live.consumption ?? 0; + const fvToHome = Math.min(live.production, cons); const charging = live.batteryPower > 0 ? live.batteryPower : 0; const discharging = live.batteryPower < 0 ? -live.batteryPower : 0; - const exporting = live.grid < 0 ? -live.grid : 0; - const importing = live.grid > 0 ? live.grid : 0; - const solarSurplus = Math.max(0, live.production - live.consumption); + const exporting = live.grid != null && live.grid < 0 ? -live.grid : 0; + const importing = live.grid != null && live.grid > 0 ? live.grid : 0; + const solarSurplus = Math.max(0, live.production - cons); const gridToBattery = Math.max(0, Math.min(charging - solarSurplus, importing)); const fvToBattery = Math.max(0, charging - gridToBattery); @@ -165,7 +166,13 @@ export default function EnergyFlowDiagram({ live, className }: EnergyFlowDiagram ? `${fmtKw(-live.batteryPower)} kW` : t('common.idle'); const gridText = - live.grid < -0.05 ? `${fmtKw(-live.grid)} kW ↑` : live.grid > 0.05 ? `${fmtKw(live.grid)} kW ↓` : '0 W'; + live.grid === null + ? '—' + : live.grid < -0.05 + ? `${fmtKw(-live.grid)} kW ↑` + : live.grid > 0.05 + ? `${fmtKw(live.grid)} kW ↓` + : '0 W'; return ( 0.1} + valueText={live.consumption === null ? '—' : `${fmtKw(live.consumption)} kW`} + active={cons > 0.1} glowColor={homeColor} > @@ -227,11 +234,11 @@ export default function EnergyFlowDiagram({ live, className }: EnergyFlowDiagram y={NODES.grid.y} label={t('common.grid')} valueText={gridText} - active={Math.abs(live.grid) > 0.1} - glowColor={live.grid < 0 ? palette.redVertido : palette.redCompra} + active={live.grid != null && Math.abs(live.grid) > 0.1} + glowColor={live.grid != null && live.grid < 0 ? palette.redVertido : palette.redCompra} onClick={() => navigate('/historico')} > - + ); diff --git a/app/src/components/LivePowerStrip.tsx b/app/src/components/LivePowerStrip.tsx index 0f9d0c3..5e56543 100644 --- a/app/src/components/LivePowerStrip.tsx +++ b/app/src/components/LivePowerStrip.tsx @@ -12,7 +12,8 @@ import { cn } from '@/lib/utils'; interface StripValueProps { icon: LucideIcon; label: string; - kw: number; + /** null = sin dato fiable (medidor caído, issue 135): se muestra '—'. */ + kw: number | null; status?: string; color: string; /** Color del icono; si se omite, usa `color` (icono y celda iguales). */ @@ -24,7 +25,7 @@ interface StripValueProps { } function StripValue({ icon: Icon, label, kw, status, color, iconColor, className, altValue, altUnit }: StripValueProps) { - const animated = useAnimatedNumber(altValue === undefined ? kw : NaN); + const animated = useAnimatedNumber(altValue === undefined ? (kw ?? NaN) : NaN); return (
@@ -33,10 +34,10 @@ function StripValue({ icon: Icon, label, kw, status, color, iconColor, className

{label}

- {altValue !== undefined ? altValue : fmtKw(animated)} + {altValue !== undefined ? altValue : kw === null ? '—' : fmtKw(animated)} {altUnit !== undefined ? ( {altUnit} - ) : ( + ) : kw === null ? null : ( kW )}

@@ -60,14 +61,15 @@ interface LivePowerStripProps { export default function LivePowerStrip({ live, atMin }: LivePowerStripProps) { const palette = useEnergyColors(); const { t } = useTranslation(); - const exporting = live.grid < -0.05; + const exporting = live.grid != null && live.grid < -0.05; const batteryLabel = (bp: number): string => { if (bp > 0.05) return t('common.charging'); if (bp < -0.05) return t('common.discharging'); return t('common.idle'); }; - const gridLabel = (grid: number): string => { + const gridLabel = (grid: number | null): string => { + if (grid === null) return '—'; if (grid > 0.05) return t('live.buying'); if (grid < -0.05) return t('live.exporting'); return t('live.balanced'); @@ -106,7 +108,7 @@ export default function LivePowerStrip({ live, atMin }: LivePowerStripProps) { = untilMin) break; productionKwh += p.production * dtH; consumptionKwh += p.consumption * dtH; - if (p.grid > 0) gridImportKwh += p.grid * dtH; - else gridExportKwh += -p.grid * dtH; + if (p.grid != null) { + if (p.grid > 0) gridImportKwh += p.grid * dtH; + else gridExportKwh += -p.grid * dtH; + } if (p.batteryPower > 0) batteryChargedKwh += p.batteryPower * dtH; if (p.batteryPower < 0) batteryDischargedKwh += -p.batteryPower * dtH; directFvKwh += Math.min(p.production, p.consumption) * dtH; diff --git a/app/src/data/types.ts b/app/src/data/types.ts index 56251c1..3a98e4b 100644 --- a/app/src/data/types.ts +++ b/app/src/data/types.ts @@ -8,7 +8,8 @@ export interface PowerPoint { consumption: number; batteryPower: number; soc: number; - grid: number; + /** null = entradas de la fórmula caídas en ese bucket (issue 135): sin dato fiable. */ + grid: number | null; } /** Valor de serie de un inversor por clave: p.inverters[key] o p[key] (solis/fox). */ @@ -31,10 +32,12 @@ export interface LiveInverter { export interface LivePower { production: number; - consumption: number; + /** null = algún medidor de consumo caído (issue 135): consumo no fiable. */ + consumption: number | null; batteryPower: number; soc: number; - grid: number; + /** null = fuente de red caída (issue 135): sin dato de red. */ + grid: number | null; solis: number; fox: number; inverters?: LiveInverter[]; diff --git a/app/src/lib/format.ts b/app/src/lib/format.ts index 512d663..b5907b8 100644 --- a/app/src/lib/format.ts +++ b/app/src/lib/format.ts @@ -24,8 +24,9 @@ export function fmtPower(kW: number): [string, string] { return [nf2().format(kW), 'kW']; } -/** Potencia siempre en kW con 2 decimales (para series del día). */ -export function fmtKw(kW: number): string { +/** Potencia siempre en kW con 2 decimales (para series del día). null = sin dato (medidores caídos). */ +export function fmtKw(kW: number | null | undefined): string { + if (kW === null || kW === undefined || !Number.isFinite(kW)) return '—'; return nf2().format(kW); }