Solar: fix PWM sizing, Voc sign, unit switching and loading on review

- PWM usable power now uses the string's voltage (Vmp x panels in series),
  delivers nothing when the charging voltage is at or above the string's Voc,
  and falls off between Vmp and Voc.  The "too little voltage" warning covers
  PWM as well as MPPT.  Previously two panels in series counted double, and a
  panel that could not charge a 48 V bank counted as fully usable.
- A Voc temperature coefficient entered without its minus sign is treated as
  negative, in the math, the URL and the input, so the cold-morning check
  cannot be made too generous.
- Switching Fahrenheit on or off converts the entered temperatures instead
  of reinterpreting them.
- While the sunlight data downloads, the off-grid tab shows a loader rather
  than asking for a location, and holds the cold-morning verdict until the
  site's temperatures are known or one has been entered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Roland 2026-10-04 18:17:20 -06:00
parent 58842db248
commit 3bfd198fa0
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
6 changed files with 176 additions and 65 deletions

View file

@ -487,6 +487,13 @@ function OwnDataSection({set, s, nasa}) {
set({ghi: formatMonthly(ghi), temp: nasa ? formatMonthly(temp) : null});
};
// Convert the temperatures with the unit, so they stay the same physical temperatures.
const switchUnits = toFahrenheit => {
const temps = s.temperatureInput.map(v => (v === null ? null
: roundDigits(toFahrenheit ? cToF(v) : fToC(v), 1)));
set({tu: toFahrenheit ? 'f' : null, temp: temps.some(v => v !== null) ? formatMonthly(temps) : null});
};
const setMonth = (key, values, index, value) => {
const next = [...values];
next[index] = value === '' ? null : value;
@ -516,7 +523,7 @@ function OwnDataSection({set, s, nasa}) {
<Toggle label='Use my own sunlight data' checked={s.ownData}
onChange={e => enable(e.currentTarget.checked)}/>
{s.ownData && <Toggle label='Fahrenheit' checked={s.fahrenheit}
onChange={e => set({tu: e.currentTarget.checked ? 'f' : null})}/>}
onChange={e => switchUnits(e.currentTarget.checked)}/>}
</Group>
{s.ownData && <Table style={{marginTop: '0.5em'}}>
<Table.Header>
@ -586,7 +593,7 @@ export function SolarCalculator() {
</Tabs.Panel>
<Tabs.Panel value='offgrid'>
<SolarOffGrid get={get} set={set} setNumber={setNumber} s={s} worst={worstPerKwp}
temperatures={sunlight?.temperature}/>
temperatures={sunlight?.temperature} loading={waitingForData}/>
</Tabs.Panel>
</Tabs>

View file

@ -227,6 +227,21 @@ describe('SolarCalculator', () => {
expect(currentParams().has('temp')).toBe(false);
});
test('switching to Fahrenheit converts your temperatures instead of relabeling them', async () => {
const temps = '0,10,,,,,,,,,,';
renderWithProviders(<SolarCalculator/>,
{route: `${DENVER}&ghi=${SUNNY_GHI.join(',')}&temp=${temps}`});
await findEstimate();
fireEvent.click(screen.getByText('Use my own sunlight data: on'));
fireEvent.click(screen.getByLabelText('Fahrenheit'));
expect(currentParams().get('tu')).toBe('f');
expect(currentParams().get('temp')).toBe('32,50,,,,,,,,,,');
expect(screen.getByLabelText('Jan temperature')).toHaveValue('32');
fireEvent.click(screen.getByLabelText('Fahrenheit'));
expect(currentParams().has('tu')).toBe(false);
expect(currentParams().get('temp')).toBe(temps);
});
test('a failed download explains how to continue', async () => {
forgetClimatology();
global.fetch = jest.fn(() => Promise.resolve({ok: false, status: 404}));

View file

@ -1,6 +1,6 @@
import React from "react";
import {Button, Grid, Group, Header, IconButton, Message, NumberInput, Panel, Select, Statistic, StatisticGroup,
Table, TextInput, Toggle} from "../ui";
import {Button, Grid, Group, Header, IconButton, Loader, Message, NumberInput, Panel, Select, Statistic,
StatisticGroup, Table, TextInput, Toggle} from "../ui";
import {roundDigits} from "../Common";
import {
BATTERIES,
@ -59,7 +59,8 @@ export function offGridFromParams(get, monthlyTemperatures) {
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),
// Always negative for silicon panels; a missing minus sign must not hide the cold-weather rise.
betaVoc: -Math.abs(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,
@ -219,8 +220,10 @@ function NeedsSection({o, r, worst, s, setNumber}) {
<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.'}
{o.controller === 'pwm' && r.sizing.pwmFraction > 0 && ` The PWM controller uses only `
+ `${fmt(r.sizing.pwmFraction * 100)}% of the panels' power, so more panels are needed.`}
{o.controller === 'pwm' && r.sizing.pwmFraction === 0 && ' These panels cannot charge this battery '
+ 'through a PWM controller; see the warning below.'}
</p>
{coverage !== null && <p>
Your {s.panelCount} panel{s.panelCount === 1 ? '' : 's'} would supply about{' '}
@ -234,10 +237,10 @@ function NeedsSection({o, r, worst, s, setNumber}) {
</Panel>;
}
function ControllerSection({o, r, set, setNumber}) {
function ControllerSection({o, r, set, setNumber, temperaturesKnown}) {
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}
const panelField = (key, label, value, fallback, step, max) => <Grid.Col span={{base: 6, sm: 3}}>
<NumberInput label={label} name={key} value={value} step={step} decimalScale={3} max={max}
onChange={v => setNumber(key, v, fallback)}/>
</Grid.Col>;
@ -253,7 +256,7 @@ function ControllerSection({o, r, set, setNumber}) {
{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)}
{panelField('bvoc', 'Voc temperature coefficient (%/°C)', o.betaVoc, PANEL_DEFAULTS.bvoc, 0.01, 0)}
<Grid.Col span={{base: 6, sm: 3}}>
<Select label='Controller type'
data={Object.entries(CONTROLLERS).map(([value, c]) => ({value, label: c.label}))}
@ -273,40 +276,48 @@ function ControllerSection({o, r, set, setNumber}) {
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.
{/* The cold-morning limit depends on the site's temperatures; do not show a placeholder's verdict. */}
{!temperaturesKnown && <div style={{marginTop: '1em'}}><Loader size='xs' label='Loading temperatures'/></div>}
{temperaturesKnown && <>
<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'>
{o.controller === 'pwm' && z.pwmFraction === 0
? `${o.series} panel${o.series === 1 ? '' : 's'} in series cannot reach the ${o.systemVoltage} V `
+ 'battery\'s charging voltage, so a PWM controller cannot charge it at all.'
: `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 the controller to charge well.'}
{' '}Put more panels in series, or use a lower system voltage.
</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>}
</>}
</>;
}
@ -315,8 +326,9 @@ function ControllerSection({o, r, set, setNumber}) {
*
* @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
* @param {boolean} loading the built-in sunlight and temperature data is still downloading
*/
export function SolarOffGrid({get, set, setNumber, s, worst, temperatures}) {
export function SolarOffGrid({get, set, setNumber, s, worst, temperatures, loading}) {
const o = offGridFromParams(get, temperatures);
const r = offGridResults(o, worst, s.panelWatts, s.panelCount);
@ -327,11 +339,12 @@ export function SolarOffGrid({get, set, setNumber, s, worst, temperatures}) {
</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}/>
{worst && <NeedsSection o={o} r={r} worst={worst} s={s} setNumber={setNumber}/>}
{!worst && loading && <div style={{marginTop: '1em'}}><Loader size='xs' label='Loading sunlight data'/></div>}
{!worst && !loading && <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}
temperaturesKnown={!loading || get('tmin') !== null}/>
</div>;
}

View file

@ -34,6 +34,11 @@ describe('offGridFromParams', () => {
expect(offGridFromParams(params('bat=lead&dod=60'), TEMPS).dod).toBe(60);
});
test('a Voc coefficient without its minus sign is read as negative, even from a shared link', () => {
expect(offGridFromParams(params('bvoc=0.27'), TEMPS).betaVoc).toBe(-0.27);
expect(offGridFromParams(params('bvoc=-0.3'), TEMPS).betaVoc).toBe(-0.3);
});
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');
@ -131,6 +136,33 @@ describe('the off-grid tab', () => {
expect(screen.getByText('Too much voltage for the controller')).toBeInTheDocument();
});
test('PWM panels that cannot reach a 48 V battery say so instead of sizing a system', async () => {
renderWithProviders(<SolarCalculator/>, {route: `${OFFGRID}&load=Fridge~50~24&ctl=pwm&sv=48`});
await findNeeds();
expect(screen.getByText('Too little voltage to charge')).toBeInTheDocument();
expect(screen.getByText(/a PWM controller cannot charge it at all/)).toBeInTheDocument();
expect(screen.queryByRole('button', {name: /^Use \d+ panels$/})).not.toBeInTheDocument();
});
test('while the data downloads, it shows a loader and no cold-morning verdict', async () => {
forgetClimatology();
global.fetch = jest.fn(() => new Promise(() => {
}));
renderWithProviders(<SolarCalculator/>, {route: `${OFFGRID}&load=Fridge~50~24`});
// One beside the location, one in place of the sizing.
expect(screen.getAllByLabelText('Loading sunlight data')).toHaveLength(2);
expect(screen.queryByText('Almost there')).not.toBeInTheDocument();
expect(screen.queryByText('String voltage on the coldest morning')).not.toBeInTheDocument();
});
test('a coldest morning the user typed is checked even while the data downloads', async () => {
forgetClimatology();
global.fetch = jest.fn(() => new Promise(() => {
}));
renderWithProviders(<SolarCalculator/>, {route: `${OFFGRID}&load=Fridge~50~24&tmin=-30`});
expect(screen.getByText('String voltage on the coldest morning')).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'}));

View file

@ -114,18 +114,31 @@ export const CONTROLLERS = {
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)) {
// A PWM controller connects a string of `series` panels straight to the battery, holding it at the battery's
// charging voltage instead of its best voltage (Vmp), so only this share of its power is used:
// - charging voltage at or above the string's Voc: no current flows, nothing is delivered;
// - between Vmp and Voc: the current falls from its maximum toward zero at Voc (taken as linear);
// - below Vmp: full current at the lower voltage, charging voltage / Vmp of the power.
export function pwmUsableFraction(vmp, voc, series, systemVoltage) {
const stringVmp = vmp * series, stringVoc = voc * series;
if (!(stringVmp > 0) || !(stringVoc > 0)) {
return 1;
}
return Math.min(1, chargeVoltage(systemVoltage) / vmp);
const charge = chargeVoltage(systemVoltage);
if (charge >= stringVoc) {
return 0;
}
if (charge > stringVmp) {
return (charge / stringVmp) * (stringVoc - charge) / (stringVoc - stringVmp);
}
return charge / stringVmp;
}
// Panel open-circuit voltage rises as it gets colder. `betaVoc` is its temperature coefficient, %/°C.
// Panel open-circuit voltage rises as it gets colder. `betaVoc` is its temperature coefficient, %/°C, which is
// negative for every silicon panel; a coefficient typed without its minus sign is treated as negative, because
// a positive one would hide the cold-weather rise that destroys controllers.
export function coldVoc(voc, betaVoc, temperature) {
return voc * (1 + (betaVoc / 100) * (temperature - 25));
return voc * (1 - (Math.abs(betaVoc) / 100) * (temperature - 25));
}
/**
@ -154,11 +167,14 @@ export function controllerSizing({
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,
// 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. A PWM controller loses power as soon as the string's
// Vmp is below the charging voltage, and all of it once its Voc is.
lowVoltage: controller === 'mppt'
? vmp * series * 0.85 < chargeVoltage(systemVoltage) + 5
: vmp * series < chargeVoltage(systemVoltage),
unevenStrings: series > 0 && panelCount % series !== 0,
pwmFraction: controller === 'pwm' ? pwmUsableFraction(vmp, systemVoltage) : 1,
pwmFraction: controller === 'pwm' ? pwmUsableFraction(vmp, voc, series, systemVoltage) : 1,
};
}

View file

@ -78,10 +78,25 @@ 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);
expect(pwmUsableFraction(31, 37, 1, 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);
expect(pwmUsableFraction(18, 22, 1, 12)).toBeCloseTo(0.8, 6);
});
test('PWM uses the string voltage, so panels in series waste even more', () => {
// Two 31 V panels make a 62 V string: 14.4 / 62.
expect(pwmUsableFraction(31, 37, 2, 12)).toBeCloseTo(0.2323, 3);
});
test('PWM delivers nothing when the string cannot reach the charging voltage', () => {
// One 37 Voc panel against a 48 V bank's 57.6 V.
expect(pwmUsableFraction(31, 37, 1, 48)).toBe(0);
expect(pwmUsableFraction(10, 12, 1, 12)).toBe(0);
});
test('PWM between Vmp and Voc delivers part of the power as current falls toward Voc', () => {
// 14.4 V sits just above a 14 V Vmp: (14.4 / 14) x (21 - 14.4) / (21 - 14).
expect(pwmUsableFraction(14, 21, 1, 12)).toBeCloseTo(0.9698, 3);
});
test('open-circuit voltage rises in the cold', () => {
@ -90,6 +105,13 @@ describe('charge controller', () => {
expect(coldVoc(37, -0.27, 25)).toBe(37);
});
test('a Voc coefficient typed without its minus sign still errs safe', () => {
expect(coldVoc(37, 0.27, -25)).toBeCloseTo(coldVoc(37, -0.27, -25), 9);
const sizing = controllerSizing({...typical, series: 3, betaVoc: 0.27});
expect(sizing.overVoltage).toBe(true);
expect(sizing.maxSeries).toBe(2);
});
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,
@ -123,6 +145,12 @@ describe('charge controller', () => {
expect(result.pwmFraction).toBeCloseTo(0.4645, 3);
expect(result.lowVoltage).toBe(false);
});
test('PWM on a battery above the string\'s voltage warns and delivers nothing', () => {
const result = controllerSizing({...typical, controller: 'pwm', series: 1, systemVoltage: 48});
expect(result.pwmFraction).toBe(0);
expect(result.lowVoltage).toBe(true);
});
});
describe('defaultColdest', () => {