Files
ems/frontend/src/pages/EnergyFlows.tsx
Dusan Vojacek b50041cfc7
All checks were successful
deploy / deploy (push) Successful in 1m21s
test / smoke-test (push) Successful in 5s
do flow pridana ekonomika
2026-04-10 23:06:25 +02:00

465 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp } from 'lucide-react'
import { Fragment, useEffect, useMemo, useState } from 'react'
import { EnergyFlowSankey, type FlowTotals } from '../components/EnergyFlowSankey'
import { useEnergyFlowsDaily, useEnergyFlowsIntervals } from '../hooks/useEnergyFlows'
import { useSiteStatus } from '../hooks/useSiteStatus'
import { pragueCalendarDay } from '../lib/pragueDate'
import type { DailyEnergyFlows } from '../types/energy-flows'
function currentMonth(): string {
return pragueCalendarDay().slice(0, 7)
}
function monthLabel(ym: string): string {
const [y, m] = ym.split('-').map(Number)
const names = [
'Leden', 'Únor', 'Březen', 'Duben', 'Květen', 'Červen',
'Červenec', 'Srpen', 'Září', 'Říjen', 'Listopad', 'Prosinec',
]
return `${names[m - 1]} ${y}`
}
function shiftMonth(ym: string, delta: number): string {
const [y, m] = ym.split('-').map(Number)
const d = new Date(y, m - 1 + delta, 1)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
function fmtDay(iso: string): string {
const d = new Date(iso + 'T00:00:00')
return d.toLocaleDateString('cs-CZ', { weekday: 'short', day: 'numeric', month: 'numeric' })
}
function fmtTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Prague' })
}
function kwh(v: number | null | undefined, d = 2): string {
if (v == null) return ''
return v.toFixed(d)
}
function czk(v: number | null | undefined): string {
if (v == null) return ''
return v.toLocaleString('cs-CZ', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
function aggregateFlows(days: DailyEnergyFlows[]): FlowTotals & {
pv_production_kwh: number
grid_import_kwh: number
grid_export_kwh: number
batt_charge_kwh: number
batt_discharge_kwh: number
load_kwh: number
grid_import_cashflow_czk: number
grid_export_revenue_czk: number
grid_to_load_cost_czk: number
grid_to_batt_cost_czk: number
} {
const z = {
pv_production_kwh: 0,
grid_import_kwh: 0,
grid_export_kwh: 0,
batt_charge_kwh: 0,
batt_discharge_kwh: 0,
load_kwh: 0,
pv_to_load_kwh: 0,
pv_to_batt_kwh: 0,
pv_to_grid_kwh: 0,
batt_to_load_kwh: 0,
batt_to_grid_kwh: 0,
grid_to_load_kwh: 0,
grid_to_batt_kwh: 0,
grid_import_cashflow_czk: 0,
grid_export_revenue_czk: 0,
grid_to_load_cost_czk: 0,
grid_to_batt_cost_czk: 0,
}
for (const d of days) {
z.pv_production_kwh += d.pv_production_kwh
z.grid_import_kwh += d.grid_import_kwh
z.grid_export_kwh += d.grid_export_kwh
z.batt_charge_kwh += d.batt_charge_kwh
z.batt_discharge_kwh += d.batt_discharge_kwh
z.load_kwh += d.load_kwh
z.pv_to_load_kwh += d.pv_to_load_kwh
z.pv_to_batt_kwh += d.pv_to_batt_kwh
z.pv_to_grid_kwh += d.pv_to_grid_kwh
z.batt_to_load_kwh += d.batt_to_load_kwh
z.batt_to_grid_kwh += d.batt_to_grid_kwh
z.grid_to_load_kwh += d.grid_to_load_kwh
z.grid_to_batt_kwh += d.grid_to_batt_kwh
z.grid_import_cashflow_czk += d.grid_import_cashflow_czk ?? 0
z.grid_export_revenue_czk += d.grid_export_revenue_czk ?? 0
z.grid_to_load_cost_czk += d.grid_to_load_cost_czk ?? 0
z.grid_to_batt_cost_czk += d.grid_to_batt_cost_czk ?? 0
}
return z
}
function IntervalDetail({ siteId, day }: { siteId: number; day: string }) {
const { intervals, loading } = useEnergyFlowsIntervals(siteId, day)
if (loading) {
return <div className="py-3 text-center text-xs text-slate-500">Načítání intervalů</div>
}
if (intervals.length === 0) {
return <div className="py-3 text-center text-xs text-slate-500">Žádné intervaly</div>
}
return (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-slate-700 text-slate-400">
<th className="px-2 py-1 text-left">Čas</th>
<th className="px-2 py-1 text-right">PVLoad</th>
<th className="px-2 py-1 text-right">PVBatt</th>
<th className="px-2 py-1 text-right">PVGrid</th>
<th className="px-2 py-1 text-right">BattLoad</th>
<th className="px-2 py-1 text-right">BattGrid</th>
<th className="px-2 py-1 text-right">GridLoad</th>
<th className="px-2 py-1 text-right">GridBatt</th>
</tr>
</thead>
<tbody>
{intervals.map((iv) => (
<tr key={iv.interval_start} className="border-b border-slate-800 hover:bg-slate-800/40">
<td className="px-2 py-1 text-slate-300">{fmtTime(iv.interval_start)}</td>
<td className="px-2 py-1 text-right">{kwh(iv.pv_to_load_kwh)}</td>
<td className="px-2 py-1 text-right">{kwh(iv.pv_to_batt_kwh)}</td>
<td className="px-2 py-1 text-right">{kwh(iv.pv_to_grid_kwh)}</td>
<td className="px-2 py-1 text-right">{kwh(iv.batt_to_load_kwh)}</td>
<td className="px-2 py-1 text-right">{kwh(iv.batt_to_grid_kwh)}</td>
<td className="px-2 py-1 text-right">{kwh(iv.grid_to_load_kwh)}</td>
<td className="px-2 py-1 text-right">{kwh(iv.grid_to_batt_kwh)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
export default function EnergyFlows() {
const { site: siteRow, ready: siteReady, error: siteError } = useSiteStatus()
const siteId = siteRow?.site_id ?? null
const [month, setMonth] = useState(currentMonth)
const [expandedDay, setExpandedDay] = useState<string | null>(null)
/** null = součet za celý měsíc; jinak ISO den pro Sankey + perspektivní karty */
const [scopeDay, setScopeDay] = useState<string | null>(null)
const { days, loading, error, reload } = useEnergyFlowsDaily(siteId, month)
useEffect(() => {
setScopeDay(null)
}, [month])
const totals = useMemo(() => {
if (days.length === 0) return null
if (scopeDay) {
const row = days.find((d) => d.day === scopeDay)
if (row) return aggregateFlows([row])
}
return aggregateFlows(days)
}, [days, scopeDay])
const flowOnly: FlowTotals | null = totals
? {
pv_to_load_kwh: totals.pv_to_load_kwh,
pv_to_batt_kwh: totals.pv_to_batt_kwh,
pv_to_grid_kwh: totals.pv_to_grid_kwh,
batt_to_load_kwh: totals.batt_to_load_kwh,
batt_to_grid_kwh: totals.batt_to_grid_kwh,
grid_to_load_kwh: totals.grid_to_load_kwh,
grid_to_batt_kwh: totals.grid_to_batt_kwh,
}
: null
const battEff =
totals && totals.batt_charge_kwh > 0.01
? Math.min(100, (totals.batt_discharge_kwh / totals.batt_charge_kwh) * 100)
: null
const avgImportKcPerKwh =
totals && totals.grid_import_kwh > 0.001
? totals.grid_import_cashflow_czk / totals.grid_import_kwh
: null
const avgExportKcPerKwh =
totals && totals.grid_export_kwh > 0.001
? totals.grid_export_revenue_czk / totals.grid_export_kwh
: null
const gridNetCzk =
totals != null ? totals.grid_import_cashflow_czk - totals.grid_export_revenue_czk : null
return (
<main className="mx-auto max-w-7xl space-y-6 px-4 py-6 md:px-8">
{siteReady && siteRow && (
<p className="text-xs text-slate-500">
Lokalita: <span className="text-slate-400">{siteRow.site_code}</span> toky jsou{' '}
<strong className="text-slate-400">modelované</strong> prioritní alokací z minutové telemetrie (
<code className="rounded bg-slate-800 px-1">fn_fill_audit_interval</code>), ne přímé měření větví.
</p>
)}
{siteError && (
<div className="rounded-lg border border-amber-900/50 bg-amber-950/30 px-3 py-2 text-sm text-amber-200">
{siteError}
</div>
)}
<div className="flex items-center gap-4">
<button
type="button"
onClick={() => setMonth((m) => shiftMonth(m, -1))}
className="rounded-lg p-2 text-slate-400 transition hover:bg-slate-800 hover:text-white"
>
<ChevronLeft size={20} />
</button>
<h1 className="min-w-[200px] text-center text-lg font-semibold text-white">
Toky energie {monthLabel(month)}
</h1>
<button
type="button"
onClick={() => setMonth((m) => shiftMonth(m, 1))}
className="rounded-lg p-2 text-slate-400 transition hover:bg-slate-800 hover:text-white"
>
<ChevronRight size={20} />
</button>
</div>
{!siteReady ? (
<div className="py-12 text-center text-sm text-slate-500">Načítání lokality</div>
) : siteId == null ? (
<div className="py-12 text-center text-sm text-slate-500">Vyberte lokalitu v horní liště.</div>
) : loading ? (
<div className="py-12 text-center text-sm text-slate-500">Načítání</div>
) : error ? (
<div className="rounded-lg border border-red-900/50 bg-red-950/30 px-4 py-3 text-sm text-red-300">
{error}
</div>
) : (
<>
{days.length > 0 && (
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-slate-800 bg-slate-900/60 px-3 py-2">
<label htmlFor="energy-flow-scope" className="text-sm text-slate-400">
Graf a karty:
</label>
<select
id="energy-flow-scope"
className="max-w-[min(100%,20rem)] rounded-lg border border-slate-700 bg-slate-900 px-3 py-1.5 text-sm text-slate-200"
value={scopeDay ?? ''}
onChange={(e) => {
const v = e.target.value
setScopeDay(v === '' ? null : v)
}}
>
<option value="">Celý měsíc (součet)</option>
{days.map((d) => (
<option key={d.day} value={d.day}>
{fmtDay(d.day)}
</option>
))}
</select>
</div>
)}
{totals && (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
<p className="text-xs font-medium uppercase tracking-wide text-amber-400">Perspektiva FVE</p>
<p className="mt-2 text-2xl font-semibold text-white">{totals.pv_production_kwh.toFixed(1)} kWh</p>
<ul className="mt-2 space-y-1 text-xs text-slate-400">
<li> spotřeba: {totals.pv_to_load_kwh.toFixed(2)} kWh</li>
<li> baterie: {totals.pv_to_batt_kwh.toFixed(2)} kWh</li>
<li> síť (export): {totals.pv_to_grid_kwh.toFixed(2)} kWh</li>
</ul>
</div>
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
<p className="text-xs font-medium uppercase tracking-wide text-sky-400">Perspektiva síť</p>
<p className="mt-2 text-sm text-slate-400">
Import: <span className="font-semibold text-red-300">{totals.grid_import_kwh.toFixed(2)}</span> kWh
{' · '}
Export:{' '}
<span className="font-semibold text-green-300">{totals.grid_export_kwh.toFixed(2)}</span> kWh
</p>
<ul className="mt-2 space-y-1 text-xs text-slate-400">
<li>Import spotřeba: {totals.grid_to_load_kwh.toFixed(2)} kWh</li>
<li>Import baterie: {totals.grid_to_batt_kwh.toFixed(2)} kWh</li>
</ul>
</div>
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
<p className="text-xs font-medium uppercase tracking-wide text-emerald-400">Perspektiva baterie</p>
<p className="mt-2 text-sm text-slate-400">
Nabito: <span className="text-white">{totals.batt_charge_kwh.toFixed(2)}</span> kWh · Vybito:{' '}
<span className="text-white">{totals.batt_discharge_kwh.toFixed(2)}</span> kWh
</p>
<ul className="mt-2 space-y-1 text-xs text-slate-400">
<li>Z FVE: {totals.pv_to_batt_kwh.toFixed(2)} kWh · Ze sítě: {totals.grid_to_batt_kwh.toFixed(2)} kWh</li>
<li>Do spotřeby: {totals.batt_to_load_kwh.toFixed(2)} kWh · Do sítě: {totals.batt_to_grid_kwh.toFixed(2)} kWh</li>
{battEff != null && (
<li className="text-slate-500">Poměr vybití/nabití: {battEff.toFixed(0)} % (zjednodušeně)</li>
)}
</ul>
</div>
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
<p className="text-xs font-medium uppercase tracking-wide text-violet-400">Perspektiva spotřeby</p>
<p className="mt-2 text-2xl font-semibold text-white">{totals.load_kwh.toFixed(1)} kWh</p>
<p className="mt-1 text-xs text-slate-500">Celkový odběr (model z telemetrie)</p>
<ul className="mt-2 space-y-1 text-xs text-slate-400">
<li>
Z FVE: <span className="text-amber-200/90">{totals.pv_to_load_kwh.toFixed(2)}</span> kWh
</li>
<li>
Z baterie: <span className="text-emerald-200/90">{totals.batt_to_load_kwh.toFixed(2)}</span> kWh
</li>
<li>
Ze sítě (import): <span className="text-sky-200/90">{totals.grid_to_load_kwh.toFixed(2)}</span> kWh
</li>
</ul>
<p className="mt-2 border-t border-slate-800 pt-2 text-[11px] leading-snug text-slate-500">
Součet tří toků odpovídá modelu prioritní alokace; může se mírně lišit od sloupce Spotřeba kvůli
zaokrouhlení po 15min intervalech.
</p>
</div>
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
<p className="text-xs font-medium uppercase tracking-wide text-rose-400">Perspektiva financí</p>
<p className="mt-2 text-sm text-slate-400">
Nákup ze sítě:{' '}
<span className="font-semibold text-red-200">{czk(totals.grid_import_cashflow_czk)}</span>
</p>
<p className="mt-1 text-sm text-slate-400">
Prodej do sítě:{' '}
<span className="font-semibold text-green-200">{czk(totals.grid_export_revenue_czk)}</span>
</p>
<p className="mt-1 text-sm text-slate-300">
Bilance sítě (nákup prodej):{' '}
<span className="font-semibold text-white">{czk(gridNetCzk)}</span>
</p>
<ul className="mt-2 space-y-1 border-t border-slate-800 pt-2 text-xs text-slate-400">
<li>
Prům. cena nákupu:{' '}
{avgImportKcPerKwh != null ? (
<span className="text-rose-200/90">{avgImportKcPerKwh.toFixed(3)} /kWh</span>
) : (
<span></span>
)}
</li>
<li>
Prům. cena prodeje:{' '}
{avgExportKcPerKwh != null ? (
<span className="text-emerald-200/90">{avgExportKcPerKwh.toFixed(3)} /kWh</span>
) : (
<span></span>
)}
</li>
</ul>
<p className="mt-2 text-[11px] font-medium uppercase tracking-wide text-slate-500">
Rozpad nákladů importu (efektivní cena × modelovaný tok)
</p>
<ul className="mt-1 space-y-1 text-xs text-slate-400">
<li>Do spotřeby: {czk(totals.grid_to_load_cost_czk)} </li>
<li>Do baterie: {czk(totals.grid_to_batt_cost_czk)} </li>
</ul>
<p className="mt-2 border-t border-slate-800 pt-2 text-[11px] leading-snug text-slate-500">
Stejná jednotková cena v každém 15min slotu; součet rozpadu se může mírně lišit od celkového nákupu kvůli
zaokrouhlení a odchylce modelu toků od měřeného importu.
</p>
</div>
</div>
)}
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
<h2 className="mb-2 text-sm font-medium text-slate-300">
Sankey {scopeDay ? fmtDay(scopeDay) : 'celý měsíc (součet)'}
</h2>
<EnergyFlowSankey totals={flowOnly} />
</div>
<div className="overflow-hidden rounded-xl border border-slate-800 bg-slate-900">
<div className="border-b border-slate-800 px-4 py-3">
<h2 className="text-sm font-medium text-slate-300">Denní přehled</h2>
</div>
{days.length === 0 ? (
<div className="px-4 py-10 text-center text-sm text-slate-500">
Žádná data zkuste jiný měsíc nebo backfill auditu (
<code className="rounded bg-slate-800 px-1">fn_fill_audit_range</code>).
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-700 text-xs text-slate-400">
<th className="px-3 py-2 text-left">Den</th>
<th className="px-3 py-2 text-right">PV kWh</th>
<th className="px-3 py-2 text-right">Import</th>
<th className="px-3 py-2 text-right">Export</th>
<th className="px-3 py-2 text-right">Nabití</th>
<th className="px-3 py-2 text-right">Vybití</th>
<th className="px-3 py-2 text-right">Spotřeba</th>
</tr>
</thead>
<tbody>
{days.map((row) => (
<Fragment key={row.day}>
<tr
className="cursor-pointer border-b border-slate-800 hover:bg-slate-800/50"
onClick={() => setExpandedDay((p) => (p === row.day ? null : row.day))}
>
<td className="px-3 py-2 text-sm text-slate-200">
<span className="mr-1 inline-block w-4">
{expandedDay === row.day ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</span>
<button
type="button"
className={`rounded px-1 text-left underline-offset-2 hover:underline ${
scopeDay === row.day ? 'text-amber-300' : ''
}`}
title="Zobrazit tento den v Sankey a ve všech kartách"
onClick={(e) => {
e.stopPropagation()
setScopeDay(row.day)
}}
>
{fmtDay(row.day)}
</button>
</td>
<td className="px-3 py-2 text-right text-sm">{row.pv_production_kwh.toFixed(1)}</td>
<td className="px-3 py-2 text-right text-sm">{row.grid_import_kwh.toFixed(2)}</td>
<td className="px-3 py-2 text-right text-sm">{row.grid_export_kwh.toFixed(2)}</td>
<td className="px-3 py-2 text-right text-sm">{row.batt_charge_kwh.toFixed(2)}</td>
<td className="px-3 py-2 text-right text-sm">{row.batt_discharge_kwh.toFixed(2)}</td>
<td className="px-3 py-2 text-right text-sm">{row.load_kwh.toFixed(1)}</td>
</tr>
{expandedDay === row.day && siteId != null ? (
<tr>
<td colSpan={7} className="bg-slate-900/50 px-4 py-2">
<IntervalDetail siteId={siteId} day={row.day} />
</td>
</tr>
) : null}
</Fragment>
))}
</tbody>
</table>
</div>
)}
</div>
<p className="text-center text-xs text-slate-600">
<button
type="button"
className="underline hover:text-slate-400"
onClick={() => void reload()}
>
Znovu načíst
</button>
</p>
</>
)}
</main>
)
}