Symptom
Operator report (2026-04-19): planner shows PV generation of −1.1 kW at Mon 02:00 local — physically impossible. Screenshot in PR description.
Root cause
`go/internal/pvmodel/model.go:109-148` `(Model).Predict`:
Feature vector has an intercept term:
```go
x = [ 1.0, // ← always 1
clearSkyW,
clearSkyW * cloudFactor,
clearSkyW * sin(h), ... ]
```
At night `clearSkyW = 0`, so every feature except `x[0]` is zero and:
```
learned = Beta[0] * 1 + 0 + 0 + ... = Beta[0]
prior = 0
y = trust * Beta[0] (trust = 1 after 50 samples)
```
RLS trains only in daylight (Update gates on `clearSkyW >= 50`), but nothing pins `Beta[0]` to 0. Whenever the daytime residual is minimized by a non-zero intercept + lower `Beta[2]` coefficient, the model picks that — and the intercept leaks into night predictions.
The planner's `selectPlannerPVW` (mpc/service.go:832) makes it worse at night specifically: when `forecastPVW < 200 W` (which is always true at night — met.no returns 0) it falls back unreserved to the twin's prediction, so the 1.1 kW phantom generation becomes the MPC's sole PV input.
Fix (this issue — narrow)
Hard gate at the top of `Model.Predict`: when `clearSkyW < 50` return 0. Mirrors the Update-side gate (same threshold, same physical justification: no sun → no PV).
Follow-up (separate issue)
The intercept still drifts during the day and biases daytime predictions. See #TBD.
Symptom
Operator report (2026-04-19): planner shows PV generation of −1.1 kW at Mon 02:00 local — physically impossible. Screenshot in PR description.
Root cause
`go/internal/pvmodel/model.go:109-148` `(Model).Predict`:
Feature vector has an intercept term:
```go
x = [ 1.0, // ← always 1
clearSkyW,
clearSkyW * cloudFactor,
clearSkyW * sin(h), ... ]
```
At night `clearSkyW = 0`, so every feature except `x[0]` is zero and:
```
learned = Beta[0] * 1 + 0 + 0 + ... = Beta[0]
prior = 0
y = trust * Beta[0] (trust = 1 after 50 samples)
```
RLS trains only in daylight (Update gates on `clearSkyW >= 50`), but nothing pins `Beta[0]` to 0. Whenever the daytime residual is minimized by a non-zero intercept + lower `Beta[2]` coefficient, the model picks that — and the intercept leaks into night predictions.
The planner's `selectPlannerPVW` (mpc/service.go:832) makes it worse at night specifically: when `forecastPVW < 200 W` (which is always true at night — met.no returns 0) it falls back unreserved to the twin's prediction, so the 1.1 kW phantom generation becomes the MPC's sole PV input.
Fix (this issue — narrow)
Hard gate at the top of `Model.Predict`: when `clearSkyW < 50` return 0. Mirrors the Update-side gate (same threshold, same physical justification: no sun → no PV).
Follow-up (separate issue)
The intercept still drifts during the day and biases daytime predictions. See #TBD.