Skip to content

feat(forecast): project irradiance onto each PV array's plane - #718

Draft
HuggeK wants to merge 1 commit into
srcfl:masterfrom
HuggeK:worktree-strang-pvperf
Draft

feat(forecast): project irradiance onto each PV array's plane#718
HuggeK wants to merge 1 commit into
srcfl:masterfrom
HuggeK:worktree-strang-pvperf

Conversation

@HuggeK

@HuggeK HuggeK commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

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 sunpos physics already in the tree and
summed.

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 config, no new endpoint and no new dependency.

What changed

  • sunposPOAFromComponents(sun, ghi, dhi, tilt, az) extracted so plane-of-array
    irradiance can be driven by measured components rather than only the built-in
    clear-sky model. POA now delegates to it, so existing behaviour is unchanged.
  • sunpos.POAFromGHI — Erbs correlation splitting GHI into direct + diffuse, for
    providers that publish only global horizontal.
  • forecastService.Arrays, filled from weather.pv_arrays; when arrays exist
    the per-plane sum replaces the flat estimate.

What is deliberately unchanged

  • Sites with no arrays configured keep the previous flat estimate.
  • Forecast.Solar is untouched. It already returns site-calibrated watts from
    the same geometry; projecting again would scale its numbers twice.
  • No control-tick, dispatch or optimizer-contract changes. This only sharpens a
    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 empirical
and 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, POAFromComponents takes it directly and Erbs is not
used 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 a follow-up PR that wires STRÅNG as an actual irradiance source
(and derives cloud cover from it), adds geographic-coverage metadata to every
weather provider, and swaps the location picker to MapLibre GL JS. This PR no
longer depends on any of that.

Testing

sunpos: POA is asserted identical to POAFromComponents driven with the
clear-sky components, so the refactor is provably 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, and a site
with no arrays is asserted byte-identical to the old estimate.

Part of the roadmap in discussion #717.

@frahlg

frahlg commented Jul 30, 2026

Copy link
Copy Markdown
Member

This has a merge conflict against master and cannot be reviewed as it stands (CONFLICTING / DIRTY). Several PRs landed today — #700, #706, #707, #715, #716, #721, #722 — so a rebase is needed before anyone can read the diff.

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.

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>
@HuggeK
HuggeK force-pushed the worktree-strang-pvperf branch from 3229d3a to 13bad76 Compare July 31, 2026 08:21
@HuggeK HuggeK changed the title feat(forecast): orientation-aware PV forecasts + STRÅNG scoring foundations (Phase 1 of #717) feat(forecast): project irradiance onto each PV array's plane Jul 31, 2026
@HuggeK

HuggeK commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto master and split, as suggested.

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 energy_ledger_* tables and this branch's pv_performance_daily were added to the same migration list slot, and #652 left ftw-history-card.js untouched so nothing in the UI work collided.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants