Skip to content

feat(weather): add STRÅNG as an irradiance source and declare source coverage - #734

Draft
HuggeK wants to merge 2 commits into
srcfl:masterfrom
HuggeK:strang-source
Draft

feat(weather): add STRÅNG as an irradiance source and declare source coverage#734
HuggeK wants to merge 2 commits into
srcfl:masterfrom
HuggeK:strang-source

Conversation

@HuggeK

@HuggeK HuggeK commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

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.

Stacked on #718, which carries the plane-of-array wiring this builds on.
Review that one first; the diff here is against it.

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:

Code Quantity Noon 2026-06-21
116 CIE-weighted UV (mW/m²) 146.6
117 Global horizontal (GHI) 810.5
118 Direct normal (DNI) 917.2
119 Sunshine duration (min/h) 60.0
120 PAR 357.9
121 Direct horizontal 723.0
122 Diffuse (DHI) 87.5

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. That
is 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° of
elevation
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.

Hour (UTC) GHI Sunshine Cloud cover
00:00 0.0 0 min unknown — below horizon
04:00 163.2 60 min 0%
12:00 810.5 60 min 0%
20:00 2.5 0 min unknown — about to set

Coverage metadata (#726)

New go/internal/coverage registry and GET /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:

Stockholm    forecast: all 4 OK | irradiance: OK STRÅNG | price: all 3 OK
Berlin       forecast: all 4 OK | irradiance: NO STRÅNG | price: OK ENTSO-E, NO Elpriset
Sydney       forecast: all 4 OK | irradiance: NO STRÅNG | price: none
New York     forecast: all 4 OK | irradiance: NO STRÅNG | price: none

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: false is therefore definitive — that location is genuinely not served —
while covers: true means "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

  • PV performance scoring + calibration — the consumer that makes STRÅNG do
    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 tested
    function, 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 low is the optimistic bound; the invariant low ≤ base ≤ high ≤ 0 is asserted over a table including NaN and ±Inf.
  • MapLibre GL JS 6 replaces Leaflet. v6 is ESM-only and code-split, so it
    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 vet clean.

Web: 247/248. The one failure is pre-existing on master and Windows-only —
theme-tokens.test.mjs builds C:\C:\… because new URL(".", import.meta.url).pathname
returns /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.

claude and others added 2 commits July 31, 2026 10:20
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>
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.

2 participants