mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
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:
parent
b5ea2054c1
commit
dfdf3d8166
5 changed files with 843 additions and 4 deletions
|
|
@ -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>
|
||||
|
|
|
|||
337
app/src/components/calculators/SolarOffGrid.js
Normal file
337
app/src/components/calculators/SolarOffGrid.js
Normal 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>;
|
||||
}
|
||||
141
app/src/components/calculators/SolarOffGrid.test.js
Normal file
141
app/src/components/calculators/SolarOffGrid.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
194
app/src/components/calculators/offgrid.js
Normal file
194
app/src/components/calculators/offgrid.js
Normal 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;
|
||||
}
|
||||
146
app/src/components/calculators/offgrid.test.js
Normal file
146
app/src/components/calculators/offgrid.test.js
Normal 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/);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue