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', () => {