/* data.jsx — mock data + scenario generator */ // Seeded PRNG so charts are stable across renders. function mulberry32(a){ return function(){ a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; } } // Generate 48h * 4 quarters of price + plan starting at midnight of "today". function makeSchedule(seedKey, weather){ const rnd = mulberry32(seedKey); const slots = []; // 192 quarter-hour slots for (let i=0;i<192;i++){ const hour = (i/4) % 24; const day = Math.floor(i/96); // Two-peak daily curve: morning + evening const morn = Math.exp(-Math.pow((hour-7.2)/1.8, 2)) * 110; const eve = Math.exp(-Math.pow((hour-18.5)/2.0, 2)) * 130; const base = 55 + morn + eve; const noise = (rnd()-0.5) * 28; const dayDrift = day === 0 ? 0 : -8 + (rnd()-0.5)*10; const buy = Math.max(28, base + noise + dayDrift); const sell = Math.max(20, buy * 0.42 + (rnd()-0.5)*8); // Solar production (peak ~13:00). Weather scales it. const solBell = Math.exp(-Math.pow((hour-12.8)/3.0, 2)); const solScale = weather === 'sunny' ? 8.2 : weather === 'mixed' ? 5.0 : 1.8; const solar = Math.max(0, solBell * solScale + (rnd()-0.5)*0.6); // House baseload + cooking/evening const baseload = 0.35 + Math.exp(-Math.pow((hour-7.8)/1.0,2))*1.6 + Math.exp(-Math.pow((hour-19.0)/1.2,2))*2.4; const consumption = Math.max(0.25, baseload + (rnd()-0.5)*0.25); // "Actual" series — only meaningful for past slots (relative to nowIdx). // Sprinkle realistic deviation so forecast vs outcome is visibly different. const solDev = (rnd()-0.5) * Math.max(.4, solar*0.35) + (weather==='mixed'? (rnd()-0.5)*1.2 : 0); const consDev = (rnd()-0.5) * Math.max(.25, consumption*0.18); const actualSolar = Math.max(0, solar + solDev); const actualConsumption = Math.max(0.15, consumption + consDev); // Fryst prognos "inför timmen" (backend: solar_pattern.frozen_forecast_hourly, // kWh per klocktimme, skrivs aldrig om). Ett värde PER klocktimme (konstant // över de 4 kvartarna), medvetet skilt från både den levande prognosen // (solar) och utfallet (actualSolar) så avvikelse-banden blir meningsfulla. // null för ett par timmar → demonstrerar gap-hanteringen ("gap, inte nolla": // feature nyss aktiverad / datastopp). Bara idag (day 0). const clockHour = Math.floor(hour); let frozenSolar = null; if (day === 0){ const gap = (clockHour === 6 || clockHour === 13); // saknat värde → null if (!gap){ const hourBell = Math.exp(-Math.pow((clockHour-12.8)/3.0, 2)) * solScale; const bias = Math.sin(clockHour*1.3) * 0.20; // deterministisk ±20 % frozenSolar = Math.max(0, hourBell * (1 + bias)); } } slots.push({i, hour, day, buy, sell, solar, consumption, actualSolar, actualConsumption, frozenSolar, action:'vanta'}); } // Battery sim — pick actions per slot. // Action codes (matches reference legend): // ladda_nat — Ladda (nät) blue // ladda_sol — Ladda (sol) amber // tack_huset — Täck huset orange // salj_sol — Sälj sol green // vanta — Vänta gray // exportera — Exportera dark green // peak — Peak shaving red // nollexport — Nollexport cool gray // observera — Observera light gray // oforandrat — Oförändrat amber-orange const sortedByPrice = [...slots].map((s,i)=>({i, p:s.buy})).sort((a,b)=>a.p-b.p); const cheapestIdx = new Set(sortedByPrice.slice(0,18).map(s=>s.i)); // bottom slots → ladda nät const priciestIdx = new Set(sortedByPrice.slice(-18).map(s=>s.i)); // top slots → tack huset / peak / salj let soc = 38; // % const cap = 13.5; // kWh battery for (const s of slots){ const surplus = s.solar - s.consumption; if (s.solar > 0.4 && surplus > 0.4){ // sun is generating more than home needs if (soc < 92 && s.buy < 130) s.action = 'ladda_sol'; else if (s.buy > 180) s.action = 'salj_sol'; else if (s.buy > 100) s.action = 'salj_batteri'; else s.action = 'salj_sol'; } else if (priciestIdx.has(s.i) && soc > 25){ s.action = s.buy > 220 ? 'peak' : 'tack_huset'; } else if (cheapestIdx.has(s.i) && soc < 88){ s.action = 'ladda_nat'; } else if (s.buy > 160 && soc > 22){ s.action = 'tack_huset'; } else if (Math.abs(surplus) < 0.15){ s.action = 'nollexport'; } else if (s.solar > 0.2 && surplus > 0){ s.action = 'salj_batteri'; } else { s.action = 'vanta'; } // crude SoC update if (s.action === 'ladda_nat') soc += 1.6; else if (s.action === 'ladda_sol') soc += Math.min(2.0, surplus*0.9); else if (s.action === 'tack_huset' || s.action === 'peak') soc -= Math.min(2.0, s.consumption*0.7); else if (s.action === 'salj_sol') soc -= 0.4; soc = Math.max(8, Math.min(98, soc)); s.soc = soc; } /* Ärliga solserier för demon (SPEC 2026-08-26), samma kontrakt som hems-adapter: frozen ändras aldrig under dagen (deterministisk ur pure), actual är null från innevarande timme (omätt ≠ 0). */ const hFrozen = [], hPure = [], hAct = []; const demoNowH = (window.hemsDate ? window.hemsDate() : new Date()).getHours(); for (let h = 0; h < 24; h++){ const qs = slots.slice(h*4, h*4 + 4); const pure = qs.reduce((acc, s) => acc + s.solar, 0) * 0.25; hPure.push(Math.round(pure*1000)/1000); hFrozen.push(Math.max(0, Math.round(pure*(1 + Math.sin(h*1.3)*0.12)*1000)/1000)); hAct.push(h < demoNowH ? Math.round(qs.reduce((acc, s) => acc + s.actualSolar, 0)*0.25*1000)/1000 : null); } /* Demo-mätare: Σ mätta timmar + ~40 min in i pågående (mätaren ligger FÖRE honest-serien, som i produktionen). */ slots.solarLiveKwh = Math.round((hAct.reduce((a,v) => a + (v||0), 0) + (hPure[demoNowH]||0)*0.65)*10)/10; /* Demovärden så markören går att se; skarpt kommer talen ur timdocs. */ /* isToday/date MÅSTE med — utan dem hamnar texten i den odaterade grenen och demon visar en variant som skarpt läge aldrig ska nå. */ slots.dataQuality = { nightSolar: 3, balance: null, isToday: true, date: window.hemsToday ? window.hemsToday() : null }; slots.solarHonest = { frozen: hFrozen, pure: hPure, actual: hAct, accuracy: { day: 0.9542, d7: 0.9231, d30: 0.9057, last_date: null, updated: null } }; return slots; } const ACTION_META_FALLBACK = { label:'Okänd', color:'var(--ink-3)', desc:'' }; const _ACTION_META_RAW = { ladda_nat: { label:'Ladda (nät)', color:'var(--c-charge-net)', desc:'Vi köper billig el nu för att tjäna senare.' }, ladda_sol: { label:'Ladda (sol)', color:'var(--c-charge-sol)', desc:'Gratis sol-överskott lagras i batteriet.' }, tack_huset: { label:'Täck huset', color:'var(--c-cover)', desc:'Batteriet driver huset så vi slipper köpa el.' }, salj_sol: { label:'Sälj sol', color:'var(--c-sell)', desc:'Vi säljer sol direkt — bättre nu än senare.' }, salj_batteri: { label:'Export (batteri)', color:'var(--c-export)', desc:'Batteriet tömmer till nätet — säljpriset är bra just nu.' }, 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.' }, }; // Wrap in a Proxy so unknown action keys never crash the UI const ACTION_META = new Proxy(_ACTION_META_RAW, { get(t, k){ return t[k] || ACTION_META_FALLBACK; } }); const SCENARIOS = { sunny: { label:'Sol & vind', weather:'sunny', solIcon:'sun', temp:18, wind:5, prodToday:42.6 }, mixed: { label:'Växlande', weather:'mixed', solIcon:'cloudy', temp:11, wind:8, prodToday:24.1 }, cloudy: { label:'Mulet & dyrt', weather:'cloudy',solIcon:'cloud', temp: 4, wind:3, prodToday: 6.8 }, }; // Format helpers const fmt = { ore: v => Math.round(v).toLocaleString('sv-SE'), kr: v => v.toLocaleString('sv-SE', { minimumFractionDigits:0, maximumFractionDigits:0 }), kwh: v => v.toLocaleString('sv-SE', { minimumFractionDigits:1, maximumFractionDigits:1 }), kwh0: v => v.toLocaleString('sv-SE', { minimumFractionDigits:0, maximumFractionDigits:0 }), hhmm: q => { const h = Math.floor(q/4), m = (q%4)*15; return String(h).padStart(2,'0')+':'+String(m).padStart(2,'0'); }, pct: v => Math.round(v) + '%', }; Object.assign(window, { makeSchedule, ACTION_META, SCENARIOS, fmt });