/* gravio-adapter.jsx — kollegans prototypform ur våra backend-payloads ──────────────────────────────────────────────────────────────────── Kollegans vyer läser alla samma struktur: `today[96]`, ett objekt per kvart med sol/cons/spot/bat/socs/ev/loads/gridIn/gridOut/mode. I prototypen produceras den av `buildDay()`, som hittar på allt ur en sinusfunktion. Här byggs den ur riktig data. KÄLLKONTRAKT (SPEC_DESIGN_BACKEND_KOPPLING 2026-08-15, staging-revision hems-staging-01370-nok). Två saker styr allt nedan: 1. PASSERAT ÄR UTFALL, PLANERAT ÄR PLAN. quarterly_decisions energifält (charge/discharge/grid_import/grid_export/solar_export) fylls från planens startkvart och framåt. För passerade kvartar är de 0 — inte för att inget hände, utan för att posten inte är en plan. Utfall per kvart läses ur /api/history consumption[].quarters[]. (Tidigare härledde den här filen effekt ur SoC-deltat och nätflöde ur energibalansen. Båda är borta: de kompenserade för att fel källa lästes för passerade kvartar.) 2. ORDNING ÄR KONTRAKT. batterier = settings.batteries, laddboxar = settings.ev_chargers, laster = settings.deferrable_loads. Nyckel finns med värde null = ingen data → dölj, aldrig fel. Regeln genom hela filen: backend bestämmer VAD som står där, kollegans fil bestämmer HUR det ser ut. Finns ett värde inte blir fältet null och gravioCoverage säger varför. En vy som får null döljer sitt element. */ const GQ = 96; /* ---- Driftlägen --------------------------------------------------------- Kollegans MODES har fem lägen, vi har tio. Mappningen skrivs ut explicit — utan den föll våra fem vanligaste actions tyst till 'vanta'. Färgerna är VÅRA CSS-variabler, inte prototypens slate/emerald-palett. */ const GRAVIO_MODES = { buy: { label:'Köp nät', color:'var(--c-charge-net)', desc:'Vi köper från nätet — sol och batteri räcker inte just nu.' }, sellsol: { label:'Sälj sol', color:'var(--c-sell)', desc:'Vi säljer sol direkt — bättre nu än senare.' }, batexport: { label:'Sälj batteri', color:'var(--c-export)', desc:'Batteriet tömmer till nätet — säljpriset är bra just nu.' }, solcharge: { label:'Ladda sol', color:'var(--c-charge-sol)', desc:'Solöverskottet laddar batteriet inför kvällen.' }, battery: { label:'Täck huset', color:'var(--c-cover)', desc:'Batteriet driver huset — vi undviker dyrt köp.' }, peak: { label:'Peak shaving', color:'var(--c-peak)', desc:'Batteriet skyddar mot effektavgift på fakturan.' }, vanta: { label:'Vänta', color:'var(--c-wait)', desc:'Inget bättre läge just nu — vi sparar batteriet.' }, nollexport: { label:'Nollexport', color:'var(--c-zero)', desc:'Säljpriset är negativt — sol kastas istället.' }, ai_styrt: { label:'AI-styrt', color:'var(--c-observe)', desc:'Annat system styr — HEMS rör inte batteriet.' }, oforandrat: { label:'Oförändrat', color:'var(--c-unchanged)', desc:'Ingen ändring — fortsätter med tidigare beslut.' }, }; const ACTION_TO_MODE = { ladda_nat:'buy', ladda_sol:'solcharge', tack_huset:'battery', salj_sol:'sellsol', salj_batteri:'batexport', peak:'peak', vanta:'vanta', nollexport:'nollexport', ai_styrt:'ai_styrt', oforandrat:'oforandrat', buy:'buy', sellsol:'sellsol', batexport:'batexport', solcharge:'solcharge', battery:'battery', // Backendens råa action-namn (per_battery_actions m.fl.) selfconsumption:'battery', chargesolar:'solcharge', chargegrid:'buy', export:'batexport', observe:'ai_styrt', idle:'vanta', pause:'vanta', /* sellsolar = SOL som säljs direkt, inte batteriexport. 17 av 192 kvartar hade namnet 25/8 och föll på fallbacken till "Vänta": lägesringen färgade 9 % av dygnet fel, kvartsrubriken sa "Vänta", och PLANERAT-raden skrev "Vänta" om en kvart vars reason var "LP: export | hysteres: export 10.0→sellsolar 0.0". Skilj det från 'export'/'batexport', som ÄR batteriurladdning. */ sellsolar:'sellsol', }; const _unknownActions = new Set(); function gravioMode(action){ const m = ACTION_TO_MODE[action]; if (m) return m; // Tyst fallback är hur en tidigare defekt kunde leva. Logga en gång per namn. if (action && !_unknownActions.has(action)){ _unknownActions.add(action); console.warn('[gravio-adapter] okänd action utan lägesmappning:', action, '→ visas som "Vänta"'); } return 'vanta'; } // Batteriets källa/riktning härleds ur backendens EGET beslut, aldrig ur // effektens tecken — annars går "peak" och "täck huset" inte att skilja åt. const BAT_SRC = { /* sellsolar med FLIT utan post: solen går till nätet, batteriet är inte inblandat. Kvart 30:s reason säger det själv — "För liten export för att löna sig (0.11 kWh) — batteriet vilar". En källa här hade satt en batteririktning som inte finns. */ ladda_sol:'sol', solcharge:'sol', chargesolar:'sol', ladda_nat:'nät', buy:'nät', chargegrid:'nät', tack_huset:'hus', battery:'hus', peak:'hus', selfconsumption:'hus', salj_batteri:'export', batexport:'export', export:'export', }; /* Vilka action-namn betyder att energi LÄMNAR batteriet. Både backendens råa namn (per_battery_actions) och de mappade — samma mängd, två vokabulärer. 'sellsolar' hör INTE hit: det är sol som säljs direkt, inte batteriurladdning. Med den i mängden hade batteriets kommando fått negativt tecken i 17 kvartar där batteriet vilar. */ const BAT_DISCH_ACT = new Set(['selfconsumption','export','peakshaving','discharge', 'battery','batexport','tack_huset','peak','salj_batteri']); /* ---- Enheter ------------------------------------------------------------ Prototypen hårdkodar tre batterier och två laddboxar på inskrivna koordinater. Vi har N av varje. Varje vy som ritar noder MÅSTE räkna positioner ur den här listan — aldrig ur index 0/1/2. */ function gravioUnits(settings, live){ const s = settings || {}; const l = live || {}; // settings.batteries[] är per enhet och är ordningskontraktet. Saknas den // är hushållet ett-batteri och battery_capacity_kwh är den enhetens kapacitet. const bl = Array.isArray(s.batteries) ? s.batteries : null; const socList = Array.isArray(l.soc_list) ? l.soc_list : null; const powList = Array.isArray(l.battery_power_list) ? l.battery_power_list : null; const acts = Array.isArray(l.per_battery_actions) ? l.per_battery_actions : null; const totCap = Number(s.battery_capacity_kwh) || null; const n = bl?.length || socList?.length || 1; const bats = Array.from({length:n}, (_, k) => { const b = bl ? (bl[k] || {}) : null; const ai = b ? b.ai_controlled === true : (acts?.[k]?.ai_controlled === true); return { id: 'bat'+k, // AI-styrt batteri ligger sist och styrs inte av HEMS. name: n === 1 ? 'Batteri' : (ai ? 'Batteri '+(k+1)+' · extern AI' : 'Batteri '+(k+1)), cap: b ? (Number(b.capacity_kwh) || null) : (n === 1 ? totCap : null), pMax: b ? (Number(b.discharge_rate_kw) || Number(b.charge_rate_kw) || null) : null, aiControlled: ai, }; }); const chargers = (window.evModel?.normalize ? window.evModel.normalize(s) : []); /* VILKEN BIL boxen laddar, inte bara boxens namn. Kontraktet har settings.ev_cars[] och två pekare mot den: sessionens car_id (bilen som står i boxen NU) och boxens default_car_id (förvalet). Sessionen vinner — samma ordning som gc-bil och som backend läser den. carName är null när hushållet inte har någon bil registrerad; då faller varje vy tillbaka på boxens namn, aldrig på ett hittat "Bil 1". */ const carList = Array.isArray(s.ev_cars) ? s.ev_cars.filter(x => x && x.id != null) : []; const evs = chargers.map((c, i) => { const sess = window.evModel?.sessionFor ? window.evModel.sessionFor(s, c, i) : null; const carId = sess?.car_id ?? c.default_car_id ?? null; const car = carId != null ? carList.find(x => String(x.id) === String(carId)) : null; return { id: c.id, /* Brytaren på Bil-fliken (ev_chargers[].active). Saknat eller true = aktiv. Listan behåller ALLA boxar — den är positionsbunden mot ev_power_list — men en avstängd box får inga planerade kvartar. */ active: c.active !== false, name: (window.evModel?.displayName ? window.evModel.displayName(c, i) : (c.name || 'Laddbox '+(i+1))), carId: car ? car.id : null, carName: car && car.name ? String(car.name).trim() || null : null, cfg: c, }; }); const loads = (Array.isArray(s.deferrable_loads) ? s.deferrable_loads : []) .filter(x => x && x.enabled !== false) .map((x, i) => ({ id: x.id ?? 'load'+i, name: x.name || 'Last '+(i+1), power_kw: Number(x.power_kw) || 0, reportsPower: x.reports_power === true })); const bankCap = bats.length && bats.every(b => b.cap > 0) ? bats.reduce((acc, b) => acc + b.cap, 0) : totCap; return { bats, evs, loads, totCap, /* Nämnaren för bankens SoC. totCap (battery_capacity_kwh) är ett toppfält och behöver inte vara summan av batteries[].capacity_kwh — är enheternas kapaciteter kända vinner deras summa, för det är den vi räknar mot. */ bankCap, // 'per-unit' = varje batteri kan ritas för sig (live). 'aggregate' = bara // summan; rita EN nod, inte N lika stora delar. batteryDetail: (powList && powList.length === n && n > 1) ? 'per-unit' : 'aggregate', // Planen gäller det AGGREGERADE HEMS-batteriet — per-enhet-plan finns inte. planDetail: 'aggregate', }; } /* ---- Utfall per kvart ur /api/history ---------------------------------- Ett doc per timme med quarters[0..3] i W-medel. null = kvarten har inte kommit än. Pågående kvart är delvis fylld: w = ackumulerade sekunder av 900. */ function _actualsByQuarter(history, dateStr){ const out = {}; for (const row of (history?.consumption || [])){ if (dateStr && row.date !== dateStr) continue; const h = row.hour; if (!(h >= 0 && h < 24)) continue; const qs = Array.isArray(row.quarters) ? row.quarters : null; if (qs){ qs.forEach((q, qi) => { if (!q) return; out[h*4 + qi] = { home: q.home, sol: q.sol, grid: q.grid, bat: q.bat, soc: q.soc, partial: q.w != null && q.w < 890 ? (q.w/900) : null, }; }); } else if (row.home_consumption != null){ // Bara timmedel: sprid på timmens fyra kvartar (markeras som timupplöst). for (let qi = 0; qi < 4; qi++) out[h*4 + qi] = { home: row.home_consumption, sol: row.solar_power, grid: row.grid_power, bat: row.battery_power, soc: row.soc, hourly: true, }; } } return out; } /* ---- EV-plan per laddbox ------------------------------------------------ ev_per_car[] är per box med kwh[] indexerat på globalt kvartindex (läs bara index < 192; vektorn kan vara 288 lång vid 3-dygnshorisont). Sparse: null när planen inte är multi-EV, och null om backend raderat den vid inkonsistens → falla ALLTID tillbaka på ev_plan. MATCHNING SKER PER POSITION (kontrakt §11, KONTRAKT_HOMEY_APP_LASTER_EV §6). Kanoniskt laddbox-id är ev_1, ev_2, … = 1-indexerad position i settings.ev_chargers. Det som ligger i settings[].id kan vara den gamla dashboardens 0-indexerade ev0/ev1 — backend kanoniserar vid inläsning, så "ev0 i settings men ev_1 i ev_per_car" är väntat, inte ett glapp. Råa id-strängen får därför INTE användas för matchning. */ const canonChargerId = (i) => 'ev_' + (i + 1); function _evPlanByCharger(analysis, units, evLoad){ const n = units.evs.length; const out = Array.from({length:n}, () => ({})); /* AVSTÄNGD LADDBOX RITAS INTE (3/9). Brytaren på Bil-fliken skriver ev_chargers[].active, och den lästes BARA av gc-bil.jsx — planen, schemat, hjulet och flödet kände inte till den. Användarna stängde av laddboxen och såg bilen ligga kvar i planen. Kontraktet: saknat eller true = aktiv, false = boxen planeras inte (BACKEND_BEHOV, rev 01380). Filtret ligger HÄR och inte i units.evs: listan är POSITIONSBUNDEN mot ev_power_list och ev_per_car, så att korta ner den hade flyttat live- effekten till fel bil. En avstängd box behåller sin plats och får noll kvartar. */ const inactive = (k) => units.evs[k] && units.evs[k].active === false; const per = analysis?.ev_per_car; let matched = 0, offered = 0; if (Array.isArray(per) && per.length){ offered = per.length; per.forEach((entry, pos) => { if (!Array.isArray(entry?.kwh)) return; const cid = String(entry.charger_id ?? ''); // 1) kanoniskt id ↔ position, 2) råa id:t om det råkar stämma, // 3) postens egen ordning som sista utväg. let k = units.evs.findIndex((_, i) => canonChargerId(i) === cid); if (k < 0) k = units.evs.findIndex(u => String(u.id) === cid); if (k < 0 && pos < n) k = pos; if (k < 0 || inactive(k)) return; matched++; entry.kwh.forEach((v, i) => { if (i < 192 && v > 0) out[k][i] = v; }); }); if (matched) return { byCharger: out, shared: false, matched, offered }; console.warn('[gravio-adapter] ev_per_car kunde inte knytas till någon laddbox —', 'positioner:', units.evs.length, 'charger_id:', per.map(x => x && x.charger_id), '→ faller tillbaka på ev_plan (aggregerad).'); } // Fallback: hela planen på första boxen. Med flera boxar är den inte // tillskrivbar en enskild — `shared` säger det. const iv = analysis?.ev_plan?.intervals; if (Array.isArray(iv) && n && !inactive(0)){ iv.forEach(x => { const i0 = x?.start_quarter, i1 = x?.end_quarter; if (i0 == null || i1 == null) return; for (let i = i0; i < i1; i++) if (i < 192) out[0][i] = x.kwh_per_quarter ?? null; }); return { byCharger: out, shared: n > 1, matched, offered }; } // Sista utväg: banden ur buildEvLoadPlan (samma fält, redan tolkade). if (Array.isArray(evLoad?.ev) && n && !inactive(0)){ for (const ivx of evLoad.ev){ const parts = Array.isArray(ivx?.parts) && ivx.parts.length ? ivx.parts : [ivx]; for (const p of parts){ if (p?.i0 == null || p?.i1 == null) continue; for (let i = p.i0; i <= p.i1; i++) out[0][i] = p.kwhPerQ ?? p.kwh_per_quarter ?? null; } } return { byCharger: out, shared: n > 1, matched, offered }; } return { byCharger: out, shared: false, matched, offered }; } /* ---- Lastplan per last ------------------------------------------------- deferrable_plan[]: start_quarter/end_quarter (EXKLUSIV), power_kw, samt solar_kwh/energy_kwh sedan 15/8. Andelen ger etiketten sol/nät/blandat; saknad nyckel = okänt, ingen parentes. DYGNSUPPDELNING (SPEC_DESIGN_lastfonster §10–§11, 2/9): posterna grupperas på KALENDERDYGN ur kvartsindex, aldrig på fältet `day`. Ett fönster 22:00–16:00 ger en körning med day = 0 som kan ligga helt i morgon förmiddag (verifierat i UTREDNING §7) — den hör till Imorgon i redovisningen. Korsar körningen midnatt delas den med `*_after`-fälten; delen före midnatt är totalen minus dem. Posten delas ALDRIG i två körningar — delningen är enbart redovisning. */ const _LD_KEYS = ['energyKwh', 'solarKwh', 'costKr', 'gridCostKr', 'ratedKwh']; function _ldDay(u, d){ if (!u.days[d]) u.days[d] = { energyKwh:0, solarKwh:0, costKr:0, gridCostKr:0, ratedKwh:0, gridMissing:false, costMissing:false, runs:[] }; return u.days[d]; } function _ldAdd(u, d, o){ const t = _ldDay(u, d); _LD_KEYS.forEach(k => { if (o[k] != null && isFinite(o[k])) t[k] += o[k]; }); if (o.gridMissing) t.gridMissing = true; if (o.costMissing) t.costMissing = true; if (o.run) t.runs.push(o.run); } function _loadPlanByUnit(analysis, units, evLoad){ const out = units.loads.map(() => ({ q:{}, src:null, days:{} })); const raw = ('deferrable_plan' in (analysis||{})) ? analysis.deferrable_plan : analysis?.schedule_data?.deferrable_plan; const plan = Array.isArray(raw) ? raw : []; if (plan.length){ plan.forEach(p => { const k = units.loads.findIndex(u => String(u.id) === String(p?.load_id)); const idx = k >= 0 ? k : units.loads.findIndex(u => u.name === p?.name); if (idx < 0 || p.start_quarter == null || p.end_quarter == null) return; const kw = Number(p.power_kw) || units.loads[idx].power_kw || 0; for (let i = p.start_quarter; i < p.end_quarter; i++) out[idx].q[i] = kw/4; // Backendräknad kostnad (16/8). ACKUMULERAS: en last i läget "får delas // upp" har en post per del, och tilldelning lät sista delen vinna medan // kvartarna summerades — kortet visade då kWh ur ett block och kronor ur // ett annat. Sol-andelen räknas på totalen, inte på sista delen. if (p.energy_kwh != null) out[idx].energyKwh = (out[idx].energyKwh || 0) + Number(p.energy_kwh); if (p.solar_kwh != null) out[idx].solarKwh = (out[idx].solarKwh || 0) + Number(p.solar_kwh); if (p.cost_kr != null) out[idx].costKr = (out[idx].costKr || 0) + Number(p.cost_kr); /* Dygnsuppdelningen. `grid_cost_kr` SKRIVS ALLTID av backend sedan 2/9, även som 0.0 (helt soltäckt körning) — saknas fältet är planen skriven före dess och då visas ingen "varav köpt el"-rad alls. 0 och "vet ej" får aldrig bli samma sak. SAMMA REGEL FÖR `cost_kr`. Planer skrivna före 16/8 bär kWh men inga kronor; utan `costMissing` blev de ett grönt "0,00 kr" — exakt den styling som betyder "helt soltäckt körning". Klienten får inte påstå ett pris den inte fått. Saknas ett *_after-tal på en korsande körning går delningen inte att räkna alls, och då är BÅDA dygnen okända. */ const num = (v) => { const n = Number(v); return isFinite(n) ? n : 0; }; const s = p.start_quarter, e = p.end_quarter; const gridHas = p.grid_cost_kr != null; const costHas = p.cost_kr != null; const rated = kw * (e - s) / 4; const eTot = p.energy_kwh != null ? num(p.energy_kwh) : rated; const cross = s < GQ && e > GQ; if (cross){ const aRated = kw * (e - GQ) / 4; const aE = p.energy_kwh_after != null ? num(p.energy_kwh_after) : null; const aC = p.cost_kr_after != null ? num(p.cost_kr_after) : null; const aG = p.grid_cost_kr_after != null ? num(p.grid_cost_kr_after) : null; const aS = p.solar_kwh_after != null ? num(p.solar_kwh_after) : null; const costGone = !costHas || aC == null; const gridGone = !gridHas || aG == null; const run = { i0:s, i1:e, whole:[s, e], win: p.window_idx != null ? Number(p.window_idx) : 0 }; _ldAdd(out[idx], 1, { energyKwh: aE != null ? aE : aRated, solarKwh: aS || 0, costKr: aC || 0, gridCostKr: aG != null ? aG : 0, ratedKwh: aRated, gridMissing: gridGone, costMissing: costGone, run: { ...run, part:'after', from: GQ, to: e } }); _ldAdd(out[idx], 0, { energyKwh: eTot - (aE != null ? aE : aRated), solarKwh: num(p.solar_kwh) - (aS || 0), costKr: num(p.cost_kr) - (aC || 0), gridCostKr: gridHas ? num(p.grid_cost_kr) - (aG || 0) : 0, ratedKwh: rated - aRated, gridMissing: gridGone, costMissing: costGone, run: { ...run, part:'before', from: s, to: GQ } }); } else { _ldAdd(out[idx], s >= GQ ? 1 : 0, { energyKwh: eTot, solarKwh: num(p.solar_kwh), costKr: num(p.cost_kr), gridCostKr: gridHas ? num(p.grid_cost_kr) : 0, ratedKwh: rated, gridMissing: !gridHas, costMissing: !costHas, run: { i0:s, i1:e, whole:[s, e], win: p.window_idx != null ? Number(p.window_idx) : 0, part:null, from:s, to:e } }); } }); out.forEach(u => { /* Körningarna sorteras i tid. deferrable_plan kommer INTE sorterad på start_quarter (mätt: 52, 148, 17, 113), och dygnsraden renderar den här listan rakt av — kortet visade då "13:00–14:00, 04:15–05:15" tre rader under en sorterad lista över samma två körningar. Två listor över samma sak i motsatt ordning är exakt den självmotsägelse dygnsraderna infördes för att ta bort. */ Object.keys(u.days).forEach(d => { u.days[d].runs.sort((a, b) => (a.from - b.from) || (a.i0 - b.i0)); }); if (u.energyKwh > 0 && u.solarKwh != null){ const share = u.solarKwh / u.energyKwh; u.src = share >= 0.9 ? 'sol' : share <= 0.1 ? 'nät' : 'blandat'; u.solarShare = share; } }); return out; } // Fallback: banden ur buildEvLoadPlan. (evLoad?.loads || []).forEach(l => { // findIndex returnerar 0 för första lasten, och 0 är falsy — || kastade // därför alltid id-träffen på index 0. const byId = units.loads.findIndex(u => String(u.id) === String(l?.id)); const k = byId >= 0 ? byId : units.loads.findIndex(u => u.name === l?.name); if (k < 0) return; const kw = Number(l.power_kw) || units.loads[k].power_kw || 0; (l.bands || []).forEach(b => { if (b?.i0 == null || b?.i1 == null) return; for (let i = b.i0; i <= b.i1; i++) out[k].q[i] = kw/4; }); }); return out; } /* ---- Kvartsserien ------------------------------------------------------ dayOffset 0 = idag (slots 0–95), 1 = imorgon (96–191). opts: { analysis, history } — analysis för ev_per_car/deferrable_plan, history för utfall per kvart. */ function buildGravioDay(slots, live, evLoad, settings, dayOffset, opts){ if (!Array.isArray(slots) || slots.length < GQ) return null; const { analysis, history } = opts || {}; const off = (dayOffset || 0) * GQ; const units = gravioUnits(settings, live); const nBat = units.bats.length; const l = live || {}; /* Samma flagga som forecast.jsx:58 läser. Är den sann har backend redan vikt in de skjutbara lasterna i consumption — då får de INTE läggas på igen. */ const consIncludesLoads = slots.consIncludesLoads === true; /* RENT SPOTPRIS. buy/sell från backend är kompletta (avgifter, energiskatt, moms) och får aldrig räknas om — men användaren vill kunna se spot utan påslag. Relationen är verifierad mot alla 192 kvartar 26/8: spot = sell − grid_benefit_kwh + sell_margin_kwh buy = (spot + grid_fee_kwh + 42,000) × (1 + vat_percent/100) Kontrollkonstanten (energiskatten) låg på 42,000 öre med ett spann av 0,012 över hela dygnet, alltså ren avrundning. Spot behöver den INTE — den räknas ur sell och backendens eget grid_benefit_kwh, medan buy-ledet bara är bevis för att modellen håller. Saknas grid_benefit_kwh: spot blir null och knappen döljs. Gissa aldrig nyttan till 0 — det gör spot till säljpris och ser rätt ut. Backend är ombedd att exponera spot explicit (BACKEND_BEHOV punkt 7); då byts den här härledningen mot fältet. */ /* SÄLJMARGINALEN MÅSTE MED (backend 3/9). Backend räknar sell = spot + grid_benefit_kwh − sell_margin_kwh (domain/prices.py:31-32), så den inverterade formeln behöver båda termerna. Utan marginalen visades spot för lågt för de 27 av 78 staging-users (34,6 %) som har sell_margin_kwh satt. Saknas fältet är marginalen 0 — det är ett påslag som inte finns, inte ett okänt värde, till skillnad från grid_benefit_kwh där saknad nyckel döljer knappen. */ const gridBenefit = Number(settings?.grid_benefit_kwh); const _sellMargin = Number(settings?.sell_margin_kwh); const sellMargin = Number.isFinite(_sellMargin) ? _sellMargin : 0; const spotOf = (sell) => (sell == null || !Number.isFinite(gridBenefit)) ? null : sell - gridBenefit + sellMargin; const evPlan = _evPlanByCharger(analysis, units, evLoad); const loadPlan = _loadPlanByUnit(analysis, units, evLoad); const actuals = dayOffset === 0 ? _actualsByQuarter(history, window.hemsToday ? window.hemsToday() : null) : {}; const now = window.hemsDate ? window.hemsDate() : new Date(); const nowQ = Math.min(GQ-1, now.getHours()*4 + Math.floor(now.getMinutes()/15)); const liveActs = Array.isArray(l.per_battery_actions) ? l.per_battery_actions : null; const out = []; for (let q = 0; q < GQ; q++){ const s = slots[off + q]; if (!s) break; const gi = off + q; const isPast = dayOffset === 0 && q < nowQ; const isNow = dayOffset === 0 && q === nowQ; const act = isPast || isNow ? actuals[q] : null; const action = gravioMode(s.action); const batSrc = BAT_SRC[s.action] || null; // Första plankvarten kan vara delvis förbrukad: kW = kWh × 4 / fraction. const frac = (isNow && s.fraction > 0 && s.fraction <= 1) ? s.fraction : 1; /* Batterieffekt. Tre källor, i tur och ordning: live per enhet (nu) → utfall ur history (passerat) → planens kWh. */ let bat, batSource; if (isNow && units.batteryDetail === 'per-unit'){ bat = l.battery_power_list.map((w, k) => ({ p: (Number(w)||0)/1000, src: liveActs?.[k]?.action ? (BAT_SRC[liveActs[k].action] || null) : batSrc, aiControlled: units.bats[k]?.aiControlled || undefined, })); batSource = 'live'; } else { let kw; if (isNow && typeof l.battery_power === 'number'){ kw = l.battery_power/1000; batSource = 'live'; } else if (isPast && act?.bat != null){ kw = act.bat/1000; batSource = act.hourly ? 'utfall-timme' : 'utfall'; } else { kw = ((s.charge_kwh || 0) - (s.discharge_kwh || 0)) * 4 / frac; batSource = 'plan'; } // EN post för hela banken: planen gäller det aggregerade batteriet, och // att dela den på N vore påhittat. bat = [{ p: kw, src: batSrc, aggregate: nBat > 1 }]; } /* SoC: live per enhet nu, uppmätt bakåt, planerad framåt. socPct betyder TVÅ olika saker beroende på fall, och det var roten till Anders bugg 18/8: vid NU är listan per enhet (soc_list), i övriga kvartar är den ETT tal som redan är hela bankens. Fyra vyer läste [0] som "husets SoC" — med tre batterier visade kortet batteri 1:s 82 % under etiketten SOC medan banken låg på 86 %. Samma fel åt andra hållet i kWh: aggregatprocenten multiplicerades med BATTERI 1:s kapacitet, så en framtida kvart visade 26 av 60 kWh. Därför räknas allt nedan mot bankens kapacitet, och varje vy får ett färdigt banktal (socAggPct) i stället för att härleda ett eget. */ const perUnitSoc = nBat > 1 && (isNow && Array.isArray(l.soc_list) && l.soc_list.length === nBat); /* Vid NU gäller mätvärdet, och det finns i två former: soc_list per enhet eller det vägda soc. Ett enbatterihushåll har bara EN enhet, så listans första post ÄR husets mätvärde — läs den före det vägda talet, och båda före planen. Utan den raden bytte ett saknat `soc` tyst mätvärde mot plan i den vanligaste konfigurationen vi har. */ const socPct = perUnitSoc ? l.soc_list.map(Number) : (isNow && nBat === 1 && Array.isArray(l.soc_list) && l.soc_list.length === 1) ? [Number(l.soc_list[0])] : (isNow && typeof l.soc === 'number') ? [l.soc] : (isPast && act?.soc != null) ? [act.soc] : [s.soc != null ? s.soc : null]; const socs = socPct.map((p, k) => { const cap = perUnitSoc ? units.bats[k]?.cap : (nBat === 1 ? (units.bats[0]?.cap ?? units.totCap) : units.bankCap); return (p != null && cap) ? (p/100)*cap : null; }); const socTot = socs.every(x => x != null) ? socs.reduce((a,c)=>a+c,0) : null; /* Bankens SoC som ett tal. Ur summan och kapaciteten när båda finns — då är procenten kapacitetsVIKTAD och stämmer med kWh-raden under den. Annars backendens vägda live.soc, sist planens ettal. */ const socCapKwh = nBat === 1 ? (units.bats[0]?.cap ?? units.totCap) : units.bankCap; const socAggPct = (socTot != null && socCapKwh) ? (socTot/socCapKwh)*100 : (isNow && typeof l.soc === 'number') ? l.soc : (socPct[0] ?? null); // Sol och förbrukning: uppmätt bakåt, prognos framåt. Båda exponeras // separat också — "utfall mot prognos" kräver de två sida vid sida, och // det är den siffran prototypen ersatte med ett slumptal. /* null = ingen prognos för timmen. buildSlots lämnar 0 respektive 1,0 kW där arrayen tar slut; de talen får inte ritas som mätdata. */ const solPlan = s.solarKnown === false ? null : s.solar; const consPlan = s.consKnown === false ? null : s.consumption; /* Två förbrukningsbegrepp, och de får aldrig bära samma etikett: consPlan = huset (backendens consumption). Flödesbilden räknar lasterna som EGNA noder — läggs de på hemnoden dubblas de. consPlanTot = huset + planerade skjutbara laster, alltså det ELMÄTAREN kommer visa. forecast.jsx:70 har samma definition (consFc = consumption + loadAddQ), och consAct är mätt på hela huset — jämförelser måste ske mot den här. Elbilen ingår i ingendera: den ritas som eget band/nod. */ const consPlanTot = consPlan == null ? null : consPlan + (consIncludesLoads ? 0 : loadPlan.reduce((acc, lp) => acc + ((lp.q[gi] || 0) * 4), 0)); const solAct = (isPast || isNow) && act?.sol != null ? act.sol/1000 : null; const consAct = (isPast || isNow) && act?.home != null ? act.home/1000 : null; /* NU-kvarten ska visa MÄTVÄRDET, inte planen. Batteri, laddbox, elnät och SoC löstes redan så här ovan; sol och förbrukning var undantagen och föll tillbaka på planen — 1,2 kW ritades medan mätaren visade 5,8. Flödesvyn hade en egen live-avläsning, men den fick aldrig live-objektet och föll tyst tillbaka. Ett ställe i stället för ett per vy. solPlan/consPlan/consPlanTot ligger kvar som egna fält, så prognos-mot- utfall-jämförelser fortfarande läser planen uttryckligen. */ const solLive = isNow && typeof l.solar_power === 'number' ? l.solar_power/1000 : null; const consLive = isNow && typeof l.home_consumption === 'number' ? l.home_consumption/1000 : null; const sol = solLive != null ? solLive : (isPast && solAct != null ? solAct : solPlan); const cons = consLive != null ? consLive : (isPast && consAct != null ? consAct : consPlan); /* Laddboxar. ev_power_list och ev_source_split_list är indexerade på positionen i settings.ev_chargers, samma ordningskontrakt som planen. Källan är dominerande av solar/battery/grid (pro-rata av totalen — en konvention, elektroner är omärkta). */ const splitList = Array.isArray(l.ev_source_split_list) ? l.ev_source_split_list : null; const ev = units.evs.map((u, k) => { const kwh = evPlan.byCharger[k]?.[gi]; let p = kwh != null ? kwh*4 : null, src = null, srcScope = null; if (isNow && Array.isArray(l.ev_power_list) && l.ev_power_list[k] != null){ p = (Number(l.ev_power_list[k])||0)/1000; } if (isNow){ const sp = splitList?.[k] || (units.evs.length === 1 ? l.ev_source_split : null); if (sp){ const cand = [['sol', sp.solar_w||0], ['nät', sp.grid_w||0], ['batteri', sp.battery_w||0]] .sort((a,b) => b[1]-a[1])[0]; if (cand && cand[1] > 50){ src = cand[0]; srcScope = splitList ? 'box' : 'total'; } } } return { p, src, srcScope, name: u.name, shared: evPlan.shared || undefined, planMatched: evPlan.matched, planOffered: evPlan.offered }; }); const loads = units.loads.map((u, k) => { const kwh = loadPlan[k]?.q[gi]; /* Uppmätt lasteffekt i NU-kvarten (BACKEND_BEHOV §10): load_power_list i /live, W, indexerad på positionen i deferrable_loads — samma ordningskontrakt som ev_power_list. null = lasten rapporterar inte. measured-flaggan skiljer mätvärdet från planens angivna effekt. */ let p = kwh != null ? kwh*4 : null, measured = false; if (isNow && Array.isArray(l.load_power_list) && l.load_power_list[k] != null){ p = (Number(l.load_power_list[k])||0)/1000; measured = true; } return { p, measured, src: loadPlan[k]?.src || null, solarShare: loadPlan[k]?.solarShare, name: u.name }; }); /* Nätflöde. Samma ordning: live nu, utfall bakåt, plan framåt. history grid: negativ = export. */ let gridIn, gridOut, gridSource; if (isNow && typeof l.grid_power === 'number'){ const kw = l.grid_power/1000; gridIn = Math.max(0, kw); gridOut = Math.max(0, -kw); gridSource = 'live'; } else if (isPast && act?.grid != null){ const kw = act.grid/1000; gridIn = Math.max(0, kw); gridOut = Math.max(0, -kw); gridSource = act.hourly ? 'utfall-timme' : 'utfall'; } else { gridIn = (s.grid_import_kwh || 0) * 4 / frac; gridOut = ((s.grid_export_kwh || 0) + (s.solar_export_kwh || 0)) * 4 / frac; gridSource = 'plan'; } /* HEMS:s KOMMANDO — inte mätvärdet. `per_battery_actions[].power_kw` är magnituden HEMS styr med: alltid positiv (splitten negerar aldrig för urladdning) och alltid 0 för AI-batterier, som HEMS bara observerar. Tecknet kommer därför ur action, aldrig ur talet. Skilj det från `bat[]` ovan, som vid NU är växelriktarens UPPMÄTTA effekt. De två är samma tal bara när ingen kört över styrningen: tvingar någon urladdning manuellt mäts −17 kW medan HEMS fortfarande rekommenderar +0,8 kW (Lennie 20/8 — kortet "Rekommenderad effekt" visade mätvärdet och läste därför som att HEMS beordrat urladdningen). För kvartar som inte är NU finns inget kommando att läsa: då ÄR planen rekommendationen, kWh × 4. */ let batCmd = null, batCmdSource; if (isNow && liveActs && liveActs.length){ batCmd = liveActs.map((b, k) => { const ai = !!(b?.ai_controlled || units.bats[k]?.aiControlled); const mag = Math.abs(Number(b?.power_kw) || 0); return { p: ai ? null : (BAT_DISCH_ACT.has(b?.action) ? -mag : mag), src: b?.action ? (BAT_SRC[b.action] || null) : batSrc, ai: ai || undefined, }; }); batCmdSource = 'kommando'; } else if (isNow && typeof l.power_kw === 'number'){ // Aggregerat kommando. Tecknet ur action här också — fältet kan komma // osignerat, och abs() + action ger samma svar i båda fallen. const mag = Math.abs(l.power_kw); batCmd = [{ p: (BAT_DISCH_ACT.has(l.action) || BAT_DISCH_ACT.has(s.action)) ? -mag : mag, src: batSrc, aggregate: nBat > 1 }]; batCmdSource = 'kommando-summa'; } else { batCmd = [{ p: ((s.charge_kwh || 0) - (s.discharge_kwh || 0)) * 4 / frac, src: batSrc, aggregate: nBat > 1 }]; batCmdSource = 'plan'; } out.push({ i: q, gi, h: q/4, isPast, isNow, // Priser i öre. buy/sell INKLUDERAR avgifter — lägg inte på prototypens // hårdkodade NET_ORE/NYTTA_ORE ovanpå dem. buy: s.buy, sell: s.sell, spot: spotOf(s.sell), feesIncluded: true, sol, cons, solPlan, consPlan, consPlanTot, solAct, consAct, solLive, consLive, // Sol och förbrukning är TIMprognoser — samma värde i timmens fyra // kvartar, medan priset är per kvart. Vyerna måste säga det, annars // läses fyra lika kvartar som ett fel (Lennie 18/8). fcHourly: s.fcHourly === true, // Planerad SoC ur quarterly_decisions, uppmätt ur history quarters[]. socPlanPct: s.soc != null ? s.soc : null, socActPct: (isPast || isNow) && act?.soc != null ? act.soc : null, // Delvis fylld kvart (w/900) — utfall ska inte jämföras rakt mot en hel kvart. actPartial: act?.partial ?? null, actHourly: act?.hourly || undefined, bat, batSource, socPct, socs, socTot, socAggPct, socCapKwh, // Vad HEMS styr med (kommando/plan) vid sidan av vad som mäts. batCmd, batCmdSource, // true = socPct är per enhet. Annars är den ETT banktal och får inte // ritas som om varje batteri mätts för sig. socPerUnit: perUnitSoc, ev, loads, gridIn, gridOut, gridSource, mode: action, reason: s.reason || null, }); } out.loadMeta = loadPlan.map(lp => ({ costKr: lp.costKr ?? null, energyKwh: lp.energyKwh ?? null, solarKwh: lp.solarKwh ?? null, src: lp.src || null, // Per kalenderdygn: { 0: {…idag}, 1: {…imorgon} }. Saknas nyckeln har // lasten ingen körning det dygnet — det är inte samma sak som noll. days: lp.days || {}, })); /* Ärliga solserierna (SPEC 2026-08-26) följer med DAGENS array — honest är ett dygnsobjekt för idag. Saknas ⇒ null ⇒ gc-prognoser renderar exakt som förut. */ if (off === 0) out.solarHonest = slots.solarHonest || null; if (off === 0) out.dataQuality = slots.dataQuality || null; /* Mätarvärdet (SPEC 2026-08-27): /api/live-data.solar_today_kwh, prorata på pågående kvart i backend. Null-bevarande — omätt ≠ 0. Samma tal som app-headern och Flöde visar; solkortet ska inte säga något annat. */ if (off === 0) out.solarLiveKwh = live?.solar_today_kwh ?? slots.solarLiveKwh ?? null; return out; } /* ---- Täckningsrapport -------------------------------------------------- Status härleds ur den MAPPADE dagen, inte ur om källarrayen finns. */ function gravioCoverage(slots, live, evLoad, settings, day, analysis){ const u = gravioUnits(settings, live); const has = (v) => v != null; const s0 = Array.isArray(slots) ? slots[0] : null; const d = Array.isArray(day) ? day : []; const N = d.length; const cnt = (f) => d.filter(f).length; const bySource = (key) => { const m = {}; d.forEach(q => { const v = q[key]; if (v) m[v] = (m[v]||0)+1; }); return Object.entries(m).map(([k,v]) => `${v} ${k}`).join(', ') || 'ingen'; }; const nEv = cnt(q => q.ev?.some(e => e.p != null && e.p > 0.05)); const nEvSrc = cnt(q => q.ev?.some(e => e.src)); const nLoad = cnt(q => q.loads?.some(x => x.p != null)); const nLoadSrc = u.loads.filter((_, k) => d.some(q => q.loads?.[k]?.src)).length; const nSocKwh = cnt(q => q.socs?.every(x => x != null)); return [ { field:'spot / buy / sell', status: has(s0?.buy) ? 'real' : 'missing', note:'Öre per kWh, avgifter inkluderade. Prototypens NET_ORE=95 och NYTTA_ORE=10 är hårdkodade och ska INTE läggas på.' }, { field:'sol (prognos + utfall)', status: has(s0?.solar) ? 'real' : 'missing', note:'LP-planens bias-korrigerade prognos framåt, uppmätt bakåt ur /api/history quarters[].' }, { field:'cons (prognos + utfall)', status: has(s0?.consumption) ? 'real' : 'missing', note:'LP display-input framåt, uppmätt bakåt. Inkluderar planerade laster.' }, { field:'mode / driftläge', status: has(s0?.action) ? 'real' : 'missing', note:'Tio lägen mot prototypens fem. Peak, nollexport, AI-styrt och Vänta har egna poster.' }, { field:'batteri — antal', status: u.bats.length ? 'real' : 'missing', note:`${u.bats.length} st ur ${Array.isArray(settings?.batteries) ? 'settings.batteries' : 'soc_list'}.` }, { field:'batteri — per enhet', status: u.batteryDetail === 'per-unit' ? 'real' : 'aggregate', note: u.batteryDetail === 'per-unit' ? 'battery_power_list + per_battery_actions finns — varje batteri ritas för sig.' : (u.bats.length === 1 ? 'Ett batteri — frågan uppstår inte.' : 'battery_power_list är null. Rita EN nod tills Homey skickar per-enhet-listan.') }, { field:'batteri — effekt per kvart', status: 'real', note:`Källa per kvart: ${bySource('batSource')}. Passerat = utfall ur /api/history, nu = live, framåt = planens charge_kwh/discharge_kwh (×4, delat med fraction i första plankvarten).` }, { field:'batteri — plan per enhet', status:'aggregate', note:'Planen gäller det aggregerade HEMS-batteriet (AI-batteri exkluderat). Per-enhet finns bara live.' }, { field:'batteri — SoC i kWh', status: nSocKwh === N && N ? 'real' : (nSocKwh ? 'partial' : 'missing'), note: Array.isArray(settings?.batteries) ? 'settings.batteries[].capacity_kwh per enhet.' : (u.totCap ? `battery_capacity_kwh ${u.totCap} kWh gäller det enda batteriet.` : 'Ingen kapacitet i settings — SoC bara i procent.') }, { field:'nätflöde per kvart', status: 'real', note:`Källa per kvart: ${bySource('gridSource')}. Energibalansen sluter per plankvart, så noll import en hel natt är korrekt när batteriet täcker huset.` }, { field:'laddboxar', status: u.evs.length ? 'real' : 'none', note:`${u.evs.length} st ur settings.ev_chargers.` }, { field:'EV — plan per kvart', // Ingen laddning i DAG är inte saknad data — planen kan ligga imorgon // (kvartsindex 96–191, som den här vyn inte mappar). status: nEv ? 'real' : 'none', note: nEv ? `${nEv} av ${N} kvartar laddar.` : 'Ingen laddning planerad i dygnet. Planen kan ligga imorgon (kvart 96–191).' }, { field:'EV — plan per laddbox', status: (() => { const m = d[0]?.ev?.[0]?.planMatched || 0, o = d[0]?.ev?.[0]?.planOffered || 0; if (m > 0) return 'real'; if (o > 0) return 'missing'; // ev_per_car fanns men matchade inget return u.evs.length > 1 ? 'aggregate' : 'real'; })(), note: (() => { const m = d[0]?.ev?.[0]?.planMatched || 0, o = d[0]?.ev?.[0]?.planOffered || 0; if (m > 0) return `ev_per_car knuten till ${m} av ${o} ${o === 1 ? 'box' : 'boxar'} per position (kanoniskt id ${u.evs.map((_,i)=>canonChargerId(i)).join(', ')}). settings[].id är ${u.evs.map(x=>x.id).join(', ')} — backend kanoniserar, så skillnaden är väntad.`; if (o > 0) return `ev_per_car har ${o} ${o === 1 ? 'post' : 'poster'} som inte kunde knytas till någon laddbox → ev_plan visas aggregerad.`; return u.evs.length > 1 ? 'ev_per_car är null (ingen multi-EV-plan) → ev_plan visas på första boxen och märks som delad.' : 'En laddbox — ev_plan ÄR boxens plan.'; })() }, { field:'EV — källa (sol/nät/batteri)', status: nEvSrc ? 'real' : 'missing', note: nEvSrc ? (d.some(q => q.ev?.some(e => e.srcScope === 'box')) ? 'ev_source_split_list per box (dominerande källa). Gäller nu-kvarten — planen bär ingen källa.' : 'ev_source_split (totalen) — en laddbox, så den är boxens.') : 'Ingen laddning just nu, eller ingen källa i live-payloaden. Planen bär ingen källa.' }, { field:'skjutbara laster', status: !u.loads.length ? 'none' : (nLoad ? 'real' : 'missing'), note: !u.loads.length ? 'Inga laster konfigurerade.' : `${u.loads.length} st; ${nLoad} av ${N} kvartar har planerad drift.` }, { field:'last — energikälla', status: !u.loads.length ? 'none' : (nLoadSrc === u.loads.length ? 'real' : nLoadSrc ? 'partial' : 'missing'), note: nLoadSrc ? `solar_kwh/energy_kwh för ${nLoadSrc} av ${u.loads.length} laster → sol ≥ 0,9, nät ≤ 0,1, annars blandat. Prognos, inte uppmätt.` : 'Planen skrevs före 15/8 och saknar solar_kwh/energy_kwh → ingen parentes.' }, { field:'utfall mot prognos', status:'real', note:'/api/history quarters[] mot timprognos/4. Pågående kvart skalas med w/900. Prototypens slumptal ((rnd(i+901)−0.5)×4) används inte.' }, { field:'ekonomi — besparing', status: 'partial', note:'economics ger net_savings_kr, baseline_cost_kr, optimized_cost_kr. Prototypens buildStats räknar egna tal med koefficienterna 0.45/0.35/0.9/0.12/0.5 — antaganden som inte får gå live.' }, { field:'veckoschema per bil', status:'missing', note:'Vi har EN avresetid + EN avresedag per laddbox. Sju tider per bil är ett nytt backend-kontrakt.' }, ]; } // _loadPlanByUnit exponeras för gc-last-test.html: dygnsuppdelningen och // midnattsdelningen är den enda logiken i adaptern med egna regler nog att // behöva ett riktigt testfall, och harnessen ska köra KODEN, inte en kopia. Object.assign(window, { GRAVIO_MODES, gravioUnits, buildGravioDay, gravioCoverage, _loadPlanByUnit });