Solar Panels Production Forecast

If you have a solar installation, you quickly start wanting to know not just what the panels produced today but what they are likely to produce in the next few hours. Home Assistant actually ships with two built-in paths for this — and both hit a wall in ways that matter for real home automation use.

The first is the Forecast.Solar integration, which comes built into HA with no extra installation. It works out of the box on the free tier, but the free tier updates only once per hour, restricts you to a single array plane, and covers only locations within the EU PVGIS database. More importantly, it is built on historical irradiance averages combined with weather data — not on an actual numerical weather prediction model. The forecast shape it gives you is statistically plausible, not drawn from today’s synoptic situation. Upgrading to an API key unlocks 30-minute updates and multi-plane support, but that is a paid subscription.

The second option is Solcast, available through HACS. Solcast is genuinely excellent — it uses satellite imagery and NWP data and exposes 15+ sensors including confidence percentiles. The problem is the access model. You need to register a Solcast account, configure your rooftop sites in their web dashboard, and live within the free tier’s API call budget: new accounts get 10 calls per day, legacy hobbyist accounts get 50. Ten calls per day works out to one update every 2.4 hours. That is not fast enough for anything time-sensitive like battery dispatch decisions.

This guide takes a different route. A single Python script pulls 15-minute irradiance and temperature data directly from the Open-Meteo DWD ICON-D2 numerical weather model — no account, no API key, no web dashboard, no call budget. It runs as a systemd service on any Linux box, uses the actual NWP forecast for today’s atmospheric conditions, and surfaces eight sensors in Home Assistant via MQTT Discovery every 15 minutes by default. The whole thing is self-contained and runs entirely on your own infrastructure.

The practical payoff goes beyond curiosity. If your installation includes a battery and your energy tariff has time-of-use pricing, a 15-minute-resolution forecast lets you run real automations: charge the battery from the grid overnight when rates are low, let solar fill it in the morning, and then — if the afternoon forecast shows enough generation — export surplus to the grid during the peak-price window rather than wasting storage capacity holding energy you will produce anyway. Without a forward-looking forecast these decisions fall back to heuristics; with one they become data-driven and measurable.

The script is written around a Deye SUN-10K inverter, which belongs to the same hardware family as the popular Sunsynk range. If you have not yet connected your Deye or Sunsynk inverter to Home Assistant, this guide covers the full integration and dashboard setup.

Table of Contents
    Add a header to begin generating the table of contents

    How it works

    The data source — Open-Meteo ICON-D2

    Open-Meteo is a free, open-source weather API that requires no registration or API key for non-commercial use. Personal home automation falls squarely within the allowed use case. The script uses the DWD ICON-D2 model specifically because it provides the global_tilted_irradiance variable — irradiance already projected onto your panel’s physical plane — rather than forcing you to do the tilt-and-azimuth conversion yourself. ICON-D2 runs at 2 km horizontal resolution and covers a roughly 48-hour horizon, which is exactly what the script requests (forecast_days=2).

    There is one coordinate system mismatch worth knowing about upfront. The .env file stores azimuth as a compass bearing, where 0 is North and 180 is South — the convention most people are used to. Open-Meteo uses the opposite: 0 is South, 90 is West. The script converts automatically (azimuth_api = AZIMUTH − 180) so you never need to think about it, but if you are ever debugging unexpected output this is the first thing to check.

    The physics model

    The power estimate is a deliberately simple PVWatts-style model. It is not trying to out-precision a full scientific simulation — it is trying to give you a number that tracks the forecast shape well enough to make useful home automation decisions. The chain is: take the plane-of-array irradiance, estimate cell temperature using a NOCT model, apply a linear temperature derating to the array’s STC power, multiply by a flat inverter efficiency, and clip the result at the inverter’s AC limit. That is four steps, no lookup tables, no spectral correction, no shading model — exactly as much as you need.

    The defaults in the script match an 11 × Longi LR5-66HTH-535M array (5 885 Wp) with a Deye SUN-10K inverter. All hardware constants are configurable via .env — see the Configuration section below.

    MQTT Discovery

    Instead of requiring any manual entity configuration in Home Assistant, the script uses MQTT Discovery. On every connection to the broker it publishes a JSON config payload to homeassistant/sensor/{device_id}/{key}/config for each of its eight sensors. Home Assistant reads those payloads and automatically creates the entities under a single device card. If you rename the device later, use the included remove_entities.sh script to clear the old retained config topics before restarting with the new name — otherwise both sets of entities linger.

    Architecture Diagram

    EXTERNAL API Open-Meteo DWD ICON-D2 global_tilted_irradiance temperature_2m minutely_15  |  no API key forecast_days=2 (~48 h) tilt=35°  |  azimuth (converted) api.open-meteo.com/v1/dwd-icon HTTPS  |  timeout=20 s raise_for_status() requests.get() HTTPS GET PYTHON SERVICE PROCESS   /   systemd-managed irradiance_forecast.py venv Python 3  |  paho-mqtt ≥ 2.1  |  requests ≥ 2.31  |  tzdata  |  UPDATE_INTERVAL=900 s (15 min) Config Loading .env (next to script) OR systemd EnvironmentFile= real env vars always win over .env parse fetch_forecast() HTTP GET with lat/lon, tilt, azimuth, timezone azimuth_api = AZIMUTH − 180 compass(0=N,90=E) → Open-Meteo(0=S,90=W) returns JSON blob (minutely_15 arrays) per slot: estimate_ac_power_w() see physics model → build_state_payload() aggregates 15-min slots → 8 sensor values idx_now = last slot ≤ now (clamped) remaining anchored to current_slot_ts (not strict >now) afternoon window: AFTERNOON_START_H–AFTERNOON_END_H (12–16) make_client() paho CallbackAPIVersion.VERSION2 LWT: availability → “offline” (QoS 1, retained) reconnect_delay 1–60 s  |  keepalive=60 s publish_discovery() 8 sensor config topics on_connect (retained, QoS 1) PHYSICS MODEL   estimate_ac_power_w(poa, ambient_temp) PVWatts-style  ·  11× Longi LR5-66HTH-535M  ·  Deye SUN-10K-SG01HP3 POA Irradiance (W/m²) global_tilted_irradiance from Open-Meteo NOCT Cell Temperature (°C) T_cell = T_amb + (NOCT−20)/800 × POA NOCT = 45 °C  (typical for this cell family) DC Power (W) P_dc = ARRAY_WP × (POA/1000)       × (1 + γ × (T_cell − 25)) ARRAY_WP = 5885 Wp   γ = −0.0029 /°C  (LR5 HPBC datasheet) × Inverter Efficiency P_ac = P_dc × 0.97 flat 97% approximation (≈97.6% peak / ~97% Euro eff) AC Power (W) — clipped return min(P_ac, 10 000 W) Deye SUN-10K AC nominal limit No shading, no soiling, no spectral correction — intentional simplification Designed to track forecast shape, not compete with full pvlib if poa ≤ 0: return 0.0  (guard) DEPLOYMENT /etc/systemd/system/irradiance_forecast.service   |   Type=simple  |  Restart=on-failure  |  RestartSec=15 /opt/irradiance_forecast/  ·  venv/bin/python  ·  .env   |   After=network-online.target SIGTERM / SIGINT → shutdown flag → publishes availability=”offline” → disconnect –dry-run mode: fetch + compute once, print JSON, no MQTT  |  TZ from /etc/localtime symlink MQTT_USER / MQTT_PASSWORD optional  |  HOSTNAME_LABEL → DEVICE_SLUG (alphanum, lowercase) DEVICE_ID = pv_forecast_{DEVICE_SLUG}  |  loop: main thread sleeps 1 s intervals, checks stop flag paho-mqtt TCP 3 topics  QoS 1 MQTT BROKER user-configured host MQTT_HOST  |  MQTT_PORT=1883 Topics (all retained  |  QoS 1): ▶ state pv_forecast/{DEVICE_SLUG}/state JSON payload with 8 fields + “updated” ▶ availability  (LWT) pv_forecast/{DEVICE_SLUG}/availability LWT payload: “offline”   online: “online” ▶ discovery ×8 homeassistant/sensor/ {DEVICE_ID}/{key}/config reconnect_delay: 1–60 s (auto) client_id: irradiance_forecast_{SLUG} MQTT_USER / MQTT_PASSWORD optional loop_start() (background thread) publish_discovery on on_connect callback MQTT Discovery HOME ASSISTANT MQTT Discovery auto-creates 1 device + 8 sensors DEVICE pv_forecast_{DEVICE_SLUG} 8 SENSORS Power power_w  ·  W  ·  device_class: power state_class: measurement  |  mdi:solar-power Energy Remaining This Hour energy_hour_kwh  ·  kWh  ·  energy slots: current_slot_ts ≤ t < hour_end Energy Today energy_today_kwh  ·  kWh  ·  energy full-day forecast total (NOT elapsed) — same as tomorrow Energy Remaining Today energy_today_remaining_kwh  ·  kWh t ≥ current_slot_ts & same day (partial slot = future) Energy Next Hour energy_next_hour_kwh  ·  kWh  ·  energy slots: hour_end ≤ t < next_hour_end Energy Tomorrow energy_tomorrow_kwh  ·  kWh  ·  energy full-day forecast (same semantics as energy_today) POA Irradiance poa_irradiance_wm2  ·  W/m²  ·  irradiance current slot raw value  |  mdi:weather-sunny Afternoon Solar Forecast afternoon_energy_kwh  ·  kWh sell-vs-store battery decision signal window: AFTERNOON_START_H–END_H (default 12–16) Common to all sensors: entity_id: sensor.{DEVICE_SLUG}_{key} unique_id: pv_forecast_{SLUG}_{key} state_topic: pv_forecast/{SLUG}/state value_template: value_json.{json_key} availability_topic: shared availability device: identifiers, name, manufacturer, model all sensors appear under one HA device card afternoon_energy: no device_class (no standard match) LEGEND Component zones: External service / API Python service process Physics model sub-box Function sub-box Config sub-box MQTT broker (external) Home Assistant Arrows: HTTPS GET MQTT publish MQTT subscribe / consume Internal function flow Physics chain / cross-call Deployment boundary 2026-07  ·  grounded in irradiance_forecast.py source SA agent / Irradiance_Forecast project

    The system has four components connected left to right:

    Open-Meteo API — the only external dependency. The script calls it over HTTPS every 15 minutes, requesting global_tilted_irradiance and temperature_2m at 15-minute resolution for the next 48 hours. No key, no account.

    irradiance_forecast.py — the Python service (systemd-managed, runs in a venv under /opt). Internally it chains four steps:

    1. Config loading — reads .env or systemd EnvironmentFile=, real env vars always win
    2. fetch_forecast() — fires the HTTP GET, converts azimuth convention (compass → Open-Meteo)
    3. Physics model — per slot: NOCT cell temp → DC power with temp derating → × inverter efficiency → clipped at AC limit (10 kW)
    4. build_state_payload() — aggregates the per-slot AC values into the 8 sensor numbers, then make_client() / publish_discovery() pushes them to the broker

    MQTT Broker — holds three retained topics: the JSON state payload, an availability (LWT) topic, and 8 discovery config topics under homeassistant/sensor/…

    Home Assistant — subscribes to those topics. The 8 discovery configs auto-create one device card with 8 sensors; no YAML needed. State updates arrive as a single JSON blob, each sensor extracts its field via value_template.

    Data flows strictly left to right: Open-Meteo → Python service → MQTT → HA. Nothing flows back.

    The eight sensors

    Once running, the service publishes the following sensors every 15 minutes (configurable via UPDATE_INTERVAL):

    • Power — current estimated AC output in Watts
    • Energy Remaining This Hour — kWh forecast for the rest of the current clock hour
    • Energy Today — full-day forecast total in kWh (not elapsed — the same semantics as Energy Tomorrow)
    • Energy Remaining Today — kWh from the current 15-minute slot to end of day
    • Energy Next Hour — kWh forecast for the next complete clock hour
    • Energy Tomorrow — full next-day forecast in kWh
    • POA Irradiance — raw plane-of-array irradiance in W/m² for the current slot
    • Afternoon Solar Forecast — kWh total for a configurable afternoon window (default 12:00–16:00), useful as a sell-vs-store battery decision signal

    What you will need

    • A Linux host with Python 3.9 or later (the script uses zoneinfo, which requires 3.9+)
    • An MQTT broker reachable from that host — Mosquitto running on the same machine or on your Home Assistant instance both work
    • Home Assistant with the MQTT integration configured and connected to the same broker
    • mosquitto-clients installed (only needed if you use the remove_entities.sh cleanup script)
    • Your panel array’s latitude, longitude, tilt, and compass azimuth

    The solution works as-is for most setups — deploy it, fill in the .env file, and the sensors appear in Home Assistant. That said, the hardware constants (panel count, rated power, temperature coefficient, inverter rating) are specific to your installation and need to be edited directly in the Python file. The most comfortable way to do this is with Claude Code running inside Home Assistant’s VS Code panel, where you can describe your hardware in plain English and let it make the changes for you. If you have not set that up yet, this guide walks through integrating VS Code and Claude Code into the HA UI.

    The files

    The project consists of six files. Copy each one to your working directory before running the deploy script.

    irradiance_forecast.py

    This is the main script. All configuration — including hardware constants — is driven by the .env file. No values need to be edited in the Python source before deploying.

    🐍
    #!/usr/bin/env python3
    class="st">"""
    irradiance_forecast.py — PV production forecast publisher
    
    Pulls tilted-plane solar irradiance from Open-Meteo's DWD ICON-D2 model
    (free, non-commercial tier — no key needed, personal home-automation use
    is explicitly an allowed use case), converts it into an estimated AC
    power / energy figure for THIS specific array + inverter, and publishes
    it to Home Assistant via MQTT Discovery as one device with several
    sensors.
    
    Deploy at: /opt/irradiance_forecast/irradiance_forecast.py
    Config:    /opt/irradiance_forecast/.env   (see .env.example)
    Service:   /opt/irradiance_forecast/irradiance_forecast.service
    
    Test the math/API call without touching MQTT at all:
        python3 irradiance_forecast.py --dry-run
    class="st">"""
    
    import argparse
    import json
    import logging
    import os
    import re
    import signal
    import sys
    import time
    from datetime import datetime, timedelta
    from zoneinfo import ZoneInfo
    
    import requests
    
    # ============================================================
    # 1) ENV CONFIG — loaded from .env next to this script
    #    (also works fine if systemd's EnvironmentFile= sets these
    #     directly — real env vars always win over the file below)
    # ============================================================
    
    SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
    
    
    def _strip_inline_comment(value):
        # Strip trailing inline comments (e.g. `160 #orientation`), but leave
        # quoted values alone so a literal "#" can still be used. Applied to
        # every value env() returns, not just the .env-file parse below,
        # because systemd's EnvironmentFile= only treats a line as a comment
        # when "#"/";" is its very FIRST character — it does NOT strip inline
        # comments — so a value set that way reaches os.environ unstripped and
        # would otherwise bypass this entirely (real env vars win over the
        # file parse in env() below).
        if value is None or value.startswith(class="st">'"') or value.startswith(class="st">"'"):
            return value
        return re.split(r"\s+#", value, maxsplit=1)[0].strip()
    
    
    def load_dotenv(path):
        env = {}
        if os.path.exists(path):
            with open(path, encoding=class="st">"utf-8") as f:
                for line in f:
                    line = line.strip()
                    if not line or line.startswith("#") or "=" not in line:
                        continue
                    key, _, value = line.partition(class="st">"=")
                    env[key.strip()] = _strip_inline_comment(value.strip())
        return env
    
    
    _FILE_ENV = load_dotenv(os.path.join(SCRIPT_DIR, class="st">".env"))
    
    
    def env(key, default=None):
        # Real process environment (e.g. systemd EnvironmentFile=) wins;
        # falls back to the plain .env file parsed above; then a default.
        return _strip_inline_comment(os.environ.get(key, _FILE_ENV.get(key, default)))
    
    
    MQTT_HOST       = env(class="st">"MQTT_HOST", class="st">"localhost")
    MQTT_PORT       = int(env(class="st">"MQTT_PORT", class="st">"1883"))
    MQTT_USER       = env(class="st">"MQTT_USER") or None
    MQTT_PASSWORD   = env(class="st">"MQTT_PASSWORD") or None
    HOSTNAME_LABEL  = env(class="st">"HOSTNAME_LABEL", class="st">"pv_forecast")
    UPDATE_INTERVAL = int(env(class="st">"UPDATE_INTERVAL", class="st">"900"))
    
    # ============================================================
    # 2) SITE / HARDWARE CONFIG — edit these, they rarely change
    # ============================================================
    
    LATITUDE  = float(env(class="st">"LATITUDE",  class="st">"0"))
    LONGITUDE = float(env(class="st">"LONGITUDE", class="st">"0"))
    
    
    def get_local_tz():
        # Follow the host's configured timezone (/etc/localtime symlink).
        try:
            zone_name = os.path.realpath(class="st">"/etc/localtime").split(class="st">"zoneinfo/", 1)[1]
            return ZoneInfo(zone_name)
        except Exception:
            return ZoneInfo(class="st">"UTC")
    
    
    LOCAL_TZ = get_local_tz()
    
    # Tilt: 0 = flat/horizontal, 90 = vertical. Same convention everywhere.
    PANEL_TILT_DEG = float(env(class="st">"PANEL_TILT_DEG", class="st">"35"))
    
    # Azimuth as compass bearing: 0=North, 90=East, 180=South, 270=West.
    # Conversion to Open-Meteo convention happens in fetch_forecast().
    AZIMUTH = float(env(class="st">"AZIMUTH", class="st">"180"))
    
    # Array + inverter
    ARRAY_WP            = float(env(class="st">"ARRAY_WP",            class="st">"5885"))    # total STC peak power (W)
    TEMP_COEFF_PMAX     = float(env(class="st">"TEMP_COEFF_PMAX",    class="st">"-0.0029"))  # Pmax temp coeff (%/°C as decimal)
    NOCT_C              = float(env(class="st">"NOCT_C",              class="st">"45.0"))    # nominal operating cell temp (°C)
    INVERTER_AC_LIMIT_W = float(env(class="st">"INVERTER_AC_LIMIT_W", class="st">"10000"))   # nominal AC rating (W)
    INVERTER_EFFICIENCY = float(env(class="st">"INVERTER_EFFICIENCY",  class="st">"0.97"))   # flat DC→AC efficiency
    
    OPEN_METEO_URL = class="st">"https://api.open-meteo.com/v1/dwd-icon"
    FORECAST_DAYS  = 2
    
    AFTERNOON_START_H = int(env(class="st">"AFTERNOON_START_H", class="st">"12"))
    AFTERNOON_END_H   = int(env(class="st">"AFTERNOON_END_H",   class="st">"16"))
    
    # ============================================================
    # 3) MQTT / HA DISCOVERY TOPOLOGY
    # ============================================================
    
    DEVICE_SLUG = (
        class="st">"".join(c if c.isalnum() else class="st">"_" for c in HOSTNAME_LABEL.lower()).strip(class="st">"_")
        or class="st">"pv_forecast"
    )
    DEVICE_ID         = fclass="st">"pv_forecast_{DEVICE_SLUG}"
    DISCOVERY_PREFIX  = class="st">"homeassistant"
    STATE_TOPIC       = fclass="st">"pv_forecast/{DEVICE_SLUG}/state"
    AVAILABILITY_TOPIC = fclass="st">"pv_forecast/{DEVICE_SLUG}/availability"
    
    DEVICE_INFO = {
        class="st">"identifiers": [DEVICE_ID],
        class="st">"name":        HOSTNAME_LABEL,
        class="st">"manufacturer": class="st">"irradiance_forecast.py (Open-Meteo / DWD ICON-D2)",
        class="st">"model":       class="st">"11x Longi LR5-66HTH-535M / Deye SUN-10K-SG01HP3-EU-AM2",
    }
    
    SENSORS = [
        (class="st">"power",               class="st">"Power",                    class="st">"W",    class="st">"power",      class="st">"measurement", class="st">"power_w",                    class="st">"mdi:solar-power"),
        (class="st">"energy_hour",         class="st">"Energy Remaining This Hour",class="st">"kWh", class="st">"energy",     class="st">"measurement", class="st">"energy_hour_kwh",             class="st">"mdi:solar-power-variant"),
        (class="st">"energy_today",        class="st">"Energy Today",             class="st">"kWh",  class="st">"energy",     class="st">"measurement", class="st">"energy_today_kwh",            class="st">"mdi:solar-power-variant"),
        (class="st">"energy_today_remaining",class="st">"Energy Remaining Today", class="st">"kWh",  class="st">"energy",     class="st">"measurement", class="st">"energy_today_remaining_kwh",  class="st">"mdi:solar-power-variant"),
        (class="st">"energy_next_hour",    class="st">"Energy Next Hour",         class="st">"kWh",  class="st">"energy",     class="st">"measurement", class="st">"energy_next_hour_kwh",        class="st">"mdi:solar-power-variant"),
        (class="st">"energy_tomorrow",     class="st">"Energy Tomorrow",          class="st">"kWh",  class="st">"energy",     class="st">"measurement", class="st">"energy_tomorrow_kwh",         class="st">"mdi:solar-power-variant"),
        (class="st">"poa_irradiance",      class="st">"POA Irradiance",           class="st">"W/m²", class="st">"irradiance", class="st">"measurement", class="st">"poa_irradiance_wm2",          class="st">"mdi:weather-sunny"),
        (class="st">"afternoon_energy",    class="st">"Afternoon Solar Forecast", class="st">"kWh",  None,         class="st">"measurement", class="st">"afternoon_energy_kwh",        class="st">"mdi:weather-sunny-alert"),
    ]
    
    log = logging.getLogger(class="st">"irradiance_forecast")
    
    
    # ============================================================
    # 4) FETCH + PHYSICS MODEL
    # ============================================================
    
    def fetch_forecast():
        params = {
            class="st">"latitude":    LATITUDE,
            class="st">"longitude":   LONGITUDE,
            class="st">"minutely_15": class="st">"global_tilted_irradiance,temperature_2m",
            class="st">"tilt":        PANEL_TILT_DEG,
            class="st">"azimuth":     AZIMUTH - 180,  # compass→Open-Meteo convention
            class="st">"forecast_days": FORECAST_DAYS,
            class="st">"timezone":    LOCAL_TZ.key,
        }
        resp = requests.get(OPEN_METEO_URL, params=params, timeout=20)
        resp.raise_for_status()
        return resp.json()
    
    
    def estimate_ac_power_w(poa_irradiance_wm2, ambient_temp_c):
        class="st">"""PVWatts-style estimate: NOCT cell-temp model + linear temp derating,
        flat inverter efficiency, clipped at the inverter's AC rating.class="st">"""
        if poa_irradiance_wm2 is None or poa_irradiance_wm2 <= 0:
            return 0.0
        cell_temp_c = ambient_temp_c + (NOCT_C - 20.0) / 800.0 * poa_irradiance_wm2
        dc_power_w  = ARRAY_WP * (poa_irradiance_wm2 / 1000.0) * (
            1 + TEMP_COEFF_PMAX * (cell_temp_c - 25.0)
        )
        dc_power_w = max(0.0, dc_power_w)
        ac_power_w = dc_power_w * INVERTER_EFFICIENCY
        return min(ac_power_w, INVERTER_AC_LIMIT_W)
    
    
    def build_state_payload(data, now=None):
        now   = now or datetime.now(LOCAL_TZ)
        times = data[class="st">"minutely_15"][class="st">"time"]
        poa   = data[class="st">"minutely_15"][class="st">"global_tilted_irradiance"]
        temp  = data[class="st">"minutely_15"][class="st">"temperature_2m"]
    
        ts = [datetime.fromisoformat(t).replace(tzinfo=LOCAL_TZ) for t in times]
        ac = [estimate_ac_power_w(g, t) for g, t in zip(poa, temp)]
    
        idx_now = 0
        for i, t in enumerate(ts):
            if t <= now:
                idx_now = i
            else:
                break
    
        hour_start    = now.replace(minute=0, second=0, microsecond=0)
        hour_end      = hour_start + timedelta(hours=1)
        next_hour_end = hour_end   + timedelta(hours=1)
        today         = now.date()
        tomorrow      = today + timedelta(days=1)
    
        # Anchor to current slot start — partially-elapsed slot counts as future.
        current_slot_ts = ts[idx_now]
    
        energy_hour_wh = sum(
            p * 0.25 for p, t in zip(ac, ts)
            if current_slot_ts <= t < hour_end
        )
        energy_today_wh = sum(                      # full-day total, NOT elapsed
            p * 0.25 for p, t in zip(ac, ts) if t.date() == today
        )
        energy_today_remaining_wh = sum(
            p * 0.25 for p, t in zip(ac, ts)
            if t.date() == today and t >= current_slot_ts
        )
        energy_next_hour_wh = sum(
            p * 0.25 for p, t in zip(ac, ts)
            if hour_end <= t < next_hour_end
        )
        energy_tomorrow_wh = sum(
            p * 0.25 for p, t in zip(ac, ts) if t.date() == tomorrow
        )
        afternoon_wh = sum(
            p * 0.25 for p, t in zip(ac, ts)
            if t.date() == today and AFTERNOON_START_H <= t.hour < AFTERNOON_END_H
        )
    
        return {
            class="st">"power_w":                    round(ac[idx_now]),
            class="st">"energy_hour_kwh":            round(energy_hour_wh            / 1000.0, 3),
            class="st">"energy_today_kwh":           round(energy_today_wh           / 1000.0, 3),
            class="st">"energy_today_remaining_kwh": round(energy_today_remaining_wh / 1000.0, 3),
            class="st">"energy_next_hour_kwh":       round(energy_next_hour_wh       / 1000.0, 3),
            class="st">"energy_tomorrow_kwh":        round(energy_tomorrow_wh        / 1000.0, 3),
            class="st">"poa_irradiance_wm2":         round(poa[idx_now]) if poa[idx_now] is not None else 0,
            class="st">"afternoon_energy_kwh":       round(afternoon_wh              / 1000.0, 3),
            class="st">"updated":                    now.isoformat(),
        }
    
    
    # ============================================================
    # 5) MQTT
    # ============================================================
    
    def publish_discovery(client):
        for key, name, unit, device_class, state_class, json_key, icon in SENSORS:
            config_topic = fclass="st">"{DISCOVERY_PREFIX}/sensor/{DEVICE_ID}/{key}/config"
            payload = {
                class="st">"name":             name,
                class="st">"unique_id":        fclass="st">"{DEVICE_ID}_{key}",
                class="st">"object_id":        fclass="st">"{DEVICE_SLUG}_{key}",
                class="st">"state_topic":      STATE_TOPIC,
                class="st">"value_template":   fclass="st">"{{{{ value_json.{json_key} }}}}",
                class="st">"unit_of_measurement": unit,
                class="st">"state_class":      state_class,
                class="st">"icon":             icon,
                class="st">"availability_topic": AVAILABILITY_TOPIC,
                class="st">"device":           DEVICE_INFO,
            }
            if device_class is not None:
                payload[class="st">"device_class"] = device_class
            client.publish(config_topic, json.dumps(payload), qos=1, retain=True)
        log.info(class="st">"Published HA discovery config for %d sensors under device %s",
                 len(SENSORS), DEVICE_ID)
    
    
    def make_client():
        import paho.mqtt.client as mqtt
        client = mqtt.Client(
            mqtt.CallbackAPIVersion.VERSION2,
            client_id=fclass="st">"irradiance_forecast_{DEVICE_SLUG}"
        )
        if MQTT_USER:
            client.username_pw_set(MQTT_USER, MQTT_PASSWORD)
        client.will_set(AVAILABILITY_TOPIC, payload=class="st">"offline", qos=1, retain=True)
        client.reconnect_delay_set(min_delay=1, max_delay=60)
    
        def on_connect(client, userdata, flags, reason_code, properties):
            if reason_code == 0:
                log.info(class="st">"Connected to MQTT broker at %s:%s", MQTT_HOST, MQTT_PORT)
                publish_discovery(client)
                client.publish(AVAILABILITY_TOPIC, class="st">"online", qos=1, retain=True)
            else:
                log.error(class="st">"MQTT connect failed: %s", reason_code)
    
        def on_disconnect(client, userdata, flags, reason_code, properties=None):
            log.warning(class="st">"Disconnected from MQTT broker (reason=%s)", reason_code)
    
        client.on_connect    = on_connect
        client.on_disconnect = on_disconnect
        return client
    
    
    # ============================================================
    # 6) MAIN
    # ============================================================
    
    def run_dry():
        logging.basicConfig(level=logging.INFO, format=class="st">"%(asctime)s %(levelname)s %(message)s")
        log.info(class="st">"Dry run: fetching Open-Meteo ICON-D2 forecast (no MQTT)...")
        data    = fetch_forecast()
        payload = build_state_payload(data)
        print(json.dumps(payload, indent=2))
        return payload
    
    
    def run_service():
        logging.basicConfig(level=logging.INFO, format=class="st">"%(asctime)s %(levelname)s %(message)s")
        log.info(class="st">"Starting irradiance_forecast: device=%s mqtt=%s:%s interval=%ss",
                 DEVICE_ID, MQTT_HOST, MQTT_PORT, UPDATE_INTERVAL)
    
        client = make_client()
        client.connect(MQTT_HOST, MQTT_PORT, keepalive=60)
        client.loop_start()
    
        stop = {class="st">"flag": False}
    
        def shutdown(signum, frame):
            log.info(class="st">"Signal %s received, shutting down...", signum)
            stop[class="st">"flag"] = True
    
        signal.signal(signal.SIGTERM, shutdown)
        signal.signal(signal.SIGINT,  shutdown)
    
        try:
            while not stop[class="st">"flag"]:
                try:
                    data    = fetch_forecast()
                    payload = build_state_payload(data)
                    client.publish(STATE_TOPIC, json.dumps(payload), qos=1, retain=True)
                    log.info(class="st">"Published state: %s", payload)
                except Exception:
                    log.exception(class="st">"Update cycle failed, will retry next interval")
    
                for _ in range(UPDATE_INTERVAL):
                    if stop[class="st">"flag"]:
                        break
                    time.sleep(1)
        finally:
            client.publish(AVAILABILITY_TOPIC, class="st">"offline", qos=1, retain=True)
            time.sleep(0.3)
            client.loop_stop()
            client.disconnect()
            log.info(class="st">"Stopped cleanly.")
    
    
    if __name__ == class="st">"__main__":
        parser = argparse.ArgumentParser(description=class="st">"PV forecast -> MQTT/HA publisher")
        parser.add_argument(class="st">"--dry-run", action=class="st">"store_true",
                            help=class="st">"fetch + compute once, print JSON, exit (no MQTT)")
        args = parser.parse_args()
    
        if args.dry_run:
            run_dry()
            sys.exit(0)
    
        run_service()

    Requirements.md

    This file doubles as the pip requirements list. Both install.sh and deploy.sh pass it directly to pip install -r.
    🐍
    requests>=2.31
    paho-mqtt>=2.1
    tzdata

    irradiance_forecast.service

    The systemd unit file. The deploy script copies this to /etc/systemd/system/ — systemd does not load unit files from /opt, so the two locations are intentional. The script itself, the venv, and the .env all live under /opt/irradiance_forecast/.

    🐍
    # This file belongs in /etc/systemd/system/irradiance_forecast.service
    # The .py / .env / venv stay in /opt/irradiance_forecast/.
    #
    # Install:
    #   sudo cp irradiance_forecast.service /etc/systemd/system/
    #   sudo systemctl daemon-reload
    #   sudo systemctl enable --now irradiance_forecast
    #   journalctl -u irradiance_forecast -f
    
    [Unit]
    Description=PV production forecast publisher (Open-Meteo ICON-D2 -> MQTT/HA)
    After=network-online.target
    Wants=network-online.target
    
    [Service]
    Type=simple
    WorkingDirectory=/opt/irradiance_forecast
    EnvironmentFile=/opt/irradiance_forecast/.env
    ExecStart=/opt/irradiance_forecast/venv/bin/python /opt/irradiance_forecast/irradiance_forecast.py
    Restart=on-failure
    RestartSec=15
    # Uncomment to run as a dedicated unprivileged user:
    # User=irradiance_forecast
    # Group=irradiance_forecast
    
    [Install]
    WantedBy=multi-user.target

    deploy.sh

    Run this once as root from your project directory. It is idempotent — safe to re-run after updating the script. It creates the install directory, copies the source, sets up the Python venv, installs dependencies, registers the systemd service, and enables it to start on boot. If a .env already exists at /opt/irradiance_forecast/.env it will not be overwritten, so your credentials survive updates.

    🐍
    #!/usr/bin/env bash
    # Deploys irradiance_forecast to /opt/irradiance_forecast and installs it as
    # a systemd service. Idempotent — safe to re-run after updates.
    # Run as root: sudo bash deploy.sh
    
    set -euo pipefail
    
    INSTALL_DIR=/opt/irradiance_forecast
    SERVICE=irradiance_forecast
    SCRIPT_DIR="$(cd "$(dirname "<span class="sv">${BASH_SOURCE[0]}</span>")" && pwd)"
    
    if [[ "sv">$EUID -ne 0 ]]; then
        echo "error: run as root (sudo bash deploy.sh)" >&2
        exit 1
    fi
    
    echo "==> Installing irradiance_forecast to <span class="sv">$INSTALL_DIR</span>"
    
    # 1. Create install directory and copy source.
    install -d -m 755 "<span class="sv">$INSTALL_DIR</span>"
    cp "<span class="sv">$SCRIPT_DIR</span>/irradiance_forecast.py" "<span class="sv">$INSTALL_DIR</span>/"
    cp "<span class="sv">$SCRIPT_DIR</span>/Requirements.md"        "<span class="sv">$INSTALL_DIR</span>/"
    chmod -R a+rX "<span class="sv">$INSTALL_DIR</span>"
    
    # 2. .env — never overwrite one already deployed.
    if [[ -f "<span class="sv">$INSTALL_DIR</span>/.env" ]]; then
        echo "==> <span class="sv">$INSTALL_DIR</span>/.env already exists, leaving it untouched"
    elif [[ -f "<span class="sv">$SCRIPT_DIR</span>/.env" ]]; then
        cp "<span class="sv">$SCRIPT_DIR</span>/.env" "<span class="sv">$INSTALL_DIR</span>/.env"
        chmod 600 "<span class="sv">$INSTALL_DIR</span>/.env"
        echo "==> Copied .env from project folder"
    else
        cat > "<span class="sv">$INSTALL_DIR</span>/.env" << 'EOF'
    MQTT_HOST=localhost
    MQTT_PORT=1883
    MQTT_USER=
    MQTT_PASSWORD=
    HOSTNAME_LABEL=pv_forecast
    UPDATE_INTERVAL=900
    LATITUDE=0
    LONGITUDE=0
    AZIMUTH=0
    EOF
        chmod 600 "<span class="sv">$INSTALL_DIR</span>/.env"
        echo "==> Created a template .env — edit it before starting the service"
    fi
    
    # 3. Python venv.
    if [[ ! -d "<span class="sv">$INSTALL_DIR</span>/venv" ]]; then
        echo "==> Creating venv"
        python3 -m venv "<span class="sv">$INSTALL_DIR</span>/venv"
    fi
    "<span class="sv">$INSTALL_DIR</span>/venv/bin/pip" install --quiet --upgrade pip
    "<span class="sv">$INSTALL_DIR</span>/venv/bin/pip" install --quiet -r "<span class="sv">$INSTALL_DIR</span>/Requirements.md"
    echo "==> Python deps installed"
    
    # 4. systemd unit — the ONE file that does NOT live under /opt.
    install -m 644 "<span class="sv">$SCRIPT_DIR</span>/irradiance_forecast.service" /etc/systemd/system/
    systemctl daemon-reload
    systemctl enable "<span class="sv">$SERVICE</span>"
    echo "==> systemd unit installed and enabled"
    
    echo ""
    echo "Done. Next steps:"
    echo "  1. Edit <span class="sv">$INSTALL_DIR</span>/.env  (MQTT_HOST, LATITUDE, LONGITUDE, AZIMUTH ...)"
    echo "  2. systemctl start <span class="sv">$SERVICE</span>"
    echo "  3. journalctl -u <span class="sv">$SERVICE</span> -f"

    install.sh

    Use this if you want to run the script locally from the project folder without deploying to /opt — useful for development or a quick test on a machine that already has Python set up.

    🐍
    #!/usr/bin/env bash
    # Sets up a local venv and installs the deps from Requirements.md.
    # Run once from the project folder:  ./install.sh
    # Then test with:  venv/bin/python irradiance_forecast.py --dry-run
    
    set -euo pipefail
    SCRIPT_DIR="$(cd "$(dirname "<span class="sv">${BASH_SOURCE[0]}</span>")" && pwd)"
    cd "<span class="sv">$SCRIPT_DIR</span>"
    
    python3 -m venv venv
    venv/bin/pip install --upgrade pip
    venv/bin/pip install -r Requirements.md
    
    echo
    echo "Done. Run the script with:"
    echo "  <span class="sv">$SCRIPT_DIR</span>/venv/bin/python <span class="sv">$SCRIPT_DIR</span>/irradiance_forecast.py --dry-run"

    remove_entities.sh

    When you change HOSTNAME_LABEL — or decommission the host entirely — old retained MQTT Discovery config topics linger in the broker and HA shows ghost entities alongside the new ones. This script discovers all retained discovery topics for your device, publishes empty retained payloads to each one (which causes HA to remove the entities), then clears the retained state and availability topics too.

    ⚠️ Requires mosquitto-clientsapt install mosquitto-clients
    🐍
    #!/usr/bin/env bash
    # Removes all MQTT discovery entities for HOSTNAME_LABEL's device from HA.
    # Usage: bash remove_entities.sh [path-to-.env]
    #        (defaults to /opt/irradiance_forecast/.env)
    set -euo pipefail
    
    ENV_FILE="<span class="sv">${1:-/opt/irradiance_forecast/.env}</span>"
    [[ -f "<span class="sv">$ENV_FILE</span>" ]] && source "<span class="sv">$ENV_FILE</span>"
    
    HOSTNAME_LABEL="<span class="sv">${HOSTNAME_LABEL:?set HOSTNAME_LABEL in $ENV_FILE}</span>"
    MQTT_HOST="<span class="sv">${MQTT_HOST:?set MQTT_HOST in $ENV_FILE}</span>"
    MQTT_PORT="<span class="sv">${MQTT_PORT:-1883}</span>"
    MQTT_USER="<span class="sv">${MQTT_USER:-}</span>"
    MQTT_PASSWORD="<span class="sv">${MQTT_PASSWORD:-}</span>"
    
    AUTH=(-h "<span class="sv">$MQTT_HOST</span>" -p "<span class="sv">$MQTT_PORT</span>")
    [[ -n "<span class="sv">$MQTT_USER</span>" ]] && AUTH+=(-u "<span class="sv">$MQTT_USER</span>" -P "<span class="sv">$MQTT_PASSWORD</span>")
    
    # Same slugging rule as DEVICE_SLUG in irradiance_forecast.py.
    SLUG=$(echo -n "<span class="sv">$HOSTNAME_LABEL</span>" \
      | tr '[:upper:]' '[:lower:]' \
      | sed -E 's/[^a-z0-9]/_/g; s/^_+|_+$//g')
    [[ -z "<span class="sv">$SLUG</span>" ]] && SLUG="pv_forecast"
    DEVICE_ID="pv_forecast_<span class="sv">${SLUG}</span>"
    
    echo "Discovering retained discovery topics for device '<span class="sv">${DEVICE_ID}</span>'..."
    
    TOPICS=$(
        mosquitto_sub "<span class="sv">${AUTH[@]}</span>" \
            -t "homeassistant/sensor/<span class="sv">${DEVICE_ID}</span>/+/config" \
            -W 3 -v 2>/dev/null \
        | awk '{print $1}' \
        || true
    )
    
    if [[ -z "<span class="sv">$TOPICS</span>" ]]; then
        echo "No discovery entities found for '<span class="sv">${DEVICE_ID}</span>' — nothing to remove."
    else
        COUNT=0
        while IFS= read -r topic; do
            [[ -z "<span class="sv">$topic</span>" ]] && continue
            echo "  removing: <span class="sv">$topic</span>"
            mosquitto_pub "<span class="sv">${AUTH[@]}</span>" -t "<span class="sv">$topic</span>" -n -r   # empty retained = remove
            ((COUNT++))
        done <<< "<span class="sv">$TOPICS</span>"
        echo "Removed <span class="sv">${COUNT}</span> entities for '<span class="sv">${DEVICE_ID}</span>'."
    fi
    
    # Clear retained state + availability (both published with retain=True).
    mosquitto_pub "<span class="sv">${AUTH[@]}</span>" -t "pv_forecast/<span class="sv">${SLUG}</span>/state"        -n -r
    mosquitto_pub "<span class="sv">${AUTH[@]}</span>" -t "pv_forecast/<span class="sv">${SLUG}</span>/availability" -n -r
    echo "Cleared retained state/availability topics for '<span class="sv">${SLUG}</span>'."

    Configuring your system

    The .env file

    All configuration lives in .env — MQTT credentials, site coordinates, hardware constants, and the afternoon window. Create this file at /opt/irradiance_forecast/.env before starting the service. The deploy script creates a template automatically if none exists.

    ⚠️ All values must be plain numbers — no Python expressions. Write ARRAY_WP=5885, not ARRAY_WP=11*535. The script reads values as strings and passes them to float(); arithmetic expressions crash the service at startup.

    The hardware values below come from your panel and inverter datasheets. Here is where to find each one:

    • PANEL_TILT_DEG — roof tilt angle in degrees (0 = flat, 90 = vertical). Sent to Open-Meteo alongside AZIMUTH as a server-side geometry input — the API uses it to compute plane-of-array irradiance. Read your roof pitch from the installer’s commissioning report, or measure it. Typical pitched roofs are 25–40°.
    • ARRAY_WP — total STC peak power in Watts. On the panel datasheet find Pmax or Rated Peak Power under the Standard Test Conditions (STC) table — not NOCT or low-irradiance figures. Multiply by panel count. Example: 11 panels × 535 Wp = 5885.
    • TEMP_COEFF_PMAX — temperature coefficient of Pmax, as a decimal per °C. On the panel datasheet look for γPmaxTemp. Coeff. of Pmax, or Power Temperature Coefficient. It is listed as a percentage, e.g. −0.29 %/°C. Divide by 100 to get the decimal: −0.29 ÷ 100 = -0.0029. The sign is always negative — power drops as the cell heats up.
    • NOCT_C — Nominal Operating Cell Temperature in °C. On the panel datasheet look for NOCT or TONC. Typical range is 42–49 °C. If the datasheet does not list it, 45 is a safe conservative default for standard aluminium-framed panels.
    • INVERTER_AC_LIMIT_W — nominal continuous AC output power in Watts. On the inverter datasheet find Rated AC Output Power or Nominal AC Power — not the peak or max surge figure. For a Deye SUN-10K this is 10000 W.
    • INVERTER_EFFICIENCY — flat DC→AC conversion efficiency. Use the European Efficiency (ηEuro) figure from the inverter datasheet — it is a weighted average across realistic operating points and more representative than the peak efficiency. Convert from percentage: 97.6 % → 0.976. If only peak efficiency is listed, subtract 1–2 percentage points as an approximation and express as a decimal.
    🐍
    # MQTT broker connection
    MQTT_HOST=192.168.1.10        # IP or hostname of your broker
    MQTT_PORT=1883
    MQTT_USER=your_mqtt_username
    MQTT_PASSWORD=your_mqtt_password
    
    # Device identity — drives entity_id and MQTT topic path in HA
    HOSTNAME_LABEL=pv_forecast
    
    # How often to fetch and publish (seconds).
    # 900 = every 15 minutes, matching Open-Meteo data resolution.
    UPDATE_INTERVAL=900
    
    # Panel installation coordinates (decimal degrees)
    LATITUDE=52.2297
    LONGITUDE=21.0122
    
    # Panel plane geometry (both sent to Open-Meteo as query parameters)
    PANEL_TILT_DEG=35             # 0 = flat/horizontal, 90 = vertical
    AZIMUTH=180                   # compass bearing: 0=N, 90=E, 180=S, 270=W
    
    # Array hardware — update these for your panels
    ARRAY_WP=5885                 # total STC peak power (panel_count × panel_Wp)
    TEMP_COEFF_PMAX=-0.0029       # Pmax temp coeff from datasheet (%/°C as decimal)
    NOCT_C=45.0                   # nominal operating cell temperature (°C)
    
    # Inverter hardware
    INVERTER_AC_LIMIT_W=10000     # nominal continuous AC output rating (W)
    INVERTER_EFFICIENCY=0.97      # flat DC→AC efficiency approximation
    
    # Afternoon sell-vs-store decision window (24h local time)
    AFTERNOON_START_H=12
    AFTERNOON_END_H=16

    ⚠️ Inline comments after values (e.g. AZIMUTH=180 #south-facing) are stripped automatically by the script’s own parser. systemd’s EnvironmentFile= does not strip inline comments natively, but the script handles this too — every value goes through the same comment-stripping function regardless of where it came from.

    Testing before connecting to Home Assistant

    The script has a --dry-run mode that fetches the Open-Meteo forecast, runs the physics model, prints the resulting JSON payload, and exits without touching MQTT at all. It is the fastest way to verify that your coordinates, tilt, and azimuth produce sensible numbers before you involve the broker.

    💻
    venv/bin/python irradiance_forecast.py --dry-run

    The output looks like this (values are illustrative):

    📋
    {
      "power_w": 3842,
      "energy_hour_kwh": 0.847,
      "energy_today_kwh": 24.312,
      "energy_today_remaining_kwh": 11.204,
      "energy_next_hour_kwh": 0.921,
      "energy_tomorrow_kwh": 19.874,
      "poa_irradiance_wm2": 687,
      "afternoon_energy_kwh": 8.441,
      "updated": "2026-07-31T13:22:07.441882+02:00"
    }

    A few sanity checks: power_w should be plausible for the time of day and season. At solar noon on a clear summer day for a ~6 kWp array it should be somewhere between 4 000 W and the inverter’s AC limit. If you get zero for every slot, your coordinates or azimuth conversion might be off. If power_w sits exactly at INVERTER_AC_LIMIT_W for many consecutive slots, your array is clipping at the inverter limit — that is physically correct behaviour for an undersized inverter, not a bug.

    What you get in Home Assistant

    Once the service is running and connected to the same MQTT broker as Home Assistant, the entities appear automatically under a single device card. No YAML configuration required — MQTT Discovery handles everything. The device will be named whatever you set in HOSTNAME_LABEL and will carry eight sensor entities with entity IDs following the pattern sensor.{hostname_label}_{key}.

    The most immediately useful automations to build from these sensors:

    • Use energy_today_remaining vs. current battery SoC to decide whether to sell surplus now or store for evening use — the afternoon_energy_kwh sensor was built specifically for this.
    • Compare energy_tomorrow_kwh against your household daily consumption to pre-charge or pre-cool ahead of a low-production day.
    • Use power_w in a Riemann sum helper to cross-check against your inverter’s own production meter.
    • Alert when poa_irradiance_wm2 drops sharply mid-day — a sudden dip during what should be a clear afternoon can indicate soiling or partial shading worth investigating.

    Cleaning up old entities

    If you ever change HOSTNAME_LABEL or move the service to a different host, the old retained MQTT Discovery payloads stay in the broker. Home Assistant will keep showing the ghost entities as unavailable. Run remove_entities.sh to clear them cleanly:

    💻
    bash remove_entities.sh
    # or, if your .env is in a non-default location:
    bash remove_entities.sh /path/to/.env

    The script subscribes to the discovery topic pattern for your device, collects all retained config topics, and publishes an empty retained payload to each one — which is the MQTT Discovery signal that tells HA to remove the entity. It then clears the retained state and availability topics. The whole thing completes in a few seconds.

    ⚠️You need mosquitto-clients installed for this script: sudo apt install mosquitto-clients

    Wrapping up

    The whole solution comes in under 400 lines of Python, has three runtime dependencies, requires no cloud account or API key, and drops eight useful sensors into Home Assistant without touching a single line of YAML. The physics model is honest about its simplicity — it does not try to replace pvlib, it tries to give you numbers that are good enough to make battery dispatch and load-shifting decisions. For most home automation use cases, that is exactly what you need.

    The update interval defaults to 15 minutes to match the Open-Meteo data resolution. Polling more frequently than that fetches the same data and just adds unnecessary load to a free service — leave it at 900 seconds unless you have a specific reason to change it.

    Scroll to Top