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);
}
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