/* ================================================================ RUNTIME FINDER — PURE LOGIC CORE No DOM access in this section. qa.js extracts everything between the RF CORE markers and unit-tests it in Node. ================================================================ */ /* ===== RF CORE START ===== */ /* --------------------------------------------------------------- EDITABLE PRODUCT CONFIG — the ONLY place products are defined. name, wh (capacity), outW (continuous watts), surgeW (peak watts), price (APPROXIMATE US street price in USD), cls (size class it represents). Update prices here before deploying, and check the picks against the stations aindlix has actually reviewed. --------------------------------------------------------------- */ var RF_PRODUCTS = [ { name: "Bluetti EB3A", wh: 268, outW: 600, surgeW: 1200, price: 219, cls: 300 }, { name: "Jackery Explorer 300 Plus", wh: 288, outW: 300, surgeW: 600, price: 199, cls: 300 }, { name: "EcoFlow RIVER 2 Max", wh: 512, outW: 500, surgeW: 1000, price: 299, cls: 500 }, { name: "EcoFlow RIVER 2 Pro", wh: 768, outW: 800, surgeW: 1600, price: 429, cls: 700 }, { name: "EcoFlow DELTA 2", wh: 1024, outW: 1800, surgeW: 2700, price: 599, cls: 1000 }, { name: "Anker SOLIX C1000", wh: 1056, outW: 2000, surgeW: 3000, price: 649, cls: 1000 }, { name: "Jackery Explorer 1000 v2", wh: 1070, outW: 1500, surgeW: 3000, price: 749, cls: 1000 }, { name: "Bluetti AC180", wh: 1152, outW: 1800, surgeW: 2700, price: 549, cls: 1000 }, { name: "Bluetti AC180P", wh: 1440, outW: 1800, surgeW: 2700, price: 649, cls: 1500 }, { name: "EcoFlow DELTA 2 Max", wh: 2048, outW: 2400, surgeW: 4800, price: 1099, cls: 2000 }, { name: "Bluetti AC200L", wh: 2048, outW: 2400, surgeW: 3600, price: 1199, cls: 2000 }, { name: "Jackery Explorer 3000 Pro", wh: 3024, outW: 3000, surgeW: 6000, price: 1999, cls: 3000 }, { name: "EcoFlow DELTA Pro", wh: 3600, outW: 3600, surgeW: 7200, price: 2599, cls: 3600 } ]; var RF_AMAZON_TAG = "jagomin-20"; var RF_CLASSES = [300, 500, 700, 1000, 1500, 2000, 3000, 3600]; /* Typical surge rating (W) a station in each size class offers. Used for the class-level surge warning; individual products are checked too. */ var RF_CLASS_SURGE = { 300: 600, 500: 1000, 700: 1400, 1000: 2000, 1500: 3000, 2000: 4800, 3000: 6000, 3600: 7200 }; var RF_EFF_AC = 0.85; var RF_EFF_DC = 0.95; /* Reserve rule: a size class only counts if the load uses at most 90% of it. A battery should never be planned to absolute zero. This is also what makes the spec's worked examples reproduce: 1,497Wh rated lands in the 2,000Wh class, not the 1,500Wh class it would drain to 99.8%. */ var RF_RESERVE = 0.90; var RF_OUT_STEPS = [300, 500, 600, 800, 1000, 1200, 1500, 1800, 2000, 2400, 3000, 3600]; /* CPAP average draw by humidifier setting (spec preset table). */ var RF_CPAP_W = { off: 35, on: 60, tube: 90 }; /* Fridge presets: avgW already includes the ~35% duty cycle. */ var RF_FRIDGES = { mini: { label: "Mini fridge (3-4 cu ft)", avgW: 29 }, eff: { label: "Efficient full-size fridge", avgW: 49 }, typ: { label: "Typical full-size fridge", avgW: 63 }, french: { label: "Large French-door fridge", avgW: 80 }, old: { label: "Older fridge (10+ years)", avgW: 126 } }; var RF_FRIDGE_DUTY = 0.35; /* duty cycle behind the preset averages */ /* Camping / home-extra devices (spec preset table). defHrs = default hours per day. */ var RF_DEVICES = { carfridge: { label: "12V car fridge / cooler", w: 50, defHrs: 12 }, starlinkmini: { label: "Starlink Mini", w: 30, defHrs: 6 }, starlink: { label: "Starlink Standard", w: 75, defHrs: 6 }, laptop: { label: "Laptop", w: 65, defHrs: 5 }, phone: { label: "Phone charging", w: 15, defHrs: 3 }, led: { label: "LED light", w: 10, defHrs: 5 }, fan: { label: "Box / camping fan", w: 45, defHrs: 6 }, tv: { label: "LED TV (32-55 in)", w: 100, defHrs: 4 }, blanket: { label: "Electric blanket", w: 75, defHrs: 8 } }; /* Loads a portable station should honestly refuse (spec: NOT in v1). */ var RF_REFUSALS = { heater: { label: "Space heater", w: 1500 }, kettle: { label: "Electric kettle", w: 1200 }, hairdryer: { label: "Hair dryer", w: 1500 }, acunit: { label: "Portable air conditioner", w: 1000 } }; var RF_HOME_BASE = { routerW: 15, ledEachW: 10, phoneEachW: 15 }; var RF_STARLINK_W = { mini: 30, std: 75 }; /* ---------- small helpers ---------- */ function rfR1(x) { return Math.round(x * 10) / 10; } function rfFmt(n) { var s = String(Math.round(n)), out = "", i; for (i = 0; i < s.length; i++) { if (i > 0 && (s.length - i) % 3 === 0) { out += ","; } out += s.charAt(i); } return out; } function rfShow(x) { /* 1 decimal under 20, whole number above */ return x < 20 ? String(rfR1(x)) : String(Math.round(x)); } /* ---------- the verified formulas ---------- */ function rfRuntimeHours(stationWh, avgW, eff) { return stationWh * eff / avgW; } function rfRatedNeeded(energyWh, eff) { return energyWh / eff; } function rfClassFor(ratedWh) { var i; for (i = 0; i < RF_CLASSES.length; i++) { if (ratedWh <= RF_CLASSES[i] * RF_RESERVE) { return { cls: RF_CLASSES[i], idx: i, over: false }; } } return { cls: RF_CLASSES[RF_CLASSES.length - 1], idx: RF_CLASSES.length - 1, over: true }; } function rfMinOutputW(maxSingleW) { var i; for (i = 0; i < RF_OUT_STEPS.length; i++) { if (maxSingleW <= RF_OUT_STEPS[i]) { return RF_OUT_STEPS[i]; } } return RF_OUT_STEPS[RF_OUT_STEPS.length - 1]; } function rfFridgeRunningW(avgW) { return avgW / RF_FRIDGE_DUTY; } /* preset averages back to label watts */ function rfAmazonUrl(product) { return "https://www.amazon.com/s?k=" + encodeURIComponent(product.name) + "&tag=" + RF_AMAZON_TAG; } /* ---------- shared verdict assembly ---------- Every solver returns the same shape: { scenario, refusal, empty, energyWh, eff, ratedWh, clsWh, clsIdx, over, minOutputW, minSurgeW, surgeWarn, avgW, headline:{value,unit}, covered:{value,unit}, load:{kind,avgW,eff,sleepHrs,whPerDay}, notes:[..] } */ function rfFinishVerdict(v) { var c = rfClassFor(v.ratedWh); if (v.marginBump && !c.over) { var ni = Math.min(c.idx + 1, RF_CLASSES.length - 1); c = { cls: RF_CLASSES[ni], idx: ni, over: false }; } v.clsWh = c.cls; v.clsIdx = c.idx; v.over = c.over; v.surgeWarn = (v.minSurgeW > 0) && (v.minSurgeW > RF_CLASS_SURGE[v.clsWh]); return v; } function rfRefusalVerdict(scenario, item) { return { scenario: scenario, refusal: true, empty: false, refusalLabel: item.label, refusalW: item.w, energyWh: 0, eff: RF_EFF_AC, ratedWh: 0, clsWh: 0, clsIdx: -1, over: false, minOutputW: 0, minSurgeW: 0, surgeWarn: false, avgW: item.w, headline: { value: 0, unit: "" }, covered: { value: 0, unit: "" }, load: { kind: scenario, avgW: item.w, eff: RF_EFF_AC, sleepHrs: 8, whPerDay: 0 }, notes: [] }; } function rfEmptyVerdict(scenario) { return { scenario: scenario, refusal: false, empty: true, energyWh: 0, eff: RF_EFF_AC, ratedWh: 0, clsWh: 0, clsIdx: -1, over: false, minOutputW: 0, minSurgeW: 0, surgeWarn: false, avgW: 0, headline: { value: 0, unit: "" }, covered: { value: 0, unit: "" }, load: { kind: scenario, avgW: 0, eff: RF_EFF_AC, sleepHrs: 8, whPerDay: 0 }, notes: [] }; } /* ---------- Scenario 1: CPAP nights ---------- */ function rfCpapSolve(o) { var w = RF_CPAP_W[o.hum]; var eff = (o.cable === "dc") ? RF_EFF_DC : RF_EFF_AC; var energy = w * o.sleepHrs * o.nights; var rated = rfRatedNeeded(energy, eff); var v = { scenario: "cpap", refusal: false, empty: false, energyWh: energy, eff: eff, ratedWh: rated, minOutputW: rfMinOutputW(w), minSurgeW: 0, avgW: w, headline: { value: o.nights, unit: o.nights === 1 ? "night" : "nights" }, covered: { value: 0, unit: "nights" }, load: { kind: "cpap", avgW: w, eff: eff, sleepHrs: o.sleepHrs, whPerDay: w * o.sleepHrs }, notes: [], marginBump: !!o.shared, watts: w, sleepHrs: o.sleepHrs, nights: o.nights, cable: o.cable }; rfFinishVerdict(v); v.covered.value = rfRuntimeHours(v.clsWh, w, eff) / o.sleepHrs; if (o.cable === "ac") { var dcNights = rfRuntimeHours(v.clsWh, w, RF_EFF_DC) / o.sleepHrs; v.notes.push({ kind: "good", text: "Tip: with a 12V DC cable this same station lasts about " + rfShow(dcNights) + " nights instead of " + rfShow(v.covered.value) + " — the DC cable hands back roughly a third of the runtime the inverter takes away." }); } else { v.notes.push({ kind: "good", text: "DC cable selected, so the better 0.95 efficiency is already counted in these numbers." }); } if (o.shared) { v.notes.push({ kind: "warn", text: "We added one size class of headroom because other devices will share this station." }); } return v; } /* ---------- Scenario 2: Fridge in an outage ---------- */ function rfFridgeSolve(o) { /* o: {avgW, runningW, hours} — UI resolves presets/custom into these */ var energy = o.avgW * o.hours; var rated = rfRatedNeeded(energy, RF_EFF_AC); var minSurge = 3 * o.runningW; var v = { scenario: "fridge", refusal: false, empty: false, energyWh: energy, eff: RF_EFF_AC, ratedWh: rated, minOutputW: rfMinOutputW(o.runningW), minSurgeW: minSurge, avgW: o.avgW, headline: { value: o.hours, unit: "hours" }, covered: { value: 0, unit: "hours" }, load: { kind: "fridge", avgW: o.avgW, eff: RF_EFF_AC, sleepHrs: 8, whPerDay: o.avgW * 24 }, notes: [], marginBump: false, hours: o.hours, runningW: o.runningW }; rfFinishVerdict(v); v.covered.value = rfRuntimeHours(v.clsWh, o.avgW, RF_EFF_AC); v.notes.push({ kind: "warn", text: "Surge check: your fridge's compressor needs a station that lists at least " + rfFmt(minSurge) + "W of surge (peak) power. Enough capacity with too little surge still trips the station." }); if (v.surgeWarn) { v.notes.push({ kind: "bad", text: "Warning: stations in the " + rfFmt(v.clsWh) + "Wh class typically offer only about " + rfFmt(RF_CLASS_SURGE[v.clsWh]) + "W of surge — below what this fridge needs. Step up a class or pick a model with a higher listed surge." }); } return v; } /* ---------- Scenario 3: Camping / RV bundle ---------- */ function rfCampSolve(o) { /* o: {items:[{key,label,w,hrs,refusal}], days} */ var i, it; for (i = 0; i < o.items.length; i++) { if (o.items[i].refusal) { return rfRefusalVerdict("camp", o.items[i]); } } var whPerDay = 0, maxW = 0, used = 0; for (i = 0; i < o.items.length; i++) { it = o.items[i]; if (it.hrs > 0) { whPerDay += it.w * it.hrs; if (it.w > maxW) { maxW = it.w; } used++; } } if (used === 0 || whPerDay <= 0) { return rfEmptyVerdict("camp"); } var energy = whPerDay * o.days; var rated = rfRatedNeeded(energy, RF_EFF_AC); var v = { scenario: "camp", refusal: false, empty: false, energyWh: energy, eff: RF_EFF_AC, ratedWh: rated, minOutputW: rfMinOutputW(maxW), minSurgeW: 0, avgW: whPerDay / 24, headline: { value: o.days, unit: o.days === 1 ? "day" : "days" }, covered: { value: 0, unit: "days" }, load: { kind: "camp", avgW: whPerDay / 24, eff: RF_EFF_AC, sleepHrs: 8, whPerDay: whPerDay }, notes: [], marginBump: false, whPerDay: whPerDay, days: o.days }; rfFinishVerdict(v); v.covered.value = (v.clsWh * RF_EFF_AC) / whPerDay; v.notes.push({ kind: "good", text: "Your bundle uses about " + rfFmt(whPerDay) + "Wh per day. A solar panel in good sun can refill much of that daily — see the panel checker note below the results." }); return v; } /* ---------- Scenario 4: Home backup ---------- */ function rfHomeSolve(o) { /* o: {fridgeAvgW, fridgeRunningW, starlinkW, lights, phones, hours, extras:[{key,label,w,hrs,refusal}]} */ var i; for (i = 0; i < o.extras.length; i++) { if (o.extras[i].refusal) { return rfRefusalVerdict("home", o.extras[i]); } } var totalAvgW = o.fridgeAvgW + RF_HOME_BASE.routerW + o.lights * RF_HOME_BASE.ledEachW + o.phones * RF_HOME_BASE.phoneEachW + o.starlinkW; var energy = totalAvgW * o.hours; var maxW = o.fridgeRunningW; for (i = 0; i < o.extras.length; i++) { var ex = o.extras[i]; if (ex.hrs > 0) { energy += ex.w * Math.min(ex.hrs, o.hours); if (ex.w > maxW) { maxW = ex.w; } } } var rated = rfRatedNeeded(energy, RF_EFF_AC); var minSurge = 3 * o.fridgeRunningW; var v = { scenario: "home", refusal: false, empty: false, energyWh: energy, eff: RF_EFF_AC, ratedWh: rated, minOutputW: rfMinOutputW(maxW), minSurgeW: minSurge, avgW: energy / o.hours, headline: { value: o.hours, unit: "hours" }, covered: { value: 0, unit: "hours" }, load: { kind: "home", avgW: energy / o.hours, eff: RF_EFF_AC, sleepHrs: 8, whPerDay: (energy / o.hours) * 24 }, notes: [], marginBump: false, hours: o.hours, runningW: o.fridgeRunningW, totalAvgW: totalAvgW }; rfFinishVerdict(v); v.covered.value = rfRuntimeHours(v.clsWh, v.avgW, RF_EFF_AC); v.notes.push({ kind: "warn", text: "Surge check: your fridge's compressor needs a station that lists at least " + rfFmt(minSurge) + "W of surge (peak) power — check it before you buy." }); if (v.surgeWarn) { v.notes.push({ kind: "bad", text: "Warning: stations in the " + rfFmt(v.clsWh) + "Wh class typically offer only about " + rfFmt(RF_CLASS_SURGE[v.clsWh]) + "W of surge — below what this fridge needs. Step up a class or pick a model with a higher listed surge." }); } return v; } /* ---------- Product picking + per-product runtime ---------- */ function rfBestInClass(cls) { var best = null, i; for (i = 0; i < RF_PRODUCTS.length; i++) { if (RF_PRODUCTS[i].cls === cls && (best === null || RF_PRODUCTS[i].wh > best.wh)) { best = RF_PRODUCTS[i]; } } return best; } function rfPickProducts(v) { if (v.refusal || v.empty) { return null; } var idx = v.clsIdx, best = rfBestInClass(RF_CLASSES[idx]), cheaper = null, longer = null, longerNote = ""; var i, p; if (idx > 0) { cheaper = rfBestInClass(RF_CLASSES[idx - 1]); } if (cheaper === null) { /* smallest class: fall back to the other product in the same class */ for (i = 0; i < RF_PRODUCTS.length; i++) { p = RF_PRODUCTS[i]; if (p.cls === RF_CLASSES[idx] && p !== best && (cheaper === null || p.price < cheaper.price)) { cheaper = p; } } } if (idx < RF_CLASSES.length - 1) { longer = rfBestInClass(RF_CLASSES[idx + 1]); } if (longer === null) { longer = best; longerNote = "Shown with an expansion battery added — this is the top portable class."; } return { best: best, cheaper: cheaper, longer: longer, longerNote: longerNote }; } function rfProductRuntime(product, load) { if (load.kind === "cpap") { return { value: rfRuntimeHours(product.wh, load.avgW, load.eff) / load.sleepHrs, unit: "nights" }; } if (load.kind === "camp") { return { value: (product.wh * RF_EFF_AC) / load.whPerDay, unit: "days" }; } return { value: rfRuntimeHours(product.wh, load.avgW, load.eff), unit: "hours" }; /* fridge + home */ } function rfProductCautions(product, v) { var out = []; if (product.outW < v.minOutputW) { out.push("Check output: your setup needs at least " + rfFmt(v.minOutputW) + "W continuous; this model lists " + rfFmt(product.outW) + "W."); } if (v.minSurgeW > 0 && product.surgeW < v.minSurgeW) { out.push("Check surge: your fridge needs at least " + rfFmt(v.minSurgeW) + "W surge; this model lists " + rfFmt(product.surgeW) + "W."); } return out; } /* ---------- URL params: state <-> query ---------- State shapes (canonical, used by the UI and round-trip tested): cpap: {s:"cpap", hum:"off|on|tube", cable:"ac|dc", hrs:8, nights:1, shared:0|1} fridge: {s:"fridge", f:"mini|eff|typ|french|old|custom", nw:180, duty:35, hrs:24} camp: {s:"camp", days:3, items:{carfridge:12, led:5, heater:2}} (key -> hours/day, only ticked ones) home: {s:"home", f:"typ", nw:180, duty:35, sl:"off|mini|std", lights:2, phones:2, hrs:24, extras:{tv:4}} */ var RF_AMP = "\u0026"; /* ampersand, escaped so WP inline-serving cannot entity-encode it */ function rfStateToParams(st) { var pairs = [], k; pairs.push("s=" + encodeURIComponent(st.s)); if (st.s === "cpap") { pairs.push("hum=" + st.hum, "cable=" + st.cable, "hrs=" + st.hrs, "nights=" + st.nights, "shared=" + (st.shared ? 1 : 0)); } else if (st.s === "fridge") { pairs.push("f=" + st.f, "hrs=" + st.hrs); if (st.f === "custom") { pairs.push("nw=" + st.nw, "duty=" + st.duty); } } else if (st.s === "camp") { pairs.push("days=" + st.days); var list = []; for (k in st.items) { if (Object.prototype.hasOwnProperty.call(st.items, k)) { list.push(k + ":" + st.items[k]); } } if (list.length) { pairs.push("d=" + encodeURIComponent(list.join(","))); } } else if (st.s === "home") { pairs.push("f=" + st.f, "sl=" + st.sl, "lights=" + st.lights, "phones=" + st.phones, "hrs=" + st.hrs); if (st.f === "custom") { pairs.push("nw=" + st.nw, "duty=" + st.duty); } var xl = []; for (k in st.extras) { if (Object.prototype.hasOwnProperty.call(st.extras, k)) { xl.push(k + ":" + st.extras[k]); } } if (xl.length) { pairs.push("x=" + encodeURIComponent(xl.join(","))); } } return pairs.join(RF_AMP); } function rfParseQuery(qs) { var out = {}, parts, i, kv; if (qs.charAt(0) === "?") { qs = qs.substring(1); } if (!qs) { return out; } parts = qs.split(RF_AMP); for (i = 0; i < parts.length; i++) { kv = parts[i].split("="); if (kv.length >= 2) { out[decodeURIComponent(kv[0])] = decodeURIComponent(kv.slice(1).join("=")); } } return out; } function rfIntIn(val, dflt, min, max) { var n = parseInt(val, 10); if (isNaN(n)) { return dflt; } if (n < min) { return min; } if (n > max) { return max; } return n; } function rfParseItemList(str, allowed) { var out = {}, parts, i, kv, key, hrs; if (!str) { return out; } parts = String(str).split(","); for (i = 0; i < parts.length; i++) { kv = parts[i].split(":"); if (kv.length === 2) { key = kv[0]; hrs = parseInt(kv[1], 10); if (allowed[key] && !isNaN(hrs) && hrs > 0 && hrs <= 24) { out[key] = hrs; } } } return out; } function rfAllowedCamp() { var o = {}, k; for (k in RF_DEVICES) { o[k] = true; } for (k in RF_REFUSALS) { o[k] = true; } return o; } function rfAllowedHomeExtras() { return { tv: true, blanket: true, heater: true, kettle: true, hairdryer: true, acunit: true }; } function rfParamsToState(p) { var s = p.s || "cpap"; if (s === "fridge") { var ff = RF_FRIDGES[p.f] || p.f === "custom" ? p.f : "typ"; return { s: "fridge", f: ff, nw: rfIntIn(p.nw, 180, 60, 400), duty: rfIntIn(p.duty, 35, 20, 60), hrs: rfIntIn(p.hrs, 24, 1, 96) }; } if (s === "camp") { return { s: "camp", days: rfIntIn(p.days, 3, 1, 14), items: rfParseItemList(p.d, rfAllowedCamp()) }; } if (s === "home") { var hf = RF_FRIDGES[p.f] || p.f === "custom" ? p.f : "typ"; return { s: "home", f: hf, nw: rfIntIn(p.nw, 180, 60, 400), duty: rfIntIn(p.duty, 35, 20, 60), sl: (p.sl === "mini" || p.sl === "std") ? p.sl : "off", lights: rfIntIn(p.lights, 2, 0, 6), phones: rfIntIn(p.phones, 2, 0, 6), hrs: rfIntIn(p.hrs, 24, 1, 96), extras: rfParseItemList(p.x, rfAllowedHomeExtras()) }; } return { s: "cpap", hum: RF_CPAP_W[p.hum] ? p.hum : "off", cable: p.cable === "dc" ? "dc" : "ac", hrs: rfIntIn(p.hrs, 8, 4, 12), nights: rfIntIn(p.nights, 1, 1, 30), shared: p.shared === "1" ? 1 : 0 }; } function rfDefaultState() { return { s: "cpap", hum: "off", cable: "ac", hrs: 8, nights: 1, shared: 0 }; } /* ===== RF CORE END ===== */ /* ================================================================ RUNTIME FINDER — DOM LAYER (uses the pure core above) ================================================================ */ (function () { "use strict"; function el(id) { return document.getElementById(id); } function esc(s) { return String(s).replace(//g, "\u003e"); } /* ---------- per-scenario state cache ---------- */ var states = { cpap: rfDefaultState(), fridge: { s: "fridge", f: "typ", nw: 180, duty: 35, hrs: 24 }, camp: { s: "camp", days: 3, items: { carfridge: 12, led: 5 } }, home: { s: "home", f: "typ", nw: 180, duty: 35, sl: "off", lights: 2, phones: 2, hrs: 24, extras: {} } }; var current = "cpap"; /* ---------- build camping device rows + home extras ---------- */ function deviceRow(key, dev, danger) { var lab = document.createElement("label"); lab.className = "rf-check" + (danger ? " rf-danger" : ""); lab.innerHTML = '' + '' + esc(dev.label) + " (" + dev.w + 'W)' + (danger ? "Heating and cooling loads — read the honest verdict before sizing anything." : "About " + dev.w + "W average draw.") + "" + ' h/day'; return lab; } var campBox = el("camp-devices"), k; for (k in RF_DEVICES) { campBox.appendChild(deviceRow(k, RF_DEVICES[k], false)); } var dangerTitle = document.createElement("p"); dangerTitle.className = "rf-hint"; dangerTitle.style.margin = "10px 0 6px"; dangerTitle.textContent = "Big heating / cooling appliances:"; campBox.appendChild(dangerTitle); for (k in RF_REFUSALS) { campBox.appendChild(deviceRow(k, RF_REFUSALS[k], true)); } var homeBox = el("home-extras"); homeBox.appendChild(deviceRow("tv", RF_DEVICES.tv, false)); homeBox.appendChild(deviceRow("blanket", RF_DEVICES.blanket, false)); for (k in RF_REFUSALS) { homeBox.appendChild(deviceRow(k, RF_REFUSALS[k], true)); } /* ---------- segmented controls ---------- */ function segSet(box, val) { var btns = box.querySelectorAll("button"), i; for (i = 0; i < btns.length; i++) { btns[i].setAttribute("aria-pressed", btns[i].getAttribute("data-val") === String(val) ? "true" : "false"); } } function segGet(box) { var b = box.querySelector('button[aria-pressed="true"]'); return b ? b.getAttribute("data-val") : null; } function segWire(box, cb) { box.addEventListener("click", function (e) { var t = e.target; if (t && t.tagName === "BUTTON") { segSet(box, t.getAttribute("data-val")); cb(); } }); } /* ---------- read UI into state ---------- */ function readCpap() { var st = states.cpap; st.hum = segGet(el("cpap-hum")) || "off"; st.cable = segGet(el("cpap-cable")) || "ac"; st.hrs = parseInt(el("cpap-sleep").value, 10) || 8; st.nights = parseInt(segGet(el("cpap-nights")) || "1", 10); st.shared = el("cpap-shared").checked ? 1 : 0; } function readFridgeInto(st, prefix) { st.f = el(prefix + "-preset").value; st.nw = parseInt(el(prefix + "-nw").value, 10) || 180; st.duty = parseInt(el(prefix + "-duty").value, 10) || 35; } function readFridge() { var st = states.fridge; readFridgeInto(st, "fridge"); st.hrs = parseInt(segGet(el("fridge-hours")) || "24", 10); } function readDeviceBox(box) { var items = {}, checks = box.querySelectorAll("input[data-dev]"), i, key, hrsEl, hrs; for (i = 0; i < checks.length; i++) { if (checks[i].checked) { key = checks[i].getAttribute("data-dev"); hrsEl = box.querySelector('input[data-hrs="' + key + '"]'); hrs = hrsEl ? parseInt(hrsEl.value, 10) : 0; if (isNaN(hrs) || hrs < 1) { hrs = 1; } if (hrs > 24) { hrs = 24; } items[key] = hrs; } } return items; } function readCamp() { var st = states.camp; st.days = parseInt(el("camp-days").value, 10) || 3; st.items = readDeviceBox(el("camp-devices")); } function readHome() { var st = states.home; readFridgeInto(st, "home"); st.sl = segGet(el("home-starlink")) || "off"; st.hrs = parseInt(segGet(el("home-hours")) || "24", 10); st.lights = parseInt(el("home-lights-val").textContent, 10) || 0; st.phones = parseInt(el("home-phones-val").textContent, 10) || 0; st.extras = readDeviceBox(el("home-extras")); } function readCurrent() { if (current === "cpap") { readCpap(); } else if (current === "fridge") { readFridge(); } else if (current === "camp") { readCamp(); } else { readHome(); } } /* ---------- write state into UI ---------- */ function setDeviceBox(box, items) { var checks = box.querySelectorAll("input[data-dev]"), i, key, hrsEl; for (i = 0; i < checks.length; i++) { key = checks[i].getAttribute("data-dev"); checks[i].checked = !!items[key]; hrsEl = box.querySelector('input[data-hrs="' + key + '"]'); if (hrsEl && items[key]) { hrsEl.value = items[key]; } } } function writeFridgeInto(st, prefix) { el(prefix + "-preset").value = st.f; el(prefix + "-custom").hidden = st.f !== "custom"; el(prefix + "-nw").value = st.nw; el(prefix + "-nw-val").textContent = st.nw; el(prefix + "-duty").value = st.duty; el(prefix + "-duty-val").textContent = st.duty; } function applyState(st) { if (st.s === "cpap") { segSet(el("cpap-hum"), st.hum); segSet(el("cpap-cable"), st.cable); el("cpap-sleep").value = st.hrs; el("cpap-sleep-val").textContent = st.hrs; segSet(el("cpap-nights"), st.nights); el("cpap-shared").checked = !!st.shared; } else if (st.s === "fridge") { writeFridgeInto(st, "fridge"); segSet(el("fridge-hours"), st.hrs); } else if (st.s === "camp") { el("camp-days").value = st.days; el("camp-days-val").textContent = st.days; setDeviceBox(el("camp-devices"), st.items); } else if (st.s === "home") { writeFridgeInto(st, "home"); segSet(el("home-starlink"), st.sl); segSet(el("home-hours"), st.hrs); el("home-lights-val").textContent = st.lights; el("home-phones-val").textContent = st.phones; setDeviceBox(el("home-extras"), st.extras); } } /* ---------- solve ---------- */ function fridgeResolved(st) { if (st.f === "custom") { return { avgW: st.nw * st.duty / 100, runningW: st.nw }; } return { avgW: RF_FRIDGES[st.f].avgW, runningW: rfFridgeRunningW(RF_FRIDGES[st.f].avgW) }; } function resolveItems(map) { var out = [], key, dev, ref; for (key in map) { if (!Object.prototype.hasOwnProperty.call(map, key)) { continue; } dev = RF_DEVICES[key]; ref = RF_REFUSALS[key]; if (dev) { out.push({ key: key, label: dev.label, w: dev.w, hrs: map[key], refusal: false }); } else if (ref) { out.push({ key: key, label: ref.label, w: ref.w, hrs: map[key], refusal: true }); } } return out; } function solve(st) { var fr; if (st.s === "cpap") { return rfCpapSolve({ hum: st.hum, cable: st.cable, sleepHrs: st.hrs, nights: st.nights, shared: !!st.shared }); } if (st.s === "fridge") { fr = fridgeResolved(st); return rfFridgeSolve({ avgW: fr.avgW, runningW: fr.runningW, hours: st.hrs }); } if (st.s === "camp") { return rfCampSolve({ items: resolveItems(st.items), days: st.days }); } fr = fridgeResolved(st); return rfHomeSolve({ fridgeAvgW: fr.avgW, fridgeRunningW: fr.runningW, starlinkW: st.sl === "mini" ? RF_STARLINK_W.mini : (st.sl === "std" ? RF_STARLINK_W.std : 0), lights: st.lights, phones: st.phones, hours: st.hrs, extras: resolveItems(st.extras) }); } /* ---------- render ---------- */ function notesHtml(notes) { var out = "", i; for (i = 0; i < notes.length; i++) { out += '
A ' + esc(v.refusalLabel.toLowerCase()) + " pulls about " + rfFmt(v.refusalW) + 'W. Even the biggest portable station in this finder would be empty in under an hour or two, and a small one in minutes — that is not backup power, that is an expensive disappointment. For heating and cooling loads you need a different solution: a generator, a large fixed home battery, or riding out the outage without it. Untick it and we will happily size everything else.