/* gc-prognoser.jsx — "Prognoser" längst ner på Översikt (kollegans avsnitt, GravioApp.jsx rad 1240–1276). ---------------------------------------------------------------------------- Sex kort: sol och förbrukning för idag, imorgon och igår. Poängen är att se hur dygnet blev — utfall mot prognos — på ett ställe. Datakällor, och varför de skiljer sig: idag `day` — solPlan/consPlanTot är prognos, solAct/consAct är mätvärden per kvart. Bara MÄTTA kvartar räknas, och pågående kvart skalas med actPartial: annars jämförs ett halvt dygns utfall mot ett helt dygns prognos. imorgon `tomorrow` — bara prognos, inget utfall finns. igår /api/economics/comparison days[0].solar_kwh / load_kwh — UPPMÄTT dygnssumma. Egen hämtning: sidans `comparison` styrs av statistikvyns navigering och kan peka var som helst. AVSTEG från hans fil: han visar "±X kWh mot prognos" även för igår, ur ett simulerat prognosvärde. Backend sparar inte gårdagens prognos — bara utfallet. Ett sådant tal skulle vara påhittat, så gårdagskorten visar utfallet och säger i stället varför jämförelsen inte finns. Se BACKEND_BEHOV. */ const GP_SOL = '#eab308', GP_CONS = '#e2e8f0'; function GcPrognoser({ day, tomorrow, units, nowIdx }){ const num = window.gcNum, qLabel = window.gcLabel; const [yd, setYd] = React.useState({ kind:'loading' }); React.useEffect(() => { /* Lokala datumfält, ALDRIG toISOString: den ger UTC, och mellan 00:00 och 02:00 svensk sommartid ligger UTC-datumet ett dygn back — då hämtade korten FÖRRGÅRDAGEN under etiketten "igår". setDate(-1) hanterar också månadsskifte och sommartidsövergång. Samma formatterare som stats-v2.svISO; hemsDate() är hushållets klocka. */ const d = window.hemsDate ? window.hemsDate() : new Date(); d.setDate(d.getDate() - 1); const y = d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0'); /* gcFetchReady, inte ett eget try/catch: komponenten monteras före bootHems satt nyckeln, och ett kast med noKeyYet är övergående. */ return window.gcFetchReady(() => window.hems.getEconomicsComparison(y, y, 0), { onOk: (c) => { const d0 = (c && c.days && c.days[0]) || null; setYd(!d0 || d0.missing ? { kind:'none', date:y } : { kind:'ok', date:y, sol:d0.solar_kwh ?? null, cons:d0.load_kwh ?? null, partial: (d0.coverage_quarters ?? 96) < 96, q: d0.coverage_quarters ?? null }); }, onFail: () => setYd({ kind:'error', date:y }), }); }, []); /* Idag: prognos för hela dygnet, plus utfall och avvikelse över de kvartar som faktiskt är mätta. Avvikelsen räknas ur de AVRUNDADE talen, så raden alltid stämmer med siffrorna intill. ALLT normaliseras till FÖRFLUTEN KLOCKTID. `actPartial` (= w/900) används BARA på pågående kvart, där den betyder förfluten andel. På dygnets mätluckor betyder den täckning, och då är den fel att skala med: - som vikt på `act` underskattar den dygnet (23/8: 69,1 kWh sol visat, 75,4 faktiskt gett, prognos 75,4) - som vikt på `base` blir plan − base summan av mätluckorna, inte framtiden (21:45 stod korten på "6,3 kWh sol kvar" med solen nere) - blandat blir värst: 23/8 visade förbrukningskortet 26,4 hittills och 2,4 kvar av 35,6, alltså −6,8 mot prognos — men avvikelseraden sa −5,8, en kWh användaren inte kunde räkna sig till. `solAct`/`consAct` är MEDELEFFEKT över kvartens mätta sekunder, så rate × hel kvart är rätt energi även i en lucka. Följden: plan − base = kvar exakt (samma p i båda leden), och de fyra talen på kortet — hittills, kvar, prognos, avvikelse — går ihop. Kvar räknas ALDRIG som plan − utfall: det blandar in dagens prognosfel i det som återstår, så en solig förmiddag skulle radera eftermiddagens sol. Avvikelsen räknas ur de AVRUNDADE talen, så raderna aldrig glider isär. */ const today = React.useMemo(() => { if (!Array.isArray(day) || !day.length) return null; const sum = (arr, f) => arr.reduce((a,x) => a + (f(x) || 0), 0); const done = day.filter(x => x.solAct != null || x.consAct != null); const cur = Math.max(0, Math.min(nowIdx ?? 0, day.length - 1)); const p = day[cur].actPartial ?? 1; /* Förfluten tid: hela kvartar före nu + den förflutna andelen av nu-kvarten. Samma fönster för plan och utfall — därför kan de jämföras. */ const elapsed = (f) => sum(day.slice(0, cur), f) + (f(day[cur]) || 0)*p; const mk = (planF, actF) => { const plan = sum(day, x => planF(x)*0.25); if (!done.length) return { plan, act:null, diff:null }; const base = elapsed(x => planF(x)*0.25); const act = elapsed(x => (actF(x) ?? 0)*0.25); const r1 = (v) => Math.round(v*10)/10; return { plan: r1(plan), act, base, rest: Math.max(0, r1(plan) - r1(base)), diff: r1(act) - r1(base) }; }; const base = { sol: mk(x => x.solPlan || 0, x => x.solAct), cons: mk(x => (x.consPlanTot ?? x.consPlan ?? 0), x => x.consAct), doneN: done.length, }; /* ÄRLIG SOLPROGNOS (SPEC 2026-08-26). Den mergade serien (passerade timmar överskrivna med utfall) lät prognosen "träffa" 100 % vid dygnets slut — prognos och utfall är TVÅ serier och jämförs som två: · hittills = MÄTAREN (live solar_today_kwh, null-bevarande) — samma tal som app-headern och Flöde; Σ actual som fallback · kvar = Σ forecast_pure från och med PÅGÅENDE timme, minus det som redan mätts i den (klampat 0) — ingen timme mellan stolarna · i morse = Σ frozen_day_hourly, 06-ankaret som aldrig rör sig intradag · diff = parvis på mätta timmar: Σ actual[h] − Σ frozen[h] frozen=null (ny user utan ledger) ⇒ morgonrad + diff döljs, resten visas. Saknas honest helt ⇒ exakt gamla beräkningen ovan. */ const H = day.solarHonest; if (H && Array.isArray(H.actual)){ const r1 = (v) => Math.round(v*10)/10; const nowH = Math.floor(cur/4); const meas = H.actual.map((v,h) => ({v,h})).filter(x => x.v != null); const hittMeas = meas.reduce((a,x) => a + x.v, 0); /* HUVUDTALET ÄR MÄTAREN (SPEC 2026-08-27). honest.actual_hourly är per definition "hela timmar upp till 30 min sedan" (L2-cache 30 min + bara hela timmar) — den släpar 1–2 h, mest mitt på dagen; 27/8 visade kortet 50,4 när mätaren stod på 57,0. solar_today_kwh är samma tal som app-headern och Flöde visar ⇒ alla tre visar nu samma siffra. Fallback: mätare → Σ actual (mätta) → prognos (act=null). */ const liveKwh = day.solarLiveKwh; const hitt = liveKwh ?? (meas.length ? hittMeas : null); /* KVAR från och med PÅGÅENDE timme — pure.slice(nowH+1) lämnade en hel timme mellan stolarna (32,2 + 27,5 ≠ 71,8-frågan). Ur nu-timmens prognos dras det som redan mätts i timmar honest inte har (hitt − Σ mätta = timme 15 + del av 16 i exemplet), klampat på 0. Invariant: hittills + kvar = dagens förväntade utfall. */ const hasPure = Array.isArray(H.pure); let kvar = null; if (hasPure){ const kvarEfter = H.pure.slice(nowH+1).reduce((a,v) => a + (v||0), 0); const extra = hitt != null ? Math.max(0, hitt - hittMeas) : 0; kvar = kvarEfter + Math.max(0, (H.pure[nowH] || 0) - extra); } const morgon = Array.isArray(H.frozen) ? H.frozen.reduce((a,v) => a + (v||0), 0) : null; const frozenElapsed = morgon != null ? meas.reduce((a,x) => a + (H.frozen[x.h]||0), 0) : null; const totPure = hasPure ? H.pure.reduce((a,v) => a + (v||0), 0) : null; /* DYGNSDIFFEN är huvudraden: "(hittills + kvar) − i morse" svarar på det man frågar kortet ("dagen ser ut att bli X mot i morse"). Den parvisa på mätta timmar blev obegriplig som huvudrad (+2,5 när dagen låg −10,8) — kvar som dim underrad. Räknat ur AVRUNDADE tal så raderna stämmer med siffrorna intill (filens regel). */ const dygnsdiff = (hitt != null && kvar != null && morgon != null) ? r1(r1(hitt) + r1(kvar) - r1(morgon)) : null; base.sol = { honest: true, act: hitt != null ? r1(hitt) : null, rest: kvar != null ? r1(kvar) : null, plan: morgon != null ? r1(morgon) : (totPure != null ? r1(totPure) : null), morgon: morgon != null ? r1(morgon) : null, diff: dygnsdiff, diffSub: (morgon != null && meas.length) ? r1(r1(hittMeas) - r1(frozenElapsed)) : null, acc: H.accuracy || null, }; } return base; }, [day, nowIdx]); const loadShare = React.useCallback((arr) => { if (!Array.isArray(arr) || !arr.length || !units || !units.loads?.length) return null; const per = units.loads.map((u, k) => { let kwh = 0, a = null, b = null; arr.forEach((x, i) => { const kw = x.loads?.[k]?.p || 0; if (kw > 0.01){ kwh += kw*0.25; if (a == null) a = i; b = i + 1; } }); return { name: u.name, kwh, a, b }; }).filter((x) => x.kwh > 0.05); if (!per.length) return null; return { tot: per.reduce((acc, x) => acc + x.kwh, 0), per }; }, [units]); const tmrw = React.useMemo(() => { if (!Array.isArray(tomorrow) || !tomorrow.length) return null; const sum = (f) => tomorrow.reduce((a,x) => a + (f(x) || 0)*0.25, 0); return { sol: sum(x => x.solPlan || 0), cons: sum(x => (x.consPlanTot ?? x.consPlan ?? 0)) }; }, [tomorrow]); const loadsToday = React.useMemo(() => loadShare(day), [loadShare, day]); const loadsTmrw = React.useMemo(() => loadShare(tomorrow), [loadShare, tomorrow]); if (!today) return null; const diffLine = (d, good, lbl) => d == null ? null : { txt: `${d >= 0 ? '+' : '−'}${num(Math.abs(d),1)} kWh ${lbl || 'mot prognos'}`, col: good(d) ? '#4ade80' : '#fb923c', }; /* Träffsäkerhet 0–1 ⇒ %; null ⇒ "–", ALDRIG 100 %. Beräknas nattligt. */ const pct = (v) => v == null ? '–' : Math.round(v*100) + ' %'; /* "varav N kWh 14–16". Med fler än två laster slås de ihop — raden ska vara en rad, och det viktiga är hur mycket av siffran som är bokat. */ const shareLine = (ls) => { if (!ls) return null; const one = (x) => `${x.name}: ${num(x.kwh,1)} kWh ${qLabel(x.a % 96)}–${qLabel(x.b % 96)}`; return 'varav ' + (ls.per.length === 1 ? one(ls.per[0]) : `bokade laster ${num(ls.tot,1)} kWh`); }; /* Stora talet på idag-korten är UTFALLET hittills — det är dagens fakta. Prognosen finns kvar som kontext på raden under, tillsammans med det som återstår av den. Före första mätta kvarten finns inget utfall att visa, då står prognosen kvar som huvudtal. */ const restLine = (x) => x.act == null ? null : { kvar: x.rest != null ? `${num(x.rest,1)} kWh kvar` : null, tot: x.honest && x.morgon != null ? `i morse ${num(x.morgon,1)}` : x.plan != null ? `prognos ${num(x.plan,1)}` : null, }; const bigVal = (x) => x.act != null ? x.act : x.plan; const bigUnit = (x) => x.act != null ? 'kWh hittills' : 'kWh prognos'; const cards = [ { name:'Sol idag', col:GP_SOL, v:bigVal(today.sol), unit:bigUnit(today.sol), rest: restLine(today.sol), diff: diffLine(today.sol.diff, (d) => d >= 0, today.sol.honest ? 'mot morgonprognosen' : undefined), diffSub: today.sol.honest && today.sol.diffSub != null ? `${today.sol.diffSub >= 0 ? '+' : '−'}${num(Math.abs(today.sol.diffSub),1)} kWh hittills mot morgonens timmar` : null, acc: today.sol.honest ? (today.sol.acc || {}) : undefined, dq: (() => { const q = day.dataQuality; if (!q) return null; const bits = []; /* Räknarna är PINGAR, inte timmar — orden ska säga det. "1 obalanserade" var fel böjning i singularfallet. */ if (q.nightSolar) bits.push(`${q.nightSolar} ${q.nightSolar === 1 ? 'mätning' : 'mätningar'} med nattsol`); if (q.balance) bits.push(`${q.balance} ${q.balance === 1 ? 'obalanserad mätning' : 'obalanserade mätningar'}`); if (!bits.length) return null; /* "idag" bara när räknarna verkligen gäller dagens dygn — annars gäller de historikens senaste dygn (mock/släpande historik). */ /* Suffixet böjs mot TOTALEN ("1 … utesluten", "3 … uteslutna"), och gäller räknarna INTE dagens dygn skrivs DYGNET ut. Odaterad text på ett kort som heter "Sol idag" kan bara läsas som dagens uteslutningar — samma tysta lögn som null-bevarandet och "aldrig 100 %" finns för att förhindra. Inträffar skarpt så snart historiken släpar ett dygn eller vakten slog till i går men inte i dag. */ const tot = (q.nightSolar || 0) + (q.balance || 0); const när = q.isToday ? ' idag' : q.date ? ' ' + new Date(q.date + 'T12:00:00').toLocaleDateString('sv-SE', { day:'numeric', month:'long' }) : ' tidigare dygn'; return bits.join(' · ') + (tot === 1 ? ' utesluten' : ' uteslutna') + ' ur facit' + när; })() }, { name:'Förbrukning idag', col:GP_CONS, v:bigVal(today.cons), unit:bigUnit(today.cons), rest: restLine(today.cons), diff: diffLine(today.cons.diff, (d) => d <= 0), share: shareLine(loadsToday) }, ...(tmrw ? [ { name:'Sol imorgon', col:GP_SOL, v:tmrw.sol, unit:'kWh prognos', dim:true }, { name:'Förbrukning imorgon', col:GP_CONS, v:tmrw.cons, unit:'kWh prognos', dim:true, share: shareLine(loadsTmrw) }, ] : []), { name:'Sol igår', col:GP_SOL, v:yd.kind === 'ok' ? yd.sol : null, unit:'kWh utfall', pending: yd.kind === 'loading' }, { name:'Förbrukning igår', col:GP_CONS, v:yd.kind === 'ok' ? yd.cons : null, unit:'kWh utfall', pending: yd.kind === 'loading' }, ]; return (
Prognoser {today.doneN ? `utfall t.o.m. ${qLabel(Math.min(nowIdx, day.length-1))}` : 'inget utfall än i dag'}
{cards.map((c) => (
{c.name}
{c.pending ? '…' : num(c.v, 1)} {c.unit}
{c.rest && (
{c.rest.kvar} {c.rest.tot}
)} {c.diff && (
{c.diff.txt}
)} {c.diffSub && (
{c.diffSub}
)} {c.share && (
{c.share}
)} {c.acc !== undefined && (
{`Igår: ${pct(c.acc.day)} träff · 7 dagar: ${pct(c.acc.d7)} · 30 dagar: ${pct(c.acc.d30)}`}
)} {/* DATAKVALITET (backend 27/8): karantänerade pingar räknas bort ur solfacit och prognosträff, så utfallet kan ligga under mätaren utan räknefel. Sitter under träffraden eftersom det är just de talen den förklarar. Visas bara när något faktiskt hoppats över. */} {c.dq && (
{c.dq}
)}
))}
{/* Gårdagskorten visar utfall utan avvikelse — säg varför, en gång. */}

{yd.kind === 'ok' && yd.partial ? `Igår är uppmätt över ${yd.q} av 96 kvartar. Gårdagens prognos sparas inte, så avvikelsen kan inte visas.` : yd.kind === 'ok' ? 'Igår är uppmätt. Gårdagens prognos sparas inte, så avvikelsen kan inte visas.' : yd.kind === 'none' ? 'Inget mätt dygn för igår.' : yd.kind === 'error' ? 'Kunde inte hämta gårdagens utfall.' : 'Hämtar gårdagens utfall…'}

); } Object.assign(window, { GcPrognoser });