/* 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'; const GcFold = window.GcFold; // gc-core.jsx, laddas före denna fil function GcPrognoser({ day, tomorrow, units, nowIdx }){ const num = window.gcNum, qLabel = window.gcLabel; const [yd, setYd] = React.useState({ kind:'loading' }); /* Ett dygn i taget (André 24/9, mockup 3): Igår · Idag · Imorgon som textflikar, två kort sida vid sida. Valet sparas i webbläsaren. */ const [pDay, setPDay] = React.useState(() => { try{ return localStorage.getItem('gc-prog-day') || 'today'; }catch(_){ return 'today'; } }); const pickDay = (d) => { setPDay(d); try{ localStorage.setItem('gc-prog-day', d); }catch(_){} }; 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 } /* K5 (15/9, Andrés beslut 5): house_kwh = hus + smartlaster, utan bil och inmatning, uppskattat inräknat. load_kwh är ekonomins balans (bilen som hus, inmatning som minus) och används bara för dygn före flaggan (house_kwh null). */ : { kind:'ok', date:y, sol:d0.solar_kwh ?? null, cons: d0.house_kwh ?? d0.load_kwh ?? null, consIsHouse: d0.house_kwh != null, estKwh: d0.house_est_kwh ?? null, estQ: d0.house_est_quarters ?? 0, estReason: d0.house_est_reason ?? 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. */ /* Beräkningen bor i gc-core.jsx (`gcDayTotals`) sedan 21/9 — Schema-rutorna läser samma funktion, så de två vyerna inte kan visa olika soltal. Den kommenterade motiveringen (ärlig solprognos, mätaren som huvudtal, samma förflutna fönster för plan och utfall) ligger där. */ const today = React.useMemo(() => window.gcDayTotals(day, nowIdx), [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, parts = 0, prevOn = false; arr.forEach((x, i) => { const kw = x.loads?.[k]?.pPlan ?? x.loads?.[k]?.p ?? 0; const on = kw > 0.01; if (on){ kwh += kw*0.25; if (a == null) a = i; b = i + 1; if (!prevOn) parts++; } prevOn = on; }); return { name: u.name, kwh, a, b, parts }; }).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]); /* K4 (15/9): uppmätt per last ur /api/load-runs (dagens avslutade delar) och pågående ur live.load_runs_open (via day.loadRunsOpen). Timdocsens load_kwh används ALDRIG som uppmätt. */ const runs = window.useLoadRuns ? window.useLoadRuns(true) : null; /* Manuell körning (André 4/10, v778): dagens manuella energi (/api/load-manual day_kwh) står inte i history eller load_runs_open och läggs till, som i Schemas lastruta (v773). På = pågår. */ const [manual] = window.useLoadManual ? window.useLoadManual(true) : [null]; const todayKey = (() => { const d = window.hemsDate ? window.hemsDate() : new Date(); return d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0'); })(); const loadMeasured = React.useCallback((u) => { if (!u || u.reportsPower !== true || u.id == null) return null; const byLoad = runs && runs.enabled === true && runs.loads ? runs.loads[String(u.id)] : null; let done = 0, parts = 0, found = false; if (byLoad) Object.values(byLoad).forEach(w => (w?.history || []).forEach(h => { if (h && String(h.day_key) === todayKey && h.kwh != null){ done += Number(h.kwh) || 0; parts++; found = true; } })); const open = window.lwOpenRuns ? window.lwOpenRuns(day?.loadRunsOpen, u.id, todayKey) : []; const openKwh = open.reduce((s, r) => s + (Number(r.kwh) || 0), 0); const ma = window.lwManualAdd ? window.lwManualAdd(manual, runs, day?.loadRunsOpen, u.id) : null; /* Backend 4/10: ihop bara när den manuella tiden ligger utanför dagens körningar, annars separat (manSep). */ const add = ma && !ma.overlap ? ma.kwh : 0; if (!found && !open.length && !ma) return null; return { done: done + add, parts, open: open.length > 0 || !!(ma && ma.on), openKwh, total: done + openKwh + add, manSep: ma && ma.overlap ? ma.kwh : null }; }, [runs, day, todayKey, manual]); 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. */ /* K4: tre lägen per last (idag). FÖRE = plan, UNDER = hittills · plan, EFTER = uppmätt · plan. Delad körning: "N av M delar klara". Last utan reports_power: bara plan. Imorgon: alltid plan. */ const shareLine = (ls, isToday) => { if (!ls) return null; /* Talen vita, kWh efter planvärdet (Lennie 15/9): raden är en tokenlista där {v} renderas vitt. */ const W = (v) => ({ v }); const plan = (x) => [`${x.name}: plan `, W(num(x.kwh,1)), ` kWh ${qLabel(x.a % 96)}–${qLabel(x.b % 96)}`]; const one = (x) => { if (!isToday) return plan(x); const u = units.loads.find(l => l.name === x.name); const m = loadMeasured(u); if (!m) return plan(x); const parts = (x.parts || 1) > 1 ? ` · ${m.parts} av ${x.parts} delar klara` : ''; const cur = Math.max(0, Math.min(nowIdx ?? 0, 95)); const val = m.open ? m.total : m.done; const word = m.open ? 'hittills' : (cur >= x.b ? 'totalt' : 'hittills'); return [`${x.name}: `, W(num(val,1)), ` ${word} · `, ...(m.manSep != null ? [W(num(m.manSep,1)), ' manuellt · '] : []), 'plan ', W(num(x.kwh,1)), ' kWh' + parts]; }; return ['varav ', ...(ls.per.length === 1 ? one(ls.per[0]) : ['bokade laster ', W(num(ls.tot,1)), ' kWh'])]; }; /* K3: uppskattade kvartar idag (V2X-frys / sensorkarantän). */ const estLine = (he) => { if (!he || !he.quarters) return null; const h = (n) => num(n*0.25, 2).replace(/,?0+$/, '') + ' h'; const r = he.reasons || {}; const v2x = r.v2x || 0, other = he.quarters - v2x; const why = v2x >= other ? `bilen matade in ${h(v2x)}` : `sensorkarantän ${h(other)}`; return `varav ${num(he.kwh,1)} kWh uppskattat (${why})`; }; /* 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. */ /* Lennie 15/9: huvudraden är "hittills / prognos" där prognos = dagens live-uppdaterade förväntan (hittills + kvar). Raden under bara "kvar"; morgonprognosen står redan i diffraden. */ /* Solnedgång = sista kvarten idag med solprognos > 0 (ur dagens kvartar). Visas dämpat efter "kvar idag": "· till 19:30". */ const sunsetLbl = React.useMemo(() => { if (!day) return null; let last = -1; for (let i = 0; i < Math.min(day.length, 96); i++){ const s = day[i]; const v = s?.solPlan ?? s?.sol; if (v != null && v > 0.02) last = i; } return last >= 0 ? qLabel((last + 1) % 96) : null; }, [day]); const restLine = (x, extra) => x.act == null ? null : { kvar: x.rest != null ? `${num(x.rest,1)} kWh kvar idag` : null, tot: x.rest != null && extra ? extra : null, }; const bigVal = (x) => x.act != null ? x.act : x.plan; const bigUnit = (x) => x.act != null ? 'kWh' : 'kWh prognos'; const liveTot = (x) => x.act != null && x.rest != null ? x.act + x.rest : null; const cardsToday = [ { name:'Sol', col:GP_SOL, v:bigVal(today.sol), unit:bigUnit(today.sol), tot:liveTot(today.sol), rest: restLine(today.sol, sunsetLbl ? <>· till solnedgång {sunsetLbl} : null), diff: diffLine(today.sol.diff, (d) => d >= 0, today.sol.honest ? 'mot grundprognosen' : undefined), diffSub: today.sol.honest && today.sol.diffSub != null ? window.hemsVals(`${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', col:GP_CONS, v:bigVal(today.cons), unit:bigUnit(today.cons), tot:liveTot(today.cons), rest: restLine(today.cons), diff: diffLine(today.cons.diff, (d) => d <= 0), est: estLine(day.homeEst), share: shareLine(loadsToday, true) }, ]; const cardsTmrw = tmrw ? [ { name:'Sol', col:GP_SOL, v:tmrw.sol, unit:'kWh prognos', dim:true }, { name:'Förbrukning', col:GP_CONS, v:tmrw.cons, unit:'kWh prognos', dim:true, share: shareLine(loadsTmrw, false) }, ] : []; const cardsYd = [ { name:'Sol', col:GP_SOL, v:yd.kind === 'ok' ? yd.sol : null, unit:'kWh utfall', pending: yd.kind === 'loading' }, { name:'Förbrukning', col:GP_CONS, v:yd.kind === 'ok' ? yd.cons : null, unit:'kWh utfall', pending: yd.kind === 'loading', est: yd.kind === 'ok' && yd.estQ > 0 ? `varav ${num(yd.estKwh || 0,1)} kWh uppskattat (${yd.estReason === 'v2x' ? 'bilen matade in' : 'sensorkarantän'} ${num(yd.estQ*0.25,2).replace(/,?0+$/, '')} h)` : null }, ]; const dayTab = pDay === 'tomorrow' && !tmrw ? 'today' : pDay; const cards = dayTab === 'yesterday' ? cardsYd : dayTab === 'tomorrow' ? cardsTmrw : cardsToday; const utfallLbl = today.doneN ? `utfall t.o.m. ${qLabel(Math.min(nowIdx, day.length-1))}` : 'inget utfall än i dag'; /* v587: Prognoser-rubriken står till höger om dagvalen; "utfall t.o.m." flyttad in i Sol-kortets namnrad, bara under Idag (tiden gäller dagens utfall). */ return (
{[['yesterday','Igår'],['today','Idag'],['tomorrow','Imorgon']].map(([id, t]) => ( ))}
Prognoser
{cards.map((c) => (
{c.name} {dayTab === 'today' && c.name === 'Sol' && {utfallLbl}}
{c.pending ? '…' : num(c.v, 1)} {c.tot != null && <>/{num(c.tot, 1)}} {c.unit}
{c.rest && (
{c.rest.kvar} {c.rest.tot}
)} {c.diff && (
{c.diff.txt}
)} {c.est && (
{c.est}
)} {c.share && (
{c.share.map((s, k) => typeof s === 'string' ? s : {s.v})}
)} {/* 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. Bara under Igår. */} {dayTab === 'yesterday' &&

{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 });