Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 17 additions & 10 deletions app/src/components/EnergyFlowDiagram.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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 (
<motion.svg
Expand Down Expand Up @@ -198,8 +205,8 @@ export default function EnergyFlowDiagram({ live, className }: EnergyFlowDiagram
x={NODES.home.x}
y={NODES.home.y}
label={t('flow.home')}
valueText={`${fmtKw(live.consumption)} kW`}
active={live.consumption > 0.1}
valueText={live.consumption === null ? '—' : `${fmtKw(live.consumption)} kW`}
active={cons > 0.1}
glowColor={homeColor}
>
<House size={30} style={{ color: homeColor }} />
Expand Down Expand Up @@ -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')}
>
<UtilityPole size={26} style={{ color: live.grid < 0 ? palette.redVertido : palette.redCompra }} />
<UtilityPole size={26} style={{ color: live.grid != null && live.grid < 0 ? palette.redVertido : palette.redCompra }} />
</FlowNode>
</motion.svg>
);
Expand Down
16 changes: 9 additions & 7 deletions app/src/components/LivePowerStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand All @@ -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 (
<div className={cn('flex items-center gap-3 px-4 py-3 sm:px-5', className)}>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl" style={{ backgroundColor: `${color}1F`, color: iconColor ?? color }}>
Expand All @@ -33,10 +34,10 @@ function StripValue({ icon: Icon, label, kw, status, color, iconColor, className
<div className="min-w-0">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-faint">{label}</p>
<p className="font-display text-[22px] font-semibold leading-tight text-app" aria-live="off">
{altValue !== undefined ? altValue : fmtKw(animated)}
{altValue !== undefined ? altValue : kw === null ? '—' : fmtKw(animated)}
{altUnit !== undefined ? (
<span className="ml-1 text-[0.6em] font-medium text-faint">{altUnit}</span>
) : (
) : kw === null ? null : (
<span className="ml-1 text-[0.6em] font-medium text-faint">kW</span>
)}
</p>
Expand All @@ -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');
Expand Down Expand Up @@ -106,7 +108,7 @@ export default function LivePowerStrip({ live, atMin }: LivePowerStripProps) {
<StripValue
icon={exporting ? ArrowUpFromLine : ArrowDownToLine}
label={t('common.grid')}
kw={Math.abs(live.grid)}
kw={live.grid === null ? null : Math.abs(live.grid)}
status={gridLabel(live.grid)}
color={exporting ? palette.redVertido : palette.redCompra}
className="border-l border-t border-app lg:border-t-0"
Expand Down
6 changes: 4 additions & 2 deletions app/src/data/EnergyDataProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
const EnergyDataContext = createContext<EnergyDataApi | null>(null);

let lastLiveSunState: string | undefined;
export function getLiveSunState(): string | undefined {

Check warning on line 29 in app/src/data/EnergyDataProvider.tsx

View workflow job for this annotation

GitHub Actions / frontend

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
return lastLiveSunState;
}

Expand Down Expand Up @@ -81,8 +81,10 @@
if (p.t >= 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;
Expand Down Expand Up @@ -362,7 +364,7 @@
return <EnergyDataContext.Provider value={value}>{children}</EnergyDataContext.Provider>;
}

export function useEnergyData(): EnergyDataApi {

Check warning on line 367 in app/src/data/EnergyDataProvider.tsx

View workflow job for this annotation

GitHub Actions / frontend

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
const ctx = useContext(EnergyDataContext);
if (!ctx) throw new Error('useEnergyData debe usarse dentro de <EnergyDataProvider>');
return ctx;
Expand Down
9 changes: 6 additions & 3 deletions app/src/data/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand All @@ -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[];
Expand Down
5 changes: 3 additions & 2 deletions app/src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
37 changes: 31 additions & 6 deletions server/src/solar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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')
Expand All @@ -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),
})
})

Expand Down
88 changes: 87 additions & 1 deletion server/test/solar.test.js
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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
Expand Down
Loading