mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 02:48:38 -04:00
Add a Solar calculator with offline NASA POWER data
Estimates a solar array's typical-year production from a location and the panels; everything else has defaults under an optional "Fine-tune" section. The model works from 12 monthly averages: Klein's average day per month, the Erbs monthly diffuse fraction, Collares-Pereira & Rabl / Liu & Jordan hourly split, HDKR transposition for fixed, single-axis and dual-axis arrays, then PVWatts' ASHRAE incidence loss, SAPM cell temperature, DC power, system losses and optional inverter. "Best for the year" and "Best for winter" sweep the tilt. Sunlight, diffuse, albedo and temperature come from NASA POWER's 2001-2020 climatology on a 1 degree grid, packed into app/public/data (3.1 MB, ~1 MB gzipped) by scripts/build_solar_climatology.py and fetched only when the calculator opens. Users can replace it with their own monthly values. Every setting lives in the URL, with defaults left out, so the Share button shares the whole configuration. Against PVWatts v8 for a 1 kW fixed array: Phoenix +0.6%, Seattle +8.8%, Anchorage +8.0% annually; these are recorded as tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8713a6136b
commit
4f06dfe9c8
11 changed files with 2010 additions and 0 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -150,3 +150,6 @@ app/cypress/videos
|
|||
release/latest.json
|
||||
perf_results/
|
||||
scripts/api_perf_hosts.json
|
||||
|
||||
# Tile cache for scripts/build_solar_climatology.py
|
||||
/scripts/.solar_climatology_cache/
|
||||
|
|
|
|||
BIN
app/public/data/solar-climatology.bin
Normal file
BIN
app/public/data/solar-climatology.bin
Normal file
Binary file not shown.
|
|
@ -14,6 +14,7 @@ import {FoodStorageCalculator} from "./calculators/FoodStorageCalculator";
|
|||
import {OneTimePadCalculator} from "./calculators/OneTimePadCalculator";
|
||||
import {VinDecoderCalculator} from "./calculators/VinDecoderCalculator";
|
||||
import {SpeedTestCalculator} from "./calculators/SpeedTestCalculator";
|
||||
import {SolarCalculator} from "./calculators/SolarCalculator";
|
||||
|
||||
// Calculators are shown in these titled groups, in this order. Each calculator carries a
|
||||
// `group` matching one of these names; any calculator whose group is missing/unknown falls
|
||||
|
|
@ -35,6 +36,7 @@ export const useCalculators = () => {
|
|||
{key: 'ratio', icon: 'th large', button: 'Ratio', group: 'Engineering', contents: <RatioCalculators/>},
|
||||
{key: 'electrical', icon: 'lightning', button: 'Electrical', group: 'Engineering', contents: <ElectricalCalculators/>},
|
||||
{key: 'drive', icon: 'cogs', button: 'Drive Ratio', group: 'Engineering', contents: <DriveCalculator/>},
|
||||
{key: 'solar', icon: 'sun', button: 'Solar', group: 'Engineering', contents: <SolarCalculator/>},
|
||||
{key: 'water', icon: 'tint', button: 'Water Storage', group: 'Preparedness & Storage', contents: <WaterCalculator/>},
|
||||
{key: 'ration', icon: 'food', button: 'Food Storage', group: 'Preparedness & Storage', contents: <FoodStorageCalculator/>},
|
||||
{key: 'antenna', icon: 'signal', button: 'Antenna', group: 'Radio & Communications', contents: <DipoleAntennaCalculator/>},
|
||||
|
|
|
|||
541
app/src/components/calculators/SolarCalculator.js
Normal file
541
app/src/components/calculators/SolarCalculator.js
Normal file
|
|
@ -0,0 +1,541 @@
|
|||
import React from "react";
|
||||
import {useSearchParams} from "react-router";
|
||||
import {
|
||||
Accordion,
|
||||
Button,
|
||||
Grid,
|
||||
Group,
|
||||
Header,
|
||||
Loader,
|
||||
Message,
|
||||
NumberInput,
|
||||
Panel,
|
||||
Select,
|
||||
Statistic,
|
||||
StatisticGroup,
|
||||
Table,
|
||||
TextInput,
|
||||
Toggle,
|
||||
} from "../ui";
|
||||
import {InfoPopup, roundDigits} from "../Common";
|
||||
import {
|
||||
DEFAULT_LOSSES,
|
||||
defaultAzimuth,
|
||||
estimateProduction,
|
||||
formatMonthly,
|
||||
INVERTER_NOMINAL_EFFICIENCY,
|
||||
LOSS_CATEGORIES,
|
||||
MODULE_TYPES,
|
||||
MONTH_NAMES,
|
||||
MOUNTINGS,
|
||||
optimalTilt,
|
||||
parseCoordinate,
|
||||
parseMonthly,
|
||||
totalLoss,
|
||||
TRACKING,
|
||||
} from "./solar";
|
||||
import {CLIMATOLOGY_SOURCE, lookupClimatology, useClimatology} from "./solarData";
|
||||
|
||||
// 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
|
||||
// code, small. Keys are short and must stay stable; shared links depend on them.
|
||||
const DEFAULT_PANEL_WATTS = 400;
|
||||
const DEFAULT_PANEL_COUNT = 1;
|
||||
|
||||
const COMPASS = [
|
||||
{value: '0', label: 'N'}, {value: '45', label: 'NE'}, {value: '90', label: 'E'}, {value: '135', label: 'SE'},
|
||||
{value: '180', label: 'S'}, {value: '225', label: 'SW'}, {value: '270', label: 'W'}, {value: '315', label: 'NW'},
|
||||
];
|
||||
|
||||
const fToC = f => (f - 32) * 5 / 9;
|
||||
const cToF = c => c * 9 / 5 + 32;
|
||||
|
||||
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 = 2) => (Number.isFinite(value) ? `${roundDigits(value, digits)}` : '—');
|
||||
|
||||
// Read and write the calculator's query parameters. Writes replace the history entry, so typing does
|
||||
// not fill the Back button with one entry per keystroke.
|
||||
export function useSolarParams() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const get = key => searchParams.get(key);
|
||||
|
||||
// `updates` maps keys to values; null, undefined and '' remove the key.
|
||||
const set = updates => setSearchParams(prev => {
|
||||
const next = new URLSearchParams(prev);
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.set(key, `${value}`);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
}, {replace: true});
|
||||
|
||||
// Store a number, or remove it when it equals the default.
|
||||
const setNumber = (key, value, fallback) =>
|
||||
set({[key]: value === '' || Number(value) === fallback ? null : value});
|
||||
|
||||
return {searchParams, get, set, setNumber};
|
||||
}
|
||||
|
||||
// Turn the query parameters into the model's inputs. Invalid values fall back to defaults, so a
|
||||
// hand-edited or truncated link never crashes the page.
|
||||
export function systemFromParams(get) {
|
||||
const latitude = parseCoordinate(get('lat'), 'lat');
|
||||
const longitude = parseCoordinate(get('lon'), 'lon');
|
||||
const fahrenheit = get('tu') === 'f';
|
||||
|
||||
// Own sunlight data replaces the NASA data when present.
|
||||
const ownData = get('ghi') !== null;
|
||||
const ghi = parseMonthly(get('ghi')).map(v => (v !== null && v >= 0 ? v : null));
|
||||
const temperatureInput = parseMonthly(get('temp'));
|
||||
const temperature = temperatureInput.map(v => (v === null ? null : (fahrenheit ? fToC(v) : v)));
|
||||
|
||||
const panelWatts = Math.max(0, numberOr(get('pw'), DEFAULT_PANEL_WATTS));
|
||||
const panelCount = Math.max(0, Math.round(numberOr(get('pn'), DEFAULT_PANEL_COUNT)));
|
||||
const kwp = panelWatts * panelCount / 1000;
|
||||
|
||||
const tracking = TRACKING[get('track')] ? get('track') : 'fixed';
|
||||
const mounting = MOUNTINGS[get('mount')] ? get('mount') : 'open';
|
||||
const gamma = numberOr(get('gamma'), MODULE_TYPES.standard.gamma);
|
||||
const defaultTilt = latitude === null ? 0 : Math.round(Math.abs(latitude));
|
||||
const tilt = Math.min(90, Math.max(0, numberOr(get('tilt'), defaultTilt)));
|
||||
const azimuth = numberOr(get('az'), defaultAzimuth(latitude ?? 0));
|
||||
// Null means "use the monthly albedo from the data", which includes seasonal snow.
|
||||
const albedoInput = numberOr(get('alb'), null);
|
||||
const albedo = albedoInput === null ? null : Math.min(1, Math.max(0, albedoInput));
|
||||
|
||||
const lossValues = parseMonthly(get('losses'));
|
||||
const losses = LOSS_CATEGORIES.map((c, i) => {
|
||||
const v = lossValues[i];
|
||||
return v !== null && v >= 0 && v <= 100 ? v : c.default;
|
||||
});
|
||||
|
||||
const inverterOn = get('inv') === '1';
|
||||
const acRating = numberOr(get('iac'), roundDigits(kwp / 1.2, 2));
|
||||
const efficiency = numberOr(get('ieff'), INVERTER_NOMINAL_EFFICIENCY);
|
||||
|
||||
return {
|
||||
latitude, longitude, fahrenheit, ownData, ghi, temperatureInput, temperature, panelWatts, panelCount,
|
||||
kwp, tracking, mounting, gamma, defaultTilt, tilt, azimuth, albedo, losses, inverterOn, acRating,
|
||||
efficiency,
|
||||
};
|
||||
}
|
||||
|
||||
// The sunlight inputs for the model: the user's own values, or the NASA data at the location.
|
||||
export function sunlightFor(s, grid) {
|
||||
if (s.ownData) {
|
||||
return {ghi: s.ghi, diffuse: null, temperature: s.temperature, albedo: null};
|
||||
}
|
||||
if (!grid || s.latitude === null || s.longitude === null) {
|
||||
return null;
|
||||
}
|
||||
return lookupClimatology(grid, s.latitude, s.longitude);
|
||||
}
|
||||
|
||||
// What stops an estimate, or an empty list when it can run.
|
||||
export function missingInputs(s, sunlight) {
|
||||
const missing = [];
|
||||
if (s.latitude === null) {
|
||||
missing.push('a latitude');
|
||||
}
|
||||
if (!s.ownData && s.longitude === null) {
|
||||
missing.push('a longitude');
|
||||
}
|
||||
if (s.ownData && s.ghi.some(v => v === null)) {
|
||||
missing.push('sunlight for all 12 months');
|
||||
}
|
||||
if (!(s.kwp > 0)) {
|
||||
missing.push('panel watts and count');
|
||||
}
|
||||
if (missing.length === 0 && (!sunlight || sunlight.ghi.some(v => v === null))) {
|
||||
missing.push('a location with sunlight data');
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
const modelInputs = (s, sunlight) => ({
|
||||
latitude: s.latitude,
|
||||
ghi: sunlight.ghi,
|
||||
diffuse: sunlight.diffuse,
|
||||
temperature: sunlight.temperature,
|
||||
albedo: s.albedo ?? sunlight.albedo ?? undefined,
|
||||
kwp: s.kwp,
|
||||
tilt: s.tilt,
|
||||
azimuth: s.azimuth,
|
||||
tracking: s.tracking,
|
||||
mounting: s.mounting,
|
||||
gamma: s.gamma,
|
||||
losses: s.losses,
|
||||
inverter: s.inverterOn ? {acRating: s.acRating, efficiency: s.efficiency} : null,
|
||||
});
|
||||
|
||||
const compassLabel = azimuth => {
|
||||
const exact = COMPASS.find(c => Number(c.value) === azimuth);
|
||||
return exact ? exact.label : `${azimuth}°`;
|
||||
};
|
||||
|
||||
// Small explanatory text at the top of a collapsed section.
|
||||
const Hint = ({children}) => <p style={{fontSize: '0.9em', opacity: 0.75, marginTop: 0}}>{children}</p>;
|
||||
|
||||
const ESTIMATE_INFO = 'A typical-year estimate from monthly averages. Real years vary; expect roughly ±10–15% '
|
||||
+ 'over a year and more in any single month. Shading from trees and buildings is not modeled beyond the '
|
||||
+ 'Shading loss.';
|
||||
|
||||
function LocationSection({get, set, s, climatology}) {
|
||||
const latError = get('lat') && s.latitude === null ? 'Latitude must be between -90 and 90' : null;
|
||||
const lonError = get('lon') && s.longitude === null ? 'Longitude must be between -180 and 180' : null;
|
||||
|
||||
let source;
|
||||
if (s.ownData) {
|
||||
source = 'Using your own sunlight data.';
|
||||
} else if (climatology.loading) {
|
||||
source = <Loader size='xs' label='Loading sunlight data'/>;
|
||||
} else if (climatology.error) {
|
||||
source = <Message kind='warning' title='Sunlight data could not be loaded'>
|
||||
Enter your own monthly sunlight under "Use my own sunlight data" below.
|
||||
</Message>;
|
||||
} else {
|
||||
source = `Sunlight and temperature: ${CLIMATOLOGY_SOURCE}.`;
|
||||
}
|
||||
|
||||
return <>
|
||||
<Header as='h3'>1. Location</Header>
|
||||
<Grid>
|
||||
<Grid.Col span={{base: 12, xs: 6}}>
|
||||
<TextInput label='Latitude' placeholder='40.015 or 40°0′54″N' name='lat'
|
||||
value={get('lat') || ''} error={latError}
|
||||
onChange={e => set({lat: e.target.value})}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 12, xs: 6}}>
|
||||
<TextInput label='Longitude' placeholder='-105.27 or 105°16′12″W' name='lon'
|
||||
value={get('lon') || ''} error={lonError}
|
||||
onChange={e => set({lon: e.target.value})}/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<div style={{fontSize: '0.85em', opacity: 0.75, marginTop: '0.5em'}}>{source}</div>
|
||||
</>;
|
||||
}
|
||||
|
||||
function PanelsSection({setNumber, s}) {
|
||||
return <>
|
||||
<Header as='h3' style={{marginTop: '1em'}}>2. Your panels</Header>
|
||||
<Grid>
|
||||
<Grid.Col span={{base: 6, sm: 3}}>
|
||||
<NumberInput label='Watts per panel' name='pw' value={s.panelWatts} min={0} step={10}
|
||||
onChange={v => setNumber('pw', v, DEFAULT_PANEL_WATTS)}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 6, sm: 3}}>
|
||||
<NumberInput label='Panels' name='pn' value={s.panelCount} min={0} step={1}
|
||||
onChange={v => setNumber('pn', v, DEFAULT_PANEL_COUNT)}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 12, sm: 6}} style={{display: 'flex', alignItems: 'flex-end'}}>
|
||||
<span style={{opacity: 0.75, paddingBottom: '0.5em'}}>
|
||||
{fmt(s.kwp * 1000, 0)} W total ({fmt(s.kwp)} kW)
|
||||
</span>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</>;
|
||||
}
|
||||
|
||||
function Results({result, s}) {
|
||||
const unit = s.inverterOn ? 'kWh AC' : 'kWh DC';
|
||||
const rows = result.months.map(m => <Table.Row key={m.month}>
|
||||
<Table.Cell>{m.month}</Table.Cell>
|
||||
<Table.Cell>{fmt(m.energyDaily)}</Table.Cell>
|
||||
<Table.Cell>{fmt(m.energy, 0)}</Table.Cell>
|
||||
<Table.Cell>{fmt(m.poa)}</Table.Cell>
|
||||
<Table.Cell>{fmt(m.dayLength, 1)} h</Table.Cell>
|
||||
</Table.Row>);
|
||||
|
||||
return <Panel style={{marginTop: '1em'}}>
|
||||
<Header as='h3'>Estimate <InfoPopup content={ESTIMATE_INFO}/></Header>
|
||||
<StatisticGroup>
|
||||
<Statistic value={fmt(result.annual, 0)} label={`${unit} per year`}/>
|
||||
<Statistic value={fmt(result.annual / 365, 1)} label={`${unit} per day, average`}/>
|
||||
<Statistic value={fmt(result.worst.energyDaily, 1)} label={`${unit} per day in ${result.worst.month}`}/>
|
||||
<Statistic value={fmt(result.specificYield, 0)} label='kWh per year per kW of panels'/>
|
||||
</StatisticGroup>
|
||||
<Table>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Month</Table.HeaderCell>
|
||||
<Table.HeaderCell>{unit}/day</Table.HeaderCell>
|
||||
<Table.HeaderCell>{unit}/month</Table.HeaderCell>
|
||||
<Table.HeaderCell>Sun hours on panel</Table.HeaderCell>
|
||||
<Table.HeaderCell>Day length</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>{rows}</Table.Body>
|
||||
</Table>
|
||||
</Panel>;
|
||||
}
|
||||
|
||||
function AngleSection({setNumber, s, sunlight, ready}) {
|
||||
const compass = COMPASS.find(c => Number(c.value) === s.azimuth)?.value || null;
|
||||
const applyOptimal = goal => setNumber('tilt', optimalTilt(modelInputs(s, sunlight), goal), s.defaultTilt);
|
||||
const azimuthDefault = defaultAzimuth(s.latitude ?? 0);
|
||||
|
||||
if (s.tracking !== 'fixed') {
|
||||
return <Hint>
|
||||
A tracking array turns to follow the sun, so it has no fixed angle. Change Tracking under Panel details
|
||||
to set one.
|
||||
</Hint>;
|
||||
}
|
||||
|
||||
return <>
|
||||
<Hint>
|
||||
Leave these alone unless your panels are already mounted. The default (tilted to your latitude, facing
|
||||
the equator) is close to the best for the year. "Best for winter" suits off-grid systems that must get
|
||||
through the darkest month.
|
||||
</Hint>
|
||||
<Grid>
|
||||
<Grid.Col span={{base: 12, sm: 6}}>
|
||||
<NumberInput label='Tilt (degrees from flat)' name='tilt' value={s.tilt} min={0} max={90} step={1}
|
||||
onChange={v => setNumber('tilt', v, s.defaultTilt)}/>
|
||||
<Group gap='xs' style={{marginTop: '0.25em'}}>
|
||||
<Button type='button' size='small' disabled={!ready} onClick={() => applyOptimal('annual')}>
|
||||
Best for the year
|
||||
</Button>
|
||||
<Button type='button' size='small' disabled={!ready} onClick={() => applyOptimal('winter')}>
|
||||
Best for winter
|
||||
</Button>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 12, sm: 6}}>
|
||||
<Group gap='xs' wrap='nowrap' align='flex-end'>
|
||||
<NumberInput label='Facing (compass degrees)' name='az' value={s.azimuth} min={0} max={359}
|
||||
step={5} style={{flex: 1}} onChange={v => setNumber('az', v, azimuthDefault)}/>
|
||||
<Select aria-label='Compass direction' data={COMPASS} value={compass} placeholder='—'
|
||||
style={{width: '5.5em'}} allowDeselect={false}
|
||||
onChange={v => setNumber('az', v, azimuthDefault)}/>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</>;
|
||||
}
|
||||
|
||||
function DetailsSection({set, setNumber, s}) {
|
||||
const moduleType = Object.entries(MODULE_TYPES).find(([, m]) => m.gamma === s.gamma)?.[0] || 'custom';
|
||||
return <>
|
||||
<Hint>
|
||||
The defaults suit a typical ground or pole mounted array of ordinary panels. The temperature coefficient
|
||||
is on your panel's datasheet; panels lose this much power for each °C they run above 25 °C.
|
||||
</Hint>
|
||||
<Grid>
|
||||
<Grid.Col span={{base: 12, sm: 6}}>
|
||||
<Select label='Panel type'
|
||||
data={[
|
||||
...Object.entries(MODULE_TYPES).map(([value, m]) => ({value, label: m.label})),
|
||||
{value: 'custom', label: 'Custom (from datasheet)'},
|
||||
]}
|
||||
value={moduleType} allowDeselect={false}
|
||||
onChange={v => v !== 'custom' && setNumber('gamma', MODULE_TYPES[v].gamma,
|
||||
MODULE_TYPES.standard.gamma)}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 12, sm: 6}}>
|
||||
<NumberInput label='Temperature coefficient of power (%/°C)' name='gamma' value={s.gamma}
|
||||
max={0} step={0.01} decimalScale={3}
|
||||
onChange={v => setNumber('gamma', v, MODULE_TYPES.standard.gamma)}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 12, sm: 6}}>
|
||||
<Select label='Mounting' data={Object.entries(MOUNTINGS).map(([value, m]) => ({value, label: m.label}))}
|
||||
value={s.mounting} allowDeselect={false}
|
||||
onChange={v => set({mount: v === 'open' ? null : v})}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 12, sm: 6}}>
|
||||
<Select label='Tracking' data={Object.entries(TRACKING).map(([value, t]) => ({value, label: t.label}))}
|
||||
value={s.tracking} allowDeselect={false}
|
||||
onChange={v => set({track: v === 'fixed' ? null : v})}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 12, sm: 6}}>
|
||||
<NumberInput label='Ground reflectance (albedo)' name='alb' value={s.albedo ?? ''}
|
||||
placeholder='From the data (monthly, includes snow)' min={0} max={1} step={0.05}
|
||||
decimalScale={2} onChange={v => set({alb: v === '' ? null : v})}/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</>;
|
||||
}
|
||||
|
||||
function LossesSection({set, s}) {
|
||||
const setLoss = (index, value) => {
|
||||
const next = [...s.losses];
|
||||
next[index] = value === '' ? LOSS_CATEGORIES[index].default : Number(value);
|
||||
const isDefault = next.every((v, i) => v === DEFAULT_LOSSES[i]);
|
||||
set({losses: isDefault ? null : formatMonthly(next)});
|
||||
};
|
||||
|
||||
return <>
|
||||
<Hint>
|
||||
Real systems lose some power to dirt, wiring, shading and more. These are PVWatts' defaults. Raise Shading
|
||||
if trees or buildings shade your panels, or Snow if snow sits on them for weeks.
|
||||
</Hint>
|
||||
<Grid>
|
||||
{LOSS_CATEGORIES.map((c, i) => <Grid.Col key={c.key} span={{base: 6, sm: 4}}>
|
||||
<NumberInput label={`${c.label} (%)`} name={`loss-${c.key}`} value={s.losses[i]}
|
||||
min={0} max={100} step={0.5} onChange={v => setLoss(i, v)}/>
|
||||
</Grid.Col>)}
|
||||
</Grid>
|
||||
<Button type='button' size='small' style={{marginTop: '0.5em'}} onClick={() => set({losses: null})}>
|
||||
Reset to PVWatts defaults
|
||||
</Button>
|
||||
</>;
|
||||
}
|
||||
|
||||
function InverterSection({set, setNumber, s}) {
|
||||
return <>
|
||||
<Hint>
|
||||
Leave this off if your panels charge batteries through a charge controller. Turn it on to see AC output
|
||||
from a grid-tie or hybrid inverter.
|
||||
</Hint>
|
||||
<Toggle label='Include an inverter (AC output)' checked={s.inverterOn}
|
||||
onChange={e => set({inv: e.currentTarget.checked ? '1' : null})}/>
|
||||
{s.inverterOn && <Grid style={{marginTop: '0.5em'}}>
|
||||
<Grid.Col span={{base: 6}}>
|
||||
<NumberInput label='AC rating (kW)' name='iac' value={s.acRating} min={0} step={0.1} decimalScale={2}
|
||||
onChange={v => setNumber('iac', v, roundDigits(s.kwp / 1.2, 2))}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 6}}>
|
||||
<NumberInput label='Efficiency (%)' name='ieff' value={s.efficiency} min={50} max={100} step={0.5}
|
||||
onChange={v => setNumber('ieff', v, INVERTER_NOMINAL_EFFICIENCY)}/>
|
||||
</Grid.Col>
|
||||
</Grid>}
|
||||
</>;
|
||||
}
|
||||
|
||||
function OwnDataSection({set, s, nasa}) {
|
||||
const unit = s.fahrenheit ? '°F' : '°C';
|
||||
|
||||
// Start from the NASA values, when available, so the user edits rather than types all 24.
|
||||
const enable = on => {
|
||||
if (!on) {
|
||||
set({ghi: null, temp: null});
|
||||
return;
|
||||
}
|
||||
const ghi = nasa ? nasa.ghi.map(v => (v === null ? '' : roundDigits(v, 2))) : Array(12).fill('');
|
||||
const temp = nasa
|
||||
? nasa.temperature.map(v => (v === null ? '' : roundDigits(s.fahrenheit ? cToF(v) : v, 1)))
|
||||
: Array(12).fill('');
|
||||
// An all-blank list is still written for sunlight, so the section stays on for typing.
|
||||
set({ghi: formatMonthly(ghi), temp: nasa ? formatMonthly(temp) : null});
|
||||
};
|
||||
|
||||
const setMonth = (key, values, index, value) => {
|
||||
const next = [...values];
|
||||
next[index] = value === '' ? null : value;
|
||||
set({[key]: formatMonthly(next)});
|
||||
};
|
||||
|
||||
const rows = MONTH_NAMES.map((name, i) => <Table.Row key={name}>
|
||||
<Table.Cell>{name}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<NumberInput name={`ghi-${i}`} aria-label={`${name} sunlight`} value={s.ghi[i] ?? ''} min={0}
|
||||
step={0.1} hideControls onChange={v => setMonth('ghi', s.ghi, i, v)}/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<NumberInput name={`temp-${i}`} aria-label={`${name} temperature`}
|
||||
placeholder={s.fahrenheit ? '68' : '20'} value={s.temperatureInput[i] ?? ''} step={1}
|
||||
hideControls onChange={v => setMonth('temp', s.temperatureInput, i, v)}/>
|
||||
</Table.Cell>
|
||||
</Table.Row>);
|
||||
|
||||
return <>
|
||||
<Hint>
|
||||
Only needed if you have better local measurements than the built-in data, for example in mountains, where
|
||||
a 1° grid cell (about 69 miles across) can average very different terrain. Sunlight is global horizontal
|
||||
irradiance in kWh/m²/day, the same as "peak sun hours" on flat ground. Blank temperatures use 20 °C.
|
||||
</Hint>
|
||||
<Group gap='lg'>
|
||||
<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})}/>}
|
||||
</Group>
|
||||
{s.ownData && <Table style={{marginTop: '0.5em'}}>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Month</Table.HeaderCell>
|
||||
<Table.HeaderCell>Sunlight (kWh/m²/day)</Table.HeaderCell>
|
||||
<Table.HeaderCell>Avg. temp ({unit})</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>{rows}</Table.Body>
|
||||
</Table>}
|
||||
</>;
|
||||
}
|
||||
|
||||
export function SolarCalculator() {
|
||||
const {searchParams, get, set, setNumber} = useSolarParams();
|
||||
const climatology = useClimatology();
|
||||
const queryString = searchParams.toString();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const s = React.useMemo(() => systemFromParams(get), [queryString]);
|
||||
const nasa = React.useMemo(
|
||||
() => (climatology.grid && s.latitude !== null && s.longitude !== null
|
||||
? lookupClimatology(climatology.grid, s.latitude, s.longitude) : null),
|
||||
[climatology.grid, s.latitude, s.longitude]);
|
||||
const sunlight = s.ownData ? sunlightFor(s, null) : nasa;
|
||||
const missing = missingInputs(s, sunlight);
|
||||
const ready = missing.length === 0;
|
||||
const result = React.useMemo(
|
||||
() => (ready ? estimateProduction(modelInputs(s, sunlight)) : null),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[s, sunlight, ready]);
|
||||
|
||||
const angleTitle = s.tracking === 'fixed'
|
||||
? `Panel angle: ${s.tilt}° tilt, facing ${compassLabel(s.azimuth)}`
|
||||
: 'Panel angle: follows the sun';
|
||||
const detailsTitle = `Panel details: ${TRACKING[s.tracking].label.toLowerCase()}, `
|
||||
+ `${MOUNTINGS[s.mounting].label.toLowerCase()}`;
|
||||
const waitingForData = climatology.loading && !s.ownData;
|
||||
|
||||
return <div>
|
||||
<Header as='h1'>Solar</Header>
|
||||
<p>
|
||||
Estimate what solar panels will produce over a typical year. Enter a location and your panels; everything
|
||||
else has sensible defaults. Settings are saved in the page address, so you can bookmark or share them.
|
||||
</p>
|
||||
|
||||
<LocationSection get={get} set={set} s={s} climatology={climatology}/>
|
||||
<PanelsSection setNumber={setNumber} s={s}/>
|
||||
|
||||
{ready && <Results result={result} s={s}/>}
|
||||
{!ready && !waitingForData &&
|
||||
<Message kind='info' title='Almost there'>Enter {missing.join(', ')} to see an estimate.</Message>}
|
||||
|
||||
<Header as='h3' style={{marginTop: '1.5em'}}>Fine-tune (optional)</Header>
|
||||
<Accordion multiple>
|
||||
<Accordion.Item value='angle'>
|
||||
<Accordion.Control>{angleTitle}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<AngleSection setNumber={setNumber} s={s} sunlight={sunlight} ready={ready}/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value='details'>
|
||||
<Accordion.Control>{detailsTitle}</Accordion.Control>
|
||||
<Accordion.Panel><DetailsSection set={set} setNumber={setNumber} s={s}/></Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value='losses'>
|
||||
<Accordion.Control>System losses: {fmt(totalLoss(s.losses))}%</Accordion.Control>
|
||||
<Accordion.Panel><LossesSection set={set} s={s}/></Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value='inverter'>
|
||||
<Accordion.Control>Inverter: {s.inverterOn ? 'on (AC output)' : 'off (DC output)'}</Accordion.Control>
|
||||
<Accordion.Panel><InverterSection set={set} setNumber={setNumber} s={s}/></Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value='own'>
|
||||
<Accordion.Control>Use my own sunlight data: {s.ownData ? 'on' : 'off'}</Accordion.Control>
|
||||
<Accordion.Panel><OwnDataSection set={set} s={s} nasa={nasa}/></Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</div>;
|
||||
}
|
||||
190
app/src/components/calculators/SolarCalculator.test.js
Normal file
190
app/src/components/calculators/SolarCalculator.test.js
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import React from "react";
|
||||
import {fireEvent, screen, within} from "@testing-library/react";
|
||||
import {renderWithProviders} from "../../test-utils";
|
||||
import {missingInputs, SolarCalculator, sunlightFor, systemFromParams} from "./SolarCalculator";
|
||||
import {CalculatorsPage} from "../Calculators";
|
||||
import {DEFAULT_LOSSES, estimateProduction, MODULE_TYPES} from "./solar";
|
||||
import {decodeClimatology, forgetClimatology, lookupClimatology} from "./solarData";
|
||||
import {mockClimatologyFetch, SUNNY_GHI, uniformClimatology} from "./solarData.fixtures";
|
||||
import {roundDigits} from "../Common";
|
||||
|
||||
const BASE = '/more/calculators?calc=solar';
|
||||
const DENVER = `${BASE}&lat=40&lon=-105`;
|
||||
|
||||
const params = query => {
|
||||
const searchParams = new URLSearchParams(query);
|
||||
return key => searchParams.get(key);
|
||||
};
|
||||
|
||||
const currentParams = () => new URLSearchParams(window.location.search);
|
||||
|
||||
// The estimate appears once the data file has loaded and decoded, which can be slow on a busy machine.
|
||||
const findEstimate = (unit = 'DC') => screen.findByText(`kWh ${unit} per year`, {}, {timeout: 5000});
|
||||
|
||||
// The annual estimate the page should show for a location, from the same uniform test data.
|
||||
const expectedAnnual = (overrides = {}) => {
|
||||
const grid = decodeClimatology(uniformClimatology());
|
||||
const sunlight = lookupClimatology(grid, 40, -105);
|
||||
return estimateProduction({latitude: 40, kwp: 0.4, tilt: 40, azimuth: 180, ...sunlight, ...overrides}).annual;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
forgetClimatology();
|
||||
mockClimatologyFetch();
|
||||
});
|
||||
|
||||
describe('systemFromParams', () => {
|
||||
test('defaults when the URL is empty', () => {
|
||||
const s = systemFromParams(params(''));
|
||||
expect(s.latitude).toBeNull();
|
||||
expect(s.kwp).toBeCloseTo(0.4, 6);
|
||||
expect(s.tracking).toBe('fixed');
|
||||
expect(s.mounting).toBe('open');
|
||||
expect(s.gamma).toBe(MODULE_TYPES.standard.gamma);
|
||||
expect(s.losses).toEqual(DEFAULT_LOSSES);
|
||||
expect(s.inverterOn).toBe(false);
|
||||
expect(s.ownData).toBe(false);
|
||||
expect(s.albedo).toBeNull();
|
||||
});
|
||||
|
||||
test('tilt and azimuth default from the latitude', () => {
|
||||
expect(systemFromParams(params('lat=40.4')).tilt).toBe(40);
|
||||
expect(systemFromParams(params('lat=40.4')).azimuth).toBe(180);
|
||||
expect(systemFromParams(params('lat=-33.9')).tilt).toBe(34);
|
||||
expect(systemFromParams(params('lat=-33.9')).azimuth).toBe(0);
|
||||
});
|
||||
|
||||
test('invalid values fall back instead of crashing', () => {
|
||||
const s = systemFromParams(params('lat=999&track=bogus&mount=x&tilt=abc&losses=1,2,abc&alb=7&pn=-3'));
|
||||
expect(s.latitude).toBeNull();
|
||||
expect(s.tracking).toBe('fixed');
|
||||
expect(s.mounting).toBe('open');
|
||||
expect(s.tilt).toBe(0);
|
||||
expect(s.losses.slice(0, 3)).toEqual([1, 2, DEFAULT_LOSSES[2]]);
|
||||
expect(s.albedo).toBe(1);
|
||||
expect(s.kwp).toBe(0);
|
||||
});
|
||||
|
||||
test('own Fahrenheit temperatures are converted to Celsius', () => {
|
||||
const s = systemFromParams(params('ghi=5&tu=f&temp=32,212'));
|
||||
expect(s.ownData).toBe(true);
|
||||
expect(s.temperature[0]).toBeCloseTo(0, 6);
|
||||
expect(s.temperature[1]).toBeCloseTo(100, 6);
|
||||
expect(s.temperatureInput[0]).toBe(32);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sunlight and missing inputs', () => {
|
||||
const grid = decodeClimatology(uniformClimatology());
|
||||
|
||||
test('the NASA data is used unless the user gave their own', () => {
|
||||
const nasa = sunlightFor(systemFromParams(params('lat=40&lon=-105')), grid);
|
||||
expect(nasa.ghi[5]).toBeCloseTo(SUNNY_GHI[5], 1);
|
||||
expect(nasa.diffuse[5]).toBeCloseTo(SUNNY_GHI[5] * 0.3, 1);
|
||||
|
||||
const own = sunlightFor(systemFromParams(params(`lat=40&lon=-105&ghi=${Array(12).fill(1).join(',')}`)), grid);
|
||||
expect(own.ghi).toEqual(Array(12).fill(1));
|
||||
expect(own.diffuse).toBeNull();
|
||||
});
|
||||
|
||||
test('a location is enough with the NASA data', () => {
|
||||
const s = systemFromParams(params('lat=40&lon=-105'));
|
||||
expect(missingInputs(s, sunlightFor(s, grid))).toEqual([]);
|
||||
const noLon = systemFromParams(params('lat=40'));
|
||||
expect(missingInputs(noLon, sunlightFor(noLon, grid))).toEqual(['a longitude']);
|
||||
});
|
||||
|
||||
test('own data needs all 12 months but no longitude', () => {
|
||||
const s = systemFromParams(params('lat=40&ghi=1,2,3'));
|
||||
expect(missingInputs(s, sunlightFor(s, grid))).toEqual(['sunlight for all 12 months']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SolarCalculator', () => {
|
||||
test('is linked from the Engineering group of the calculators page', () => {
|
||||
renderWithProviders(<CalculatorsPage/>, {route: '/more/calculators'});
|
||||
const engineering = screen.getByRole('heading', {name: 'Engineering'}).closest('.wrolpi-header').parentElement;
|
||||
const link = within(engineering).getByRole('link', {name: /Solar/});
|
||||
expect(link).toHaveAttribute('href', '/more/calculators?calc=solar');
|
||||
});
|
||||
|
||||
test('asks for a location', async () => {
|
||||
renderWithProviders(<SolarCalculator/>, {route: BASE});
|
||||
expect(await screen.findByText('Enter a latitude, a longitude to see an estimate.', {}, {timeout: 5000}))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a location alone gives an estimate from the built-in data', async () => {
|
||||
renderWithProviders(<SolarCalculator/>, {route: DENVER});
|
||||
expect(await findEstimate()).toBeInTheDocument();
|
||||
expect(screen.getByText(`${roundDigits(expectedAnnual(), 0)}`)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Sunlight and temperature: NASA POWER/)).toBeInTheDocument();
|
||||
expect(global.fetch).toHaveBeenCalledWith(expect.stringMatching(/\/data\/solar-climatology\.bin$/));
|
||||
});
|
||||
|
||||
test('typing writes the URL and leaves defaults out', async () => {
|
||||
renderWithProviders(<SolarCalculator/>, {route: BASE});
|
||||
fireEvent.change(screen.getByLabelText('Latitude'), {target: {value: '40°0\'54"N'}});
|
||||
expect(currentParams().get('lat')).toBe('40°0\'54"N');
|
||||
expect(currentParams().get('calc')).toBe('solar');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Panels'), {target: {value: '4'}});
|
||||
expect(currentParams().get('pn')).toBe('4');
|
||||
fireEvent.change(screen.getByLabelText('Panels'), {target: {value: '1'}});
|
||||
expect(currentParams().has('pn')).toBe(false);
|
||||
});
|
||||
|
||||
test('an invalid latitude is flagged', () => {
|
||||
renderWithProviders(<SolarCalculator/>, {route: `${BASE}&lat=95`});
|
||||
expect(screen.getByText('Latitude must be between -90 and 90')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('the inverter switches the results to AC', async () => {
|
||||
renderWithProviders(<SolarCalculator/>, {route: DENVER});
|
||||
await findEstimate();
|
||||
fireEvent.click(screen.getByText('Inverter: off (DC output)'));
|
||||
fireEvent.click(screen.getByLabelText('Include an inverter (AC output)'));
|
||||
expect(currentParams().get('inv')).toBe('1');
|
||||
expect(screen.getByText('kWh AC per year')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('best-for-the-year replaces a poor tilt', async () => {
|
||||
renderWithProviders(<SolarCalculator/>, {route: `${DENVER}&tilt=10`});
|
||||
await findEstimate();
|
||||
expect(screen.getByText('Panel angle: 10° tilt, facing S')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('Panel angle: 10° tilt, facing S'));
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'Best for the year'}));
|
||||
// A result of 40 equals the default (the latitude), so it is left out of the URL.
|
||||
const tilt = Number(currentParams().get('tilt') ?? 40);
|
||||
expect(tilt).toBeGreaterThanOrEqual(30);
|
||||
expect(tilt).toBeLessThanOrEqual(44);
|
||||
});
|
||||
|
||||
test('own sunlight data starts from the built-in values and replaces them', async () => {
|
||||
renderWithProviders(<SolarCalculator/>, {route: DENVER});
|
||||
await findEstimate();
|
||||
fireEvent.click(screen.getByText('Use my own sunlight data: off'));
|
||||
fireEvent.click(screen.getByLabelText('Use my own sunlight data'));
|
||||
|
||||
const ghi = currentParams().get('ghi').split(',').map(Number);
|
||||
ghi.forEach((v, i) => expect(v).toBeCloseTo(SUNNY_GHI[i], 1));
|
||||
expect(screen.getByText('Using your own sunlight data.')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Jun sunlight')).toHaveValue(`${ghi[5]}`);
|
||||
|
||||
// Halving June's sunlight lowers the estimate.
|
||||
fireEvent.change(screen.getByLabelText('Jun sunlight'), {target: {value: `${ghi[5] / 2}`}});
|
||||
expect(currentParams().get('ghi').split(',')[5]).toBe(`${ghi[5] / 2}`);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Use my own sunlight data'));
|
||||
expect(currentParams().has('ghi')).toBe(false);
|
||||
expect(currentParams().has('temp')).toBe(false);
|
||||
});
|
||||
|
||||
test('a failed download explains how to continue', async () => {
|
||||
forgetClimatology();
|
||||
global.fetch = jest.fn(() => Promise.resolve({ok: false, status: 404}));
|
||||
renderWithProviders(<SolarCalculator/>, {route: DENVER});
|
||||
expect(await screen.findByText('Sunlight data could not be loaded', {}, {timeout: 5000})).toBeInTheDocument();
|
||||
expect(screen.getByText('Enter a location with sunlight data to see an estimate.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
472
app/src/components/calculators/solar.js
Normal file
472
app/src/components/calculators/solar.js
Normal file
|
|
@ -0,0 +1,472 @@
|
|||
// Pure calculation functions for the Solar calculator. No React here; SolarCalculator.js is the UI.
|
||||
//
|
||||
// The model estimates a typical year from 12 monthly averages, the standard approach when hourly
|
||||
// typical-year weather files are not available:
|
||||
//
|
||||
// 1. Sun geometry for Klein's representative day of each month (Duffie & Beckman ch. 1).
|
||||
// 2. Monthly global horizontal irradiation (GHI) is split into diffuse and beam (Erbs monthly
|
||||
// correlation), then spread across the day (Collares-Pereira & Rabl for total, Liu & Jordan
|
||||
// for diffuse).
|
||||
// 3. Each step is transposed onto the panel with the HDKR sky model, for a fixed, single-axis or
|
||||
// dual-axis array.
|
||||
// 4. PVWatts' models turn plane-of-array irradiance into energy: ASHRAE angle-of-incidence loss,
|
||||
// SAPM cell temperature, linear temperature-corrected DC power, multiplicative system losses,
|
||||
// and an optional inverter efficiency curve with clipping.
|
||||
//
|
||||
// Angles are in degrees at the API boundary. Azimuths are compass bearings (0 = north, 90 = east,
|
||||
// 180 = south, 270 = west); internally the Duffie & Beckman convention is used (0 = south, east
|
||||
// negative, west positive). Energy is kWh, irradiation kWh/m², irradiance W/m², power kW.
|
||||
|
||||
export const SOLAR_CONSTANT = 1361; // W/m²
|
||||
|
||||
const RAD = Math.PI / 180;
|
||||
const sin = deg => Math.sin(deg * RAD);
|
||||
const cos = deg => Math.cos(deg * RAD);
|
||||
const tan = deg => Math.tan(deg * RAD);
|
||||
const acos = x => Math.acos(Math.min(1, Math.max(-1, x))) / RAD;
|
||||
const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x));
|
||||
|
||||
export const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
export const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
|
||||
// Klein's recommended average day of each month: the day whose extraterrestrial radiation is
|
||||
// closest to the month's mean. Day of the year, 1-365.
|
||||
export const KLEIN_DAYS = [17, 47, 75, 105, 135, 162, 198, 228, 258, 288, 318, 344];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sun geometry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Solar declination (Cooper), degrees.
|
||||
export function declination(dayOfYear) {
|
||||
return 23.45 * sin(360 * (284 + dayOfYear) / 365);
|
||||
}
|
||||
|
||||
// Sunset hour angle on a horizontal surface, degrees. 0 during polar night, 180 during polar day.
|
||||
export function sunsetHourAngle(latitude, decl) {
|
||||
return acos(-tan(latitude) * tan(decl));
|
||||
}
|
||||
|
||||
// Orbit eccentricity correction to the solar constant.
|
||||
export function eccentricity(dayOfYear) {
|
||||
return 1 + 0.033 * cos(360 * dayOfYear / 365);
|
||||
}
|
||||
|
||||
// Daily extraterrestrial irradiation on a horizontal surface, kWh/m²/day (D&B eq. 1.10.3).
|
||||
export function extraterrestrialDaily(latitude, dayOfYear) {
|
||||
const decl = declination(dayOfYear);
|
||||
const ws = sunsetHourAngle(latitude, decl);
|
||||
const wh = (24 / Math.PI) * SOLAR_CONSTANT * eccentricity(dayOfYear)
|
||||
* (cos(latitude) * cos(decl) * sin(ws) + (Math.PI * ws / 180) * sin(latitude) * sin(decl));
|
||||
return Math.max(0, wh / 1000);
|
||||
}
|
||||
|
||||
// Day length in hours.
|
||||
export function dayLength(latitude, decl) {
|
||||
return 2 * sunsetHourAngle(latitude, decl) / 15;
|
||||
}
|
||||
|
||||
// Sun elevation at solar noon, degrees. Negative means the sun does not rise.
|
||||
export function noonElevation(latitude, decl) {
|
||||
return 90 - Math.abs(latitude - decl);
|
||||
}
|
||||
|
||||
// Sun position for an hour angle (degrees, 15° per hour from solar noon, afternoon positive).
|
||||
// Returns cosine of the zenith angle and the azimuth in the D&B convention (0 = south).
|
||||
export function sunPosition(latitude, decl, hourAngle) {
|
||||
const cosZenith = cos(latitude) * cos(decl) * cos(hourAngle) + sin(latitude) * sin(decl);
|
||||
const sinZenith = Math.sqrt(Math.max(0, 1 - cosZenith * cosZenith));
|
||||
let azimuth = 0;
|
||||
if (sinZenith > 1e-9 && Math.abs(cos(latitude)) > 1e-9) {
|
||||
azimuth = acos((cosZenith * sin(latitude) - sin(decl)) / (sinZenith * cos(latitude)));
|
||||
azimuth = hourAngle < 0 ? -azimuth : azimuth;
|
||||
}
|
||||
return {cosZenith, zenith: acos(cosZenith), azimuth};
|
||||
}
|
||||
|
||||
// Cosine of the angle of incidence on a surface of tilt `tilt` and D&B azimuth `surfaceAzimuth`.
|
||||
export function cosIncidence(zenith, sunAzimuth, tilt, surfaceAzimuth) {
|
||||
return cos(zenith) * cos(tilt) + sin(zenith) * sin(tilt) * cos(sunAzimuth - surfaceAzimuth);
|
||||
}
|
||||
|
||||
// Compass bearing (0 = north) to the D&B azimuth convention (0 = south, east negative).
|
||||
export function compassToSolarAzimuth(compass) {
|
||||
let a = ((compass % 360) + 360) % 360 - 180;
|
||||
return a === -180 ? 180 : a;
|
||||
}
|
||||
|
||||
// Default panel azimuth: face the equator.
|
||||
export function defaultAzimuth(latitude) {
|
||||
return latitude < 0 ? 0 : 180;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Monthly irradiation to sub-daily steps
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Monthly average diffuse fraction Hd/H from the clearness index (Erbs, Klein & Duffie 1982).
|
||||
// The correlation was fit for 0.3 <= KT <= 0.8, so KT is clamped to that range.
|
||||
export function erbsMonthlyDiffuseFraction(kt, sunsetAngle) {
|
||||
const k = clamp(kt, 0.3, 0.8);
|
||||
if (sunsetAngle <= 81.4) {
|
||||
return 1.391 - 3.560 * k + 4.189 * k * k - 2.137 * k * k * k;
|
||||
}
|
||||
return 1.311 - 3.022 * k + 3.427 * k * k - 1.821 * k * k * k;
|
||||
}
|
||||
|
||||
// Ratio of hourly to daily total irradiation at hour angle `w` (Collares-Pereira & Rabl).
|
||||
export function totalHourlyRatio(w, sunsetAngle) {
|
||||
if (Math.abs(w) >= sunsetAngle) {
|
||||
return 0;
|
||||
}
|
||||
const a = 0.409 + 0.5016 * sin(sunsetAngle - 60);
|
||||
const b = 0.6609 - 0.4767 * sin(sunsetAngle - 60);
|
||||
return diffuseHourlyRatio(w, sunsetAngle) * (a + b * cos(w));
|
||||
}
|
||||
|
||||
// Ratio of hourly to daily diffuse irradiation at hour angle `w` (Liu & Jordan).
|
||||
export function diffuseHourlyRatio(w, sunsetAngle) {
|
||||
if (Math.abs(w) >= sunsetAngle) {
|
||||
return 0;
|
||||
}
|
||||
const denominator = sin(sunsetAngle) - (Math.PI * sunsetAngle / 180) * cos(sunsetAngle);
|
||||
return (Math.PI / 24) * (cos(w) - cos(sunsetAngle)) / denominator;
|
||||
}
|
||||
|
||||
// Number of evenly spaced steps between sunrise and sunset. Steps adapt to the day length, so a
|
||||
// short polar-edge day is still sampled.
|
||||
export const STEPS_PER_DAY = 48;
|
||||
|
||||
// The average day of a month as a list of steps, each with its hour angle, duration (hours), and
|
||||
// the horizontal irradiances (W/m²) for global, beam and diffuse. The two hourly ratios are
|
||||
// normalized over the day so the steps add back up to exactly the monthly GHI and diffuse given.
|
||||
export function averageDaySteps(latitude, monthIndex, ghi, diffuse = null) {
|
||||
const n = KLEIN_DAYS[monthIndex];
|
||||
const decl = declination(n);
|
||||
const ws = sunsetHourAngle(latitude, decl);
|
||||
const h0 = extraterrestrialDaily(latitude, n);
|
||||
if (!(ghi > 0) || !(ws > 0) || !(h0 > 0)) {
|
||||
return {decl, sunsetAngle: ws, kt: null, diffuseFraction: null, steps: []};
|
||||
}
|
||||
|
||||
const kt = ghi / h0;
|
||||
const hd = diffuse != null && diffuse >= 0
|
||||
? Math.min(diffuse, ghi)
|
||||
: ghi * erbsMonthlyDiffuseFraction(kt, ws);
|
||||
|
||||
const dw = 2 * ws / STEPS_PER_DAY;
|
||||
const dt = dw / 15;
|
||||
const raw = [];
|
||||
let totalSum = 0, diffuseSum = 0;
|
||||
for (let i = 0; i < STEPS_PER_DAY; i++) {
|
||||
const w = -ws + (i + 0.5) * dw;
|
||||
const rt = totalHourlyRatio(w, ws);
|
||||
const rd = diffuseHourlyRatio(w, ws);
|
||||
raw.push({w, rt, rd});
|
||||
totalSum += rt * dt;
|
||||
diffuseSum += rd * dt;
|
||||
}
|
||||
|
||||
const steps = raw.map(({w, rt, rd}) => {
|
||||
// kWh/m² per hour is numerically kW/m², so x1000 gives the step's mean irradiance in W/m².
|
||||
const global = totalSum > 0 ? (rt / totalSum) * ghi * 1000 : 0;
|
||||
const diff = Math.min(global, diffuseSum > 0 ? (rd / diffuseSum) * hd * 1000 : 0);
|
||||
return {hourAngle: w, hours: dt, global, diffuse: diff, beam: global - diff};
|
||||
});
|
||||
return {decl, sunsetAngle: ws, kt, diffuseFraction: hd / ghi, steps};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Panel orientation and plane-of-array irradiance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const TRACKING = {
|
||||
fixed: {label: 'Fixed'},
|
||||
single: {label: 'Single-axis (N–S axis)'},
|
||||
dual: {label: 'Dual-axis'},
|
||||
};
|
||||
|
||||
// Single-axis trackers are limited to this rotation either side of flat.
|
||||
export const SINGLE_AXIS_LIMIT = 60;
|
||||
|
||||
// Panel tilt and D&B azimuth for a step. `tilt`/`azimuth` are only used for fixed arrays.
|
||||
export function panelOrientation(tracking, sun, tilt, azimuth) {
|
||||
if (tracking === 'dual') {
|
||||
return {tilt: sun.zenith, azimuth: sun.azimuth};
|
||||
}
|
||||
if (tracking === 'single') {
|
||||
// Horizontal north-south axis rotating east-west (Marion & Dobos ideal rotation).
|
||||
const rotation = clamp(
|
||||
Math.atan(Math.tan(sun.zenith * RAD) * Math.sin(sun.azimuth * RAD)) / RAD,
|
||||
-SINGLE_AXIS_LIMIT, SINGLE_AXIS_LIMIT);
|
||||
return {tilt: Math.abs(rotation), azimuth: rotation >= 0 ? 90 : -90};
|
||||
}
|
||||
return {tilt, azimuth};
|
||||
}
|
||||
|
||||
// Plane-of-array irradiance for one step with the HDKR (Hay-Davies-Klucher-Reindl) sky model.
|
||||
// Returns the beam (direct plus circumsolar), sky diffuse and ground-reflected parts in W/m², and
|
||||
// the angle of incidence.
|
||||
export function poaIrradiance({global, beam, diffuse}, sun, orientation, albedo, dayOfYear) {
|
||||
const {tilt, azimuth} = orientation;
|
||||
const cosTheta = cosIncidence(sun.zenith, sun.azimuth, tilt, azimuth);
|
||||
|
||||
// Beam normal irradiance, capped at the extraterrestrial value; near sunrise the division by a
|
||||
// tiny cos(zenith) would otherwise blow up.
|
||||
const extraNormal = SOLAR_CONSTANT * eccentricity(dayOfYear);
|
||||
const cosZ = Math.max(sun.cosZenith, 0.01745);
|
||||
const dni = Math.min(beam / cosZ, extraNormal);
|
||||
const rb = Math.max(0, cosTheta) / cosZ;
|
||||
|
||||
const anisotropy = clamp(dni / extraNormal, 0, 1);
|
||||
const horizonFactor = global > 0 ? Math.sqrt(beam / global) : 0;
|
||||
const beamPoa = dni * Math.max(0, cosTheta) + diffuse * anisotropy * rb;
|
||||
const skyPoa = diffuse * (1 - anisotropy) * ((1 + cos(tilt)) / 2)
|
||||
* (1 + horizonFactor * Math.pow(sin(tilt / 2), 3));
|
||||
const groundPoa = global * albedo * (1 - cos(tilt)) / 2;
|
||||
return {beam: beamPoa, sky: skyPoa, ground: groundPoa, total: beamPoa + skyPoa + groundPoa, cosTheta};
|
||||
}
|
||||
|
||||
// ASHRAE incidence angle modifier for the glass cover; applied to the beam part.
|
||||
export function ashraeIam(cosTheta, b0 = 0.05) {
|
||||
if (cosTheta <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return clamp(1 - b0 * (1 / cosTheta - 1), 0, 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Temperature, DC power, losses, inverter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Sandia (SAPM) cell temperature coefficients by mounting.
|
||||
export const MOUNTINGS = {
|
||||
open: {label: 'Open rack (ground or pole)', a: -3.56, b: -0.075, deltaT: 3},
|
||||
roof: {label: 'Close roof mount', a: -2.98, b: -0.0471, deltaT: 1},
|
||||
flush: {label: 'Insulated back / flush', a: -2.81, b: -0.0455, deltaT: 0},
|
||||
};
|
||||
|
||||
// PVWatts v8 temperature coefficients of power, %/°C.
|
||||
export const MODULE_TYPES = {
|
||||
standard: {label: 'Standard (crystalline)', gamma: -0.37},
|
||||
premium: {label: 'Premium (high efficiency)', gamma: -0.35},
|
||||
thinFilm: {label: 'Thin film', gamma: -0.32},
|
||||
};
|
||||
|
||||
export const DEFAULT_WIND_SPEED = 1; // m/s; deliberately low, so cell temperatures are not optimistic.
|
||||
|
||||
// SAPM cell temperature, °C.
|
||||
export function cellTemperature(poa, ambient, mounting = 'open', windSpeed = DEFAULT_WIND_SPEED) {
|
||||
const m = MOUNTINGS[mounting] || MOUNTINGS.open;
|
||||
const moduleTemp = poa * Math.exp(m.a + m.b * windSpeed) + ambient;
|
||||
return moduleTemp + (poa / 1000) * m.deltaT;
|
||||
}
|
||||
|
||||
// Daily air temperature swing, used when only a monthly mean is known: the mean +/- this amount.
|
||||
export const DEFAULT_DAILY_SWING = 5;
|
||||
|
||||
// Air temperature at an hour angle, peaking mid-afternoon (3 pm).
|
||||
export function ambientTemperature(mean, hourAngle, swing = DEFAULT_DAILY_SWING) {
|
||||
return mean + swing * cos(hourAngle - 45);
|
||||
}
|
||||
|
||||
// DC power, kW, for an array of `kwp` kilowatts (STC rating) and temperature coefficient `gamma` (%/°C).
|
||||
export function dcPower(effectiveIrradiance, cellTemp, kwp, gamma) {
|
||||
return Math.max(0, kwp * (effectiveIrradiance / 1000) * (1 + (gamma / 100) * (cellTemp - 25)));
|
||||
}
|
||||
|
||||
// PVWatts system loss categories and their default percentages.
|
||||
export const LOSS_CATEGORIES = [
|
||||
{key: 'soiling', label: 'Soiling', default: 2},
|
||||
{key: 'shading', label: 'Shading', default: 3},
|
||||
{key: 'snow', label: 'Snow', default: 0},
|
||||
{key: 'mismatch', label: 'Mismatch', default: 2},
|
||||
{key: 'wiring', label: 'Wiring', default: 2},
|
||||
{key: 'connections', label: 'Connections', default: 0.5},
|
||||
{key: 'lid', label: 'Light-induced degradation', default: 1.5},
|
||||
{key: 'nameplate', label: 'Nameplate rating', default: 1},
|
||||
{key: 'age', label: 'Age', default: 0},
|
||||
{key: 'availability', label: 'Availability', default: 3},
|
||||
];
|
||||
|
||||
export const DEFAULT_LOSSES = LOSS_CATEGORIES.map(c => c.default);
|
||||
|
||||
// Combine loss percentages multiplicatively, as PVWatts does. Returns a percentage.
|
||||
export function totalLoss(percentages) {
|
||||
const kept = percentages.reduce((acc, p) => acc * (1 - clamp(Number(p) || 0, 0, 100) / 100), 1);
|
||||
return 100 * (1 - kept);
|
||||
}
|
||||
|
||||
export const INVERTER_NOMINAL_EFFICIENCY = 96;
|
||||
const INVERTER_REFERENCE_EFFICIENCY = 0.9637;
|
||||
|
||||
// AC power, kW, from the PVWatts inverter model: part-load efficiency curve, clipped at the AC rating.
|
||||
export function inverterOutput(pdc, acRating, nominalEfficiency = INVERTER_NOMINAL_EFFICIENCY) {
|
||||
if (!(pdc > 0) || !(acRating > 0)) {
|
||||
return 0;
|
||||
}
|
||||
const etaNom = nominalEfficiency / 100;
|
||||
const pdc0 = acRating / etaNom;
|
||||
const zeta = pdc / pdc0;
|
||||
const eta = (etaNom / INVERTER_REFERENCE_EFFICIENCY) * (-0.0162 * zeta - 0.0059 / zeta + 0.9858);
|
||||
return Math.min(Math.max(0, eta * pdc), acRating);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The whole model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const DEFAULT_ALBEDO = 0.2;
|
||||
|
||||
/**
|
||||
* Estimate monthly and annual production.
|
||||
*
|
||||
* @param {Object} system
|
||||
* @param {number} system.latitude degrees, north positive
|
||||
* @param {number[]} system.ghi 12 monthly average GHI values, kWh/m²/day
|
||||
* @param {number[]} [system.diffuse] 12 monthly diffuse values, kWh/m²/day; estimated when absent
|
||||
* @param {number[]} [system.temperature] 12 monthly mean air temperatures, °C; 20 when absent
|
||||
* @param {number[]} [system.albedo] ground reflectance, one value or 12 monthly values
|
||||
* @param {number} system.kwp array STC rating, kW
|
||||
* @param {number} [system.tilt] degrees from horizontal (fixed arrays)
|
||||
* @param {number} [system.azimuth] compass degrees (fixed arrays)
|
||||
* @param {string} [system.tracking] 'fixed' | 'single' | 'dual'
|
||||
* @param {string} [system.mounting] key of MOUNTINGS
|
||||
* @param {number} [system.gamma] temperature coefficient of power, %/°C
|
||||
* @param {number[]} [system.losses] system loss percentages, see LOSS_CATEGORIES
|
||||
* @param {Object} [system.inverter] {acRating (kW), efficiency (%)}; null for DC output
|
||||
*/
|
||||
export function estimateProduction({
|
||||
latitude,
|
||||
ghi,
|
||||
diffuse = null,
|
||||
temperature = null,
|
||||
albedo = DEFAULT_ALBEDO,
|
||||
kwp,
|
||||
tilt = 0,
|
||||
azimuth = defaultAzimuth(latitude),
|
||||
tracking = 'fixed',
|
||||
mounting = 'open',
|
||||
gamma = MODULE_TYPES.standard.gamma,
|
||||
losses = DEFAULT_LOSSES,
|
||||
inverter = null,
|
||||
}) {
|
||||
const lossFactor = 1 - totalLoss(losses) / 100;
|
||||
const surfaceAzimuth = compassToSolarAzimuth(azimuth);
|
||||
|
||||
const months = MONTH_NAMES.map((name, m) => {
|
||||
const n = KLEIN_DAYS[m];
|
||||
const givenAlbedo = Array.isArray(albedo) ? albedo[m] : albedo;
|
||||
const monthAlbedo = Number.isFinite(givenAlbedo) ? givenAlbedo : DEFAULT_ALBEDO;
|
||||
const meanTemp = temperature && Number.isFinite(temperature[m]) ? temperature[m] : 20;
|
||||
const day = averageDaySteps(latitude, m, ghi[m], diffuse ? diffuse[m] : null);
|
||||
|
||||
let poaDaily = 0, energyDaily = 0;
|
||||
for (const step of day.steps) {
|
||||
const sun = sunPosition(latitude, day.decl, step.hourAngle);
|
||||
if (sun.cosZenith <= 0) {
|
||||
continue;
|
||||
}
|
||||
const orientation = panelOrientation(tracking, sun, tilt, surfaceAzimuth);
|
||||
const poa = poaIrradiance(step, sun, orientation, monthAlbedo, n);
|
||||
const effective = poa.beam * ashraeIam(poa.cosTheta) + poa.sky + poa.ground;
|
||||
const tc = cellTemperature(poa.total, ambientTemperature(meanTemp, step.hourAngle), mounting);
|
||||
let power = dcPower(effective, tc, kwp, gamma) * lossFactor;
|
||||
if (inverter) {
|
||||
power = inverterOutput(power, inverter.acRating, inverter.efficiency);
|
||||
}
|
||||
poaDaily += poa.total * step.hours / 1000;
|
||||
energyDaily += power * step.hours;
|
||||
}
|
||||
|
||||
return {
|
||||
month: name,
|
||||
ghi: ghi[m],
|
||||
kt: day.kt,
|
||||
diffuseFraction: day.diffuseFraction,
|
||||
// Plane-of-array kWh/m²/day: the honest "peak sun hours" for this panel.
|
||||
poa: poaDaily,
|
||||
energyDaily,
|
||||
energy: energyDaily * DAYS_IN_MONTH[m],
|
||||
dayLength: dayLength(latitude, day.decl),
|
||||
noonElevation: noonElevation(latitude, day.decl),
|
||||
};
|
||||
});
|
||||
|
||||
const annual = months.reduce((acc, m) => acc + m.energy, 0);
|
||||
const worst = months.reduce((a, b) => (b.energyDaily < a.energyDaily ? b : a));
|
||||
const best = months.reduce((a, b) => (b.energyDaily > a.energyDaily ? b : a));
|
||||
return {
|
||||
months,
|
||||
annual,
|
||||
specificYield: kwp > 0 ? annual / kwp : null,
|
||||
capacityFactor: kwp > 0 ? annual / (kwp * 8760) : null,
|
||||
worst,
|
||||
best,
|
||||
totalLoss: totalLoss(losses),
|
||||
};
|
||||
}
|
||||
|
||||
// Sweep fixed tilts 0-90° at the given azimuth and return the best one. `goal` is 'annual' (most
|
||||
// energy in a year) or 'winter' (most energy in the worst month, which sizes off-grid systems).
|
||||
export function optimalTilt(system, goal = 'annual') {
|
||||
let best = {tilt: 0, value: -1};
|
||||
for (let tilt = 0; tilt <= 90; tilt++) {
|
||||
const result = estimateProduction({...system, tilt, tracking: 'fixed'});
|
||||
const value = goal === 'winter' ? result.worst.energyDaily : result.annual;
|
||||
if (value > best.value + 1e-9) {
|
||||
best = {tilt, value};
|
||||
}
|
||||
}
|
||||
return best.tilt;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Parse latitude/longitude text: decimal ("40.015", "-105.27") or degrees-minutes-seconds
|
||||
// ("40°0'54\"N", "105 16 12 W"). Returns null when unparseable or out of range.
|
||||
export function parseCoordinate(text, kind = 'lat') {
|
||||
if (text === null || text === undefined) {
|
||||
return null;
|
||||
}
|
||||
const s = `${text}`.trim().toUpperCase();
|
||||
if (!s) {
|
||||
return null;
|
||||
}
|
||||
const hemisphere = s.match(/[NSEW]/);
|
||||
const parts = s.replace(/[NSEW]/g, ' ').match(/-?\d+(\.\d+)?/g);
|
||||
if (!parts || parts.length > 3) {
|
||||
return null;
|
||||
}
|
||||
const [d, m = 0, sec = 0] = parts.map(Number);
|
||||
if (m < 0 || m >= 60 || sec < 0 || sec >= 60) {
|
||||
return null;
|
||||
}
|
||||
let value = Math.abs(d) + m / 60 + sec / 3600;
|
||||
if (d < 0 || s.trim().startsWith('-')) {
|
||||
value = -value;
|
||||
}
|
||||
if (hemisphere) {
|
||||
const h = hemisphere[0];
|
||||
if ((kind === 'lat' && (h === 'E' || h === 'W')) || (kind === 'lon' && (h === 'N' || h === 'S'))) {
|
||||
return null;
|
||||
}
|
||||
if (h === 'S' || h === 'W') {
|
||||
value = -Math.abs(value);
|
||||
}
|
||||
}
|
||||
const limit = kind === 'lat' ? 90 : 180;
|
||||
return Math.abs(value) <= limit ? value : null;
|
||||
}
|
||||
|
||||
// Parse a comma separated list of 12 monthly numbers. Blank entries become null.
|
||||
export function parseMonthly(text) {
|
||||
const values = (text || '').split(',').map(v => (v.trim() === '' ? null : Number(v)));
|
||||
return MONTH_NAMES.map((_, i) => (Number.isFinite(values[i]) ? values[i] : null));
|
||||
}
|
||||
|
||||
export function formatMonthly(values) {
|
||||
return values.map(v => (v === null || v === undefined || v === '' ? '' : `${v}`)).join(',');
|
||||
}
|
||||
310
app/src/components/calculators/solar.test.js
Normal file
310
app/src/components/calculators/solar.test.js
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
import {
|
||||
ambientTemperature,
|
||||
ashraeIam,
|
||||
averageDaySteps,
|
||||
cellTemperature,
|
||||
compassToSolarAzimuth,
|
||||
DAYS_IN_MONTH,
|
||||
dcPower,
|
||||
declination,
|
||||
DEFAULT_LOSSES,
|
||||
defaultAzimuth,
|
||||
diffuseHourlyRatio,
|
||||
erbsMonthlyDiffuseFraction,
|
||||
estimateProduction,
|
||||
extraterrestrialDaily,
|
||||
formatMonthly,
|
||||
inverterOutput,
|
||||
KLEIN_DAYS,
|
||||
optimalTilt,
|
||||
parseCoordinate,
|
||||
parseMonthly,
|
||||
poaIrradiance,
|
||||
sunPosition,
|
||||
sunsetHourAngle,
|
||||
totalHourlyRatio,
|
||||
totalLoss,
|
||||
} from "./solar";
|
||||
|
||||
// A sunny 40°N GHI profile, kWh/m²/day, with a clearness index of about 0.6 year-round (Denver-like).
|
||||
const GHI_40N = [2.6, 3.5, 4.7, 5.8, 6.5, 7.2, 7.0, 6.2, 5.2, 4.0, 2.9, 2.3];
|
||||
const TEMP_40N = [0, 2, 6, 10, 15, 21, 24, 23, 18, 11, 4, 0];
|
||||
|
||||
const system = (overrides = {}) => ({
|
||||
latitude: 40,
|
||||
ghi: GHI_40N,
|
||||
temperature: TEMP_40N,
|
||||
kwp: 1,
|
||||
tilt: 40,
|
||||
azimuth: 180,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const integrate = (ratio, ws) => {
|
||||
// Midpoint rule over the day, ratios are per hour.
|
||||
const n = 2000, dw = 2 * ws / n;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
sum += ratio(-ws + (i + 0.5) * dw, ws) * dw / 15;
|
||||
}
|
||||
return sum;
|
||||
};
|
||||
|
||||
describe('sun geometry', () => {
|
||||
test('declination matches Cooper at the solstices and equinox', () => {
|
||||
expect(declination(172)).toBeCloseTo(23.45, 1); // June 21
|
||||
expect(declination(355)).toBeCloseTo(-23.45, 1); // December 21
|
||||
expect(Math.abs(declination(81))).toBeLessThan(0.5); // March 22
|
||||
// Klein's February day (D&B Table 1.6.1 lists -13.0).
|
||||
expect(declination(KLEIN_DAYS[1])).toBeCloseTo(-12.95, 1);
|
||||
});
|
||||
|
||||
test('sunset hour angle is 90° at the equator and handles polar day and night', () => {
|
||||
expect(sunsetHourAngle(0, 20)).toBeCloseTo(90, 5);
|
||||
expect(sunsetHourAngle(40, 0)).toBeCloseTo(90, 5);
|
||||
expect(sunsetHourAngle(80, 20)).toBe(180);
|
||||
expect(sunsetHourAngle(80, -20)).toBe(0);
|
||||
});
|
||||
|
||||
test('extraterrestrial daily irradiation matches Duffie & Beckman Example 1.10.1', () => {
|
||||
// 43°N on April 15: H0 = 33.8 MJ/m² = 9.39 kWh/m².
|
||||
expect(extraterrestrialDaily(43, 105)).toBeCloseTo(33.8 / 3.6, 1);
|
||||
expect(extraterrestrialDaily(80, 355)).toBe(0); // polar night
|
||||
});
|
||||
|
||||
test('sun is due south at solar noon and east in the morning (northern hemisphere)', () => {
|
||||
const noon = sunPosition(40, 0, 0);
|
||||
expect(noon.azimuth).toBeCloseTo(0, 5);
|
||||
expect(noon.zenith).toBeCloseTo(40, 5);
|
||||
expect(sunPosition(40, 0, -45).azimuth).toBeLessThan(0);
|
||||
expect(sunPosition(40, 0, 45).azimuth).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('compass azimuth converts to the Duffie & Beckman convention', () => {
|
||||
expect(compassToSolarAzimuth(180)).toBe(0);
|
||||
expect(compassToSolarAzimuth(90)).toBe(-90);
|
||||
expect(compassToSolarAzimuth(270)).toBe(90);
|
||||
expect(compassToSolarAzimuth(0)).toBe(180);
|
||||
expect(compassToSolarAzimuth(360)).toBe(180);
|
||||
expect(defaultAzimuth(40)).toBe(180);
|
||||
expect(defaultAzimuth(-33)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('monthly irradiation to steps', () => {
|
||||
test('Erbs monthly diffuse fraction follows both branches and clamps KT', () => {
|
||||
expect(erbsMonthlyDiffuseFraction(0.5, 80)).toBeCloseTo(0.3911, 4);
|
||||
expect(erbsMonthlyDiffuseFraction(0.5, 100)).toBeCloseTo(1.311 - 1.511 + 0.85675 - 0.227625, 4);
|
||||
expect(erbsMonthlyDiffuseFraction(0.95, 80)).toBeCloseTo(erbsMonthlyDiffuseFraction(0.8, 80), 10);
|
||||
expect(erbsMonthlyDiffuseFraction(0.1, 80)).toBeCloseTo(erbsMonthlyDiffuseFraction(0.3, 80), 10);
|
||||
});
|
||||
|
||||
test('Liu & Jordan diffuse ratio integrates to one day', () => {
|
||||
for (const ws of [60, 90, 120, 180]) {
|
||||
expect(integrate(diffuseHourlyRatio, ws)).toBeCloseTo(1, 3);
|
||||
}
|
||||
});
|
||||
|
||||
test('Collares-Pereira & Rabl total ratio integrates to about one day', () => {
|
||||
// The correlation is empirical, so it only approximately conserves the daily total; the
|
||||
// model normalizes it, but a large error here would mean a transcription mistake.
|
||||
for (const ws of [60, 90, 120]) {
|
||||
expect(Math.abs(integrate(totalHourlyRatio, ws) - 1)).toBeLessThan(0.03);
|
||||
}
|
||||
});
|
||||
|
||||
test('average day steps add back up to the monthly GHI and diffuse', () => {
|
||||
const day = averageDaySteps(40, 5, 7.2);
|
||||
const sum = key => day.steps.reduce((acc, s) => acc + s[key] * s.hours, 0) / 1000;
|
||||
expect(sum('global')).toBeCloseTo(7.2, 6);
|
||||
expect(sum('diffuse')).toBeCloseTo(7.2 * day.diffuseFraction, 6);
|
||||
expect(day.steps.every(s => s.beam >= 0 && s.diffuse >= 0)).toBe(true);
|
||||
// Peak at solar noon.
|
||||
const peak = day.steps.reduce((a, b) => (b.global > a.global ? b : a));
|
||||
expect(Math.abs(peak.hourAngle)).toBeLessThan(5);
|
||||
});
|
||||
|
||||
test('a supplied diffuse value is used instead of the Erbs estimate', () => {
|
||||
expect(averageDaySteps(40, 5, 7.2, 1.8).diffuseFraction).toBeCloseTo(0.25, 6);
|
||||
});
|
||||
|
||||
test('polar night and missing GHI produce no steps', () => {
|
||||
expect(averageDaySteps(80, 11, 0.1).steps).toEqual([]);
|
||||
expect(averageDaySteps(40, 5, null).steps).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('plane of array', () => {
|
||||
test('a flat panel receives exactly the horizontal irradiance', () => {
|
||||
const day = averageDaySteps(40, 5, 7.2);
|
||||
const decl = day.decl;
|
||||
for (const step of day.steps) {
|
||||
const sun = sunPosition(40, decl, step.hourAngle);
|
||||
if (sun.zenith > 85) continue; // the DNI cap applies right at sunrise
|
||||
const poa = poaIrradiance(step, sun, {tilt: 0, azimuth: 0}, 0.2, KLEIN_DAYS[5]);
|
||||
expect(poa.total).toBeCloseTo(step.global, 6);
|
||||
}
|
||||
});
|
||||
|
||||
test('a vertical panel sees half the ground', () => {
|
||||
const step = {global: 500, beam: 0, diffuse: 500};
|
||||
const sun = sunPosition(40, 0, 0);
|
||||
const poa = poaIrradiance(step, sun, {tilt: 90, azimuth: 0}, 0.2, 81);
|
||||
expect(poa.ground).toBeCloseTo(500 * 0.2 / 2, 6);
|
||||
});
|
||||
|
||||
test('ASHRAE incidence angle modifier', () => {
|
||||
expect(ashraeIam(1)).toBe(1);
|
||||
expect(ashraeIam(0.5)).toBeCloseTo(0.95, 6); // 60°
|
||||
expect(ashraeIam(0)).toBe(0);
|
||||
expect(ashraeIam(0.01)).toBe(0); // never negative
|
||||
});
|
||||
});
|
||||
|
||||
describe('temperature, power, losses, inverter', () => {
|
||||
test('SAPM cell temperature, open rack glass/polymer', () => {
|
||||
// 1000 * exp(-3.56 - 0.075) + 25 = 51.38, plus deltaT 3.
|
||||
expect(cellTemperature(1000, 25, 'open', 1)).toBeCloseTo(54.38, 2);
|
||||
expect(cellTemperature(1000, 25, 'flush', 1)).toBeGreaterThan(cellTemperature(1000, 25, 'open', 1));
|
||||
expect(cellTemperature(0, 10, 'open')).toBe(10);
|
||||
});
|
||||
|
||||
test('air temperature peaks at 3 pm and averages to the mean', () => {
|
||||
expect(ambientTemperature(20, 45)).toBeCloseTo(25, 6);
|
||||
expect(ambientTemperature(20, -135)).toBeCloseTo(15, 6);
|
||||
});
|
||||
|
||||
test('DC power scales with irradiance and loses power when hot', () => {
|
||||
expect(dcPower(1000, 25, 1, -0.37)).toBeCloseTo(1, 6);
|
||||
expect(dcPower(500, 25, 2, -0.37)).toBeCloseTo(1, 6);
|
||||
expect(dcPower(1000, 35, 1, -0.37)).toBeCloseTo(0.963, 6);
|
||||
expect(dcPower(0, 25, 1, -0.37)).toBe(0);
|
||||
});
|
||||
|
||||
test('PVWatts default losses combine to 14.08%', () => {
|
||||
expect(totalLoss(DEFAULT_LOSSES)).toBeCloseTo(14.08, 2);
|
||||
expect(totalLoss([])).toBe(0);
|
||||
expect(totalLoss([50, 50])).toBeCloseTo(75, 6);
|
||||
});
|
||||
|
||||
test('PVWatts inverter is at nominal efficiency at full load and clips', () => {
|
||||
// At zeta = 1 the curve evaluates to exactly the nominal efficiency.
|
||||
const ac = 1, pdc0 = ac / 0.96;
|
||||
expect(inverterOutput(pdc0, ac) / pdc0).toBeCloseTo(0.96, 4);
|
||||
expect(inverterOutput(5, ac)).toBe(ac);
|
||||
expect(inverterOutput(0, ac)).toBe(0);
|
||||
// Part load is less efficient.
|
||||
expect(inverterOutput(pdc0 * 0.05, ac) / (pdc0 * 0.05)).toBeLessThan(0.96);
|
||||
});
|
||||
});
|
||||
|
||||
describe('estimateProduction', () => {
|
||||
test('a 1 kWp south-facing array at 40°N gives a plausible annual yield', () => {
|
||||
const result = estimateProduction(system());
|
||||
// A clear 40°N site yields roughly 1500-1900 kWh/kWp DC after 14% losses.
|
||||
expect(result.annual).toBeGreaterThan(1500);
|
||||
expect(result.annual).toBeLessThan(1900);
|
||||
// Performance ratio (energy over plane-of-array sun-hours): losses plus temperature.
|
||||
const poaAnnual = result.months.reduce((acc, m, i) => acc + m.poa * DAYS_IN_MONTH[i], 0);
|
||||
expect(result.annual / poaAnnual).toBeGreaterThan(0.75);
|
||||
expect(result.annual / poaAnnual).toBeLessThan(0.88);
|
||||
expect(result.specificYield).toBeCloseTo(result.annual, 6);
|
||||
expect(result.months).toHaveLength(12);
|
||||
expect(result.totalLoss).toBeCloseTo(14.08, 2);
|
||||
});
|
||||
|
||||
test('tilting toward the equator gains in winter and loses in summer', () => {
|
||||
const flat = estimateProduction(system({tilt: 0}));
|
||||
const tilted = estimateProduction(system({tilt: 40}));
|
||||
expect(tilted.months[11].poa).toBeGreaterThan(flat.months[11].poa * 1.5);
|
||||
expect(tilted.months[5].poa).toBeLessThan(flat.months[5].poa);
|
||||
expect(flat.months[5].poa).toBeCloseTo(GHI_40N[5], 1);
|
||||
});
|
||||
|
||||
test('tracking beats fixed, and dual-axis beats single-axis', () => {
|
||||
const fixed = estimateProduction(system()).annual;
|
||||
const single = estimateProduction(system({tracking: 'single'})).annual;
|
||||
const dual = estimateProduction(system({tracking: 'dual'})).annual;
|
||||
expect(single).toBeGreaterThan(fixed);
|
||||
expect(dual).toBeGreaterThan(single);
|
||||
// Dual-axis is commonly 30-45% over a well-tilted fixed array.
|
||||
expect(dual / fixed).toBeLessThan(1.6);
|
||||
});
|
||||
|
||||
test('facing away from the equator produces less', () => {
|
||||
const south = estimateProduction(system()).annual;
|
||||
const north = estimateProduction(system({azimuth: 0})).annual;
|
||||
expect(north).toBeLessThan(south * 0.7);
|
||||
});
|
||||
|
||||
test('the southern hemisphere mirrors the northern', () => {
|
||||
// Same GHI shifted 6 months, panel facing north.
|
||||
const shifted = [...GHI_40N.slice(6), ...GHI_40N.slice(0, 6)];
|
||||
const north = estimateProduction(system({temperature: null}));
|
||||
const south = estimateProduction(system({latitude: -40, ghi: shifted, azimuth: 0, temperature: null}));
|
||||
expect(south.annual / north.annual).toBeGreaterThan(0.97);
|
||||
expect(south.annual / north.annual).toBeLessThan(1.03);
|
||||
});
|
||||
|
||||
test('hot weather reduces output', () => {
|
||||
const cool = estimateProduction(system({temperature: Array(12).fill(0)})).annual;
|
||||
const hot = estimateProduction(system({temperature: Array(12).fill(35)})).annual;
|
||||
expect(hot).toBeLessThan(cool * 0.95);
|
||||
});
|
||||
|
||||
test('the inverter costs a few percent and clips an oversized array', () => {
|
||||
const dc = estimateProduction(system()).annual;
|
||||
const ac = estimateProduction(system({inverter: {acRating: 1, efficiency: 96}})).annual;
|
||||
expect(ac / dc).toBeGreaterThan(0.93);
|
||||
expect(ac / dc).toBeLessThan(0.97);
|
||||
const clipped = estimateProduction(system({inverter: {acRating: 0.3, efficiency: 96}})).annual;
|
||||
expect(clipped).toBeLessThan(ac * 0.7);
|
||||
});
|
||||
|
||||
test('polar night months produce nothing without errors', () => {
|
||||
const result = estimateProduction(system({latitude: 75, tilt: 75}));
|
||||
expect(result.months[11].energy).toBe(0);
|
||||
expect(Number.isFinite(result.annual)).toBe(true);
|
||||
});
|
||||
|
||||
test('missing months count as zero', () => {
|
||||
const ghi = [...GHI_40N];
|
||||
ghi[0] = null;
|
||||
expect(estimateProduction(system({ghi})).months[0].energy).toBe(0);
|
||||
});
|
||||
|
||||
test('the best annual tilt is near the latitude; the best winter tilt is steeper', () => {
|
||||
const annual = optimalTilt(system());
|
||||
const winter = optimalTilt(system(), 'winter');
|
||||
expect(annual).toBeGreaterThanOrEqual(30);
|
||||
expect(annual).toBeLessThanOrEqual(44);
|
||||
expect(winter).toBeGreaterThan(annual + 8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('input parsing', () => {
|
||||
test('decimal and DMS coordinates', () => {
|
||||
expect(parseCoordinate('40.015')).toBeCloseTo(40.015, 6);
|
||||
expect(parseCoordinate('-105.27', 'lon')).toBeCloseTo(-105.27, 6);
|
||||
expect(parseCoordinate('40°0\'54"N')).toBeCloseTo(40.015, 6);
|
||||
expect(parseCoordinate('105 16 12 W', 'lon')).toBeCloseTo(-105.27, 6);
|
||||
expect(parseCoordinate('33.9 S')).toBeCloseTo(-33.9, 6);
|
||||
expect(parseCoordinate('-0.5')).toBeCloseTo(-0.5, 6);
|
||||
});
|
||||
|
||||
test('invalid coordinates are null', () => {
|
||||
expect(parseCoordinate('')).toBeNull();
|
||||
expect(parseCoordinate(null)).toBeNull();
|
||||
expect(parseCoordinate('91')).toBeNull();
|
||||
expect(parseCoordinate('181', 'lon')).toBeNull();
|
||||
expect(parseCoordinate('40 E')).toBeNull();
|
||||
expect(parseCoordinate('abc')).toBeNull();
|
||||
expect(parseCoordinate('40 75 0')).toBeNull();
|
||||
});
|
||||
|
||||
test('monthly lists round-trip and tolerate blanks', () => {
|
||||
expect(parseMonthly('1,2,,4')).toEqual([1, 2, null, 4, null, null, null, null, null, null, null, null]);
|
||||
expect(parseMonthly(null)).toHaveLength(12);
|
||||
expect(parseMonthly(formatMonthly(GHI_40N))).toEqual(GHI_40N);
|
||||
});
|
||||
});
|
||||
48
app/src/components/calculators/solarData.fixtures.js
Normal file
48
app/src/components/calculators/solarData.fixtures.js
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import {cellIndex, COLUMNS, PARAMETERS, ROWS} from "./solarData";
|
||||
|
||||
// Build a climatology file for tests, the same format scripts/build_solar_climatology.py writes.
|
||||
// `valueAt(key, month, latitude, longitude)` returns the value for a cell center, or null for no data.
|
||||
export function encodeClimatology(valueAt) {
|
||||
const header = 8;
|
||||
const bytes = new Uint8Array(header + PARAMETERS.length * 12 * ROWS * COLUMNS);
|
||||
bytes.set([...'WSOL'].map(c => c.charCodeAt(0)), 0);
|
||||
bytes[4] = 1;
|
||||
bytes[5] = PARAMETERS.length;
|
||||
PARAMETERS.forEach(({key, offset, step}, p) => {
|
||||
for (let month = 0; month < 12; month++) {
|
||||
for (let row = 0; row < ROWS; row++) {
|
||||
for (let column = 0; column < COLUMNS; column++) {
|
||||
const value = valueAt(key, month, row - 89.5, column - 179.5);
|
||||
bytes[header + cellIndex(p, month, row, column)] = value === null
|
||||
? 255
|
||||
: Math.max(0, Math.min(254, Math.round((value + offset) / step)));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
// A plain climatology: the same sunny 40°N profile everywhere.
|
||||
export const SUNNY_GHI = [2.6, 3.5, 4.7, 5.8, 6.5, 7.2, 7.0, 6.2, 5.2, 4.0, 2.9, 2.3];
|
||||
export const MILD_TEMPERATURE = [0, 2, 6, 10, 15, 21, 24, 23, 18, 11, 4, 0];
|
||||
|
||||
// Encoding the 3 MB file is slow, so it is built once and shared; nothing writes to it.
|
||||
let uniform = null;
|
||||
export const uniformClimatology = () => {
|
||||
if (!uniform) {
|
||||
uniform = encodeClimatology((key, month) => ({
|
||||
ghi: SUNNY_GHI[month],
|
||||
diffuse: SUNNY_GHI[month] * 0.3,
|
||||
albedo: 0.2,
|
||||
temperature: MILD_TEMPERATURE[month],
|
||||
})[key]);
|
||||
}
|
||||
return uniform;
|
||||
};
|
||||
|
||||
// Make `fetch` return a climatology file.
|
||||
export function mockClimatologyFetch(buffer = uniformClimatology()) {
|
||||
global.fetch = jest.fn(() => Promise.resolve({ok: true, arrayBuffer: () => Promise.resolve(buffer)}));
|
||||
return global.fetch;
|
||||
}
|
||||
133
app/src/components/calculators/solarData.js
Normal file
133
app/src/components/calculators/solarData.js
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import React from "react";
|
||||
|
||||
// Offline solar climatology for the Solar calculator: NASA POWER's 2001-2020 monthly averages on a
|
||||
// 1° grid, built by scripts/build_solar_climatology.py. The format is described in that script and
|
||||
// must match PARAMETERS below. The file is only fetched when the calculator opens.
|
||||
|
||||
export const CLIMATOLOGY_URL = `${process.env.PUBLIC_URL || ''}/data/solar-climatology.bin`;
|
||||
export const CLIMATOLOGY_SOURCE = 'NASA POWER 2001–2020 monthly averages (CC BY 4.0)';
|
||||
|
||||
const MAGIC = 'WSOL';
|
||||
const VERSION = 1;
|
||||
const HEADER_BYTES = 8;
|
||||
export const ROWS = 180;
|
||||
export const COLUMNS = 360;
|
||||
const MISSING = 255;
|
||||
|
||||
// Stored as round((value + offset) / step), in this order.
|
||||
export const PARAMETERS = [
|
||||
{key: 'ghi', offset: 0, step: 0.04},
|
||||
{key: 'diffuse', offset: 0, step: 0.04},
|
||||
{key: 'albedo', offset: 0, step: 0.004},
|
||||
{key: 'temperature', offset: 70, step: 0.5},
|
||||
];
|
||||
|
||||
const PARAMETER_BYTES = 12 * ROWS * COLUMNS;
|
||||
|
||||
// Validate a downloaded file and return its value grid.
|
||||
export function decodeClimatology(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const magic = String.fromCharCode(...bytes.slice(0, 4));
|
||||
if (magic !== MAGIC) {
|
||||
throw new Error('Not a solar climatology file');
|
||||
}
|
||||
if (bytes[4] !== VERSION || bytes[5] !== PARAMETERS.length) {
|
||||
throw new Error(`Unsupported solar climatology version ${bytes[4]}`);
|
||||
}
|
||||
if (bytes.length !== HEADER_BYTES + PARAMETERS.length * PARAMETER_BYTES) {
|
||||
throw new Error('Solar climatology file is truncated');
|
||||
}
|
||||
return bytes.subarray(HEADER_BYTES);
|
||||
}
|
||||
|
||||
// Index of a stored value.
|
||||
export const cellIndex = (parameter, month, row, column) =>
|
||||
parameter * PARAMETER_BYTES + (month * ROWS + row) * COLUMNS + column;
|
||||
|
||||
/**
|
||||
* Monthly averages at a location, bilinearly interpolated between the four surrounding cell centers.
|
||||
* Cells with no data are skipped. Longitude wraps across the antimeridian; latitude is clamped at the
|
||||
* outermost cell centers.
|
||||
*
|
||||
* @returns {{ghi: number[], diffuse: number[], albedo: number[], temperature: number[]}} 12 values each,
|
||||
* null for a month with no data
|
||||
*/
|
||||
export function lookupClimatology(grid, latitude, longitude) {
|
||||
const y = Math.min(ROWS - 1, Math.max(0, latitude + 90 - 0.5));
|
||||
const row0 = Math.floor(y);
|
||||
const row1 = Math.min(ROWS - 1, row0 + 1);
|
||||
const fy = y - row0;
|
||||
|
||||
const x = longitude + 180 - 0.5;
|
||||
const xFloor = Math.floor(x);
|
||||
const fx = x - xFloor;
|
||||
const column0 = ((xFloor % COLUMNS) + COLUMNS) % COLUMNS;
|
||||
const column1 = (column0 + 1) % COLUMNS;
|
||||
|
||||
const corners = [
|
||||
[row0, column0, (1 - fy) * (1 - fx)],
|
||||
[row0, column1, (1 - fy) * fx],
|
||||
[row1, column0, fy * (1 - fx)],
|
||||
[row1, column1, fy * fx],
|
||||
];
|
||||
|
||||
const result = {};
|
||||
PARAMETERS.forEach(({key, offset, step}, p) => {
|
||||
result[key] = Array.from({length: 12}, (_, month) => {
|
||||
let sum = 0, weight = 0;
|
||||
for (const [row, column, w] of corners) {
|
||||
const raw = grid[cellIndex(p, month, row, column)];
|
||||
if (raw !== MISSING && w > 0) {
|
||||
sum += (raw * step - offset) * w;
|
||||
weight += w;
|
||||
}
|
||||
}
|
||||
return weight > 0 ? sum / weight : null;
|
||||
});
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
let climatologyPromise = null;
|
||||
|
||||
// Fetch and decode the file once per page load. A failed fetch is not cached, so it can be retried.
|
||||
export function loadClimatology() {
|
||||
if (!climatologyPromise) {
|
||||
climatologyPromise = fetch(CLIMATOLOGY_URL)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Could not load solar data (${response.status})`);
|
||||
}
|
||||
return response.arrayBuffer();
|
||||
})
|
||||
.then(decodeClimatology)
|
||||
.catch(error => {
|
||||
climatologyPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return climatologyPromise;
|
||||
}
|
||||
|
||||
// Drop the loaded file, so the next load fetches again. Tests use this to simulate a failed download.
|
||||
export function forgetClimatology() {
|
||||
climatologyPromise = null;
|
||||
}
|
||||
|
||||
// The decoded grid, or null while loading. `error` is set when the file could not be loaded.
|
||||
export function useClimatology() {
|
||||
const [grid, setGrid] = React.useState(null);
|
||||
const [error, setError] = React.useState(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadClimatology()
|
||||
.then(g => !cancelled && setGrid(g))
|
||||
.catch(e => !cancelled && setError(e));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {grid, error, loading: !grid && !error};
|
||||
}
|
||||
141
app/src/components/calculators/solarData.test.js
Normal file
141
app/src/components/calculators/solarData.test.js
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import fs from "fs";
|
||||
import path from "path";
|
||||
import {decodeClimatology, lookupClimatology} from "./solarData";
|
||||
import {estimateProduction} from "./solar";
|
||||
import {encodeClimatology, uniformClimatology} from "./solarData.fixtures";
|
||||
|
||||
describe('decodeClimatology', () => {
|
||||
test('accepts a valid file', () => {
|
||||
expect(decodeClimatology(uniformClimatology()).length).toBe(4 * 12 * 180 * 360);
|
||||
});
|
||||
|
||||
test('rejects a file that is not ours, a newer version, or truncated', () => {
|
||||
const good = new Uint8Array(uniformClimatology());
|
||||
const wrongMagic = good.slice();
|
||||
wrongMagic[0] = 'X'.charCodeAt(0);
|
||||
expect(() => decodeClimatology(wrongMagic.buffer)).toThrow('Not a solar climatology file');
|
||||
|
||||
const newer = good.slice();
|
||||
newer[4] = 2;
|
||||
expect(() => decodeClimatology(newer.buffer)).toThrow('Unsupported');
|
||||
|
||||
expect(() => decodeClimatology(good.slice(0, 1000).buffer)).toThrow('truncated');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lookupClimatology', () => {
|
||||
// A field that varies with longitude, so interpolation is visible: GHI = 3 + lon/100.
|
||||
const sloped = decodeClimatology(encodeClimatology((key, month, lat, lon) => ({
|
||||
ghi: 3 + lon / 100,
|
||||
diffuse: 1,
|
||||
albedo: lat > 60 ? 0.8 : 0.2,
|
||||
temperature: -10 + month,
|
||||
})[key]));
|
||||
|
||||
test('returns a cell center value exactly', () => {
|
||||
const values = lookupClimatology(sloped, 40.5, -104.5);
|
||||
expect(values.ghi[0]).toBeCloseTo(3 - 1.045, 1);
|
||||
expect(values.temperature.map(v => Math.round(v))).toEqual([-10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1]);
|
||||
expect(values.diffuse[6]).toBeCloseTo(1, 1);
|
||||
});
|
||||
|
||||
test('interpolates between cell centers', () => {
|
||||
// Halfway between the cells centered on -105.5 and -104.5.
|
||||
const mid = lookupClimatology(sloped, 40.5, -105).ghi[0];
|
||||
const west = lookupClimatology(sloped, 40.5, -105.5).ghi[0];
|
||||
const east = lookupClimatology(sloped, 40.5, -104.5).ghi[0];
|
||||
expect(mid).toBeCloseTo((west + east) / 2, 6);
|
||||
// Interpolates in latitude too: halfway between albedo 0.2 and 0.8 cells.
|
||||
expect(lookupClimatology(sloped, 60, 0).albedo[0]).toBeCloseTo(0.5, 2);
|
||||
});
|
||||
|
||||
test('wraps across the antimeridian', () => {
|
||||
// Between the cells centered on 179.5 (ghi 4.795) and -179.5 (ghi 1.205).
|
||||
expect(lookupClimatology(sloped, 0.5, 180).ghi[0]).toBeCloseTo((4.795 + 1.205) / 2, 1);
|
||||
expect(lookupClimatology(sloped, 0.5, -180).ghi[0]).toBeCloseTo((4.795 + 1.205) / 2, 1);
|
||||
});
|
||||
|
||||
test('clamps at the poles', () => {
|
||||
expect(lookupClimatology(sloped, 90, 0.5).ghi[0]).toBeCloseTo(3.005, 1);
|
||||
expect(lookupClimatology(sloped, -90, 0.5).ghi[0]).toBeCloseTo(3.005, 1);
|
||||
});
|
||||
|
||||
test('skips cells with no data and returns null when all are missing', () => {
|
||||
const holes = decodeClimatology(encodeClimatology((key, month, lat, lon) =>
|
||||
(lon > 0 ? null : {ghi: 5, diffuse: 1, albedo: 0.2, temperature: 10}[key])));
|
||||
// Straddling lon 0: only the western cells have data.
|
||||
expect(lookupClimatology(holes, 10.5, 0).ghi[0]).toBeCloseTo(5, 1);
|
||||
expect(lookupClimatology(holes, 10.5, 90).ghi[0]).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// The real data file, when it has been built. The expected values come from NASA POWER's point API
|
||||
// (2001-2020 climatology), so these confirm the build script and decoder agree end to end.
|
||||
const REAL_FILE = path.join(__dirname, '../../../public/data/solar-climatology.bin');
|
||||
const describeReal = fs.existsSync(REAL_FILE) ? describe : describe.skip;
|
||||
|
||||
describeReal('the built climatology file', () => {
|
||||
let grid;
|
||||
beforeAll(() => {
|
||||
grid = decodeClimatology(fs.readFileSync(REAL_FILE).buffer.slice(0));
|
||||
});
|
||||
|
||||
// NASA POWER's monthly GHI for two cells, copied from its API (2001-2020 climatology). A cell
|
||||
// center is returned exactly, give or take the file's 0.04 kWh/m²/day step.
|
||||
const expectCell = (actual, expected) => actual.forEach((v, i) => expect(Math.abs(v - expected[i]))
|
||||
.toBeLessThanOrEqual(0.021));
|
||||
|
||||
test('matches NASA POWER at the cell near Denver', () => {
|
||||
const values = lookupClimatology(grid, 40.5, -104.5);
|
||||
expectCell(values.ghi,
|
||||
[2.4089, 3.2969, 4.6241, 5.6225, 6.3463, 7.2588, 7.0428, 6.2326, 5.2351, 3.7486, 2.6717, 2.0921]);
|
||||
expect(values.diffuse.every((v, i) => v > 0 && v < values.ghi[i])).toBe(true);
|
||||
expect(values.temperature[6]).toBeGreaterThan(values.temperature[0] + 15);
|
||||
});
|
||||
|
||||
test('matches NASA POWER at the cell near Sydney, with the seasons reversed', () => {
|
||||
const values = lookupClimatology(grid, -33.5, 151.5);
|
||||
expectCell(values.ghi,
|
||||
[6.4908, 5.6611, 4.8137, 3.8191, 2.9803, 2.357, 2.772, 3.6725, 4.8242, 5.6698, 6.2074, 6.5594]);
|
||||
});
|
||||
|
||||
// PVWatts v8 results for a 1 kW standard-module, open-rack, fixed array with the default 14.08%
|
||||
// losses, DC output in kWh, fetched from developer.nlr.gov on 2026-10-04. PVWatts uses NREL's NSRDB
|
||||
// typical-year weather, not NASA POWER, so the two agree only within the data sets' differences.
|
||||
const PVWATTS = {
|
||||
Phoenix: {
|
||||
lat: 33.45, lon: -112.07, tilt: 33, azimuth: 180,
|
||||
monthly: [139.7, 139.1, 166.3, 175.4, 175.8, 165.2, 152.4, 155.2, 157.6, 157.8, 144.8, 140.3],
|
||||
},
|
||||
Seattle: {
|
||||
lat: 47.61, lon: -122.33, tilt: 48, azimuth: 180,
|
||||
monthly: [51.9, 67.3, 89.6, 115.5, 127.1, 116.7, 130.3, 141.0, 111.9, 82.0, 48.6, 42.8],
|
||||
},
|
||||
Anchorage: {
|
||||
lat: 61.22, lon: -149.90, tilt: 61, azimuth: 180,
|
||||
monthly: [19.6, 45.0, 119.2, 117.4, 124.2, 118.0, 109.4, 109.4, 87.8, 52.0, 23.3, 12.6],
|
||||
},
|
||||
};
|
||||
|
||||
test.each(Object.entries(PVWATTS))('agrees with PVWatts in %s', (name, site) => {
|
||||
const sunlight = lookupClimatology(grid, site.lat, site.lon);
|
||||
const result = estimateProduction({
|
||||
latitude: site.lat, kwp: 1, tilt: site.tilt, azimuth: site.azimuth, ...sunlight,
|
||||
});
|
||||
const annual = site.monthly.reduce((a, b) => a + b, 0);
|
||||
expect(result.annual / annual).toBeGreaterThan(0.9);
|
||||
expect(result.annual / annual).toBeLessThan(1.1);
|
||||
// Dark months are small and the two data sets disagree most there; check the months that matter.
|
||||
const meanMonth = annual / 12;
|
||||
result.months.forEach((m, i) => {
|
||||
if (site.monthly[i] >= 0.6 * meanMonth) {
|
||||
expect(m.energy / site.monthly[i]).toBeGreaterThan(0.75);
|
||||
expect(m.energy / site.monthly[i]).toBeLessThan(1.25);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('has polar night', () => {
|
||||
expect(lookupClimatology(grid, 80, 15).ghi[11]).toBeLessThan(0.05);
|
||||
});
|
||||
});
|
||||
170
scripts/build_solar_climatology.py
Normal file
170
scripts/build_solar_climatology.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Build the offline solar climatology used by the Solar calculator.
|
||||
|
||||
Downloads NASA POWER's 2001-2020 monthly climatology for the whole globe and packs it into
|
||||
app/public/data/solar-climatology.bin, which the React app fetches when the Solar calculator opens.
|
||||
This needs the Internet and is run by a developer; WROLPi never runs it. The output only changes
|
||||
when NASA publishes a new climatology period, so regenerating it should be rare.
|
||||
|
||||
python3 scripts/build_solar_climatology.py
|
||||
|
||||
Downloaded tiles are cached (see --cache), so an interrupted run resumes where it stopped.
|
||||
|
||||
NASA POWER data is licensed CC BY 4.0; the calculator credits it. https://power.larc.nasa.gov/
|
||||
|
||||
File format (all integers little-endian), decoded by app/src/components/calculators/solarData.js:
|
||||
|
||||
bytes 0-3 b"WSOL"
|
||||
byte 4 format version (1)
|
||||
byte 5 number of parameters (4)
|
||||
bytes 6-7 reserved (0)
|
||||
then uint8 values, [parameter][month][row][column]
|
||||
|
||||
Rows are 1° latitude bands centered on -89.5 ... 89.5 (south to north), columns are 1° longitude
|
||||
bands centered on -179.5 ... 179.5 (west to east). 255 means no data. Each parameter is stored as
|
||||
round((value + offset) / step); see PARAMETERS.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import pathlib
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
REPO = pathlib.Path(__file__).resolve().parent.parent
|
||||
DEFAULT_OUTPUT = REPO / 'app/public/data/solar-climatology.bin'
|
||||
DEFAULT_CACHE = REPO / 'scripts/.solar_climatology_cache'
|
||||
|
||||
API = 'https://power.larc.nasa.gov/api/temporal/climatology/regional'
|
||||
# The regional endpoint accepts at most a 10° x 10° box and one parameter per request.
|
||||
TILE = 10
|
||||
MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']
|
||||
ROWS, COLUMNS = 180, 360
|
||||
MISSING = 255
|
||||
VERSION = 1
|
||||
|
||||
# (NASA parameter, offset, step). Must match PARAMETERS in solarData.js, in the same order.
|
||||
PARAMETERS = [
|
||||
('ALLSKY_SFC_SW_DWN', 0, 0.04), # global horizontal, kWh/m²/day, 0-10.16
|
||||
('ALLSKY_SFC_SW_DIFF', 0, 0.04), # diffuse horizontal, kWh/m²/day
|
||||
('ALLSKY_SRF_ALB', 0, 0.004), # surface albedo, 0-1.016
|
||||
('T2M', 70, 0.5), # air temperature at 2 m, °C, -70 to +57
|
||||
]
|
||||
|
||||
|
||||
def tile_url(parameter, lat_min, lon_min):
|
||||
query = urllib.parse.urlencode({
|
||||
'parameters': parameter,
|
||||
'community': 'RE',
|
||||
'latitude-min': lat_min,
|
||||
'latitude-max': lat_min + TILE,
|
||||
'longitude-min': lon_min,
|
||||
'longitude-max': lon_min + TILE,
|
||||
'format': 'JSON',
|
||||
})
|
||||
return f'{API}?{query}'
|
||||
|
||||
|
||||
def fetch_tile(parameter, lat_min, lon_min, cache, pause, retries=8):
|
||||
"""Return the tile's GeoJSON, from the cache when possible.
|
||||
|
||||
NASA rate limits the API (HTTP 429); back off for up to 10 minutes rather than give up, since a
|
||||
full build is about 2,600 requests.
|
||||
"""
|
||||
path = cache / f'{parameter}_{lat_min}_{lon_min}.json'
|
||||
if path.is_file():
|
||||
return json.loads(path.read_text())
|
||||
|
||||
delay = 30
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
time.sleep(pause)
|
||||
with urllib.request.urlopen(tile_url(parameter, lat_min, lon_min), timeout=180) as response:
|
||||
data = json.loads(response.read())
|
||||
if 'features' not in data:
|
||||
raise ValueError(f'No features: {data.get("messages")}')
|
||||
path.write_text(json.dumps(data))
|
||||
return data
|
||||
except (urllib.error.URLError, TimeoutError, ValueError) as e:
|
||||
if attempt == retries - 1:
|
||||
raise
|
||||
print(f' retrying {parameter} {lat_min},{lon_min} in {delay}s: {e}', file=sys.stderr, flush=True)
|
||||
time.sleep(delay)
|
||||
delay = min(delay * 2, 600)
|
||||
|
||||
|
||||
def bin_points(points):
|
||||
"""Average points into 1° cells. `points` maps (lon, lat) to 12 monthly values.
|
||||
|
||||
Solar parameters are already on the 1° grid (one point per cell). Temperature comes on a finer
|
||||
0.5° x 0.625° grid, so several points average into each cell.
|
||||
"""
|
||||
sums = {}
|
||||
for (lon, lat), values in points.items():
|
||||
row = min(ROWS - 1, max(0, math.floor(lat + 90)))
|
||||
column = math.floor(lon + 180) % COLUMNS
|
||||
cell = sums.setdefault((row, column), [[0.0, 0] for _ in MONTHS])
|
||||
for month, value in enumerate(values):
|
||||
if value is not None:
|
||||
cell[month][0] += value
|
||||
cell[month][1] += 1
|
||||
return {cell: [s / n if n else None for s, n in months] for cell, months in sums.items()}
|
||||
|
||||
|
||||
def quantize(value, offset, step):
|
||||
if value is None:
|
||||
return MISSING
|
||||
return max(0, min(MISSING - 1, round((value + offset) / step)))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument('--output', type=pathlib.Path, default=DEFAULT_OUTPUT)
|
||||
parser.add_argument('--cache', type=pathlib.Path, default=DEFAULT_CACHE)
|
||||
parser.add_argument('--workers', type=int, default=1, help='Concurrent requests; NASA rate limits the API.')
|
||||
parser.add_argument('--pause', type=float, default=1.0, help='Seconds to wait before each request.')
|
||||
args = parser.parse_args()
|
||||
args.cache.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
tiles = [(lat, lon) for lat in range(-90, 90, TILE) for lon in range(-180, 180, TILE)]
|
||||
out = bytearray(b'WSOL' + struct.pack('<BBH', VERSION, len(PARAMETERS), 0))
|
||||
|
||||
for parameter, offset, step in PARAMETERS:
|
||||
print(f'{parameter}: {len(tiles)} tiles', flush=True)
|
||||
points = {}
|
||||
with ThreadPoolExecutor(max_workers=args.workers) as pool:
|
||||
futures = [pool.submit(fetch_tile, parameter, lat, lon, args.cache, args.pause) for lat, lon in tiles]
|
||||
for done, future in enumerate(as_completed(futures), 1):
|
||||
data = future.result()
|
||||
header = data.get('header')
|
||||
fill = header.get('fill_value', -999) if isinstance(header, dict) else -999
|
||||
for feature in data['features']:
|
||||
lon, lat = feature['geometry']['coordinates'][:2]
|
||||
monthly = feature['properties']['parameter'][parameter]
|
||||
# Tiles share edges; a point on an edge arrives twice with the same value.
|
||||
points[(lon, lat)] = [None if monthly[m] == fill else monthly[m] for m in MONTHS]
|
||||
if done % 50 == 0:
|
||||
print(f' {done}/{len(tiles)}', flush=True)
|
||||
|
||||
cells = bin_points(points)
|
||||
grid = bytearray([MISSING]) * (12 * ROWS * COLUMNS)
|
||||
for (row, column), months in cells.items():
|
||||
for month, value in enumerate(months):
|
||||
grid[(month * ROWS + row) * COLUMNS + column] = quantize(value, offset, step)
|
||||
missing = grid.count(MISSING)
|
||||
print(f' {len(points)} points into {len(cells)} cells; {missing} missing values')
|
||||
out += grid
|
||||
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_bytes(out)
|
||||
print(f'Wrote {args.output} ({len(out):,} bytes)')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue