Skip to content

Commit 3d0cf78

Browse files
authored
fix(solar): suppress derived grid when meter inputs are missing (#135)
Unavailable consumption meters collapsed the derived consumption to 0 and the bucket formula fabricated impossible night exports. Grid is now suppressed (null) when any formula input is missing, live reports no-data with a warning alert, and the frontend renders suppressed values as an em dash. Closes #135
1 parent eb1ca78 commit 3d0cf78

7 files changed

Lines changed: 157 additions & 31 deletions

File tree

‎app/src/components/EnergyFlowDiagram.tsx‎

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -131,12 +131,13 @@ export default function EnergyFlowDiagram({ live, className }: EnergyFlowDiagram
131131
const { t } = useTranslation();
132132
const uid = useId().replace(/[:]/g, '');
133133

134-
const fvToHome = Math.min(live.production, live.consumption);
134+
const cons = live.consumption ?? 0;
135+
const fvToHome = Math.min(live.production, cons);
135136
const charging = live.batteryPower > 0 ? live.batteryPower : 0;
136137
const discharging = live.batteryPower < 0 ? -live.batteryPower : 0;
137-
const exporting = live.grid < 0 ? -live.grid : 0;
138-
const importing = live.grid > 0 ? live.grid : 0;
139-
const solarSurplus = Math.max(0, live.production - live.consumption);
138+
const exporting = live.grid != null && live.grid < 0 ? -live.grid : 0;
139+
const importing = live.grid != null && live.grid > 0 ? live.grid : 0;
140+
const solarSurplus = Math.max(0, live.production - cons);
140141
const gridToBattery = Math.max(0, Math.min(charging - solarSurplus, importing));
141142
const fvToBattery = Math.max(0, charging - gridToBattery);
142143

@@ -165,7 +166,13 @@ export default function EnergyFlowDiagram({ live, className }: EnergyFlowDiagram
165166
? `${fmtKw(-live.batteryPower)} kW`
166167
: t('common.idle');
167168
const gridText =
168-
live.grid < -0.05 ? `${fmtKw(-live.grid)} kW ↑` : live.grid > 0.05 ? `${fmtKw(live.grid)} kW ↓` : '0 W';
169+
live.grid === null
170+
? '—'
171+
: live.grid < -0.05
172+
? `${fmtKw(-live.grid)} kW ↑`
173+
: live.grid > 0.05
174+
? `${fmtKw(live.grid)} kW ↓`
175+
: '0 W';
169176

170177
return (
171178
<motion.svg
@@ -198,8 +205,8 @@ export default function EnergyFlowDiagram({ live, className }: EnergyFlowDiagram
198205
x={NODES.home.x}
199206
y={NODES.home.y}
200207
label={t('flow.home')}
201-
valueText={`${fmtKw(live.consumption)} kW`}
202-
active={live.consumption > 0.1}
208+
valueText={live.consumption === null ? '—' : `${fmtKw(live.consumption)} kW`}
209+
active={cons > 0.1}
203210
glowColor={homeColor}
204211
>
205212
<House size={30} style={{ color: homeColor }} />
@@ -227,11 +234,11 @@ export default function EnergyFlowDiagram({ live, className }: EnergyFlowDiagram
227234
y={NODES.grid.y}
228235
label={t('common.grid')}
229236
valueText={gridText}
230-
active={Math.abs(live.grid) > 0.1}
231-
glowColor={live.grid < 0 ? palette.redVertido : palette.redCompra}
237+
active={live.grid != null && Math.abs(live.grid) > 0.1}
238+
glowColor={live.grid != null && live.grid < 0 ? palette.redVertido : palette.redCompra}
232239
onClick={() => navigate('/historico')}
233240
>
234-
<UtilityPole size={26} style={{ color: live.grid < 0 ? palette.redVertido : palette.redCompra }} />
241+
<UtilityPole size={26} style={{ color: live.grid != null && live.grid < 0 ? palette.redVertido : palette.redCompra }} />
235242
</FlowNode>
236243
</motion.svg>
237244
);

‎app/src/components/LivePowerStrip.tsx‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import { cn } from '@/lib/utils';
1212
interface StripValueProps {
1313
icon: LucideIcon;
1414
label: string;
15-
kw: number;
15+
/** null = sin dato fiable (medidor caído, issue 135): se muestra '—'. */
16+
kw: number | null;
1617
status?: string;
1718
color: string;
1819
/** Color del icono; si se omite, usa `color` (icono y celda iguales). */
@@ -24,7 +25,7 @@ interface StripValueProps {
2425
}
2526

2627
function StripValue({ icon: Icon, label, kw, status, color, iconColor, className, altValue, altUnit }: StripValueProps) {
27-
const animated = useAnimatedNumber(altValue === undefined ? kw : NaN);
28+
const animated = useAnimatedNumber(altValue === undefined ? (kw ?? NaN) : NaN);
2829
return (
2930
<div className={cn('flex items-center gap-3 px-4 py-3 sm:px-5', className)}>
3031
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl" style={{ backgroundColor: `${color}1F`, color: iconColor ?? color }}>
@@ -33,10 +34,10 @@ function StripValue({ icon: Icon, label, kw, status, color, iconColor, className
3334
<div className="min-w-0">
3435
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-faint">{label}</p>
3536
<p className="font-display text-[22px] font-semibold leading-tight text-app" aria-live="off">
36-
{altValue !== undefined ? altValue : fmtKw(animated)}
37+
{altValue !== undefined ? altValue : kw === null ? '—' : fmtKw(animated)}
3738
{altUnit !== undefined ? (
3839
<span className="ml-1 text-[0.6em] font-medium text-faint">{altUnit}</span>
39-
) : (
40+
) : kw === null ? null : (
4041
<span className="ml-1 text-[0.6em] font-medium text-faint">kW</span>
4142
)}
4243
</p>
@@ -60,14 +61,15 @@ interface LivePowerStripProps {
6061
export default function LivePowerStrip({ live, atMin }: LivePowerStripProps) {
6162
const palette = useEnergyColors();
6263
const { t } = useTranslation();
63-
const exporting = live.grid < -0.05;
64+
const exporting = live.grid != null && live.grid < -0.05;
6465

6566
const batteryLabel = (bp: number): string => {
6667
if (bp > 0.05) return t('common.charging');
6768
if (bp < -0.05) return t('common.discharging');
6869
return t('common.idle');
6970
};
70-
const gridLabel = (grid: number): string => {
71+
const gridLabel = (grid: number | null): string => {
72+
if (grid === null) return '—';
7173
if (grid > 0.05) return t('live.buying');
7274
if (grid < -0.05) return t('live.exporting');
7375
return t('live.balanced');
@@ -106,7 +108,7 @@ export default function LivePowerStrip({ live, atMin }: LivePowerStripProps) {
106108
<StripValue
107109
icon={exporting ? ArrowUpFromLine : ArrowDownToLine}
108110
label={t('common.grid')}
109-
kw={Math.abs(live.grid)}
111+
kw={live.grid === null ? null : Math.abs(live.grid)}
110112
status={gridLabel(live.grid)}
111113
color={exporting ? palette.redVertido : palette.redCompra}
112114
className="border-l border-t border-app lg:border-t-0"

‎app/src/data/EnergyDataProvider.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,10 @@ function integrateSeries(series: PowerPoint[], untilMin: number, socFallback: nu
8181
if (p.t >= untilMin) break;
8282
productionKwh += p.production * dtH;
8383
consumptionKwh += p.consumption * dtH;
84-
if (p.grid > 0) gridImportKwh += p.grid * dtH;
85-
else gridExportKwh += -p.grid * dtH;
84+
if (p.grid != null) {
85+
if (p.grid > 0) gridImportKwh += p.grid * dtH;
86+
else gridExportKwh += -p.grid * dtH;
87+
}
8688
if (p.batteryPower > 0) batteryChargedKwh += p.batteryPower * dtH;
8789
if (p.batteryPower < 0) batteryDischargedKwh += -p.batteryPower * dtH;
8890
directFvKwh += Math.min(p.production, p.consumption) * dtH;

‎app/src/data/types.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ export interface PowerPoint {
88
consumption: number;
99
batteryPower: number;
1010
soc: number;
11-
grid: number;
11+
/** null = entradas de la fórmula caídas en ese bucket (issue 135): sin dato fiable. */
12+
grid: number | null;
1213
}
1314

1415
/** Valor de serie de un inversor por clave: p.inverters[key] o p[key] (solis/fox). */
@@ -31,10 +32,12 @@ export interface LiveInverter {
3132

3233
export interface LivePower {
3334
production: number;
34-
consumption: number;
35+
/** null = algún medidor de consumo caído (issue 135): consumo no fiable. */
36+
consumption: number | null;
3537
batteryPower: number;
3638
soc: number;
37-
grid: number;
39+
/** null = fuente de red caída (issue 135): sin dato de red. */
40+
grid: number | null;
3841
solis: number;
3942
fox: number;
4043
inverters?: LiveInverter[];

‎app/src/lib/format.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,9 @@ export function fmtPower(kW: number): [string, string] {
2424
return [nf2().format(kW), 'kW'];
2525
}
2626

27-
/** Potencia siempre en kW con 2 decimales (para series del día). */
28-
export function fmtKw(kW: number): string {
27+
/** Potencia siempre en kW con 2 decimales (para series del día). null = sin dato (medidores caídos). */
28+
export function fmtKw(kW: number | null | undefined): string {
29+
if (kW === null || kW === undefined || !Number.isFinite(kW)) return '—';
2930
return nf2().format(kW);
3031
}
3132

‎server/src/solar.js‎

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ export function computeLive(ha) {
8383
invLive.push({ key: inv.key, name: inv.name, kw: round3(kw) })
8484
}
8585
const consumption = consumptionKw(ha)
86+
// Medidores de consumo caídos con HAOS conectado (issue 135): el consumo
87+
// derivado no es fiable → null + alerta. Un 0 fabricaría exportaciones
88+
// imposibles al alimentar la curva del día.
89+
const isDown = (e) => e === undefined || e.state === 'unavailable' || e.state === 'unknown'
90+
const consDown = ha.connected && (t.consumption.powerIds || []).some((id) => isDown(ha.getState(id)))
8691

8792
const b = t.battery
8893
const batMag = b.enabled ? entityNum(ha, b.powerId) : 0
@@ -109,18 +114,29 @@ export function computeLive(ha) {
109114
}
110115
}
111116

117+
// Fuente de red caída con HAOS conectado (issue 135): grid → null en vez
118+
// de un 0 espurio que los fallbacks convertirían en import/export inventado.
119+
const gridDown =
120+
ha.connected &&
121+
((t.grid.mode === 'attrs' && t.grid.attrsId && isDown(ha.getState(t.grid.attrsId))) ||
122+
(t.grid.mode !== 'attrs' && t.grid.sensorId && isDown(ha.getState(t.grid.sensorId))) ||
123+
(t.grid.mode !== 'attrs' &&
124+
!t.grid.sensorId &&
125+
((t.grid.importId && isDown(ha.getState(t.grid.importId))) || (t.grid.exportId && isDown(ha.getState(t.grid.exportId))))))
126+
112127
const sun = ha.getState(t.sun)
113128
const weather = ha.getState(t.weather)
114129
const elevation = num(sun?.attributes?.elevation)
115130

116131
// Sanity check: de noche sin sol, no puede haber export solar
117-
if (elevation < 0 && grid < -0.03 && production < 0.02) grid = -grid
132+
if (!gridDown && elevation < 0 && grid < -0.03 && production < 0.02) grid = -grid
118133

119134
// Fallback: si no hay datos de grid pero hay consumo y no hay producción, estimar import
120-
if (grid === 0 && consumption > 0.1 && production < 0.02) grid = consumption
135+
if (!gridDown && grid === 0 && consumption > 0.1 && production < 0.02) grid = consumption
121136

122137
const alerts = []
123138
if (!ha.connected) alerts.push({ id: 'haos', severity: 'critical', text: 'Sin conexión con Home Assistant' })
139+
if (consDown) alerts.push({ id: 'consumo', severity: 'warning', text: 'Medidores de consumo sin datos' })
124140

125141
// Estado del inversor: desde statusAttrsId (opcional). Si no hay sensor con
126142
// esos atributos, no hay alerta de inversor/scraper (genérico). Textos sin
@@ -145,13 +161,13 @@ export function computeLive(ha) {
145161
const inv1 = invLive[1] || { kw: 0 }
146162
return {
147163
production: round3(production),
148-
consumption: round3(consumption),
164+
consumption: consDown ? null : round3(consumption),
149165
respaldoKw: round3(entityNum(ha, t.consumption.respaldoId, t.consumption.powerUnit === 'W' ? 1000 : 1)),
150166
noRespaldadaKw: round3(entityNum(ha, t.consumption.noRespaldadaId, t.consumption.powerUnit === 'W' ? 1000 : 1)),
151167
batteryPower: round3(batteryPower),
152168
batteryStatus: batState || 'Desconocido',
153169
soc: round1(soc),
154-
grid: round3(grid),
170+
grid: gridDown ? null : round3(grid),
155171
solis: round3(inv0.kw),
156172
fox: round3(inv1.kw),
157173
inverters: invLive,
@@ -305,7 +321,7 @@ async function getDaySeriesUncached(ha, dateStr, db) {
305321
// (fila `daily` para días pasados; `liveTargets` para HOY, que aún no tiene
306322
// fila), para que la curva integre el mismo total que la fuente profunda.
307323
// Si se usó respaldo en algún bucket, la curva se marca `estimated`.
308-
function buildDaySeries(stats, dateStr, db, liveTargets) {
324+
export function buildDaySeries(stats, dateStr, db, liveTargets) {
309325
const t = getInstall()
310326
const invIds = t.inverters.flatMap((inv) => [inv.powerId, inv.backupPowerId].filter(Boolean))
311327
const ids = [...invIds, ...t.consumption.powerIds]
@@ -373,6 +389,15 @@ function buildDaySeries(stats, dateStr, db, liveTargets) {
373389

374390
let grid = consumption - production + batteryPower
375391
if (Math.abs(grid) < 0.03) grid = 0
392+
// Issue 135: si falta CUALQUIER entrada de la fórmula en el bucket
393+
// (medidores de consumo o sensor de batería caídos), el grid derivado no
394+
// es fiable → se suprime (null) en vez de computar con ceros, que de
395+
// noche fabrica exportaciones imposibles. Si el hueco de consumo se
396+
// rellena después (issue 125), el grid se recompute con el consumo
397+
// estimado y el día queda marcado `estimated`.
398+
const consMissing = consMeterIds.some((id) => b[id] === undefined || b[id] === null)
399+
const batMissing = t.battery.enabled && t.battery.powerId && (b[t.battery.powerId] === undefined || b[t.battery.powerId] === null)
400+
if (consMissing || batMissing) grid = null
376401

377402
const hh = String(Math.floor(min / 60)).padStart(2, '0')
378403
const mm = String(min % 60).padStart(2, '0')
@@ -388,7 +413,7 @@ function buildDaySeries(stats, dateStr, db, liveTargets) {
388413
consumption: round3(consumption),
389414
batteryPower: round3(batteryPower),
390415
soc: round1(soc),
391-
grid: round3(grid),
416+
grid: grid === null ? null : round3(grid),
392417
})
393418
})
394419

‎server/test/solar.test.js‎

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, afterEach } from 'vitest'
2-
import { computeLive, cachedCollector } from '../src/solar.js'
2+
import { computeLive, cachedCollector, buildDaySeries } from '../src/solar.js'
33
import { _setForTests } from '../src/install.js'
44
import { LEGACY_TOPOLOGY, GENERIC_TOPOLOGY } from '../src/install.js'
55

@@ -100,6 +100,92 @@ describe('computeLive — topología genérica (issue #37)', () => {
100100
})
101101
})
102102

103+
describe('issue #135 — grid derivado con entradas caídas', () => {
104+
// HA conectado con la topología LEGACY (3 medidores de consumo en W,
105+
// batería con powerId/socId, grid por attrs del shim).
106+
function haLegacy(overrides = {}) {
107+
const map = {
108+
'sensor.solis_potencia_actual': { state: '0' },
109+
'sensor.almacen_pinza_power_b': { state: '0' },
110+
'sensor.medidor_respaldo_power': { state: '500' },
111+
'sensor.vivienda_medidor_power': { state: '200' },
112+
'sensor.almacen_pinza_power_a': { state: '50' },
113+
'sensor.solis_bateria_potencia': { state: '0' },
114+
'sensor.solis_bateria_soc': { state: '50' },
115+
'sensor.solis_bateria_estado': { state: 'Descargando' },
116+
'sensor.solis_scraper': { state: 'ok', attributes: { currentGridPower: 0.7, gridDirection: 'import' } },
117+
'sun.sun': { state: 'below_horizon', attributes: { elevation: -20 } },
118+
...overrides,
119+
}
120+
return { connected: true, getState: (id) => map[id] }
121+
}
122+
123+
it('computeLive: un medidor de consumo caído → consumption null + alerta, grid intacto', () => {
124+
_setForTests(LEGACY_TOPOLOGY)
125+
const live = computeLive(haLegacy({ 'sensor.medidor_respaldo_power': { state: 'unavailable' } }))
126+
expect(live.consumption).toBeNull()
127+
expect(live.alerts.some((a) => a.id === 'consumo' && a.severity === 'warning')).toBe(true)
128+
expect(live.grid).toBeCloseTo(0.7, 2) // la fuente de red sigue viva
129+
})
130+
131+
it('computeLive: fuente de red (attrs) caída → grid null, sin fallback inventado', () => {
132+
_setForTests(LEGACY_TOPOLOGY)
133+
const live = computeLive(haLegacy({ 'sensor.solis_scraper': { state: 'unavailable', attributes: {} } }))
134+
expect(live.grid).toBeNull()
135+
})
136+
137+
// Buckets de noche que reproducen el caso real del 26-Sep: medidores de
138+
// consumo caídos y batería descargando 3,3 kW. Antes: grid -3,3 kW
139+
// (exportación imposible de noche). Ahora: null.
140+
it('buildDaySeries: bucket sin medidores de consumo → grid null (no export imposible)', () => {
141+
_setForTests(LEGACY_TOPOLOGY)
142+
const stats = {
143+
'sensor.medidor_respaldo_power': [
144+
{ start: '2026-09-26T19:55:00', mean: 500 },
145+
{ start: '2026-09-26T20:05:00', mean: 500 },
146+
],
147+
'sensor.vivienda_medidor_power': [
148+
{ start: '2026-09-26T19:55:00', mean: 200 },
149+
{ start: '2026-09-26T20:05:00', mean: 200 },
150+
],
151+
'sensor.almacen_pinza_power_a': [
152+
{ start: '2026-09-26T19:55:00', mean: 50 },
153+
{ start: '2026-09-26T20:05:00', mean: 50 },
154+
],
155+
'sensor.solis_bateria_potencia': [
156+
{ start: '2026-09-26T19:55:00', mean: 3.3 },
157+
{ start: '2026-09-26T20:00:00', mean: 3.3 },
158+
{ start: '2026-09-26T20:05:00', mean: 3.3 },
159+
],
160+
'sensor.solis_bateria_soc': [
161+
{ start: '2026-09-26T19:55:00', mean: 50 },
162+
{ start: '2026-09-26T20:00:00', mean: 49 },
163+
{ start: '2026-09-26T20:05:00', mean: 49 },
164+
],
165+
}
166+
const { points } = buildDaySeries(stats, '2026-09-26', null, {})
167+
const p1955 = points.find((p) => p.label === '19:55')
168+
const p2000 = points.find((p) => p.label === '20:00')
169+
const p2005 = points.find((p) => p.label === '20:05')
170+
expect(p1955.grid).toBeCloseTo(0.75, 2) // todo presente: 0,75 kW de import
171+
expect(p2000.grid).toBeNull() // medidores caídos + batería descargando: antes -3,3 kW
172+
expect(p2005.grid).toBeCloseTo(0.75, 2) // medidores recuperados
173+
})
174+
175+
it('buildDaySeries: falta UN medidor de consumo (hueco parcial) → grid null', () => {
176+
_setForTests(LEGACY_TOPOLOGY)
177+
const stats = {
178+
'sensor.medidor_respaldo_power': [{ start: '2026-09-26T21:00:00', mean: 500 }],
179+
'sensor.almacen_pinza_power_a': [{ start: '2026-09-26T21:00:00', mean: 50 }],
180+
'sensor.solis_bateria_potencia': [{ start: '2026-09-26T21:00:00', mean: 0 }],
181+
'sensor.solis_bateria_soc': [{ start: '2026-09-26T21:00:00', mean: 50 }],
182+
}
183+
const { points } = buildDaySeries(stats, '2026-09-26', null, {})
184+
expect(points).toHaveLength(1)
185+
expect(points[0].grid).toBeNull() // consumo infrarreportado (0,55 en vez de 0,75): no fiable
186+
})
187+
})
188+
103189
describe('cachedCollector', () => {
104190
it('single-flight: N llamadas concurrentes = 1 ejecución', async () => {
105191
let calls = 0

0 commit comments

Comments
 (0)