/* flow-v2.jsx — energiflöde + dagens plan, kollegans komposition mot vår data. ──────────────────────────────────────────────────────────────────────── Porterat ur GravioApp.jsx OversiktView. Två skillnader mot prototypen, båda tvingande: 1. NODERNA RÄKNAS UR ENHETSLISTAN. Prototypen har tre batterier och två laddboxar på inskrivna koordinater. Hushållet har ett batteri och en laddbox. Satelliterna placeras därför på bågar runt centrum: sol uppe, hemmet uppe till vänster, elnätet nere, batterier på höger båge, laddboxar på nedre vänstra. Antalet ändrar bilden, inte layoutreglerna. 2. INGET RITAS SOM VI INTE HAR. Prototypens färg per laddbox (sol/nät/ batteri) bygger på ett fält som bara finns live och bara summerat över alla boxar. Den färgen sätts inte här. Se BACKEND-BEHOV nedan. Färgerna är våra CSS-variabler. Prototypens slate/emerald-palett följer inte med — två parallella färgsystem i samma app går inte att underhålla. */ const FV_MONO = { fontFamily: '"Geist Mono", ui-monospace, monospace' }; const FV_C = { x: 340, y: 340 }; const FV_R = 235; // avstånd centrum → satellit const DEG = Math.PI / 180; /* Layout: vår BEFINTLIGA flödesvys jämna cirkel (flow.jsx), inte prototypens inskrivna koordinater. Alla noder fördelas jämnt runt 360° oavsett antal — sol fixerad i topp, sedan medurs: batterier → elnät → laddboxar (baklänges) → hemmet. Cirkeln "andas" efter antalet enheter: 1 batteri + elnät + hem + 1 laddbox ger en femhörning, tre batterier + två laddboxar en oktagon. Prototypens innehåll per nod behålls (värde, enhet, namn, status). */ const fvPos = (deg) => ({ x: FV_C.x + Math.cos(deg*DEG)*FV_R, y: FV_C.y + Math.sin(deg*DEG)*FV_R }); function fvCircle(ids){ const step = 360 / ids.length; const out = {}; ids.forEach((id, i) => { out[id] = fvPos(-90 + i*step); }); return out; } const fvNum = (n, d=1) => n == null ? '—' : n.toLocaleString('sv-SE', { minimumFractionDigits:d, maximumFractionDigits:d }); const fvHHMM = (q) => String(Math.floor((q%96)/4)).padStart(2,'0')+':'+String(((q%96)%4)*15).padStart(2,'0'); // Orsaken (sol/nät/hus/export) kommer ur backendens beslut. Saknas den skrivs // bara riktningen — aldrig en påhittad orsak ur effektens tecken. const batStatusText = (bp) => { if (bp == null || bp.p == null) return 'okänt'; if (bp.p > 0.05) return bp.src === 'sol' ? 'laddar · sol' : bp.src === 'nät' ? 'laddar · nät' : 'laddar'; if (bp.p < -0.05) return bp.src === 'export' ? 'säljer' : bp.src === 'hus' ? 'täcker hus' : 'laddar ur'; return 'vilar'; }; /* ---- Sammanhängande intervall ------------------------------------------ Prototypens mkRanges, med en nödvändig skillnad: utjämningen får INTE användas på predikat som bär en orsak. Hålfyllningen (en avvikande kvart mitt i en körning räknas med) är rimlig för brusiga PROGNOSvärden — sol och förbrukning vandrar över en tröskel och tillbaka. Men för predikat som läser backendens `src` gör den ett positivt påstående om en kvart där backend angett något annat: kvart 84 säljer 6,76 kW till nätet och fylldes in i "batteriet täcker huset" (2026-08-15). smooth:false → varje kvart står för sig. minLen:1 → även en enstaka kvart redovisas, för händelser som inte kan vara brus (8,5 kW export i 15 min). */ function fvRanges(day, pred, opts){ const { smooth = true, minLen = 2 } = opts || {}; const b = day.map(pred); if (smooth) for (let i = 1; i < day.length-1; i++) if (!b[i] && b[i-1] && b[i+1]) b[i] = true; const out = []; let st = null; for (let i = 0; i <= day.length; i++){ const ok = i < day.length && b[i]; if (ok && st === null) st = i; if (!ok && st !== null){ if (i - st >= minLen) out.push([st, i]); st = null; } } return out; } const FvTimes = ({ rs }) => ( {rs.map(([a,b]) => `${fvHHMM(a)}–${fvHHMM(b)}`).join(', ')} ); function FlowV2({ day, units, nowIdx }){ const s = day && day[nowIdx] ? day[nowIdx] : null; if (!s) return null; const mode = window.GRAVIO_MODES[s.mode] || window.GRAVIO_MODES.vanta; /* Noder. Sol/hem/elnät alltid; batterier och laddboxar ur enhetslistan. Positionerna kommer ur den jämna cirkeln, aldrig ur fasta koordinater. */ const nodes = React.useMemo(() => { const batIds = units.bats.map((_, k) => 'bat'+k); const evIds = units.evs.map((_, k) => 'ev'+k); // Medurs från toppen: sol → batterier → elnät → laddboxar baklänges → hem. const order = ['sol', ...batIds, 'grid', ...evIds.slice().reverse(), 'home']; const P = fvCircle(order); /* Adaptern löser live/utfall/plan per kvart — den här vyn läste tidigare live själv, men GcOversikt skickar inget live-objekt och avläsningen föll tyst tillbaka på planen. Nu bär s.sol/s.cons redan mätvärdet vid NU. */ const solKw = s.sol, consKw = s.cons; const gIn = s.gridIn || 0, gOut = s.gridOut || 0; const out = []; out.push({ id:'sol', ...P.sol, r:54, col:'var(--c-charge-sol)', icon:'sol', val: fvNum(solKw), unit:'kW', name:'Sol', status: solKw > 0.05 ? 'producerar' : 'vilar', active: solKw > 0.05, dir:'in' }); units.bats.forEach((u, k) => { const bp = s.bat[k] || s.bat[0]; /* Per enhet bara när backend mäter per enhet. Ett banktal utslaget på varje nod hade sett ut som tre mätvärden. */ const pct = s.socPerUnit ? (s.socPct[k] ?? null) : (units.bats.length === 1 ? s.socPct[0] : null); // #3FCC8B = HEMS-styrt batteri i vår befintliga flödesvy. Samma färg här // så de två vyerna inte ger batteriet olika identitet. out.push({ id:'bat'+k, ...P['bat'+k], r:52, col:'#3FCC8B', /* Samma regel som gc-oversikt: SoC som huvudtal, effekt som andra rad. Båda behövs — nivån svarar "hur mycket finns kvar", effekten "vad gör det just nu". Tecknet följer gamla designen: minus = lämnar batteriet. De två kopiorna av flödet får inte ge batteriet olika huvudtal. */ val: pct != null ? String(Math.round(pct)) : '—', unit:'%', sub: !bp || bp.p == null || Math.abs(bp.p) <= 0.05 ? null : ((bp.p > 0 ? '+' : '−') + fvNum(Math.abs(bp.p)) + ' kW'), subCol: (bp && bp.p || 0) > 0 ? '#4ade80' : '#7dd3fc', name: bp?.aggregate ? 'Batterier (summa)' : u.name, status: batStatusText(bp), active: bp && bp.p != null && Math.abs(bp.p) > 0.05, dir: bp && bp.p > 0 ? 'out' : 'in' }); }); out.push({ id:'grid', ...P.grid, r:54, icon:'grid', col: gOut > 0.05 ? 'var(--c-sell)' : 'var(--c-charge-net)', val: fvNum(gOut > 0.05 ? gOut : gIn), unit:'kW', name:'Elnät', status: gOut > 0.05 ? 'säljer' : gIn > 0.05 ? 'köper' : 'vilar', active: gOut > 0.05 || gIn > 0.05, dir: gOut > 0.05 ? 'out' : 'in' }); units.evs.forEach((u, k) => { const e = s.ev[k]; const on = e && e.p != null && e.p > 0.05; /* V2X: negativ ev_power = bilen matar huset/husbatteriet (teckenbärande från sensorn hela vägen ut i /api/live-data). Då är boxen en KÄLLA, ekern vetter inåt och beloppet är absolutvärdet. Färgen är --c-cover, samma token som när batteriet täcker huset — samma innebörd. */ const feed = e && e.p != null && e.p < -0.05; // Källan finns nu per box (ev_source_split_list, 15/8) och färgar noden // som i prototypen. Utan källa: ingen påhittad färg, ingen påhittad text. const srcCol = { sol:'var(--c-charge-sol)', 'nät':'var(--c-charge-net)', batteri:'var(--c-cover)' }; out.push({ id:'ev'+k, ...P['ev'+k], r:48, icon:'ev', col: feed ? 'var(--c-cover)' : on ? (srcCol[e.src] || 'var(--c-charge-net)') : 'var(--line)', /* TECKNET FÖLJER MED (foto 27/8: ringen sa 15,0 medan beslutsraden sa −14,99 kW). Samma regel som batterinoden intill: minus = effekten LÄMNAR enheten. Ett obetecknat tal i ringen läste som laddning. */ val: e && e.p != null ? (feed ? '−' : '') + fvNum(Math.abs(e.p)) : '—', unit:'kW', name: u.name, status: e && e.p == null ? 'ingen plan' : feed ? 'matar huset' : on ? (e.src ? 'laddar · '+e.src : 'laddar') : 'ingen laddning', active: on || feed, dir: feed ? 'in' : 'out' }); }); out.push({ id:'home', ...P.home, r:52, col:'var(--ink-2)', icon:'home', val: fvNum(consKw), unit:'kW', name:'Hemmet', status:'förbrukar', active:true, dir:'out' }); return out; }, [s, units]); /* Dagens plan i klartext. Varje rad läser bara vad som är planerat. */ const plan = React.useMemo(() => { // Urladdning MED angiven orsak. Samma regel som batStatusText: en orsak // får bara skrivas ut när backend gett den. Utan src-kravet renderades // kvartar med action 'vanta' som "batteriet täcker huset" (2026-08-15). const covers = (x) => x.bat.some(b => b.p != null && b.p < -0.05 && b.src === 'hus'); const disch = (x) => x.bat.some(b => b.p != null && b.p < -0.05); const chg = (x, src) => x.bat.some(b => b.p != null && b.p > 0.05 && b.src === src); const gq = day.filter(x => chg(x, 'nät')); // ORSAKSBUNDNA predikat: ingen utjämning (CAUSE). PROGNOSBUNDNA: utjämning. const CAUSE = { smooth:false }; return { gridAvg: gq.length ? Math.round(gq.reduce((a,x)=>a+(x.buy||0),0)/gq.length) : null, solCh: fvRanges(day, x => chg(x, 'sol'), CAUSE), gridCh: fvRanges(day, x => chg(x, 'nät'), CAUSE), solCov: fvRanges(day, x => x.sol != null && x.cons != null && x.sol >= x.cons - 0.05), batCov: fvRanges(day, x => x.sol != null && x.cons != null && x.sol < x.cons - 0.05 && covers(x), CAUSE), // Planerad försäljning till nätet. minLen 1: en 15-minuters export på // 8,5 kW är dygnets största händelse, inte brus — den får inte kastas. batSell: fvRanges(day, x => x.bat.some(b => b.p != null && b.p < -0.05 && b.src === 'export'), { smooth:false, minLen:1 }), // "Varken sol eller batteri" måste utesluta ALL urladdning, inte bara den // med orsak 'hus' — annars kan en kvart hamna i båda raderna. none: fvRanges(day, x => x.sol != null && x.cons != null && x.sol < x.cons - 0.05 && !disch(x) && !chg(x, 'nät')), evR: units.evs.map((_, k) => fvRanges(day, x => x.ev[k]?.p != null && x.ev[k].p > 0.05, CAUSE)), loadR: units.loads.map((_, k) => fvRanges(day, x => x.loads[k]?.p != null && x.loads[k].p > 0.05, CAUSE)), // solar_kwh/energy_kwh → sol / nät / blandat. Saknas fälten: ingen parentes. loadSrc: units.loads.map((_, k) => day.find(x => x.loads[k]?.src)?.loads[k]?.src || null), }; }, [day, units]); return (
{window.gcReason(s.reason, mode.desc)}
Batteriet laddas
{plan.solCh.length > 0 && <> av sol
{plan.solCov.length > 0 && <>Solen täcker huset
Batteriet säljer till nätet
Varken sol eller batteri kan täcka huset
{units.loads[k].name} körs
{units.evs[k].name} laddas