465 lines
21 KiB
TypeScript
465 lines
21 KiB
TypeScript
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">PV→Load</th>
|
||
<th className="px-2 py-1 text-right">PV→Batt</th>
|
||
<th className="px-2 py-1 text-right">PV→Grid</th>
|
||
<th className="px-2 py-1 text-right">Batt→Load</th>
|
||
<th className="px-2 py-1 text-right">Batt→Grid</th>
|
||
<th className="px-2 py-1 text-right">Grid→Load</th>
|
||
<th className="px-2 py-1 text-right">Grid→Batt</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> Kč
|
||
</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> Kč
|
||
</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> Kč
|
||
</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)} Kč/kWh</span>
|
||
) : (
|
||
<span>–</span>
|
||
)}
|
||
</li>
|
||
<li>
|
||
Prům. cena prodeje:{' '}
|
||
{avgExportKcPerKwh != null ? (
|
||
<span className="text-emerald-200/90">{avgExportKcPerKwh.toFixed(3)} Kč/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)} Kč</li>
|
||
<li>Do baterie: {czk(totals.grid_to_batt_cost_czk)} Kč</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>
|
||
)
|
||
}
|