Skip to content

feat(forecast): orientation-aware PV forecasts + STRÅNG scoring foundations (Phase 1 of #717) - #718

Draft
HuggeK wants to merge 4 commits into
srcfl:masterfrom
HuggeK:worktree-strang-pvperf
Draft

feat(forecast): orientation-aware PV forecasts + STRÅNG scoring foundations (Phase 1 of #717)#718
HuggeK wants to merge 4 commits into
srcfl:masterfrom
HuggeK:worktree-strang-pvperf

Conversation

@HuggeK

@HuggeK HuggeK commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Phase 1 of RFC #717 — STRÅNG irradiance + roof geometry

Implements the first, no-credentials slice of the design in #717:

  1. Orientation-aware PV forecasts (live) — the highest value / lowest risk
    piece, improves every radiation-bearing provider today.
  2. STRÅNG client + performance-scoring foundations (tested, not yet wired)
    the building blocks for expected-vs-actual PV scoring.

Nothing here touches the control tick, dispatch, or the optimizer contract.

1. POA-per-array wiring (user-visible)

sunpos.POA plane-of-array physics already existed but was never used by the
forecast path, which projected shortwave radiation onto a horizontal panel
(rated × W/m² / 1000). Now, when per-plane geometry is configured (the Weather
tab's PV arrays), each provider's GHI is projected onto every plane and summed.

  • sunpos: new POAFromComponents(sun, ghi, dhi, tilt, az) (measured GHI+DHI,
    e.g. STRÅNG 117+122) and POAFromGHI(...) (GHI-only, Erbs diffuse split from
    the clearness index). POA is refactored onto POAFromComponents with its
    historical fixed-20%-diffuse behaviour preserved (golden test).
  • forecast: Service mirrors cfg.PVArrays; fetchAndStore uses the POA sum
    when a provider supplies GHI and geometry exists, else the previous flat
    estimate. Sites with no arrays configured are unchanged.

2. STRÅNG + scoring foundations

  • internal/strang: a client for SMHI's STRÅNG historical irradiance model
    (params 117 global / 122 diffuse; hourly, ~2.5 km, CC BY 4.0, no
    key
    ). STRÅNG is historical-only — it is for performance scoring and
    calibration, deliberately not registered as a forward forecast provider.
  • internal/pvperf: read-only expected-vs-actual scoring — ExpectedWh (project
    STRÅNG irradiance onto the arrays via sunpos, honouring measured diffuse when
    present) and PerformanceRatio (clamped, n/a below a floor).
Why land the foundations unwired, and what's next

The remaining wiring — a durable irradiance_history + pv_performance_daily
persistence layer, a nightly STRÅNG backfill, a GET /api/pv/performance
endpoint, and a dashed "expected (STRÅNG)" overlay on the History card — has
real blast radius (two-DB-connection migrations, control-process startup in
main.go, new API Deps wiring). Splitting it out keeps this PR focused, green,
and easy to review, and lets the scoring UX land as its own increment. The
strang and pvperf packages are fully unit-tested here so they don't merge as
dead, unverified code.

Later phases from #717 (STRÅNG-calibrated twin + ±σ optimizer band, the on-host
roofmodel/ module, the MapLibre "draw your array" UI) build on these.

Testing

  • New/changed packages green: go test ./internal/{sunpos,forecast,strang,pvperf}/....
  • Whole-module go build ./... and go vet ./... clean.
  • Full go test ./... passes except a pre-existing, unrelated flake in
    internal/state (TestOpenBackupSourceCopiesWithoutRunningMigrations, a
    SQLite "disk image is malformed" backup/integrity check) — that package is
    untouched by this branch.

Changeset included (minor). DCO signed-off.


🤖 Generated with Claude Code — implementing the design in #717.

claude and others added 2 commits July 29, 2026 16:36
…ations

Phase 1 of the SMHI STRÅNG + roof-geometry RFC (srcfl#717).

POA-per-array wiring (live, user-visible):
- sunpos: add POAFromComponents (measured GHI+DHI) and POAFromGHI (Erbs
  diffuse split from the clearness index); refactor POA onto them so the
  existing clear-sky behaviour is preserved.
- forecast: Service now mirrors the configured PV arrays and, when a
  radiation-bearing provider supplies GHI, projects it onto each plane and
  sums instead of the flat rated x (W/m^2 / 1000) estimate. Sites with no
  arrays keep the previous behaviour.

STRÅNG + scoring foundations (tested, not yet wired):
- strang: client for SMHI's historical STRÅNG irradiance model (params 117
  global / 122 diffuse; CC BY 4.0, no key). Historical only — used for
  performance scoring and calibration, never as a forward provider.
- pvperf: read-only expected-vs-actual PV scoring (ExpectedWh via sunpos +
  PerformanceRatio). No control-path coupling.

Wiring the foundations into persistence, a GET /api/pv/performance endpoint,
a nightly backfill and a dashboard overlay is the next increment.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
Signed-off-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
…verlay

Completes the scoring pipeline of the SMHI STRÅNG RFC (srcfl#717): the
tested strang/pvperf foundations are now persisted, scored nightly, served
over the API and drawn on the dashboard.

Persistence (go/internal/state):
- Add irradiance_history (cache.db, re-fetchable) and pv_performance_daily
  (state.db, precious) tables + Save/Load helpers, mirroring the existing
  forecasts / energy_daily patterns. Roundtrip-tested.

Scoring service (go/internal/pvperf):
- pvperf.Service backfills STRÅNG irradiance for a lookback window once at
  boot and nightly, projects it onto each configured array via sunpos, and
  scores the expected DC energy against measured PV (state.DailyEnergy).
  Read-only w.r.t. control; nil (-> {enabled:false}) when the site has no
  PV geometry to score against. Recent days are re-scored each run so the
  STRÅNG ~1-day analysis lag is corrected as data lands.
- FromConfig mirrors forecast.FromConfig's array derivation (explicit
  pv_arrays, else a synthesized array from the legacy flat fields).

Wiring:
- main.go constructs + starts the service alongside the forecast service,
  with graceful Stop() on shutdown, and passes it into api.Deps.
- api: GET /api/pv/performance returns the scored days plus an
  energy-weighted overall performance ratio and SMHI attribution, guarded
  by the standard {enabled:false} nil pattern.

Dashboard (web):
- ftw-bar-chart gains an optional dashed overlay line (an SVG polyline on
  the shared axis). The Produced history tile fetches /api/pv/performance
  and overlays the "expected (STRÅNG)" line plus a performance-ratio
  caption. Bars are unchanged when scoring is unavailable.

Nothing here touches the control tick, dispatch or the optimizer contract.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
Signed-off-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
@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.

claude and others added 2 commits July 30, 2026 16:12
…map to MapLibre

Closes the loop opened by STRÅNG scoring: a site's measured performance ratio
now feeds back into its forward forecast, and the Weather tab's location picker
moves off Leaflet.

- pvperf.Calibrate summarises scored days into a factor + spread. Median and
  MAD rather than mean/stddev, so a snow day or a curtailed day cannot drag the
  factor; a result outside a plausible band is refused rather than applied,
  because a site reading at 10% or 160% of nameplate is a configuration fault
  and rescaling the forecast would hide it.

- forecast.Service grows an optional Calibration hook, applied only to the two
  irradiance-derived estimates. Those share the loss-free
  irradiance-x-nameplate baseline the ratio was measured against; a
  provider-native figure is already site-calibrated and the cloud-derated prior
  carries its own derate, so scaling either would double-count.

- mpc.PVBand extracts the site-signed uncertainty band into one tested
  function. Generation is negative, so "low" is the optimistic bound and "high"
  the pessimistic one, and the optimizer rejects the request unless
  low <= base <= high <= 0. Both clamps are load-bearing and now have tests: a
  spread wider than expected generation degrades to "produces nothing" instead
  of crossing into positive PV, and a zero spread collapses the band onto the
  base forecast exactly.

- GET /api/pv/performance reports the factor, spread, sample size and whether
  it is actually applied, over a fixed window so a short ?days= cannot make a
  site look uncalibrated. The History card says so only when it is applied.

- The location picker is MapLibre GL JS 5.24.0 (BSD-3), pinned and SRI-checked,
  still lazy-loaded. v5 is deliberate: v6 dropped the UMD bundle and a bare ES
  module import cannot carry an integrity hash. The style is built inline from
  the same OpenStreetMap raster tiles, so neither tile source nor attribution
  changed, and the numeric fields stay authoritative if WebGL or the CDN fails.

- docs/data-coverage.md records what works where: forecasting is worldwide,
  prices are Europe-only, STRÅNG scoring is Nordic-only, Lantmäteriet is
  Sweden-only. Raised upstream as srcfl#726.

Nothing here touches the control tick, dispatch or the optimizer contract.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com>
v6 is ESM-only and code-split, so it loads through a pinned dynamic import
instead of a script tag. The stylesheet is a single file and keeps its
integrity hash; the JS relies on version pinning, because SRI does not
propagate to a module's own imports and hashing the entry point would leave
the larger shared chunk it pulls in unverified regardless.

Checked against the v5-to-v6 migration notes: Map, Marker, NavigationControl,
attributionControl, panTo, resize and raster sources are all unchanged. v6
drops the default export, so the module namespace is used as the API surface.
v6 also detects that it is served cross-origin and routes its worker through a
blob, so a CSP would need `worker-src blob:` here; FTW sets none today.

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.

3 participants