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