/* verification.jsx — #59 "Facit": prognos vs verkligt utfall för en vald dag. Backend: GET /api/verification?date=YYYY-MM-DD (default idag, max 14 dagar bakåt — dagväljaren speglar samma gräns). Timmar utan data (framtid idag, datalucka) = null → renderas som LUCKA i graferna, aldrig som 0. Träffsäkerhet visas i mänskliga termer ("Prognosen träffade inom X%"), aldrig som rå WAPE/MAE-jargong. < 6 timmar data → "för lite data för betyg". Vad systemet GJORDE hämtas från befintliga /api/quarter-history?date=... (samma payload som klockschemat) och renderas som ett 24h-band. Faller tillbaka till deterministisk demodata om endpointen saknas (mock-läge/prototyp) — markeras då med "demodata"-tagg. */ // ---- helpers ---------------------------------------------------------- function vfDateStr(d){ const y = d.getFullYear(); const m = String(d.getMonth()+1).padStart(2,'0'); const day = String(d.getDate()).padStart(2,'0'); return `${y}-${m}-${day}`; } function vfDayLabel(dateStr){ const today = window.hemsDate(); today.setHours(0,0,0,0); const d = new Date(dateStr + 'T00:00:00'); const diff = Math.round((today - d) / 86400000); const days = ['sön','mån','tis','ons','tor','fre','lör']; const months = ['jan','feb','mars','apr','maj','juni','juli','aug','sep','okt','nov','dec']; const base = `${days[d.getDay()]} ${d.getDate()} ${months[d.getMonth()]}`; if (diff === 0) return `Idag · ${base}`; if (diff === 1) return `Igår · ${base}`; return base; } // Deterministisk demodata (prototyp/mock) — seedad på datumet så samma dag // alltid ser likadan ut. INTE en beräkning, bara illustration. function vfMakeMockVerification(dateStr){ let s = 7; for (const c of dateStr) s = (s * 31 + c.charCodeAt(0)) >>> 0; const rnd = () => { s |= 0; s = s + 0x6D2B79F5 | 0; let t = Math.imul(s ^ s >>> 15, 1 | s); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; const isToday = dateStr === vfDateStr(window.hemsDate()); const nowHour = window.hemsDate().getHours(); const cloudBase = rnd() * 70; const hours = []; let sf=0, sa=0, cf=0, ca=0, n=0, maeS=0, maeC=0; for (let h = 0; h < 24; h++){ const hasData = !isToday || h < nowHour; if (!hasData){ hours.push({ hour:h, solar_forecast_kwh:null, solar_actual_kwh:null, cons_forecast_kwh:null, cons_actual_kwh:null, cloud_pct:null }); continue; } const bell = Math.exp(-Math.pow((h - 13) / 3.2, 2)); const cloud = Math.max(0, Math.min(100, cloudBase + (rnd() - 0.5) * 40)); const solF = +(bell * 2.6).toFixed(2); const solA = +Math.max(0, bell * 2.6 * (1.1 - cloud/100*0.45) + (rnd()-0.5)*0.25).toFixed(2); const cF = +(0.4 + Math.exp(-Math.pow((h-7.8)/1.1,2))*0.9 + Math.exp(-Math.pow((h-19)/1.4,2))*1.2 + rnd()*0.1).toFixed(2); const cA = +Math.max(0.1, cF + (rnd()-0.5)*0.45).toFixed(2); hours.push({ hour:h, solar_forecast_kwh:solF, solar_actual_kwh:solA, cons_forecast_kwh:cF, cons_actual_kwh:cA, cloud_pct: Math.round(cloud) }); sf+=solF; sa+=solA; cf+=cF; ca+=cA; maeS+=Math.abs(solA-solF); maeC+=Math.abs(cA-cF); n++; } const wape = (mae, act) => act > 0.5 ? +((mae/act)*100).toFixed(1) : 0; return { date: dateStr, hours, summary: { solar: { forecast_kwh:+sf.toFixed(1), actual_kwh:+sa.toFixed(1), mae_kwh:+(n?maeS/n:0).toFixed(2), wape_pct:wape(maeS, sa), hours_with_data:n }, consumption: { forecast_kwh:+cf.toFixed(1), actual_kwh:+ca.toFixed(1), mae_kwh:+(n?maeC/n:0).toFixed(2), wape_pct:wape(maeC, ca), hours_with_data:n }, }, _mock: true, }; } // Träffsäkerhet på vanlig svenska — ALDRIG "WAPE"/"MAE" i UI:t. function vfVerdict(sum){ if (!sum || sum.hours_with_data == null || sum.hours_with_data < 6){ return { txt: 'För lite data för betyg ännu', tone: 'dim', word: null }; } const w = Math.max(0, Math.round(sum.wape_pct ?? 0)); const word = w <= 15 ? 'Mycket bra' : w <= 30 ? 'Bra' : 'Sådär'; const tone = w <= 15 ? 'good' : w <= 30 ? 'ok' : 'meh'; return { txt: `Prognosen träffade inom ${w}%`, tone, word }; } const VF_TONE = { good: 'var(--c-sell)', ok: 'var(--c-charge-sol)', meh: 'var(--c-cover)', dim: 'var(--ink-3)', }; // ---- jämförelsegraf: prognos (streckad) vs utfall (heldragen) --------- function VfCompareChart({ hours, fKey, aKey, color, label, icon, showClouds, uid }){ const W = 520, H = 170; const padL = 34, padR = 10, padT = showClouds ? 16 : 10, padB = 20; const plotW = W - padL - padR, plotH = H - padT - padB; const vals = []; for (const h of hours){ if (h[fKey] != null) vals.push(h[fKey]); if (h[aKey] != null) vals.push(h[aKey]); } const maxV = Math.max(0.5, ...vals) * 1.12; const x = (h) => padL + ((h + 0.5) / 24) * plotW; const y = (v) => padT + plotH - (v / maxV) * plotH; // Bygg linje-segment som BRYTS vid null-timmar (lucka, inte 0) const segments = (key) => { const segs = []; let cur = null; hours.forEach((h) => { const v = h[key]; if (v == null){ cur = null; return; } if (!cur){ cur = []; segs.push(cur); } cur.push([x(h.hour), y(v)]); }); return segs.filter(s => s.length >= 1); }; const toPath = (pts) => pts.map((p, i) => `${i ? 'L' : 'M'} ${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' '); const fSegs = segments(fKey); const aSegs = segments(aKey); // Null-zoner (datalucka / framtid) — skuggas svagt så luckan syns const nullRects = []; let runStart = null; for (let h = 0; h <= 24; h++){ const isNull = h < 24 && hours[h] && hours[h][fKey] == null && hours[h][aKey] == null; if (isNull && runStart == null) runStart = h; if (!isNull && runStart != null){ nullRects.push([runStart, h]); runStart = null; } } const yTicks = [0, maxV/2, maxV]; return (
{label} prognos utfall
{/* Luckor (null-timmar) — diskret skraffering, ingen falsk nolla */} {nullRects.map(([h0, h1], i) => ( Ingen data {String(h0).padStart(2,'0')}–{String(h1).padStart(2,'0')} ))} {/* Molnighet (sol-grafen): tunn rad överst, mörkare = mer moln */} {showClouds && hours.map((h) => h.cloud_pct == null ? null : ( Molnighet kl {String(h.hour).padStart(2,'0')}: {h.cloud_pct}% ))} {/* Gridlines + y-etiketter */} {yTicks.map((v, i) => ( {v.toFixed(v >= 10 ? 0 : 1).replace('.',',')} ))} {/* x-etiketter */} {[0,6,12,18,24].map(h => ( {String(h % 24).padStart(2,'0')} ))} {/* Prognos — streckad */} {fSegs.map((seg, i) => seg.length === 1 ? : )} {/* Utfall — heldragen */} {aSegs.map((seg, i) => seg.length === 1 ? : )}
); } // ---- 24h-band: vad systemet GJORDE (quarter-history) ------------------ function VfActionBand({ qh }){ const quarters = qh?.quarters; const hasAny = quarters && Object.keys(quarters).length > 0; const mapA = window.hemsAdapter?.mapAction || ((a)=>a); // Aggregera timmar per åtgärd till legenden const tally = {}; if (hasAny){ for (const [, q] of Object.entries(quarters)){ const a = mapA(q.primary_action); tally[a] = (tally[a] || 0) + 0.25; } } const legend = Object.entries(tally) .sort((a,b)=>b[1]-a[1]).slice(0,5); return (
Vad systemet gjorde skickade kommandon · per kvart
{!hasAny ? (
Ingen körhistorik för den här dagen.
) : ( <>
{Array.from({length:96}, (_, i) => { const q = quarters[String(i)]; const a = q ? mapA(q.primary_action) : null; const meta = a ? ACTION_META[a] : null; return ( ); })}
0006121824
{legend.map(([a, h]) => ( {ACTION_META[a].label} {h.toFixed(h % 1 ? 2 : 0).replace('.',',')} h ))}
)}
); } // ---- huvudkort -------------------------------------------------------- function VerificationCard(){ const MAX_BACK = 14; // speglar backendens gräns const [offset, setOffset] = React.useState(0); // 0 = idag const [state, setState] = React.useState({ loading:true, data:null, qh:null, mock:false }); const date = React.useMemo(() => { const d = window.hemsDate(); d.setDate(d.getDate() - offset); return vfDateStr(d); }, [offset]); React.useEffect(() => { let cancelled = false; setState(s => ({ ...s, loading: true })); (async () => { let data = null, mock = false; try { data = await window.hems.getVerification(date); if (!data || !Array.isArray(data.hours)) throw new Error('tomt svar'); } catch (e){ data = vfMakeMockVerification(date); mock = true; } let qh = null; try { qh = await window.hems.getQuarterHistory(date); } catch(e){} if (cancelled) return; setState({ loading:false, data, qh, mock }); })(); return () => { cancelled = true; }; }, [date]); const { loading, data, qh, mock } = state; const sumS = data?.summary?.solar; const sumC = data?.summary?.consumption; const vS = vfVerdict(sumS); const vC = vfVerdict(sumC); const fkwh = (v) => v == null ? '—' : v.toFixed(1).replace('.',','); return (

Facit — höll prognosen?

prognos mot uppmätt utfall
{mock && !loading && demodata} {vfDayLabel(date)}
{loading ? (
) : (
{/* Betyg i mänskliga termer */}
{[['sun','Sol', sumS, vS, 'var(--solar)'], ['home','Förbrukning', sumC, vC, 'var(--home)']] .map(([icon, lbl, sum, v, color]) => (
{lbl}
{v.word ? `${v.word} — ${v.txt.charAt(0).toLowerCase()}${v.txt.slice(1)}` : v.txt}
{sum && sum.hours_with_data >= 6 && (
Prognos {fkwh(sum.forecast_kwh)} kWh · Utfall {fkwh(sum.actual_kwh)} kWh
)}
))}
)}
); } // ---- styles ------------------------------------------------------------ const __vfStyles = document.createElement('style'); __vfStyles.textContent = ` .vf-card .head{ flex-wrap: wrap; row-gap: 8px; } .vf-body{ display: grid; gap: 16px; padding: 14px 18px 18px; } .vf-picker{ display: inline-flex; align-items: center; gap: 8px; } .vf-nav{ width: 28px; height: 28px; border-radius: 8px; display: inline-flex; align-items: center; justify-content: center; font: 600 16px/1 Geist; color: var(--ink-1); cursor: pointer; background: rgba(255,255,255,.03); border: 1px solid var(--line); transition: background .15s, border-color .15s; } .vf-nav:hover:not(:disabled){ background: rgba(91,168,255,.08); border-color: rgba(91,168,255,.3); } .vf-nav:disabled{ opacity: .3; cursor: default; } .vf-date{ font-size: 12.5px; font-weight: 500; color: var(--ink); min-width: 116px; text-align: center; } .vf-mock-tag{ font-size: 9.5px; letter-spacing: .08em; text-transform: uppercase; color: var(--ink-3); border: 1px dashed var(--line-2); border-radius: 5px; padding: 2px 7px; margin-right: 4px; } .vf-verdicts{ display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } @media (max-width: 760px){ .vf-verdicts{ grid-template-columns: 1fr; } } .vf-verdict{ border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; background: rgba(255,255,255,.015); } .vf-verdict-l{ display: flex; align-items: center; gap: 7px; font-size: 10.5px; font-weight: 600; letter-spacing: .07em; text-transform: uppercase; color: var(--ink-3); } .vf-verdict-t{ margin-top: 7px; font-size: 14.5px; font-weight: 600; letter-spacing: -.01em; } .vf-verdict-s{ margin-top: 5px; font-size: 11px; color: var(--ink-2); } .vf-chart-head{ display: flex; align-items: baseline; gap: 12px; margin-bottom: 8px; flex-wrap: wrap; } .vf-chart-t{ display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px; font-weight: 600; color: var(--ink); } .vf-sub{ font-size: 11px; color: var(--ink-3); } .vf-legend{ margin-left: auto; display: inline-flex; gap: 14px; font-size: 10.5px; color: var(--ink-2); } .vf-legend > span{ display: inline-flex; align-items: center; gap: 6px; } .vf-line{ display: inline-block; width: 16px; } .vf-line.dash{ border-top: 2px dashed; opacity: .7; } .vf-line.solid{ height: 2px; border-radius: 2px; } .vf-svg{ display: block; width: 100%; height: auto; } .vf-band-wrap{ } .vf-band{ display: flex; height: 26px; border-radius: 7px; overflow: hidden; border: 1px solid var(--line); background: rgba(255,255,255,.015); } .vf-cell{ flex: 1; } .vf-band-axis{ display: flex; justify-content: space-between; margin-top: 5px; font-size: 9.5px; color: var(--ink-3); } .vf-band-legend{ margin-top: 9px; display: flex; flex-wrap: wrap; gap: 8px 16px; font-size: 11px; color: var(--ink-2); } .vf-band-legend > span{ display: inline-flex; align-items: center; gap: 6px; } .vf-band-legend .dot{ width: 8px; height: 8px; border-radius: 3px; display: inline-block; } .vf-band-legend b{ color: var(--ink-1); font-weight: 500; margin-left: 2px; } .vf-band-empty{ font-size: 12px; color: var(--ink-3); padding: 10px 0 2px; } .vf-skel-row{ display: flex; gap: 12px; } .vf-skel{ display: block; flex: 1; border-radius: 12px; background: linear-gradient(100deg, rgba(180,200,240,.05) 35%, rgba(180,200,240,.11) 50%, rgba(180,200,240,.05) 65%); background-size: 220% 100%; animation: vfShimmer 1.4s ease-in-out infinite; } @media (prefers-reduced-motion: reduce){ .vf-skel{ animation: none; } } @keyframes vfShimmer{ from{ background-position: 120% 0; } to{ background-position: -80% 0; } } `; document.head.appendChild(__vfStyles); Object.assign(window, { VerificationCard });