e.target.style.background = "#f0f0f0"}
+ onMouseLeave={e => e.target.style.background = "transparent"}
+ onClick={handleSolar}
+ >
+ Calculate Solar Performance
+
e.target.style.background = "#f0f0f0"}
diff --git a/app/src/components/MapViewer.test.js b/app/src/components/MapViewer.test.js
new file mode 100644
index 00000000..2afce0f4
--- /dev/null
+++ b/app/src/components/MapViewer.test.js
@@ -0,0 +1,45 @@
+import React from 'react';
+import {renderWithProviders as render} from '../test-utils';
+import MapViewer from './MapViewer';
+
+// MapLibre needs WebGL, which jsdom lacks; these tests only cover what the viewer does to the URL.
+jest.mock('maplibre-gl', () => ({
+ __esModule: true,
+ default: {Map: class {
+ }, addProtocol: jest.fn(), Marker: class {
+ }, Popup: class {
+ }},
+}));
+jest.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
+jest.mock('pmtiles', () => ({
+ __esModule: true, Protocol: class {
+ tile = jest.fn();
+ },
+}));
+jest.mock('protomaps-themes-base', () => ({__esModule: true, default: () => []}));
+jest.mock('@acalcutt/maplibre-contour-pmtiles', () => ({__esModule: true, default: {}}));
+jest.mock('../api', () => ({
+ ...jest.requireActual('../api'),
+ // Never resolves: the map never initializes, which these tests do not need.
+ getMapFiles: jest.fn(() => new Promise(() => {
+ })),
+ getMapPins: jest.fn(() => new Promise(() => {
+ })),
+}));
+
+describe('the map position in the URL', () => {
+ test('is removed when the map closes', () => {
+ const {unmount} = render(
, {route: '/map?lat=40&lon=-105&z=8&layer=a'});
+ unmount();
+ expect(window.location.pathname).toBe('/map');
+ expect(window.location.search).toBe('?layer=a');
+ });
+
+ test('is not removed from the page the map navigated to', () => {
+ // "Calculate Solar Performance" navigates to a page whose own lat/lon must survive the map closing.
+ const {unmount} = render(
, {route: '/map?lat=40&lon=-105&z=8'});
+ window.history.pushState({}, '', '/more/calculators?calc=solar&lat=40.0154&lon=-105.2705');
+ unmount();
+ expect(window.location.search).toBe('?calc=solar&lat=40.0154&lon=-105.2705');
+ });
+});
diff --git a/app/src/components/calculators/SolarCalculator.js b/app/src/components/calculators/SolarCalculator.js
new file mode 100644
index 00000000..c0e1ca42
--- /dev/null
+++ b/app/src/components/calculators/SolarCalculator.js
@@ -0,0 +1,625 @@
+import React from "react";
+import {useSearchParams} from "react-router";
+import {
+ Accordion,
+ Button,
+ Grid,
+ Group,
+ Header,
+ IconButton,
+ Loader,
+ Message,
+ NumberInput,
+ Panel,
+ Select,
+ Statistic,
+ StatisticGroup,
+ Table,
+ Tabs,
+ 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_ACKNOWLEDGMENT,
+ CLIMATOLOGY_CHANGES,
+ CLIMATOLOGY_DATA_REFERENCE,
+ CLIMATOLOGY_LICENSE,
+ lookupClimatology,
+ useClimatology,
+} from "./solarData";
+import {SolarOffGrid} from "./SolarOffGrid";
+
+// 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}) =>
{children}
;
+
+// NASA POWER's requested acknowledgment, the license, and how WROLPi changed the data (CC BY 4.0).
+function DataAttribution() {
+ return
+
+ {CLIMATOLOGY_ACKNOWLEDGMENT} {CLIMATOLOGY_DATA_REFERENCE}
+ License: {CLIMATOLOGY_LICENSE}
+ {CLIMATOLOGY_CHANGES}
+ ;
+}
+
+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.';
+
+// Browsers only offer geolocation to pages served over HTTPS.
+export const geolocationAvailable = () => Boolean(window.isSecureContext && navigator.geolocation);
+
+const GEOLOCATION_ERRORS = {
+ 1: 'Location permission was denied. Allow it in your browser\'s settings for this site.',
+ 2: 'Your device could not find its location. Phones with GPS work offline; most computers need the Internet '
+ + 'for this.',
+ 3: 'Finding your location took too long. Phones with GPS work offline; most computers need the Internet for '
+ + 'this.',
+};
+
+function LocationSection({get, set, s, climatology}) {
+ const [locating, setLocating] = React.useState(false);
+ const [geoError, setGeoError] = React.useState(null);
+ 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;
+
+ const locate = () => {
+ setLocating(true);
+ setGeoError(null);
+ navigator.geolocation.getCurrentPosition(
+ ({coords}) => {
+ setLocating(false);
+ // 4 decimals (about 11 m) is far finer than the 1° data and keeps the shared URL short.
+ set({lat: Number(coords.latitude.toFixed(4)), lon: Number(coords.longitude.toFixed(4))});
+ },
+ error => {
+ setLocating(false);
+ setGeoError(GEOLOCATION_ERRORS[error.code] || error.message);
+ },
+ {enableHighAccuracy: false, timeout: 20000, maximumAge: 10 * 60 * 1000},
+ );
+ };
+
+ // The data's attribution is at the bottom of the page; this line only reports the data's state.
+ let source = null;
+ if (s.ownData) {
+ source = 'Using your own sunlight data.';
+ } else if (climatology.loading) {
+ source =
;
+ } else if (climatology.error) {
+ source =
+ Enter your own monthly sunlight under "Use my own sunlight data" below.
+ ;
+ }
+
+ return <>
+
+
+
+
+
+
+ set({lat: e.target.value})}/>
+
+
+ set({lon: e.target.value})}/>
+
+
+ {geoError &&
{geoError} }
+ {source &&
{source}
}
+ >;
+}
+
+function PanelsSection({setNumber, s}) {
+ return <>
+
+
+
+ setNumber('pw', v, DEFAULT_PANEL_WATTS)}/>
+
+
+ setNumber('pn', v, DEFAULT_PANEL_COUNT)}/>
+
+
+
+ {fmt(s.kwp * 1000, 0)} W total ({fmt(s.kwp)} kW)
+
+
+
+ >;
+}
+
+function Results({result, s}) {
+ const unit = s.inverterOn ? 'kWh AC' : 'kWh DC';
+ const rows = result.months.map(m =>
+ {m.month}
+ {fmt(m.energyDaily)}
+ {fmt(m.energy, 0)}
+ {fmt(m.poa)}
+ {fmt(m.dayLength, 1)} h
+ );
+
+ return
+
+
+
+
+
+
+
+
+
+
+ Month
+ {unit}/day
+ {unit}/month
+ Sun hours on panel
+ Day length
+
+
+ {rows}
+
+ ;
+}
+
+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
+ A tracking array turns to follow the sun, so it has no fixed angle. Change Tracking under Panel details
+ to set one.
+ ;
+ }
+
+ return <>
+
+ 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.
+
+
+
+ setNumber('tilt', v, s.defaultTilt)}/>
+
+ applyOptimal('annual')}>
+ Best for the year
+
+ applyOptimal('winter')}>
+ Best for winter
+
+
+
+
+
+ setNumber('az', v, azimuthDefault)}/>
+ setNumber('az', v, azimuthDefault)}/>
+
+
+
+ >;
+}
+
+function DetailsSection({set, setNumber, s}) {
+ const moduleType = Object.entries(MODULE_TYPES).find(([, m]) => m.gamma === s.gamma)?.[0] || 'custom';
+ return <>
+
+ 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.
+
+
+
+ ({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)}/>
+
+
+ setNumber('gamma', v, MODULE_TYPES.standard.gamma)}/>
+
+
+ ({value, label: m.label}))}
+ value={s.mounting} allowDeselect={false}
+ onChange={v => set({mount: v === 'open' ? null : v})}/>
+
+
+ ({value, label: t.label}))}
+ value={s.tracking} allowDeselect={false}
+ onChange={v => set({track: v === 'fixed' ? null : v})}/>
+
+
+ set({alb: v === '' ? null : v})}/>
+
+
+ >;
+}
+
+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 <>
+
+ 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.
+
+
+ {LOSS_CATEGORIES.map((c, i) =>
+ setLoss(i, v)}/>
+ )}
+
+
set({losses: null})}>
+ Reset to PVWatts defaults
+
+ >;
+}
+
+function InverterSection({set, setNumber, s}) {
+ return <>
+
+ 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.
+
+
set({inv: e.currentTarget.checked ? '1' : null})}/>
+ {s.inverterOn &&
+
+ setNumber('iac', v, roundDigits(s.kwp / 1.2, 2))}/>
+
+
+ setNumber('ieff', v, INVERTER_NOMINAL_EFFICIENCY)}/>
+
+ }
+ >;
+}
+
+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});
+ };
+
+ // Convert the temperatures with the unit, so they stay the same physical temperatures.
+ const switchUnits = toFahrenheit => {
+ const temps = s.temperatureInput.map(v => (v === null ? null
+ : roundDigits(toFahrenheit ? cToF(v) : fToC(v), 1)));
+ set({tu: toFahrenheit ? 'f' : null, temp: temps.some(v => v !== null) ? formatMonthly(temps) : null});
+ };
+
+ const setMonth = (key, values, index, value) => {
+ const next = [...values];
+ next[index] = value === '' ? null : value;
+ set({[key]: formatMonthly(next)});
+ };
+
+ const rows = MONTH_NAMES.map((name, i) =>
+ {name}
+
+ setMonth('ghi', s.ghi, i, v)}/>
+
+
+ setMonth('temp', s.temperatureInput, i, v)}/>
+
+ );
+
+ return <>
+
+ 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.
+
+
+ enable(e.currentTarget.checked)}/>
+ {s.ownData && switchUnits(e.currentTarget.checked)}/>}
+
+ {s.ownData &&
+
+
+ Month
+ Sunlight (kWh/m²/day)
+ Avg. temp ({unit})
+
+
+ {rows}
+
}
+ >;
+}
+
+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]);
+ // Off-grid sizing works from DC production per kW of panels, whatever the inverter setting.
+ const worstPerKwp = React.useMemo(
+ () => (ready ? estimateProduction({...modelInputs(s, sunlight), kwp: 1, inverter: null}).worst : null),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [s, sunlight, ready]);
+ const tab = get('tab') === 'offgrid' ? 'offgrid' : 'estimate';
+
+ 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
+
+
+ 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.
+
+
+
+
+
+ set({tab: v === 'offgrid' ? v : null})} keepMounted={false}
+ style={{marginTop: '1.5em'}}>
+
+ Estimate
+ Off-grid sizing
+
+
+ {ready && }
+ {!ready && !waitingForData &&
+ Enter {missing.join(', ')} to see an estimate.
+
}
+
+
+
+
+
+
+
+
+
+ {angleTitle}
+
+
+
+
+
+ {detailsTitle}
+
+
+
+ System losses: {fmt(totalLoss(s.losses))}%
+
+
+
+ Inverter: {s.inverterOn ? 'on (AC output)' : 'off (DC output)'}
+
+
+
+ Use my own sunlight data: {s.ownData ? 'on' : 'off'}
+
+
+
+
+
+ ;
+}
diff --git a/app/src/components/calculators/SolarCalculator.test.js b/app/src/components/calculators/SolarCalculator.test.js
new file mode 100644
index 00000000..74f8799f
--- /dev/null
+++ b/app/src/components/calculators/SolarCalculator.test.js
@@ -0,0 +1,256 @@
+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( , {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( , {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( , {route: DENVER});
+ expect(await findEstimate()).toBeInTheDocument();
+ expect(screen.getByText(`${roundDigits(expectedAnnual(), 0)}`)).toBeInTheDocument();
+ // The attribution is only at the bottom of the page.
+ expect(screen.queryByText(/Sunlight and temperature/)).not.toBeInTheDocument();
+ expect(global.fetch).toHaveBeenCalledWith(expect.stringMatching(/\/data\/solar-climatology\.bin$/));
+ });
+
+ test('the data attribution is at the bottom of the page', async () => {
+ renderWithProviders( , {route: DENVER});
+ await findEstimate();
+ const heading = screen.getByRole('heading', {name: 'Data attribution'});
+ const section = screen.getByRole('region', {name: 'Data attribution'});
+ expect(within(section).getByText(/Prediction Of Worldwide Energy Resources/)).toBeInTheDocument();
+ expect(within(section).getByText(/WROLPi modified the data/)).toBeInTheDocument();
+ expect(within(section).getByText(/CC BY 4\.0/)).toBeInTheDocument();
+ // After everything else, including the last Fine-tune section.
+ const lastControl = screen.getByText('Use my own sunlight data: off');
+ expect(lastControl.compareDocumentPosition(heading) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ });
+
+ test('typing writes the URL and leaves defaults out', async () => {
+ renderWithProviders( , {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);
+ });
+
+ describe('use my current location', () => {
+ const withGeolocation = getCurrentPosition => {
+ Object.defineProperty(window, 'isSecureContext', {value: true, configurable: true});
+ Object.defineProperty(navigator, 'geolocation', {value: {getCurrentPosition}, configurable: true});
+ };
+
+ afterEach(() => {
+ delete window.isSecureContext;
+ delete navigator.geolocation;
+ });
+
+ test('fills in the location, rounded to 4 decimals', async () => {
+ withGeolocation(success => success({coords: {latitude: 39.7392358, longitude: -104.990251}}));
+ renderWithProviders( , {route: BASE});
+ fireEvent.click(screen.getByRole('button', {name: 'Use my current location'}));
+ expect(currentParams().get('lat')).toBe('39.7392');
+ expect(currentParams().get('lon')).toBe('-104.9903');
+ expect(screen.getByLabelText('Latitude')).toHaveValue('39.7392');
+ expect(await findEstimate()).toBeInTheDocument();
+ });
+
+ test('explains a denied permission', () => {
+ withGeolocation((success, failure) => failure({code: 1, message: 'User denied Geolocation'}));
+ renderWithProviders( , {route: BASE});
+ fireEvent.click(screen.getByRole('button', {name: 'Use my current location'}));
+ expect(screen.getByText('Could not get your location')).toBeInTheDocument();
+ expect(screen.getByText(/Location permission was denied/)).toBeInTheDocument();
+ expect(currentParams().has('lat')).toBe(false);
+ });
+
+ test('is disabled without HTTPS', () => {
+ Object.defineProperty(window, 'isSecureContext', {value: false, configurable: true});
+ renderWithProviders( , {route: BASE});
+ expect(screen.getByRole('button', {name: 'Use my current location'})).toBeDisabled();
+ });
+ });
+
+ test('an invalid latitude is flagged', () => {
+ renderWithProviders( , {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( , {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( , {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( , {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('switching to Fahrenheit converts your temperatures instead of relabeling them', async () => {
+ const temps = '0,10,,,,,,,,,,';
+ renderWithProviders( ,
+ {route: `${DENVER}&ghi=${SUNNY_GHI.join(',')}&temp=${temps}`});
+ await findEstimate();
+ fireEvent.click(screen.getByText('Use my own sunlight data: on'));
+ fireEvent.click(screen.getByLabelText('Fahrenheit'));
+ expect(currentParams().get('tu')).toBe('f');
+ expect(currentParams().get('temp')).toBe('32,50,,,,,,,,,,');
+ expect(screen.getByLabelText('Jan temperature')).toHaveValue('32');
+ fireEvent.click(screen.getByLabelText('Fahrenheit'));
+ expect(currentParams().has('tu')).toBe(false);
+ expect(currentParams().get('temp')).toBe(temps);
+ });
+
+ test('a failed download explains how to continue', async () => {
+ forgetClimatology();
+ global.fetch = jest.fn(() => Promise.resolve({ok: false, status: 404}));
+ renderWithProviders( , {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();
+ });
+});
diff --git a/app/src/components/calculators/SolarOffGrid.js b/app/src/components/calculators/SolarOffGrid.js
new file mode 100644
index 00000000..4f6c2202
--- /dev/null
+++ b/app/src/components/calculators/SolarOffGrid.js
@@ -0,0 +1,350 @@
+import React from "react";
+import {Button, Grid, Group, Header, IconButton, Loader, Message, NumberInput, Panel, Select, Statistic,
+ StatisticGroup, Table, TextInput, Toggle} from "../ui";
+import {roundDigits} from "../Common";
+import {
+ BATTERIES,
+ batteryWarnings,
+ CONTROLLERS,
+ controllerSizing,
+ dailyLoadWh,
+ defaultColdest,
+ formatLoads,
+ leadAcidCapacityFactor,
+ loadWh,
+ parseLoads,
+ requiredKwp,
+ sizeBattery,
+ SYSTEM_VOLTAGES,
+} from "./offgrid";
+
+// Defaults are typical of a 400 W residential panel; the user should copy their panel's label.
+const PANEL_DEFAULTS = {voc: 37, vmp: 31, isc: 13.8, bvoc: -0.27};
+const DEFAULT_LOAD_INVERTER_EFFICIENCY = 90;
+const DEFAULT_DAYS = 2;
+const DEFAULT_CONTROLLER_MAX_VOLTAGE = 100;
+// Above this, a higher system voltage needs a much smaller controller and thinner wire.
+const HIGH_CURRENT = 60;
+
+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 = 0) => (Number.isFinite(value) ? `${roundDigits(value, digits)}` : '—');
+
+const Hint = ({children}) => {children}
;
+
+// The off-grid inputs, read from the URL like the rest of the calculator. Invalid values fall back.
+export function offGridFromParams(get, monthlyTemperatures) {
+ const battery = BATTERIES[get('bat')] ? get('bat') : 'lfp';
+ const systemVoltage = SYSTEM_VOLTAGES.includes(Number(get('sv'))) ? Number(get('sv')) : 12;
+ const controller = CONTROLLERS[get('ctl')] ? get('ctl') : 'mppt';
+ const coldestMonthly = (monthlyTemperatures || []).filter(Number.isFinite).length
+ ? Math.min(...monthlyTemperatures.filter(Number.isFinite)) : null;
+ const defaultTmin = defaultColdest(monthlyTemperatures);
+ return {
+ loads: parseLoads(get('load')),
+ acLoads: get('ac') !== '0',
+ loadInverterEfficiency: numberOr(get('leff'), DEFAULT_LOAD_INVERTER_EFFICIENCY),
+ battery,
+ systemVoltage,
+ dod: numberOr(get('dod'), BATTERIES[battery].dod),
+ days: Math.max(0, numberOr(get('aut'), DEFAULT_DAYS)),
+ outdoors: get('bout') === '1',
+ controller,
+ voc: numberOr(get('voc'), PANEL_DEFAULTS.voc),
+ vmp: numberOr(get('vmp'), PANEL_DEFAULTS.vmp),
+ isc: numberOr(get('isc'), PANEL_DEFAULTS.isc),
+ // Always negative for silicon panels; a missing minus sign must not hide the cold-weather rise.
+ betaVoc: -Math.abs(numberOr(get('bvoc'), PANEL_DEFAULTS.bvoc)),
+ series: Math.max(1, Math.round(numberOr(get('ns'), 1))),
+ controllerMaxVoltage: numberOr(get('cmax'), DEFAULT_CONTROLLER_MAX_VOLTAGE),
+ coldestMonthly,
+ defaultTmin,
+ coldest: numberOr(get('tmin'), defaultTmin),
+ };
+}
+
+/**
+ * Everything the off-grid tab shows, from its inputs.
+ *
+ * @param {Object} o offGridFromParams()
+ * @param {Object} worst the worst month for a 1 kW array: {month, energyDaily}
+ * @param {number} panelWatts
+ * @param {number} panelCount
+ */
+export function offGridResults(o, worst, panelWatts, panelCount) {
+ const devicesWh = dailyLoadWh(o.loads);
+ // AC devices draw through an inverter, so the batteries supply more than the devices use.
+ const batteryLoadWh = o.acLoads && o.loadInverterEfficiency > 0
+ ? devicesWh / (o.loadInverterEfficiency / 100) : devicesWh;
+ const capacityFactor = o.outdoors && o.battery === 'lead' ? leadAcidCapacityFactor(o.coldestMonthly) : 1;
+ const bank = sizeBattery({
+ loadWh: batteryLoadWh, days: o.days, dod: o.dod, voltage: o.systemVoltage, capacityFactor,
+ });
+
+ const sizing = controllerSizing({
+ panelCount, panelWatts, series: o.series, voc: o.voc, vmp: o.vmp, isc: o.isc, betaVoc: o.betaVoc,
+ coldest: o.coldest, controller: o.controller, controllerMaxVoltage: o.controllerMaxVoltage,
+ systemVoltage: o.systemVoltage,
+ });
+
+ // A PWM controller wastes part of every panel's output, so more panels are needed.
+ const worstPerKwp = worst ? worst.energyDaily * sizing.pwmFraction : null;
+ const kwp = requiredKwp(batteryLoadWh, worstPerKwp, BATTERIES[o.battery].efficiency);
+ const panelsNeeded = kwp && panelWatts > 0 ? Math.ceil(kwp * 1000 / panelWatts) : null;
+ const yourArrayWh = worstPerKwp !== null
+ ? worstPerKwp * (panelCount * panelWatts / 1000) * 1000 * BATTERIES[o.battery].efficiency / 100 : null;
+
+ return {
+ devicesWh, batteryLoadWh, bank, kwp, panelsNeeded, sizing,
+ coverage: batteryLoadWh > 0 && yourArrayWh !== null ? yourArrayWh / batteryLoadWh : null,
+ warnings: batteryWarnings({battery: o.battery, outdoors: o.outdoors, coldestMonthly: o.coldestMonthly}),
+ };
+}
+
+function LoadsSection({o, r, set, setNumber}) {
+ const rows = o.loads.length ? o.loads : [{name: '', watts: '', hours: ''}];
+ const write = next => set({load: next.some(row => row.name || row.watts || row.hours) ? formatLoads(next) : null});
+ const update = (index, field, value) => write(rows.map((row, i) => (i === index ? {...row, [field]: value} : row)));
+ const remove = index => write(rows.filter((_, i) => i !== index));
+
+ return <>
+
+
+ List what you will run and for how long each day. The watts are on the device's label or power supply;
+ a refrigerator cycles on and off, so use about a third of its rating for 24 hours.
+
+
+
+
+ Device
+ Watts
+ Hours per day
+ Wh per day
+
+
+
+
+ {rows.map((row, i) =>
+
+ update(i, 'name', e.target.value)}/>
+
+
+ update(i, 'watts', v)}/>
+
+
+ update(i, 'hours', v)}/>
+
+ {fmt(loadWh(row))}
+
+ remove(i)}/>
+
+ )}
+
+
+
+ write([...rows, {name: '', watts: '', hours: ''}])}>
+ Add device
+
+ {fmt(r.devicesWh)} Wh per day
+
+
+ set({ac: e.currentTarget.checked ? null : '0'})}/>
+ {o.acLoads && setNumber('leff', v, DEFAULT_LOAD_INVERTER_EFFICIENCY)}/>}
+
+ >;
+}
+
+function BatterySection({o, set, setNumber}) {
+ return <>
+
+
+ Days without sun is how long the batteries must carry your devices through cloudy weather. Two or three
+ days is typical. Larger systems (over about 1,500 W of panels) usually use 24 or 48 volts.
+
+
+
+ ({value, label: b.label}))}
+ value={o.battery} allowDeselect={false}
+ onChange={v => set({bat: v === 'lfp' ? null : v, dod: null})}/>
+
+
+ ({value: `${v}`, label: `${v} V`}))}
+ value={`${o.systemVoltage}`} allowDeselect={false}
+ onChange={v => set({sv: v === '12' ? null : v})}/>
+
+
+ setNumber('aut', v, DEFAULT_DAYS)}/>
+
+
+ setNumber('dod', v, BATTERIES[o.battery].dod)}/>
+
+
+ set({bout: e.currentTarget.checked ? '1' : null})}/>
+
+
+ >;
+}
+
+function NeedsSection({o, r, worst, s, setNumber}) {
+ if (!(r.devicesWh > 0)) {
+ return
+ List your devices above to size the system.
+
;
+ }
+ const coverage = r.coverage;
+ return
+
+
+
+
+
+
+
+
+ Sized for {worst.month}, your darkest month, when each kW of panels makes about{' '}
+ {fmt(worst.energyDaily, 1)} kWh a day at the angle chosen under Fine-tune.
+ {o.controller === 'pwm' && r.sizing.pwmFraction > 0 && ` The PWM controller uses only `
+ + `${fmt(r.sizing.pwmFraction * 100)}% of the panels' power, so more panels are needed.`}
+ {o.controller === 'pwm' && r.sizing.pwmFraction === 0 && ' These panels cannot charge this battery '
+ + 'through a PWM controller; see the warning below.'}
+
+ {coverage !== null &&
+ Your {s.panelCount} panel{s.panelCount === 1 ? '' : 's'} would supply about{' '}
+ {fmt(Math.min(coverage, 9.99) * 100)}% of your use in {worst.month}.{' '}
+ {r.panelsNeeded !== s.panelCount && r.panelsNeeded > 0 &&
+ setNumber('pn', r.panelsNeeded, 1)}>
+ Use {r.panelsNeeded} panels
+ }
+
}
+ {r.warnings.map(w => {w} )}
+ ;
+}
+
+function ControllerSection({o, r, set, setNumber, temperaturesKnown}) {
+ const z = r.sizing;
+ const panelField = (key, label, value, fallback, step, max) =>
+ setNumber(key, v, fallback)}/>
+ ;
+
+ return <>
+ Charge controller and wiring
+
+ Copy these from the label on the back of your panel. Panels make a higher voltage when cold; too many in
+ series can exceed the controller's limit on a cold morning and destroy it. The coldest morning starts
+ 25 °C below your coldest monthly average, because averages hide cold snaps; enter your area's record
+ low if you know it.
+
+
+ {panelField('voc', 'Open-circuit voltage, Voc (V)', o.voc, PANEL_DEFAULTS.voc, 0.1)}
+ {panelField('vmp', 'Max-power voltage, Vmp (V)', o.vmp, PANEL_DEFAULTS.vmp, 0.1)}
+ {panelField('isc', 'Short-circuit current, Isc (A)', o.isc, PANEL_DEFAULTS.isc, 0.1)}
+ {panelField('bvoc', 'Voc temperature coefficient (%/°C)', o.betaVoc, PANEL_DEFAULTS.bvoc, 0.01, 0)}
+
+ ({value, label: c.label}))}
+ value={o.controller} allowDeselect={false}
+ onChange={v => set({ctl: v === 'mppt' ? null : v})}/>
+
+
+ setNumber('cmax', v, DEFAULT_CONTROLLER_MAX_VOLTAGE)}/>
+
+
+ setNumber('ns', v, 1)}/>
+
+
+ setNumber('tmin', v, o.defaultTmin)}/>
+
+
+ {/* The cold-morning limit depends on the site's temperatures; do not show a placeholder's verdict. */}
+ {!temperaturesKnown &&
}
+ {temperaturesKnown && <>
+
+
+
+ Strings
+ {z.strings} of {o.series} panel{o.series === 1 ? '' : 's'} in series
+
+
+ String voltage on the coldest morning
+ {fmt(z.stringColdVoc, 1)} V (most panels in series: {z.maxSeries})
+
+
+ Controller current rating, at least
+ {fmt(z.current)} A
+
+
+
+ {z.overVoltage &&
+ {o.series} panels in series reach {fmt(z.stringColdVoc, 1)} V on a {o.coldest} °C morning,
+ over the controller's {o.controllerMaxVoltage} V limit. Use at most {z.maxSeries} in series, or a
+ controller rated for more voltage.
+ }
+ {z.lowVoltage &&
+ {o.controller === 'pwm' && z.pwmFraction === 0
+ ? `${o.series} panel${o.series === 1 ? '' : 's'} in series cannot reach the ${o.systemVoltage} V `
+ + 'battery\'s charging voltage, so a PWM controller cannot charge it at all.'
+ : `On a hot day ${o.series} panel${o.series === 1 ? '' : 's'} in series may not stay far enough `
+ + 'above the battery\'s charging voltage for the controller to charge well.'}
+ {' '}Put more panels in series, or use a lower system voltage.
+ }
+ {z.unevenStrings &&
+ Your panel count does not divide evenly into strings of {o.series}. Every string should have the same
+ number of panels.
+ }
+ {o.controller === 'mppt' && z.current > HIGH_CURRENT && o.systemVoltage < 48 &&
+
+ {fmt(z.current)} A needs a large controller and thick wire. A higher system voltage would cut the
+ current.
+ }
+ >}
+ >;
+}
+
+/**
+ * The off-grid sizing tab.
+ *
+ * @param {Object} worst the worst month for a 1 kW DC array, or null when there is no estimate
+ * @param {number[]} temperatures monthly average temperatures at the site, °C
+ * @param {boolean} loading the built-in sunlight and temperature data is still downloading
+ */
+export function SolarOffGrid({get, set, setNumber, s, worst, temperatures, loading}) {
+ const o = offGridFromParams(get, temperatures);
+ const r = offGridResults(o, worst, s.panelWatts, s.panelCount);
+
+ return
+
+ Size a battery system to run your devices through the darkest month. Production here is DC; the
+ Inverter section under Fine-tune is for grid-tie systems and does not apply.
+
+
+
+ {worst && }
+ {!worst && loading &&
}
+ {!worst && !loading &&
+ Enter a location above to size the system.
+
}
+
+ ;
+}
diff --git a/app/src/components/calculators/SolarOffGrid.test.js b/app/src/components/calculators/SolarOffGrid.test.js
new file mode 100644
index 00000000..7c2c3ed1
--- /dev/null
+++ b/app/src/components/calculators/SolarOffGrid.test.js
@@ -0,0 +1,173 @@
+import React from "react";
+import {fireEvent, screen} from "@testing-library/react";
+import {renderWithProviders} from "../../test-utils";
+import {offGridFromParams, offGridResults} from "./SolarOffGrid";
+import {SolarCalculator} from "./SolarCalculator";
+import {forgetClimatology} from "./solarData";
+import {mockClimatologyFetch} from "./solarData.fixtures";
+
+const params = query => {
+ const searchParams = new URLSearchParams(query);
+ return key => searchParams.get(key);
+};
+const currentParams = () => new URLSearchParams(window.location.search);
+const TEMPS = [-1, 0, 5, 10, 15, 20, 24, 22, 18, 10, 4, -1];
+const DEC = {month: 'Dec', energyDaily: 3};
+
+describe('offGridFromParams', () => {
+ test('defaults', () => {
+ const o = offGridFromParams(params(''), TEMPS);
+ expect(o.loads).toEqual([]);
+ expect(o.acLoads).toBe(true);
+ expect(o.battery).toBe('lfp');
+ expect(o.dod).toBe(80);
+ expect(o.systemVoltage).toBe(12);
+ expect(o.days).toBe(2);
+ expect(o.controller).toBe('mppt');
+ expect(o.series).toBe(1);
+ expect(o.coldestMonthly).toBe(-1);
+ expect(o.coldest).toBe(-26);
+ });
+
+ test('the depth of discharge follows the battery type unless set', () => {
+ expect(offGridFromParams(params('bat=lead'), TEMPS).dod).toBe(50);
+ expect(offGridFromParams(params('bat=lead&dod=60'), TEMPS).dod).toBe(60);
+ });
+
+ test('a Voc coefficient without its minus sign is read as negative, even from a shared link', () => {
+ expect(offGridFromParams(params('bvoc=0.27'), TEMPS).betaVoc).toBe(-0.27);
+ expect(offGridFromParams(params('bvoc=-0.3'), TEMPS).betaVoc).toBe(-0.3);
+ });
+
+ test('invalid values fall back', () => {
+ const o = offGridFromParams(params('bat=x&sv=13&ctl=y&ns=0&aut=-1'), null);
+ expect(o.battery).toBe('lfp');
+ expect(o.systemVoltage).toBe(12);
+ expect(o.controller).toBe('mppt');
+ expect(o.series).toBe(1);
+ expect(o.days).toBe(0);
+ expect(o.coldest).toBe(-20);
+ });
+});
+
+describe('offGridResults', () => {
+ // 1000 Wh/day of AC devices through a 90% inverter, LiFePO4 at 80%, 2 days, 12 V.
+ const o = offGridFromParams(params('load=Stuff~100~10'), TEMPS);
+
+ test('battery and panels for the worst month', () => {
+ const r = offGridResults(o, DEC, 400, 1);
+ expect(r.devicesWh).toBe(1000);
+ expect(r.batteryLoadWh).toBeCloseTo(1111.1, 1);
+ expect(r.bank.nominalWh).toBeCloseTo(2777.8, 1);
+ expect(r.bank.ah).toBeCloseTo(231.5, 1);
+ // 1.111 kWh / (3 kWh/kWp x 95%) = 0.390 kW: one 400 W panel.
+ expect(r.kwp).toBeCloseTo(0.3899, 4);
+ expect(r.panelsNeeded).toBe(1);
+ // One 400 W panel: 3 x 0.4 x 95% = 1.14 kWh for 1.111 kWh of use.
+ expect(r.coverage).toBeCloseTo(1.026, 3);
+ });
+
+ test('DC devices skip the inverter loss', () => {
+ const dc = offGridResults(offGridFromParams(params('load=Stuff~100~10&ac=0'), TEMPS), DEC, 400, 1);
+ expect(dc.batteryLoadWh).toBe(1000);
+ });
+
+ test('a PWM controller needs more panels', () => {
+ const pwm = offGridResults(offGridFromParams(params('load=Stuff~100~10&ctl=pwm'), TEMPS), DEC, 400, 1);
+ // Only 14.4 / 31 of each panel is used: 0.390 / 0.4645 = 0.839 kW, three panels.
+ expect(pwm.panelsNeeded).toBe(3);
+ });
+
+ test('an outdoor lead-acid bank is enlarged for the cold', () => {
+ const indoor = offGridResults(offGridFromParams(params('load=Stuff~100~10&bat=lead'), TEMPS), DEC, 400, 1);
+ const outdoor = offGridResults(offGridFromParams(params('load=Stuff~100~10&bat=lead&bout=1'), TEMPS),
+ DEC, 400, 1);
+ // Coldest month -1 °C: 79% of rated capacity.
+ expect(outdoor.bank.nominalWh).toBeCloseTo(indoor.bank.nominalWh / 0.79, 1);
+ expect(outdoor.warnings).toHaveLength(1);
+ });
+
+ test('nothing to size without devices or an estimate', () => {
+ const none = offGridResults(offGridFromParams(params(''), TEMPS), DEC, 400, 1);
+ expect(none.bank).toBeNull();
+ expect(none.panelsNeeded).toBeNull();
+ expect(offGridResults(o, null, 400, 1).panelsNeeded).toBeNull();
+ });
+});
+
+describe('the off-grid tab', () => {
+ const OFFGRID = '/more/calculators?calc=solar&lat=40&lon=-105&tab=offgrid';
+
+ beforeEach(() => {
+ forgetClimatology();
+ mockClimatologyFetch();
+ });
+
+ const findNeeds = () => screen.findByText('What you need', {}, {timeout: 5000});
+
+ test('devices are added to the URL and the system is sized', async () => {
+ renderWithProviders( , {route: OFFGRID});
+ expect(await screen.findByText('Add your devices', {}, {timeout: 5000})).toBeInTheDocument();
+ fireEvent.change(screen.getByLabelText('Device 1 name'), {target: {value: 'Fridge'}});
+ fireEvent.change(screen.getByLabelText('Device 1 watts'), {target: {value: '50'}});
+ fireEvent.change(screen.getByLabelText('Device 1 hours'), {target: {value: '24'}});
+ expect(currentParams().get('load')).toBe('Fridge~50~24');
+ expect(await findNeeds()).toBeInTheDocument();
+ expect(screen.getByText('1200 Wh')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', {name: 'Add device'}));
+ expect(screen.getByLabelText('Device 2 name')).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', {name: 'Remove device 2'}));
+ expect(screen.queryByLabelText('Device 2 name')).not.toBeInTheDocument();
+ });
+
+ test('the suggested panel count can be applied', async () => {
+ renderWithProviders( , {route: `${OFFGRID}&load=Heater~500~10`});
+ await findNeeds();
+ const button = screen.getByRole('button', {name: /^Use \d+ panels$/});
+ const suggested = button.textContent.match(/\d+/)[0];
+ fireEvent.click(button);
+ expect(currentParams().get('pn')).toBe(suggested);
+ });
+
+ test('too many panels in series warns before it destroys the controller', async () => {
+ renderWithProviders( , {route: `${OFFGRID}&load=Fridge~50~24&ns=3&pn=3`});
+ await findNeeds();
+ expect(screen.getByText('Too much voltage for the controller')).toBeInTheDocument();
+ });
+
+ test('PWM panels that cannot reach a 48 V battery say so instead of sizing a system', async () => {
+ renderWithProviders( , {route: `${OFFGRID}&load=Fridge~50~24&ctl=pwm&sv=48`});
+ await findNeeds();
+ expect(screen.getByText('Too little voltage to charge')).toBeInTheDocument();
+ expect(screen.getByText(/a PWM controller cannot charge it at all/)).toBeInTheDocument();
+ expect(screen.queryByRole('button', {name: /^Use \d+ panels$/})).not.toBeInTheDocument();
+ });
+
+ test('while the data downloads, it shows a loader and no cold-morning verdict', async () => {
+ forgetClimatology();
+ global.fetch = jest.fn(() => new Promise(() => {
+ }));
+ renderWithProviders( , {route: `${OFFGRID}&load=Fridge~50~24`});
+ // One beside the location, one in place of the sizing.
+ expect(screen.getAllByLabelText('Loading sunlight data')).toHaveLength(2);
+ expect(screen.queryByText('Almost there')).not.toBeInTheDocument();
+ expect(screen.queryByText('String voltage on the coldest morning')).not.toBeInTheDocument();
+ });
+
+ test('a coldest morning the user typed is checked even while the data downloads', async () => {
+ forgetClimatology();
+ global.fetch = jest.fn(() => new Promise(() => {
+ }));
+ renderWithProviders( , {route: `${OFFGRID}&load=Fridge~50~24&tmin=-30`});
+ expect(screen.getByText('String voltage on the coldest morning')).toBeInTheDocument();
+ });
+
+ test('the tab is remembered in the URL', async () => {
+ renderWithProviders( , {route: '/more/calculators?calc=solar&lat=40&lon=-105'});
+ fireEvent.click(screen.getByRole('tab', {name: 'Off-grid sizing'}));
+ expect(currentParams().get('tab')).toBe('offgrid');
+ fireEvent.click(screen.getByRole('tab', {name: 'Estimate'}));
+ expect(currentParams().has('tab')).toBe(false);
+ });
+});
diff --git a/app/src/components/calculators/offgrid.js b/app/src/components/calculators/offgrid.js
new file mode 100644
index 00000000..ea8ee0e0
--- /dev/null
+++ b/app/src/components/calculators/offgrid.js
@@ -0,0 +1,210 @@
+// Pure calculation functions for the Solar calculator's off-grid sizing. No React here.
+//
+// Off-grid systems are sized for the worst month, not the yearly average: the battery has to carry the
+// loads through a run of cloudy days, and the panels have to refill it in the darkest month.
+//
+// Energy is Wh, power W, voltage V, current A, temperature °C, percentages 0-100.
+
+const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x));
+
+// ---------------------------------------------------------------------------
+// Loads
+// ---------------------------------------------------------------------------
+
+// Devices are stored in the URL as "name~watts~hours" joined by "|".
+const ROW_SEPARATOR = '|';
+const FIELD_SEPARATOR = '~';
+
+export function parseLoads(text) {
+ if (!text) {
+ return [];
+ }
+ return text.split(ROW_SEPARATOR).map(row => {
+ const [name = '', watts = '', hours = ''] = row.split(FIELD_SEPARATOR);
+ return {name, watts, hours};
+ });
+}
+
+export function formatLoads(rows) {
+ // The separators cannot appear inside a name.
+ const clean = value => `${value ?? ''}`.replace(/[|~]/g, ' ');
+ return rows.map(r => [clean(r.name), clean(r.watts), clean(r.hours)].join(FIELD_SEPARATOR)).join(ROW_SEPARATOR);
+}
+
+// Watt-hours a device uses per day; blank or invalid values count as nothing.
+export function loadWh({watts, hours}) {
+ const w = Number(watts), h = Number(hours);
+ return w > 0 && h > 0 ? w * Math.min(h, 24) : 0;
+}
+
+export function dailyLoadWh(rows) {
+ return rows.reduce((acc, row) => acc + loadWh(row), 0);
+}
+
+// ---------------------------------------------------------------------------
+// Battery
+// ---------------------------------------------------------------------------
+
+export const BATTERIES = {
+ lfp: {
+ label: 'Lithium iron phosphate (LiFePO4)',
+ // Usable depth of discharge, and round-trip efficiency.
+ dod: 80, efficiency: 95,
+ },
+ lead: {
+ label: 'Lead-acid (flooded or AGM)',
+ // Discharging below half shortens a lead-acid battery's life sharply.
+ dod: 50, efficiency: 85,
+ },
+};
+
+export const SYSTEM_VOLTAGES = [12, 24, 48];
+
+// Charging voltage of a battery bank, used to judge PWM controllers and MPPT headroom.
+export const chargeVoltage = systemVoltage => systemVoltage * 1.2;
+
+// Share of rated capacity a cold lead-acid battery delivers: about 80% at 0 °C and 60% at -20 °C, as on
+// typical manufacturer curves. Lithium batteries are not derated here; they must not be CHARGED below
+// 0 °C, which is a warning instead (see batteryWarnings).
+export function leadAcidCapacityFactor(temperature) {
+ if (!Number.isFinite(temperature) || temperature >= 20) {
+ return 1;
+ }
+ return clamp(1 - 0.01 * (20 - temperature), 0.5, 1);
+}
+
+/**
+ * Battery bank for `days` of the daily load with no sun.
+ *
+ * @returns {{usableWh: number, nominalWh: number, ah: number}}
+ */
+export function sizeBattery({loadWh: daily, days, dod, voltage, capacityFactor = 1}) {
+ if (!(daily > 0) || !(days > 0) || !(dod > 0) || !(voltage > 0)) {
+ return null;
+ }
+ const usableWh = daily * days;
+ const nominalWh = usableWh / (dod / 100) / capacityFactor;
+ return {usableWh, nominalWh, ah: nominalWh / voltage};
+}
+
+// ---------------------------------------------------------------------------
+// Array
+// ---------------------------------------------------------------------------
+
+/**
+ * kW of panels needed to supply the daily load in the worst month.
+ *
+ * @param {number} loadWh daily energy the battery bank must supply
+ * @param {number} worstDailyPerKwp DC kWh per day per kW of panels in the worst month
+ * @param {number} batteryEfficiency round-trip efficiency, %; conservatively applied to all energy
+ */
+export function requiredKwp(loadWh, worstDailyPerKwp, batteryEfficiency) {
+ if (!(loadWh > 0) || !(worstDailyPerKwp > 0) || !(batteryEfficiency > 0)) {
+ return null;
+ }
+ return (loadWh / 1000) / (worstDailyPerKwp * batteryEfficiency / 100);
+}
+
+// ---------------------------------------------------------------------------
+// Charge controller and strings
+// ---------------------------------------------------------------------------
+
+export const CONTROLLERS = {
+ mppt: {label: 'MPPT'},
+ pwm: {label: 'PWM'},
+};
+
+// A PWM controller connects a string of `series` panels straight to the battery, holding it at the battery's
+// charging voltage instead of its best voltage (Vmp), so only this share of its power is used:
+// - charging voltage at or above the string's Voc: no current flows, nothing is delivered;
+// - between Vmp and Voc: the current falls from its maximum toward zero at Voc (taken as linear);
+// - below Vmp: full current at the lower voltage, charging voltage / Vmp of the power.
+export function pwmUsableFraction(vmp, voc, series, systemVoltage) {
+ const stringVmp = vmp * series, stringVoc = voc * series;
+ if (!(stringVmp > 0) || !(stringVoc > 0)) {
+ return 1;
+ }
+ const charge = chargeVoltage(systemVoltage);
+ if (charge >= stringVoc) {
+ return 0;
+ }
+ if (charge > stringVmp) {
+ return (charge / stringVmp) * (stringVoc - charge) / (stringVoc - stringVmp);
+ }
+ return charge / stringVmp;
+}
+
+// Panel open-circuit voltage rises as it gets colder. `betaVoc` is its temperature coefficient, %/°C, which is
+// negative for every silicon panel; a coefficient typed without its minus sign is treated as negative, because
+// a positive one would hide the cold-weather rise that destroys controllers.
+export function coldVoc(voc, betaVoc, temperature) {
+ return voc * (1 - (Math.abs(betaVoc) / 100) * (temperature - 25));
+}
+
+/**
+ * Wiring and controller checks for an array of `panelCount` panels, `series` of them per string.
+ */
+export function controllerSizing({
+ panelCount, panelWatts, series, voc, vmp, isc, betaVoc, coldest,
+ controller, controllerMaxVoltage, systemVoltage,
+ }) {
+ const strings = series > 0 ? Math.ceil(panelCount / series) : 0;
+ const arrayWatts = panelCount * panelWatts;
+ const stringColdVoc = coldVoc(voc, betaVoc, coldest) * series;
+ const maxSeries = Math.floor(controllerMaxVoltage / coldVoc(voc, betaVoc, coldest));
+
+ let current;
+ if (controller === 'pwm') {
+ // Panel current passes straight through, plus the usual 25% margin.
+ current = isc * strings * 1.25;
+ } else {
+ current = arrayWatts / systemVoltage * 1.25;
+ }
+ return {
+ strings,
+ arrayWatts,
+ stringColdVoc,
+ maxSeries,
+ current,
+ overVoltage: stringColdVoc > controllerMaxVoltage,
+ // An MPPT controller needs the string's voltage, which sags about 15% in summer heat, to stay above the
+ // battery's charging voltage with some headroom. A PWM controller loses power as soon as the string's
+ // Vmp is below the charging voltage, and all of it once its Voc is.
+ lowVoltage: controller === 'mppt'
+ ? vmp * series * 0.85 < chargeVoltage(systemVoltage) + 5
+ : vmp * series < chargeVoltage(systemVoltage),
+ unevenStrings: series > 0 && panelCount % series !== 0,
+ pwmFraction: controller === 'pwm' ? pwmUsableFraction(vmp, voc, series, systemVoltage) : 1,
+ };
+}
+
+// Default for the coldest morning when the user has not entered one: well below the coldest monthly
+// average, because monthly averages hide cold snaps. Errs cold, since underestimating the cold is what
+// destroys controllers.
+export const COLD_MARGIN = 25;
+
+export function defaultColdest(monthlyTemperatures) {
+ const known = (monthlyTemperatures || []).filter(Number.isFinite);
+ if (known.length === 0) {
+ return -20;
+ }
+ return Math.round(Math.min(...known) - COLD_MARGIN);
+}
+
+// Advice about the battery for the site's climate. `coldestMonthly` is the coldest monthly average, °C.
+export function batteryWarnings({battery, outdoors, coldestMonthly}) {
+ const warnings = [];
+ if (!outdoors || !Number.isFinite(coldestMonthly)) {
+ return warnings;
+ }
+ if (battery === 'lfp' && coldestMonthly < 5) {
+ warnings.push('Lithium (LiFePO4) batteries are damaged by charging below 0 °C. Keep them indoors, or buy '
+ + 'batteries with built-in heaters or low-temperature charge protection.');
+ }
+ if (battery === 'lead' && coldestMonthly < 20) {
+ warnings.push(`Cold lead-acid batteries hold less: the bank is enlarged to deliver ` +
+ `${Math.round(leadAcidCapacityFactor(coldestMonthly) * 100)}% of its rating in the coldest month. ` +
+ 'Discharged lead-acid batteries can also freeze and crack; keep them charged.');
+ }
+ return warnings;
+}
diff --git a/app/src/components/calculators/offgrid.test.js b/app/src/components/calculators/offgrid.test.js
new file mode 100644
index 00000000..2366980c
--- /dev/null
+++ b/app/src/components/calculators/offgrid.test.js
@@ -0,0 +1,174 @@
+import {
+ BATTERIES,
+ batteryWarnings,
+ chargeVoltage,
+ coldVoc,
+ controllerSizing,
+ dailyLoadWh,
+ defaultColdest,
+ formatLoads,
+ leadAcidCapacityFactor,
+ loadWh,
+ parseLoads,
+ pwmUsableFraction,
+ requiredKwp,
+ sizeBattery,
+} from "./offgrid";
+
+describe('loads', () => {
+ test('round-trip through the URL format', () => {
+ const rows = [{name: 'Fridge', watts: '60', hours: '24'}, {name: 'Lights', watts: '20', hours: '5'}];
+ expect(formatLoads(rows)).toBe('Fridge~60~24|Lights~20~5');
+ expect(parseLoads(formatLoads(rows))).toEqual(rows);
+ expect(parseLoads('')).toEqual([]);
+ expect(parseLoads(null)).toEqual([]);
+ });
+
+ test('separators are removed from names', () => {
+ expect(parseLoads(formatLoads([{name: 'A|B~C', watts: '1', hours: '1'}]))[0].name).toBe('A B C');
+ });
+
+ test('daily energy is watts times hours, ignoring blanks and capping at 24 hours', () => {
+ expect(loadWh({watts: '60', hours: '24'})).toBe(1440);
+ expect(loadWh({watts: '60', hours: '30'})).toBe(1440);
+ expect(loadWh({watts: '', hours: '5'})).toBe(0);
+ expect(loadWh({watts: '-5', hours: '5'})).toBe(0);
+ expect(dailyLoadWh(parseLoads('Fridge~60~24|Lights~20~5|~~'))).toBe(1540);
+ });
+});
+
+describe('battery', () => {
+ test('a bank for 2 days of 1 kWh at 50% depth of discharge holds 4 kWh', () => {
+ const bank = sizeBattery({loadWh: 1000, days: 2, dod: 50, voltage: 12});
+ expect(bank.usableWh).toBe(2000);
+ expect(bank.nominalWh).toBe(4000);
+ expect(bank.ah).toBeCloseTo(333.3, 1);
+ });
+
+ test('a cold lead-acid bank must be bigger', () => {
+ expect(leadAcidCapacityFactor(25)).toBe(1);
+ expect(leadAcidCapacityFactor(0)).toBeCloseTo(0.8, 6);
+ expect(leadAcidCapacityFactor(-20)).toBeCloseTo(0.6, 6);
+ expect(leadAcidCapacityFactor(-60)).toBe(0.5);
+ const warm = sizeBattery({loadWh: 1000, days: 1, dod: 50, voltage: 12});
+ const cold = sizeBattery({loadWh: 1000, days: 1, dod: 50, voltage: 12, capacityFactor: 0.8});
+ expect(cold.nominalWh).toBeCloseTo(warm.nominalWh / 0.8, 6);
+ });
+
+ test('lithium needs a smaller bank than lead-acid for the same load', () => {
+ const lfp = sizeBattery({loadWh: 1000, days: 2, dod: BATTERIES.lfp.dod, voltage: 24});
+ const lead = sizeBattery({loadWh: 1000, days: 2, dod: BATTERIES.lead.dod, voltage: 24});
+ expect(lfp.nominalWh).toBeLessThan(lead.nominalWh);
+ });
+
+ test('no bank without a load', () => {
+ expect(sizeBattery({loadWh: 0, days: 2, dod: 50, voltage: 12})).toBeNull();
+ });
+});
+
+describe('array', () => {
+ test('panels needed for the worst month', () => {
+ // 2 kWh/day, 3 kWh/kWp/day in the worst month, 95% battery efficiency: 2 / 2.85 = 0.702 kW.
+ expect(requiredKwp(2000, 3, 95)).toBeCloseTo(0.7018, 4);
+ expect(requiredKwp(2000, 0, 95)).toBeNull();
+ });
+});
+
+describe('charge controller', () => {
+ test('a PWM controller wastes most of a high-voltage panel on a 12 V battery', () => {
+ expect(chargeVoltage(12)).toBeCloseTo(14.4, 6);
+ // 14.4 V / 31 V: under half the panel's power.
+ expect(pwmUsableFraction(31, 37, 1, 12)).toBeCloseTo(0.4645, 3);
+ // A "12 V" panel (Vmp ~18 V) suits PWM much better.
+ expect(pwmUsableFraction(18, 22, 1, 12)).toBeCloseTo(0.8, 6);
+ });
+
+ test('PWM uses the string voltage, so panels in series waste even more', () => {
+ // Two 31 V panels make a 62 V string: 14.4 / 62.
+ expect(pwmUsableFraction(31, 37, 2, 12)).toBeCloseTo(0.2323, 3);
+ });
+
+ test('PWM delivers nothing when the string cannot reach the charging voltage', () => {
+ // One 37 Voc panel against a 48 V bank's 57.6 V.
+ expect(pwmUsableFraction(31, 37, 1, 48)).toBe(0);
+ expect(pwmUsableFraction(10, 12, 1, 12)).toBe(0);
+ });
+
+ test('PWM between Vmp and Voc delivers part of the power as current falls toward Voc', () => {
+ // 14.4 V sits just above a 14 V Vmp: (14.4 / 14) x (21 - 14.4) / (21 - 14).
+ expect(pwmUsableFraction(14, 21, 1, 12)).toBeCloseTo(0.9698, 3);
+ });
+
+ test('open-circuit voltage rises in the cold', () => {
+ // -0.27 %/°C at -25 °C (50 °C below the 25 °C rating): +13.5%.
+ expect(coldVoc(37, -0.27, -25)).toBeCloseTo(41.995, 3);
+ expect(coldVoc(37, -0.27, 25)).toBe(37);
+ });
+
+ test('a Voc coefficient typed without its minus sign still errs safe', () => {
+ expect(coldVoc(37, 0.27, -25)).toBeCloseTo(coldVoc(37, -0.27, -25), 9);
+ const sizing = controllerSizing({...typical, series: 3, betaVoc: 0.27});
+ expect(sizing.overVoltage).toBe(true);
+ expect(sizing.maxSeries).toBe(2);
+ });
+
+ const typical = {
+ panelCount: 4, panelWatts: 400, series: 2, voc: 37, vmp: 31, isc: 13.8, betaVoc: -0.27, coldest: -25,
+ controller: 'mppt', controllerMaxVoltage: 100, systemVoltage: 24,
+ };
+
+ test('two 400 W panels in series on a 100 V MPPT controller are safe', () => {
+ const result = controllerSizing(typical);
+ expect(result.strings).toBe(2);
+ expect(result.stringColdVoc).toBeCloseTo(83.99, 2);
+ expect(result.overVoltage).toBe(false);
+ expect(result.maxSeries).toBe(2);
+ // 1600 W / 24 V x 1.25.
+ expect(result.current).toBeCloseTo(83.33, 2);
+ expect(result.lowVoltage).toBe(false);
+ expect(result.unevenStrings).toBe(false);
+ });
+
+ test('three in series exceed 100 V on a cold morning', () => {
+ const result = controllerSizing({...typical, series: 3});
+ expect(result.overVoltage).toBe(true);
+ expect(result.unevenStrings).toBe(true);
+ });
+
+ test('one panel per string is too little voltage to charge 48 V through MPPT', () => {
+ expect(controllerSizing({...typical, series: 1, systemVoltage: 48}).lowVoltage).toBe(true);
+ });
+
+ test('PWM current is the panels\' short-circuit current plus 25%', () => {
+ const result = controllerSizing({...typical, controller: 'pwm', series: 1, systemVoltage: 12});
+ expect(result.current).toBeCloseTo(13.8 * 4 * 1.25, 6);
+ expect(result.pwmFraction).toBeCloseTo(0.4645, 3);
+ expect(result.lowVoltage).toBe(false);
+ });
+
+ test('PWM on a battery above the string\'s voltage warns and delivers nothing', () => {
+ const result = controllerSizing({...typical, controller: 'pwm', series: 1, systemVoltage: 48});
+ expect(result.pwmFraction).toBe(0);
+ expect(result.lowVoltage).toBe(true);
+ });
+});
+
+describe('defaultColdest', () => {
+ test('is well below the coldest monthly average', () => {
+ expect(defaultColdest([-1, 0, 5, 10, 15, 20, 24, 22, 18, 10, 4, -1])).toBe(-26);
+ expect(defaultColdest([null, null])).toBe(-20);
+ expect(defaultColdest(null)).toBe(-20);
+ });
+});
+
+describe('batteryWarnings', () => {
+ test('warn about charging lithium in the cold, only when the batteries are outdoors', () => {
+ expect(batteryWarnings({battery: 'lfp', outdoors: true, coldestMonthly: -5})[0]).toMatch(/below 0 °C/);
+ expect(batteryWarnings({battery: 'lfp', outdoors: false, coldestMonthly: -5})).toEqual([]);
+ expect(batteryWarnings({battery: 'lfp', outdoors: true, coldestMonthly: 15})).toEqual([]);
+ });
+
+ test('explain the cold lead-acid enlargement', () => {
+ expect(batteryWarnings({battery: 'lead', outdoors: true, coldestMonthly: 0})[0]).toMatch(/80% of its rating/);
+ });
+});
diff --git a/app/src/components/calculators/solar.js b/app/src/components/calculators/solar.js
new file mode 100644
index 00000000..890a8e93
--- /dev/null
+++ b/app/src/components/calculators/solar.js
@@ -0,0 +1,481 @@
+// 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(',');
+}
+
+// Link to the Solar calculator for a location, e.g. from the map. 4 decimals (about 11 m) is far finer
+// than the 1° data and keeps the shared URL short. MapLibre reports longitudes past ±180° once the map
+// has been panned around the world, so those are wrapped back.
+export function solarCalculatorPath(latitude, longitude) {
+ const lon = longitude >= -180 && longitude <= 180 ? longitude : ((longitude + 180) % 360 + 360) % 360 - 180;
+ const round = value => Number(value.toFixed(4));
+ return `/more/calculators?calc=solar&lat=${round(latitude)}&lon=${round(lon)}`;
+}
diff --git a/app/src/components/calculators/solar.test.js b/app/src/components/calculators/solar.test.js
new file mode 100644
index 00000000..09e21a93
--- /dev/null
+++ b/app/src/components/calculators/solar.test.js
@@ -0,0 +1,324 @@
+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,
+ solarCalculatorPath,
+ 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);
+ });
+});
+
+describe('solarCalculatorPath', () => {
+ test('links to the calculator with the location rounded to 4 decimals', () => {
+ expect(solarCalculatorPath(40.0153812, -105.2705456))
+ .toBe('/more/calculators?calc=solar&lat=40.0154&lon=-105.2705');
+ });
+
+ test('wraps a longitude from a map that has been panned around the world', () => {
+ expect(solarCalculatorPath(10, 190)).toBe('/more/calculators?calc=solar&lat=10&lon=-170');
+ expect(solarCalculatorPath(10, -540.5)).toBe('/more/calculators?calc=solar&lat=10&lon=179.5');
+ expect(solarCalculatorPath(10, 180)).toBe('/more/calculators?calc=solar&lat=10&lon=180');
+ });
+});
diff --git a/app/src/components/calculators/solarData.fixtures.js b/app/src/components/calculators/solarData.fixtures.js
new file mode 100644
index 00000000..3079df27
--- /dev/null
+++ b/app/src/components/calculators/solarData.fixtures.js
@@ -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;
+}
diff --git a/app/src/components/calculators/solarData.js b/app/src/components/calculators/solarData.js
new file mode 100644
index 00000000..426644cf
--- /dev/null
+++ b/app/src/components/calculators/solarData.js
@@ -0,0 +1,146 @@
+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`;
+
+// Attribution, as NASA asks (https://power.larc.nasa.gov/docs/referencing/) and CC BY 4.0 requires. The
+// build script writes the same text, with the API version and download date, to
+// public/data/solar-climatology.LICENSE.txt; a test keeps the two in step.
+export const CLIMATOLOGY_ACKNOWLEDGMENT = 'The data was obtained from National Aeronautics and Space '
+ + 'Administration (NASA) Langley Research Center\'s Prediction Of Worldwide Energy Resources (POWER) project '
+ + 'funded through the NASA Earth Science Division.';
+export const CLIMATOLOGY_DATA_REFERENCE = 'The data was obtained from the POWER Project\'s POWER Climatology API '
+ + 'v2.10.0 version on 2026/10/04.';
+export const CLIMATOLOGY_LICENSE = 'Creative Commons Attribution 4.0 International (CC BY 4.0), '
+ + 'https://creativecommons.org/licenses/by/4.0/';
+export const CLIMATOLOGY_CHANGES = 'WROLPi modified the data: values are rounded to fit one byte each '
+ + '(0.04 kWh/m²/day for irradiance, 0.004 for albedo, 0.5 °C for temperature), temperatures are averaged from '
+ + 'NASA\'s 0.5° x 0.625° grid into 1° cells, and the calculator interpolates between cells.';
+
+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};
+}
diff --git a/app/src/components/calculators/solarData.test.js b/app/src/components/calculators/solarData.test.js
new file mode 100644
index 00000000..16a15177
--- /dev/null
+++ b/app/src/components/calculators/solarData.test.js
@@ -0,0 +1,167 @@
+import fs from "fs";
+import path from "path";
+import {
+ CLIMATOLOGY_ACKNOWLEDGMENT,
+ CLIMATOLOGY_CHANGES,
+ CLIMATOLOGY_DATA_REFERENCE,
+ CLIMATOLOGY_LICENSE,
+ 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);
+ });
+});
+
+describe('attribution', () => {
+ // The build script writes this notice beside the data; the page shows the same text.
+ const notice = fs.readFileSync(path.join(__dirname, '../../../public/data/solar-climatology.LICENSE.txt'), 'utf8');
+
+ test('the notice and the page credit NASA identically', () => {
+ expect(notice).toContain(CLIMATOLOGY_ACKNOWLEDGMENT);
+ expect(notice).toContain(CLIMATOLOGY_DATA_REFERENCE);
+ expect(notice).toContain(CLIMATOLOGY_LICENSE);
+ expect(notice).toContain(CLIMATOLOGY_CHANGES);
+ });
+
+ test('the acknowledgment is NASA POWER\'s requested wording', () => {
+ // https://power.larc.nasa.gov/docs/referencing/
+ expect(CLIMATOLOGY_ACKNOWLEDGMENT).toBe('The data was obtained from National Aeronautics and Space '
+ + 'Administration (NASA) Langley Research Center\'s Prediction Of Worldwide Energy Resources (POWER) '
+ + 'project funded through the NASA Earth Science Division.');
+ });
+});
diff --git a/app/src/components/ui/Icon.tsx b/app/src/components/ui/Icon.tsx
index 75d5b4d1..c58bb6b5 100644
--- a/app/src/components/ui/Icon.tsx
+++ b/app/src/components/ui/Icon.tsx
@@ -60,6 +60,7 @@ const iconNameAliases: Record = {
'comments': 'IconMessages',
'copy': 'IconCopy',
'cube': 'IconCube',
+ 'current location': 'IconCurrentLocation',
'directory': 'IconFolder',
'disk': 'IconDatabase',
'dot circle': 'IconCircleDot',
diff --git a/docker/help/wrolpi-help b/docker/help/wrolpi-help
index c4362d80..b61bae30 160000
--- a/docker/help/wrolpi-help
+++ b/docker/help/wrolpi-help
@@ -1 +1 @@
-Subproject commit c4362d807977c2719dbe9a870ad86f4c14580431
+Subproject commit b61bae309cf253e080824ac12eb8b4fb690b8456
diff --git a/scripts/build_solar_climatology.py b/scripts/build_solar_climatology.py
new file mode 100644
index 00000000..2fd4654e
--- /dev/null
+++ b/scripts/build_solar_climatology.py
@@ -0,0 +1,216 @@
+#!/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 build also writes solar-climatology.LICENSE.txt beside the
+data, crediting NASA as it asks (https://power.larc.nasa.gov/docs/referencing/), with the API version,
+the date the data was downloaded, and how WROLPi changed it.
+
+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 datetime
+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's requested acknowledgment. Must match CLIMATOLOGY_ACKNOWLEDGMENT in solarData.js.
+ACKNOWLEDGMENT = ('The data was obtained from National Aeronautics and Space Administration (NASA) Langley '
+ 'Research Center\'s Prediction Of Worldwide Energy Resources (POWER) project funded through the '
+ 'NASA Earth Science Division.')
+LICENSE = 'Creative Commons Attribution 4.0 International (CC BY 4.0), https://creativecommons.org/licenses/by/4.0/'
+# CC BY 4.0 requires saying what was changed. Must match CLIMATOLOGY_CHANGES in solarData.js.
+CHANGES = ('WROLPi modified the data: values are rounded to fit one byte each (0.04 kWh/m²/day for irradiance, '
+ '0.004 for albedo, 0.5 °C for temperature), temperatures are averaged from NASA\'s 0.5° x 0.625° grid '
+ 'into 1° cells, and the calculator interpolates between cells.')
+
+# (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 tile_path(cache, parameter, lat_min, lon_min):
+ return cache / f'{parameter}_{lat_min}_{lon_min}.json'
+
+
+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 = tile_path(cache, parameter, lat_min, lon_min)
+ 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 notice(api, accessed):
+ """The attribution written beside the data. `api` is like "POWER Climatology API v2.10.0"."""
+ parameters = ', '.join(p for p, _, _ in PARAMETERS)
+ return (
+ 'Solar climatology for the WROLPi Solar calculator\n'
+ '\n'
+ 'Source: NASA POWER, https://power.larc.nasa.gov/\n'
+ f'Data: 2001-2020 monthly climatology of {parameters}, on a 1° grid.\n'
+ f'The data was obtained from the POWER Project\'s {api} version on {accessed:%Y/%m/%d}.\n'
+ '\n'
+ f'{ACKNOWLEDGMENT}\n'
+ '\n'
+ f'License: {LICENSE}\n'
+ '\n'
+ f'Changes: {CHANGES}\n'
+ )
+
+
+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('