7 Commits

Author SHA1 Message Date
Nils Reiners
4af2460736 läuft noch nicht; slave inverter liest in komischen zeitabständen und es gibt wohl bei einigen Registereinträgen probleme 2025-10-29 22:06:53 +01:00
Nils Reiners
38116390df still not able to connect to slave 2025-10-23 14:00:44 +02:00
Nils Reiners
5827b494b5 slave pv inverter implemented - not yet tested 2025-10-23 13:24:44 +02:00
Nils Reiners
ba6ff9f6c3 stündliche Speicherung des Forecasts angepasst 2025-10-07 22:34:16 +02:00
Nils Reiners
9ccb1e042b stündliche Speicherung des Forecasts angepasst 2025-10-07 22:33:02 +02:00
Nils Reiners
a5bcfca39a stündliche Speicherung des Forecasts angepasst 2025-10-07 22:29:49 +02:00
Nils Reiners
a1f9e29134 pv forecaster added 2025-10-07 20:52:28 +02:00
11 changed files with 473 additions and 128 deletions

View File

@@ -1,5 +1,7 @@
from influxdb_client import InfluxDBClient, Point, WritePrecision from influxdb_client import InfluxDBClient, Point, WritePrecision
from datetime import datetime from datetime import datetime
import datetime as dt
import pandas as pd
class DataBaseInflux: class DataBaseInflux:
def __init__(self, url: str, token: str, org: str, bucket: str): def __init__(self, url: str, token: str, org: str, bucket: str):
@@ -25,4 +27,22 @@ class DataBaseInflux:
# Punkt in InfluxDB schreiben # Punkt in InfluxDB schreiben
self.write_api.write(bucket=self.bucket, org=self.org, record=point) self.write_api.write(bucket=self.bucket, org=self.org, record=point)
def store_forecasts(self, forecast_name: str, data: pd.Series):
measurement = forecast_name
run_tag = dt.datetime.now(dt.timezone.utc).replace(second=0, microsecond=0).isoformat(timespec="minutes")
pts = []
series = pd.to_numeric(data, errors="coerce").dropna()
for ts, val in series.items():
pts.append(
Point(measurement)
.tag("run", run_tag)
.field("value", float(val))
.time(ts.to_pydatetime(), WritePrecision.S)
)
self.write_api.write(bucket=self.bucket, org=self.org, record=pts)

View File

@@ -0,0 +1,61 @@
#!/usr/bin/env python3
import time
import datetime as dt
import requests
from zoneinfo import ZoneInfo
from matplotlib import pyplot as plt
import pandas as pd
TZ = "Europe/Berlin"
DAYS = 2
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
class WeatherForecaster:
def __init__(self, latitude, longitude):
self.lat = latitude
self.lon = longitude
def get_hourly_forecast(self, start_hour, days):
start_hour_local = start_hour
end_hour_local = start_hour_local + dt.timedelta(days=days)
params = {
"latitude": self.lat,
"longitude": self.lon,
"hourly": ["temperature_2m", "shortwave_radiation", "wind_speed_10m"],
"timezone": TZ,
"start_hour": start_hour_local.strftime("%Y-%m-%dT%H:%M"),
"end_hour": end_hour_local.strftime("%Y-%m-%dT%H:%M")
}
h = requests.get(OPEN_METEO_URL, params=params).json()["hourly"]
time_stamps = h["time"]
time_stamps = [
dt.datetime.fromisoformat(t).replace(tzinfo=ZoneInfo(TZ))
for t in time_stamps
]
weather = pd.DataFrame(index=time_stamps)
weather["ghi"] = h["shortwave_radiation"]
weather["temp_air"] = h["temperature_2m"]
weather["wind_speed"] = h["wind_speed_10m"]
return weather
if __name__=='__main__':
weather_forecast = WeatherForecaster(latitude=48.041, longitude=7.862)
while True:
now = dt.datetime.now()
secs = 60 - now.second #(60 - now.minute) * 60 - now.second # Sekunden bis volle Stunde
time.sleep(secs)
now_local = dt.datetime.now()
start_hour_local = (now_local + dt.timedelta(hours=1)).replace(minute=0, second=0, microsecond=0)
time_stamps, temps, ghi, wind_speed = weather_forecast.get_hourly_forecast(start_hour_local, DAYS)
plt.plot(time_stamps, temps)
plt.show()

63
main.py
View File

@@ -1,12 +1,16 @@
import time import time
from datetime import datetime from datetime import datetime
from data_base_influx import DataBaseInflux from data_base_influx import DataBaseInflux
from forecaster.weather_forecaster import WeatherForecaster
from heat_pump import HeatPump from heat_pump import HeatPump
from pv_inverter import PvInverter from pv_inverter import PvInverter
from simulators.pv_plant_simulator import PvWattsSubarrayConfig, PvWattsPlant
from solaredge_meter import SolaredgeMeter from solaredge_meter import SolaredgeMeter
from shelly_pro_3m import ShellyPro3m from shelly_pro_3m import ShellyPro3m
from energysystem import EnergySystem from energysystem import EnergySystem
from sg_ready_controller import SgReadyController from sg_ready_controller import SgReadyController
from pvlib.location import Location
import datetime as dt
# For dev-System run in terminal: ssh -N -L 127.0.0.1:8111:10.0.0.10:502 pi@192.168.1.146 # For dev-System run in terminal: ssh -N -L 127.0.0.1:8111:10.0.0.10:502 pi@192.168.1.146
# For productive-System change IP-adress in heatpump to '10.0.0.10' and port to 502 # For productive-System change IP-adress in heatpump to '10.0.0.10' and port to 502
@@ -22,25 +26,58 @@ db = DataBaseInflux(
bucket="allmende_db" bucket="allmende_db"
) )
hp_master = HeatPump(device_name='hp_master', ip_address='10.0.0.10', port=502) # hp_master = HeatPump(device_name='hp_master', ip_address='10.0.0.10', port=502)
hp_slave = HeatPump(device_name='hp_slave', ip_address='10.0.0.11', port=502) # hp_slave = HeatPump(device_name='hp_slave', ip_address='10.0.0.11', port=502)
shelly = ShellyPro3m(device_name='wohnung_2_6', ip_address='192.168.1.121') # shelly = ShellyPro3m(device_name='wohnung_2_6', ip_address='192.168.1.121')
wr = PvInverter(device_name='solaredge_master', ip_address='192.168.1.112') wr_master = PvInverter(device_name='solaredge_master', ip_address='192.168.1.112', unit=1)
wr_slave = PvInverter(device_name='solaredge_slave', ip_address='192.168.1.112', unit=3)
meter = SolaredgeMeter(device_name='solaredge_meter', ip_address='192.168.1.112') meter = SolaredgeMeter(device_name='solaredge_meter', ip_address='192.168.1.112')
es.add_components(hp_master, hp_slave, shelly, wr, meter) es.add_components(wr_master, wr_slave)#hp_master, hp_slave, shelly, wr_master, wr_slave, meter)
controller = SgReadyController(es) # controller = SgReadyController(es)
#
# # FORECASTING
# latitude = 48.041
# longitude = 7.862
# TZ = "Europe/Berlin"
# HORIZON_DAYS = 2
# weather_forecaster = WeatherForecaster(latitude=latitude, longitude=longitude)
# site = Location(latitude=latitude, longitude=longitude, altitude=35, tz=TZ, name="Gundelfingen")
#
# p_module = 435
# upper_roof_north = PvWattsSubarrayConfig(name="north", pdc0_w=(29+29+21)*p_module, tilt_deg=10, azimuth_deg=20, dc_loss=0.02, ac_loss=0.01)
# upper_roof_south = PvWattsSubarrayConfig(name="south", pdc0_w=(29+21+20)*p_module, tilt_deg=10, azimuth_deg=200, dc_loss=0.02, ac_loss=0.01)
# upper_roof_east = PvWattsSubarrayConfig(name="east", pdc0_w=7*p_module, tilt_deg=10, azimuth_deg=110, dc_loss=0.02, ac_loss=0.01)
# upper_roof_west = PvWattsSubarrayConfig(name="west", pdc0_w=7*p_module, tilt_deg=10, azimuth_deg=290, dc_loss=0.02, ac_loss=0.01)
# cfgs = [upper_roof_north, upper_roof_south, upper_roof_east, upper_roof_west]
# pv_plant = PvWattsPlant(site, cfgs)
#
# now = datetime.now()
# next_forecast_at = (now + dt.timedelta(hours=1)).replace(minute=0, second=0, microsecond=0)
while True: while True:
now = datetime.now() now = datetime.now()
if now.second % interval_seconds == 0 and now.microsecond < 100_000: if now.second % interval_seconds == 0 and now.microsecond < 100_000:
state = es.get_state_and_store_to_database(db) state = es.get_state_and_store_to_database(db)
mode = controller.perform_action(heat_pump_name='hp_master', meter_name='solaredge_meter', state=state) # mode = controller.perform_action(heat_pump_name='hp_master', meter_name='solaredge_meter', state=state)
#
# if mode == 'mode1':
# mode_as_binary = 0
# else:
# mode_as_binary = 1
# db.store_data('sg_ready', {'mode': mode_as_binary})
# if now >= next_forecast_at:
# # Start der Prognose: ab der kommenden vollen Stunde
# start_hour_local = (now + dt.timedelta(hours=1)).replace(minute=0, second=0, microsecond=0)
# weather = weather_forecaster.get_hourly_forecast(start_hour_local, HORIZON_DAYS)
# total = pv_plant.get_power(weather)
# db.store_forecasts('pv_forecast', total)
#
# # Nächste geplante Ausführung definieren (immer volle Stunde)
# # Falls wir durch Delay mehrere Stunden verpasst haben, hole auf:
# while next_forecast_at <= now:
# next_forecast_at = (next_forecast_at + dt.timedelta(hours=1)).replace(minute=0, second=0, microsecond=0)
if mode == 'mode1':
mode_as_binary = 0
else:
mode_as_binary = 1
db.store_data('sg_ready', {'mode': mode_as_binary})
time.sleep(0.1) time.sleep(0.1)

View File

@@ -1,139 +1,155 @@
import time # pv_inverter.py
import struct # -*- coding: utf-8 -*-
import pandas as pd from typing import Optional, Dict, Any, List
from typing import Dict, Any, List, Tuple, Optional
from pymodbus.client import ModbusTcpClient from pymodbus.client import ModbusTcpClient
from pymodbus.exceptions import ModbusIOException
import struct
import time
EXCEL_PATH = "modbus_registers/pv_inverter_registers.xlsx"
# Obergrenze: bis EXKLUSIVE 40206 (d.h. max. 40205)
MAX_ADDR_EXCLUSIVE = 40121
class PvInverter: class PvInverter:
def __init__(self, device_name: str, ip_address: str, port: int = 502, unit: int = 1): """
self.device_name = device_name Minimaler Reader für einen SolarEdge-Inverter hinter Modbus-TCP→RTU-Gateway.
self.ip = ip_address Liest nur die bekannten Register (wie im funktionierenden Skript).
self.port = port Kompatibel mit pymodbus 2.5.x und 3.x kein retry_on_empty.
self.unit = unit """
self.client: Optional[ModbusTcpClient] = None
self.registers: Dict[int, Dict[str, Any]] = {} # addr -> {"desc":..., "type":...}
self.connect_to_modbus()
self.load_registers(EXCEL_PATH)
# ---------- Verbindung ---------- def __init__(
def connect_to_modbus(self): self,
self.client = ModbusTcpClient(self.ip, port=self.port, timeout=3.0, retries=3) device_name: str,
ip_address: str,
port: int = 502,
unit_id: int = 1,
timeout: float = 1.5,
silent_interval: float = 0.02,
):
self.device_name = device_name
self.host = ip_address
self.port = port
self.unit = unit_id
self.timeout = timeout
self.silent_interval = silent_interval
self.client: Optional[ModbusTcpClient] = None
self._connect()
# ---------------- Verbindung ----------------
def _connect(self):
# retries=0: keine internen Mehrfachversuche
self.client = ModbusTcpClient(self.host, port=self.port, timeout=self.timeout, retries=0)
if not self.client.connect(): if not self.client.connect():
print("Verbindung zu Wechselrichter fehlgeschlagen.") raise ConnectionError(f"Verbindung zu {self.device_name} ({self.host}:{self.port}) fehlgeschlagen.")
raise SystemExit(1) print(f"✅ Verbindung hergestellt zu {self.device_name} ({self.host}:{self.port}, unit={self.unit})")
print("✅ Verbindung zu Wechselrichter hergestellt.")
def close(self): def close(self):
if self.client: if self.client:
self.client.close() self.client.close()
self.client = None self.client = None
# ---------- Register-Liste ---------- # ---------------- Low-Level Lesen ----------------
def load_registers(self, excel_path: str): def _read_regs(self, addr: int, count: int) -> Optional[List[int]]:
xls = pd.ExcelFile(excel_path) """Liest 'count' Holding-Register ab base-0 'addr' für die konfigurierte Unit-ID."""
df = xls.parse() try:
# Passe Spaltennamen hier an, falls nötig: rr = self.client.read_holding_registers(address=addr, count=count, slave=self.unit)
cols = ["MB Adresse", "Beschreibung", "Variabel Typ"] except ModbusIOException:
df = df[cols].dropna() time.sleep(self.silent_interval)
df["MB Adresse"] = df["MB Adresse"].astype(int) return None
except Exception:
time.sleep(self.silent_interval)
return None
# 1) Vorab-Filter: nur Adressen < 40206 übernehmen time.sleep(self.silent_interval)
df = df[df["MB Adresse"] < MAX_ADDR_EXCLUSIVE] if not rr or rr.isError():
return None
return rr.registers
self.registers = {
int(row["MB Adresse"]): {
"desc": str(row["Beschreibung"]).strip(),
"type": str(row["Variabel Typ"]).strip()
}
for _, row in df.iterrows()
}
# ---------- Low-Level Lesen ----------
def _try_read(self, fn_name: str, address: int, count: int) -> Optional[List[int]]:
fn = getattr(self.client, fn_name)
# pymodbus 3.8.x hat 'slave='; Fallbacks schaden nicht
for kwargs in (dict(address=address, count=count, slave=self.unit),
dict(address=address, count=count)):
try:
res = fn(**kwargs)
if res is None or (hasattr(res, "isError") and res.isError()):
continue
return res.registers
except TypeError:
continue
return None
def _read_any(self, address: int, count: int) -> Optional[List[int]]:
regs = self._try_read("read_holding_registers", address, count)
if regs is None:
regs = self._try_read("read_input_registers", address, count)
return regs
# ---------- Decoding ----------
@staticmethod @staticmethod
def _to_i16(u16: int) -> int: def _to_int16(u16: int) -> int:
return struct.unpack(">h", struct.pack(">H", u16))[0] return struct.unpack(">h", struct.pack(">H", u16))[0]
@staticmethod @staticmethod
def _to_f32_from_two(u16_hi: int, u16_lo: int, msw_first: bool = True) -> float: def _apply_sf(raw: int, sf: int) -> float:
b = struct.pack(">HH", u16_hi, u16_lo) if msw_first else struct.pack(">HH", u16_lo, u16_hi) return raw * (10 ** sf)
return struct.unpack(">f", b)[0]
# Hilfsfunktion: wie viele 16-Bit-Register braucht dieser Typ?
@staticmethod @staticmethod
def _word_count_for_type(rtype: str) -> int: def _read_string_from_regs(regs: List[int]) -> Optional[str]:
rt = (rtype or "").lower() b = b"".join(struct.pack(">H", r) for r in regs)
# Passe hier an deine Excel-Typen an: s = b.decode("ascii", errors="ignore").rstrip("\x00 ").strip()
if "uint32" in rt or "real" in rt or "float" in rt or "string(32)" in rt: return s or None
return 2
# Default: 1 Wort (z.B. int16/uint16)
return 1
def read_one(self, address_excel: int, rtype: str) -> Optional[float]: # ---------------- Hilfsfunktionen ----------------
""" def _read_string(self, addr: int, words: int) -> Optional[str]:
Liest einen Wert nach Typ ('INT' oder 'REAL' etc.). regs = self._read_regs(addr, words)
Es werden ausschließlich Register < 40206 gelesen. if regs is None:
"""
addr = int(address_excel)
words = self._word_count_for_type(rtype)
# 2) Harte Grenze prüfen: höchstes angefasstes Register muss < 40206 sein
if addr + words - 1 >= MAX_ADDR_EXCLUSIVE:
# Überspringen, da der Lesevorgang die Grenze >= 40206 berühren würde
return None return None
return self._read_string_from_regs(regs)
if words == 2: def _read_scaled(self, value_addr: int, sf_addr: int) -> Optional[float]:
regs = self._read_any(addr, 2) regs = self._read_regs(value_addr, 1)
if not regs or len(regs) < 2: sf = self._read_regs(sf_addr, 1)
return None if regs is None or sf is None:
# Deine bisherige Logik interpretiert 2 Worte als Float32: return None
return self._to_f32_from_two(regs[0], regs[1]) raw = self._to_int16(regs[0])
else: sff = self._to_int16(sf[0])
regs = self._read_any(addr, 1) return self._apply_sf(raw, sff)
if not regs:
return None
return float(self._to_i16(regs[0]))
def _read_u32_with_sf(self, value_addr: int, sf_addr: int) -> Optional[float]:
regs = self._read_regs(value_addr, 2)
sf = self._read_regs(sf_addr, 1)
if regs is None or sf is None:
return None
u32 = (regs[0] << 16) | regs[1]
sff = self._to_int16(sf[0])
return self._apply_sf(u32, sff)
# ---------------- Öffentliche API ----------------
def get_state(self) -> Dict[str, Any]: def get_state(self) -> Dict[str, Any]:
""" """Liest exakt die bekannten Register und gibt ein Dict zurück."""
Liest ALLE Register aus self.registers und gibt dict zurück. state: Dict[str, Any] = {}
Achtet darauf, dass keine Adresse (inkl. Mehrwort) >= 40206 gelesen wird.
""" # --- Common Block ---
data = {"Zeit": time.strftime("%Y-%m-%d %H:%M:%S")} state["C_Manufacturer"] = self._read_string(40004, 16)
for address, meta in sorted(self.registers.items()): state["C_Model"] = self._read_string(40020, 16)
words = self._word_count_for_type(meta["type"]) state["C_Version"] = self._read_string(40044, 8)
# 3) Nochmals Schutz auf Ebene der Iteration: state["C_SerialNumber"] = self._read_string(40052, 16)
if address + words - 1 >= MAX_ADDR_EXCLUSIVE:
continue # --- Inverter Block ---
val = self.read_one(address, meta["type"]) state["I_AC_Power_W"] = self._read_scaled(40083, 40084)
if val is None: state["I_AC_Voltage_V"] = self._read_scaled(40079, 40082)
continue state["I_AC_Frequency_Hz"] = self._read_scaled(40085, 40086)
key = f"{address} - {meta['desc']}" state["I_DC_Power_W"] = self._read_scaled(40100, 40101)
data[key] = val state["I_AC_Energy_Wh_total"] = self._read_u32_with_sf(40093, 40095)
return data
status_regs = self._read_regs(40107, 2)
if status_regs:
state["I_Status"] = status_regs[0]
state["I_Status_Vendor"] = status_regs[1]
else:
state["I_Status"] = None
state["I_Status_Vendor"] = None
return state
# ---------------- Beispiel ----------------
if __name__ == "__main__":
MODBUS_IP = "192.168.1.112"
MODBUS_PORT = 502
master = PvInverter("solaredge_master", MODBUS_IP, port=MODBUS_PORT, unit_id=1)
slave = PvInverter("solaredge_slave", MODBUS_IP, port=MODBUS_PORT, unit_id=3)
try:
sm = master.get_state()
ss = slave.get_state()
print("\n=== MASTER ===")
for k, v in sm.items():
print(f"{k:22s}: {v}")
print("\n=== SLAVE ===")
for k, v in ss.items():
print(f"{k:22s}: {v}")
finally:
master.close()
slave.close()

View File

@@ -1,4 +1,5 @@
pymodbus~=3.8.6 pymodbus~=3.8.6
pandas pandas
openpyxl openpyxl
sshtunnel sshtunnel
pvlib

View File

@@ -0,0 +1,210 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional, Dict, List, Literal, Tuple, Union
import numpy as np
import pandas as pd
import pvlib
import matplotlib.pyplot as plt
from pvlib.location import Location
from pvlib.pvsystem import PVSystem
from pvlib.modelchain import ModelChain
SeriesOrArray = Union[pd.Series, np.ndarray]
# ----------------------------- Konfiguration -----------------------------
@dataclass
class PvWattsSubarrayConfig:
name: str
pdc0_w: float # STC-DC-Leistung [W]
tilt_deg: float # Neigung (0=horizontal)
azimuth_deg: float # Azimut (180=Süd)
gamma_pdc: float = -0.004 # Tempkoeff. [1/K]
eta_inv_nom: float = 0.96 # WR-Wirkungsgrad (nominal)
albedo: float = 0.2 # Bodenreflexion
# Pauschale Verluste (PVWatts-Losses)
dc_loss: float = 0.0
ac_loss: float = 0.0
soiling: float = 0.0
# Modell
transposition_model: Literal["perez","haydavies","isotropic","klucher","reindl"] = "perez"
# ------------------------------ Subarray ---------------------------------
class PvWattsSubarray:
"""
Ein Subarray mit pvlib.ModelChain (PVWatts).
Berechnet automatisch DNI/DHI aus GHI (ERBS-Methode)
und nutzt ein SAPM-Temperaturmodell.
"""
def __init__(self, cfg: PvWattsSubarrayConfig, location: Location):
self.cfg = cfg
self.location = location
self._mc: Optional[ModelChain] = None
# ---------------------------------------------------------------------
def _create_modelchain(self) -> ModelChain:
"""Erzeuge eine pvlib.ModelChain-Instanz mit PVWatts-Parametern."""
temp_params = pvlib.temperature.TEMPERATURE_MODEL_PARAMETERS["sapm"]["open_rack_glass_polymer"]
system = PVSystem(
surface_tilt=self.cfg.tilt_deg,
surface_azimuth=self.cfg.azimuth_deg,
module_parameters={"pdc0": self.cfg.pdc0_w, "gamma_pdc": self.cfg.gamma_pdc},
inverter_parameters={"pdc0": self.cfg.pdc0_w, "eta_inv_nom": self.cfg.eta_inv_nom},
albedo=self.cfg.albedo,
temperature_model_parameters=temp_params,
module_type="glass_polymer",
racking_model="open_rack",
)
mc = ModelChain(
system, self.location,
transposition_model=self.cfg.transposition_model,
solar_position_method="nrel_numpy",
airmass_model="kastenyoung1989",
dc_model="pvwatts",
ac_model="pvwatts",
aoi_model="physical",
spectral_model=None,
losses_model="pvwatts",
temperature_model="sapm",
)
mc.losses_parameters = {
"dc_loss": float(self.cfg.dc_loss),
"ac_loss": float(self.cfg.ac_loss),
"soiling": float(self.cfg.soiling),
}
self._mc = mc
return mc
# ---------------------------------------------------------------------
def calc_dni_and_dhi(self, weather: pd.DataFrame) -> pd.DataFrame:
"""
Berechnet DNI & DHI aus GHI über die ERBS-Methode.
Gibt ein neues DataFrame mit 'ghi', 'dni', 'dhi' zurück.
"""
if "ghi" not in weather:
raise ValueError("Wetterdaten benötigen mindestens 'ghi'.")
# Sonnenstand bestimmen
sp = self.location.get_solarposition(weather.index)
erbs = pvlib.irradiance.erbs(weather["ghi"], sp["zenith"], weather.index)
out = weather.copy()
out["dni"] = erbs["dni"].clip(lower=0)
out["dhi"] = erbs["dhi"].clip(lower=0)
return out
# ---------------------------------------------------------------------
def _prepare_weather(self, weather: pd.DataFrame) -> pd.DataFrame:
"""Sichert vollständige Spalten (ghi, dni, dhi, temp_air, wind_speed)."""
if "ghi" not in weather or "temp_air" not in weather:
raise ValueError("weather benötigt Spalten: 'ghi' und 'temp_air'.")
w = weather.copy()
# Zeitzone prüfen
if w.index.tz is None:
w.index = w.index.tz_localize(self.location.tz)
else:
if str(w.index.tz) != str(self.location.tz):
w = w.tz_convert(self.location.tz)
# Wind default
if "wind_speed" not in w:
w["wind_speed"] = 1.0
# DNI/DHI ergänzen (immer mit ERBS)
if "dni" not in w or "dhi" not in w:
w = self.calc_dni_and_dhi(w)
return w
# ---------------------------------------------------------------------
def get_power(self, weather: pd.DataFrame) -> pd.Series:
"""
Berechnet AC-Leistung aus Wetterdaten.
"""
w = self._prepare_weather(weather)
mc = self._create_modelchain()
mc.run_model(weather=w)
return mc.results.ac.rename(self.cfg.name)
# ------------------------------- Anlage ----------------------------------
class PvWattsPlant:
"""
Eine PV-Anlage mit mehreren Subarrays, die ein gemeinsames Wetter-DataFrame nutzt.
"""
def __init__(self, site: Location, subarray_cfgs: List[PvWattsSubarrayConfig]):
self.site = site
self.subs: Dict[str, PvWattsSubarray] = {c.name: PvWattsSubarray(c, site) for c in subarray_cfgs}
def get_power(
self,
weather: pd.DataFrame,
*,
return_breakdown: bool = False
) -> pd.Series | Tuple[pd.Series, Dict[str, pd.Series]]:
"""Berechne Gesamtleistung und optional Einzel-Subarrays."""
parts: Dict[str, pd.Series] = {name: sub.get_power(weather) for name, sub in self.subs.items()}
# gemeinsamen Index bilden
idx = list(parts.values())[0].index
for s in parts.values():
idx = idx.intersection(s.index)
parts = {k: v.reindex(idx).fillna(0.0) for k, v in parts.items()}
total = sum(parts.values())
total.name = "total_ac"
if return_breakdown:
return total, parts
return total
# --------------------------- Beispielnutzung -----------------------------
if __name__ == "__main__":
# Standort
site = Location(latitude=52.52, longitude=13.405, altitude=35, tz="Europe/Berlin", name="Berlin")
# Zeitachse: 1 Tag, 15-minütig
times = pd.date_range("2025-06-21 00:00", "2025-06-21 23:45", freq="15min", tz=site.tz)
# Dummy-Wetter
ghi = 1000 * np.clip(np.sin(np.linspace(0, np.pi, len(times)))**1.2, 0, None)
temp_air = 16 + 8 * np.clip(np.sin(np.linspace(-np.pi/2, np.pi/2, len(times))), 0, None)
wind = np.full(len(times), 1.0)
weather = pd.DataFrame(index=times)
weather["ghi"] = ghi
weather["temp_air"] = temp_air
weather["wind_speed"] = wind
# Zwei Subarrays
cfgs = [
PvWattsSubarrayConfig(name="Sued_30", pdc0_w=6000, tilt_deg=30, azimuth_deg=180, dc_loss=0.02, ac_loss=0.01),
PvWattsSubarrayConfig(name="West_20", pdc0_w=4000, tilt_deg=20, azimuth_deg=270, soiling=0.02),
]
plant = PvWattsPlant(site, cfgs)
# Simulation
total, parts = plant.get_power(weather, return_breakdown=True)
# Plot
plt.figure(figsize=(10, 6))
plt.plot(total.index, total / 1000, label="Gesamtleistung (AC)", linewidth=2, color="black")
for name, s in parts.items():
plt.plot(s.index, s / 1000, label=name)
plt.title("PV-Leistung (PVWatts, ERBS-Methode für DNI/DHI)")
plt.ylabel("Leistung [kW]")
plt.xlabel("Zeit")
plt.legend()
plt.grid(True, linestyle="--", alpha=0.5)
plt.tight_layout()
plt.show()