mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-25 18:40:06 -04:00
Add a Solar calculator (#778)
* Add a Solar calculator with offline NASA POWER data Estimates a solar array's typical-year production from a location and the panels; everything else has defaults under an optional "Fine-tune" section. The model works from 12 monthly averages: Klein's average day per month, the Erbs monthly diffuse fraction, Collares-Pereira & Rabl / Liu & Jordan hourly split, HDKR transposition for fixed, single-axis and dual-axis arrays, then PVWatts' ASHRAE incidence loss, SAPM cell temperature, DC power, system losses and optional inverter. "Best for the year" and "Best for winter" sweep the tilt. Sunlight, diffuse, albedo and temperature come from NASA POWER's 2001-2020 climatology on a 1 degree grid, packed into app/public/data (3.1 MB, ~1 MB gzipped) by scripts/build_solar_climatology.py and fetched only when the calculator opens. Users can replace it with their own monthly values. Every setting lives in the URL, with defaults left out, so the Share button shares the whole configuration. Against PVWatts v8 for a 1 kW fixed array: Phoenix +0.6%, Seattle +8.8%, Anchorage +8.0% annually; these are recorded as tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Open the Solar calculator from the map, pins and current location The map's right-click (or long-press) menu gains "Calculate Solar Performance", and each saved pin a sun button; both open the calculator at that location. A button beside Latitude fills in the browser's current location. Locations are rounded to 4 decimals, and longitudes from a map panned around the world are wrapped back to -180..180. The map removed lat/lon/z from the URL when it closed, even after navigating to another page, which stripped the calculator's location from its URL (and from links shared with the Share button). It now only cleans up while the URL is still a map page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Add off-grid sizing to the Solar calculator A new "Off-grid sizing" tab sizes a battery system for the darkest month from a list of devices (watts x hours per day): - Battery bank for a number of days without sun, for LiFePO4 or lead-acid, at 12, 24 or 48 V. Outdoor lead-acid is enlarged for the cold; outdoor lithium warns against charging below 0 C. - Panels needed, from the worst month's DC production per kW at the chosen angle, with a button to apply the count. A PWM controller's loss (battery voltage over the panel's Vmp) raises the count. - Charge controller current, and the string voltage on the coldest morning against the controller's limit, since cold panels exceed their rated Voc. The coldest morning defaults to 25 C below the coldest monthly average. All inputs live in the URL like the rest of the calculator. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Bump wrolpi-help for the Solar calculator help page Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Credit NASA POWER as it asks, and say how the data was changed - The calculator gains a "Data attribution" link under the location with NASA POWER's requested acknowledgment, the data reference (Climatology API v2.10.0, downloaded 2026/10/04), the CC BY 4.0 license, and how WROLPi modified the data (rounded to one byte per value, temperature averaged into 1 degree cells, interpolated between cells), which CC BY 4.0 requires. - The build script writes the same text to solar-climatology.LICENSE.txt beside the data, with the API version taken from NASA's responses and the date the tiles were downloaded. A test keeps the page and file in step. - Bumps wrolpi-help for the updated attribution in the help page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Solar: fix PWM sizing, Voc sign, unit switching and loading on review - PWM usable power now uses the string's voltage (Vmp x panels in series), delivers nothing when the charging voltage is at or above the string's Voc, and falls off between Vmp and Voc. The "too little voltage" warning covers PWM as well as MPPT. Previously two panels in series counted double, and a panel that could not charge a 48 V bank counted as fully usable. - A Voc temperature coefficient entered without its minus sign is treated as negative, in the math, the URL and the input, so the cold-morning check cannot be made too generous. - Switching Fahrenheit on or off converts the entered temperatures instead of reinterpreting them. - While the sunlight data downloads, the off-grid tab shows a loader rather than asking for a location, and holds the cold-morning verdict until the site's temperatures are known or one has been entered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Solar: move the data attribution to the bottom of the page The full NASA POWER acknowledgment, license and changes now sit in a small "Data attribution" section at the end of the calculator, always visible, instead of behind a link under the location. The source line under the location points to it. Bumps wrolpi-help to match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Solar: drop the data source line under the location The attribution at the bottom of the page is enough. The line under the location now only appears to report the data's state: loading, failed to load, or replaced by the user's own values. Bumps wrolpi-help for the refreshed screenshot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
cfbed9e4d7
commit
cc8afa5370
22 changed files with 3288 additions and 18 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -150,3 +150,6 @@ app/cypress/videos
|
|||
release/latest.json
|
||||
perf_results/
|
||||
scripts/api_perf_hosts.json
|
||||
|
||||
# Tile cache for scripts/build_solar_climatology.py
|
||||
/scripts/.solar_climatology_cache/
|
||||
|
|
|
|||
11
app/public/data/solar-climatology.LICENSE.txt
Normal file
11
app/public/data/solar-climatology.LICENSE.txt
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
Solar climatology for the WROLPi Solar calculator
|
||||
|
||||
Source: NASA POWER, https://power.larc.nasa.gov/
|
||||
Data: 2001-2020 monthly climatology of ALLSKY_SFC_SW_DWN, ALLSKY_SFC_SW_DIFF, ALLSKY_SRF_ALB, T2M, on a 1° grid.
|
||||
The data was obtained from the POWER Project's POWER Climatology API v2.10.0 version on 2026/10/04.
|
||||
|
||||
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/
|
||||
|
||||
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.
|
||||
BIN
app/public/data/solar-climatology.bin
Normal file
BIN
app/public/data/solar-climatology.bin
Normal file
Binary file not shown.
|
|
@ -14,6 +14,7 @@ import {FoodStorageCalculator} from "./calculators/FoodStorageCalculator";
|
|||
import {OneTimePadCalculator} from "./calculators/OneTimePadCalculator";
|
||||
import {VinDecoderCalculator} from "./calculators/VinDecoderCalculator";
|
||||
import {SpeedTestCalculator} from "./calculators/SpeedTestCalculator";
|
||||
import {SolarCalculator} from "./calculators/SolarCalculator";
|
||||
|
||||
// Calculators are shown in these titled groups, in this order. Each calculator carries a
|
||||
// `group` matching one of these names; any calculator whose group is missing/unknown falls
|
||||
|
|
@ -35,6 +36,7 @@ export const useCalculators = () => {
|
|||
{key: 'ratio', icon: 'th large', button: 'Ratio', group: 'Engineering', contents: <RatioCalculators/>},
|
||||
{key: 'electrical', icon: 'lightning', button: 'Electrical', group: 'Engineering', contents: <ElectricalCalculators/>},
|
||||
{key: 'drive', icon: 'cogs', button: 'Drive Ratio', group: 'Engineering', contents: <DriveCalculator/>},
|
||||
{key: 'solar', icon: 'sun', button: 'Solar', group: 'Engineering', contents: <SolarCalculator/>},
|
||||
{key: 'water', icon: 'tint', button: 'Water Storage', group: 'Preparedness & Storage', contents: <WaterCalculator/>},
|
||||
{key: 'ration', icon: 'food', button: 'Food Storage', group: 'Preparedness & Storage', contents: <FoodStorageCalculator/>},
|
||||
{key: 'antenna', icon: 'signal', button: 'Antenna', group: 'Radio & Communications', contents: <DipoleAntennaCalculator/>},
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {Media, StatusContext} from "../contexts/contexts";
|
|||
import {MAP_VIEWER_URI} from "./Vars";
|
||||
import MapViewer from "./MapViewer";
|
||||
import {AddToPlaylistButton} from "./AddToPlaylist";
|
||||
import {solarCalculatorPath} from "./calculators/solar";
|
||||
import maplibregl from "maplibre-gl";
|
||||
import {Protocol} from "pmtiles";
|
||||
import layers from "protomaps-themes-base";
|
||||
|
|
@ -567,6 +568,9 @@ function MapPins() {
|
|||
navigate(`/map?lat=${pin.lat}&lon=${pin.lon}&z=14`);
|
||||
};
|
||||
|
||||
const solarButton = (pin) => <Button size='xs' icon='sun' aria-label={`Solar estimate for ${pin.label}`}
|
||||
onClick={() => navigate(solarCalculatorPath(pin.lat, pin.lon))}/>;
|
||||
|
||||
// --- Desktop headers and row ---
|
||||
const fullHeaders = [
|
||||
{key: 'color', text: '', sortBy: i => i.color},
|
||||
|
|
@ -600,10 +604,11 @@ function MapPins() {
|
|||
</Table.Cell>
|
||||
<Table.Cell>{pin.created}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{/* See PinEditRow: a Group so the three controls line up and are spaced. */}
|
||||
{/* See PinEditRow: a Group so the controls line up and are spaced. */}
|
||||
<Group gap='xs' wrap='nowrap'>
|
||||
<Button size='xs' icon='edit' aria-label={`Edit ${pin.label}`}
|
||||
onClick={() => setEditingId(pin.id)}/>
|
||||
{solarButton(pin)}
|
||||
<AddToPlaylistButton
|
||||
size='xs'
|
||||
icon='list'
|
||||
|
|
@ -646,6 +651,8 @@ function MapPins() {
|
|||
<div style={{fontSize: '0.6875rem', color: 'var(--muted)'}}>{pin.lat.toFixed(4)}, {pin.lon.toFixed(4)}</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Group gap='xs' wrap='nowrap'>
|
||||
{solarButton(pin)}
|
||||
<APIButton
|
||||
size='xs'
|
||||
role='danger'
|
||||
|
|
@ -655,6 +662,7 @@ function MapPins() {
|
|||
confirmButton='Delete'
|
||||
onClick={() => handleDelete(pin.id)}
|
||||
/>
|
||||
</Group>
|
||||
</Table.Cell>
|
||||
</Table.Row>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import {act, screen} from '@testing-library/react';
|
||||
import {act, fireEvent, screen} from '@testing-library/react';
|
||||
import {Route, Routes} from 'react-router';
|
||||
import {renderWithProviders as render} from '../test-utils';
|
||||
import {MapRoute} from './Map';
|
||||
|
|
@ -92,6 +92,16 @@ describe('the Pins tab', () => {
|
|||
expect(screen.getAllByText('Camp').length).toBeGreaterThan(0);
|
||||
expect(screen.queryAllByText(NO_PINS)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('a pin opens the Solar calculator at its location', async () => {
|
||||
api.getMapPins.mockResolvedValue({pins: [{id: 1, lat: 40.12346, lon: -111.2, label: 'Camp', color: '#f00'}]});
|
||||
renderAt('/map/pins');
|
||||
await act(async () => {
|
||||
});
|
||||
fireEvent.click(screen.getAllByRole('button', {name: 'Solar estimate for Camp'})[0]);
|
||||
expect(window.location.pathname).toBe('/more/calculators');
|
||||
expect(window.location.search).toBe('?calc=solar&lat=40.1235&lon=-111.2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Manage tab subscriptions table', () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, {useCallback, useContext, useEffect, useRef, useState} from "react";
|
||||
import {useSearchParams} from "react-router";
|
||||
import {useNavigate, useSearchParams} from "react-router";
|
||||
import maplibregl from "maplibre-gl";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
import {Protocol} from "pmtiles";
|
||||
|
|
@ -11,6 +11,7 @@ import {Button, Checkbox, Header, Icon, Panel, TextInput, Toggle} from "./ui";
|
|||
import {SettingsContext, ThemeContext} from "../contexts/contexts";
|
||||
import {mapFlavor, mapSprite} from "../themes/names";
|
||||
import {poiPopupHtml} from "./mapPoiPopup";
|
||||
import {solarCalculatorPath} from "./calculators/solar";
|
||||
|
||||
// Terrain DEM file prefix for hillshade and contours.
|
||||
const TERRAIN_PREFIX = "terrain-";
|
||||
|
|
@ -531,6 +532,7 @@ export default function MapViewer() {
|
|||
const [mapReady, setMapReady] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [contextMenu, setContextMenu] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
const [addingPin, setAddingPin] = useState(null); // {lat, lon} when pin dialog is open
|
||||
const markersRef = useRef([]);
|
||||
const pinsVisibleRef = useRef(true);
|
||||
|
|
@ -772,12 +774,16 @@ export default function MapViewer() {
|
|||
}
|
||||
mapRef.current = null;
|
||||
setMapReady(false);
|
||||
// Clean up map params from URL so they don't leak to other pages.
|
||||
// Clean up map params from URL so they don't leak to other pages. Only while the URL is
|
||||
// still a map page: after navigating away, lat/lon belong to the new page (the Solar
|
||||
// calculator, for one).
|
||||
const url = new URL(window.location);
|
||||
if (url.pathname.startsWith("/map")) {
|
||||
url.searchParams.delete("lat");
|
||||
url.searchParams.delete("lon");
|
||||
url.searchParams.delete("z");
|
||||
window.history.replaceState(null, '', url.pathname + url.search);
|
||||
}
|
||||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
|
|
@ -923,6 +929,12 @@ export default function MapViewer() {
|
|||
setContextMenu(null);
|
||||
}, [contextMenu]);
|
||||
|
||||
const handleSolar = useCallback(() => {
|
||||
if (!contextMenu) return;
|
||||
setContextMenu(null);
|
||||
navigate(solarCalculatorPath(contextMenu.lat, contextMenu.lon));
|
||||
}, [contextMenu, navigate]);
|
||||
|
||||
const handleSetDefaultLocation = useCallback(async () => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
|
@ -999,6 +1011,14 @@ export default function MapViewer() {
|
|||
>
|
||||
Add Pin Here
|
||||
</div>
|
||||
<div
|
||||
style={{padding: "0.5rem 0.75rem", cursor: "pointer", userSelect: "none"}}
|
||||
onMouseEnter={e => e.target.style.background = "#f0f0f0"}
|
||||
onMouseLeave={e => e.target.style.background = "transparent"}
|
||||
onClick={handleSolar}
|
||||
>
|
||||
Calculate Solar Performance
|
||||
</div>
|
||||
<div
|
||||
style={{padding: "0.5rem 0.75rem", cursor: "pointer", userSelect: "none"}}
|
||||
onMouseEnter={e => e.target.style.background = "#f0f0f0"}
|
||||
|
|
|
|||
45
app/src/components/MapViewer.test.js
Normal file
45
app/src/components/MapViewer.test.js
Normal file
|
|
@ -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(<MapViewer/>, {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(<MapViewer/>, {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');
|
||||
});
|
||||
});
|
||||
625
app/src/components/calculators/SolarCalculator.js
Normal file
625
app/src/components/calculators/SolarCalculator.js
Normal file
|
|
@ -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}) => <p style={{fontSize: '0.9em', opacity: 0.75, marginTop: 0}}>{children}</p>;
|
||||
|
||||
// NASA POWER's requested acknowledgment, the license, and how WROLPi changed the data (CC BY 4.0).
|
||||
function DataAttribution() {
|
||||
return <section aria-labelledby='solar-data-attribution'
|
||||
style={{marginTop: '2em', fontSize: '0.85em', opacity: 0.75}}>
|
||||
<Header as='h4' id='solar-data-attribution'>Data attribution</Header>
|
||||
<p>{CLIMATOLOGY_ACKNOWLEDGMENT} {CLIMATOLOGY_DATA_REFERENCE}</p>
|
||||
<p>License: {CLIMATOLOGY_LICENSE}</p>
|
||||
<p>{CLIMATOLOGY_CHANGES}</p>
|
||||
</section>;
|
||||
}
|
||||
|
||||
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 = <Loader size='xs' label='Loading sunlight data'/>;
|
||||
} else if (climatology.error) {
|
||||
source = <Message kind='warning' title='Sunlight data could not be loaded'>
|
||||
Enter your own monthly sunlight under "Use my own sunlight data" below.
|
||||
</Message>;
|
||||
}
|
||||
|
||||
return <>
|
||||
<Header as='h3'>1. Location</Header>
|
||||
<Grid align='flex-end'>
|
||||
<Grid.Col span='content'>
|
||||
<IconButton icon='current location' label='Use my current location' size='input-sm'
|
||||
loading={locating} disabled={!geolocationAvailable()} onClick={locate}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span='auto'>
|
||||
<TextInput label='Latitude' placeholder='40.015 or 40°0′54″N' name='lat'
|
||||
value={get('lat') || ''} error={latError}
|
||||
onChange={e => set({lat: e.target.value})}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 12, xs: 'auto'}}>
|
||||
<TextInput label='Longitude' placeholder='-105.27 or 105°16′12″W' name='lon'
|
||||
value={get('lon') || ''} error={lonError}
|
||||
onChange={e => set({lon: e.target.value})}/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
{geoError && <Message kind='warning' title='Could not get your location'>{geoError}</Message>}
|
||||
{source && <div style={{fontSize: '0.85em', opacity: 0.75, marginTop: '0.5em'}}>{source}</div>}
|
||||
</>;
|
||||
}
|
||||
|
||||
function PanelsSection({setNumber, s}) {
|
||||
return <>
|
||||
<Header as='h3' style={{marginTop: '1em'}}>2. Your panels</Header>
|
||||
<Grid>
|
||||
<Grid.Col span={{base: 6, sm: 3}}>
|
||||
<NumberInput label='Watts per panel' name='pw' value={s.panelWatts} min={0} step={10}
|
||||
onChange={v => setNumber('pw', v, DEFAULT_PANEL_WATTS)}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 6, sm: 3}}>
|
||||
<NumberInput label='Panels' name='pn' value={s.panelCount} min={0} step={1}
|
||||
onChange={v => setNumber('pn', v, DEFAULT_PANEL_COUNT)}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 12, sm: 6}} style={{display: 'flex', alignItems: 'flex-end'}}>
|
||||
<span style={{opacity: 0.75, paddingBottom: '0.5em'}}>
|
||||
{fmt(s.kwp * 1000, 0)} W total ({fmt(s.kwp)} kW)
|
||||
</span>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</>;
|
||||
}
|
||||
|
||||
function Results({result, s}) {
|
||||
const unit = s.inverterOn ? 'kWh AC' : 'kWh DC';
|
||||
const rows = result.months.map(m => <Table.Row key={m.month}>
|
||||
<Table.Cell>{m.month}</Table.Cell>
|
||||
<Table.Cell>{fmt(m.energyDaily)}</Table.Cell>
|
||||
<Table.Cell>{fmt(m.energy, 0)}</Table.Cell>
|
||||
<Table.Cell>{fmt(m.poa)}</Table.Cell>
|
||||
<Table.Cell>{fmt(m.dayLength, 1)} h</Table.Cell>
|
||||
</Table.Row>);
|
||||
|
||||
return <Panel style={{marginTop: '1em'}}>
|
||||
<Header as='h3'>Estimate <InfoPopup content={ESTIMATE_INFO}/></Header>
|
||||
<StatisticGroup>
|
||||
<Statistic value={fmt(result.annual, 0)} label={`${unit} per year`}/>
|
||||
<Statistic value={fmt(result.annual / 365, 1)} label={`${unit} per day, average`}/>
|
||||
<Statistic value={fmt(result.worst.energyDaily, 1)} label={`${unit} per day in ${result.worst.month}`}/>
|
||||
<Statistic value={fmt(result.specificYield, 0)} label='kWh per year per kW of panels'/>
|
||||
</StatisticGroup>
|
||||
<Table>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Month</Table.HeaderCell>
|
||||
<Table.HeaderCell>{unit}/day</Table.HeaderCell>
|
||||
<Table.HeaderCell>{unit}/month</Table.HeaderCell>
|
||||
<Table.HeaderCell>Sun hours on panel</Table.HeaderCell>
|
||||
<Table.HeaderCell>Day length</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>{rows}</Table.Body>
|
||||
</Table>
|
||||
</Panel>;
|
||||
}
|
||||
|
||||
function AngleSection({setNumber, s, sunlight, ready}) {
|
||||
const compass = COMPASS.find(c => Number(c.value) === s.azimuth)?.value || null;
|
||||
const applyOptimal = goal => setNumber('tilt', optimalTilt(modelInputs(s, sunlight), goal), s.defaultTilt);
|
||||
const azimuthDefault = defaultAzimuth(s.latitude ?? 0);
|
||||
|
||||
if (s.tracking !== 'fixed') {
|
||||
return <Hint>
|
||||
A tracking array turns to follow the sun, so it has no fixed angle. Change Tracking under Panel details
|
||||
to set one.
|
||||
</Hint>;
|
||||
}
|
||||
|
||||
return <>
|
||||
<Hint>
|
||||
Leave these alone unless your panels are already mounted. The default (tilted to your latitude, facing
|
||||
the equator) is close to the best for the year. "Best for winter" suits off-grid systems that must get
|
||||
through the darkest month.
|
||||
</Hint>
|
||||
<Grid>
|
||||
<Grid.Col span={{base: 12, sm: 6}}>
|
||||
<NumberInput label='Tilt (degrees from flat)' name='tilt' value={s.tilt} min={0} max={90} step={1}
|
||||
onChange={v => setNumber('tilt', v, s.defaultTilt)}/>
|
||||
<Group gap='xs' style={{marginTop: '0.25em'}}>
|
||||
<Button type='button' size='small' disabled={!ready} onClick={() => applyOptimal('annual')}>
|
||||
Best for the year
|
||||
</Button>
|
||||
<Button type='button' size='small' disabled={!ready} onClick={() => applyOptimal('winter')}>
|
||||
Best for winter
|
||||
</Button>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 12, sm: 6}}>
|
||||
<Group gap='xs' wrap='nowrap' align='flex-end'>
|
||||
<NumberInput label='Facing (compass degrees)' name='az' value={s.azimuth} min={0} max={359}
|
||||
step={5} style={{flex: 1}} onChange={v => setNumber('az', v, azimuthDefault)}/>
|
||||
<Select aria-label='Compass direction' data={COMPASS} value={compass} placeholder='—'
|
||||
style={{width: '5.5em'}} allowDeselect={false}
|
||||
onChange={v => setNumber('az', v, azimuthDefault)}/>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</>;
|
||||
}
|
||||
|
||||
function DetailsSection({set, setNumber, s}) {
|
||||
const moduleType = Object.entries(MODULE_TYPES).find(([, m]) => m.gamma === s.gamma)?.[0] || 'custom';
|
||||
return <>
|
||||
<Hint>
|
||||
The defaults suit a typical ground or pole mounted array of ordinary panels. The temperature coefficient
|
||||
is on your panel's datasheet; panels lose this much power for each °C they run above 25 °C.
|
||||
</Hint>
|
||||
<Grid>
|
||||
<Grid.Col span={{base: 12, sm: 6}}>
|
||||
<Select label='Panel type'
|
||||
data={[
|
||||
...Object.entries(MODULE_TYPES).map(([value, m]) => ({value, label: m.label})),
|
||||
{value: 'custom', label: 'Custom (from datasheet)'},
|
||||
]}
|
||||
value={moduleType} allowDeselect={false}
|
||||
onChange={v => v !== 'custom' && setNumber('gamma', MODULE_TYPES[v].gamma,
|
||||
MODULE_TYPES.standard.gamma)}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 12, sm: 6}}>
|
||||
<NumberInput label='Temperature coefficient of power (%/°C)' name='gamma' value={s.gamma}
|
||||
max={0} step={0.01} decimalScale={3}
|
||||
onChange={v => setNumber('gamma', v, MODULE_TYPES.standard.gamma)}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 12, sm: 6}}>
|
||||
<Select label='Mounting' data={Object.entries(MOUNTINGS).map(([value, m]) => ({value, label: m.label}))}
|
||||
value={s.mounting} allowDeselect={false}
|
||||
onChange={v => set({mount: v === 'open' ? null : v})}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 12, sm: 6}}>
|
||||
<Select label='Tracking' data={Object.entries(TRACKING).map(([value, t]) => ({value, label: t.label}))}
|
||||
value={s.tracking} allowDeselect={false}
|
||||
onChange={v => set({track: v === 'fixed' ? null : v})}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 12, sm: 6}}>
|
||||
<NumberInput label='Ground reflectance (albedo)' name='alb' value={s.albedo ?? ''}
|
||||
placeholder='From the data (monthly, includes snow)' min={0} max={1} step={0.05}
|
||||
decimalScale={2} onChange={v => set({alb: v === '' ? null : v})}/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</>;
|
||||
}
|
||||
|
||||
function LossesSection({set, s}) {
|
||||
const setLoss = (index, value) => {
|
||||
const next = [...s.losses];
|
||||
next[index] = value === '' ? LOSS_CATEGORIES[index].default : Number(value);
|
||||
const isDefault = next.every((v, i) => v === DEFAULT_LOSSES[i]);
|
||||
set({losses: isDefault ? null : formatMonthly(next)});
|
||||
};
|
||||
|
||||
return <>
|
||||
<Hint>
|
||||
Real systems lose some power to dirt, wiring, shading and more. These are PVWatts' defaults. Raise Shading
|
||||
if trees or buildings shade your panels, or Snow if snow sits on them for weeks.
|
||||
</Hint>
|
||||
<Grid>
|
||||
{LOSS_CATEGORIES.map((c, i) => <Grid.Col key={c.key} span={{base: 6, sm: 4}}>
|
||||
<NumberInput label={`${c.label} (%)`} name={`loss-${c.key}`} value={s.losses[i]}
|
||||
min={0} max={100} step={0.5} onChange={v => setLoss(i, v)}/>
|
||||
</Grid.Col>)}
|
||||
</Grid>
|
||||
<Button type='button' size='small' style={{marginTop: '0.5em'}} onClick={() => set({losses: null})}>
|
||||
Reset to PVWatts defaults
|
||||
</Button>
|
||||
</>;
|
||||
}
|
||||
|
||||
function InverterSection({set, setNumber, s}) {
|
||||
return <>
|
||||
<Hint>
|
||||
Leave this off if your panels charge batteries through a charge controller. Turn it on to see AC output
|
||||
from a grid-tie or hybrid inverter.
|
||||
</Hint>
|
||||
<Toggle label='Include an inverter (AC output)' checked={s.inverterOn}
|
||||
onChange={e => set({inv: e.currentTarget.checked ? '1' : null})}/>
|
||||
{s.inverterOn && <Grid style={{marginTop: '0.5em'}}>
|
||||
<Grid.Col span={{base: 6}}>
|
||||
<NumberInput label='AC rating (kW)' name='iac' value={s.acRating} min={0} step={0.1} decimalScale={2}
|
||||
onChange={v => setNumber('iac', v, roundDigits(s.kwp / 1.2, 2))}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 6}}>
|
||||
<NumberInput label='Efficiency (%)' name='ieff' value={s.efficiency} min={50} max={100} step={0.5}
|
||||
onChange={v => setNumber('ieff', v, INVERTER_NOMINAL_EFFICIENCY)}/>
|
||||
</Grid.Col>
|
||||
</Grid>}
|
||||
</>;
|
||||
}
|
||||
|
||||
function OwnDataSection({set, s, nasa}) {
|
||||
const unit = s.fahrenheit ? '°F' : '°C';
|
||||
|
||||
// Start from the NASA values, when available, so the user edits rather than types all 24.
|
||||
const enable = on => {
|
||||
if (!on) {
|
||||
set({ghi: null, temp: null});
|
||||
return;
|
||||
}
|
||||
const ghi = nasa ? nasa.ghi.map(v => (v === null ? '' : roundDigits(v, 2))) : Array(12).fill('');
|
||||
const temp = nasa
|
||||
? nasa.temperature.map(v => (v === null ? '' : roundDigits(s.fahrenheit ? cToF(v) : v, 1)))
|
||||
: Array(12).fill('');
|
||||
// An all-blank list is still written for sunlight, so the section stays on for typing.
|
||||
set({ghi: formatMonthly(ghi), temp: nasa ? formatMonthly(temp) : null});
|
||||
};
|
||||
|
||||
// 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) => <Table.Row key={name}>
|
||||
<Table.Cell>{name}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<NumberInput name={`ghi-${i}`} aria-label={`${name} sunlight`} value={s.ghi[i] ?? ''} min={0}
|
||||
step={0.1} hideControls onChange={v => setMonth('ghi', s.ghi, i, v)}/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<NumberInput name={`temp-${i}`} aria-label={`${name} temperature`}
|
||||
placeholder={s.fahrenheit ? '68' : '20'} value={s.temperatureInput[i] ?? ''} step={1}
|
||||
hideControls onChange={v => setMonth('temp', s.temperatureInput, i, v)}/>
|
||||
</Table.Cell>
|
||||
</Table.Row>);
|
||||
|
||||
return <>
|
||||
<Hint>
|
||||
Only needed if you have better local measurements than the built-in data, for example in mountains, where
|
||||
a 1° grid cell (about 69 miles across) can average very different terrain. Sunlight is global horizontal
|
||||
irradiance in kWh/m²/day, the same as "peak sun hours" on flat ground. Blank temperatures use 20 °C.
|
||||
</Hint>
|
||||
<Group gap='lg'>
|
||||
<Toggle label='Use my own sunlight data' checked={s.ownData}
|
||||
onChange={e => enable(e.currentTarget.checked)}/>
|
||||
{s.ownData && <Toggle label='Fahrenheit' checked={s.fahrenheit}
|
||||
onChange={e => switchUnits(e.currentTarget.checked)}/>}
|
||||
</Group>
|
||||
{s.ownData && <Table style={{marginTop: '0.5em'}}>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Month</Table.HeaderCell>
|
||||
<Table.HeaderCell>Sunlight (kWh/m²/day)</Table.HeaderCell>
|
||||
<Table.HeaderCell>Avg. temp ({unit})</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>{rows}</Table.Body>
|
||||
</Table>}
|
||||
</>;
|
||||
}
|
||||
|
||||
export function SolarCalculator() {
|
||||
const {searchParams, get, set, setNumber} = useSolarParams();
|
||||
const climatology = useClimatology();
|
||||
const queryString = searchParams.toString();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const s = React.useMemo(() => systemFromParams(get), [queryString]);
|
||||
const nasa = React.useMemo(
|
||||
() => (climatology.grid && s.latitude !== null && s.longitude !== null
|
||||
? lookupClimatology(climatology.grid, s.latitude, s.longitude) : null),
|
||||
[climatology.grid, s.latitude, s.longitude]);
|
||||
const sunlight = s.ownData ? sunlightFor(s, null) : nasa;
|
||||
const missing = missingInputs(s, sunlight);
|
||||
const ready = missing.length === 0;
|
||||
const result = React.useMemo(
|
||||
() => (ready ? estimateProduction(modelInputs(s, sunlight)) : null),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[s, sunlight, ready]);
|
||||
// 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 <div>
|
||||
<Header as='h1'>Solar</Header>
|
||||
<p>
|
||||
Estimate what solar panels will produce over a typical year. Enter a location and your panels; everything
|
||||
else has sensible defaults. Settings are saved in the page address, so you can bookmark or share them.
|
||||
</p>
|
||||
|
||||
<LocationSection get={get} set={set} s={s} climatology={climatology}/>
|
||||
<PanelsSection setNumber={setNumber} s={s}/>
|
||||
|
||||
<Tabs value={tab} onChange={v => set({tab: v === 'offgrid' ? v : null})} keepMounted={false}
|
||||
style={{marginTop: '1.5em'}}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value='estimate'>Estimate</Tabs.Tab>
|
||||
<Tabs.Tab value='offgrid'>Off-grid sizing</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value='estimate'>
|
||||
{ready && <Results result={result} s={s}/>}
|
||||
{!ready && !waitingForData && <div style={{marginTop: '1em'}}>
|
||||
<Message kind='info' title='Almost there'>Enter {missing.join(', ')} to see an estimate.</Message>
|
||||
</div>}
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value='offgrid'>
|
||||
<SolarOffGrid get={get} set={set} setNumber={setNumber} s={s} worst={worstPerKwp}
|
||||
temperatures={sunlight?.temperature} loading={waitingForData}/>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<Header as='h3' style={{marginTop: '1.5em'}}>Fine-tune (optional)</Header>
|
||||
<Accordion multiple>
|
||||
<Accordion.Item value='angle'>
|
||||
<Accordion.Control>{angleTitle}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<AngleSection setNumber={setNumber} s={s} sunlight={sunlight} ready={ready}/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value='details'>
|
||||
<Accordion.Control>{detailsTitle}</Accordion.Control>
|
||||
<Accordion.Panel><DetailsSection set={set} setNumber={setNumber} s={s}/></Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value='losses'>
|
||||
<Accordion.Control>System losses: {fmt(totalLoss(s.losses))}%</Accordion.Control>
|
||||
<Accordion.Panel><LossesSection set={set} s={s}/></Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value='inverter'>
|
||||
<Accordion.Control>Inverter: {s.inverterOn ? 'on (AC output)' : 'off (DC output)'}</Accordion.Control>
|
||||
<Accordion.Panel><InverterSection set={set} setNumber={setNumber} s={s}/></Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value='own'>
|
||||
<Accordion.Control>Use my own sunlight data: {s.ownData ? 'on' : 'off'}</Accordion.Control>
|
||||
<Accordion.Panel><OwnDataSection set={set} s={s} nasa={nasa}/></Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
|
||||
<DataAttribution/>
|
||||
</div>;
|
||||
}
|
||||
256
app/src/components/calculators/SolarCalculator.test.js
Normal file
256
app/src/components/calculators/SolarCalculator.test.js
Normal file
|
|
@ -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(<CalculatorsPage/>, {route: '/more/calculators'});
|
||||
const engineering = screen.getByRole('heading', {name: 'Engineering'}).closest('.wrolpi-header').parentElement;
|
||||
const link = within(engineering).getByRole('link', {name: /Solar/});
|
||||
expect(link).toHaveAttribute('href', '/more/calculators?calc=solar');
|
||||
});
|
||||
|
||||
test('asks for a location', async () => {
|
||||
renderWithProviders(<SolarCalculator/>, {route: BASE});
|
||||
expect(await screen.findByText('Enter a latitude, a longitude to see an estimate.', {}, {timeout: 5000}))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a location alone gives an estimate from the built-in data', async () => {
|
||||
renderWithProviders(<SolarCalculator/>, {route: DENVER});
|
||||
expect(await findEstimate()).toBeInTheDocument();
|
||||
expect(screen.getByText(`${roundDigits(expectedAnnual(), 0)}`)).toBeInTheDocument();
|
||||
// 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(<SolarCalculator/>, {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(<SolarCalculator/>, {route: BASE});
|
||||
fireEvent.change(screen.getByLabelText('Latitude'), {target: {value: '40°0\'54"N'}});
|
||||
expect(currentParams().get('lat')).toBe('40°0\'54"N');
|
||||
expect(currentParams().get('calc')).toBe('solar');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Panels'), {target: {value: '4'}});
|
||||
expect(currentParams().get('pn')).toBe('4');
|
||||
fireEvent.change(screen.getByLabelText('Panels'), {target: {value: '1'}});
|
||||
expect(currentParams().has('pn')).toBe(false);
|
||||
});
|
||||
|
||||
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(<SolarCalculator/>, {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(<SolarCalculator/>, {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(<SolarCalculator/>, {route: BASE});
|
||||
expect(screen.getByRole('button', {name: 'Use my current location'})).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
test('an invalid latitude is flagged', () => {
|
||||
renderWithProviders(<SolarCalculator/>, {route: `${BASE}&lat=95`});
|
||||
expect(screen.getByText('Latitude must be between -90 and 90')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('the inverter switches the results to AC', async () => {
|
||||
renderWithProviders(<SolarCalculator/>, {route: DENVER});
|
||||
await findEstimate();
|
||||
fireEvent.click(screen.getByText('Inverter: off (DC output)'));
|
||||
fireEvent.click(screen.getByLabelText('Include an inverter (AC output)'));
|
||||
expect(currentParams().get('inv')).toBe('1');
|
||||
expect(screen.getByText('kWh AC per year')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('best-for-the-year replaces a poor tilt', async () => {
|
||||
renderWithProviders(<SolarCalculator/>, {route: `${DENVER}&tilt=10`});
|
||||
await findEstimate();
|
||||
expect(screen.getByText('Panel angle: 10° tilt, facing S')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('Panel angle: 10° tilt, facing S'));
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'Best for the year'}));
|
||||
// A result of 40 equals the default (the latitude), so it is left out of the URL.
|
||||
const tilt = Number(currentParams().get('tilt') ?? 40);
|
||||
expect(tilt).toBeGreaterThanOrEqual(30);
|
||||
expect(tilt).toBeLessThanOrEqual(44);
|
||||
});
|
||||
|
||||
test('own sunlight data starts from the built-in values and replaces them', async () => {
|
||||
renderWithProviders(<SolarCalculator/>, {route: DENVER});
|
||||
await findEstimate();
|
||||
fireEvent.click(screen.getByText('Use my own sunlight data: off'));
|
||||
fireEvent.click(screen.getByLabelText('Use my own sunlight data'));
|
||||
|
||||
const ghi = currentParams().get('ghi').split(',').map(Number);
|
||||
ghi.forEach((v, i) => expect(v).toBeCloseTo(SUNNY_GHI[i], 1));
|
||||
expect(screen.getByText('Using your own sunlight data.')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Jun sunlight')).toHaveValue(`${ghi[5]}`);
|
||||
|
||||
// Halving June's sunlight lowers the estimate.
|
||||
fireEvent.change(screen.getByLabelText('Jun sunlight'), {target: {value: `${ghi[5] / 2}`}});
|
||||
expect(currentParams().get('ghi').split(',')[5]).toBe(`${ghi[5] / 2}`);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Use my own sunlight data'));
|
||||
expect(currentParams().has('ghi')).toBe(false);
|
||||
expect(currentParams().has('temp')).toBe(false);
|
||||
});
|
||||
|
||||
test('switching to Fahrenheit converts your temperatures instead of relabeling them', async () => {
|
||||
const temps = '0,10,,,,,,,,,,';
|
||||
renderWithProviders(<SolarCalculator/>,
|
||||
{route: `${DENVER}&ghi=${SUNNY_GHI.join(',')}&temp=${temps}`});
|
||||
await findEstimate();
|
||||
fireEvent.click(screen.getByText('Use my own sunlight data: on'));
|
||||
fireEvent.click(screen.getByLabelText('Fahrenheit'));
|
||||
expect(currentParams().get('tu')).toBe('f');
|
||||
expect(currentParams().get('temp')).toBe('32,50,,,,,,,,,,');
|
||||
expect(screen.getByLabelText('Jan temperature')).toHaveValue('32');
|
||||
fireEvent.click(screen.getByLabelText('Fahrenheit'));
|
||||
expect(currentParams().has('tu')).toBe(false);
|
||||
expect(currentParams().get('temp')).toBe(temps);
|
||||
});
|
||||
|
||||
test('a failed download explains how to continue', async () => {
|
||||
forgetClimatology();
|
||||
global.fetch = jest.fn(() => Promise.resolve({ok: false, status: 404}));
|
||||
renderWithProviders(<SolarCalculator/>, {route: DENVER});
|
||||
expect(await screen.findByText('Sunlight data could not be loaded', {}, {timeout: 5000})).toBeInTheDocument();
|
||||
expect(screen.getByText('Enter a location with sunlight data to see an estimate.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
350
app/src/components/calculators/SolarOffGrid.js
Normal file
350
app/src/components/calculators/SolarOffGrid.js
Normal file
|
|
@ -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}) => <p style={{fontSize: '0.9em', opacity: 0.75, marginTop: 0}}>{children}</p>;
|
||||
|
||||
// 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 <>
|
||||
<Header as='h3'>Your devices</Header>
|
||||
<Hint>
|
||||
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.
|
||||
</Hint>
|
||||
<Table>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Device</Table.HeaderCell>
|
||||
<Table.HeaderCell>Watts</Table.HeaderCell>
|
||||
<Table.HeaderCell>Hours per day</Table.HeaderCell>
|
||||
<Table.HeaderCell>Wh per day</Table.HeaderCell>
|
||||
<Table.HeaderCell/>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{rows.map((row, i) => <Table.Row key={i}>
|
||||
<Table.Cell>
|
||||
<TextInput aria-label={`Device ${i + 1} name`} placeholder='Refrigerator' value={row.name}
|
||||
onChange={e => update(i, 'name', e.target.value)}/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<NumberInput aria-label={`Device ${i + 1} watts`} placeholder='50' value={row.watts} min={0}
|
||||
hideControls onChange={v => update(i, 'watts', v)}/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<NumberInput aria-label={`Device ${i + 1} hours`} placeholder='24' value={row.hours} min={0}
|
||||
max={24} hideControls onChange={v => update(i, 'hours', v)}/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{fmt(loadWh(row))}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<IconButton icon='trash' label={`Remove device ${i + 1}`} onClick={() => remove(i)}/>
|
||||
</Table.Cell>
|
||||
</Table.Row>)}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
<Group gap='md' style={{marginTop: '0.5em'}}>
|
||||
<Button type='button' size='small' icon='add'
|
||||
onClick={() => write([...rows, {name: '', watts: '', hours: ''}])}>
|
||||
Add device
|
||||
</Button>
|
||||
<span><b>{fmt(r.devicesWh)} Wh</b> per day</span>
|
||||
</Group>
|
||||
<Group gap='lg' align='flex-end' style={{marginTop: '0.75em'}}>
|
||||
<Toggle label='Devices run on AC, through an inverter' checked={o.acLoads}
|
||||
onChange={e => set({ac: e.currentTarget.checked ? null : '0'})}/>
|
||||
{o.acLoads && <NumberInput label='Inverter efficiency (%)' name='leff' value={o.loadInverterEfficiency}
|
||||
min={50} max={100} step={1} style={{width: '12em'}}
|
||||
onChange={v => setNumber('leff', v, DEFAULT_LOAD_INVERTER_EFFICIENCY)}/>}
|
||||
</Group>
|
||||
</>;
|
||||
}
|
||||
|
||||
function BatterySection({o, set, setNumber}) {
|
||||
return <>
|
||||
<Header as='h3' style={{marginTop: '1.5em'}}>Batteries</Header>
|
||||
<Hint>
|
||||
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.
|
||||
</Hint>
|
||||
<Grid>
|
||||
<Grid.Col span={{base: 12, sm: 6}}>
|
||||
<Select label='Battery type'
|
||||
data={Object.entries(BATTERIES).map(([value, b]) => ({value, label: b.label}))}
|
||||
value={o.battery} allowDeselect={false}
|
||||
onChange={v => set({bat: v === 'lfp' ? null : v, dod: null})}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 6, sm: 3}}>
|
||||
<Select label='System voltage' data={SYSTEM_VOLTAGES.map(v => ({value: `${v}`, label: `${v} V`}))}
|
||||
value={`${o.systemVoltage}`} allowDeselect={false}
|
||||
onChange={v => set({sv: v === '12' ? null : v})}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 6, sm: 3}}>
|
||||
<NumberInput label='Days without sun' name='aut' value={o.days} min={0} step={0.5}
|
||||
onChange={v => setNumber('aut', v, DEFAULT_DAYS)}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 12, sm: 6}}>
|
||||
<NumberInput label='Usable depth of discharge (%)' name='dod' value={o.dod} min={10} max={100} step={5}
|
||||
onChange={v => setNumber('dod', v, BATTERIES[o.battery].dod)}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 12, sm: 6}} style={{display: 'flex', alignItems: 'flex-end'}}>
|
||||
<Toggle label='Batteries are outdoors or unheated' checked={o.outdoors}
|
||||
onChange={e => set({bout: e.currentTarget.checked ? '1' : null})}/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</>;
|
||||
}
|
||||
|
||||
function NeedsSection({o, r, worst, s, setNumber}) {
|
||||
if (!(r.devicesWh > 0)) {
|
||||
return <div style={{marginTop: '1em'}}>
|
||||
<Message kind='info' title='Add your devices'>List your devices above to size the system.</Message>
|
||||
</div>;
|
||||
}
|
||||
const coverage = r.coverage;
|
||||
return <Panel style={{marginTop: '1.5em'}}>
|
||||
<Header as='h3'>What you need</Header>
|
||||
<StatisticGroup>
|
||||
<Statistic value={fmt(r.panelsNeeded)} label={`${s.panelWatts} W panels for ${worst.month}`}/>
|
||||
<Statistic value={fmt(r.bank?.ah)} label={`Ah of ${o.systemVoltage} V battery`}/>
|
||||
<Statistic value={fmt(r.bank ? r.bank.nominalWh / 1000 : null, 1)} label='kWh of battery'/>
|
||||
<Statistic value={fmt(r.batteryLoadWh)} label='Wh per day from the batteries'/>
|
||||
</StatisticGroup>
|
||||
<p>
|
||||
Sized for {worst.month}, your darkest month, when each kW of panels makes about{' '}
|
||||
{fmt(worst.energyDaily, 1)} kWh a day at the angle chosen under Fine-tune.
|
||||
{o.controller === 'pwm' && r.sizing.pwmFraction > 0 && ` The PWM controller uses only `
|
||||
+ `${fmt(r.sizing.pwmFraction * 100)}% of the panels' power, so more panels are needed.`}
|
||||
{o.controller === 'pwm' && r.sizing.pwmFraction === 0 && ' These panels cannot charge this battery '
|
||||
+ 'through a PWM controller; see the warning below.'}
|
||||
</p>
|
||||
{coverage !== null && <p>
|
||||
Your {s.panelCount} panel{s.panelCount === 1 ? '' : 's'} would supply about{' '}
|
||||
<b>{fmt(Math.min(coverage, 9.99) * 100)}%</b> of your use in {worst.month}.{' '}
|
||||
{r.panelsNeeded !== s.panelCount && r.panelsNeeded > 0 &&
|
||||
<Button type='button' size='small' onClick={() => setNumber('pn', r.panelsNeeded, 1)}>
|
||||
Use {r.panelsNeeded} panels
|
||||
</Button>}
|
||||
</p>}
|
||||
{r.warnings.map(w => <Message key={w} kind='warning' title='Battery'>{w}</Message>)}
|
||||
</Panel>;
|
||||
}
|
||||
|
||||
function ControllerSection({o, r, set, setNumber, temperaturesKnown}) {
|
||||
const z = r.sizing;
|
||||
const panelField = (key, label, value, fallback, step, max) => <Grid.Col span={{base: 6, sm: 3}}>
|
||||
<NumberInput label={label} name={key} value={value} step={step} decimalScale={3} max={max}
|
||||
onChange={v => setNumber(key, v, fallback)}/>
|
||||
</Grid.Col>;
|
||||
|
||||
return <>
|
||||
<Header as='h3' style={{marginTop: '1.5em'}}>Charge controller and wiring</Header>
|
||||
<Hint>
|
||||
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.
|
||||
</Hint>
|
||||
<Grid>
|
||||
{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)}
|
||||
<Grid.Col span={{base: 6, sm: 3}}>
|
||||
<Select label='Controller type'
|
||||
data={Object.entries(CONTROLLERS).map(([value, c]) => ({value, label: c.label}))}
|
||||
value={o.controller} allowDeselect={false}
|
||||
onChange={v => set({ctl: v === 'mppt' ? null : v})}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 6, sm: 3}}>
|
||||
<NumberInput label='Controller max PV voltage (V)' name='cmax' value={o.controllerMaxVoltage} min={0}
|
||||
step={10} onChange={v => setNumber('cmax', v, DEFAULT_CONTROLLER_MAX_VOLTAGE)}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 6, sm: 3}}>
|
||||
<NumberInput label='Panels in series' name='ns' value={o.series} min={1} step={1}
|
||||
onChange={v => setNumber('ns', v, 1)}/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{base: 6, sm: 3}}>
|
||||
<NumberInput label='Coldest morning (°C)' name='tmin' value={o.coldest} step={1}
|
||||
onChange={v => setNumber('tmin', v, o.defaultTmin)}/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
{/* The cold-morning limit depends on the site's temperatures; do not show a placeholder's verdict. */}
|
||||
{!temperaturesKnown && <div style={{marginTop: '1em'}}><Loader size='xs' label='Loading temperatures'/></div>}
|
||||
{temperaturesKnown && <>
|
||||
<Table style={{marginTop: '1em'}}>
|
||||
<Table.Body>
|
||||
<Table.Row>
|
||||
<Table.Cell>Strings</Table.Cell>
|
||||
<Table.Cell>{z.strings} of {o.series} panel{o.series === 1 ? '' : 's'} in series</Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell>String voltage on the coldest morning</Table.Cell>
|
||||
<Table.Cell>{fmt(z.stringColdVoc, 1)} V (most panels in series: {z.maxSeries})</Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell>Controller current rating, at least</Table.Cell>
|
||||
<Table.Cell>{fmt(z.current)} A</Table.Cell>
|
||||
</Table.Row>
|
||||
</Table.Body>
|
||||
</Table>
|
||||
{z.overVoltage && <Message kind='error' title='Too much voltage for the controller'>
|
||||
{o.series} panels in series reach {fmt(z.stringColdVoc, 1)} V on a {o.coldest} °C morning,
|
||||
over the controller's {o.controllerMaxVoltage} V limit. Use at most {z.maxSeries} in series, or a
|
||||
controller rated for more voltage.
|
||||
</Message>}
|
||||
{z.lowVoltage && <Message kind='warning' title='Too little voltage to charge'>
|
||||
{o.controller === 'pwm' && z.pwmFraction === 0
|
||||
? `${o.series} panel${o.series === 1 ? '' : 's'} in series cannot reach the ${o.systemVoltage} V `
|
||||
+ 'battery\'s charging voltage, so a PWM controller cannot charge it at all.'
|
||||
: `On a hot day ${o.series} panel${o.series === 1 ? '' : 's'} in series may not stay far enough `
|
||||
+ 'above the battery\'s charging voltage for the controller to charge well.'}
|
||||
{' '}Put more panels in series, or use a lower system voltage.
|
||||
</Message>}
|
||||
{z.unevenStrings && <Message kind='warning' title='Uneven strings'>
|
||||
Your panel count does not divide evenly into strings of {o.series}. Every string should have the same
|
||||
number of panels.
|
||||
</Message>}
|
||||
{o.controller === 'mppt' && z.current > HIGH_CURRENT && o.systemVoltage < 48 &&
|
||||
<Message kind='info' title='High current'>
|
||||
{fmt(z.current)} A needs a large controller and thick wire. A higher system voltage would cut the
|
||||
current.
|
||||
</Message>}
|
||||
</>}
|
||||
</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <div style={{marginTop: '1em'}}>
|
||||
<Hint>
|
||||
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.
|
||||
</Hint>
|
||||
<LoadsSection o={o} r={r} set={set} setNumber={setNumber}/>
|
||||
<BatterySection o={o} set={set} setNumber={setNumber}/>
|
||||
{worst && <NeedsSection o={o} r={r} worst={worst} s={s} setNumber={setNumber}/>}
|
||||
{!worst && loading && <div style={{marginTop: '1em'}}><Loader size='xs' label='Loading sunlight data'/></div>}
|
||||
{!worst && !loading && <div style={{marginTop: '1em'}}>
|
||||
<Message kind='info' title='Almost there'>Enter a location above to size the system.</Message>
|
||||
</div>}
|
||||
<ControllerSection o={o} r={r} set={set} setNumber={setNumber}
|
||||
temperaturesKnown={!loading || get('tmin') !== null}/>
|
||||
</div>;
|
||||
}
|
||||
173
app/src/components/calculators/SolarOffGrid.test.js
Normal file
173
app/src/components/calculators/SolarOffGrid.test.js
Normal file
|
|
@ -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(<SolarCalculator/>, {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(<SolarCalculator/>, {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(<SolarCalculator/>, {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(<SolarCalculator/>, {route: `${OFFGRID}&load=Fridge~50~24&ctl=pwm&sv=48`});
|
||||
await findNeeds();
|
||||
expect(screen.getByText('Too little voltage to charge')).toBeInTheDocument();
|
||||
expect(screen.getByText(/a PWM controller cannot charge it at all/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', {name: /^Use \d+ panels$/})).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('while the data downloads, it shows a loader and no cold-morning verdict', async () => {
|
||||
forgetClimatology();
|
||||
global.fetch = jest.fn(() => new Promise(() => {
|
||||
}));
|
||||
renderWithProviders(<SolarCalculator/>, {route: `${OFFGRID}&load=Fridge~50~24`});
|
||||
// One beside the location, one in place of the sizing.
|
||||
expect(screen.getAllByLabelText('Loading sunlight data')).toHaveLength(2);
|
||||
expect(screen.queryByText('Almost there')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('String voltage on the coldest morning')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a coldest morning the user typed is checked even while the data downloads', async () => {
|
||||
forgetClimatology();
|
||||
global.fetch = jest.fn(() => new Promise(() => {
|
||||
}));
|
||||
renderWithProviders(<SolarCalculator/>, {route: `${OFFGRID}&load=Fridge~50~24&tmin=-30`});
|
||||
expect(screen.getByText('String voltage on the coldest morning')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('the tab is remembered in the URL', async () => {
|
||||
renderWithProviders(<SolarCalculator/>, {route: '/more/calculators?calc=solar&lat=40&lon=-105'});
|
||||
fireEvent.click(screen.getByRole('tab', {name: 'Off-grid sizing'}));
|
||||
expect(currentParams().get('tab')).toBe('offgrid');
|
||||
fireEvent.click(screen.getByRole('tab', {name: 'Estimate'}));
|
||||
expect(currentParams().has('tab')).toBe(false);
|
||||
});
|
||||
});
|
||||
210
app/src/components/calculators/offgrid.js
Normal file
210
app/src/components/calculators/offgrid.js
Normal file
|
|
@ -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;
|
||||
}
|
||||
174
app/src/components/calculators/offgrid.test.js
Normal file
174
app/src/components/calculators/offgrid.test.js
Normal file
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
481
app/src/components/calculators/solar.js
Normal file
481
app/src/components/calculators/solar.js
Normal file
|
|
@ -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)}`;
|
||||
}
|
||||
324
app/src/components/calculators/solar.test.js
Normal file
324
app/src/components/calculators/solar.test.js
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
48
app/src/components/calculators/solarData.fixtures.js
Normal file
48
app/src/components/calculators/solarData.fixtures.js
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import {cellIndex, COLUMNS, PARAMETERS, ROWS} from "./solarData";
|
||||
|
||||
// Build a climatology file for tests, the same format scripts/build_solar_climatology.py writes.
|
||||
// `valueAt(key, month, latitude, longitude)` returns the value for a cell center, or null for no data.
|
||||
export function encodeClimatology(valueAt) {
|
||||
const header = 8;
|
||||
const bytes = new Uint8Array(header + PARAMETERS.length * 12 * ROWS * COLUMNS);
|
||||
bytes.set([...'WSOL'].map(c => c.charCodeAt(0)), 0);
|
||||
bytes[4] = 1;
|
||||
bytes[5] = PARAMETERS.length;
|
||||
PARAMETERS.forEach(({key, offset, step}, p) => {
|
||||
for (let month = 0; month < 12; month++) {
|
||||
for (let row = 0; row < ROWS; row++) {
|
||||
for (let column = 0; column < COLUMNS; column++) {
|
||||
const value = valueAt(key, month, row - 89.5, column - 179.5);
|
||||
bytes[header + cellIndex(p, month, row, column)] = value === null
|
||||
? 255
|
||||
: Math.max(0, Math.min(254, Math.round((value + offset) / step)));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
// A plain climatology: the same sunny 40°N profile everywhere.
|
||||
export const SUNNY_GHI = [2.6, 3.5, 4.7, 5.8, 6.5, 7.2, 7.0, 6.2, 5.2, 4.0, 2.9, 2.3];
|
||||
export const MILD_TEMPERATURE = [0, 2, 6, 10, 15, 21, 24, 23, 18, 11, 4, 0];
|
||||
|
||||
// Encoding the 3 MB file is slow, so it is built once and shared; nothing writes to it.
|
||||
let uniform = null;
|
||||
export const uniformClimatology = () => {
|
||||
if (!uniform) {
|
||||
uniform = encodeClimatology((key, month) => ({
|
||||
ghi: SUNNY_GHI[month],
|
||||
diffuse: SUNNY_GHI[month] * 0.3,
|
||||
albedo: 0.2,
|
||||
temperature: MILD_TEMPERATURE[month],
|
||||
})[key]);
|
||||
}
|
||||
return uniform;
|
||||
};
|
||||
|
||||
// Make `fetch` return a climatology file.
|
||||
export function mockClimatologyFetch(buffer = uniformClimatology()) {
|
||||
global.fetch = jest.fn(() => Promise.resolve({ok: true, arrayBuffer: () => Promise.resolve(buffer)}));
|
||||
return global.fetch;
|
||||
}
|
||||
146
app/src/components/calculators/solarData.js
Normal file
146
app/src/components/calculators/solarData.js
Normal file
|
|
@ -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};
|
||||
}
|
||||
167
app/src/components/calculators/solarData.test.js
Normal file
167
app/src/components/calculators/solarData.test.js
Normal file
|
|
@ -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.');
|
||||
});
|
||||
});
|
||||
|
|
@ -60,6 +60,7 @@ const iconNameAliases: Record<string, keyof typeof Tabler> = {
|
|||
'comments': 'IconMessages',
|
||||
'copy': 'IconCopy',
|
||||
'cube': 'IconCube',
|
||||
'current location': 'IconCurrentLocation',
|
||||
'directory': 'IconFolder',
|
||||
'disk': 'IconDatabase',
|
||||
'dot circle': 'IconCircleDot',
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit c4362d807977c2719dbe9a870ad86f4c14580431
|
||||
Subproject commit b61bae309cf253e080824ac12eb8b4fb690b8456
|
||||
216
scripts/build_solar_climatology.py
Normal file
216
scripts/build_solar_climatology.py
Normal file
|
|
@ -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('<BBH', VERSION, len(PARAMETERS), 0))
|
||||
apis = set()
|
||||
|
||||
for parameter, offset, step in PARAMETERS:
|
||||
print(f'{parameter}: {len(tiles)} tiles', flush=True)
|
||||
points = {}
|
||||
with ThreadPoolExecutor(max_workers=args.workers) as pool:
|
||||
futures = [pool.submit(fetch_tile, parameter, lat, lon, args.cache, args.pause) for lat, lon in tiles]
|
||||
for done, future in enumerate(as_completed(futures), 1):
|
||||
data = future.result()
|
||||
header = data.get('header')
|
||||
fill = header.get('fill_value', -999) if isinstance(header, dict) else -999
|
||||
if isinstance(header, dict) and 'api' in header:
|
||||
apis.add(f'{header["api"]["name"]} {header["api"]["version"]}')
|
||||
for feature in data['features']:
|
||||
lon, lat = feature['geometry']['coordinates'][:2]
|
||||
monthly = feature['properties']['parameter'][parameter]
|
||||
# Tiles share edges; a point on an edge arrives twice with the same value.
|
||||
points[(lon, lat)] = [None if monthly[m] == fill else monthly[m] for m in MONTHS]
|
||||
if done % 50 == 0:
|
||||
print(f' {done}/{len(tiles)}', flush=True)
|
||||
|
||||
cells = bin_points(points)
|
||||
grid = bytearray([MISSING]) * (12 * ROWS * COLUMNS)
|
||||
for (row, column), months in cells.items():
|
||||
for month, value in enumerate(months):
|
||||
grid[(month * ROWS + row) * COLUMNS + column] = quantize(value, offset, step)
|
||||
missing = grid.count(MISSING)
|
||||
print(f' {len(points)} points into {len(cells)} cells; {missing} missing values')
|
||||
out += grid
|
||||
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_bytes(out)
|
||||
print(f'Wrote {args.output} ({len(out):,} bytes)')
|
||||
|
||||
# The access date is when the newest tile was downloaded, which a resumed build keeps honest.
|
||||
newest = max(tile_path(args.cache, p, lat, lon).stat().st_mtime
|
||||
for p, _, _ in PARAMETERS for lat, lon in tiles)
|
||||
accessed = datetime.date.fromtimestamp(newest)
|
||||
license_path = args.output.with_name(args.output.stem + '.LICENSE.txt')
|
||||
license_path.write_text(notice(' / '.join(sorted(apis)), accessed), encoding='utf-8')
|
||||
print(f'Wrote {license_path}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue