implementace co nejdrive dosazeni SOC na home-01 a umozneni plneho socu n slotu ped koncem sell < 0
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-05-26 08:07:00 +02:00
parent 8494ea26de
commit 91a9bef3d7
10 changed files with 566 additions and 25 deletions

View File

@@ -3,6 +3,8 @@
export type PlanMaskSlot = {
allow_charge: boolean
allow_discharge_export: boolean
neg_sell_phase?: 'none' | 'prep' | 'tail' | null
neg_sell_soc_target_wh?: number | null
}
export type PlanSolverSnapshot = {
@@ -47,9 +49,16 @@ export function parsePlanSolverSnapshot(
const row = m as Record<string, unknown>
const slot = recordString(row.slot)
if (slot == null) continue
const phaseRaw = row.neg_sell_phase
const phase =
phaseRaw === 'prep' || phaseRaw === 'tail' || phaseRaw === 'none'
? phaseRaw
: null
masksByIso.set(slot, {
allow_charge: recordBool(row.allow_charge),
allow_discharge_export: recordBool(row.allow_discharge_export),
neg_sell_phase: phase,
neg_sell_soc_target_wh: recordNumber(row.neg_sell_soc_target_wh),
})
}
}

View File

@@ -395,11 +395,41 @@ function tableRowClass(
if (buy != null && buy < 0) parts.push('bg-green-950/80')
else if (sell != null && sell < 0) parts.push('bg-red-950/80')
if ((i.pv_a_curtailed_w ?? 0) > 0) parts.push('border-l-4 border-l-yellow-500')
else if (mask?.neg_sell_phase === 'prep' || mask?.neg_sell_phase === 'tail') {
parts.push('border-l-4 border-l-violet-600/70')
}
else if (mask?.allow_charge) parts.push('border-l-4 border-l-emerald-600/70')
else if (mask?.allow_discharge_export) parts.push('border-l-4 border-l-orange-500/70')
return parts.join(' ')
}
function pvAAllowedW(i: PlanningIntervalDto): number | null {
const fc = i.pv_a_forecast_solver_w ?? i.pv_a_forecast_w
if (fc == null) return null
return Math.max(0, fc - (i.pv_a_curtailed_w ?? 0))
}
function negSellPhaseBadge(mask: PlanMaskSlot | null): {
label: string
klass: string
title: string
} | null {
const p = mask?.neg_sell_phase
if (p == null || p === 'none') return null
if (p === 'prep') {
return {
label: 'sell prep',
klass: 'bg-violet-500/20 text-violet-100 ring-1 ring-violet-500/40',
title: 'Fáze přípravy SoC v okně záporného výkupu (cíl z DB, typ. 80 %)',
}
}
return {
label: 'sell tail',
klass: 'bg-fuchsia-500/20 text-fuchsia-100 ring-1 ring-fuchsia-500/40',
title: 'Závěr okna sell<0: rampa na plné SoC, volitelný ventil pole B',
}
}
function exportModeBadge(i: PlanningIntervalDto): {
label: string
klass: string
@@ -543,6 +573,14 @@ function PlanSlotDetail({
{mask?.allow_discharge_export ? (
<span className="rounded bg-orange-950/60 px-2 py-0.5 text-[10px] text-orange-200"> export bat. OK</span>
) : null}
{(() => {
const pb = negSellPhaseBadge(mask)
return pb ? (
<span className={`inline-flex rounded-md px-2 py-0.5 text-[10px] font-semibold ${pb.klass}`} title={pb.title}>
{pb.label}
</span>
) : null
})()}
</div>
<dl className="mt-3 grid gap-2 font-mono text-xs sm:grid-cols-2 lg:grid-cols-3">
<div>
@@ -572,8 +610,21 @@ function PlanSlotDetail({
</dd>
</div>
<div>
<dt className="text-slate-500">Škrcení A</dt>
<dd>{(i.pv_a_curtailed_w ?? 0) > 0 ? `${i.pv_a_curtailed_w} W` : '—'}</dd>
<dt className="text-slate-500">Škrcení A / reg 340</dt>
<dd>
{(i.pv_a_curtailed_w ?? 0) > 0 ? (
<span className="text-yellow-200">
CURTAIL {(i.pv_a_curtailed_w ?? 0).toLocaleString('cs-CZ')} W
</span>
) : (
'—'
)}
{pvAAllowedW(i) != null ? (
<span className="ml-2 text-slate-400">
· povoleno {pvAAllowedW(i)!.toLocaleString('cs-CZ')} W
</span>
) : null}
</dd>
</div>
<div>
<dt className="text-slate-500">Výnos slotu</dt>
@@ -1008,7 +1059,7 @@ export default function Planning() {
[visibleSlots, selectedStart],
)
const tableColCount = 13 + (solverSnap != null ? 1 : 0) + (showGenCut ? 1 : 0)
const tableColCount = 14 + (solverSnap != null ? 1 : 0) + (showGenCut ? 1 : 0)
async function onReplan() {
if (siteId == null) return
@@ -1603,6 +1654,12 @@ export default function Planning() {
</th>
) : null}
<th className="whitespace-nowrap py-2 pr-2 font-medium">SoC %</th>
<th
className="whitespace-nowrap py-2 pr-2 font-medium"
title="Povolený výkon pole A (forecast curtail) ≈ Deye reg 340 max solar"
>
PV A
</th>
<th className="whitespace-nowrap py-2 pr-2 font-medium" title="Budoucí sloty: korigovaná předpověď z delta profilu; proběhlé sloty z auditu.">
<span className="block">FVE W</span>
<span className="block text-[10px] font-normal normal-case text-slate-600">delta · audit</span>
@@ -1656,6 +1713,8 @@ export default function Planning() {
const sel = selectedStart === i.interval_start
const slotMask = maskForInterval(solverSnap, i.interval_start)
const exBadge = exportModeBadge(i)
const phaseBadge = negSellPhaseBadge(slotMask)
const pvAllowed = pvAAllowedW(i)
return (
<tr
key={i.interval_start}
@@ -1729,6 +1788,24 @@ export default function Planning() {
? `${i.battery_soc_target_pct.toFixed(1)}`
: '—'}
</td>
<td className="pr-2 font-mono tabular-nums text-slate-300">
<div className="flex flex-wrap items-center gap-1">
{pvAllowed != null ? pvAllowed.toLocaleString('cs-CZ') : '—'}
{(i.pv_a_curtailed_w ?? 0) > 0 ? (
<span className="rounded bg-yellow-500/20 px-1 py-0.5 text-[9px] font-semibold text-yellow-200">
CURTAIL
</span>
) : null}
{phaseBadge ? (
<span
className={`rounded px-1 py-0.5 text-[9px] font-semibold ${phaseBadge.klass}`}
title={phaseBadge.title}
>
{phaseBadge.label}
</span>
) : null}
</div>
</td>
<FveWCell i={i} nowMs={nowMs} />
<td className="pr-2 font-mono tabular-nums text-slate-300">
{formatPlanPowerW(i.load_baseline_w)}