From 3bfd198fa0db029099f03542e9dc9e61373968a5 Mon Sep 17 00:00:00 2001 From: Roland Date: Sun, 4 Oct 2026 18:17:20 -0600 Subject: [PATCH] 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 --- .../components/calculators/SolarCalculator.js | 11 +- .../calculators/SolarCalculator.test.js | 15 +++ .../components/calculators/SolarOffGrid.js | 111 ++++++++++-------- .../calculators/SolarOffGrid.test.js | 32 +++++ app/src/components/calculators/offgrid.js | 38 ++++-- .../components/calculators/offgrid.test.js | 34 +++++- 6 files changed, 176 insertions(+), 65 deletions(-) diff --git a/app/src/components/calculators/SolarCalculator.js b/app/src/components/calculators/SolarCalculator.js index e8734eba..2b492e3b 100644 --- a/app/src/components/calculators/SolarCalculator.js +++ b/app/src/components/calculators/SolarCalculator.js @@ -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}) { enable(e.currentTarget.checked)}/> {s.ownData && set({tu: e.currentTarget.checked ? 'f' : null})}/>} + onChange={e => switchUnits(e.currentTarget.checked)}/>} {s.ownData && @@ -586,7 +593,7 @@ export function SolarCalculator() { + temperatures={sunlight?.temperature} loading={waitingForData}/> diff --git a/app/src/components/calculators/SolarCalculator.test.js b/app/src/components/calculators/SolarCalculator.test.js index 4ee7020b..5eaf4b55 100644 --- a/app/src/components/calculators/SolarCalculator.test.js +++ b/app/src/components/calculators/SolarCalculator.test.js @@ -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(, + {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})); diff --git a/app/src/components/calculators/SolarOffGrid.js b/app/src/components/calculators/SolarOffGrid.js index 69ea3d44..4f6c2202 100644 --- a/app/src/components/calculators/SolarOffGrid.js +++ b/app/src/components/calculators/SolarOffGrid.js @@ -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}) {

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.'}

{coverage !== null &&

Your {s.panelCount} panel{s.panelCount === 1 ? '' : 's'} would supply about{' '} @@ -234,10 +237,10 @@ function NeedsSection({o, r, worst, s, setNumber}) { ; } -function ControllerSection({o, r, set, setNumber}) { +function ControllerSection({o, r, set, setNumber, temperaturesKnown}) { const z = r.sizing; - const panelField = (key, label, value, fallback, step) => - + setNumber(key, v, fallback)}/> ; @@ -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)}

- - - Strings - {z.strings} of {o.series} panel{o.series === 1 ? '' : 's'} in series - - - String voltage on the coldest morning - {fmt(z.stringColdVoc, 1)} V (most panels in series: {z.maxSeries}) - - - Controller current rating, at least - {fmt(z.current)} A - - -
- {z.overVoltage && - {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. - } - {z.lowVoltage && - 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. - } - {z.unevenStrings && - Your panel count does not divide evenly into strings of {o.series}. Every string should have the same - number of panels. - } - {o.controller === 'mppt' && z.current > HIGH_CURRENT && o.systemVoltage < 48 && - - {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 &&
} + {temperaturesKnown && <> + + + + Strings + {z.strings} of {o.series} panel{o.series === 1 ? '' : 's'} in series + + + String voltage on the coldest morning + {fmt(z.stringColdVoc, 1)} V (most panels in series: {z.maxSeries}) + + + Controller current rating, at least + {fmt(z.current)} A + + +
+ {z.overVoltage && + {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. } + {z.lowVoltage && + {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. + } + {z.unevenStrings && + Your panel count does not divide evenly into strings of {o.series}. Every string should have the same + number of panels. + } + {o.controller === 'mppt' && z.current > HIGH_CURRENT && o.systemVoltage < 48 && + + {fmt(z.current)} A needs a large controller and thick wire. A higher system voltage would cut the + current. + } + } ; } @@ -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}) { - {worst - ? - :
- Enter a location above to size the system. -
} - + {worst && } + {!worst && loading &&
} + {!worst && !loading &&
+ Enter a location above to size the system. +
} + ; } diff --git a/app/src/components/calculators/SolarOffGrid.test.js b/app/src/components/calculators/SolarOffGrid.test.js index cc45f275..7c2c3ed1 100644 --- a/app/src/components/calculators/SolarOffGrid.test.js +++ b/app/src/components/calculators/SolarOffGrid.test.js @@ -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(, {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(, {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(, {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(, {route: '/more/calculators?calc=solar&lat=40&lon=-105'}); fireEvent.click(screen.getByRole('tab', {name: 'Off-grid sizing'})); diff --git a/app/src/components/calculators/offgrid.js b/app/src/components/calculators/offgrid.js index f2f1c121..ea8ee0e0 100644 --- a/app/src/components/calculators/offgrid.js +++ b/app/src/components/calculators/offgrid.js @@ -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, }; } diff --git a/app/src/components/calculators/offgrid.test.js b/app/src/components/calculators/offgrid.test.js index 11eea164..2366980c 100644 --- a/app/src/components/calculators/offgrid.test.js +++ b/app/src/components/calculators/offgrid.test.js @@ -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', () => {