diff --git a/.gitignore b/.gitignore index 655c907f..a3ab9bc5 100644 --- a/.gitignore +++ b/.gitignore @@ -150,3 +150,6 @@ app/cypress/videos release/latest.json perf_results/ scripts/api_perf_hosts.json + +# Tile cache for scripts/build_solar_climatology.py +/scripts/.solar_climatology_cache/ diff --git a/app/public/data/solar-climatology.LICENSE.txt b/app/public/data/solar-climatology.LICENSE.txt new file mode 100644 index 00000000..4290e795 --- /dev/null +++ b/app/public/data/solar-climatology.LICENSE.txt @@ -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. diff --git a/app/public/data/solar-climatology.bin b/app/public/data/solar-climatology.bin new file mode 100644 index 00000000..9a5bcb68 Binary files /dev/null and b/app/public/data/solar-climatology.bin differ diff --git a/app/src/components/Calculators.js b/app/src/components/Calculators.js index 99878cde..da82243f 100644 --- a/app/src/components/Calculators.js +++ b/app/src/components/Calculators.js @@ -14,6 +14,7 @@ import {FoodStorageCalculator} from "./calculators/FoodStorageCalculator"; import {OneTimePadCalculator} from "./calculators/OneTimePadCalculator"; import {VinDecoderCalculator} from "./calculators/VinDecoderCalculator"; import {SpeedTestCalculator} from "./calculators/SpeedTestCalculator"; +import {SolarCalculator} from "./calculators/SolarCalculator"; // Calculators are shown in these titled groups, in this order. Each calculator carries a // `group` matching one of these names; any calculator whose group is missing/unknown falls @@ -35,6 +36,7 @@ export const useCalculators = () => { {key: 'ratio', icon: 'th large', button: 'Ratio', group: 'Engineering', contents: }, {key: 'electrical', icon: 'lightning', button: 'Electrical', group: 'Engineering', contents: }, {key: 'drive', icon: 'cogs', button: 'Drive Ratio', group: 'Engineering', contents: }, + {key: 'solar', icon: 'sun', button: 'Solar', group: 'Engineering', contents: }, {key: 'water', icon: 'tint', button: 'Water Storage', group: 'Preparedness & Storage', contents: }, {key: 'ration', icon: 'food', button: 'Food Storage', group: 'Preparedness & Storage', contents: }, {key: 'antenna', icon: 'signal', button: 'Antenna', group: 'Radio & Communications', contents: }, diff --git a/app/src/components/Map.js b/app/src/components/Map.js index 3c5ed0d5..46b2554d 100644 --- a/app/src/components/Map.js +++ b/app/src/components/Map.js @@ -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) => + + + + + + setNumber('az', v, azimuthDefault)}/> + ({value, label: m.label})), + {value: 'custom', label: 'Custom (from datasheet)'}, + ]} + value={moduleType} allowDeselect={false} + onChange={v => v !== 'custom' && setNumber('gamma', MODULE_TYPES[v].gamma, + MODULE_TYPES.standard.gamma)}/> + + + setNumber('gamma', v, MODULE_TYPES.standard.gamma)}/> + + + ({value, label: t.label}))} + value={s.tracking} allowDeselect={false} + onChange={v => set({track: v === 'fixed' ? null : v})}/> + + + set({alb: v === '' ? null : v})}/> + + + ; +} + +function LossesSection({set, s}) { + const setLoss = (index, value) => { + const next = [...s.losses]; + next[index] = value === '' ? LOSS_CATEGORIES[index].default : Number(value); + const isDefault = next.every((v, i) => v === DEFAULT_LOSSES[i]); + set({losses: isDefault ? null : formatMonthly(next)}); + }; + + return <> + + Real systems lose some power to dirt, wiring, shading and more. These are PVWatts' defaults. Raise Shading + if trees or buildings shade your panels, or Snow if snow sits on them for weeks. + + + {LOSS_CATEGORIES.map((c, i) => + setLoss(i, v)}/> + )} + + + ; +} + +function InverterSection({set, setNumber, s}) { + return <> + + Leave this off if your panels charge batteries through a charge controller. Turn it on to see AC output + from a grid-tie or hybrid inverter. + + set({inv: e.currentTarget.checked ? '1' : null})}/> + {s.inverterOn && + + setNumber('iac', v, roundDigits(s.kwp / 1.2, 2))}/> + + + setNumber('ieff', v, INVERTER_NOMINAL_EFFICIENCY)}/> + + } + ; +} + +function OwnDataSection({set, s, nasa}) { + const unit = s.fahrenheit ? '°F' : '°C'; + + // Start from the NASA values, when available, so the user edits rather than types all 24. + const enable = on => { + if (!on) { + set({ghi: null, temp: null}); + return; + } + const ghi = nasa ? nasa.ghi.map(v => (v === null ? '' : roundDigits(v, 2))) : Array(12).fill(''); + const temp = nasa + ? nasa.temperature.map(v => (v === null ? '' : roundDigits(s.fahrenheit ? cToF(v) : v, 1))) + : Array(12).fill(''); + // An all-blank list is still written for sunlight, so the section stays on for typing. + set({ghi: formatMonthly(ghi), temp: nasa ? formatMonthly(temp) : null}); + }; + + // Convert the temperatures with the unit, so they stay the same physical temperatures. + const switchUnits = toFahrenheit => { + const temps = s.temperatureInput.map(v => (v === null ? null + : roundDigits(toFahrenheit ? cToF(v) : fToC(v), 1))); + set({tu: toFahrenheit ? 'f' : null, temp: temps.some(v => v !== null) ? formatMonthly(temps) : null}); + }; + + const setMonth = (key, values, index, value) => { + const next = [...values]; + next[index] = value === '' ? null : value; + set({[key]: formatMonthly(next)}); + }; + + const rows = MONTH_NAMES.map((name, i) => + {name} + + setMonth('ghi', s.ghi, i, v)}/> + + + setMonth('temp', s.temperatureInput, i, v)}/> + + ); + + return <> + + Only needed if you have better local measurements than the built-in data, for example in mountains, where + a 1° grid cell (about 69 miles across) can average very different terrain. Sunlight is global horizontal + irradiance in kWh/m²/day, the same as "peak sun hours" on flat ground. Blank temperatures use 20 °C. + + + enable(e.currentTarget.checked)}/> + {s.ownData && switchUnits(e.currentTarget.checked)}/>} + + {s.ownData && + + + Month + Sunlight (kWh/m²/day) + Avg. temp ({unit}) + + + {rows} +
} + ; +} + +export function SolarCalculator() { + const {searchParams, get, set, setNumber} = useSolarParams(); + const climatology = useClimatology(); + const queryString = searchParams.toString(); + // eslint-disable-next-line react-hooks/exhaustive-deps + const s = React.useMemo(() => systemFromParams(get), [queryString]); + const nasa = React.useMemo( + () => (climatology.grid && s.latitude !== null && s.longitude !== null + ? lookupClimatology(climatology.grid, s.latitude, s.longitude) : null), + [climatology.grid, s.latitude, s.longitude]); + const sunlight = s.ownData ? sunlightFor(s, null) : nasa; + const missing = missingInputs(s, sunlight); + const ready = missing.length === 0; + const result = React.useMemo( + () => (ready ? estimateProduction(modelInputs(s, sunlight)) : null), + // eslint-disable-next-line react-hooks/exhaustive-deps + [s, sunlight, ready]); + // Off-grid sizing works from DC production per kW of panels, whatever the inverter setting. + const worstPerKwp = React.useMemo( + () => (ready ? estimateProduction({...modelInputs(s, sunlight), kwp: 1, inverter: null}).worst : null), + // eslint-disable-next-line react-hooks/exhaustive-deps + [s, sunlight, ready]); + const tab = get('tab') === 'offgrid' ? 'offgrid' : 'estimate'; + + const angleTitle = s.tracking === 'fixed' + ? `Panel angle: ${s.tilt}° tilt, facing ${compassLabel(s.azimuth)}` + : 'Panel angle: follows the sun'; + const detailsTitle = `Panel details: ${TRACKING[s.tracking].label.toLowerCase()}, ` + + `${MOUNTINGS[s.mounting].label.toLowerCase()}`; + const waitingForData = climatology.loading && !s.ownData; + + return
+
Solar
+

+ Estimate what solar panels will produce over a typical year. Enter a location and your panels; everything + else has sensible defaults. Settings are saved in the page address, so you can bookmark or share them. +

+ + + + + set({tab: v === 'offgrid' ? v : null})} keepMounted={false} + style={{marginTop: '1.5em'}}> + + Estimate + Off-grid sizing + + + {ready && } + {!ready && !waitingForData &&
+ Enter {missing.join(', ')} to see an estimate. +
} +
+ + + +
+ +
Fine-tune (optional)
+ + + {angleTitle} + + + + + + {detailsTitle} + + + + System losses: {fmt(totalLoss(s.losses))}% + + + + Inverter: {s.inverterOn ? 'on (AC output)' : 'off (DC output)'} + + + + Use my own sunlight data: {s.ownData ? 'on' : 'off'} + + + + + +
; +} diff --git a/app/src/components/calculators/SolarCalculator.test.js b/app/src/components/calculators/SolarCalculator.test.js new file mode 100644 index 00000000..74f8799f --- /dev/null +++ b/app/src/components/calculators/SolarCalculator.test.js @@ -0,0 +1,256 @@ +import React from "react"; +import {fireEvent, screen, within} from "@testing-library/react"; +import {renderWithProviders} from "../../test-utils"; +import {missingInputs, SolarCalculator, sunlightFor, systemFromParams} from "./SolarCalculator"; +import {CalculatorsPage} from "../Calculators"; +import {DEFAULT_LOSSES, estimateProduction, MODULE_TYPES} from "./solar"; +import {decodeClimatology, forgetClimatology, lookupClimatology} from "./solarData"; +import {mockClimatologyFetch, SUNNY_GHI, uniformClimatology} from "./solarData.fixtures"; +import {roundDigits} from "../Common"; + +const BASE = '/more/calculators?calc=solar'; +const DENVER = `${BASE}&lat=40&lon=-105`; + +const params = query => { + const searchParams = new URLSearchParams(query); + return key => searchParams.get(key); +}; + +const currentParams = () => new URLSearchParams(window.location.search); + +// The estimate appears once the data file has loaded and decoded, which can be slow on a busy machine. +const findEstimate = (unit = 'DC') => screen.findByText(`kWh ${unit} per year`, {}, {timeout: 5000}); + +// The annual estimate the page should show for a location, from the same uniform test data. +const expectedAnnual = (overrides = {}) => { + const grid = decodeClimatology(uniformClimatology()); + const sunlight = lookupClimatology(grid, 40, -105); + return estimateProduction({latitude: 40, kwp: 0.4, tilt: 40, azimuth: 180, ...sunlight, ...overrides}).annual; +}; + +beforeEach(() => { + forgetClimatology(); + mockClimatologyFetch(); +}); + +describe('systemFromParams', () => { + test('defaults when the URL is empty', () => { + const s = systemFromParams(params('')); + expect(s.latitude).toBeNull(); + expect(s.kwp).toBeCloseTo(0.4, 6); + expect(s.tracking).toBe('fixed'); + expect(s.mounting).toBe('open'); + expect(s.gamma).toBe(MODULE_TYPES.standard.gamma); + expect(s.losses).toEqual(DEFAULT_LOSSES); + expect(s.inverterOn).toBe(false); + expect(s.ownData).toBe(false); + expect(s.albedo).toBeNull(); + }); + + test('tilt and azimuth default from the latitude', () => { + expect(systemFromParams(params('lat=40.4')).tilt).toBe(40); + expect(systemFromParams(params('lat=40.4')).azimuth).toBe(180); + expect(systemFromParams(params('lat=-33.9')).tilt).toBe(34); + expect(systemFromParams(params('lat=-33.9')).azimuth).toBe(0); + }); + + test('invalid values fall back instead of crashing', () => { + const s = systemFromParams(params('lat=999&track=bogus&mount=x&tilt=abc&losses=1,2,abc&alb=7&pn=-3')); + expect(s.latitude).toBeNull(); + expect(s.tracking).toBe('fixed'); + expect(s.mounting).toBe('open'); + expect(s.tilt).toBe(0); + expect(s.losses.slice(0, 3)).toEqual([1, 2, DEFAULT_LOSSES[2]]); + expect(s.albedo).toBe(1); + expect(s.kwp).toBe(0); + }); + + test('own Fahrenheit temperatures are converted to Celsius', () => { + const s = systemFromParams(params('ghi=5&tu=f&temp=32,212')); + expect(s.ownData).toBe(true); + expect(s.temperature[0]).toBeCloseTo(0, 6); + expect(s.temperature[1]).toBeCloseTo(100, 6); + expect(s.temperatureInput[0]).toBe(32); + }); +}); + +describe('sunlight and missing inputs', () => { + const grid = decodeClimatology(uniformClimatology()); + + test('the NASA data is used unless the user gave their own', () => { + const nasa = sunlightFor(systemFromParams(params('lat=40&lon=-105')), grid); + expect(nasa.ghi[5]).toBeCloseTo(SUNNY_GHI[5], 1); + expect(nasa.diffuse[5]).toBeCloseTo(SUNNY_GHI[5] * 0.3, 1); + + const own = sunlightFor(systemFromParams(params(`lat=40&lon=-105&ghi=${Array(12).fill(1).join(',')}`)), grid); + expect(own.ghi).toEqual(Array(12).fill(1)); + expect(own.diffuse).toBeNull(); + }); + + test('a location is enough with the NASA data', () => { + const s = systemFromParams(params('lat=40&lon=-105')); + expect(missingInputs(s, sunlightFor(s, grid))).toEqual([]); + const noLon = systemFromParams(params('lat=40')); + expect(missingInputs(noLon, sunlightFor(noLon, grid))).toEqual(['a longitude']); + }); + + test('own data needs all 12 months but no longitude', () => { + const s = systemFromParams(params('lat=40&ghi=1,2,3')); + expect(missingInputs(s, sunlightFor(s, grid))).toEqual(['sunlight for all 12 months']); + }); +}); + +describe('SolarCalculator', () => { + test('is linked from the Engineering group of the calculators page', () => { + renderWithProviders(, {route: '/more/calculators'}); + const engineering = screen.getByRole('heading', {name: 'Engineering'}).closest('.wrolpi-header').parentElement; + const link = within(engineering).getByRole('link', {name: /Solar/}); + expect(link).toHaveAttribute('href', '/more/calculators?calc=solar'); + }); + + test('asks for a location', async () => { + renderWithProviders(, {route: BASE}); + expect(await screen.findByText('Enter a latitude, a longitude to see an estimate.', {}, {timeout: 5000})) + .toBeInTheDocument(); + }); + + test('a location alone gives an estimate from the built-in data', async () => { + renderWithProviders(, {route: DENVER}); + expect(await findEstimate()).toBeInTheDocument(); + expect(screen.getByText(`${roundDigits(expectedAnnual(), 0)}`)).toBeInTheDocument(); + // The attribution is only at the bottom of the page. + expect(screen.queryByText(/Sunlight and temperature/)).not.toBeInTheDocument(); + expect(global.fetch).toHaveBeenCalledWith(expect.stringMatching(/\/data\/solar-climatology\.bin$/)); + }); + + test('the data attribution is at the bottom of the page', async () => { + renderWithProviders(, {route: DENVER}); + await findEstimate(); + const heading = screen.getByRole('heading', {name: 'Data attribution'}); + const section = screen.getByRole('region', {name: 'Data attribution'}); + expect(within(section).getByText(/Prediction Of Worldwide Energy Resources/)).toBeInTheDocument(); + expect(within(section).getByText(/WROLPi modified the data/)).toBeInTheDocument(); + expect(within(section).getByText(/CC BY 4\.0/)).toBeInTheDocument(); + // After everything else, including the last Fine-tune section. + const lastControl = screen.getByText('Use my own sunlight data: off'); + expect(lastControl.compareDocumentPosition(heading) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + test('typing writes the URL and leaves defaults out', async () => { + renderWithProviders(, {route: BASE}); + fireEvent.change(screen.getByLabelText('Latitude'), {target: {value: '40°0\'54"N'}}); + expect(currentParams().get('lat')).toBe('40°0\'54"N'); + expect(currentParams().get('calc')).toBe('solar'); + + fireEvent.change(screen.getByLabelText('Panels'), {target: {value: '4'}}); + expect(currentParams().get('pn')).toBe('4'); + fireEvent.change(screen.getByLabelText('Panels'), {target: {value: '1'}}); + expect(currentParams().has('pn')).toBe(false); + }); + + describe('use my current location', () => { + const withGeolocation = getCurrentPosition => { + Object.defineProperty(window, 'isSecureContext', {value: true, configurable: true}); + Object.defineProperty(navigator, 'geolocation', {value: {getCurrentPosition}, configurable: true}); + }; + + afterEach(() => { + delete window.isSecureContext; + delete navigator.geolocation; + }); + + test('fills in the location, rounded to 4 decimals', async () => { + withGeolocation(success => success({coords: {latitude: 39.7392358, longitude: -104.990251}})); + renderWithProviders(, {route: BASE}); + fireEvent.click(screen.getByRole('button', {name: 'Use my current location'})); + expect(currentParams().get('lat')).toBe('39.7392'); + expect(currentParams().get('lon')).toBe('-104.9903'); + expect(screen.getByLabelText('Latitude')).toHaveValue('39.7392'); + expect(await findEstimate()).toBeInTheDocument(); + }); + + test('explains a denied permission', () => { + withGeolocation((success, failure) => failure({code: 1, message: 'User denied Geolocation'})); + renderWithProviders(, {route: BASE}); + fireEvent.click(screen.getByRole('button', {name: 'Use my current location'})); + expect(screen.getByText('Could not get your location')).toBeInTheDocument(); + expect(screen.getByText(/Location permission was denied/)).toBeInTheDocument(); + expect(currentParams().has('lat')).toBe(false); + }); + + test('is disabled without HTTPS', () => { + Object.defineProperty(window, 'isSecureContext', {value: false, configurable: true}); + renderWithProviders(, {route: BASE}); + expect(screen.getByRole('button', {name: 'Use my current location'})).toBeDisabled(); + }); + }); + + test('an invalid latitude is flagged', () => { + renderWithProviders(, {route: `${BASE}&lat=95`}); + expect(screen.getByText('Latitude must be between -90 and 90')).toBeInTheDocument(); + }); + + test('the inverter switches the results to AC', async () => { + renderWithProviders(, {route: DENVER}); + await findEstimate(); + fireEvent.click(screen.getByText('Inverter: off (DC output)')); + fireEvent.click(screen.getByLabelText('Include an inverter (AC output)')); + expect(currentParams().get('inv')).toBe('1'); + expect(screen.getByText('kWh AC per year')).toBeInTheDocument(); + }); + + test('best-for-the-year replaces a poor tilt', async () => { + renderWithProviders(, {route: `${DENVER}&tilt=10`}); + await findEstimate(); + expect(screen.getByText('Panel angle: 10° tilt, facing S')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Panel angle: 10° tilt, facing S')); + fireEvent.click(await screen.findByRole('button', {name: 'Best for the year'})); + // A result of 40 equals the default (the latitude), so it is left out of the URL. + const tilt = Number(currentParams().get('tilt') ?? 40); + expect(tilt).toBeGreaterThanOrEqual(30); + expect(tilt).toBeLessThanOrEqual(44); + }); + + test('own sunlight data starts from the built-in values and replaces them', async () => { + renderWithProviders(, {route: DENVER}); + await findEstimate(); + fireEvent.click(screen.getByText('Use my own sunlight data: off')); + fireEvent.click(screen.getByLabelText('Use my own sunlight data')); + + const ghi = currentParams().get('ghi').split(',').map(Number); + ghi.forEach((v, i) => expect(v).toBeCloseTo(SUNNY_GHI[i], 1)); + expect(screen.getByText('Using your own sunlight data.')).toBeInTheDocument(); + expect(screen.getByLabelText('Jun sunlight')).toHaveValue(`${ghi[5]}`); + + // Halving June's sunlight lowers the estimate. + fireEvent.change(screen.getByLabelText('Jun sunlight'), {target: {value: `${ghi[5] / 2}`}}); + expect(currentParams().get('ghi').split(',')[5]).toBe(`${ghi[5] / 2}`); + + fireEvent.click(screen.getByLabelText('Use my own sunlight data')); + expect(currentParams().has('ghi')).toBe(false); + expect(currentParams().has('temp')).toBe(false); + }); + + test('switching to Fahrenheit converts your temperatures instead of relabeling them', async () => { + const temps = '0,10,,,,,,,,,,'; + renderWithProviders(, + {route: `${DENVER}&ghi=${SUNNY_GHI.join(',')}&temp=${temps}`}); + await findEstimate(); + fireEvent.click(screen.getByText('Use my own sunlight data: on')); + fireEvent.click(screen.getByLabelText('Fahrenheit')); + expect(currentParams().get('tu')).toBe('f'); + expect(currentParams().get('temp')).toBe('32,50,,,,,,,,,,'); + expect(screen.getByLabelText('Jan temperature')).toHaveValue('32'); + fireEvent.click(screen.getByLabelText('Fahrenheit')); + expect(currentParams().has('tu')).toBe(false); + expect(currentParams().get('temp')).toBe(temps); + }); + + test('a failed download explains how to continue', async () => { + forgetClimatology(); + global.fetch = jest.fn(() => Promise.resolve({ok: false, status: 404})); + renderWithProviders(, {route: DENVER}); + expect(await screen.findByText('Sunlight data could not be loaded', {}, {timeout: 5000})).toBeInTheDocument(); + expect(screen.getByText('Enter a location with sunlight data to see an estimate.')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/calculators/SolarOffGrid.js b/app/src/components/calculators/SolarOffGrid.js new file mode 100644 index 00000000..4f6c2202 --- /dev/null +++ b/app/src/components/calculators/SolarOffGrid.js @@ -0,0 +1,350 @@ +import React from "react"; +import {Button, Grid, Group, Header, IconButton, Loader, Message, NumberInput, Panel, Select, Statistic, + StatisticGroup, Table, TextInput, Toggle} from "../ui"; +import {roundDigits} from "../Common"; +import { + BATTERIES, + batteryWarnings, + CONTROLLERS, + controllerSizing, + dailyLoadWh, + defaultColdest, + formatLoads, + leadAcidCapacityFactor, + loadWh, + parseLoads, + requiredKwp, + sizeBattery, + SYSTEM_VOLTAGES, +} from "./offgrid"; + +// Defaults are typical of a 400 W residential panel; the user should copy their panel's label. +const PANEL_DEFAULTS = {voc: 37, vmp: 31, isc: 13.8, bvoc: -0.27}; +const DEFAULT_LOAD_INVERTER_EFFICIENCY = 90; +const DEFAULT_DAYS = 2; +const DEFAULT_CONTROLLER_MAX_VOLTAGE = 100; +// Above this, a higher system voltage needs a much smaller controller and thinner wire. +const HIGH_CURRENT = 60; + +const numberOr = (value, fallback) => { + if (value === null || value === undefined || value === '') { + return fallback; + } + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +}; + +const fmt = (value, digits = 0) => (Number.isFinite(value) ? `${roundDigits(value, digits)}` : '—'); + +const Hint = ({children}) =>

{children}

; + +// The off-grid inputs, read from the URL like the rest of the calculator. Invalid values fall back. +export function offGridFromParams(get, monthlyTemperatures) { + const battery = BATTERIES[get('bat')] ? get('bat') : 'lfp'; + const systemVoltage = SYSTEM_VOLTAGES.includes(Number(get('sv'))) ? Number(get('sv')) : 12; + const controller = CONTROLLERS[get('ctl')] ? get('ctl') : 'mppt'; + const coldestMonthly = (monthlyTemperatures || []).filter(Number.isFinite).length + ? Math.min(...monthlyTemperatures.filter(Number.isFinite)) : null; + const defaultTmin = defaultColdest(monthlyTemperatures); + return { + loads: parseLoads(get('load')), + acLoads: get('ac') !== '0', + loadInverterEfficiency: numberOr(get('leff'), DEFAULT_LOAD_INVERTER_EFFICIENCY), + battery, + systemVoltage, + dod: numberOr(get('dod'), BATTERIES[battery].dod), + days: Math.max(0, numberOr(get('aut'), DEFAULT_DAYS)), + outdoors: get('bout') === '1', + controller, + voc: numberOr(get('voc'), PANEL_DEFAULTS.voc), + vmp: numberOr(get('vmp'), PANEL_DEFAULTS.vmp), + isc: numberOr(get('isc'), PANEL_DEFAULTS.isc), + // Always negative for silicon panels; a missing minus sign must not hide the cold-weather rise. + betaVoc: -Math.abs(numberOr(get('bvoc'), PANEL_DEFAULTS.bvoc)), + series: Math.max(1, Math.round(numberOr(get('ns'), 1))), + controllerMaxVoltage: numberOr(get('cmax'), DEFAULT_CONTROLLER_MAX_VOLTAGE), + coldestMonthly, + defaultTmin, + coldest: numberOr(get('tmin'), defaultTmin), + }; +} + +/** + * Everything the off-grid tab shows, from its inputs. + * + * @param {Object} o offGridFromParams() + * @param {Object} worst the worst month for a 1 kW array: {month, energyDaily} + * @param {number} panelWatts + * @param {number} panelCount + */ +export function offGridResults(o, worst, panelWatts, panelCount) { + const devicesWh = dailyLoadWh(o.loads); + // AC devices draw through an inverter, so the batteries supply more than the devices use. + const batteryLoadWh = o.acLoads && o.loadInverterEfficiency > 0 + ? devicesWh / (o.loadInverterEfficiency / 100) : devicesWh; + const capacityFactor = o.outdoors && o.battery === 'lead' ? leadAcidCapacityFactor(o.coldestMonthly) : 1; + const bank = sizeBattery({ + loadWh: batteryLoadWh, days: o.days, dod: o.dod, voltage: o.systemVoltage, capacityFactor, + }); + + const sizing = controllerSizing({ + panelCount, panelWatts, series: o.series, voc: o.voc, vmp: o.vmp, isc: o.isc, betaVoc: o.betaVoc, + coldest: o.coldest, controller: o.controller, controllerMaxVoltage: o.controllerMaxVoltage, + systemVoltage: o.systemVoltage, + }); + + // A PWM controller wastes part of every panel's output, so more panels are needed. + const worstPerKwp = worst ? worst.energyDaily * sizing.pwmFraction : null; + const kwp = requiredKwp(batteryLoadWh, worstPerKwp, BATTERIES[o.battery].efficiency); + const panelsNeeded = kwp && panelWatts > 0 ? Math.ceil(kwp * 1000 / panelWatts) : null; + const yourArrayWh = worstPerKwp !== null + ? worstPerKwp * (panelCount * panelWatts / 1000) * 1000 * BATTERIES[o.battery].efficiency / 100 : null; + + return { + devicesWh, batteryLoadWh, bank, kwp, panelsNeeded, sizing, + coverage: batteryLoadWh > 0 && yourArrayWh !== null ? yourArrayWh / batteryLoadWh : null, + warnings: batteryWarnings({battery: o.battery, outdoors: o.outdoors, coldestMonthly: o.coldestMonthly}), + }; +} + +function LoadsSection({o, r, set, setNumber}) { + const rows = o.loads.length ? o.loads : [{name: '', watts: '', hours: ''}]; + const write = next => set({load: next.some(row => row.name || row.watts || row.hours) ? formatLoads(next) : null}); + const update = (index, field, value) => write(rows.map((row, i) => (i === index ? {...row, [field]: value} : row))); + const remove = index => write(rows.filter((_, i) => i !== index)); + + return <> +
Your devices
+ + List what you will run and for how long each day. The watts are on the device's label or power supply; + a refrigerator cycles on and off, so use about a third of its rating for 24 hours. + + + + + Device + Watts + Hours per day + Wh per day + + + + + {rows.map((row, i) => + + update(i, 'name', e.target.value)}/> + + + update(i, 'watts', v)}/> + + + update(i, 'hours', v)}/> + + {fmt(loadWh(row))} + + remove(i)}/> + + )} + +
+ + + {fmt(r.devicesWh)} Wh per day + + + set({ac: e.currentTarget.checked ? null : '0'})}/> + {o.acLoads && setNumber('leff', v, DEFAULT_LOAD_INVERTER_EFFICIENCY)}/>} + + ; +} + +function BatterySection({o, set, setNumber}) { + return <> +
Batteries
+ + Days without sun is how long the batteries must carry your devices through cloudy weather. Two or three + days is typical. Larger systems (over about 1,500 W of panels) usually use 24 or 48 volts. + + + + ({value: `${v}`, label: `${v} V`}))} + value={`${o.systemVoltage}`} allowDeselect={false} + onChange={v => set({sv: v === '12' ? null : v})}/> + + + setNumber('aut', v, DEFAULT_DAYS)}/> + + + setNumber('dod', v, BATTERIES[o.battery].dod)}/> + + + set({bout: e.currentTarget.checked ? '1' : null})}/> + + + ; +} + +function NeedsSection({o, r, worst, s, setNumber}) { + if (!(r.devicesWh > 0)) { + return
+ List your devices above to size the system. +
; + } + const coverage = r.coverage; + return +
What you need
+ + + + + + +

+ Sized for {worst.month}, your darkest month, when each kW of panels makes about{' '} + {fmt(worst.energyDaily, 1)} kWh a day at the angle chosen under Fine-tune. + {o.controller === 'pwm' && r.sizing.pwmFraction > 0 && ` The PWM controller uses only ` + + `${fmt(r.sizing.pwmFraction * 100)}% of the panels' power, so more panels are needed.`} + {o.controller === 'pwm' && r.sizing.pwmFraction === 0 && ' These panels cannot charge this battery ' + + 'through a PWM controller; see the warning below.'} +

+ {coverage !== null &&

+ Your {s.panelCount} panel{s.panelCount === 1 ? '' : 's'} would supply about{' '} + {fmt(Math.min(coverage, 9.99) * 100)}% of your use in {worst.month}.{' '} + {r.panelsNeeded !== s.panelCount && r.panelsNeeded > 0 && + } +

} + {r.warnings.map(w => {w})} +
; +} + +function ControllerSection({o, r, set, setNumber, temperaturesKnown}) { + const z = r.sizing; + const panelField = (key, label, value, fallback, step, max) => + setNumber(key, v, fallback)}/> + ; + + return <> +
Charge controller and wiring
+ + Copy these from the label on the back of your panel. Panels make a higher voltage when cold; too many in + series can exceed the controller's limit on a cold morning and destroy it. The coldest morning starts + 25 °C below your coldest monthly average, because averages hide cold snaps; enter your area's record + low if you know it. + + + {panelField('voc', 'Open-circuit voltage, Voc (V)', o.voc, PANEL_DEFAULTS.voc, 0.1)} + {panelField('vmp', 'Max-power voltage, Vmp (V)', o.vmp, PANEL_DEFAULTS.vmp, 0.1)} + {panelField('isc', 'Short-circuit current, Isc (A)', o.isc, PANEL_DEFAULTS.isc, 0.1)} + {panelField('bvoc', 'Voc temperature coefficient (%/°C)', o.betaVoc, PANEL_DEFAULTS.bvoc, 0.01, 0)} + +