mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-25 18:40:06 -04:00
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:
parent
58842db248
commit
3bfd198fa0
6 changed files with 176 additions and 65 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}));
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'}));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue