/* 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. /* v610: en TOM lista (batteries: []) betyder ingen lista. Annars blev b = {} och ett-batterihemmet förlorade battery_capacity_kwh och effektgränsen (Kvartsgatan 26/9: batteries [] och 32 kWh gav kapacitet null). */ const bl = Array.isArray(s.batteries) && s.batteries.length ? 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) : (n === 1 ? (Number(s.battery_discharge_rate_kw) || Number(s.battery_charge_rate_kw) || null) : null), /* Lägsta nivå (v599, batteripanelen): per enhet ur batteries[].soc_min, annars hemmets soc_min. */ socMin: (b && b.soc_min != null && isFinite(Number(b.soc_min))) ? Number(b.soc_min) : (s.soc_min != null && isFinite(Number(s.soc_min)) ? Number(s.soc_min) : 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; /* D10 (identitetsläget 3/10): "Fråga varje gång" utan vald bil — ingen bil att visa, vyerna visar boxens namn. */ const pick = !!(window.evModel?.needsCarChoice && window.evModel.needsCarChoice(s, c, sess)); const carId = pick ? null : (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, /* Identitetsläget (3/10): uid binder effekt och status, slot binder planen. */ uid: c.uid || null, slot: c.slot || null, /* 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, identity: !!(window.evModel?.identityMode && window.evModel.identityMode(s)), /* Huvudsäkringen (settings.main_fuse_a), nämnare för fasvisningen i Energiflödet (v580). null = okänd. */ mainFuseA: Number(s.main_fuse_a) > 0 ? Number(s.main_fuse_a) : null, /* 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; /* Smartlastens uppmätta energi (backend 12/9, commit 831a5f9): per TIMME, aldrig per kvart — fördelas jämnt över timmens fyra kvartar. kWh per kvart = kWh per timme / 4. Saknas fälten bär raden ingen last (hasLoad:false), vilket inte är samma sak som noll. */ const hasLoad = row.load_kwh != null; const ld = hasLoad ? { loadKwh: Number(row.load_kwh) / 4, loadKwhPlan: Number(row.load_kwh_plan || 0) / 4, hasLoad: true } : {}; if (qs){ qs.forEach((q, qi) => { if (!q) return; /* K1 (15/9): kvart utan husmätning men med uppskattning (V2X-frys, sensorkarantän) bär home_est (W). Utfallet läser home ?? home_est och märks est:true med timradens orsak. Mätfältet home är aldrig satt på en uppskattad kvart. */ const est = q.home == null && q.home_est != null; out[h*4 + qi] = { home: q.home ?? q.home_est, sol: q.sol, grid: q.grid, bat: q.bat, soc: q.soc, /* Per batteri (backend 26/9): W och % per enhet i settings.batteries-ordning, AI inräknat. null-element = saknat värde, aldrig 0. */ batList: Array.isArray(q.bat_list) ? q.bat_list : null, socList: Array.isArray(q.soc_list) ? q.soc_list : null, partial: q.w != null && q.w < 890 ? (q.w/900) : null, ...ld, /* v764 (backend 29/9, TILL_DESIGN_hus_netto_per_kvart): lastavdraget per kvart i W, viktat som home. I bruttoläge ingår det i home. */ ldW: q.ld ?? null, est: est || undefined, estReason: est ? (row.home_est_reason || 'unknown') : undefined, }; }); } 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, ...ld, }; } } 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 ?? ''); /* Identitetsläget (D4): planen slås upp på boxens slot och inget annat. Annars: 1) kanoniskt id ↔ position, 2) råa id:t om det råkar stämma, 3) postens egen ordning. */ let k; if (units.identity) k = units.evs.findIndex(u => u.slot != null && String(u.slot) === cid); else { 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']; /* §20 (André 25/9): källa per körning, i kWh och kr. Valfria — saknas fältet på någon körning i dygnet sätts Miss och raden visar "–". Inget av dem räknas fram som en differens mot energy_kwh/cost_kr. */ const _LD_OPT = [['gridKwh', 'grid_kwh'], ['batteryKwh', 'battery_kwh'], ['solarValueKr', 'solar_value_kr'], ['batteryValueKr', 'battery_value_kr']]; function _ldOpt(p, cross){ const o = {}, a = {}, b = {}; _LD_OPT.forEach(([k, f]) => { const v = p[f] != null && isFinite(Number(p[f])) ? Number(p[f]) : null; if (!cross){ o[k] = v; return; } const va = p[f + '_after'] != null && isFinite(Number(p[f + '_after'])) ? Number(p[f + '_after']) : null; a[k] = (v != null && va != null) ? va : null; b[k] = (v != null && va != null) ? v - va : null; }); return cross ? { after: a, before: b } : o; } window.GC_LD_OPT = _LD_OPT.map(x => x[0]); 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; _LD_OPT.forEach(([k]) => { if (o[k] == null) t[k + 'Miss'] = true; else t[k] = (t[k] || 0) + o[k]; }); if (o.run) t.runs.push(o.run); } function _loadPlanByUnit(analysis, units, evLoad){ const out = units.loads.map(() => ({ q:{}, src:null, days:{}, rem:{}, cmd:{}, byWin:{} })); 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; /* ETT TAL PER KÖRNING (kravlista 12/9 krav 1–2, 10). Bandet bär planens `energy_kwh` fördelad enligt profilen (`on_q`), aldrig power_kw × tid: Lennies VVB visade 11,8 kWh (2,96 kW × 4 h) medan LP:n räknade med 9,12. Integralen över körningen är nu exakt energy_kwh. */ const prof = window.lpProfile(p, units.loads[idx].power_kw); Object.keys(prof).forEach(i => { out[idx].q[i] = (out[idx].q[i] || 0) + prof[i]; }); const isRem = p.remnant === true; if (isRem) for (let i = p.start_quarter; i < p.end_quarter; i++) out[idx].rem[i] = true; /* Krav 13 (14/9): kommandot. Varje placering (varje del, remnant likaså) märker sina kvartar start–end med { i0, i1, done }: done = start + on_q (kvart, kan vara bråkdel) när on_q finns, annars null. Ringen ritar konturen ur detta och fyller bara där q[i] har energi. */ { const onQ = p.on_q != null ? Number(p.on_q) : NaN; /* winIdx och energyKwh räknas här: deklarationerna längre ner i samma callback gav undefined (Babel gör const till var), så kortets Klar ca föll alltid tillbaka på on_q-slutet (granskningen 5/10, bygge 784). */ const cmdKw = p.rated_power_kw != null && isFinite(Number(p.rated_power_kw)) ? Number(p.rated_power_kw) : kw; const cmdE = p.energy_kwh != null && isFinite(Number(p.energy_kwh)) ? Number(p.energy_kwh) : cmdKw * (p.end_quarter - p.start_quarter) / 4; const cmd = { i0: p.start_quarter, i1: p.end_quarter, done: isFinite(onQ) ? p.start_quarter + onQ : null, remnant: isRem, winIdx: p.window_idx != null ? Number(p.window_idx) : 0, energyKwh: cmdE }; for (let i = p.start_quarter; i < p.end_quarter && i < 192; i++) out[idx].cmd[i] = cmd; } // 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; /* "Angivet" (krav 4) = angiven effekt × körtid. rated_power_kw är sparse; saknas det är det lika med power_kw. */ const ratedKw = p.rated_power_kw != null ? num(p.rated_power_kw) : kw; const rated = ratedKw * (e - s) / 4; const eTot = p.energy_kwh != null ? num(p.energy_kwh) : rated; const winIdx = p.window_idx != null ? Number(p.window_idx) : 0; /* Minutfält (19/9): bärs vidare till fönsterkort, dygnsrader och band. */ const mm = window.lpMinutes ? window.lpMinutes(p) : null; const m0 = mm ? mm.m0 : null, m1 = mm ? mm.m1 : null; /* Per fönster (krav 3b): dag 0 före dag 1, första placeringen vinner. */ const bw = out[idx].byWin; const dayOf = s >= GQ ? 1 : 0; const ent = { energyKwh: eTot, day: dayOf, i0: s, i1: e, m0, m1, remnant: isRem, onQ: p.on_q != null ? Number(p.on_q) : null }; if (!bw[winIdx] || (dayOf < bw[winIdx].day)){ const prev = bw[winIdx]; bw[winIdx] = ent; if (prev) bw[winIdx].next = prev; } else if (!bw[winIdx].next || dayOf < bw[winIdx].next.day || (dayOf === bw[winIdx].next.day && s < bw[winIdx].next.i0)){ /* Nästa placering efter den första (v633): fönsterkortet lyfter fram den när dagens körning är klar. */ if (!(dayOf === bw[winIdx].day && s === bw[winIdx].i0)) bw[winIdx].next = ent; } const cross = s < GQ && e > GQ; if (cross){ const op = _ldOpt(p, true); const aRated = ratedKw * (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], m0, m1, win: winIdx, remnant: isRem, energyKwh: eTot }; _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, ...op.after, run: { ...run, part:'after', from: GQ, to: e, fromM: mm ? 1440 : null, toM: m1 } }); _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, ...op.before, run: { ...run, part:'before', from: s, to: GQ, fromM: m0, toM: mm ? 1440 : null } }); } 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, ..._ldOpt(p, false), run: { i0:s, i1:e, whole:[s, e], m0, m1, win: winIdx, remnant: isRem, energyKwh: eTot, part:null, from:s, to:e, fromM: m0, toM: m1 } }); } }); 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; /* Backendens eget spot_price vinner över härledningen så snart det finns på kvarten (André 16/9: "kan du plocka det från rätt fält?"). Härledningen står kvar som fallback tills fältet exponeras, och döljer knappen när grid_benefit_kwh saknas. */ const spotFor = (s) => Number.isFinite(s.spotPrice) ? s.spotPrice : spotOf(s.sell); 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 }]; } /* v591 (André 26/9: "Värdet skall visa verkligheten"). Vid NU får varje batteri som rör sig srcLive ur MÄTAREN, samma regel som lasterna (Lennie 14/9); src (kommandots action) lämnas orörd för planvyerna. Bankens summa klassas och batteriet räknas som marginellt. Laddning: import ≈ 0 ⇒ sol (utan sol: '?'), import ≥ laddeffekt ⇒ nät, däremellan blandat. Urladdning: export ≈ 0 ⇒ hus, export ≥ urladdning ⇒ export, däremellan blandat. Utan live grid_power: '?' (går inte att avgöra), aldrig kommandot. */ let batSrcLive = null; if (isNow && batSource === 'live'){ const gw = typeof l.grid_power === 'number' ? l.grid_power/1000 : null; const solNow = typeof l.solar_power === 'number' ? l.solar_power/1000 : 0; const chg = bat.reduce((t, b) => t + Math.max(0, b.p || 0), 0); const dis = bat.reduce((t, b) => t + Math.max(0, -(b.p || 0)), 0); const imp = gw == null ? null : Math.max(0, gw), exp = gw == null ? null : Math.max(0, -gw); /* Nätets tomgångsbrus (52–95 W enstaka pingar hos LEN 26/9) får inte växla källan: samma band som statustexten (v592). */ const idle = window.GC_IDLE_KW || 0.15; batSrcLive = { chg: imp == null ? '?' : imp <= idle ? (solNow > 0.05 ? 'sol' : '?') : imp >= chg - idle ? 'nät' : solNow > 0.05 ? 'blandat' : '?', dis: exp == null ? '?' : exp <= idle ? 'hus' : exp >= dis - idle ? 'export' : 'blandat' }; bat = bat.map((b) => (b.p||0) > 0.05 ? { ...b, srcLive: batSrcLive.chg } : (b.p||0) < -0.05 ? { ...b, srcLive: batSrcLive.dis } : b); } /* Bankens uppmätta effekt vid NU (backend 25/9, TILL_DESIGN_battery_power): live.battery_power är Homeys banktal eller backendens summa av battery_power_list — komplett när varje element är ett tal. battery_power_measured === false (föreslaget fält) = pingen saknade batterieffekt; 0 då inget mätvärde. */ const batBankKw = isNow && typeof l.battery_power === 'number' && l.battery_power_measured !== false && (!Array.isArray(l.battery_power_list) || l.battery_power_list.every(Number.isFinite)) ? l.battery_power / 1000 : null; /* Backend 25/9 06:30: battery_power_measured === false vid NU = hemmet skickar ingen batterieffekt, och historiken har då 0 W i stället för inget värde. Gäller passerade kvartar också; planen frammåt inte. null = ej pingat sedan deploy. */ const batUnmeasured = l.battery_power_measured === false && (isNow || isPast); /* 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, measured = false; if (units.identity){ /* D3: effekten per box ur ev_power_by_charger[uid]. null eller saknad = okänd (inte mätt), ingen återgång till positionslistan. */ const w = isNow && window.evModel?.powerByUidW ? window.evModel.powerByUidW(l, u) : null; if (w != null){ p = w/1000; measured = true; } } else if (isNow && Array.isArray(l.ev_power_list) && l.ev_power_list[k] != null){ p = (Number(l.ev_power_list[k])||0)/1000; measured = true; } if (isNow){ /* Källfördelningen finns inte per uid (D3): i identitetsläget bara för en ensam box. */ const boxSplit = units.identity ? null : splitList; const sp = boxSplit?.[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 = boxSplit ? 'box' : 'total'; } } } return { p, measured, 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, src = loadPlan[k]?.src || null, nowShare = null; 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; /* Källan i NU ur MÄTAREN, inte planens klassning av hela körningen (Lennie 14/9: "körs nät" medan nätet stod på 0,0 och solen gav 3,6). import ≈ 0 ⇒ sol; import ≥ lasteffekt ⇒ nät; däremellan blandat. */ if (p > 0.05 && typeof l.grid_power === 'number'){ const imp = Math.max(0, l.grid_power/1000); const solNow = typeof l.solar_power === 'number' ? l.solar_power/1000 : 0; src = imp <= 0.05 ? (solNow > 0.05 ? 'sol' : 'batteri') : imp >= p - 0.05 ? 'nät' : 'blandat'; if (src === 'blandat') nowShare = Math.max(0, Math.min(1, 1 - imp/p)); } } return { p, pPlan: kwh != null ? kwh*4 : null, measured, src, remnant: loadPlan[k]?.rem?.[gi] === true || undefined, cmd: loadPlan[k]?.cmd?.[gi] || undefined, solarShare: nowShare != null ? nowShare : loadPlan[k]?.solarShare, name: u.name }; }); /* Smartlasten i Prognos-grafen (krav 11, 12/9). Tre serier per timme: consBase = prognosen UTAN last (cons_components.final − loads_add) consLoadsAdd = planens lastökning (loads_add) — streckad ovanpå basen loadAct = lastens UPPMÄTTA energi (load_kwh), kW-medel över kvarten loadActPlan = den del som förbrukades medan planen hade lasten PÅ consAct är husets NETTO (efter lastavdraget) när history saknar load_display_gross; är flaggan sann är consAct BRUTTO och loadAct får inte adderas (K2, 15/9). loadAct ur timdocsen är strypt (en skrivning per 5-min-slot) och får ALDRIG visas som "uppmätt". */ const consBase = s.consBase != null ? s.consBase : null; const consLoadsAdd = s.consLoadsAdd != null ? s.consLoadsAdd : null; const loadAct = (isPast || isNow) && act?.hasLoad ? act.loadKwh * 4 : null; const loadActPlan = (isPast || isNow) && act?.hasLoad ? act.loadKwhPlan * 4 : null; /* v764: consLd = kvartens lastavdrag i kW (quarters[].ld). consLdEst: hemmet har en last utan effektmätning och kvarten har avdrag, då är ld angiven effekt × körtid. ld är en summa över lasterna, så i ett hem med både mätta och omätta laster går det inte att skilja dem åt. */ const consLd = (isPast || isNow) && act?.ldW != null && isFinite(Number(act.ldW)) ? Number(act.ldW)/1000 : null; const consLdEst = consLd != null && consLd > 0 && units.loads.some((u) => !u.reportsPower) ? true : undefined; /* 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: spotFor(s), spotSource: Number.isFinite(s.spotPrice) ? 'backend' : 'härledd', feesIncluded: true, sol, cons, solPlan, consPlan, consPlanTot, solAct, consAct, solLive, consLive, consIncludesLoads, consBase, consLoadsAdd, loadAct, loadActPlan, consLd, consLdEst, // 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, // K1: uppskattad huskvart (home_est) — inlärningen får den inte, vyerna märker den. consEst: (isPast || isNow) && act?.est ? true : undefined, consEstReason: act?.est ? act.estReason : undefined, bat, batSource, batBankKw, batUnmeasured, batSrcLive, socPct, socs, socTot, socAggPct, socCapKwh, batUnitsAct: (isPast && Array.isArray(act?.batList)) ? act.batList.map((w) => w == null || !isFinite(Number(w)) ? null : Number(w)/1000) : null, // 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, /* NU-kvartens läge ur LIVE-beslutet när det finns (André 20/9: Schemas batterinod läste per_battery_actions och visade köp före lägesraden och Prognos-stapeln, som läste planen). Källa: l.action (aggregerat), annars första icke-AI-batteriets action. Okänt namn ⇒ planen. Övriga kvartar: planens action som förut. */ mode: (() => { if (!isNow) return action; const liveAct = (typeof l.action === 'string' && l.action) ? l.action : (liveActs || []).find(b => b && b.action && !b.ai_controlled)?.action; const m = liveAct ? ACTION_TO_MODE[liveAct] : null; return m || action; })(), modeSource: isNow && ((typeof l.action === 'string' && l.action) || (liveActs || []).some(b => b && b.action && !b.ai_controlled)) ? 'live' : 'plan', reason: s.reason || null, /* Säkringsbegränsning (kontrakt 20/9): vid NU ur live-kommandot (l.limit bredvid reason), annars planens/historikens per kvart. Testkrok ?limit=fuse0 | fuse3 | overload på NU-kvarten — gated (Claude Code 20/9, kroken låg i skarp drift): bara i mockläge (ingen sess_- token) eller med ?debug=1. Med riktig session och utan debug är backendens limit det enda som visas. */ limit: (() => { if (isNow){ try { const sp = new URLSearchParams(location.search); const tok = localStorage.getItem('hems_session_token') || ''; const mock = !(tok && tok.startsWith('sess_')); const q = (mock || sp.get('debug') === '1') ? sp.get('limit') : null; if (q === 'fuse0') return { by:'fuse', lp_kw:12, allowed_kw:0, house_kw:6.5, ev_kw:0, solar_kw:2.3, fuse_a:27, fuse_kw:18.7, text:'Säkringsskyddet stoppade laddningen: huset drar 6.5 kW och säkringen tillåter 18.7 kW.' }; if (q === 'fuse3') return { by:'fuse', lp_kw:12, allowed_kw:3, house_kw:6.5, ev_kw:0, solar_kw:2.3, fuse_a:27, fuse_kw:18.7, text:'Säkringsskyddet begränsade laddningen från 12 kW till 3 kW.' }; if (q === 'overload') return { by:'fuse_overload', grid_kw:19.1, battery_kw:5.0, streak:2, fuse_kw:18.7, text:'Importen 19.1 kW översteg säkringen 18.7 kW, laddningen pausad.' }; } catch(_){} } return isNow && l.limit && typeof l.limit === 'object' ? l.limit : (s.limit || 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 || {}, // Per fönsterindex (krav 3b): { : { energyKwh, day, i0, i1, remnant } }. byWin: lp.byWin || {}, })); /* Ä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; /* K1 (15/9, Andrés beslut 1 & 3): history.load_display_gross ⇒ husvärdena är BRUTTO (smartlasten tillbakalagd); vyerna får då INTE addera loadAct. home_est-summeringen räknas per dygn ur kvartarna (backendens toppnivåtal spänner alla dygn i svaret). */ if (off === 0){ out.loadDisplayGross = history?.load_display_gross === true; const estQ = out.filter(x => x.consEst); const reasons = {}; estQ.forEach(x => { reasons[x.consEstReason || 'unknown'] = (reasons[x.consEstReason || 'unknown'] || 0) + 1; }); out.homeEst = estQ.length ? { quarters: estQ.length, kwh: estQ.reduce((s, x) => s + (x.consAct || 0)*0.25, 0), reasons } : null; /* Pågående smartlastkörningar ur ackumulatorn (HEMS_LIVE_LOAD_ACC). */ out.loadRunsOpen = Array.isArray(live?.load_runs_open) ? live.load_runs_open : null; out.homeTodayKwh = typeof live?.home_today_kwh === 'number' ? live.home_today_kwh : 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 });