UPDATE // RELASE NOTES Solar Forecast ML

PATCH-SCRIPT für SFML STATS “JAHRESPROGNOSE”

Es hat sich ein kleiner Fehler im letzten Update von SFML STATS eingeschlichen. Diese wird im kommenden Update behoben.
Wer es aber schon jetzt beheben möchte:

Schritt 1:
die hier angehängte Datei herunterladen

Schritt 2:
Die Dateiendung von .txt in .py umändern

Schritt 3:
zu folgenden Verzeichniss auf eurem HA navigieren (SMB, Filebrowser,…)

config/custom_components/sfml_stats/readers

Schritt 4:
Die Datei: annual_forecast_reader.py durch die heruntergeladene Datei ersetzen
(bitte vorher die Orginaldatei sichern)

Schritt 5:
HA neuladen

Viel Spaß

Zara

WICHTIGER HINWEIS IN DIESEM THREAD SCHREIBE NUR ICH ODER ADMIN`S BITTE NUTZT FÜR WEITERE FRAGEN DEN ENTSPRECHENDEN THREAD

Wer lieber mit einem Code-Editor arbeitet, bitte den Code mit diesem hier ersetzen:

# ******************************************************************************
# @copyright (C) 2026 Zara-Toorox - Solar Forecast Stats x86 DB-Version part of Solar Forecast ML DB
# * This program is protected by a Proprietary Non-Commercial License.
# 1. Personal and Educational use only.
# 2. COMMERCIAL USE AND AI TRAINING ARE STRICTLY PROHIBITED.
# 3. Clear attribution to "Zara-Toorox" is required.
# * Full license terms: https://github.com/Zara-Toorox/ha-solar-forecast-ml/blob/main/LICENSE
# ******************************************************************************

"""Annual yield forecast reader for SFML Stats. @zara"""
from __future__ import annotations

import logging
import math
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Any, AsyncIterator, TYPE_CHECKING

import aiosqlite

from ..const import SOLAR_FORECAST_DB

if TYPE_CHECKING:
    from ..storage.db_connection_manager import DatabaseConnectionManager

_LOGGER = logging.getLogger(__name__)

# Berlin TMY clear-sky index per month (ratio of actual/clear-sky irradiation).
# Derived from DWD TRY 2015 reference year for Central European lowlands.
# Used to extrapolate months without measured data.
_TMY_CLEARSKY_INDEX = {
    1: 0.28, 2: 0.34, 3: 0.42, 4: 0.50, 5: 0.52,
    6: 0.53, 7: 0.51, 8: 0.49, 9: 0.44, 10: 0.35,
    11: 0.26, 12: 0.23,
}

# Approximate daylight hours per month at 52°N for physics baseline scaling.
_DAYLIGHT_HOURS_52N = {
    1: 8.3, 2: 9.8, 3: 11.8, 4: 13.9, 5: 15.6,
    6: 16.6, 7: 16.1, 8: 14.6, 9: 12.7, 10: 10.7,
    11: 8.8, 12: 7.9,
}


@dataclass
class MonthlyYield:
    """Monthly yield summary with measured and projected values. @zara"""

    year: int
    month: int
    measured_days: int
    total_days_in_month: int

    # Measured values (from actual data)
    measured_yield_kwh: float
    measured_avg_daily_kwh: float
    measured_min_day_kwh: float
    measured_max_day_kwh: float
    measured_avg_peak_w: float
    measured_avg_production_hours: float
    measured_avg_accuracy: float

    # Projected values (full month estimate)
    projected_yield_kwh: float
    source: str  # "measured", "partial", "estimated"

    # Self-consumption data (if available)
    self_consumption_kwh: float = 0.0
    grid_export_kwh: float = 0.0
    avg_autarky_percent: float = 0.0


@dataclass
class CalibrationData:
    """Panel group calibration state. @zara"""

    group_name: str
    global_factor: float
    sample_count: int
    confidence: float


@dataclass
class AnnualForecast:
    """Complete annual yield forecast result. @zara"""

    # System info
    latitude: float
    longitude: float
    installed_kwp: float
    panel_groups: list[CalibrationData]

    # Data coverage
    first_data_date: date
    last_data_date: date
    total_measured_days: int

    # Monthly breakdown
    months: list[MonthlyYield]

    # Aggregated annual values
    annual_yield_kwh: float
    specific_yield_kwh_per_kwp: float
    performance_ratio: float
    best_month: int
    worst_month: int

    # Confidence
    measured_months: int
    estimated_months: int
    confidence_percent: float

    # Yield bands
    optimistic_yield_kwh: float
    pessimistic_yield_kwh: float

    # Records
    record_day_kwh: float
    record_day_date: str
    record_peak_w: float


class AnnualForecastReader:
    """Reads historical data and computes an annual yield forecast. @zara"""

    _db_manager: DatabaseConnectionManager | None = None

    def __init__(
        self,
        config_path: Path,
        db_manager: DatabaseConnectionManager | None = None,
    ) -> None:
        """Initialize the annual forecast reader. @zara"""
        self._config_path = config_path
        self._db_path = config_path / SOLAR_FORECAST_DB
        if db_manager is not None:
            AnnualForecastReader._db_manager = db_manager

    @property
    def is_available(self) -> bool:
        """Check if database is accessible. @zara"""
        if self._db_manager is not None:
            return self._db_manager.is_available
        return self._db_path.exists()

    @asynccontextmanager
    async def _get_db_connection(self) -> AsyncIterator[aiosqlite.Connection]:
        """Get a database connection from the manager. @zara"""
        from ..storage.db_connection_manager import get_manager

        manager = get_manager()
        if manager is not None and manager.is_connected:
            yield await manager.get_connection()
        else:
            conn = await aiosqlite.connect(str(self._db_path))
            conn.row_factory = aiosqlite.Row
            try:
                yield conn
            finally:
                await conn.close()

    async def async_get_annual_forecast(self) -> AnnualForecast | None:
        """Compute a full annual yield forecast from historical data. @zara"""
        if not self.is_available:
            _LOGGER.debug("Database not found: %s", self._db_path)
            return None

        try:
            async with self._get_db_connection() as conn:
                system_info = await self._get_system_info(conn)
                calibration = await self._get_calibration_data(conn)
                monthly_data = await self._get_monthly_summaries(conn)
                energy_data = await self._get_monthly_energy(conn)
                records = await self._get_records(conn)
                data_range = await self._get_data_range(conn)

            if not system_info:
                _LOGGER.warning("No system info found in database")
                return None

            latitude = system_info["latitude"]
            installed_kwp = system_info["installed_kwp"]

            # Build 12-month projection
            months = self._build_monthly_projection(
                monthly_data, energy_data, installed_kwp, latitude
            )

            annual_yield = sum(m.projected_yield_kwh for m in months)
            specific_yield = annual_yield / installed_kwp if installed_kwp > 0 else 0

            # Theoretical maximum (clear sky, no losses)
            theoretical_max = self._compute_theoretical_annual(installed_kwp, latitude)
            performance_ratio = annual_yield / theoretical_max if theoretical_max > 0 else 0

            best_month = max(months, key=lambda m: m.projected_yield_kwh).month
            worst_month = min(months, key=lambda m: m.projected_yield_kwh).month

            measured_months = sum(1 for m in months if m.source == "measured")
            partial_months = sum(1 for m in months if m.source == "partial")
            estimated_months = sum(1 for m in months if m.source == "estimated")

            # Confidence based on data coverage
            confidence = (measured_months * 100 + partial_months * 60) / 12

            # Yield bands from daily variance
            optimistic, pessimistic = self._compute_yield_bands(
                months, annual_yield
            )

            return AnnualForecast(
                latitude=latitude,
                longitude=system_info["longitude"],
                installed_kwp=installed_kwp,
                panel_groups=calibration,
                first_data_date=data_range["first"],
                last_data_date=data_range["last"],
                total_measured_days=data_range["count"],
                months=months,
                annual_yield_kwh=round(annual_yield, 1),
                specific_yield_kwh_per_kwp=round(specific_yield, 0),
                performance_ratio=round(performance_ratio * 100, 1),
                best_month=best_month,
                worst_month=worst_month,
                measured_months=measured_months,
                estimated_months=estimated_months,
                confidence_percent=round(confidence, 1),
                optimistic_yield_kwh=round(optimistic, 1),
                pessimistic_yield_kwh=round(pessimistic, 1),
                record_day_kwh=records.get("max_kwh", 0),
                record_day_date=records.get("max_date", ""),
                record_peak_w=records.get("peak_w", 0),
            )

        except Exception as err:
            _LOGGER.error("Error computing annual forecast: %s", err)
            return None

    # -------------------------------------------------------------------------
    # Database queries
    # -------------------------------------------------------------------------

    async def _get_system_info(self, conn: aiosqlite.Connection) -> dict | None:
        """Read system info from astronomy_system_info. @zara"""
        try:
            async with conn.execute(
                """SELECT latitude, longitude, installed_capacity_kwp,
                          max_peak_record_kwh, max_peak_date, max_peak_hour
                   FROM astronomy_system_info WHERE id = 1"""
            ) as cursor:
                row = await cursor.fetchone()
                if row:
                    return {
                        "latitude": row["latitude"],
                        "longitude": row["longitude"],
                        "installed_kwp": row["installed_capacity_kwp"] or 0,
                    }
        except Exception as err:
            _LOGGER.debug("Error reading system info: %s", err)
        return None

    async def _get_calibration_data(
        self, conn: aiosqlite.Connection
    ) -> list[CalibrationData]:
        """Read panel group calibration factors. @zara"""
        result: list[CalibrationData] = []
        try:
            async with conn.execute(
                """SELECT group_name, global_factor, sample_count, confidence
                   FROM physics_calibration_groups"""
            ) as cursor:
                rows = await cursor.fetchall()
                for row in rows:
                    result.append(CalibrationData(
                        group_name=row["group_name"],
                        global_factor=row["global_factor"],
                        sample_count=row["sample_count"],
                        confidence=row["confidence"],
                    ))
        except Exception as err:
            _LOGGER.debug("Error reading calibration data: %s", err)
        return result

    async def _get_monthly_summaries(
        self, conn: aiosqlite.Connection
    ) -> dict[str, dict]:
        """Aggregate daily_summaries by year-month. @zara"""
        result: dict[str, dict] = {}
        try:
            async with conn.execute(
                """SELECT
                       strftime('%Y-%m', date) AS ym,
                       COUNT(*) AS days,
                       ROUND(AVG(actual_total_kwh), 3) AS avg_daily,
                       ROUND(SUM(actual_total_kwh), 3) AS total_kwh,
                       ROUND(MIN(actual_total_kwh), 3) AS min_day,
                       ROUND(MAX(actual_total_kwh), 3) AS max_day,
                       ROUND(AVG(peak_power_w), 0) AS avg_peak_w,
                       ROUND(AVG(production_hours), 1) AS avg_prod_hours,
                       ROUND(AVG(accuracy_percent), 1) AS avg_accuracy
                   FROM daily_summaries
                   WHERE actual_total_kwh > 0
                   GROUP BY strftime('%Y-%m', date)
                   ORDER BY ym"""
            ) as cursor:
                rows = await cursor.fetchall()
                for row in rows:
                    result[row["ym"]] = {
                        "days": row["days"],
                        "avg_daily": row["avg_daily"] or 0,
                        "total_kwh": row["total_kwh"] or 0,
                        "min_day": row["min_day"] or 0,
                        "max_day": row["max_day"] or 0,
                        "avg_peak_w": row["avg_peak_w"] or 0,
                        "avg_prod_hours": row["avg_prod_hours"] or 0,
                        "avg_accuracy": row["avg_accuracy"] or 0,
                    }
        except Exception as err:
            _LOGGER.debug("Error reading monthly summaries: %s", err)
        return result

    async def _get_monthly_energy(
        self, conn: aiosqlite.Connection
    ) -> dict[str, dict]:
        """Aggregate energy stats by year-month. @zara"""
        result: dict[str, dict] = {}
        try:
            async with conn.execute(
                """SELECT
                       strftime('%Y-%m', date) AS ym,
                       ROUND(SUM(self_consumption_kwh), 2) AS self_consumed,
                       ROUND(SUM(grid_export_kwh), 2) AS exported,
                       ROUND(AVG(autarkie_percent), 1) AS avg_autarky
                   FROM stats_daily_energy
                   WHERE solar_yield_kwh > 0
                   GROUP BY strftime('%Y-%m', date)
                   ORDER BY ym"""
            ) as cursor:
                rows = await cursor.fetchall()
                for row in rows:
                    result[row["ym"]] = {
                        "self_consumed": row["self_consumed"] or 0,
                        "exported": row["exported"] or 0,
                        "avg_autarky": row["avg_autarky"] or 0,
                    }
        except Exception as err:
            _LOGGER.debug("Error reading monthly energy: %s", err)
        return result

    async def _get_records(self, conn: aiosqlite.Connection) -> dict:
        """Get all-time records. @zara"""
        result: dict[str, Any] = {}
        try:
            async with conn.execute(
                """SELECT date, actual_total_kwh, peak_power_w
                   FROM daily_summaries
                   WHERE actual_total_kwh > 0
                   ORDER BY actual_total_kwh DESC
                   LIMIT 1"""
            ) as cursor:
                row = await cursor.fetchone()
                if row:
                    result["max_kwh"] = row["actual_total_kwh"]
                    result["max_date"] = row["date"]

            async with conn.execute(
                """SELECT max_peak_record_kwh FROM astronomy_system_info WHERE id = 1"""
            ) as cursor:
                row = await cursor.fetchone()
                if row:
                    result["peak_w"] = row["max_peak_record_kwh"] or 0
        except Exception as err:
            _LOGGER.debug("Error reading records: %s", err)
        return result

    async def _get_data_range(self, conn: aiosqlite.Connection) -> dict:
        """Get first/last date and total days with data. @zara"""
        try:
            async with conn.execute(
                """SELECT MIN(date) AS first_date, MAX(date) AS last_date,
                          COUNT(*) AS total_days
                   FROM daily_summaries
                   WHERE actual_total_kwh > 0"""
            ) as cursor:
                row = await cursor.fetchone()
                if row and row["first_date"]:
                    return {
                        "first": date.fromisoformat(row["first_date"]),
                        "last": date.fromisoformat(row["last_date"]),
                        "count": row["total_days"],
                    }
        except Exception as err:
            _LOGGER.debug("Error reading data range: %s", err)
        return {"first": date.today(), "last": date.today(), "count": 0}

    # -------------------------------------------------------------------------
    # Projection logic
    # -------------------------------------------------------------------------

    def _build_monthly_projection(
        self,
        monthly_data: dict[str, dict],
        energy_data: dict[str, dict],
        installed_kwp: float,
        latitude: float,
    ) -> list[MonthlyYield]:
        """Build a 12-month projection mixing measured and estimated data. @zara"""
        import calendar

        today = date.today()
        current_year = today.year
        months: list[MonthlyYield] = []

        for month_num in range(1, 13):
            days_in_month = calendar.monthrange(current_year, month_num)[1]

            # Find the best matching data for this calendar month
            measured = self._find_best_month_data(monthly_data, month_num)
            energy = self._find_best_month_energy(energy_data, month_num)

            if measured and measured["days"] >= days_in_month * 0.8:
                # Full month of data (80%+ coverage) — use as-is
                projected = measured["total_kwh"]
                # Scale to full month if slightly incomplete
                if measured["days"] < days_in_month:
                    projected = measured["avg_daily"] * days_in_month
                source = "measured"
            elif measured and measured["days"] >= 5:
                # Partial month — extrapolate from daily average
                projected = measured["avg_daily"] * days_in_month
                source = "partial"
            else:
                # No data — estimate from physics + climate
                projected = self._estimate_month(
                    month_num, installed_kwp, latitude
                )
                measured = {
                    "days": 0, "avg_daily": projected / days_in_month,
                    "total_kwh": 0, "min_day": 0, "max_day": 0,
                    "avg_peak_w": 0, "avg_prod_hours": 0, "avg_accuracy": 0,
                }
                source = "estimated"

            months.append(MonthlyYield(
                year=current_year,
                month=month_num,
                measured_days=measured["days"],
                total_days_in_month=days_in_month,
                measured_yield_kwh=round(measured["total_kwh"], 2),
                measured_avg_daily_kwh=round(measured["avg_daily"], 2),
                measured_min_day_kwh=round(measured["min_day"], 2),
                measured_max_day_kwh=round(measured["max_day"], 2),
                measured_avg_peak_w=round(measured["avg_peak_w"], 0),
                measured_avg_production_hours=round(measured["avg_prod_hours"], 1),
                measured_avg_accuracy=round(measured["avg_accuracy"], 1),
                projected_yield_kwh=round(projected, 2),
                source=source,
                self_consumption_kwh=round(energy.get("self_consumed", 0), 2) if energy else 0,
                grid_export_kwh=round(energy.get("exported", 0), 2) if energy else 0,
                avg_autarky_percent=round(energy.get("avg_autarky", 0), 1) if energy else 0,
            ))

        return months

    def _find_best_month_data(
        self, monthly_data: dict[str, dict], target_month: int
    ) -> dict | None:
        """Find the best available data for a given calendar month. @zara"""
        # Prefer most recent year with data for this month
        candidates = []
        for ym, data in monthly_data.items():
            try:
                parts = ym.split("-")
                if int(parts[1]) == target_month:
                    candidates.append((int(parts[0]), data))
            except (ValueError, IndexError):
                continue

        if not candidates:
            return None

        # Return data from the most recent year
        candidates.sort(key=lambda x: x[0], reverse=True)
        return candidates[0][1]

    def _find_best_month_energy(
        self, energy_data: dict[str, dict], target_month: int
    ) -> dict | None:
        """Find energy data for a given calendar month. @zara"""
        candidates = []
        for ym, data in energy_data.items():
            try:
                parts = ym.split("-")
                if int(parts[1]) == target_month:
                    candidates.append((int(parts[0]), data))
            except (ValueError, IndexError):
                continue

        if not candidates:
            return None

        candidates.sort(key=lambda x: x[0], reverse=True)
        return candidates[0][1]

    def _estimate_month(
        self, month: int, installed_kwp: float, latitude: float
    ) -> float:
        """Estimate monthly yield from physics baseline and climate data. @zara"""
        import calendar

        days = calendar.monthrange(date.today().year, month)[1]
        daylight = _DAYLIGHT_HOURS_52N.get(month, 12.0)

        # Adjust daylight for actual latitude (simple linear scaling)
        lat_factor = 1.0 + (latitude - 52.0) * 0.01

        # Peak sun hours = daylight * clear-sky index * atmospheric losses
        clearsky_idx = _TMY_CLEARSKY_INDEX.get(month, 0.40)
        peak_sun_hours = daylight * clearsky_idx * 0.75 * lat_factor

        # Monthly yield = kWp * PSH * days * system_efficiency
        system_efficiency = 0.82
        monthly_kwh = installed_kwp * peak_sun_hours * days * system_efficiency

        return monthly_kwh

    def _compute_theoretical_annual(
        self, installed_kwp: float, latitude: float
    ) -> float:
        """Compute theoretical annual clear-sky yield. @zara"""
        import calendar

        total = 0.0
        year = date.today().year
        for month in range(1, 13):
            days = calendar.monthrange(year, month)[1]
            daylight = _DAYLIGHT_HOURS_52N.get(month, 12.0)
            lat_factor = 1.0 + (latitude - 52.0) * 0.01
            # Clear-sky: no cloud losses, only basic atmospheric + system
            total += installed_kwp * daylight * 0.75 * lat_factor * days * 0.82
        return total

    def _compute_yield_bands(
        self, months: list[MonthlyYield], expected: float
    ) -> tuple[float, float]:
        """Compute optimistic and pessimistic yield bands. @zara"""
        # Use measured variance where available
        measured = [m for m in months if m.source != "estimated"]
        if not measured:
            return expected * 1.15, expected * 0.85

        # Compute coefficient of variation from daily averages
        daily_values = [m.measured_avg_daily_kwh for m in measured if m.measured_avg_daily_kwh > 0]
        if len(daily_values) < 3:
            return expected * 1.15, expected * 0.85

        mean_val = sum(daily_values) / len(daily_values)
        variance = sum((v - mean_val) ** 2 for v in daily_values) / len(daily_values)
        cv = math.sqrt(variance) / mean_val if mean_val > 0 else 0.15

        # Bound the variation to a reasonable range
        spread = min(max(cv * 0.5, 0.08), 0.20)

        return expected * (1 + spread), expected * (1 - spread)

annual_forecast_reader.txt (21,8 KB)

4 „Gefällt mir“