feat(weather): add STRÅNG as an irradiance source and declare source coverage - #734
Draft
HuggeK wants to merge 2 commits into
Draft
feat(weather): add STRÅNG as an irradiance source and declare source coverage#734HuggeK wants to merge 2 commits into
HuggeK wants to merge 2 commits into
Conversation
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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Makes SMHI STRÅNG a real irradiance source rather than an unreferenced client,
gives every external data source a machine-readable statement of where in the
world it works, and moves the location picker off Leaflet.
Second half of the split @frahlg asked for.
STRÅNG's parameters, measured rather than assumed
SMHI's apidocs pages currently 404, so the parameter set was mapped against the
live API. Probing 100–130 returned data for exactly 116–122 and 404 for
everything else. Names were then confirmed from their magnitudes on a clear day
at Stockholm rather than guessed:
Two independent checks pin the identification: 121 + 122 = 723.0 + 87.5 =
810.5, which is exactly 117 (direct + diffuse = global), and 119 caps at
exactly 60 — minutes within the hour.
Cloud cover: it does not exist, but it is derivable
STRÅNG publishes no cloud-cover parameter; it is a radiation model. It does
publish sunshine duration — minutes per hour during which direct beam exceeded
the WMO 120 W/m² threshold — so cloudiness comes out as
1 − minutes/60. Thatis an observed quantity rather than an inferred cloud field, which makes it
better grounded than a forecast provider's cloud percentage, and coarser in one
direction: it cannot see thin cirrus that dims without blocking.
Why CloudCover takes a location, and what the live data showed
The first working version returned 100% cloud cover at midnight, confidently,
every night. Sunshine duration is zero at night for the trivial reason that there
is no sun. The same failure appears near sunrise and sunset, where the beam
crosses ten or more air masses and cannot reach 120 W/m² even under a spotless
sky — at Stockholm on 2026-06-21 the 20:00Z hour reads GHI 2.5 W/m² and 0 minutes
of sunshine, because the sun is six minutes from setting, not because it is
overcast.
So
CloudCover(lat, lon)declines to answer unless the sun clears 5° ofelevation at some point in the hour, and returns an explicit unknown rather
than defaulting to clear or overcast — those lead to opposite decisions at a
call site. It samples the hour's start, midpoint and end, because the hour in
which the sun crosses the line has both a start and a midpoint below it (02:00Z
that day: 1.59°, 4.34°, 7.25°) and would otherwise be discarded despite carrying
a real half-hour of sunshine.
Coverage metadata (#726)
New
go/internal/coverageregistry andGET /api/data-sources: every forecast,irradiance and price source now declares its area, country list, licence, whether
it needs a key, and whether it reaches this site. The Weather tab renders it
under the map and flags anything that does not apply.
This makes an existing silence explicit. STRÅNG is Nordic-only and every price
provider is European, so sites elsewhere were getting empty results with no
explanation:
PV performance scoring now declines to start outside the STRÅNG domain instead of
retrying nightly forever.
Why the bounds are advisory, and why that is not a cop-out
STRÅNG's model grid is rotated relative to lat/lon, so any lat/lon box is a
superset of the real domain. All four corners of the declared box were probed
live and every one returned no data, so the gap is real and measured, not
hypothetical.
covers: falseis therefore definitive — that location is genuinely not served —while
covers: truemeans "worth asking". The upstream API stays the final word.The alternative, encoding the rotated grid polygon, would buy accuracy at the
corners of a box no operator's house sits in, at the cost of a lump of geometry
nobody can maintain.
Nothing here is a safety input. It decides what the UI shows and whether a
pointless nightly fetch is skipped.
Also in this PR
something visible. Nightly backfill,
GET /api/pv/performance, a dashed"expected (STRÅNG)" overlay on the Produced tile, and a calibration factor fed
back into the forward forecast. The factor is a median with a
median-absolute-deviation spread, so one snowy day cannot move it, and it is
refused outside a plausible band — a site reading at 10% of nameplate is a
configuration fault, and silently rescaling the forecast would hide it.
mpc.PVBand— extracts the site-signed robust band into one testedfunction, discharging the correctness risk flagged in RFC: SMHI STRÅNG irradiance + Lantmäteriet roof geometry for PV prediction, performance scoring & map-drawn arrays #717. Generation is
negative, so
lowis the optimistic bound; the invariantlow ≤ base ≤ high ≤ 0is asserted over a table including NaN and ±Inf.loads via a pinned dynamic import; the stylesheet keeps its integrity hash while
the JS relies on version pinning, since an integrity hash on the entry point
would not cover the shared chunk it imports anyway. Same OpenStreetMap raster
tiles and attribution as before. The numeric lat/lon fields stay authoritative,
so a CDN or WebGL failure costs the picker and nothing else.
Testing
Go: 11 coverage tests (the probe results are encoded as assertions, so widening
the box fails them and says why), 14 STRÅNG tests including the night, polar-night
and near-horizon cases, 6 for the new endpoint, plus the existing pvperf,
calibration and PVBand suites.
go vetclean.Web: 247/248. The one failure is pre-existing on
masterand Windows-only —theme-tokens.test.mjsbuildsC:\C:\…becausenew URL(".", import.meta.url).pathnamereturns
/C:/….Verified end-to-end against a seeded local instance: performance ratio 0.90047,
calibration factor 0.92 (σ 0.033, 21 days, applied), and the live STRÅNG fetch
above.
Part of the roadmap in discussion #717. Closes part of #726.