Skip to content

Commit d654e10

Browse files
committed
feat(forecast): add weather-based solar PV forecast (Option A)
- New collector/forecast.py: fetches Open-Meteo hourly weather, computes clear-sky PV via pvlib for array tilt/azimuth, applies cloud cover and bifacial gain, stores predicted watts. - Adds forecast settings (lat/lon, array kWp/azimuth/tilt/bifacial gain, provider, horizon, refresh interval) with UI metadata. - New MariaDB table lux_solar_forecast and /api/forecast endpoint. - Writes same forecast to InfluxDB as 'PV power predicted' and 'Cloud cover' so existing Grafana panels render it. - Dashboard card shows next 24h peak and today's predicted kWh. - Refresh scheduler piggybacks the collector writer loop. Specs for this deployment: 1.56 kWp, due south, 21° tilt, 10% bifacial gain, Waller TX (30.0591N, -95.9324W).
1 parent 802ac8c commit d654e10

7 files changed

Lines changed: 615 additions & 1 deletion

File tree

‎api/__init__.py‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,54 @@ def api_energy(
441441

442442

443443

444+
@app.get("/api/forecast")
445+
def api_forecast(
446+
hours: int = Query(48, ge=1, le=168, description="Hours of forecast to return"),
447+
):
448+
"""Return the stored solar PV forecast from MariaDB.
449+
450+
Returns hourly predicted PV watts, newest forecast first. Optionally filter
451+
to a specific number of hours from now.
452+
"""
453+
from datetime import datetime, timedelta
454+
455+
conn = _get_conn()
456+
try:
457+
with conn.cursor() as cur:
458+
cur.execute(
459+
"""
460+
SELECT ts, predicted_watts, cloud_cover, source
461+
FROM lux_solar_forecast
462+
WHERE ts >= NOW()
463+
AND ts < NOW() + INTERVAL %s HOUR
464+
ORDER BY ts ASC
465+
""",
466+
(hours,),
467+
)
468+
rows = cur.fetchall()
469+
470+
data = [
471+
{
472+
"ts": ts.isoformat() if ts else None,
473+
"predicted_watts": float(predicted_watts),
474+
"cloud_cover": float(cloud_cover) if cloud_cover is not None else None,
475+
"source": source,
476+
}
477+
for ts, predicted_watts, cloud_cover, source in rows
478+
]
479+
480+
generated_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
481+
return {
482+
"hours": hours,
483+
"count": len(data),
484+
"generated_at": generated_at,
485+
"unit": "W",
486+
"data": data,
487+
}
488+
finally:
489+
conn.close()
490+
491+
444492
# ── settings ────────────────────────────────────────────────────────────────
445493

446494
class SettingUpdate(BaseModel):

‎api/static/index.html‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@
149149
<div class="label" id="solar-label">Solar PV</div>
150150
<div class="property" id="sa-solar-total">--</div>
151151
<div class="property small"><span class="name">Peak:</span><span id="sa-solar-peak">--</span></div>
152+
<div class="property small"><span class="name">Next peak:</span><span id="sa-solar-forecast-peak">--</span></div>
153+
<div class="property small"><span class="name">Forecast today:</span><span id="sa-solar-forecast-today">--</span></div>
152154
</div>
153155
</a></div>
154156
<div><a href="#" class="status" data-detail="grid" aria-labelledby="grid-label">
@@ -704,6 +706,43 @@ <h3 id="rule-editor-title">Edit Rule</h3>
704706
});
705707
}
706708

709+
async function loadForecastCard() {
710+
try {
711+
const res = await fetchJson(API + '/api/forecast?hours=48');
712+
const rows = res.data || [];
713+
if (!rows.length) {
714+
setText('sa-solar-forecast-peak', '--');
715+
setText('sa-solar-forecast-today', '--');
716+
return;
717+
}
718+
// Find the max predicted watts in the next 24h.
719+
const next24h = rows.filter(r => new Date(r.ts) <= new Date(Date.now() + 24 * 3600 * 1000));
720+
let peak = null;
721+
let peakTs = null;
722+
for (const r of next24h) {
723+
if (peak === null || r.predicted_watts > peak) {
724+
peak = r.predicted_watts;
725+
peakTs = r.ts;
726+
}
727+
}
728+
// Sum today's predicted energy (kWh).
729+
const now = new Date();
730+
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
731+
const todayRows = rows.filter(r => {
732+
const d = new Date(r.ts);
733+
return d >= todayStart && d < new Date(todayStart.getTime() + 24 * 3600 * 1000);
734+
});
735+
const todayKwh = todayRows.reduce((sum, r) => sum + (r.predicted_watts || 0), 0) / 1000;
736+
737+
setText('sa-solar-forecast-peak', peak !== null ? fmtInt(peak) + ' W ' + fmtTime(peakTs).replace('Updated ', '') : '--');
738+
setText('sa-solar-forecast-today', fmt(todayKwh, 1) + ' kWh');
739+
} catch (e) {
740+
console.error('loadForecastCard error', e);
741+
setText('sa-solar-forecast-peak', '--');
742+
setText('sa-solar-forecast-today', '--');
743+
}
744+
}
745+
707746
async function loadMainCharts() {
708747
try {
709748
const res = await fetchJson(API + '/api/history?minutes=1440&max_points=300&fields=pv1_power,pv2_power,charge_power,discharge_power,grid_import_power,grid_export_power,load_power,battery_voltage,soc');
@@ -1270,10 +1309,11 @@ <h4 style="margin:1rem 0 0.5rem;">Set — action</h4>
12701309
});
12711310

12721311
refresh();
1312+
loadForecastCard();
12731313
loadMainCharts();
12741314
connectWS();
12751315
setInterval(refresh, 15000);
1276-
});
1316+
setInterval(loadForecastCard, 300000);;
12771317
</script>
12781318
</body>
12791319
</html>

‎collector/collector.py‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,7 @@ def __init__(self, config: CollectorConfig,
432432
self._last_write = 0.0
433433
self._config_fingerprint: Optional[str] = None
434434
self._loaded_restart_values: Dict[str, str] = {}
435+
self._last_forecast_refresh = 0.0
435436

436437
# Register handler for graceful shutdown
437438
signal.signal(signal.SIGINT, self._signal_handler)
@@ -703,11 +704,63 @@ def _writer_loop(self) -> None:
703704
except Exception:
704705
logger.exception("Snapshot callback failed")
705706

707+
# Periodic solar forecast refresh (Option A).
708+
self._maybe_refresh_forecast()
709+
706710
except Exception:
707711
logger.exception("Failed to write snapshot")
708712

709713
logger.info("Writer loop exiting")
710714

715+
def _maybe_refresh_forecast(self) -> None:
716+
"""Refresh the solar forecast on a configurable interval.
717+
718+
Reads forecast settings fresh from MariaDB each cycle, so enabling or
719+
changing forecast settings takes effect without a restart.
720+
"""
721+
try:
722+
from .forecast import config_from_settings, refresh
723+
from .settings import get_all
724+
import pymysql
725+
726+
settings = self._read_db_settings()
727+
if not settings:
728+
return
729+
cfg = config_from_settings(settings)
730+
if not cfg.enabled:
731+
return
732+
733+
now = time.time()
734+
interval = max(60.0, float(cfg.refresh_min) * 60.0)
735+
if now - self._last_forecast_refresh < interval:
736+
return
737+
738+
conn = pymysql.connect(
739+
host=self.cfg.outputs.mariadb_host,
740+
port=self.cfg.outputs.mariadb_port,
741+
user=self.cfg.outputs.mariadb_user,
742+
password=self.cfg.outputs.mariadb_password,
743+
database=self.cfg.outputs.mariadb_database,
744+
autocommit=True,
745+
)
746+
try:
747+
prefix = self.cfg.outputs.mariadb_table_prefix
748+
influx_cfg = None
749+
if self.cfg.outputs.influx_enabled and self.cfg.outputs.influx_token:
750+
influx_cfg = {
751+
"url": self.cfg.outputs.influx_url,
752+
"token": self.cfg.outputs.influx_token,
753+
"org": self.cfg.outputs.influx_org,
754+
"bucket": self.cfg.outputs.influx_bucket,
755+
}
756+
written = refresh(conn, prefix, settings, influx_cfg)
757+
if written is not None:
758+
self._last_forecast_refresh = now
759+
finally:
760+
conn.close()
761+
except Exception:
762+
logger.exception("Forecast refresh failed")
763+
711764
# ── Sanity clamping ──────────────────────────────────────────────
712765
_SANITY_LIMITS: Dict = {
713766
"pv1_power": 8000,

0 commit comments

Comments
 (0)