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 <noreply@anthropic.com>
This commit is contained in:
Roland 2026-10-04 17:33:55 -06:00
parent b5ea2054c1
commit dfdf3d8166
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
5 changed files with 843 additions and 4 deletions

View file

@ -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() {
<LocationSection get={get} set={set} s={s} climatology={climatology}/>
<PanelsSection setNumber={setNumber} s={s}/>
{ready && <Results result={result} s={s}/>}
{!ready && !waitingForData && <div style={{marginTop: '1em'}}>
<Message kind='info' title='Almost there'>Enter {missing.join(', ')} to see an estimate.</Message>
</div>}
<Tabs value={tab} onChange={v => set({tab: v === 'offgrid' ? v : null})} keepMounted={false}
style={{marginTop: '1.5em'}}>
<Tabs.List>
<Tabs.Tab value='estimate'>Estimate</Tabs.Tab>
<Tabs.Tab value='offgrid'>Off-grid sizing</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value='estimate'>
{ready && <Results result={result} s={s}/>}
{!ready && !waitingForData && <div style={{marginTop: '1em'}}>
<Message kind='info' title='Almost there'>Enter {missing.join(', ')} to see an estimate.</Message>
</div>}
</Tabs.Panel>
<Tabs.Panel value='offgrid'>
<SolarOffGrid get={get} set={set} setNumber={setNumber} s={s} worst={worstPerKwp}
temperatures={sunlight?.temperature}/>
</Tabs.Panel>
</Tabs>
<Header as='h3' style={{marginTop: '1.5em'}}>Fine-tune (optional)</Header>
<Accordion multiple>

View file

@ -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}) => <p style={{fontSize: '0.9em', opacity: 0.75, marginTop: 0}}>{children}</p>;
// 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 <>
<Header as='h3'>Your devices</Header>
<Hint>
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.
</Hint>
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Device</Table.HeaderCell>
<Table.HeaderCell>Watts</Table.HeaderCell>
<Table.HeaderCell>Hours per day</Table.HeaderCell>
<Table.HeaderCell>Wh per day</Table.HeaderCell>
<Table.HeaderCell/>
</Table.Row>
</Table.Header>
<Table.Body>
{rows.map((row, i) => <Table.Row key={i}>
<Table.Cell>
<TextInput aria-label={`Device ${i + 1} name`} placeholder='Refrigerator' value={row.name}
onChange={e => update(i, 'name', e.target.value)}/>
</Table.Cell>
<Table.Cell>
<NumberInput aria-label={`Device ${i + 1} watts`} placeholder='50' value={row.watts} min={0}
hideControls onChange={v => update(i, 'watts', v)}/>
</Table.Cell>
<Table.Cell>
<NumberInput aria-label={`Device ${i + 1} hours`} placeholder='24' value={row.hours} min={0}
max={24} hideControls onChange={v => update(i, 'hours', v)}/>
</Table.Cell>
<Table.Cell>{fmt(loadWh(row))}</Table.Cell>
<Table.Cell>
<IconButton icon='trash' label={`Remove device ${i + 1}`} onClick={() => remove(i)}/>
</Table.Cell>
</Table.Row>)}
</Table.Body>
</Table>
<Group gap='md' style={{marginTop: '0.5em'}}>
<Button type='button' size='small' icon='add'
onClick={() => write([...rows, {name: '', watts: '', hours: ''}])}>
Add device
</Button>
<span><b>{fmt(r.devicesWh)} Wh</b> per day</span>
</Group>
<Group gap='lg' align='flex-end' style={{marginTop: '0.75em'}}>
<Toggle label='Devices run on AC, through an inverter' checked={o.acLoads}
onChange={e => set({ac: e.currentTarget.checked ? null : '0'})}/>
{o.acLoads && <NumberInput label='Inverter efficiency (%)' name='leff' value={o.loadInverterEfficiency}
min={50} max={100} step={1} style={{width: '12em'}}
onChange={v => setNumber('leff', v, DEFAULT_LOAD_INVERTER_EFFICIENCY)}/>}
</Group>
</>;
}
function BatterySection({o, set, setNumber}) {
return <>
<Header as='h3' style={{marginTop: '1.5em'}}>Batteries</Header>
<Hint>
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.
</Hint>
<Grid>
<Grid.Col span={{base: 12, sm: 6}}>
<Select label='Battery type'
data={Object.entries(BATTERIES).map(([value, b]) => ({value, label: b.label}))}
value={o.battery} allowDeselect={false}
onChange={v => set({bat: v === 'lfp' ? null : v, dod: null})}/>
</Grid.Col>
<Grid.Col span={{base: 6, sm: 3}}>
<Select label='System voltage' data={SYSTEM_VOLTAGES.map(v => ({value: `${v}`, label: `${v} V`}))}
value={`${o.systemVoltage}`} allowDeselect={false}
onChange={v => set({sv: v === '12' ? null : v})}/>
</Grid.Col>
<Grid.Col span={{base: 6, sm: 3}}>
<NumberInput label='Days without sun' name='aut' value={o.days} min={0} step={0.5}
onChange={v => setNumber('aut', v, DEFAULT_DAYS)}/>
</Grid.Col>
<Grid.Col span={{base: 12, sm: 6}}>
<NumberInput label='Usable depth of discharge (%)' name='dod' value={o.dod} min={10} max={100} step={5}
onChange={v => setNumber('dod', v, BATTERIES[o.battery].dod)}/>
</Grid.Col>
<Grid.Col span={{base: 12, sm: 6}} style={{display: 'flex', alignItems: 'flex-end'}}>
<Toggle label='Batteries are outdoors or unheated' checked={o.outdoors}
onChange={e => set({bout: e.currentTarget.checked ? '1' : null})}/>
</Grid.Col>
</Grid>
</>;
}
function NeedsSection({o, r, worst, s, setNumber}) {
if (!(r.devicesWh > 0)) {
return <div style={{marginTop: '1em'}}>
<Message kind='info' title='Add your devices'>List your devices above to size the system.</Message>
</div>;
}
const coverage = r.coverage;
return <Panel style={{marginTop: '1.5em'}}>
<Header as='h3'>What you need</Header>
<StatisticGroup>
<Statistic value={fmt(r.panelsNeeded)} label={`${s.panelWatts} W panels for ${worst.month}`}/>
<Statistic value={fmt(r.bank?.ah)} label={`Ah of ${o.systemVoltage} V battery`}/>
<Statistic value={fmt(r.bank ? r.bank.nominalWh / 1000 : null, 1)} label='kWh of battery'/>
<Statistic value={fmt(r.batteryLoadWh)} label='Wh per day from the batteries'/>
</StatisticGroup>
<p>
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.'}
</p>
{coverage !== null && <p>
Your {s.panelCount} panel{s.panelCount === 1 ? '' : 's'} would supply about{' '}
<b>{fmt(Math.min(coverage, 9.99) * 100)}%</b> of your use in {worst.month}.{' '}
{r.panelsNeeded !== s.panelCount && r.panelsNeeded > 0 &&
<Button type='button' size='small' onClick={() => setNumber('pn', r.panelsNeeded, 1)}>
Use {r.panelsNeeded} panels
</Button>}
</p>}
{r.warnings.map(w => <Message key={w} kind='warning' title='Battery'>{w}</Message>)}
</Panel>;
}
function ControllerSection({o, r, set, setNumber}) {
const z = r.sizing;
const panelField = (key, label, value, fallback, step) => <Grid.Col span={{base: 6, sm: 3}}>
<NumberInput label={label} name={key} value={value} step={step} decimalScale={3}
onChange={v => setNumber(key, v, fallback)}/>
</Grid.Col>;
return <>
<Header as='h3' style={{marginTop: '1.5em'}}>Charge controller and wiring</Header>
<Hint>
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.
</Hint>
<Grid>
{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)}
<Grid.Col span={{base: 6, sm: 3}}>
<Select label='Controller type'
data={Object.entries(CONTROLLERS).map(([value, c]) => ({value, label: c.label}))}
value={o.controller} allowDeselect={false}
onChange={v => set({ctl: v === 'mppt' ? null : v})}/>
</Grid.Col>
<Grid.Col span={{base: 6, sm: 3}}>
<NumberInput label='Controller max PV voltage (V)' name='cmax' value={o.controllerMaxVoltage} min={0}
step={10} onChange={v => setNumber('cmax', v, DEFAULT_CONTROLLER_MAX_VOLTAGE)}/>
</Grid.Col>
<Grid.Col span={{base: 6, sm: 3}}>
<NumberInput label='Panels in series' name='ns' value={o.series} min={1} step={1}
onChange={v => setNumber('ns', v, 1)}/>
</Grid.Col>
<Grid.Col span={{base: 6, sm: 3}}>
<NumberInput label='Coldest morning (°C)' name='tmin' value={o.coldest} step={1}
onChange={v => setNumber('tmin', v, o.defaultTmin)}/>
</Grid.Col>
</Grid>
<Table style={{marginTop: '1em'}}>
<Table.Body>
<Table.Row>
<Table.Cell>Strings</Table.Cell>
<Table.Cell>{z.strings} of {o.series} panel{o.series === 1 ? '' : 's'} in series</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>String voltage on the coldest morning</Table.Cell>
<Table.Cell>{fmt(z.stringColdVoc, 1)} V (most panels in series: {z.maxSeries})</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Controller current rating, at least</Table.Cell>
<Table.Cell>{fmt(z.current)} A</Table.Cell>
</Table.Row>
</Table.Body>
</Table>
{z.overVoltage && <Message kind='error' title='Too much voltage for the controller'>
{o.series} panels in series reach {fmt(z.stringColdVoc, 1)} V on a {o.coldest} °C morning, over the
controller's {o.controllerMaxVoltage} V limit. Use at most {z.maxSeries} in series, or a controller rated
for more voltage.
</Message>}
{z.lowVoltage && <Message kind='warning' title='Too little voltage to charge'>
On a hot day {o.series} panel{o.series === 1 ? '' : 's'} in series may not stay far enough above the
battery's charging voltage for an MPPT controller. Put more panels in series.
</Message>}
{z.unevenStrings && <Message kind='warning' title='Uneven strings'>
Your panel count does not divide evenly into strings of {o.series}. Every string should have the same
number of panels.
</Message>}
{o.controller === 'mppt' && z.current > HIGH_CURRENT && o.systemVoltage < 48 &&
<Message kind='info' title='High current'>
{fmt(z.current)} A needs a large controller and thick wire. A higher system voltage would cut the
current.
</Message>}
</>;
}
/**
* The off-grid sizing tab.
*
* @param {Object} worst the worst month for a 1 kW DC array, or null when there is no estimate
* @param {number[]} temperatures monthly average temperatures at the site, °C
*/
export function SolarOffGrid({get, set, setNumber, s, worst, temperatures}) {
const o = offGridFromParams(get, temperatures);
const r = offGridResults(o, worst, s.panelWatts, s.panelCount);
return <div style={{marginTop: '1em'}}>
<Hint>
Size a battery system to run your devices through the darkest month. Production here is DC; the
Inverter section under Fine-tune is for grid-tie systems and does not apply.
</Hint>
<LoadsSection o={o} r={r} set={set} setNumber={setNumber}/>
<BatterySection o={o} set={set} setNumber={setNumber}/>
{worst
? <NeedsSection o={o} r={r} worst={worst} s={s} setNumber={setNumber}/>
: <div style={{marginTop: '1em'}}>
<Message kind='info' title='Almost there'>Enter a location above to size the system.</Message>
</div>}
<ControllerSection o={o} r={r} set={set} setNumber={setNumber}/>
</div>;
}

View file

@ -0,0 +1,141 @@
import React from "react";
import {fireEvent, screen} from "@testing-library/react";
import {renderWithProviders} from "../../test-utils";
import {offGridFromParams, offGridResults} from "./SolarOffGrid";
import {SolarCalculator} from "./SolarCalculator";
import {forgetClimatology} from "./solarData";
import {mockClimatologyFetch} from "./solarData.fixtures";
const params = query => {
const searchParams = new URLSearchParams(query);
return key => searchParams.get(key);
};
const currentParams = () => new URLSearchParams(window.location.search);
const TEMPS = [-1, 0, 5, 10, 15, 20, 24, 22, 18, 10, 4, -1];
const DEC = {month: 'Dec', energyDaily: 3};
describe('offGridFromParams', () => {
test('defaults', () => {
const o = offGridFromParams(params(''), TEMPS);
expect(o.loads).toEqual([]);
expect(o.acLoads).toBe(true);
expect(o.battery).toBe('lfp');
expect(o.dod).toBe(80);
expect(o.systemVoltage).toBe(12);
expect(o.days).toBe(2);
expect(o.controller).toBe('mppt');
expect(o.series).toBe(1);
expect(o.coldestMonthly).toBe(-1);
expect(o.coldest).toBe(-26);
});
test('the depth of discharge follows the battery type unless set', () => {
expect(offGridFromParams(params('bat=lead'), TEMPS).dod).toBe(50);
expect(offGridFromParams(params('bat=lead&dod=60'), TEMPS).dod).toBe(60);
});
test('invalid values fall back', () => {
const o = offGridFromParams(params('bat=x&sv=13&ctl=y&ns=0&aut=-1'), null);
expect(o.battery).toBe('lfp');
expect(o.systemVoltage).toBe(12);
expect(o.controller).toBe('mppt');
expect(o.series).toBe(1);
expect(o.days).toBe(0);
expect(o.coldest).toBe(-20);
});
});
describe('offGridResults', () => {
// 1000 Wh/day of AC devices through a 90% inverter, LiFePO4 at 80%, 2 days, 12 V.
const o = offGridFromParams(params('load=Stuff~100~10'), TEMPS);
test('battery and panels for the worst month', () => {
const r = offGridResults(o, DEC, 400, 1);
expect(r.devicesWh).toBe(1000);
expect(r.batteryLoadWh).toBeCloseTo(1111.1, 1);
expect(r.bank.nominalWh).toBeCloseTo(2777.8, 1);
expect(r.bank.ah).toBeCloseTo(231.5, 1);
// 1.111 kWh / (3 kWh/kWp x 95%) = 0.390 kW: one 400 W panel.
expect(r.kwp).toBeCloseTo(0.3899, 4);
expect(r.panelsNeeded).toBe(1);
// One 400 W panel: 3 x 0.4 x 95% = 1.14 kWh for 1.111 kWh of use.
expect(r.coverage).toBeCloseTo(1.026, 3);
});
test('DC devices skip the inverter loss', () => {
const dc = offGridResults(offGridFromParams(params('load=Stuff~100~10&ac=0'), TEMPS), DEC, 400, 1);
expect(dc.batteryLoadWh).toBe(1000);
});
test('a PWM controller needs more panels', () => {
const pwm = offGridResults(offGridFromParams(params('load=Stuff~100~10&ctl=pwm'), TEMPS), DEC, 400, 1);
// Only 14.4 / 31 of each panel is used: 0.390 / 0.4645 = 0.839 kW, three panels.
expect(pwm.panelsNeeded).toBe(3);
});
test('an outdoor lead-acid bank is enlarged for the cold', () => {
const indoor = offGridResults(offGridFromParams(params('load=Stuff~100~10&bat=lead'), TEMPS), DEC, 400, 1);
const outdoor = offGridResults(offGridFromParams(params('load=Stuff~100~10&bat=lead&bout=1'), TEMPS),
DEC, 400, 1);
// Coldest month -1 °C: 79% of rated capacity.
expect(outdoor.bank.nominalWh).toBeCloseTo(indoor.bank.nominalWh / 0.79, 1);
expect(outdoor.warnings).toHaveLength(1);
});
test('nothing to size without devices or an estimate', () => {
const none = offGridResults(offGridFromParams(params(''), TEMPS), DEC, 400, 1);
expect(none.bank).toBeNull();
expect(none.panelsNeeded).toBeNull();
expect(offGridResults(o, null, 400, 1).panelsNeeded).toBeNull();
});
});
describe('the off-grid tab', () => {
const OFFGRID = '/more/calculators?calc=solar&lat=40&lon=-105&tab=offgrid';
beforeEach(() => {
forgetClimatology();
mockClimatologyFetch();
});
const findNeeds = () => screen.findByText('What you need', {}, {timeout: 5000});
test('devices are added to the URL and the system is sized', async () => {
renderWithProviders(<SolarCalculator/>, {route: OFFGRID});
expect(await screen.findByText('Add your devices', {}, {timeout: 5000})).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Device 1 name'), {target: {value: 'Fridge'}});
fireEvent.change(screen.getByLabelText('Device 1 watts'), {target: {value: '50'}});
fireEvent.change(screen.getByLabelText('Device 1 hours'), {target: {value: '24'}});
expect(currentParams().get('load')).toBe('Fridge~50~24');
expect(await findNeeds()).toBeInTheDocument();
expect(screen.getByText('1200 Wh')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', {name: 'Add device'}));
expect(screen.getByLabelText('Device 2 name')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', {name: 'Remove device 2'}));
expect(screen.queryByLabelText('Device 2 name')).not.toBeInTheDocument();
});
test('the suggested panel count can be applied', async () => {
renderWithProviders(<SolarCalculator/>, {route: `${OFFGRID}&load=Heater~500~10`});
await findNeeds();
const button = screen.getByRole('button', {name: /^Use \d+ panels$/});
const suggested = button.textContent.match(/\d+/)[0];
fireEvent.click(button);
expect(currentParams().get('pn')).toBe(suggested);
});
test('too many panels in series warns before it destroys the controller', async () => {
renderWithProviders(<SolarCalculator/>, {route: `${OFFGRID}&load=Fridge~50~24&ns=3&pn=3`});
await findNeeds();
expect(screen.getByText('Too much voltage for the controller')).toBeInTheDocument();
});
test('the tab is remembered in the URL', async () => {
renderWithProviders(<SolarCalculator/>, {route: '/more/calculators?calc=solar&lat=40&lon=-105'});
fireEvent.click(screen.getByRole('tab', {name: 'Off-grid sizing'}));
expect(currentParams().get('tab')).toBe('offgrid');
fireEvent.click(screen.getByRole('tab', {name: 'Estimate'}));
expect(currentParams().has('tab')).toBe(false);
});
});

View file

@ -0,0 +1,194 @@
// Pure calculation functions for the Solar calculator's off-grid sizing. No React here.
//
// Off-grid systems are sized for the worst month, not the yearly average: the battery has to carry the
// loads through a run of cloudy days, and the panels have to refill it in the darkest month.
//
// Energy is Wh, power W, voltage V, current A, temperature °C, percentages 0-100.
const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x));
// ---------------------------------------------------------------------------
// Loads
// ---------------------------------------------------------------------------
// Devices are stored in the URL as "name~watts~hours" joined by "|".
const ROW_SEPARATOR = '|';
const FIELD_SEPARATOR = '~';
export function parseLoads(text) {
if (!text) {
return [];
}
return text.split(ROW_SEPARATOR).map(row => {
const [name = '', watts = '', hours = ''] = row.split(FIELD_SEPARATOR);
return {name, watts, hours};
});
}
export function formatLoads(rows) {
// The separators cannot appear inside a name.
const clean = value => `${value ?? ''}`.replace(/[|~]/g, ' ');
return rows.map(r => [clean(r.name), clean(r.watts), clean(r.hours)].join(FIELD_SEPARATOR)).join(ROW_SEPARATOR);
}
// Watt-hours a device uses per day; blank or invalid values count as nothing.
export function loadWh({watts, hours}) {
const w = Number(watts), h = Number(hours);
return w > 0 && h > 0 ? w * Math.min(h, 24) : 0;
}
export function dailyLoadWh(rows) {
return rows.reduce((acc, row) => acc + loadWh(row), 0);
}
// ---------------------------------------------------------------------------
// Battery
// ---------------------------------------------------------------------------
export const BATTERIES = {
lfp: {
label: 'Lithium iron phosphate (LiFePO4)',
// Usable depth of discharge, and round-trip efficiency.
dod: 80, efficiency: 95,
},
lead: {
label: 'Lead-acid (flooded or AGM)',
// Discharging below half shortens a lead-acid battery's life sharply.
dod: 50, efficiency: 85,
},
};
export const SYSTEM_VOLTAGES = [12, 24, 48];
// Charging voltage of a battery bank, used to judge PWM controllers and MPPT headroom.
export const chargeVoltage = systemVoltage => systemVoltage * 1.2;
// Share of rated capacity a cold lead-acid battery delivers: about 80% at 0 °C and 60% at -20 °C, as on
// typical manufacturer curves. Lithium batteries are not derated here; they must not be CHARGED below
// 0 °C, which is a warning instead (see batteryWarnings).
export function leadAcidCapacityFactor(temperature) {
if (!Number.isFinite(temperature) || temperature >= 20) {
return 1;
}
return clamp(1 - 0.01 * (20 - temperature), 0.5, 1);
}
/**
* Battery bank for `days` of the daily load with no sun.
*
* @returns {{usableWh: number, nominalWh: number, ah: number}}
*/
export function sizeBattery({loadWh: daily, days, dod, voltage, capacityFactor = 1}) {
if (!(daily > 0) || !(days > 0) || !(dod > 0) || !(voltage > 0)) {
return null;
}
const usableWh = daily * days;
const nominalWh = usableWh / (dod / 100) / capacityFactor;
return {usableWh, nominalWh, ah: nominalWh / voltage};
}
// ---------------------------------------------------------------------------
// Array
// ---------------------------------------------------------------------------
/**
* kW of panels needed to supply the daily load in the worst month.
*
* @param {number} loadWh daily energy the battery bank must supply
* @param {number} worstDailyPerKwp DC kWh per day per kW of panels in the worst month
* @param {number} batteryEfficiency round-trip efficiency, %; conservatively applied to all energy
*/
export function requiredKwp(loadWh, worstDailyPerKwp, batteryEfficiency) {
if (!(loadWh > 0) || !(worstDailyPerKwp > 0) || !(batteryEfficiency > 0)) {
return null;
}
return (loadWh / 1000) / (worstDailyPerKwp * batteryEfficiency / 100);
}
// ---------------------------------------------------------------------------
// Charge controller and strings
// ---------------------------------------------------------------------------
export const CONTROLLERS = {
mppt: {label: 'MPPT'},
pwm: {label: 'PWM'},
};
// A PWM controller connects the panels straight to the battery, holding them near the battery's voltage
// instead of their best voltage (Vmp), so only this share of their power is used.
export function pwmUsableFraction(vmp, systemVoltage) {
if (!(vmp > 0)) {
return 1;
}
return Math.min(1, chargeVoltage(systemVoltage) / vmp);
}
// Panel open-circuit voltage rises as it gets colder. `betaVoc` is its temperature coefficient, %/°C.
export function coldVoc(voc, betaVoc, temperature) {
return voc * (1 + (betaVoc / 100) * (temperature - 25));
}
/**
* Wiring and controller checks for an array of `panelCount` panels, `series` of them per string.
*/
export function controllerSizing({
panelCount, panelWatts, series, voc, vmp, isc, betaVoc, coldest,
controller, controllerMaxVoltage, systemVoltage,
}) {
const strings = series > 0 ? Math.ceil(panelCount / series) : 0;
const arrayWatts = panelCount * panelWatts;
const stringColdVoc = coldVoc(voc, betaVoc, coldest) * series;
const maxSeries = Math.floor(controllerMaxVoltage / coldVoc(voc, betaVoc, coldest));
let current;
if (controller === 'pwm') {
// Panel current passes straight through, plus the usual 25% margin.
current = isc * strings * 1.25;
} else {
current = arrayWatts / systemVoltage * 1.25;
}
return {
strings,
arrayWatts,
stringColdVoc,
maxSeries,
current,
overVoltage: stringColdVoc > controllerMaxVoltage,
// An MPPT controller needs the string's voltage, which sags about 15% in summer heat, to stay
// above the battery's charging voltage with some headroom.
lowVoltage: controller === 'mppt' && vmp * series * 0.85 < chargeVoltage(systemVoltage) + 5,
unevenStrings: series > 0 && panelCount % series !== 0,
pwmFraction: controller === 'pwm' ? pwmUsableFraction(vmp, systemVoltage) : 1,
};
}
// Default for the coldest morning when the user has not entered one: well below the coldest monthly
// average, because monthly averages hide cold snaps. Errs cold, since underestimating the cold is what
// destroys controllers.
export const COLD_MARGIN = 25;
export function defaultColdest(monthlyTemperatures) {
const known = (monthlyTemperatures || []).filter(Number.isFinite);
if (known.length === 0) {
return -20;
}
return Math.round(Math.min(...known) - COLD_MARGIN);
}
// Advice about the battery for the site's climate. `coldestMonthly` is the coldest monthly average, °C.
export function batteryWarnings({battery, outdoors, coldestMonthly}) {
const warnings = [];
if (!outdoors || !Number.isFinite(coldestMonthly)) {
return warnings;
}
if (battery === 'lfp' && coldestMonthly < 5) {
warnings.push('Lithium (LiFePO4) batteries are damaged by charging below 0 °C. Keep them indoors, or buy '
+ 'batteries with built-in heaters or low-temperature charge protection.');
}
if (battery === 'lead' && coldestMonthly < 20) {
warnings.push(`Cold lead-acid batteries hold less: the bank is enlarged to deliver ` +
`${Math.round(leadAcidCapacityFactor(coldestMonthly) * 100)}% of its rating in the coldest month. ` +
'Discharged lead-acid batteries can also freeze and crack; keep them charged.');
}
return warnings;
}

View file

@ -0,0 +1,146 @@
import {
BATTERIES,
batteryWarnings,
chargeVoltage,
coldVoc,
controllerSizing,
dailyLoadWh,
defaultColdest,
formatLoads,
leadAcidCapacityFactor,
loadWh,
parseLoads,
pwmUsableFraction,
requiredKwp,
sizeBattery,
} from "./offgrid";
describe('loads', () => {
test('round-trip through the URL format', () => {
const rows = [{name: 'Fridge', watts: '60', hours: '24'}, {name: 'Lights', watts: '20', hours: '5'}];
expect(formatLoads(rows)).toBe('Fridge~60~24|Lights~20~5');
expect(parseLoads(formatLoads(rows))).toEqual(rows);
expect(parseLoads('')).toEqual([]);
expect(parseLoads(null)).toEqual([]);
});
test('separators are removed from names', () => {
expect(parseLoads(formatLoads([{name: 'A|B~C', watts: '1', hours: '1'}]))[0].name).toBe('A B C');
});
test('daily energy is watts times hours, ignoring blanks and capping at 24 hours', () => {
expect(loadWh({watts: '60', hours: '24'})).toBe(1440);
expect(loadWh({watts: '60', hours: '30'})).toBe(1440);
expect(loadWh({watts: '', hours: '5'})).toBe(0);
expect(loadWh({watts: '-5', hours: '5'})).toBe(0);
expect(dailyLoadWh(parseLoads('Fridge~60~24|Lights~20~5|~~'))).toBe(1540);
});
});
describe('battery', () => {
test('a bank for 2 days of 1 kWh at 50% depth of discharge holds 4 kWh', () => {
const bank = sizeBattery({loadWh: 1000, days: 2, dod: 50, voltage: 12});
expect(bank.usableWh).toBe(2000);
expect(bank.nominalWh).toBe(4000);
expect(bank.ah).toBeCloseTo(333.3, 1);
});
test('a cold lead-acid bank must be bigger', () => {
expect(leadAcidCapacityFactor(25)).toBe(1);
expect(leadAcidCapacityFactor(0)).toBeCloseTo(0.8, 6);
expect(leadAcidCapacityFactor(-20)).toBeCloseTo(0.6, 6);
expect(leadAcidCapacityFactor(-60)).toBe(0.5);
const warm = sizeBattery({loadWh: 1000, days: 1, dod: 50, voltage: 12});
const cold = sizeBattery({loadWh: 1000, days: 1, dod: 50, voltage: 12, capacityFactor: 0.8});
expect(cold.nominalWh).toBeCloseTo(warm.nominalWh / 0.8, 6);
});
test('lithium needs a smaller bank than lead-acid for the same load', () => {
const lfp = sizeBattery({loadWh: 1000, days: 2, dod: BATTERIES.lfp.dod, voltage: 24});
const lead = sizeBattery({loadWh: 1000, days: 2, dod: BATTERIES.lead.dod, voltage: 24});
expect(lfp.nominalWh).toBeLessThan(lead.nominalWh);
});
test('no bank without a load', () => {
expect(sizeBattery({loadWh: 0, days: 2, dod: 50, voltage: 12})).toBeNull();
});
});
describe('array', () => {
test('panels needed for the worst month', () => {
// 2 kWh/day, 3 kWh/kWp/day in the worst month, 95% battery efficiency: 2 / 2.85 = 0.702 kW.
expect(requiredKwp(2000, 3, 95)).toBeCloseTo(0.7018, 4);
expect(requiredKwp(2000, 0, 95)).toBeNull();
});
});
describe('charge controller', () => {
test('a PWM controller wastes most of a high-voltage panel on a 12 V battery', () => {
expect(chargeVoltage(12)).toBeCloseTo(14.4, 6);
// 14.4 V / 31 V: under half the panel's power.
expect(pwmUsableFraction(31, 12)).toBeCloseTo(0.4645, 3);
// A "12 V" panel (Vmp ~18 V) suits PWM much better.
expect(pwmUsableFraction(18, 12)).toBeCloseTo(0.8, 6);
expect(pwmUsableFraction(10, 12)).toBe(1);
});
test('open-circuit voltage rises in the cold', () => {
// -0.27 %/°C at -25 °C (50 °C below the 25 °C rating): +13.5%.
expect(coldVoc(37, -0.27, -25)).toBeCloseTo(41.995, 3);
expect(coldVoc(37, -0.27, 25)).toBe(37);
});
const typical = {
panelCount: 4, panelWatts: 400, series: 2, voc: 37, vmp: 31, isc: 13.8, betaVoc: -0.27, coldest: -25,
controller: 'mppt', controllerMaxVoltage: 100, systemVoltage: 24,
};
test('two 400 W panels in series on a 100 V MPPT controller are safe', () => {
const result = controllerSizing(typical);
expect(result.strings).toBe(2);
expect(result.stringColdVoc).toBeCloseTo(83.99, 2);
expect(result.overVoltage).toBe(false);
expect(result.maxSeries).toBe(2);
// 1600 W / 24 V x 1.25.
expect(result.current).toBeCloseTo(83.33, 2);
expect(result.lowVoltage).toBe(false);
expect(result.unevenStrings).toBe(false);
});
test('three in series exceed 100 V on a cold morning', () => {
const result = controllerSizing({...typical, series: 3});
expect(result.overVoltage).toBe(true);
expect(result.unevenStrings).toBe(true);
});
test('one panel per string is too little voltage to charge 48 V through MPPT', () => {
expect(controllerSizing({...typical, series: 1, systemVoltage: 48}).lowVoltage).toBe(true);
});
test('PWM current is the panels\' short-circuit current plus 25%', () => {
const result = controllerSizing({...typical, controller: 'pwm', series: 1, systemVoltage: 12});
expect(result.current).toBeCloseTo(13.8 * 4 * 1.25, 6);
expect(result.pwmFraction).toBeCloseTo(0.4645, 3);
expect(result.lowVoltage).toBe(false);
});
});
describe('defaultColdest', () => {
test('is well below the coldest monthly average', () => {
expect(defaultColdest([-1, 0, 5, 10, 15, 20, 24, 22, 18, 10, 4, -1])).toBe(-26);
expect(defaultColdest([null, null])).toBe(-20);
expect(defaultColdest(null)).toBe(-20);
});
});
describe('batteryWarnings', () => {
test('warn about charging lithium in the cold, only when the batteries are outdoors', () => {
expect(batteryWarnings({battery: 'lfp', outdoors: true, coldestMonthly: -5})[0]).toMatch(/below 0 °C/);
expect(batteryWarnings({battery: 'lfp', outdoors: false, coldestMonthly: -5})).toEqual([]);
expect(batteryWarnings({battery: 'lfp', outdoors: true, coldestMonthly: 15})).toEqual([]);
});
test('explain the cold lead-acid enlargement', () => {
expect(batteryWarnings({battery: 'lead', outdoors: true, coldestMonthly: 0})[0]).toMatch(/80% of its rating/);
});
});