From dfdf3d816686ff2f2cb50d8c87852789371a980e Mon Sep 17 00:00:00 2001 From: Roland Date: Sun, 4 Oct 2026 17:33:55 -0600 Subject: [PATCH] Add off-grid sizing to the Solar calculator A new "Off-grid sizing" tab sizes a battery system for the darkest month from a list of devices (watts x hours per day): - Battery bank for a number of days without sun, for LiFePO4 or lead-acid, at 12, 24 or 48 V. Outdoor lead-acid is enlarged for the cold; outdoor lithium warns against charging below 0 C. - Panels needed, from the worst month's DC production per kW at the chosen angle, with a button to apply the count. A PWM controller's loss (battery voltage over the panel's Vmp) raises the count. - Charge controller current, and the string voltage on the coldest morning against the controller's limit, since cold panels exceed their rated Voc. The coldest morning defaults to 25 C below the coldest monthly average. All inputs live in the URL like the rest of the calculator. Co-Authored-By: Claude Opus 5.5 --- .../components/calculators/SolarCalculator.js | 29 +- .../components/calculators/SolarOffGrid.js | 337 ++++++++++++++++++ .../calculators/SolarOffGrid.test.js | 141 ++++++++ app/src/components/calculators/offgrid.js | 194 ++++++++++ .../components/calculators/offgrid.test.js | 146 ++++++++ 5 files changed, 843 insertions(+), 4 deletions(-) create mode 100644 app/src/components/calculators/SolarOffGrid.js create mode 100644 app/src/components/calculators/SolarOffGrid.test.js create mode 100644 app/src/components/calculators/offgrid.js create mode 100644 app/src/components/calculators/offgrid.test.js diff --git a/app/src/components/calculators/SolarCalculator.js b/app/src/components/calculators/SolarCalculator.js index aa38b1d0..2e078f10 100644 --- a/app/src/components/calculators/SolarCalculator.js +++ b/app/src/components/calculators/SolarCalculator.js @@ -15,6 +15,7 @@ import { Statistic, StatisticGroup, Table, + Tabs, TextInput, Toggle, } from "../ui"; @@ -36,6 +37,7 @@ import { TRACKING, } from "./solar"; import {CLIMATOLOGY_SOURCE, lookupClimatology, useClimatology} from "./solarData"; +import {SolarOffGrid} from "./SolarOffGrid"; // Every input lives in the URL query so the Share button (which shares window.location.href) shares // the whole configuration. Values equal to their default are left out to keep the URL, and its QR @@ -526,6 +528,12 @@ export function SolarCalculator() { () => (ready ? estimateProduction(modelInputs(s, sunlight)) : null), // eslint-disable-next-line react-hooks/exhaustive-deps [s, sunlight, ready]); + // Off-grid sizing works from DC production per kW of panels, whatever the inverter setting. + const worstPerKwp = React.useMemo( + () => (ready ? estimateProduction({...modelInputs(s, sunlight), kwp: 1, inverter: null}).worst : null), + // eslint-disable-next-line react-hooks/exhaustive-deps + [s, sunlight, ready]); + const tab = get('tab') === 'offgrid' ? 'offgrid' : 'estimate'; const angleTitle = s.tracking === 'fixed' ? `Panel angle: ${s.tilt}° tilt, facing ${compassLabel(s.azimuth)}` @@ -544,10 +552,23 @@ export function SolarCalculator() { - {ready && } - {!ready && !waitingForData &&
- Enter {missing.join(', ')} to see an estimate. -
} + set({tab: v === 'offgrid' ? v : null})} keepMounted={false} + style={{marginTop: '1.5em'}}> + + Estimate + Off-grid sizing + + + {ready && } + {!ready && !waitingForData &&
+ Enter {missing.join(', ')} to see an estimate. +
} +
+ + + +
Fine-tune (optional)
diff --git a/app/src/components/calculators/SolarOffGrid.js b/app/src/components/calculators/SolarOffGrid.js new file mode 100644 index 00000000..69ea3d44 --- /dev/null +++ b/app/src/components/calculators/SolarOffGrid.js @@ -0,0 +1,337 @@ +import React from "react"; +import {Button, Grid, Group, Header, IconButton, Message, NumberInput, Panel, Select, Statistic, StatisticGroup, + Table, TextInput, Toggle} from "../ui"; +import {roundDigits} from "../Common"; +import { + BATTERIES, + batteryWarnings, + CONTROLLERS, + controllerSizing, + dailyLoadWh, + defaultColdest, + formatLoads, + leadAcidCapacityFactor, + loadWh, + parseLoads, + requiredKwp, + sizeBattery, + SYSTEM_VOLTAGES, +} from "./offgrid"; + +// Defaults are typical of a 400 W residential panel; the user should copy their panel's label. +const PANEL_DEFAULTS = {voc: 37, vmp: 31, isc: 13.8, bvoc: -0.27}; +const DEFAULT_LOAD_INVERTER_EFFICIENCY = 90; +const DEFAULT_DAYS = 2; +const DEFAULT_CONTROLLER_MAX_VOLTAGE = 100; +// Above this, a higher system voltage needs a much smaller controller and thinner wire. +const HIGH_CURRENT = 60; + +const numberOr = (value, fallback) => { + if (value === null || value === undefined || value === '') { + return fallback; + } + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +}; + +const fmt = (value, digits = 0) => (Number.isFinite(value) ? `${roundDigits(value, digits)}` : '—'); + +const Hint = ({children}) =>

{children}

; + +// The off-grid inputs, read from the URL like the rest of the calculator. Invalid values fall back. +export function offGridFromParams(get, monthlyTemperatures) { + const battery = BATTERIES[get('bat')] ? get('bat') : 'lfp'; + const systemVoltage = SYSTEM_VOLTAGES.includes(Number(get('sv'))) ? Number(get('sv')) : 12; + const controller = CONTROLLERS[get('ctl')] ? get('ctl') : 'mppt'; + const coldestMonthly = (monthlyTemperatures || []).filter(Number.isFinite).length + ? Math.min(...monthlyTemperatures.filter(Number.isFinite)) : null; + const defaultTmin = defaultColdest(monthlyTemperatures); + return { + loads: parseLoads(get('load')), + acLoads: get('ac') !== '0', + loadInverterEfficiency: numberOr(get('leff'), DEFAULT_LOAD_INVERTER_EFFICIENCY), + battery, + systemVoltage, + dod: numberOr(get('dod'), BATTERIES[battery].dod), + days: Math.max(0, numberOr(get('aut'), DEFAULT_DAYS)), + outdoors: get('bout') === '1', + controller, + voc: numberOr(get('voc'), PANEL_DEFAULTS.voc), + vmp: numberOr(get('vmp'), PANEL_DEFAULTS.vmp), + isc: numberOr(get('isc'), PANEL_DEFAULTS.isc), + betaVoc: numberOr(get('bvoc'), PANEL_DEFAULTS.bvoc), + series: Math.max(1, Math.round(numberOr(get('ns'), 1))), + controllerMaxVoltage: numberOr(get('cmax'), DEFAULT_CONTROLLER_MAX_VOLTAGE), + coldestMonthly, + defaultTmin, + coldest: numberOr(get('tmin'), defaultTmin), + }; +} + +/** + * Everything the off-grid tab shows, from its inputs. + * + * @param {Object} o offGridFromParams() + * @param {Object} worst the worst month for a 1 kW array: {month, energyDaily} + * @param {number} panelWatts + * @param {number} panelCount + */ +export function offGridResults(o, worst, panelWatts, panelCount) { + const devicesWh = dailyLoadWh(o.loads); + // AC devices draw through an inverter, so the batteries supply more than the devices use. + const batteryLoadWh = o.acLoads && o.loadInverterEfficiency > 0 + ? devicesWh / (o.loadInverterEfficiency / 100) : devicesWh; + const capacityFactor = o.outdoors && o.battery === 'lead' ? leadAcidCapacityFactor(o.coldestMonthly) : 1; + const bank = sizeBattery({ + loadWh: batteryLoadWh, days: o.days, dod: o.dod, voltage: o.systemVoltage, capacityFactor, + }); + + const sizing = controllerSizing({ + panelCount, panelWatts, series: o.series, voc: o.voc, vmp: o.vmp, isc: o.isc, betaVoc: o.betaVoc, + coldest: o.coldest, controller: o.controller, controllerMaxVoltage: o.controllerMaxVoltage, + systemVoltage: o.systemVoltage, + }); + + // A PWM controller wastes part of every panel's output, so more panels are needed. + const worstPerKwp = worst ? worst.energyDaily * sizing.pwmFraction : null; + const kwp = requiredKwp(batteryLoadWh, worstPerKwp, BATTERIES[o.battery].efficiency); + const panelsNeeded = kwp && panelWatts > 0 ? Math.ceil(kwp * 1000 / panelWatts) : null; + const yourArrayWh = worstPerKwp !== null + ? worstPerKwp * (panelCount * panelWatts / 1000) * 1000 * BATTERIES[o.battery].efficiency / 100 : null; + + return { + devicesWh, batteryLoadWh, bank, kwp, panelsNeeded, sizing, + coverage: batteryLoadWh > 0 && yourArrayWh !== null ? yourArrayWh / batteryLoadWh : null, + warnings: batteryWarnings({battery: o.battery, outdoors: o.outdoors, coldestMonthly: o.coldestMonthly}), + }; +} + +function LoadsSection({o, r, set, setNumber}) { + const rows = o.loads.length ? o.loads : [{name: '', watts: '', hours: ''}]; + const write = next => set({load: next.some(row => row.name || row.watts || row.hours) ? formatLoads(next) : null}); + const update = (index, field, value) => write(rows.map((row, i) => (i === index ? {...row, [field]: value} : row))); + const remove = index => write(rows.filter((_, i) => i !== index)); + + return <> +
Your devices
+ + List what you will run and for how long each day. The watts are on the device's label or power supply; + a refrigerator cycles on and off, so use about a third of its rating for 24 hours. + + + + + Device + Watts + Hours per day + Wh per day + + + + + {rows.map((row, i) => + + update(i, 'name', e.target.value)}/> + + + update(i, 'watts', v)}/> + + + update(i, 'hours', v)}/> + + {fmt(loadWh(row))} + + remove(i)}/> + + )} + +
+ + + {fmt(r.devicesWh)} Wh per day + + + set({ac: e.currentTarget.checked ? null : '0'})}/> + {o.acLoads && setNumber('leff', v, DEFAULT_LOAD_INVERTER_EFFICIENCY)}/>} + + ; +} + +function BatterySection({o, set, setNumber}) { + return <> +
Batteries
+ + Days without sun is how long the batteries must carry your devices through cloudy weather. Two or three + days is typical. Larger systems (over about 1,500 W of panels) usually use 24 or 48 volts. + + + + ({value: `${v}`, label: `${v} V`}))} + value={`${o.systemVoltage}`} allowDeselect={false} + onChange={v => set({sv: v === '12' ? null : v})}/> + + + setNumber('aut', v, DEFAULT_DAYS)}/> + + + setNumber('dod', v, BATTERIES[o.battery].dod)}/> + + + set({bout: e.currentTarget.checked ? '1' : null})}/> + + + ; +} + +function NeedsSection({o, r, worst, s, setNumber}) { + if (!(r.devicesWh > 0)) { + return
+ List your devices above to size the system. +
; + } + const coverage = r.coverage; + return +
What you need
+ + + + + + +

+ Sized for {worst.month}, your darkest month, when each kW of panels makes about{' '} + {fmt(worst.energyDaily, 1)} kWh a day at the angle chosen under Fine-tune. + {o.controller === 'pwm' && ` The PWM controller uses only ${fmt(r.sizing.pwmFraction * 100)}% of the ` + + 'panels\' power, so more panels are needed.'} +

+ {coverage !== null &&

+ Your {s.panelCount} panel{s.panelCount === 1 ? '' : 's'} would supply about{' '} + {fmt(Math.min(coverage, 9.99) * 100)}% of your use in {worst.month}.{' '} + {r.panelsNeeded !== s.panelCount && r.panelsNeeded > 0 && + } +

} + {r.warnings.map(w => {w})} +
; +} + +function ControllerSection({o, r, set, setNumber}) { + const z = r.sizing; + const panelField = (key, label, value, fallback, step) => + setNumber(key, v, fallback)}/> + ; + + return <> +
Charge controller and wiring
+ + Copy these from the label on the back of your panel. Panels make a higher voltage when cold; too many in + series can exceed the controller's limit on a cold morning and destroy it. The coldest morning starts + 25 °C below your coldest monthly average, because averages hide cold snaps; enter your area's record + low if you know it. + + + {panelField('voc', 'Open-circuit voltage, Voc (V)', o.voc, PANEL_DEFAULTS.voc, 0.1)} + {panelField('vmp', 'Max-power voltage, Vmp (V)', o.vmp, PANEL_DEFAULTS.vmp, 0.1)} + {panelField('isc', 'Short-circuit current, Isc (A)', o.isc, PANEL_DEFAULTS.isc, 0.1)} + {panelField('bvoc', 'Voc temperature coefficient (%/°C)', o.betaVoc, PANEL_DEFAULTS.bvoc, 0.01)} + +