feat(forecast): project irradiance onto each PV array's plane - #718
Conversation
|
This has a merge conflict against Not touching the branch myself: it is yours, and rebasing someone else's open work is how two people end up building the same thing twice. Worth saying while you are in there: at +1747/-26 across 20 files this is large for one review, and it will keep conflicting while it waits. The description already splits cleanly in two — the POA-per-array wiring is live and user-visible, the STRÅNG client is tested but not wired to anything. The first half improves every radiation-bearing provider today and could go in on its own; the second half has no user-visible effect yet and can follow. Two PRs would land faster than one. For anyone else reading: the RFC is Discussion #717, not an issue. |
1d756db to
3229d3a
Compare
Radiation-bearing providers previously produced one flat estimate, rated x (W/m2 / 1000), which ignored panel orientation entirely: a south-facing 35 deg roof and a flat one got the same forecast. When per-plane geometry is configured (Weather tab: tilt/azimuth/kWp), global horizontal irradiance is now projected onto each plane via the existing sunpos physics and summed. Providers publishing only GHI get an Erbs correlation to split direct from diffuse first. Sites with no arrays keep the previous behaviour, and Forecast.Solar - which already returns site-calibrated watts - is deliberately left untouched so its numbers are not scaled twice. Split out of the original srcfl#718 per review: this half is user-visible and improves every radiation-bearing provider today. The STRANG client and performance scoring that shared that branch move to a follow-up PR. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com>
3229d3a to
13bad76
Compare
|
Rebased onto This PR is now the POA-per-array wiring only: +341/-22 across 5 files, down from +1747/-26 across 20. It is the half that improves every radiation-bearing provider today, with no new config, endpoint or dependency. The STRÅNG client and the scoring package have been lifted out entirely — they are not in this diff. They move to a follow-up that actually wires STRÅNG as an irradiance source rather than leaving it unreferenced. Two things worth recording from the rebase, both of which turned out to be trivially additive: master's On the follow-up: I checked whether STRÅNG can also serve cloud cover, since that would let it back a forward path too. It cannot — the model publishes exactly seven parameters (116-122) and all are radiation quantities. Confirmed against the live API rather than the docs, which 404. Cloud cover is derivable though, and I verified the identity that makes it cheap: at solar noon params 121 + 122 = 723.0 + 87.5 = 810.5, exactly param 117, so the direct/diffuse split is internally consistent. Param 119 is sunshine duration capped at 60 min/h, which is a direct observational proxy for cloudiness. |
Radiation-bearing providers previously produced one flat estimate, rated x (W/m2 / 1000), which ignored panel orientation entirely: a south-facing 35 deg roof and a flat one got the same forecast. When per-plane geometry is configured (Weather tab: tilt/azimuth/kWp), global horizontal irradiance is now projected onto each plane via the existing sunpos physics and summed. Providers publishing only GHI get an Erbs correlation to split direct from diffuse first. Sites with no arrays keep the previous behaviour, and Forecast.Solar - which already returns site-calibrated watts - is deliberately left untouched so its numbers are not scaled twice. Split out of the original srcfl#718 per review: this half is user-visible and improves every radiation-bearing provider today. The STRANG client and performance scoring that shared that branch move to a follow-up PR. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com>
13bad76 to
a089ebc
Compare
Radiation-bearing providers previously produced one flat estimate, rated x (W/m2 / 1000), which ignored panel orientation entirely: a south-facing 35 deg roof and a flat one got the same forecast. When per-plane geometry is configured (Weather tab: tilt/azimuth/kWp), global horizontal irradiance is now projected onto each plane via the existing sunpos physics and summed. Providers publishing only GHI get an Erbs correlation to split direct from diffuse first. Sites with no arrays keep the previous behaviour, and Forecast.Solar - which already returns site-calibrated watts - is deliberately left untouched so its numbers are not scaled twice. Split out of the original srcfl#718 per review: this half is user-visible and improves every radiation-bearing provider today. The STRANG client and performance scoring that shared that branch move to a follow-up PR. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com>
a089ebc to
1343ce8
Compare
Radiation-bearing providers previously produced one flat estimate, rated x (W/m2 / 1000), which ignored panel orientation entirely: a south-facing 35 deg roof and a flat one got the same forecast. When per-plane geometry is configured (Weather tab: tilt/azimuth/kWp), global horizontal irradiance is now projected onto each plane via the existing sunpos physics and summed. Providers publishing only GHI get an Erbs correlation to split direct from diffuse first. Sites with no arrays keep the previous behaviour, and Forecast.Solar - which already returns site-calibrated watts - is deliberately left untouched so its numbers are not scaled twice. Split out of the original srcfl#718 per review: this half is user-visible and improves every radiation-bearing provider today. The STRANG client and performance scoring that shared that branch move to a follow-up PR. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com>
1343ce8 to
5e5c8d9
Compare
Radiation-bearing providers previously produced one flat estimate, rated x (W/m2 / 1000), which ignored panel orientation entirely: a south-facing 35 deg roof and a flat one got the same forecast. When per-plane geometry is configured (Weather tab: tilt/azimuth/kWp), global horizontal irradiance is now projected onto each plane via the existing sunpos physics and summed. Providers publishing only GHI get an Erbs correlation to split direct from diffuse first. Sites with no arrays keep the previous behaviour, and Forecast.Solar - which already returns site-calibrated watts - is deliberately left untouched so its numbers are not scaled twice. Split out of the original srcfl#718 per review: this half is user-visible and improves every radiation-bearing provider today. The STRANG client and performance scoring that shared that branch move to a follow-up PR. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com>
5e5c8d9 to
bdf1adb
Compare
Radiation-bearing providers previously produced one flat estimate, rated x (W/m2 / 1000), which ignored panel orientation entirely: a south-facing 35 deg roof and a flat one got the same forecast. When per-plane geometry is configured (Weather tab: tilt/azimuth/kWp), global horizontal irradiance is now projected onto each plane via the existing sunpos physics and summed. Providers publishing only GHI get an Erbs correlation to split direct from diffuse first. Sites with no arrays keep the previous behaviour, and Forecast.Solar - which already returns site-calibrated watts - is deliberately left untouched so its numbers are not scaled twice. Split out of the original srcfl#718 per review: this half is user-visible and improves every radiation-bearing provider today. The STRANG client and performance scoring that shared that branch move to a follow-up PR. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com>
bdf1adb to
f7c77e3
Compare
Radiation-bearing providers previously produced one flat estimate, rated x (W/m2 / 1000), which ignored panel orientation entirely: a south-facing 35 deg roof and a flat one got the same forecast. When per-plane geometry is configured (Weather tab: tilt/azimuth/kWp), global horizontal irradiance is now projected onto each plane via the existing sunpos physics and summed. Providers publishing only GHI get an Erbs correlation to split direct from diffuse first. Sites with no arrays keep the previous behaviour, and Forecast.Solar - which already returns site-calibrated watts - is deliberately left untouched so its numbers are not scaled twice. Split out of the original srcfl#718 per review: this half is user-visible and improves every radiation-bearing provider today. The STRANG client and performance scoring that shared that branch move to a follow-up PR. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com>
f7c77e3 to
6ab9da8
Compare
Radiation-bearing providers previously produced one flat estimate, rated x (W/m2 / 1000), which ignored panel orientation entirely: a south-facing 35 deg roof and a flat one got the same forecast. When per-plane geometry is configured (Weather tab: tilt/azimuth/kWp), global horizontal irradiance is now projected onto each plane via the existing sunpos physics and summed. Providers publishing only GHI get an Erbs correlation to split direct from diffuse first. Sites with no arrays keep the previous behaviour, and Forecast.Solar - which already returns site-calibrated watts - is deliberately left untouched so its numbers are not scaled twice. Split out of the original srcfl#718 per review: this half is user-visible and improves every radiation-bearing provider today. The STRANG client and performance scoring that shared that branch move to a follow-up PR. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com>
6ab9da8 to
55c2c64
Compare
Radiation-bearing providers previously produced one flat estimate, rated x (W/m2 / 1000), which ignored panel orientation entirely: a south-facing 35 deg roof and a flat one got the same forecast. When per-plane geometry is configured (Weather tab: tilt/azimuth/kWp), global horizontal irradiance is now projected onto each plane via the existing sunpos physics and summed. Providers publishing only GHI get an Erbs correlation to split direct from diffuse first. Sites with no arrays keep the previous behaviour, and Forecast.Solar - which already returns site-calibrated watts - is deliberately left untouched so its numbers are not scaled twice. Split out of the original srcfl#718 per review: this half is user-visible and improves every radiation-bearing provider today. The STRANG client and performance scoring that shared that branch move to a follow-up PR. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com>
55c2c64 to
ecdeb87
Compare
Radiation-bearing providers previously produced one flat estimate, rated x (W/m2 / 1000), which ignored panel orientation entirely: a south-facing 35 deg roof and a flat one got the same forecast. When per-plane geometry is configured (Weather tab: tilt/azimuth/kWp), global horizontal irradiance is now projected onto each plane via the existing sunpos physics and summed. Providers publishing only GHI get an Erbs correlation to split direct from diffuse first. Sites with no arrays keep the previous behaviour, and Forecast.Solar - which already returns site-calibrated watts - is deliberately left untouched so its numbers are not scaled twice. Split out of the original srcfl#718 per review: this half is user-visible and improves every radiation-bearing provider today. The STRANG client and performance scoring that shared that branch move to a follow-up PR. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com>
ecdeb87 to
6e49b38
Compare
Radiation-bearing providers previously produced one flat estimate, rated x (W/m2 / 1000), which ignored panel orientation entirely: a south-facing 35 deg roof and a flat one got the same forecast. When per-plane geometry is configured (Weather tab: tilt/azimuth/kWp), global horizontal irradiance is now projected onto each plane via the existing sunpos physics and summed. Providers publishing only GHI get an Erbs correlation to split direct from diffuse first. Sites with no arrays keep the previous behaviour, and Forecast.Solar - which already returns site-calibrated watts - is deliberately left untouched so its numbers are not scaled twice. Split out of the original srcfl#718 per review: this half is user-visible and improves every radiation-bearing provider today. The STRANG client and performance scoring that shared that branch move to a follow-up PR. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com>
6e49b38 to
2bd8eca
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2bd8eca94b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| pvW = s.RatedPVW * (*r.SolarWm2) / 1000.0 | ||
| case solarWm2 != nil: | ||
| var ok bool | ||
| pvW, ok = pvWFromGHI(s.Lat, s.Lon, r.HourStart, *solarWm2, s.RatedPVW, s.Arrays) |
There was a problem hiding this comment.
Project hourly GHI at the interval midpoint
When per-array geometry is enabled with Open-Meteo, this passes the provider's hour label directly to the instantaneous solar-position model. Open-Meteo's shortwave_radiation value is an average over the preceding hour, so near sunrise and sunset the projection uses the wrong elevation and azimuth; a nonzero interval ending just after sunset can even become zero because POAFromGHI sees the sun below the horizon. Preserve the radiation interval in RawForecast and project at its midpoint (and store it under the matching slot) rather than treating the label as an instantaneous HourStart.
Useful? React with 👍 / 👎.
| kt := 0.0 | ||
| if i0h > 0 { | ||
| kt = ghi / i0h | ||
| } | ||
| dhi := ghi * ErbsDiffuseFraction(kt) |
There was a problem hiding this comment.
Bound the beam estimate at low solar elevation
When forecast GHI exceeds the instantaneous extraterrestrial-horizontal value—readily possible for an hourly average near sunrise or sunset—kt exceeds 1, Erbs fixes DHI at only 16.5% of GHI, and POAFromComponents divides the remaining 83.5% by a very small cosZ. For example, 100 W/m² at zenith 89° can become roughly 4.8 kW/m² on a sun-facing plane, yielding a many-times-nameplate PV forecast that can distort planner decisions. Cap the inferred beam/DNI to a physical bound or compute the clearness index from interval-averaged extraterrestrial irradiation before projecting it.
Useful? React with 👍 / 👎.
…coverage STRANG becomes a real irradiance source rather than an unreferenced client, every external data source now says where in the world it works, and the location picker moves off Leaflet. STRANG's parameter set was mapped against the live API because SMHI's apidocs pages 404: exactly 116-122 exist. Identification was confirmed by physics rather than by guessing - at solar noon 121 + 122 = 723.0 + 87.5 = 810.5, exactly parameter 117, and 119 caps at 60, i.e. minutes within the hour. STRANG publishes no cloud cover; it is a radiation model. Cloudiness is instead derived from sunshine duration as 1 - minutes/60, which is observed rather than inferred but coarser: blind to thin cirrus, undefined at night. CloudCover() therefore returns an explicit unknown instead of defaulting to clear, because those two lead to opposite decisions. The new coverage registry makes an existing silence explicit. STRANG is Nordic-only and every price provider is European, so sites elsewhere were getting empty results with no explanation (srcfl#726). GET /api/data-sources now reports area, countries, licence and whether each source reaches this site, and the Weather tab renders it under the map. Bounds are advisory: STRANG's grid is rotated, so a lat/lon box can only ever be a superset - all four in-box corners were probed and returned no data. False is definitive, true means worth trying. Scoring now declines to start outside the domain instead of retrying nightly forever. Stacked on srcfl#718, which carries the plane-of-array wiring this builds on. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com>
Projects forecast irradiance onto each PV array's plane instead of assuming a
flat panel.
Radiation-bearing providers previously produced one estimate,
rated × (W/m² / 1000), which ignores orientation entirely — a south-facing 35°roof and a flat one received the same forecast. When per-plane geometry is
configured (the Weather tab's PV arrays: tilt/azimuth/kWp), that GHI is now
projected onto each plane via the
sunposphysics already in the tree andsummed.
This is the first half of the original #718, split per @frahlg's review. It is
the user-visible half: it improves every radiation-bearing provider today, with
no new endpoint and no new dependency.
What changed
sunpos—POAFromComponents(sun, ghi, dhi, tilt, az)extracted so plane-of-arrayirradiance can be driven by measured components rather than only the built-in
clear-sky model.
POAnow delegates to it, so existing behaviour is unchanged.sunpos.POAFromGHI— Erbs correlation splitting GHI into direct + diffuse, forproviders that publish only global horizontal.
forecast—Service.Arrays, filled fromweather.pv_arrays; when completearrays exist the per-plane sum replaces the flat estimate.
forecast— negative irradiance becomes zero; non-finite irradiance and PVestimates are omitted before they can become SQLite
NULL.config— array geometry keeps omitted tilt/azimuth distinct from explicit0°, and incomplete or invalid planes are ignored.What is deliberately unchanged
GHI providers.
its configured arrays are now validated before request construction.
number those paths already consume.
Why Erbs, and what it costs when the provider gives no diffuse component
Projecting GHI onto a tilted plane requires knowing how much of it is beam and how
much is diffuse: the beam part depends on the angle of incidence, the diffuse part
largely does not. Providers like Open-Meteo publish only global horizontal, so the
split has to be inferred.
Erbs is the standard correlation for this — it estimates the diffuse fraction from
the clearness index
kt(GHI over extraterrestrial horizontal). It is empiricaland imperfect on partly-cloudy hours, but it is far closer than the implicit
alternative, which was to treat every panel as horizontal. Where a provider does
publish a diffuse component,
POAFromComponentstakes it directly and Erbs is notused at all.
Scope of the split, and where the rest went
The original branch also carried an SMHI STRÅNG client and an expected-vs-actual
scoring package. Those were tested but not wired to anything user-visible, which
is what made the diff hard to review at +1747/-26.
They now move to follow-up PR #734, which wires STRÅNG as an irradiance source,
adds geographic-coverage metadata, and updates the location UI. This PR no
longer depends on any of that.
Testing
sunpos:POAis asserted identical toPOAFromComponentsdriven with theclear-sky components, so the refactor is behaviour-preserving; a south-facing 35°
plane at solar noon is checked to receive more than a horizontal one; Erbs is
checked at both clear and overcast
kt.forecast: arrays move the predicted watts in the expected direction, a sitewith no arrays is asserted identical to the old estimate, incomplete Stockholm
array metadata is ignored, and negative, zero, NaN and Inf GHI are covered with
and without arrays.
Part of the roadmap in discussion #717.
Current review state
masteratee416d1fafter chore(ci): bump docker/login-action from 3 to 4.5.2 #781–chore(ci): bump docker/build-push-action from 6 to 7 #784 and fix: format IPv6 endpoints for integrations #786 on 2026-08-04;no merge conflicts needed resolution. Those newer master commits only changed
CI workflow pins.
55c2c64b.P2 follow-up
The independent review of
1343ce8fidentified three P2s. This head addressesall three in the same branch:
estimated as zero; non-finite irradiance is logged and the row is omitted;
non-finite PV estimates are also omitted and negative PV is clamped to zero.
missing required field no longer becomes
0°north. Complete arrays areprojected; if none remain, GHI providers use the safe flat path.
uses per-array geometry. The rendered Weather tab was checked in a local
browser; screenshot evidence was captured at
/private/tmp/ftw-pr718-weather-tab.png.Human visual review is still required before merge.
Validation
go test ./internal/config ./internal/forecast ./internal/sunpos ./internal/mpcmake verifywith Python 3.12.13, a project venv, and 37 drivers fromdrivers/BUNDLED_SOURCE.jsonmake verify-all: full Go and optimizer tests, external MPC tests, composemigration, container boundaries, vet, native build, and Linux ARM64, Linux
AMD64, and Windows AMD64 builds
pre-push check confirmed the branch was not behind
origin/master.55c2c64bare green; optimizer, driversand module-boundary jobs are correctly path-skipped. Merge remains blocked by
the draft state and independent review.