Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/strang-poa-pv-forecast.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"ftw": minor
---

PV forecasts are now orientation-aware. When per-plane geometry is configured
(the Weather tab's PV arrays: tilt/azimuth/kWp), a radiation-bearing forecast
provider's global horizontal irradiance is projected onto each panel plane via
the physics `sunpos` model and summed, instead of the previous flat
`rated × (W/m² / 1000)` estimate that ignored panel orientation. Sites with no
arrays configured keep the existing behaviour, and providers that already return
site-calibrated watts (Forecast.Solar) are left untouched.

Providers that publish only global horizontal irradiance get an Erbs correlation
to split it into direct and diffuse components before projection, so a
south-facing 35° roof and a flat one no longer receive the same forecast.
32 changes: 25 additions & 7 deletions go/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -978,9 +978,10 @@ type Weather struct {
// predictions when each plane is described separately than when
// everything is averaged into a single tilt/azimuth.
//
// When set, PVArrays overrides the legacy single-array fields.
// Providers that can't use site geometry (met_no, open_meteo)
// ignore this entirely and just use PVRatedW.
// When set, PVArrays overrides the legacy single-array fields for
// geometry-aware providers. GHI providers such as open_meteo project
// radiation onto these planes; incomplete entries are ignored and the
// provider falls back to its flat estimate.
PVArrays []PVArray `yaml:"pv_arrays,omitempty" json:"pv_arrays,omitempty"`

// HeatingWPerDegC adds load proportional to max(18°C − outdoor_temp, 0).
Expand All @@ -995,10 +996,27 @@ type Weather struct {
// + east roof + garage) with different tilt/azimuth. The sum of all
// KWp values should match the total PV nameplate at the site.
type PVArray struct {
Name string `yaml:"name,omitempty" json:"name,omitempty"`
KWp float64 `yaml:"kwp" json:"kwp"`
TiltDeg float64 `yaml:"tilt_deg" json:"tilt_deg"`
AzimuthDeg float64 `yaml:"azimuth_deg" json:"azimuth_deg"`
Name string `yaml:"name,omitempty" json:"name,omitempty"`
KWp float64 `yaml:"kwp" json:"kwp"`
TiltDeg *float64 `yaml:"tilt_deg" json:"tilt_deg"`
AzimuthDeg *float64 `yaml:"azimuth_deg" json:"azimuth_deg"`
}

// CompleteGeometry returns one usable PV plane. Tilt and azimuth are
// pointers so an omitted field cannot be confused with a valid 0° value.
// Invalid or partial entries are intentionally not fatal: callers use the
// flat forecast path when no complete plane remains.
func (a PVArray) CompleteGeometry() (tiltDeg, azimuthDeg, kWp float64, ok bool) {
if a.KWp <= 0 || math.IsNaN(a.KWp) || math.IsInf(a.KWp, 0) ||
a.TiltDeg == nil || a.AzimuthDeg == nil {
return 0, 0, 0, false
}
tiltDeg, azimuthDeg = *a.TiltDeg, *a.AzimuthDeg
if math.IsNaN(tiltDeg) || math.IsInf(tiltDeg, 0) || tiltDeg < 0 || tiltDeg > 90 ||
math.IsNaN(azimuthDeg) || math.IsInf(azimuthDeg, 0) || azimuthDeg < 0 || azimuthDeg > 360 {
return 0, 0, 0, false
}
return tiltDeg, azimuthDeg, a.KWp, true
}

// Battery is per-battery overrides (keyed by driver name in the top-level map).
Expand Down
36 changes: 36 additions & 0 deletions go/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,42 @@ batteries:
}
}

func TestPVArrayGeometryDistinguishesMissingFromZero(t *testing.T) {
yaml := minimalYAML + `
weather:
provider: open_meteo
latitude: 59.3293
longitude: 18.0686
pv_arrays:
- name: partial
kwp: 10
tilt_deg: 35
- name: north flat
kwp: 5
tilt_deg: 0
azimuth_deg: 0
`
c, err := Parse([]byte(yaml), "/tmp")
if err != nil {
t.Fatal(err)
}
if c.Weather == nil || len(c.Weather.PVArrays) != 2 {
t.Fatalf("weather arrays missing: %+v", c.Weather)
}
partial := c.Weather.PVArrays[0]
if partial.AzimuthDeg != nil {
t.Fatalf("omitted azimuth should remain nil, got %v", *partial.AzimuthDeg)
}
if _, _, _, ok := partial.CompleteGeometry(); ok {
t.Fatal("partial geometry must not be treated as a north-facing array")
}
northFlat := c.Weather.PVArrays[1]
tilt, azimuth, kwp, ok := northFlat.CompleteGeometry()
if !ok || tilt != 0 || azimuth != 0 || kwp != 5 {
t.Fatalf("explicit zero geometry should remain valid: tilt=%v azimuth=%v kwp=%v ok=%v", tilt, azimuth, kwp, ok)
}
}

func TestSiteMeterDriverReturnsName(t *testing.T) {
c, err := Parse([]byte(minimalYAML), ".")
if err != nil {
Expand Down
3 changes: 2 additions & 1 deletion go/internal/config/restart_required_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,9 @@ func TestRestartRequiredFor_BootSections(t *testing.T) {
c.Weather = &Weather{Provider: "open_meteo", Latitude: 59, Longitude: 18}
}, "weather"},
{"weather pv_arrays added", func(c *Config) {
tilt, azimuth := 30.0, 180.0
c.Weather = &Weather{Provider: "met_no", Latitude: 59, Longitude: 18,
PVArrays: []PVArray{{KWp: 5, TiltDeg: 30, AzimuthDeg: 180}}}
PVArrays: []PVArray{{KWp: 5, TiltDeg: &tilt, AzimuthDeg: &azimuth}}}
}, "weather"},
{"weather heating coefficient", func(c *Config) {
c.Weather.HeatingWPerDegC = 250
Expand Down
117 changes: 107 additions & 10 deletions go/internal/forecast/forecast.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (

"github.com/srcfl/ftw/go/internal/config"
"github.com/srcfl/ftw/go/internal/state"
"github.com/srcfl/ftw/go/internal/sunpos"
)

// Provider is implemented by each weather source.
Expand Down Expand Up @@ -243,10 +244,16 @@ func EstimatePVW(lat, lon float64, t time.Time, cloudPct *float64, ratedW float6

// Service wraps a provider + store + scheduler for forecasts.
type Service struct {
Provider Provider
Store *state.Store
Lat, Lon float64
RatedPVW float64 // total rated PV across all arrays (used for estimate)
Provider Provider
Store *state.Store
Lat, Lon float64
RatedPVW float64 // total rated PV across all arrays (used for estimate)

// Arrays holds per-plane geometry (tilt/azimuth/kWp) mirrored from the
// weather config. When set, a radiation-bearing provider's horizontal
// GHI is projected onto each plane via sunpos and summed, instead of the
// orientation-blind flat rated×(W/m²/1000) estimate. Empty → flat estimate.
Arrays []Array

stop chan struct{}
done chan struct{}
Expand All @@ -273,9 +280,9 @@ func FromConfig(cfg *config.Weather, ratedPVW float64, st *state.Store, userAgen
// their geometry just because the config model grew.
var arrays []Array
for _, a := range cfg.PVArrays {
arrays = append(arrays, Array{
TiltDeg: a.TiltDeg, AzimuthDeg: a.AzimuthDeg, KWp: a.KWp,
})
if converted, ok := arrayFromConfig(a); ok {
arrays = append(arrays, converted)
}
}
if len(arrays) == 0 {
arrays = append(arrays, Array{
Expand All @@ -286,10 +293,21 @@ func FromConfig(cfg *config.Weather, ratedPVW float64, st *state.Store, userAgen
default:
return nil
}
// Mirror per-plane geometry for the POA path. Shared across all
// providers: forecast_solar already applies geometry server-side (so
// this stays unused there), but open_meteo / STRÅNG-style GHI providers
// use it to project irradiance onto each plane in fetchAndStore.
var arrays []Array
for _, a := range cfg.PVArrays {
if converted, ok := arrayFromConfig(a); ok {
arrays = append(arrays, converted)
}
}
return &Service{
Provider: p, Store: st,
Lat: cfg.Latitude, Lon: cfg.Longitude,
RatedPVW: ratedPVW,
Arrays: arrays,
stop: make(chan struct{}),
done: make(chan struct{}),
}
Expand Down Expand Up @@ -333,6 +351,18 @@ func (s *Service) fetchAndStore(ctx context.Context) {
nowMs := time.Now().UnixMilli()
points := make([]state.ForecastPoint, 0, len(rows))
for _, r := range rows {
// A negative irradiance is not physical; retain the row with a
// zero signal. Non-finite values are invalid provider data and must
// not reach SQLite, where they can become NULL silently.
var solarWm2 *float64
if r.SolarWm2 != nil {
ghi, ok := normalizeIrradiance(*r.SolarWm2)
if !ok {
slog.Warn("forecast row skipped", "reason", "non-finite irradiance", "provider", s.Provider.Name(), "slot", r.HourStart)
continue
}
solarWm2 = &ghi
}
// Pick the most direct PV signal the provider gave us. Forecast.Solar
// returns site-calibrated watts directly; Open-Meteo returns shortwave
// radiation we turn into watts via rated × W/m²/1000; met.no only has
Expand All @@ -341,18 +371,30 @@ func (s *Service) fetchAndStore(ctx context.Context) {
switch {
case r.PVWEstimated != nil:
pvW = *r.PVWEstimated
case r.SolarWm2 != nil && s.RatedPVW > 0:
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

if !ok {
slog.Warn("forecast row skipped", "reason", "non-finite irradiance", "provider", s.Provider.Name(), "slot", r.HourStart)
continue
}
default:
pvW = EstimatePVW(s.Lat, s.Lon, r.HourStart, r.CloudCoverPct, s.RatedPVW)
}
if math.IsNaN(pvW) || math.IsInf(pvW, 0) {
slog.Warn("forecast row skipped", "reason", "non-finite PV estimate", "provider", s.Provider.Name(), "slot", r.HourStart)
continue
}
if pvW < 0 {
pvW = 0
}
pvPtr := &pvW
points = append(points, state.ForecastPoint{
SlotTsMs: r.HourStart.UnixMilli(),
SlotLenMin: 60,
CloudCoverPct: r.CloudCoverPct,
TempC: r.TempC,
SolarWm2: r.SolarWm2,
SolarWm2: solarWm2,
PVWEstimated: pvPtr,
Source: s.Provider.Name(),
FetchedAtMs: nowMs,
Expand All @@ -365,6 +407,61 @@ func (s *Service) fetchAndStore(ctx context.Context) {
slog.Info("forecast fetched", "count", len(points), "provider", s.Provider.Name())
}

func arrayFromConfig(a config.PVArray) (Array, bool) {
tiltDeg, azimuthDeg, kWp, ok := a.CompleteGeometry()
if !ok {
return Array{}, false
}
return Array{TiltDeg: tiltDeg, AzimuthDeg: azimuthDeg, KWp: kWp}, true
}

func normalizeIrradiance(ghiWm2 float64) (float64, bool) {
if math.IsNaN(ghiWm2) || math.IsInf(ghiWm2, 0) {
return 0, false
}
if ghiWm2 < 0 {
return 0, true
}
return ghiWm2, true
}

func pvWFromGHI(lat, lon float64, t time.Time, ghiWm2, ratedPVW float64, arrays []Array) (float64, bool) {
ghiWm2, ok := normalizeIrradiance(ghiWm2)
if !ok {
return 0, false
}
if len(arrays) > 0 {
return poaPVWattsFromGHI(lat, lon, t, ghiWm2, arrays), true
}
if ratedPVW <= 0 {
return 0, true
}
return ratedPVW * ghiWm2 / 1000.0, true
}

// poaPVWattsFromGHI converts a global-horizontal irradiance (W/m², positive)
// into expected DC PV output (W, positive) by projecting it onto each
// configured array's plane via sunpos and scaling by nameplate. This is the
// orientation-aware replacement for the flat rated×(W/m²/1000) estimate; it
// is used whenever the provider supplies GHI and the site has per-plane
// geometry. Returns 0 when the sun is down or no arrays produce output.
func poaPVWattsFromGHI(lat, lon float64, t time.Time, ghiWm2 float64, arrays []Array) float64 {
ghiWm2, ok := normalizeIrradiance(ghiWm2)
if !ok {
return 0
}
var total float64
for _, a := range arrays {
if a.KWp <= 0 {
continue
}
poa := sunpos.POAFromGHI(t, lat, lon, ghiWm2, a.TiltDeg, a.AzimuthDeg)
// kWp×1000 = nameplate W at STC (1000 W/m²); scale by POA/1000.
total += a.KWp * 1000.0 * (poa / 1000.0)
}
return total
}

// Load returns forecasts in [sinceMs, untilMs].
func (s *Service) Load(sinceMs, untilMs int64) ([]state.ForecastPoint, error) {
return s.Store.LoadForecasts(sinceMs, untilMs)
Expand Down
Loading