diff --git a/Optimization14A_quickstart.md b/Optimization14A_quickstart.md new file mode 100644 index 000000000..bd6cd56c4 --- /dev/null +++ b/Optimization14A_quickstart.md @@ -0,0 +1,145 @@ +# §14a EnWG Curtailment Analysis - Quick Start Guide + +## What does `analyze_14a.py` do? + +This script performs a complete §14a curtailment analysis for heat pumps and charging points in a distribution grid: + +1. **Loads a grid** (ding0 format) +2. **Adds heat pumps** (50 units, 11-20 kW, realistic size distribution) +3. **Adds charging points** (30 units, 3.7-50 kW, home/work/fast charging) +4. **Generates realistic winter timeseries** (configurable days, default: 7 days) +5. **Runs OPF with §14a curtailment** (opf_version=3) +6. **Analyzes curtailment results** (statistics, daily/monthly aggregation) +7. **Creates visualizations** (3 plots: timeseries, HP profiles, CP profiles) +8. **Exports CSV data** (summary, curtailment data, totals per HP/CP) + +**Output:** Results folder with plots and CSV files for detailed analysis. + +--- + +## Setup & Run (Complete Workflow) + +### Create Project Directory + +```bash +# Create and navigate to project directory +mkdir -p ~/projects/edisgo_14a +cd ~/projects/edisgo_14a +``` + +### Clone eDisGo Repository + +```bash +# Clone the repository +git clone https://github.com/openego/eDisGo.git +cd eDisGo + +# Checkout the §14a feature branch +git checkout project/411_LoMa_14aOptimization_with_virtual_generators +``` + +### Setup Python Environment + +```bash +# Create Python 3.11 virtual environment +python3.11 -m venv .venv + +# Activate virtual environment +source .venv/bin/activate # Linux/Mac +# OR +.venv\Scripts\activate # Windows + +# Upgrade pip +pip install --upgrade pip setuptools wheel +``` + +### Install eDisGo + +```bash +# Install eDisGo in development mode +python -m pip install -e .[dev] # install eDisGo from source +``` + +The script uses grid data from the `30879` folder: + +```bash +# Navigate to workspace root +cd ~/projects/edisgo_14a + +# Grid folder should be at: +# ~/projects/edisgo_14a/30879/ +# with files: buses.csv, lines.csv, generators.csv, etc. + +# Verify grid data exists +ls -lh 30879/ +``` + +### Run the Analysis + +```bash +# Navigate to script location +cd ~/projects/edisgo_14a + +# Run the analysis +python analyze_14a.py +``` + +**Expected runtime:** 5-15 minutes (depending on hardware) + +--- + +## Configuration + +Edit these variables in `analyze_14a.py` (around line 1015): + +```python +# Grid configuration +GRID_PATH = "./30879" # Path to ding0 grid folder +SCENARIO = "eGon2035" # Scenario name + +# Simulation parameters +NUM_DAYS = 7 # Number of days to simulate (7, 30, 365) +NUM_HEAT_PUMPS = 50 # Number of heat pumps to add +NUM_CHARGING_POINTS = 30 # Number of charging points to add + +# Output +OUTPUT_DIR = "./" # Where to save results +``` + +--- + +## Output Files + +After running, you'll find a results folder: + +``` +results_7d_HP50_CP30_14a/ +├── summary_statistics.csv # Overall statistics +├── curtailment_timeseries.csv # Hourly curtailment per HP/CP +├── curtailment_daily.csv # Daily aggregation +├── curtailment_monthly.csv # Monthly aggregation +├── hp_curtailment_total.csv # Total curtailment per HP +├── cp_curtailment_total.csv # Total curtailment per CP +├── curtailment_timeseries.png # Time series plot +├── detailed_hp_profiles.png # Detailed HP profile +└── detailed_cp_profiles.png # Detailed CP profile (if curtailed) +``` + +--- + +## Using Your Own Grid + +### Option 1: Use Another ding0 Grid + +```python +# In analyze_14a.py, change GRID_PATH: +GRID_PATH = "/path/to/your/ding0/grid/folder" + +# Grid folder must contain: +# - buses.csv +# - lines.csv +# - generators.csv +# - loads.csv +# - transformers.csv +# - etc. +``` diff --git a/doc/index.rst b/doc/index.rst index 444e35f04..ccffab368 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -65,6 +65,7 @@ Contents quickstart usage_details features_in_detail + optimization_de dev_notes definitions_and_units configs diff --git a/doc/optimization_de.rst b/doc/optimization_de.rst new file mode 100644 index 000000000..5a48d076e --- /dev/null +++ b/doc/optimization_de.rst @@ -0,0 +1,3002 @@ +Julia-Optimierung in eDisGo mit PowerModels +======================================================================= + +Inhaltsverzeichnis +------------------ + +1. `Überblick <#überblick>`__ +2. `Notation und Meta-Variablen <#notation-und-meta-variablen>`__ +3. `Alle Julia-Variablen + (Tabellarisch) <#alle-julia-variablen-tabellarisch>`__ +4. `Zeitliche Einordnung der + Optimierung <#zeitliche-einordnung-der-optimierung>`__ +5. `Die analyze-Funktion <#die-analyze-funktion>`__ +6. `Die reinforce-Funktion <#die-reinforce-funktion>`__ +7. `Die §14a EnWG Optimierung <#die-14a-enwg-optimierung>`__ +8. `Zeitreihen-Nutzung <#zeitreihen-nutzung>`__ +9. `Dateipfade und Referenzen <#dateipfade-und-referenzen>`__ + +-------------- + +Überblick +--------- + +Die Julia-Optimierung in eDisGo verwendet **PowerModels.jl** zur Lösung +von Optimal Power Flow (OPF) Problemen. Der Workflow erfolgt über eine +Python-Julia-Schnittstelle: + +- **Python (eDisGo)**: Netzmodellierung, Zeitreihen, + Ergebnisverarbeitung +- **Julia (PowerModels)**: Mathematische Optimierung, Solver-Interface +- **Kommunikation**: JSON über stdin/stdout + +**Optimierungsziele:** - Minimierung von Netzverlusten - Einhaltung von +Spannungs- und Stromgrenzen - Flexibilitätsnutzung (Speicher, +Wärmepumpen, E-Autos, DSM) - Optional: §14a EnWG Abregelung mit +Zeitbudget-Constraints + +-------------- + +Notation und Meta-Variablen +--------------------------- + +Bevor wir die konkreten Optimierungsvariablen betrachten, hier eine +Übersicht über die **allgemeinen Variablen und Notation**, die im +Julia-Code verwendet werden: + +Meta-Variablen (nicht Teil des Optimierungsproblems) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++----------------+-------+-----------------------+--------------------+ +| Variable | Typ | Beschreibung | Verwendung | ++================+=======+=======================+====================+ +| ``pm`` | `` | PowerModels-Objekt | Enthält das | +| | Abstr | | gesamte | +| | actPo | | O | +| | werMo | | ptimierungsproblem | +| | del`` | | (Netz, Variablen, | +| | | | Constraints) | ++----------------+-------+-----------------------+--------------------+ +| ``nw`` oder | `` | Network-ID | Identifiziert | +| ``n`` | Int`` | (Zeitschritt-Index) | einen Zeitschritt | +| | | | im | +| | | | Mu | +| | | | lti-Period-Problem | +| | | | (0, 1, 2, …, T-1) | ++----------------+-------+-----------------------+--------------------+ +| ``nw_ids(pm)`` | ``Ar | Alle Network-IDs | Gibt alle | +| | ray{I | | Z | +| | nt}`` | | eitschritt-Indizes | +| | | | zurück, z.B. | +| | | | ``[0, | +| | | | 1, 2, ..., 8759]`` | +| | | | für 8760h | ++----------------+-------+-----------------------+--------------------+ +| ` | ``D | Referenzdaten für | Zugriff auf | +| `ref(pm, nw)`` | ict`` | Zeitschritt | Netzdaten eines | +| | | | bestimmten | +| | | | Zeitschritts | ++----------------+-------+-----------------------+--------------------+ +| ` | ``D | Variablen-Dictionary | Zugriff auf | +| `var(pm, nw)`` | ict`` | | Opt | +| | | | imierungsvariablen | +| | | | eines Zeitschritts | ++----------------+-------+-----------------------+--------------------+ +| ``model`` oder | ``Ju | Ju | Das | +| ``pm.model`` | MP.Mo | MP-Optimierungsmodell | zugrundeliegende | +| | del`` | | mathematische | +| | | | Optimierungsmodell | ++----------------+-------+-----------------------+--------------------+ + +Index-Variablen +~~~~~~~~~~~~~~~ + ++---------------+----------------+---------------------+---------------+ +| Variable | Bedeutung | Beschreibung | Beispiel | ++===============+================+=====================+===============+ +| ``i``, ``j`` | Bus-Index | Identifiziert | ``i=1`` = Bus | +| | | Knoten im Netzwerk | “Bus_MV_123” | ++---------------+----------------+---------------------+---------------+ +| ``l`` | Branch-Index | Identifiziert | ``l=5`` = | +| | (Lin | Leitungen und | Leitung | +| | e/Transformer) | Transformatoren | “Line_LV_456” | ++---------------+----------------+---------------------+---------------+ +| ``g`` | G | Identifiziert | ``g=3`` = | +| | enerator-Index | Generatoren (PV, | “PV_001” | +| | | Wind, BHKW, Slack) | | ++---------------+----------------+---------------------+---------------+ +| ``s`` | Storage-Index | Identifiziert | ``s=1`` = | +| | | Batteriespeicher | “Storage_1” | ++---------------+----------------+---------------------+---------------+ +| ``h`` | Heat | Identifiziert | ``h=2`` = | +| | Pump-Index | Wärmepumpen | “HP_LV_789” | ++---------------+----------------+---------------------+---------------+ +| ``c`` | Charging | Identifiziert | ``c=4`` = | +| | Point-Index | Ladepunkte für | “CP_LV_101” | +| | | E-Autos | | ++---------------+----------------+---------------------+---------------+ +| ``d`` | DSM-Index | Identifiziert | ``d=1`` = | +| | | DSM-Lasten | “DSM_Load_1” | ++---------------+----------------+---------------------+---------------+ +| ``t`` oder | Zei | Zeitpunkt im | ``t=0`` = | +| ``n`` | tschritt-Index | O | 2035-01-01 | +| | | ptimierungshorizont | 00:00, | +| | | | ``t=1`` = | +| | | | 01:00, … | ++---------------+----------------+---------------------+---------------+ + +PowerModels-Funktionen +~~~~~~~~~~~~~~~~~~~~~~ + ++---------------------------+--------------------+--------------------+----------------------+ +| Funktion | Rückgabewert | Beschreibung | Beispiel | ++===========================+====================+====================+======================+ +| ``ids(pm, :bus, nw=n)`` | ``Array{Int}`` | Gibt alle Bus-IDs | ``[1, 2, 3, ...]`` | +| | | für Zeitschritt n | | +| | | zurück | | ++---------------------------+--------------------+--------------------+----------------------+ +| ``ids(pm, :branch,`` | ``Array{Int}`` | Gibt alle | ``[1, 2, 3, ...]`` | +| ``nw=n)`` | | Branch-IDs | | +| | | (Leitungen/Trafos) | | +| | | zurück | | ++---------------------------+--------------------+--------------------+----------------------+ +| ``ids(pm, :gen, nw=n)`` | ``Array{Int}`` | Gibt alle | ``[1, 2, 3, ...]`` | +| | | Generator-IDs | | +| | | zurück | | ++---------------------------+--------------------+--------------------+----------------------+ +| ``ids(pm, :storage,`` | ``Array{Int}`` | Gibt alle | ``[1, 2, 3]`` | +| ``nw=n)`` | | Storage-IDs zurück | | ++---------------------------+--------------------+--------------------+----------------------+ +| ``ref(pm, nw, :bus, i)`` | ``Dict`` | Gibt Daten für Bus | ``{"vmin": 0.9,`` | +| | | i in Zeitschritt | ``"vmax": 1.1}`` | +| | | nw | | ++---------------------------+--------------------+--------------------+----------------------+ +| ``ref(pm, nw, :branch,`` | ``Dict`` | Gibt Daten für | ``{"rate_a": 0.5,`` | +| ``l)`` | | Branch l in | ``"br_r": 0.01}`` | +| | | Zeitschritt nw | | ++---------------------------+--------------------+--------------------+----------------------+ +| ``var(pm, nw, :p, l)`` | ``JuMP.Variable`` | Gibt | JuMP-Variable-Objekt | +| | | Wirkleistungs- | | +| | | variable für | | +| | | Branch l zurück | | ++---------------------------+--------------------+--------------------+----------------------+ +| ``var(pm, nw, :w, i)`` | ``JuMP.Variable`` | Gibt Spannungs- | JuMP-Variable-Objekt | +| | | variable für | | +| | | Bus i zurück | | ++---------------------------+--------------------+--------------------+----------------------+ + +Typische Code-Muster +~~~~~~~~~~~~~~~~~~~~ + +1. Iteration über alle Zeitschritte +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: julia + + for n in nw_ids(pm) + # Code für Zeitschritt n + println("Verarbeite Zeitschritt $n") + end + +2. Iteration über alle Busse in einem Zeitschritt +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: julia + + for i in ids(pm, :bus, nw=n) + # Code für Bus i in Zeitschritt n + bus_data = ref(pm, n, :bus, i) + println("Bus $i: Vmin = $(bus_data["vmin"])") + end + +3. Zugriff auf Variablen +^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: julia + + # Variable abrufen + w_i = var(pm, n, :w, i) # Spannungsvariable für Bus i, Zeitschritt n + + # Variable in Constraint verwenden + JuMP.@constraint(pm.model, w_i >= 0.9^2) # Untere Spannungsgrenze + +4. Variable erstellen und im Dictionary speichern +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: julia + + # Variablen-Dictionary für Zeitschritt n initialisieren + var(pm, n)[:p_hp14a] = JuMP.@variable( + pm.model, + [h in ids(pm, :gen_hp_14a, nw=n)], + base_name = "p_hp14a_$(n)", + lower_bound = 0.0 + ) + + # Später darauf zugreifen + for h in ids(pm, :gen_hp_14a, nw=n) + p_hp14a_h = var(pm, n, :p_hp14a, h) + end + +Multi-Network-Struktur +~~~~~~~~~~~~~~~~~~~~~~ + +PowerModels verwendet eine **Multi-Network-Struktur** für zeitabhängige +Optimierung: + +:: + + pm (PowerModel) + ├─ nw["0"] (Zeitschritt 0: 2035-01-01 00:00) + │ ├─ :bus → {1: {...}, 2: {...}, ...} ← Alle 150 Busse + │ ├─ :branch → {1: {...}, 2: {...}, ...} ← Alle 200 Leitungen/Trafos + │ ├─ :gen → {1: {...}, 2: {...}, ...} ← Alle 50 Generatoren + │ ├─ :load → {1: {...}, 2: {...}, ...} ← Alle 120 Lasten + │ └─ :storage → {1: {...}, 2: {...}, ...} ← Alle 5 Speicher + │ + ├─ nw["1"] (Zeitschritt 1: 2035-01-01 01:00) + │ ├─ :bus → {1: {...}, 2: {...}, ...} ← WIEDER alle 150 Busse + │ ├─ :branch → {1: {...}, 2: {...}, ...} ← WIEDER alle 200 Leitungen + │ └─ ... ← usw. + │ + ├─ nw["2"] (Zeitschritt 2: 2035-01-01 02:00) + │ ├─ :bus → {1: {...}, 2: {...}, ...} ← WIEDER alle 150 Busse + │ └─ ... + │ + ├─ ... (8757 weitere Zeitschritte) + │ + └─ nw["8759"] (Zeitschritt 8759: 2035-12-31 23:00) + └─ Komplettes Netz nochmal + +**WICHTIG: Das Netz existiert T-mal!** + +Für einen Optimierungshorizont von **8760 Stunden** (1 Jahr) bedeutet das: + +- Das gesamte Netz wird **8760-mal dupliziert** +- Jeder Zeitschritt hat seine eigene vollständige Netz-Kopie +- Alle Busse, Leitungen, Trafos, Generatoren, Lasten existieren **8760-mal** +- Jeder Zeitschritt hat **eigene Optimierungsvariablen** + +**Was unterscheidet die Zeitschritte?** + ++--------+----------------------+--------------------------------------+ +| Aspekt | Zeitschritte | Unterschiedlich pro Zeitschritt | ++========+======================+======================================+ +| **Net | Identisch | Gleiche Busse, Leitungen, Trafos | +| ztopol | | | +| ogie** | | | ++--------+----------------------+--------------------------------------+ +| **Net | Identisch | Gleiche Widerstände, Kapazitäten | +| zparam | | | +| eter** | | | ++--------+----------------------+--------------------------------------+ +| ** | Unterschiedlich | Generator-Einspeisung, Lasten, COP | +| Zeitre | | | +| ihen-W | | | +| erte** | | | ++--------+----------------------+--------------------------------------+ +| * | Unterschiedlich | Spannungen, Leistungsflüsse, | +| *Varia | | Speicher-Leistung | +| blen** | | | ++--------+----------------------+--------------------------------------+ +| **Sp | Gekoppelt | SOC[t+1] hängt von SOC[t] ab | +| eicher | | | +| -SOC** | | | ++--------+----------------------+--------------------------------------+ + +**Beispiel: Wirkleistungsvariable p[l,i,j]** + +Für eine Leitung ``l=5`` zwischen Bus ``i=10`` und ``j=11``: + +- ``var(pm, 0, :p)[(5,10,11)]`` = Wirkleistung in Zeitschritt 0 (00:00 Uhr) +- ``var(pm, 1, :p)[(5,10,11)]`` = Wirkleistung in Zeitschritt 1 (01:00 Uhr) +- ``var(pm, 2, :p)[(5,10,11)]`` = Wirkleistung in Zeitschritt 2 (02:00 Uhr) +- … +- ``var(pm, 8759, :p)[(5,10,11)]`` = Wirkleistung in Zeitschritt 8759 (23:00 Uhr) + +→ **8760 verschiedene Variablen** für dieselbe Leitung! + +**Optimierungsproblem-Größe:** + +Für ein Netz mit: + +- 150 Busse +- 200 Leitungen/Trafos +- 50 Generatoren +- 5 Batteriespeicher +- 20 Wärmepumpen +- 10 Ladepunkte +- 8760 Zeitschritte (1 Jahr, 1h-Auflösung) + +**Anzahl Variablen (grob):** + +- Spannungen: 150 Busse x 8760 Zeitschritte = **1,314,000 Variablen** +- Leitungsflüsse: 200 x 2 (p,q) x 8760 = **3,504,000 Variablen** +- Generatoren: 50 x 2 (p,q) x 8760 = **876,000 Variablen** +- Speicher: 5 x 2 (Leistung + SOC) x 8760 = **87,600 Variablen** +- … + +→ **Mehrere Millionen Variablen** für Jahressimulation! + +**Warum dieser Ansatz?** + +**Vorteile:** - Erlaubt zeitgekoppelte Optimierung (Speicher, +Wärmepumpen) - PowerModels-Syntax bleibt einfach (jeder Zeitschritt wie +Einzelproblem) - Flexible Zeitreihen (unterschiedliche Werte pro +Zeitschritt) + +**Nachteile:** - Sehr großes Optimierungsproblem (Millionen Variablen) - +Hoher Speicherbedarf - Lange Lösungszeiten (Minuten bis Stunden) + +**Inter-Zeitschritt Constraints:** + +Bestimmte Constraints koppeln die Zeitschritte: + +.. code:: julia + + # Speicher-Energiekopplung + for n in 0:8758 # Alle Zeitschritte außer letzter + for s in storage_ids + # SOC in t+1 hängt von SOC in t und Leistung in t ab + @constraint(pm.model, + var(pm, n+1, :se, s) == + var(pm, n, :se, s) + var(pm, n, :ps, s) x Δt x η + ) + end + end + +→ Diese Constraints verbinden die sonst unabhängigen Zeitschritte! + +**Zusammenfassung:** - Jeder Zeitschritt hat eine **eigene vollständige +Kopie** des Netzes - Zeitreihen-Werte (Lasten, Einspeisung) +unterscheiden sich zwischen Zeitschritten - Variablen existieren **pro +Zeitschritt** (8760-mal für jede physikalische Variable!) - +Inter-zeitschritt Constraints (Speicher-SOC, Wärmespeicher) koppeln die +Zeitschritte - **Für 8760 Zeitschritte:** Das Netz existiert 8760-mal → +Millionen von Variablen + +-------------- + +Alle Julia-Variablen (Tabellarisch) +----------------------------------- + +Netz-Variablen (Grid Variables) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++------------------+---------------+------------+--------------------+ +| Variable | Dimension | Einheit | Beschreibung | ++==================+===============+============+====================+ +| ``p[l,i,j]`` | ℝ | MW | Wirkleistungsfluss | +| | | | auf Leitung/Trafo | +| | | | von Bus i zu Bus j | ++------------------+---------------+------------+--------------------+ +| ``q[l,i,j]`` | ℝ | MVAr | B | +| | | | lindleistungsfluss | +| | | | auf Leitung/Trafo | +| | | | von Bus i zu Bus j | ++------------------+---------------+------------+--------------------+ +| ``w[i]`` | ℝ⁺ | p.u.² | Quadrierte | +| | | | Spannungsamplitude | +| | | | an Bus i | ++------------------+---------------+------------+--------------------+ +| ``ccm[l,i,j]`` | ℝ⁺ | kA² | Quadrierte | +| | | | Stromstärke auf | +| | | | Leitung/Trafo | ++------------------+---------------+------------+--------------------+ +| ``ll[l,i,j]`` | [0,1] | - | Leitungsauslastung | +| | | | (nur OPF Version 1 | +| | | | & 3) | ++------------------+---------------+------------+--------------------+ + +**Hinweise:** - ``l`` = Leitungs-/Trafo-ID - ``i,j`` = Bus-IDs +(from_bus, to_bus) - Quadrierte Variablen vermeiden nichtkonvexe +Wurzelfunktionen + +-------------- + +Generator-Variablen (Generation Variables) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++---------------+-----------------+-------------+---------------------+ +| Variable | Dimension | Einheit | Beschreibung | ++===============+=================+=============+=====================+ +| ``pg[g]`` | ℝ | MW | Wirkl | +| | | | eistungseinspeisung | +| | | | Generator g | ++---------------+-----------------+-------------+---------------------+ +| ``qg[g]`` | ℝ | MVAr | Blindl | +| | | | eistungseinspeisung | +| | | | Generator g | ++---------------+-----------------+-------------+---------------------+ +| ``pgc[g]`` | ℝ⁺ | MW | Abregelung | +| | | | nicht-regelbarer | +| | | | Generatoren | +| | | | (Curtailment) | ++---------------+-----------------+-------------+---------------------+ +| ``pgs`` | ℝ | MW | Slack-Generator | +| | | | Wirkleistung | +| | | | (Netzanschluss) | ++---------------+-----------------+-------------+---------------------+ +| ``qgs`` | ℝ | MVAr | Slack-Generator | +| | | | Blindleistung | ++---------------+-----------------+-------------+---------------------+ + +**Hinweise:** - Slack-Generator repräsentiert Übertragungsnetz-Anschluss +- Curtailment nur für EE-Anlagen (PV, Wind) + +-------------- + +Batteriespeicher-Variablen (Battery Storage Variables) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++---------------+-----------------+-------------+---------------------+ +| Variable | Dimension | Einheit | Beschreibung | ++===============+=================+=============+=====================+ +| ``ps[s,t]`` | ℝ | MW | Wirkleistung | +| | | | Batteriespeicher s | +| | | | zum Zeitpunkt t (+ | +| | | | = Entladung, - = | +| | | | Ladung) | ++---------------+-----------------+-------------+---------------------+ +| ``qs[s,t]`` | ℝ | MVAr | Blindleistung | +| | | | Batteriespeicher s | ++---------------+-----------------+-------------+---------------------+ +| ``se[s,t]`` | ℝ⁺ | MWh | Energieinhalt | +| | | | (State of Energy) | +| | | | Batteriespeicher s | ++---------------+-----------------+-------------+---------------------+ + +**Constraints:** - SOC-Kopplung zwischen Zeitschritten: +``se[t+1] = se[t] + ps[t] x Δt x η`` - Kapazitätsgrenzen: +``se_min ≤ se[t] ≤ se_max`` - Leistungsgrenzen: +``ps_min ≤ ps[t] ≤ ps_max`` + +-------------- + +Wärmepumpen-Variablen (Heat Pump Variables) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++---------------+-----------------+-------------+---------------------+ +| Variable | Dimension | Einheit | Beschreibung | ++===============+=================+=============+=====================+ +| ``php[h,t]`` | ℝ⁺ | MW | Elektrische | +| | | | Leistungsaufnahme | +| | | | Wärmepumpe h | ++---------------+-----------------+-------------+---------------------+ +| ``qhp[h,t]`` | ℝ | MVAr | Blindleistung | +| | | | Wärmepumpe h | ++---------------+-----------------+-------------+---------------------+ +| ``phs[h,t]`` | ℝ | MW | Leistung | +| | | | Wärmespeicher h (+ | +| | | | = Beladung, - = | +| | | | Entladung) | ++---------------+-----------------+-------------+---------------------+ +| ``hse[h,t]`` | ℝ⁺ | MWh | Energieinhalt | +| | | | Wärmespeicher h | ++---------------+-----------------+-------------+---------------------+ + +**Hinweise:** - Wärmepumpen mit thermischem Speicher können zeitlich +verschoben werden - Wärmebedarf muss über Optimierungshorizont gedeckt +werden - COP (Coefficient of Performance) verknüpft elektrische und +thermische Leistung + +-------------- + +Ladepunkt-Variablen (Charging Point / EV Variables) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++----------------+----------------+-------------+---------------------+ +| Variable | Dimension | Einheit | Beschreibung | ++================+================+=============+=====================+ +| ``pcp[c,t]`` | ℝ⁺ | MW | Ladeleistung | +| | | | Ladepunkt c zum | +| | | | Zeitpunkt t | ++----------------+----------------+-------------+---------------------+ +| ``qcp[c,t]`` | ℝ | MVAr | Blindleistung | +| | | | Ladepunkt c | ++----------------+----------------+-------------+---------------------+ +| ``cpe[c,t]`` | ℝ⁺ | MWh | Energieinhalt | +| | | | Fahrzeugbatterie am | +| | | | Ladepunkt c | ++----------------+----------------+-------------+---------------------+ + +**Constraints:** - Energiekopplung: +``cpe[t+1] = cpe[t] + pcp[t] x Δt x η`` - Kapazität: +``cpe_min ≤ cpe[t] ≤ cpe_max`` - Ladeleistung: ``0 ≤ pcp[t] ≤ pcp_max`` + +-------------- + +DSM-Variablen (Demand Side Management Variables) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +============= ========= ======= ===================================== +Variable Dimension Einheit Beschreibung +============= ========= ======= ===================================== +``pdsm[d,t]`` ℝ⁺ MW Verschiebbare Last d zum Zeitpunkt t +``qdsm[d,t]`` ℝ MVAr Blindleistung DSM-Last d +``dsme[d,t]`` ℝ⁺ MWh Virtueller Energieinhalt DSM-Speicher +============= ========= ======= ===================================== + +**Hinweise:** - DSM modelliert verschiebbare Lasten (z.B. +Industrieprozesse) - Gesamtenergie über Horizont bleibt konstant + +-------------- + +Slack-Variablen für Netzrestriktionen (Slack Variables) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Nur in **OPF Version 2 & 4** (mit Netzrestriktionen): + ++----------------+-----------+---------+-------------------------------------------+ +| Variable | Dimension | Einheit | Beschreibung | ++================+===========+=========+===========================================+ +| ``phps[h,t]`` | ℝ⁺ | MW | Slack für Wärmepumpen-Restriktion | ++----------------+-----------+---------+-------------------------------------------+ +| ``phps2[h,t]`` | ℝ⁺ | MW | Slack für Wärmepumpen-Betriebsrestriktion | ++----------------+-----------+---------+-------------------------------------------+ +| ``phss[h,t]`` | ℝ⁺ | MW | Slack für Wärmespeicher-Restriktion | ++----------------+-----------+---------+-------------------------------------------+ +| ``pds[d,t]`` | ℝ⁺ | MW | Lastabwurf (Load Shedding) | ++----------------+-----------+---------+-------------------------------------------+ +| ``pgens[g,t]`` | ℝ⁺ | MW | Slack für Generator-Abregelung | ++----------------+-----------+---------+-------------------------------------------+ +| ``pcps[c,t]`` | ℝ⁺ | MW | Slack für Ladepunkt-Restriktion | ++----------------+-----------+---------+-------------------------------------------+ +| ``phvs[t]`` | ℝ⁺ | MW | Slack für Hochspannungs-Anforderungen | ++----------------+-----------+---------+-------------------------------------------+ + +**Zweck:** - Gewährleisten Lösbarkeit des Optimierungsproblems - Hohe +Kosten im Zielfunktional → werden minimiert - Zeigen an, wo +Netzrestriktionen nicht eingehalten werden können + +-------------- + +§14a EnWG Variablen (NEU) +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Nur wenn ``curtailment_14a=True``: + +Wärmepumpen §14a +^^^^^^^^^^^^^^^^ + ++----------------+----------------+-------------+---------------------+ +| Variable | Dimension | Einheit | Beschreibung | ++================+================+=============+=====================+ +| `` | ℝ⁺ | MW | Virtueller | +| p_hp14a[h,t]`` | | | Generator für | +| | | | WP-Abregelung (0 | +| | | | bis pmax) | ++----------------+----------------+-------------+---------------------+ +| `` | Binär | {0,1} | - | +| z_hp14a[h,t]`` | | | | ++----------------+----------------+-------------+---------------------+ + +Ladepunkte §14a +^^^^^^^^^^^^^^^ + ++----------------+----------------+-------------+---------------------+ +| Variable | Dimension | Einheit | Beschreibung | ++================+================+=============+=====================+ +| `` | ℝ⁺ | MW | Virtueller | +| p_cp14a[c,t]`` | | | Generator für | +| | | | CP-Abregelung (0 | +| | | | bis pmax) | ++----------------+----------------+-------------+---------------------+ +| `` | Binär | {0,1} | - | +| z_cp14a[c,t]`` | | | | ++----------------+----------------+-------------+---------------------+ + +**Wichtige Parameter:** - ``pmax = P_nominal - P_min_14a`` (maximale +Abregelleistung) - ``P_min_14a = 0.0042 MW`` (4.2 kW Mindestleistung +gemäß §14a) - ``max_hours_per_day`` (z.B. 2h/Tag Zeitbudget) + +**Funktionsweise:** - Virtueller Generator “erzeugt” Leistung am +WP/CP-Bus - Effekt: Nettolast = Original-Last - p_hp14a - Simuliert +Abregelung ohne komplexe Lastanpassung + +-------------- + +Zeitliche Einordnung der Optimierung +------------------------------------ + +Gesamter Workflow +~~~~~~~~~~~~~~~~~ + +**WICHTIGER HINWEIS zum Workflow:** - **Reinforce VOR der Optimierung:** +Nur optional und sinnvoll für das Basisnetz (z.B. ohne +Wärmepumpen/E-Autos). Wenn man vor der Optimierung bereits das komplette +Netz ausbaut, gibt es keine Überlastungen mehr und die Optimierung zur +Flexibilitätsnutzung macht keinen Sinn. - **Reinforce NACH der +Optimierung:** In der Regel erforderlich! Die Optimierung nutzt +Flexibilität um Netzausbau zu minimieren, kann aber nicht alle Probleme +lösen. Verbleibende Überlastungen und Spannungsverletzungen müssen durch +konventionellen Netzausbau behoben werden. + +**Typischer Anwendungsfall:** 1. Basisnetz laden (z.B. Ist-Zustand ohne +neue Wärmepumpen) 2. Optional: reinforce auf Basisnetz 3. (Neue) +Komponenten hinzufügen (Wärmepumpen, E-Autos für Zukunftsszenario) 4. +Optimierung durchführen → nutzt Flexibilität statt Netzausbau 5. +**Zwingend:** reinforce mit optimierten Zeitreihen → behebt verbleibende +Probleme + +:: + + ┌─────────────────────────────────────────────────────────────────────┐ + │ 1. INITIALISIERUNG │ + ├─────────────────────────────────────────────────────────────────────┤ + │ - Netzladung (ding0-Netz oder Datenbank) │ + │ - Import Zeitreihen (Generatoren, Lasten ohne neue Komponenten) │ + │ - Konfiguration Optimierungsparameter │ + └─────────────────────────────────────────────────────────────────────┘ + ↓ + ┌─────────────────────────────────────────────────────────────────────┐ + │ 2. BASISNETZ-VERSTÄRKUNG (optional) │ + ├─────────────────────────────────────────────────────────────────────┤ + │ edisgo.reinforce() │ + │ - Verstärkung des Basisnetzes (OHNE neue WP/CP) │ + │ - Sinnvoll als Referenzszenario │ + │ - Erstellt Ausgangsbasis für Szenariovergleich │ + │ │ + │ WICHTIG: Dies ist NICHT der Hauptverstärkungsschritt! │ + └─────────────────────────────────────────────────────────────────────┘ + ↓ + ┌─────────────────────────────────────────────────────────────────────┐ + │ 3. NEUE KOMPONENTEN HINZUFÜGEN │ + ├─────────────────────────────────────────────────────────────────────┤ + │ - Wärmepumpen hinzufügen (mit thermischen Speichern) │ + │ - E-Auto Ladepunkte hinzufügen (mit Flexibilitätsbändern) │ + │ - Batteriespeicher hinzufügen │ + │ - Zeitreihen für neue Komponenten setzen │ + │ │ + │ → Netz ist jetzt wahrscheinlich überlastet │ + │ → KEIN reinforce an dieser Stelle! │ + └─────────────────────────────────────────────────────────────────────┘ + ↓ + ┌─────────────────────────────────────────────────────────────────────┐ + │ 4. JULIA-OPTIMIERUNG │ + ├─────────────────────────────────────────────────────────────────────┤ + │ edisgo.pm_optimize(opf_version=2, curtailment_14a=True) │ + │ │ + │ ZIEL: Flexibilität nutzen um Netzausbau zu VERMEIDEN │ + │ - Batteriespeicher optimal laden/entladen │ + │ - Wärmepumpen zeitlich verschieben (thermischer Speicher) │ + │ - E-Auto-Ladung optimieren (innerhalb Flexibilitätsband) │ + │ - §14a Abregelung bei Engpässen (max. 2h/Tag) │ + │ │ + │ 4.1 PYTHON → POWERMODELS KONVERTIERUNG │ + │ ├─ to_powermodels(): Netz → PowerModels-Dictionary │ + │ ├─ Zeitreihen für alle Komponenten │ + │ ├─ Falls 14a: Virtuelle Generatoren für WP/CP erstellen │ + │ └─ Serialisierung zu JSON │ + │ │ + │ 4.2 PYTHON → JULIA KOMMUNIKATION │ + │ ├─ Starte Julia-Subprozess: julia Main.jl [args] │ + │ ├─ Übergabe JSON via stdin │ + │ └─ Args: grid_name, results_path, method (soc/nc), etc. │ + │ │ + │ 4.3 JULIA-OPTIMIERUNG │ + │ ├─ Parse JSON → PowerModels Multinetwork │ + │ ├─ Solver-Auswahl: Gurobi (SOC) oder IPOPT (NC) │ + │ ├─ build_mn_opf_bf_flex(): │ + │ │ ├─ Variablen erstellen (alle aus Tabellen oben) │ + │ │ ├─ Constraints pro Zeitschritt: │ + │ │ │ ├─ Leistungsbilanz an Knoten │ + │ │ │ ├─ Spannungsfallgleichungen │ + │ │ │ ├─ Stromgleichungen │ + │ │ │ ├─ Speicher-/WP-/CP-Zustandsgleichungen │ + │ │ │ ├─ §14a Binär-Kopplung (falls aktiviert) │ + │ │ │ └─ §14a Mindest-Nettolast (falls aktiviert) │ + │ │ ├─ Inter-Zeitschritt Constraints: │ + │ │ │ ├─ Energiekopplung Speicher/WP/CP │ + │ │ │ └─ §14a Tages-Zeitbudget (falls aktiviert) │ + │ │ └─ Zielfunktion setzen (versionsabhängig) │ + │ ├─ Optimierung lösen │ + │ ├─ Ergebnisse zu JSON serialisieren │ + │ └─ Output via stdout │ + │ │ + │ 4.4 JULIA → PYTHON KOMMUNIKATION │ + │ ├─ Python liest stdout zeilenweise │ + │ ├─ Erfasse JSON-Ergebnis (beginnt mit {"name") │ + │ └─ Parse JSON zu Dictionary │ + │ │ + │ 4.5 POWERMODELS → EDISGO KONVERTIERUNG │ + │ ├─ from_powermodels(): Extrahiere optimierte Zeitreihen │ + │ ├─ Schreibe zu edisgo.timeseries: │ + │ │ ├─ generators_active_power, generators_reactive_power │ + │ │ ├─ storage_units_active_power (optimiert) │ + │ │ ├─ heat_pump_loads (zeitlich verschoben) │ + │ │ ├─ charging_point_loads (optimiert) │ + │ │ └─ §14a Abregelung als virtuelle Generatoren: │ + │ │ ├─ hp_14a_support_{name} │ + │ │ └─ cp_14a_support_{name} │ + │ └─ Abregelung = Virtuelle Generator-Leistung │ + │ │ + │ ERGEBNIS: Optimierte Zeitreihen mit minimiertem Netzausbaubedarf │ + │ Aber: Evtl. verbleibende Überlastungen (Slacks > 0) │ + └─────────────────────────────────────────────────────────────────────┘ + ↓ + ┌─────────────────────────────────────────────────────────────────────┐ + │ 5. NETZAUSBAU MIT OPTIMIERTEN ZEITREIHEN │ + ├─────────────────────────────────────────────────────────────────────┤ + │ edisgo.reinforce() │ + │ │ + │ WICHTIG: Dieser Schritt ist in der Regel erforderlich! │ + │ │ + │ Warum? │ + │ - Optimierung nutzt Flexibilität, kann aber nicht alle Probleme │ + │ lösen (z.B. Netzrestriktionen, zu geringe Flexibilität) │ + │ - Slack-Variablen > 0 zeigen verbleibende Verletzungen │ + │ - Verbleibende Überlastungen müssen durch Netzausbau behoben │ + │ werden │ + │ │ + │ Ablauf: │ + │ - Iterative Verstärkungsmaßnahmen │ + │ - Leitungsausbau, Trafoausbau │ + │ - Berechnung Netzausbaukosten │ + │ │ + │ ERGEBNIS: Netzausbaukosten NACH Flexibilitätsnutzung │ + │ (deutlich geringer als ohne Optimierung!) │ + └─────────────────────────────────────────────────────────────────────┘ + ↓ + ┌─────────────────────────────────────────────────────────────────────┐ + │ 6. AUSWERTUNG │ + ├─────────────────────────────────────────────────────────────────────┤ + │ - Analyse optimierter Zeitreihen │ + │ - Berechnung §14a Statistiken (Abregelenergie, Zeitbudget-Nutzung) │ + │ - Vergleich Netzausbaukosten (mit vs. ohne Optimierung) │ + │ - Flexibilitätsnutzung analysieren │ + │ - Visualisierung, Export │ + └─────────────────────────────────────────────────────────────────────┘ + +-------------- + +Workflow-Varianten im Vergleich +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Die folgende Tabelle zeigt die wichtigsten Workflow-Varianten und deren +Anwendungsfälle: + +.. list-table:: + :header-rows: 1 + :widths: 20 25 30 25 + + * - Workflow + - Schritte + - Wann sinnvoll? + - Ergebnis + * - **A: Nur Netzausbau (ohne Optimierung)** + - 1. Netz laden + + 2. Komponenten hinzufügen + + 3. ``reinforce()`` + - - Keine Flexibilitäten vorhanden + - Schnelle konservative Planung + - Referenzszenario + - Hohe Netzausbaukosten, Flexibilitätspotenzial ungenutzt + * - **B: Mit Optimierung (EMPFOHLEN)** + - 1. Netz laden + + 2. Optional: ``reinforce()`` auf Basisnetz + + 3. Komponenten hinzufügen + + 4. ``pm_optimize()`` + + 5. **Zwingend:** ``reinforce()`` + - - Flexibilitäten vorhanden (Speicher, WP, CP) + - §14a-Nutzung gewünscht + - Minimierung Netzausbaukosten + - Minimale Netzausbaukosten, optimale Flexibilitätsnutzung, betriebssicheres Netz + * - **C: Basisnetz-Referenz + Optimierung** + - 1. Netz laden (Basisnetz) + + 2. ``reinforce()`` → Kosten₁ + + 3. Neue Komponenten hinzufügen + + 4. ``pm_optimize()`` + + 5. ``reinforce()`` → Kosten₂ + + 6. Vergleich: Kosten₂ - Kosten₁ + - - Kostenvergleich mit/ohne neue Komponenten + - Analyse Zusatzkosten durch WP/CP + - Bewertung §14a-Nutzen + - Kostentransparenz, Attributierung auf neue Komponenten, Quantifizierung Flexibilitätsnutzen + * - **D: Mehrere Optimierungsszenarien** + - 1. Netz laden + Komponenten hinzufügen + + 2a. ``reinforce()`` → Referenz + + 2b. ``pm_optimize(14a=False)`` + ``reinforce()`` + + 2c. ``pm_optimize(14a=True)`` + ``reinforce()`` + + 3. Vergleich + - - Bewertung verschiedener Flexibilitätsoptionen + - Kosten-Nutzen-Analyse §14a + - Sensitivitätsanalyse + - Vollständiger Szenariovergleich, optimale Strategiewahl, fundierte Entscheidungsgrundlage + +**Wichtige Erkenntnisse:** + +1. **Reinforce vor Optimierung macht nur Sinn für:** + + - Basisnetz ohne neue Komponenten (Referenzszenario) + - Dokumentation des Ausgangszustands + - Status quo soll ermittelt werden + - **NICHT nach Hinzufügen der (neuen) Komponenten, deren + Flexibilitätseinsatz untersucht werden soll** → Würde + Flexibilitätspotenzial zunichtemachen + +2. **Reinforce nach Optimierung ist in der Regel sinnvoll:** + + - Optimierung reduziert Netzausbau, löst aber nicht alle Probleme + - Slack-Variablen zeigen verbleibende Verletzungen + +3. **Beispielhafte Kostenreduktion:** + + - Ohne Optimierung: 100% Netzausbaukosten (Referenz) + - Mit Optimierung ohne §14a: 60-80% der Referenzkosten + - Mit Optimierung mit §14a: 40-60% der Referenzkosten + - Abhängig von: Flexibilitätsgrad, Netzstruktur, Lastprofile + +**Beispiel-Code für Workflow B (empfohlen):** + +.. code:: python + + # Workflow B: Mit Optimierung (BESTE PRAXIS) + + # 1. Netz laden + edisgo = EDisGo(ding0_grid="path/to/grid") + + # Zeitreihen laden etc. + + # 2. Optional: Basisnetz verstärken (für Vergleich) + # edisgo.reinforce() # Nur wenn Referenzkosten gewünscht oder auf status quo ausgebaut werden soll + + # 3. Neue Komponenten für Zukunftsszenario hinzufügen + edisgo.add_heat_pumps( + scenario="eGon2035", + with_thermal_storage=True # Flexibilität! + ) + edisgo.add_charging_points( + scenario="eGon2035" + ) + + # 4. Optimierung durchführen + edisgo.pm_optimize( + opf_version=2, # Mit Netzrestriktionen + curtailment_14a=True, # §14a-Abregelung nutzen + max_hours_per_day=2.0, # 2h/Tag Zeitbudget + solver="gurobi" + ) + + # 5. ZWINGEND: Netzausbau für verbleibende Probleme + edisgo.reinforce() + + # 6. Ergebnisse analysieren + costs = edisgo.results.grid_expansion_costs + curtailment = edisgo.timeseries.generators_active_power[ + [c for c in edisgo.timeseries.generators_active_power.columns + if '14a_support' in c] + ] + + print(f"Netzausbaukosten (nach Optimierung): {costs:,.0f} €") + print(f"§14a-Abregelung gesamt: {curtailment.sum().sum():.2f} MWh") + +-------------- + +Detaillierter Zeitablauf der Julia-Optimierung +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Phase 1: Problemaufbau (build_mn_opf_bf_flex) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +**Für jeden Zeitschritt n im Optimierungshorizont:** + +.. code:: julia + + for n in nw_ids(pm) + # 1. VARIABLEN ERSTELLEN + PowerModels.variable_bus_voltage(pm, nw=n) # w[i] + PowerModels.variable_gen_power(pm, nw=n) # pg, qg + PowerModels.variable_branch_power(pm, nw=n) # p, q + eDisGo_OPF.variable_branch_current(pm, nw=n) # ccm + + # Flexibilitäten + eDisGo_OPF.variable_storage_power(pm, nw=n) # ps + eDisGo_OPF.variable_heat_pump_power(pm, nw=n) # php + eDisGo_OPF.variable_heat_storage_power(pm, nw=n) # phs + eDisGo_OPF.variable_charging_point_power(pm, nw=n) # pcp + + # Falls OPF Version 1 oder 3: Line Loading + if opf_version in [1, 3] + eDisGo_OPF.variable_line_loading(pm, nw=n) # ll + end + + # Falls OPF Version 2 oder 4: Slack-Variablen + if opf_version in [2, 4] + eDisGo_OPF.variable_slack_heatpumps(pm, nw=n) # phps, phps2 + eDisGo_OPF.variable_slack_heat_storage(pm, nw=n) # phss + eDisGo_OPF.variable_slack_loads(pm, nw=n) # pds + eDisGo_OPF.variable_slack_gens(pm, nw=n) # pgens + eDisGo_OPF.variable_slack_cps(pm, nw=n) # pcps + end + + # Falls §14a aktiviert: Virtuelle Generatoren + Binärvariablen + if curtailment_14a + eDisGo_OPF.variable_gen_hp_14a_power(pm, nw=n) # p_hp14a + eDisGo_OPF.variable_gen_hp_14a_binary(pm, nw=n) # z_hp14a + eDisGo_OPF.variable_gen_cp_14a_power(pm, nw=n) # p_cp14a + eDisGo_OPF.variable_gen_cp_14a_binary(pm, nw=n) # z_cp14a + end + + # 2. CONSTRAINTS PRO ZEITSCHRITT + for i in ids(pm, :bus, nw=n) + constraint_power_balance(pm, i, n) # Eq 3.3, 3.4 + end + + for l in ids(pm, :branch, nw=n) + constraint_voltage_drop(pm, l, n) # Eq 3.5 + constraint_current_limit(pm, l, n) # Eq 3.6 + if opf_version in [1, 3] + constraint_line_loading(pm, l, n) # ll definition + end + end + + for s in ids(pm, :storage, nw=n) + constraint_storage_state(pm, s, n) # Eq 3.9 + constraint_storage_complementarity(pm, s, n) # Eq 3.10 + end + + for h in ids(pm, :heat_pump, nw=n) + constraint_heat_pump_operation(pm, h, n) # Eq 3.19 + constraint_heat_storage_state(pm, h, n) # Eq 3.22 + constraint_heat_storage_complementarity(pm, h, n)# Eq 3.23 + end + + for c in ids(pm, :charging_point, nw=n) + constraint_cp_state(pm, c, n) # Eq 3.25 + constraint_cp_complementarity(pm, c, n) # Eq 3.26 + end + + for d in ids(pm, :dsm, nw=n) + constraint_dsm_state(pm, d, n) # Eq 3.32 + constraint_dsm_complementarity(pm, d, n) # Eq 3.33 + end + + # §14a Constraints pro Zeitschritt + if curtailment_14a + for h in ids(pm, :gen_hp_14a, nw=n) + constraint_hp_14a_binary_coupling(pm, h, n) # p_hp14a ≤ pmax x z + constraint_hp_14a_min_net_load(pm, h, n) # Nettolast ≥ min(Last, 4.2kW) + end + for c in ids(pm, :gen_cp_14a, nw=n) + constraint_cp_14a_binary_coupling(pm, c, n) + constraint_cp_14a_min_net_load(pm, c, n) + end + end + end + +Phase 2: Inter-Zeitschritt Constraints +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: julia + + # Speicher-Energiekopplung zwischen Zeitschritten + for s in ids(pm, :storage) + for t in 1:(T-1) + se[t+1] == se[t] + ps[t] x Δt x η + end + end + + # Wärmespeicher-Kopplung + for h in ids(pm, :heat_pump) + for t in 1:(T-1) + hse[t+1] == hse[t] + phs[t] x Δt x η + end + end + + # EV-Batterie-Kopplung + for c in ids(pm, :charging_point) + for t in 1:(T-1) + cpe[t+1] == cpe[t] + pcp[t] x Δt x η + end + end + + # §14a Tages-Zeitbudget + if curtailment_14a + # Gruppiere Zeitschritte in 24h-Tage + day_groups = group_timesteps_by_day(timesteps) + + for day in day_groups + for h in ids(pm, :gen_hp_14a) + sum(z_hp14a[h,t] for t in day) ≤ max_hours_per_day / Δt + end + for c in ids(pm, :gen_cp_14a) + sum(z_cp14a[c,t] for t in day) ≤ max_hours_per_day / Δt + end + end + end + +Phase 3: Zielfunktion +^^^^^^^^^^^^^^^^^^^^^ + +**OPF Version 1** (geliftete Restriktionen, ohne Slacks): + +.. code:: julia + + minimize: 0.9 x sum(Verluste) + 0.1 x max(ll) + 0.05 x sum(p_hp14a) + 0.05 x sum(p_cp14a) + +**OPF Version 2** (mit Netzrestriktionen, mit Slacks): + +.. code:: julia + + minimize: 0.4 x sum(Verluste) + 0.6 x sum(Slacks) + 0.5 x sum(p_hp14a) + 0.5 x sum(p_cp14a) + +**OPF Version 3** (mit HV-Anforderungen, geliftete Restriktionen): + +.. code:: julia + + minimize: 0.9 x sum(Verluste) + 0.1 x max(ll) + 50 x sum(phvs) + 0.05 x sum(p_hp14a) + 0.05 x sum(p_cp14a) + +**OPF Version 4** (mit HV-Anforderungen und Restriktionen): + +.. code:: julia + + minimize: 0.4 x sum(Verluste) + 0.6 x sum(Slacks) + 50 x sum(phvs) + 0.5 x sum(p_hp14a) + 0.5 x sum(p_cp14a) + +**Wichtig:** - §14a-Terme haben moderate Gewichte → Abregelung wird +genutzt, aber minimiert - Slack-Variablen haben hohe implizite Kosten → +nur wenn unvermeidbar - HV-Slack hat sehr hohes Gewicht → Einhaltung +prioritär + +Phase 4: Lösen +^^^^^^^^^^^^^^ + +.. code:: julia + + # Solver-Auswahl + if method == "soc" + solver = Gurobi.Optimizer + # SOC-Relaxation: ccm-Constraints als Second-Order-Cone + elseif method == "nc" + solver = Ipopt.Optimizer + # Non-Convex: ccm-Constraints als quadratische Gleichungen + end + + # Optimierung durchführen + result = optimize_model!(pm, solver) + + # Optional: Warm-Start NC mit SOC-Lösung + if warm_start + result_soc = optimize_model!(pm, Gurobi.Optimizer) + initialize_from_soc!(pm, result_soc) + result = optimize_model!(pm, Ipopt.Optimizer) + end + +-------------- + +Die analyze-Funktion +-------------------- + +Funktionsdefinition +~~~~~~~~~~~~~~~~~~~ + +**Datei:** ``edisgo/edisgo.py`` (Zeile ~1038) + +**Signatur:** + +.. code:: python + + def analyze( + self, + mode: str | None = None, + timesteps: pd.DatetimeIndex | None = None, + troubleshooting_mode: str | None = None, + scale_timeseries: float | None = None, + **kwargs + ) -> None + +Was macht analyze? +~~~~~~~~~~~~~~~~~~ + +Die ``analyze``-Funktion führt eine **statische, nicht-lineare +Leistungsflussberechnung** (Power Flow Analysis, PFA) mit PyPSA durch. +Sie berechnet: + +1. **Spannungen** an allen Knoten (``v_res``) +2. **Ströme** auf allen Leitungen und Trafos (``i_res``) +3. **Wirkleistungsflüsse** auf Betriebsmitteln (``pfa_p``) +4. **Blindleistungsflüsse** auf Betriebsmitteln (``pfa_q``) + +Die Ergebnisse werden in ``edisgo.results`` gespeichert. + +Parameter +~~~~~~~~~ + ++--------------------+------------------+-----------------------------+ +| Parameter | Default | Beschreibung | ++====================+==================+=============================+ +| ``mode`` | str \| None | Analyseebene: ``'mv'`` | +| | | (MS-Netz), ``'mvlv'`` (MS | +| | | mit NS an Sekundärseite), | +| | | ``'lv'`` (einzelnes | +| | | NS-Netz), ``None`` | +| | | (gesamtes Netz) | ++--------------------+------------------+-----------------------------+ +| ``timesteps`` | DatetimeIndex \| | Zeitschritte für Analyse. | +| | None | ``None`` = alle in | +| | | ``timeseries.timeindex`` | ++--------------------+------------------+-----------------------------+ +| ``trou | str \| None | ``'lpf'`` = Linear PF | +| bleshooting_mode`` | | seeding, ``'iteration'`` = | +| | | schrittweise Lasterhöhung | ++--------------------+------------------+-----------------------------+ +| `` | float \| None | Skalierungsfaktor für | +| scale_timeseries`` | | Zeitreihen (z.B. 0.5 für | +| | | 50% Last) | ++--------------------+------------------+-----------------------------+ + +Zeitreihen-Nutzung +~~~~~~~~~~~~~~~~~~ + +``analyze`` verwendet **alle** Zeitreihen aus ``edisgo.timeseries``: + +Generatoren +^^^^^^^^^^^ + +- **Quelle:** ``edisgo.timeseries.generators_active_power`` +- **Quelle:** ``edisgo.timeseries.generators_reactive_power`` +- **Inhalt:** Einspeisung aller Generatoren (PV, Wind, BHKW, etc.) in + MW/MVAr +- **Zeitauflösung:** Typisch 1h oder 15min +- **Herkunft:** Datenbank (eGon), WorstCase-Profil, oder optimierte + Zeitreihen + +Lasten +^^^^^^ + +- **Quelle:** ``edisgo.timeseries.loads_active_power`` +- **Quelle:** ``edisgo.timeseries.loads_reactive_power`` +- **Inhalt:** Haushaltslast, Gewerbe, Industrie in MW/MVAr +- **Zeitauflösung:** Typisch 1h oder 15min +- **Herkunft:** Datenbank, Standardlastprofile, oder gemessene Daten + +Speicher +^^^^^^^^ + +- **Quelle:** ``edisgo.timeseries.storage_units_active_power`` +- **Quelle:** ``edisgo.timeseries.storage_units_reactive_power`` +- **Inhalt:** Batteriespeicher Ladung/Entladung in MW/MVAr +- **Zeitauflösung:** Wie Zeitreihenindex +- **Herkunft:** Optimierung oder vorgegebene Fahrpläne + +Wärmepumpen +^^^^^^^^^^^ + +- **Quelle:** Indirekt aus ``heat_demand_df`` und ``cop_df`` +- **Berechnung:** ``P_el = heat_demand / COP`` +- **Zeitauflösung:** Wie Zeitreihenindex +- **Herkunft:** Wärmebedarfsprofile (z.B. BDEW), COP-Profile + (temperaturabhängig) +- **Nach Optimierung:** Aus optimierten Zeitreihen + ``timeseries.heat_pumps_active_power`` + +Ladepunkte (E-Autos) +^^^^^^^^^^^^^^^^^^^^ + +- **Quelle:** ``edisgo.timeseries.charging_points_active_power`` +- **Zeitauflösung:** Wie Zeitreihenindex +- **Herkunft:** Ladeprofile (z.B. SimBEV), Flexibilitätsbänder, oder + Optimierung + +Prozessablauf +~~~~~~~~~~~~~ + +.. code:: python + + # 1. Zeitschritte bestimmen + if timesteps is None: + timesteps = self.timeseries.timeindex + else: + timesteps = pd.DatetimeIndex(timesteps) + + # 2. In PyPSA-Netzwerk konvertieren + pypsa_network = self.to_pypsa( + mode=mode, + timesteps=timesteps + ) + + # 3. Optional: Zeitreihen skalieren + if scale_timeseries is not None: + pypsa_network.loads_t.p_set *= scale_timeseries + pypsa_network.generators_t.p_set *= scale_timeseries + # ... weitere Zeitreihen skalieren + + # 4. Leistungsflussberechnung durchführen + pypsa_network.pf( + timesteps, + use_seed=(troubleshooting_mode == 'lpf') + ) + + # 5. Konvergenz prüfen + converged_ts = timesteps[pypsa_network.converged] + not_converged_ts = timesteps[~pypsa_network.converged] + + if len(not_converged_ts) > 0: + logger.warning(f"Power flow did not converge for {len(not_converged_ts)} timesteps") + + # 6. Ergebnisse verarbeiten + pypsa_io.process_pfa_results( + edisgo_obj=self, + pypsa_network=pypsa_network, + timesteps=timesteps + ) + + # 7. Ergebnisse in edisgo.results speichern + # self.results.v_res -> Spannungen an Knoten + # self.results.i_res -> Ströme auf Leitungen + # self.results.pfa_p -> Wirkleistungsflüsse + # self.results.pfa_q -> Blindleistungsflüsse + +Wann wird analyze aufgerufen? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. **Manuell vom Benutzer:** + + .. code:: python + + edisgo.analyze() # Analysiere gesamtes Netz, alle Zeitschritte + +2. **Von reinforce-Funktion:** + + - Initial: Identifiziere Netzprobleme + - Nach jeder Verstärkung: Prüfe ob Probleme gelöst + - Iterativ bis keine Verletzungen mehr + +3. **Nach Optimierung (optional):** + + .. code:: python + + edisgo.pm_optimize(...) + edisgo.analyze() # Analysiere mit optimierten Zeitreihen + +4. **Bei Worst-Case-Analyse:** + + .. code:: python + + # Nur zwei kritische Zeitpunkte + worst_case_ts = edisgo.get_worst_case_timesteps() + edisgo.analyze(timesteps=worst_case_ts) + +Troubleshooting-Modi +~~~~~~~~~~~~~~~~~~~~ + +Linear Power Flow Seeding (``'lpf'``) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +- Problem: Nicht-linearer PF konvergiert nicht +- Lösung: Starte mit linearer PF-Lösung (Winkel) als Startwert +- Nutzen: Stabilisiert Konvergenz bei schwierigen Netzen + +Iteration (``'iteration'``) +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +- Problem: Konvergenz bei hoher Last nicht möglich +- Lösung: Beginne mit 10% Last, erhöhe schrittweise bis 100% +- Nutzen: Findet Lösung bei extremen Betriebspunkten + +Ausgabe +~~~~~~~ + +**Erfolgreiche Analyse:** + +:: + + Info: Power flow analysis completed for 8760 timesteps + Info: 8760 timesteps converged, 0 did not converge + +**Konvergenzprobleme:** + +:: + + Warning: Power flow did not converge for 15 timesteps + Warning: Non-converged timesteps: ['2035-01-15 18:00', '2035-07-21 12:00', ...] + +-------------- + +Die reinforce-Funktion +---------------------- + +.. _funktionsdefinition-1: + +Funktionsdefinition +~~~~~~~~~~~~~~~~~~~ + +**Datei:** ``edisgo/edisgo.py`` (Zeile ~1243) **Implementierung:** +``edisgo/flex_opt/reinforce_grid.py`` (Zeile ~25) + +**Signatur:** + +.. code:: python + + def reinforce( + self, + timesteps_pfa: str | pd.DatetimeIndex | None = None, + reduced_analysis: bool = False, + max_while_iterations: int = 20, + split_voltage_band: bool = True, + mode: str | None = None, + without_generator_import: bool = False, + n_minus_one: bool = False, + **kwargs + ) -> None + +Was macht reinforce? +~~~~~~~~~~~~~~~~~~~~ + +Die ``reinforce``-Funktion **identifiziert Netzprobleme** (Überlastung, +Spannungsverletzungen) und **führt Verstärkungsmaßnahmen** durch: + +1. **Leitungsverstärkung:** Parallele Leitungen oder Ersatz durch + größeren Querschnitt +2. **Transformatorverstärkung:** Paralleltrafos oder größere Leistung +3. **Spannungsebenen-Trennung:** LV-Netze bei Bedarf aufteilen +4. **Kostenberechnung:** Netzausbaukosten (€) + +.. _parameter-1: + +Parameter +~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 25 20 20 35 + + * - Parameter + - Typ + - Default + - Beschreibung + * - ``timesteps_pfa`` + - ``str | DatetimeIndex | None`` + - ``'snapshot_analysis'`` + - ``'snapshot_analysis'`` = 2 Worst-Case-Zeitschritte, ``DatetimeIndex`` = benutzerdefiniert, ``None`` = alle Zeitschritte + * - ``reduced_analysis`` + - ``bool`` + - ``False`` + - Nutzt nur die kritischsten Zeitschritte (höchste Überlast oder Spannungsabweichung) + * - ``max_while_iterations`` + - ``int`` + - ``20`` + - Maximale Anzahl der Verstärkungsiterationen + * - ``split_voltage_band`` + - ``bool`` + - ``True`` + - Getrennte Spannungsbänder für NS/MS (z.B. NS ±3 %, MS ±7 %) + * - ``mode`` + - ``str | None`` + - ``None`` + - Netzebene: ``'mv'``, ``'mvlv'``, ``'lv'`` oder ``None`` (= automatisch) + * - ``without_generator_import`` + - ``bool`` + - ``False`` + - Ignoriert Generatoreinspeisung (nur für Planungsanalysen sinnvoll) + * - ``n_minus_one`` + - ``bool`` + - ``False`` + - Berücksichtigt das (n-1)-Kriterium + +.. _zeitreihen-nutzung-1: + +Zeitreihen-Nutzung +~~~~~~~~~~~~~~~~~~ + +Nutzt **dieselben Zeitreihen wie analyze**: + +- ``generators_active_power``, ``generators_reactive_power`` +- ``loads_active_power``, ``loads_reactive_power`` +- ``storage_units_active_power``, ``storage_units_reactive_power`` +- Wärmepumpen-Lasten (aus heat_demand/COP) +- Ladepunkt-Lasten + +**Zeitreihen-Auswahl:** + +Option 1: Snapshot Analysis (``timesteps_pfa='snapshot_analysis'``) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: python + + # Nur 2 kritische Zeitpunkte + ts1 = timestep_max_residual_load # Max. Residuallast (hohe Last, wenig Einspeisung) + ts2 = timestep_min_residual_load # Min. Residuallast (niedrige Last, viel Einspeisung) + timesteps = [ts1, ts2] + +**Vorteil:** Sehr schnell (nur 2 PFA statt 8760) **Nachteil:** Kann +seltene Probleme übersehen + +Option 2: Reduzierte Analyse (``reduced_analysis=True``) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: python + + # 1. Initiale PFA mit allen Zeitschritten + edisgo.analyze(timesteps=all_timesteps) + + # 2. Identifiziere kritischste Zeitschritte + critical_timesteps = get_most_critical_timesteps( + overloading_factor=1.0, # Nur Zeitschritte mit Überlast + voltage_deviation=0.03 # Nur Zeitschritte mit >3% Spannungsabweichung + ) + + # 3. Verstärkung nur auf Basis dieser Zeitschritte + timesteps = critical_timesteps # z.B. 50 statt 8760 + +**Vorteil:** Deutlich schneller als volle Analyse, genauer als Snapshot +**Nachteil:** Initial-PFA mit allen Zeitschritten nötig + +Option 3: Alle Zeitschritte (``timesteps_pfa=None``) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: python + + timesteps = edisgo.timeseries.timeindex # z.B. alle 8760h eines Jahres + +**Vorteil:** Maximale Genauigkeit **Nachteil:** Sehr rechenintensiv +(viele PFA) + +Option 4: Custom (``timesteps_pfa=custom_datetimeindex``) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: python + + # Z.B. nur Wintermonate + timesteps = pd.date_range('2035-01-01', '2035-03-31', freq='H') + +reinforce Algorithmus +~~~~~~~~~~~~~~~~~~~~~ + +Schritt 1: Überlastungen beseitigen +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: python + + iteration = 0 + while has_overloading() and iteration < max_while_iterations: + iteration += 1 + + # 1.1 HV/MV-Station prüfen + if hv_mv_station_max_overload() > 0: + reinforce_hv_mv_station() + + # 1.2 MV/LV-Stationen prüfen + for station in mv_lv_stations: + if station.max_overload > 0: + reinforce_mv_lv_station(station) + + # 1.3 MV-Leitungen prüfen + for line in mv_lines: + if line.max_relative_overload > 0: + reinforce_line(line) + + # 1.4 LV-Leitungen prüfen + for line in lv_lines: + if line.max_relative_overload > 0: + reinforce_line(line) + + # 1.5 Erneute Analyse + edisgo.analyze(timesteps=timesteps) + + # 1.6 Konvergenz prüfen + if not has_overloading(): + break + +**Verstärkungsmaßnahmen:** - **Parallelleitungen:** Identischer Typ +parallel schalten - **Leitungsersatz:** Größerer Querschnitt (z.B. +150mm² → 240mm²) - **Paralleltrafos:** Identischer Trafo parallel - +**Trafoersatz:** Größere Leistung (z.B. 630kVA → 1000kVA) + +Schritt 2: MV-Spannungsprobleme lösen +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: python + + iteration = 0 + while has_voltage_issues_mv() and iteration < max_while_iterations: + iteration += 1 + + # Identifiziere kritische Leitungen + critical_lines = get_lines_voltage_issues(voltage_level='mv') + + for line in critical_lines: + reinforce_line(line) + + # Erneute Analyse + edisgo.analyze(timesteps=timesteps) + +Schritt 3: MV/LV-Stations-Spannungsprobleme +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: python + + for station in mv_lv_stations: + if has_voltage_issues_at_secondary_side(station): + # Trafo-Kapazität erhöhen + reinforce_mv_lv_station(station) + + edisgo.analyze(timesteps=timesteps) + +Schritt 4: LV-Spannungsprobleme lösen +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: python + + for lv_grid in lv_grids: + while has_voltage_issues(lv_grid) and iteration < max_while_iterations: + iteration += 1 + + # Kritische Leitungen verstärken + critical_lines = get_lines_voltage_issues( + grid=lv_grid, + voltage_level='lv' + ) + + for line in critical_lines: + reinforce_line(line) + + edisgo.analyze(timesteps=timesteps, mode='lv') + +Schritt 5: Finale Überprüfung +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: python + + # Prüfe ob Spannungsverstärkungen neue Überlastungen verursacht haben + edisgo.analyze(timesteps=timesteps) + + if has_overloading(): + # Zurück zu Schritt 1 + goto_step_1() + +Schritt 6: Kostenberechnung +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: python + + # Berechne Netzausbaukosten + costs = calculate_grid_expansion_costs(edisgo) + + # Kosten pro Komponente + line_costs = costs['lines'] # € + trafo_costs = costs['transformers'] # € + total_costs = costs['total'] # € + + # Speichere in edisgo.results + edisgo.results.grid_expansion_costs = costs + +Verstärkungslogik +~~~~~~~~~~~~~~~~~ + +Leitungsverstärkung +^^^^^^^^^^^^^^^^^^^ + +.. code:: python + + def reinforce_line(line): + # 1. Berechne benötigte Kapazität + required_capacity = line.s_nom * (1 + max_relative_overload) + + # 2. Option A: Parallelleitungen + num_parallel = ceil(required_capacity / line.s_nom) + cost_parallel = num_parallel * line_cost(line.type) + + # 3. Option B: Größerer Querschnitt + new_type = get_next_larger_type(line.type) + if new_type is not None: + cost_replacement = line_cost(new_type) + else: + cost_replacement = inf + + # 4. Wähle günstigere Option + if cost_parallel < cost_replacement: + add_parallel_lines(line, num_parallel - 1) + else: + replace_line(line, new_type) + +Transformatorverstärkung +^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: python + + def reinforce_transformer(trafo): + # 1. Berechne benötigte Leistung + required_power = trafo.s_nom * (1 + max_relative_overload) + + # 2. Option A: Paralleltrafos + num_parallel = ceil(required_power / trafo.s_nom) + cost_parallel = num_parallel * trafo_cost(trafo.type) + + # 3. Option B: Größerer Trafo + new_type = get_next_larger_trafo(trafo.s_nom) + cost_replacement = trafo_cost(new_type) + + # 4. Wähle günstigere Option + if cost_parallel < cost_replacement: + add_parallel_trafos(trafo, num_parallel - 1) + else: + replace_trafo(trafo, new_type) + +.. _ausgabe-1: + +Ausgabe +~~~~~~~ + +**Erfolgreiche Verstärkung:** + +:: + + Info: ==> Checking stations. + Info: MV station is not overloaded. + Info: All MV/LV stations are within allowed load range. + Info: ==> Checking lines. + Info: Reinforcing 15 overloaded MV lines. + Info: Reinforcing 42 overloaded LV lines. + Info: ==> Voltage issues in MV grid. + Info: Reinforcing 8 lines due to voltage issues. + Info: ==> Voltage issues in LV grids. + Info: Reinforcing 23 lines in LV grids. + Info: Grid reinforcement finished. Total costs: 145,320 € + +**Iterations-Limit erreicht:** + +:: + + Warning: Maximum number of iterations (20) reached. Grid issues may remain. + +Wann wird reinforce aufgerufen? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. **Manuell vom Benutzer:** + + .. code:: python + + edisgo.reinforce() + +2. **Basisnetz-Verstärkung VOR neuen Komponenten (optional):** + + .. code:: python + + # Laden eines Basisnetzes (z.B. Ist-Zustand 2024) + edisgo = EDisGo(ding0_grid="path/to/grid") + edisgo.import_generators(scenario="status_quo") + edisgo.import_loads(scenario="status_quo") + + # Optional: Verstärkung des Basisnetzes (als Referenz) + edisgo.reinforce() # Kosten für Basisnetz dokumentieren + + # DANN: Neue Komponenten für Zukunftsszenario hinzufügen + edisgo.add_heat_pumps(scenario="2035_high") + edisgo.add_charging_points(scenario="2035_high") + + # WICHTIG: KEIN reinforce an dieser Stelle! + # Stattdessen: Optimierung nutzen → siehe Punkt 5 + + **Sinnvoll für:** Referenzszenario, Vergleich Netzausbaukosten + mit/ohne Flexibilität + +3. **Nach Szenario-Simulation OHNE Optimierung:** + + .. code:: python + + # Wenn man KEINE Optimierung nutzen möchte (rein konventioneller Netzausbau) + edisgo.add_charging_points(scenario='high_ev') + edisgo.reinforce() # Verstärke Netz für neue Last (ohne Flexibilität) + + **Nachteil:** Hohe Netzausbaukosten, Flexibilitätspotenzial wird + nicht genutzt + +4. **Nach Optimierung (ZWINGEND ERFORDERLICH!):** + + .. code:: python + + # Korrekte Reihenfolge: + # 1. Neue Komponenten hinzufügen (WP, CP, Speicher) + edisgo.add_heat_pumps(...) + edisgo.add_charging_points(...) + + # 2. Optimierung durchführen (nutzt Flexibilität) + edisgo.pm_optimize(opf_version=2, curtailment_14a=True) + + # 3. ZWINGEND: Netzausbau für verbleibende Probleme + edisgo.reinforce() # Behebt Überlastungen die Optimierung nicht lösen konnte + + # Ergebnis: Minimierte Netzausbaukosten durch Flexibilitätsnutzung + + **Warum zwingend?** + + - Optimierung minimiert Netzausbau, kann aber nicht alle Probleme + lösen + - Slack-Variablen > 0 zeigen verbleibende + Netzrestriktionsverletzungen + - Verbleibende Überlastungen müssen durch konventionellen Ausbau + behoben werden + - **Ohne diesen Schritt:** Netz ist NICHT betriebssicher! + +5. **Iterativer Workflow für mehrere Szenarien:** + + .. code:: python + + # Vergleich verschiedener Flexibilitätsszenarien + + # Szenario 1: Ohne Optimierung (Referenz) + edisgo_ref = edisgo.copy() + edisgo_ref.reinforce() + costs_ref = edisgo_ref.results.grid_expansion_costs + + # Szenario 2: Mit Optimierung aber ohne §14a + edisgo_opt = edisgo.copy() + edisgo_opt.pm_optimize(opf_version=2, curtailment_14a=False) + edisgo_opt.reinforce() + costs_opt = edisgo_opt.results.grid_expansion_costs + + # Szenario 3: Mit Optimierung und §14a + edisgo_14a = edisgo.copy() + edisgo_14a.pm_optimize(opf_version=2, curtailment_14a=True) + edisgo_14a.reinforce() + costs_14a = edisgo_14a.results.grid_expansion_costs + + # Vergleich + print(f"Ohne Optimierung: {costs_ref:,.0f} €") + print(f"Mit Optimierung: {costs_opt:,.0f} € (-{100*(1-costs_opt/costs_ref):.1f}%)") + print(f"Mit §14a: {costs_14a:,.0f} € (-{100*(1-costs_14a/costs_ref):.1f}%)") + +-------------- + +Die §14a EnWG Optimierung +------------------------- + +Was ist §14a EnWG? +~~~~~~~~~~~~~~~~~~ + +**Gesetzesgrundlage:** § 14a Energiewirtschaftsgesetz (EnWG) + +**Inhalt:** Netzbetreiber dürfen **steuerbare Verbrauchseinrichtungen** +(Wärmepumpen, Ladeeinrichtungen für E-Autos) bei Netzengpässen +**abregelnd** bis auf eine **Mindestleistung** (4,2 kW). + +**Bedingungen:** - Maximales **Zeitbudget**: Typisch 2 Stunden pro Tag - +**Mindestleistung**: 4,2 kW (0,0042 MW) muss gewährleistet bleiben - +**Vergütung**: Reduziertes Netzentgelt für Kunden + +**Ziel:** Netzausbau reduzieren durch gezielte Spitzenlast-Kappung + +Wie unterscheidet sich §14a von Standard-Optimierung? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Standard-Optimierung (OHNE §14a): +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +- **Wärmepumpen mit thermischem Speicher:** Zeitliche Lastverschiebung +- **E-Autos:** Ladesteuerung innerhalb Flexibilitätsband +- **Inflexible WP/CP:** Können NICHT abgeregelt werden + +§14a-Optimierung: +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +- **ALLE Wärmepumpen > 4,2 kW:** Können bis auf 4,2 kW abgeregelt + werden +- **ALLE Ladepunkte > 4,2 kW:** Können bis auf 4,2 kW abgeregelt werden +- **Auch ohne Speicher:** Abregelung möglich +- **Zeitbudget-Constraints:** Max. 2h/Tag Abregelung +- **Binäre Entscheidung:** Abregelung aktiv JA/NEIN + +Mathematische Modellierung +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Virtuelle Generatoren +^^^^^^^^^^^^^^^^^^^^^ + +§14a-Abregelung wird durch **virtuelle Generatoren** modelliert: + +:: + + Nettolast_WP = Original_Last_WP - p_hp14a + + Beispiel: + - WP-Last: 8 kW + - Abregelung: 3,8 kW (virtueller Generator erzeugt 3,8 kW) + - Nettolast: 8 - 3,8 = 4,2 kW (Mindestlast) + +**Vorteile dieser Modellierung:** - Keine Änderung der Last-Zeitreihen +nötig - Kompatibel mit PowerModels-Struktur - Einfache Implementierung +in Optimierungsproblem + +Variablen (pro Wärmepumpe h, Zeitschritt t) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: julia + + # Kontinuierliche Variable: Abregelleistung + @variable(model, 0 <= p_hp14a[h,t] <= pmax[h]) + + # Binäre Variable: Abregelung aktiv? + @variable(model, z_hp14a[h,t], Bin) + +**Parameter:** - ``pmax[h] = P_nominal[h] - P_min_14a`` - +``P_nominal[h]``: Nennleistung Wärmepumpe h (z.B. 8 kW) - +``P_min_14a = 4,2 kW``: Gesetzliche Mindestlast - +``pmax[h] = 8 - 4,2 = 3,8 kW``: Maximale Abregelleistung + +- ``max_hours_per_day = 2``: Zeitbudget in Stunden pro Tag + +Constraints +^^^^^^^^^^^ + +1. Binäre Kopplung (Binary Coupling) +'''''''''''''''''''''''''''''''''''' + +.. code:: julia + + @constraint(model, p_hp14a[h,t] <= pmax[h] x z_hp14a[h,t]) + +**Bedeutung:** - Wenn ``z_hp14a[h,t] = 0`` (keine Abregelung): +``p_hp14a[h,t] = 0`` - Wenn ``z_hp14a[h,t] = 1`` (Abregelung aktiv): +``0 ≤ p_hp14a[h,t] ≤ pmax[h]`` + + +2. Mindest-Nettolast (Minimum Net Load) +''''''''''''''''''''''''''''''''''''''' + +.. code:: julia + + @constraint(model, + p_hp_load[h,t] - p_hp14a[h,t] >= min(p_hp_load[h,t], p_min_14a) + ) + +**Bedeutung:** - Nettolast muss mindestens so groß wie aktuelle Last +ODER 4,2 kW sein - Verhindert “negative Last” (Generator größer als +Last) + +**Spezialfälle:** + +**Fall A: WP ist aus** (``p_hp_load[h,t] < 1e-6 MW``): + +.. code:: julia + + @constraint(model, p_hp14a[h,t] == 0) + +Abregelung macht keinen Sinn, wenn WP eh aus ist. + +**Fall B: WP zu klein** (``pmax[h] < 1e-6 MW``): + +.. code:: julia + + @constraint(model, p_hp14a[h,t] == 0) + +WP-Nennleistung < 4,2 kW → keine Abregelung möglich. + +**Fall C: Normalbetrieb** (``p_hp_load[h,t] >= p_min_14a``): + +.. code:: julia + + @constraint(model, p_hp_load[h,t] - p_hp14a[h,t] >= p_min_14a) + +Nettolast muss mindestens 4,2 kW bleiben. + +3. Tages-Zeitbudget (Daily Time Budget) +''''''''''''''''''''''''''''''''''''''' + +.. code:: julia + + for day in days + @constraint(model, + sum(z_hp14a[h,t] for t in timesteps_in_day(day)) + <= max_hours_per_day / time_elapsed_per_timestep + ) + end + +**Beispiel:** - Zeitauflösung: 1h - Zeitbudget: 2h/Tag - Constraint: +``sum(z_hp14a[h,t] for t in 0..23) <= 2`` + +**Beispiel mit 15min-Auflösung:** - Zeitauflösung: 0,25h - Zeitbudget: +2h/Tag - Constraint: +``sum(z_hp14a[h,t] for t in 0..95) <= 2 / 0.25 = 8`` + +**Alternative:** Total Budget Constraint (über gesamten Horizont): + +.. code:: julia + + @constraint(model, + sum(z_hp14a[h,t] for t in all_timesteps) + <= max_hours_total / time_elapsed_per_timestep + ) + +Zielfunktions-Integration +^^^^^^^^^^^^^^^^^^^^^^^^^ + +§14a-Variablen werden mit **moderaten Gewichten** ins Zielfunktional +aufgenommen: + +**OPF Version 2 (mit §14a):** + +.. code:: julia + + minimize: + 0.4 x sum(line_losses[t] for t in timesteps) + + 0.6 x sum(all_slacks[t] for t in timesteps) + + 0.5 x sum(p_hp14a[h,t] for h,t) + + 0.5 x sum(p_cp14a[c,t] for c,t) + +**Interpretation der Gewichte:** - ``0.4`` für Verluste: Basiskosten +Netzbetrieb - ``0.6`` für Slacks: Hohe Priorität Netzrestriktionen +einhalten - ``0.5`` für §14a: Moderate Kosten → Abregelung wird genutzt, +aber minimiert - Präferenz: Erst andere Flexibilitäten (Speicher, +zeitliche Verschiebung) - Wenn andere Flexibilitäten nicht ausreichen: +§14a als “letzte Reserve” + +**OPF Version 1 (mit §14a):** + +.. code:: julia + + minimize: + 0.9 x sum(line_losses[t] for t in timesteps) + + 0.1 x max(line_loading[l,t] for l,t) + + 0.05 x sum(p_hp14a[h,t] for h,t) + + 0.05 x sum(p_cp14a[c,t] for c,t) + +**Niedrigeres Gewicht (0.05):** §14a wird bevorzugt gegenüber hoher +Leitungsauslastung. + +Implementation Details +~~~~~~~~~~~~~~~~~~~~~~ + +Datei-Struktur +^^^^^^^^^^^^^^ + +**Python-Seite:** - **Datei:** ``edisgo/io/powermodels_io.py`` - +**Funktionen:** - ``_build_gen_hp_14a_support()``: Erstellt virtuelle +Generatoren für WP - ``_build_gen_cp_14a_support()``: Erstellt virtuelle +Generatoren für CP - ``to_powermodels(..., curtailment_14a=True)``: Ruft +obige Funktionen auf + +**Julia-Seite:** - **Variablen:** +``edisgo/opf/eDisGo_OPF.jl/src/core/variables.jl`` - +``variable_gen_hp_14a_power()`` - ``variable_gen_hp_14a_binary()`` - +``variable_gen_cp_14a_power()`` - ``variable_gen_cp_14a_binary()`` + +- **Constraints:** + ``edisgo/opf/eDisGo_OPF.jl/src/core/constraint_hp_14a.jl`` + + - ``constraint_hp_14a_binary_coupling()`` + - ``constraint_hp_14a_min_net_load()`` + +- **Constraints:** + ``edisgo/opf/eDisGo_OPF.jl/src/core/constraint_cp_14a.jl`` + + - ``constraint_cp_14a_binary_coupling()`` + - ``constraint_cp_14a_min_net_load()`` + +- **Problemdefinition:** + ``edisgo/opf/eDisGo_OPF.jl/src/prob/opf_bf.jl`` + + - Integration in ``build_mn_opf_bf_flex()`` + +Python: Virtuelle Generatoren erstellen +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: python + + def _build_gen_hp_14a_support( + edisgo_obj, + pm_dict, + timesteps, + max_hours_per_day=2.0 + ): + """ + Erstellt virtuelle Generatoren für §14a-Abregelung von Wärmepumpen. + """ + # 1. Alle Wärmepumpen identifizieren + heat_pumps = edisgo_obj.topology.loads_df[ + edisgo_obj.topology.loads_df.type == "heat_pump" + ] + + gen_hp_14a_dict = {} + + for idx, (hp_name, hp_row) in enumerate(heat_pumps.iterrows()): + # 2. Parameter berechnen + p_nominal = hp_row["p_set"] # Nennleistung in MW + p_min_14a = 0.0042 # 4.2 kW + pmax = p_nominal - p_min_14a + + # Nur wenn WP groß genug (> 4.2 kW) + if pmax > 1e-6: + # 3. Virtuellen Generator definieren + gen_hp_14a_dict[idx] = { + "name": f"hp_14a_support_{hp_name}", + "gen_bus": hp_row["bus"], # Gleicher Bus wie WP + "pmax": pmax, # Maximale Abregelleistung + "pmin": 0.0, + "qmax": 0.0, # Nur Wirkleistung + "qmin": 0.0, + "max_hours_per_day": max_hours_per_day, + "p_min_14a": p_min_14a, + "hp_name": hp_name, # Referenz zur Original-WP + "index": idx, + "source_id": f"gen_hp_14a_{idx}" + } + + # 4. Zeitreihen erstellen (zunächst Nullen, Optimierung setzt Werte) + gen_hp_14a_p = pd.DataFrame( + 0.0, + index=timesteps, + columns=[f"gen_hp_14a_{i}" for i in gen_hp_14a_dict.keys()] + ) + + gen_hp_14a_q = gen_hp_14a_p.copy() + + # 5. In PowerModels-Dictionary einfügen + for n, ts in enumerate(timesteps): + pm_dict["nw"][str(n)]["gen_hp_14a"] = gen_hp_14a_dict + + return pm_dict + +**Analoger Code für Ladepunkte (``_build_gen_cp_14a_support``).** + +Julia: Variablen definieren +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: julia + + function variable_gen_hp_14a_power(pm::AbstractPowerModel; nw::Int=nw_id_default) + """ + Erstellt kontinuierliche Variable für §14a WP-Abregelung. + """ + gen_hp_14a = PowerModels.ref(pm, nw, :gen_hp_14a) + + # Variable p_hp14a[i] für jeden virtuellen Generator i + PowerModels.var(pm, nw)[:p_hp14a] = JuMP.@variable( + pm.model, + [i in keys(gen_hp_14a)], + base_name = "p_hp14a_$(nw)", + lower_bound = 0.0, + upper_bound = gen_hp_14a[i]["pmax"], + start = 0.0 + ) + end + + function variable_gen_hp_14a_binary(pm::AbstractPowerModel; nw::Int=nw_id_default) + """ + Erstellt binäre Variable für §14a WP-Abregelung. + """ + gen_hp_14a = PowerModels.ref(pm, nw, :gen_hp_14a) + + # Binärvariable z_hp14a[i] + PowerModels.var(pm, nw)[:z_hp14a] = JuMP.@variable( + pm.model, + [i in keys(gen_hp_14a)], + base_name = "z_hp14a_$(nw)", + binary = true, + start = 0 + ) + end + +Julia: Constraints implementieren +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: julia + + function constraint_hp_14a_binary_coupling( + pm::AbstractPowerModel, + i::Int, + nw::Int=nw_id_default + ) + """ + Binäre Kopplung: p_hp14a <= pmax x z_hp14a + """ + p_hp14a = PowerModels.var(pm, nw, :p_hp14a, i) + z_hp14a = PowerModels.var(pm, nw, :z_hp14a, i) + + gen_hp_14a = PowerModels.ref(pm, nw, :gen_hp_14a, i) + pmax = gen_hp_14a["pmax"] + + # Constraint + JuMP.@constraint(pm.model, p_hp14a <= pmax * z_hp14a) + end + + function constraint_hp_14a_min_net_load( + pm::AbstractPowerModel, + i::Int, + nw::Int=nw_id_default + ) + """ + Mindest-Nettolast: Last - p_hp14a >= min(Last, 4.2kW) + """ + p_hp14a = PowerModels.var(pm, nw, :p_hp14a, i) + gen_hp_14a = PowerModels.ref(pm, nw, :gen_hp_14a, i) + + # Finde zugehörige WP-Last + hp_name = gen_hp_14a["hp_name"] + hp_bus = gen_hp_14a["gen_bus"] + p_hp_load = get_hp_load_at_bus(pm, hp_bus, hp_name, nw) + + pmax = gen_hp_14a["pmax"] + p_min_14a = gen_hp_14a["p_min_14a"] + + # Spezialfälle + if pmax < 1e-6 + # WP zu klein + JuMP.@constraint(pm.model, p_hp14a == 0.0) + elseif p_hp_load < 1e-6 + # WP ist aus + JuMP.@constraint(pm.model, p_hp14a == 0.0) + else + # Normalbetrieb: Nettolast >= min(Last, Mindestlast) + min_net_load = min(p_hp_load, p_min_14a) + JuMP.@constraint(pm.model, p_hp_load - p_hp14a >= min_net_load) + end + end + +Julia: Zeitbudget-Constraint +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: julia + + function constraint_hp_14a_time_budget_daily( + pm::AbstractPowerModel, + gen_hp_14a_ids, + timesteps_per_day, + max_hours_per_day, + time_elapsed + ) + """ + Tages-Zeitbudget: Σ(z_hp14a über Tag) <= max_hours / Δt + """ + # Gruppiere Zeitschritte nach Tagen + day_groups = group_timesteps_by_day(timesteps_per_day) + + for (day_idx, day_timesteps) in enumerate(day_groups) + for i in gen_hp_14a_ids + # Summe binärer Variablen über Tag + z_sum = sum( + PowerModels.var(pm, nw, :z_hp14a, i) + for nw in day_timesteps + ) + + # Max. erlaubte Zeitschritte + max_timesteps = max_hours_per_day / time_elapsed + + # Constraint + JuMP.@constraint(pm.model, z_sum <= max_timesteps) + end + end + end + +**Hilfsfunktion:** + +.. code:: julia + + function group_timesteps_by_day(timesteps) + """ + Gruppiert Zeitschritte in 24h-Tage. + + Beispiel: + - Input: [2035-01-01 00:00, 2035-01-01 01:00, ..., 2035-01-02 00:00, ...] + - Output: [[0..23], [24..47], [48..71], ...] + """ + days = [] + current_day = [] + current_date = Date(timesteps[0]) + + for (idx, ts) in enumerate(timesteps) + ts_date = Date(ts) + + if ts_date == current_date + push!(current_day, idx) + else + push!(days, current_day) + current_day = [idx] + current_date = ts_date + end + end + + # Letzter Tag + if !isempty(current_day) + push!(days, current_day) + end + + return days + end + +Integration in Optimierungsproblem +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +**Datei:** ``edisgo/opf/eDisGo_OPF.jl/src/prob/opf_bf.jl`` + +.. code:: julia + + function build_mn_opf_bf_flex(pm::AbstractPowerModel; kwargs...) + # ... Standard-Variablen ... + + # §14a Variablen (falls aktiviert) + for n in PowerModels.nw_ids(pm) + if haskey(PowerModels.ref(pm, n), :gen_hp_14a) && + !isempty(PowerModels.ref(pm, n, :gen_hp_14a)) + + # Variablen erstellen + variable_gen_hp_14a_power(pm, nw=n) + variable_gen_hp_14a_binary(pm, nw=n) + end + + if haskey(PowerModels.ref(pm, n), :gen_cp_14a) && + !isempty(PowerModels.ref(pm, n, :gen_cp_14a)) + + variable_gen_cp_14a_power(pm, nw=n) + variable_gen_cp_14a_binary(pm, nw=n) + end + end + + # ... Standard-Constraints ... + + # §14a Constraints pro Zeitschritt + for n in PowerModels.nw_ids(pm) + # WP §14a + for i in PowerModels.ids(pm, :gen_hp_14a, nw=n) + constraint_hp_14a_binary_coupling(pm, i, n) + constraint_hp_14a_min_net_load(pm, i, n) + end + + # CP §14a + for i in PowerModels.ids(pm, :gen_cp_14a, nw=n) + constraint_cp_14a_binary_coupling(pm, i, n) + constraint_cp_14a_min_net_load(pm, i, n) + end + end + + # §14a Zeitbudget-Constraints + if haskey(PowerModels.ref(pm, first(nw_ids)), :gen_hp_14a) + gen_hp_14a_ids = PowerModels.ids(pm, :gen_hp_14a, nw=first(nw_ids)) + constraint_hp_14a_time_budget_daily( + pm, + gen_hp_14a_ids, + timesteps_per_day, + max_hours_per_day, + time_elapsed + ) + end + + # Analog für CP + + # ... Zielfunktion (mit §14a-Termen) ... + end + +Ergebnisinterpretation +~~~~~~~~~~~~~~~~~~~~~~ + +Nach der Optimierung sind die §14a-Abregelungen in den Zeitreihen +enthalten: + +.. code:: python + + # Optimierung durchführen + edisgo.pm_optimize( + opf_version=2, + curtailment_14a=True, + max_hours_per_day=2.0 + ) + + # §14a-Abregelungen extrahieren + hp_14a_gens = [ + col for col in edisgo.timeseries.generators_active_power.columns + if 'hp_14a_support' in col + ] + + curtailment_hp = edisgo.timeseries.generators_active_power[hp_14a_gens] + + # Beispiel: WP "HP_1234" + hp_support_gen = "hp_14a_support_HP_1234" + curtailment_ts = curtailment_hp[hp_support_gen] # MW + + # Original-Last + hp_load_ts = edisgo.timeseries.loads_active_power["HP_1234"] # MW + + # Nettolast (nach Abregelung) + net_load_ts = hp_load_ts - curtailment_ts + + # Statistiken + total_curtailment_mwh = curtailment_ts.sum() # MWh (bei 1h-Auflösung) + total_load_mwh = hp_load_ts.sum() + curtailment_percentage = (total_curtailment_mwh / total_load_mwh) * 100 + + # Zeitbudget-Nutzung + hours_curtailed = (curtailment_ts > 0).sum() # Anzahl Stunden mit Abregelung + days_in_horizon = len(curtailment_ts) / 24 + avg_hours_per_day = hours_curtailed / days_in_horizon + + print(f"Abregelung HP_1234:") + print(f" Total: {total_curtailment_mwh:.2f} MWh ({curtailment_percentage:.1f}%)") + print(f" Stunden mit Abregelung: {hours_curtailed}") + print(f" Ø pro Tag: {avg_hours_per_day:.2f}h (Limit: 2h)") + +**Beispiel-Ausgabe:** + +:: + + Abregelung HP_1234: + Total: 15.32 MWh (3.2%) + Stunden mit Abregelung: 487 + Ø pro Tag: 1.33h (Limit: 2h) + +Beispiel-Workflow mit §14a +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code:: python + + # 1. Netz laden + edisgo = EDisGo(ding0_grid="path/to/grid") + + # 2. Wärmepumpen hinzufügen (alle > 4.2 kW können abgeregelt werden) + edisgo.add_component( + comp_type="heat_pump", + comp_name="HP_001", + bus="Bus_LV_123", + p_set=0.008, # 8 kW Nennleistung + ... + ) + + # 3. Zeitreihen setzen + edisgo.set_time_series_manual( + generators_p=..., + loads_p=..., + heat_pump_cop_df=..., + heat_demand_df=... + ) + + # 4. Optimierung MIT §14a + edisgo.pm_optimize( + opf_version=2, # Mit Netzrestriktionen + Slacks + curtailment_14a=True, # §14a aktivieren + max_hours_per_day=2.0, # Zeitbudget 2h/Tag + curtailment_14a_total_hours=None, # Optional: Gesamtbudget statt täglich + solver="gurobi", # Binäre Variablen → MILP-Solver nötig + warm_start=False # Kein Warm-Start bei binären Variablen + ) + + # 5. ZWINGEND: Netzausbau für verbleibende Probleme + edisgo.reinforce() # Behebt Überlastungen die Optimierung nicht lösen konnte + + # 6. Ergebnisse analysieren + # §14a Abregelung ist nun in edisgo.timeseries.generators_active_power + # als virtuelle Generatoren "hp_14a_support_..." und "cp_14a_support_..." + + # Netzausbaukosten (nach Flexibilitätsnutzung) + costs = edisgo.results.grid_expansion_costs + print(f"Netzausbaukosten: {costs:,.0f} €") + + # §14a Statistiken + hp_14a_curtailment = edisgo.timeseries.generators_active_power[ + [c for c in edisgo.timeseries.generators_active_power.columns + if 'hp_14a_support' in c] + ] + cp_14a_curtailment = edisgo.timeseries.generators_active_power[ + [c for c in edisgo.timeseries.generators_active_power.columns + if 'cp_14a_support' in c] + ] + + total_curtailment = hp_14a_curtailment.sum().sum() + cp_14a_curtailment.sum().sum() + print(f"§14a Abregelung gesamt: {total_curtailment:.2f} MWh") + +-------------- + +.. _zeitreihen-nutzung-2: + +Zeitreihen-Nutzung +------------------ + +Überblick Zeitreihen-Datenstruktur +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Alle Zeitreihen werden in ``edisgo.timeseries`` gespeichert: + +.. code:: python + + edisgo.timeseries + ├── timeindex: pd.DatetimeIndex # z.B. 8760 Stunden eines Jahres + ├── generators_active_power: pd.DataFrame # MW, Spalten = Generator-Namen + ├── generators_reactive_power: pd.DataFrame # MVAr + ├── loads_active_power: pd.DataFrame # MW, Spalten = Last-Namen + ├── loads_reactive_power: pd.DataFrame # MVAr + ├── storage_units_active_power: pd.DataFrame # MW (+ = Entladung, - = Ladung) + ├── storage_units_reactive_power: pd.DataFrame # MVAr + └── ... weitere Komponenten + +Zeitreihen-Quellen +~~~~~~~~~~~~~~~~~~ + +1. Datenbank-Import (eGon-Datenbank) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: python + + edisgo.import_generators( + generator_scenario="eGon2035", + engine=db_engine + ) + +**Inhalt:** - PV-Anlagen: Zeitreihen aus Wetterdaten (Globalstrahlung) - +Windkraftanlagen: Zeitreihen aus Windgeschwindigkeiten - BHKW: +Wärmegeführter Betrieb oder Grundlast + +**Auflösung:** Typisch 1h für ein Jahr (8760 Zeitschritte) + +2. Worst-Case-Profile +^^^^^^^^^^^^^^^^^^^^^ + +.. code:: python + + edisgo.set_time_series_worst_case( + cases=["feed-in_case", "load_case"] + ) + +**feed-in_case:** - PV: Maximale Einstrahlung (z.B. Mittag im Sommer) - +Wind: Maximaler Wind - Lasten: Minimale Last + +**load_case:** - Generatoren: Minimale Einspeisung - Lasten: Maximale +Last (z.B. Winterabend) + +**Nutzung:** Schnelle Netzplanung ohne vollständige Zeitreihen + +3. Manuelle Zeitreihen +^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: python + + # Eigene Zeitreihen erstellen + timesteps = pd.date_range("2035-01-01", periods=8760, freq="H") + gen_p = pd.DataFrame({ + "PV_001": pv_timeseries, + "Wind_002": wind_timeseries + }, index=timesteps) + + edisgo.set_time_series_manual( + generators_p=gen_p, + loads_p=load_p, + ... + ) + +4. Optimierte Zeitreihen (nach pm_optimize) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Nach ``edisgo.pm_optimize()`` werden Zeitreihen überschrieben mit +optimierten Werten: + +.. code:: python + + # VOR Optimierung: Original-Lasten + hp_load_original = edisgo.timeseries.loads_active_power["HP_123"] + + # Optimierung durchführen + edisgo.pm_optimize(opf_version=2) + + # NACH Optimierung: Optimierte Lasten + hp_load_optimized = edisgo.timeseries.loads_active_power["HP_123"] + + # Unterschied: Zeitliche Verschiebung durch thermischen Speicher + # oder Abregelung durch §14a + +**Zusätzlich:** Virtuelle Generatoren für §14a: + +.. code:: python + + # NEU nach Optimierung mit curtailment_14a=True + curtailment = edisgo.timeseries.generators_active_power["hp_14a_support_HP_123"] + +Zeitreihen in analyze +~~~~~~~~~~~~~~~~~~~~~ + +.. code:: python + + edisgo.analyze(timesteps=None) + +**Verwendete Zeitreihen:** 1. ``generators_active_power``, +``generators_reactive_power`` - Für alle PV, Wind, BHKW, etc. - +Einspeisung ins Netz + +2. ``loads_active_power``, ``loads_reactive_power`` + + - Für alle Haushalte, Gewerbe, Industrie + - Entnahme aus dem Netz + +3. ``storage_units_active_power``, ``storage_units_reactive_power`` + + - Batteriespeicher Ladung/Entladung + - Positiv = Entladung (wie Generator) + - Negativ = Ladung (wie Last) + +4. **Wärmepumpen** (speziell): + + - NICHT direkt aus ``loads_active_power`` + - Berechnung: ``P_el(t) = heat_demand(t) / COP(t)`` + - ``heat_demand_df``: Wärmebedarf in MW_th + - ``cop_df``: Coefficient of Performance (temperaturabhängig) + +5. **Ladepunkte**: + + - ``charging_points_active_power`` (falls vorhanden) + - Oder aus Flexibilitätsbändern berechnet + +**Ablauf:** + +.. code:: python + + def to_pypsa(self, mode=None, timesteps=None): + # 1. Zeitreihen extrahieren + gen_p = self.timeseries.generators_active_power.loc[timesteps] + load_p = self.timeseries.loads_active_power.loc[timesteps] + + # 2. Wärmepumpen elektrische Last berechnen + hp_loads = [] + for hp_name in heat_pumps: + heat_demand = self.heat_pump.heat_demand_df.loc[timesteps, hp_name] + cop = self.heat_pump.cop_df.loc[timesteps, hp_name] + hp_load_p = heat_demand / cop + hp_loads.append(hp_load_p) + + # 3. Zu PyPSA-Zeitreihen konvertieren + pypsa.loads_t.p_set = load_p + pypsa.generators_t.p_set = gen_p + pypsa.storage_units_t.p_set = storage_p + + return pypsa + +Zeitreihen in reinforce +~~~~~~~~~~~~~~~~~~~~~~~ + +``reinforce`` nutzt dieselben Zeitreihen wie ``analyze``, aber mit +verschiedenen Auswahlmodi: + +Modus 1: Snapshot Analysis +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: python + + edisgo.reinforce(timesteps_pfa="snapshot_analysis") + +**Zeitreihen:** - Nur 2 Zeitschritte: 1. **Max. Residuallast:** +``t_max = argmax(load - generation)`` 2. **Min. Residuallast:** +``t_min = argmin(load - generation)`` + +**Berechnung:** + +.. code:: python + + residual_load = ( + edisgo.timeseries.loads_active_power.sum(axis=1) + - edisgo.timeseries.generators_active_power.sum(axis=1) + ) + + t_max = residual_load.idxmax() # Kritischer Zeitpunkt für Überlastung + t_min = residual_load.idxmin() # Kritischer Zeitpunkt für Rückspeisung + + timesteps = pd.DatetimeIndex([t_max, t_min]) + +**Vorteil:** Sehr schnell (nur 2 Power Flow Analysen) **Risiko:** +Übersieht seltene Extremereignisse + +Modus 2: Reduzierte Analyse +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: python + + edisgo.reinforce(reduced_analysis=True) + +**Ablauf:** 1. Initiale PFA mit allen Zeitschritten 2. Identifiziere +kritische Zeitschritte: - Überlastung > 0% auf irgendeiner Komponente - +Spannungsabweichung > 3% an irgendeinem Knoten 3. Wähle nur diese +Zeitschritte für Verstärkung + +**Zeitreihen:** + +.. code:: python + + # 1. Initiale Analyse + edisgo.analyze(timesteps=edisgo.timeseries.timeindex) + + # 2. Kritische Zeitschritte identifizieren + overloaded_ts = edisgo.results.i_res[ + (edisgo.results.i_res / s_nom).max(axis=1) > 1.0 + ].index + + voltage_issues_ts = edisgo.results.v_res[ + ((edisgo.results.v_res < 0.97) | (edisgo.results.v_res > 1.03)).any(axis=1) + ].index + + critical_ts = overloaded_ts.union(voltage_issues_ts).unique() + + # 3. Nur diese Zeitschritte für Verstärkung + timesteps = critical_ts # z.B. 50 statt 8760 + +**Vorteil:** Viel schneller als volle Analyse, genauer als Snapshot +**Nachteil:** Initial-PFA mit allen Zeitschritten notwendig + +Modus 3: Alle Zeitschritte +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: python + + edisgo.reinforce(timesteps_pfa=None) + +**Zeitreihen:** - Alle Zeitschritte in ``edisgo.timeseries.timeindex`` - +Typisch 8760 Stunden für ein Jahr + +**Vorteil:** Höchste Genauigkeit **Nachteil:** Sehr rechenintensiv + +Modus 4: Custom +^^^^^^^^^^^^^^^ + +.. code:: python + + # Nur Wintermonate + winter_ts = edisgo.timeseries.timeindex[ + edisgo.timeseries.timeindex.month.isin([11, 12, 1, 2]) + ] + + edisgo.reinforce(timesteps_pfa=winter_ts) + +Zeitreihen in pm_optimize +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Input-Zeitreihen +^^^^^^^^^^^^^^^^ + +.. code:: python + + edisgo.pm_optimize( + opf_version=2, + curtailment_14a=True + ) + +**Verwendete Zeitreihen (vor Optimierung):** + +1. **Generatoren:** + + - ``generators_active_power``: Einspeise-Zeitreihen + - ``generators_reactive_power``: Blindleistung (oder aus cos φ + berechnet) + +2. **Inflexible Lasten:** + + - ``loads_active_power``: Haushalte, Gewerbe ohne Flexibilität + - ``loads_reactive_power``: Blindleistung + +3. **Batteriespeicher:** + + - Initial State of Charge (SOC) + - Kapazität, Ladeleistung, Entladeleistung + - **Keine Input-Zeitreihen** (werden optimiert) + +4. **Wärmepumpen:** + + - ``heat_demand_df``: Wärmebedarf in MW_th + - ``cop_df``: COP-Zeitreihen + - Flexibilitätsband (falls flexible WP): + + - ``p_min``, ``p_max`` pro Zeitschritt + - Thermischer Speicher: Kapazität, Anfangs-SOC + +5. **Ladepunkte:** + + - Flexibilitätsbänder: + + - ``p_min``, ``p_max`` pro Zeitschritt + - ``energy_min``, ``energy_max`` (SOC-Grenzen) + + - Ladeeffizienz + +6. **§14a-Komponenten (falls aktiviert):** + + - Alle WP/CP > 4,2 kW werden identifiziert + - Virtuelle Generatoren mit ``pmax = P_nom - 4,2kW`` erstellt + +Optimierungs-Prozess +^^^^^^^^^^^^^^^^^^^^ + +.. code:: python + + # Python → Julia Datenübergabe + pm_dict = to_powermodels( + edisgo_obj, + timesteps=timesteps, + curtailment_14a=True + ) + + # pm_dict enthält: + { + "multinetwork": true, + "nw": { + "0": { # Zeitschritt 0 + "bus": {...}, + "load": {...}, # Inflexible Lasten (P, Q vorgegeben) + "gen": {...}, # Generatoren (P, Q vorgegeben) + "storage": {...}, # Batterie (SOC optimiert) + "heat_pump": {...}, # WP (P optimiert, Wärmebedarf vorgegeben) + "charging_point": {...}, # CP (P optimiert, Flexband vorgegeben) + "gen_hp_14a": {...}, # §14a virtuelle Generatoren (P optimiert) + "gen_cp_14a": {...} + }, + "1": {...}, # Zeitschritt 1 + ... + } + } + +**Julia-Optimierung:** - Löst für alle Zeitschritte gleichzeitig +(Multi-Period OPF) - Bestimmt optimale Fahrpläne für: - Batteriespeicher +(Ladung/Entladung) - Wärmepumpen (elektrische Leistung) - Wärmespeicher +(Beladung/Entladung) - Ladepunkte (Ladeleistung) - §14a-Abregelung +(Curtailment) + +Output-Zeitreihen +^^^^^^^^^^^^^^^^^ + +.. code:: python + + # Julia → Python Ergebnisrückgabe + result = pm_optimize(...) + + # from_powermodels schreibt optimierte Zeitreihen zurück + from_powermodels(edisgo_obj, result) + +**Aktualisierte Zeitreihen:** + +1. **Generatoren:** + + - ``generators_active_power``: + + - Original-Generatoren (ggf. mit Curtailment) + - **NEU:** Virtuelle §14a-Generatoren (``hp_14a_support_...``, + ``cp_14a_support_...``) + + - ``generators_reactive_power``: Optimierte Blindleistung + +2. **Speicher:** + + - ``storage_units_active_power``: Optimierte Ladung/Entladung + - ``storage_units_reactive_power``: Optimierte Blindleistung + +3. **Wärmepumpen:** + + - ``loads_active_power`` (WP-Spalten): Optimierte elektrische + Leistung + - Berücksichtigt thermischen Speicher (zeitliche Verschiebung) + +4. **Ladepunkte:** + + - ``loads_active_power`` (CP-Spalten): Optimierte Ladeleistung + - Innerhalb Flexibilitätsbänder + +5. **§14a-Abregelung (NEU):** + + - Als virtuelle Generatoren in ``generators_active_power``: + - Spalten: ``hp_14a_support_{hp_name}``, + ``cp_14a_support_{cp_name}`` + - Werte: Abregelleistung in MW + - **Nettolast = Original-Last - Abregelung** + +**Beispiel:** + +.. code:: python + + # VOR Optimierung + print(edisgo.timeseries.generators_active_power.columns) + # ['PV_001', 'Wind_002', 'PV_003', ...] + + print(edisgo.timeseries.loads_active_power.columns) + # ['Load_HH_001', 'HP_LV_123', 'CP_LV_456', ...] + + # Optimierung MIT §14a + edisgo.pm_optimize(opf_version=2, curtailment_14a=True) + + # NACH Optimierung + print(edisgo.timeseries.generators_active_power.columns) + # ['PV_001', 'Wind_002', 'PV_003', ..., + # 'hp_14a_support_HP_LV_123', # NEU! + # 'cp_14a_support_CP_LV_456'] # NEU! + + # WP-Last optimiert (zeitlich verschoben durch thermischen Speicher) + hp_load_opt = edisgo.timeseries.loads_active_power["HP_LV_123"] + + # §14a-Abregelung + hp_curtailment = edisgo.timeseries.generators_active_power["hp_14a_support_HP_LV_123"] + + # Effektive Nettolast + hp_net_load = hp_load_opt - hp_curtailment + +Zeitreihen-Auflösung +~~~~~~~~~~~~~~~~~~~~ + +Die Zeitauflösung beeinflusst Optimierung und Genauigkeit: + ++------------+----------------------+----------------------+-----------+ +| Auflösung | Zeitschritte/Jahr | Optimierungsgröße | Use Case | ++============+======================+======================+===========+ +| 1 Stunde | 8760 | Groß (langsam) | Jahressi | +| | | | mulation, | +| | | | det | +| | | | aillierte | +| | | | Planung | ++------------+----------------------+----------------------+-----------+ +| 15 Minuten | 35040 | Sehr groß | Intr | +| | | | aday-Flex | +| | | | ibilität, | +| | | | genaue | +| | | | Ne | +| | | | tzanalyse | ++------------+----------------------+----------------------+-----------+ +| 1 Tag | 365 | Klein (schnell) | Grobe | +| | | | Planung, | +| | | | Se | +| | | | nsitivitä | +| | | | tsstudien | ++------------+----------------------+----------------------+-----------+ +| Worst-Case | 2-10 | Sehr klein | Schnelle | +| | | | Net | +| | | | zplanung, | +| | | | Screening | ++------------+----------------------+----------------------+-----------+ + +**Wichtig für §14a:** - Zeitbudget skaliert mit Auflösung: - +1h-Auflösung: ``max_timesteps_per_day = 2h / 1h = 2`` - 15min-Auflösung: +``max_timesteps_per_day = 2h / 0.25h = 8`` + +Zeitreihen-Persistenz +~~~~~~~~~~~~~~~~~~~~~ + +.. code:: python + + # Speichern + edisgo.save(directory="results/grid_001") + + # Lädt: + # - Netztopologie (Leitungen, Trafos, Busse) + # - Zeitreihen (alle DataFrames) + # - Results (PFA-Ergebnisse) + # - Optimierungsergebnisse (§14a-Curtailment) + + # Laden + edisgo = EDisGo(directory="results/grid_001") + + # Zeitreihen direkt verfügbar + timesteps = edisgo.timeseries.timeindex + gen_p = edisgo.timeseries.generators_active_power + +-------------- + +Dateipfade und Referenzen +------------------------- + +Python-Dateien +~~~~~~~~~~~~~~ + ++-----------------+---------------+------------------------------------+ +| Datei | Pfad | Beschreibung | ++=================+===============+====================================+ +| **EDisGo | ``edisg | ``analyze()``, ``reinforce()``, | +| Hauptklasse** | o/edisgo.py`` | ``pm_optimize()`` Methoden | ++-----------------+---------------+------------------------------------+ +| **PowerModels | ``edi | ``to_powermodels()``, | +| I/O** | sgo/io/powerm | ``from_powermodels()``, | +| | odels_io.py`` | §14a-Generatoren | ++-----------------+---------------+------------------------------------+ +| **PowerModels | ``edisg | Julia-Subprozess, | +| OPF** | o/opf/powermo | JSON-Kommunikation | +| | dels_opf.py`` | | ++-----------------+---------------+------------------------------------+ +| **Reinforce | ``edisgo/ | Verstärkungsalgorithmus | +| I | flex_opt/ | | +| mplementation** | reinforce_ | | +| | grid.py`` | | ++-----------------+---------------+------------------------------------+ +| **Reinforce | ``edisgo/ | Leitungs-/Trafo-Verstärkung | +| Measures** | flex_opt/ | | +| | reinforce_ | | +| | measures.py`` | | ++-----------------+---------------+------------------------------------+ +| **Timeseries** | ``edisg | Zeitreihen-Verwaltung | +| | o/edisgo.py`` | | +| | (Klasse | | +| | `` | | +| | TimeSeries``) | | ++-----------------+---------------+------------------------------------+ + +Julia-Dateien +~~~~~~~~~~~~~ + ++-----------------+---------------+------------------------------------+ +| Datei | Pfad | Beschreibung | ++=================+===============+====================================+ +| **Main Entry** | ``edisgo/o | Solver-Setup, JSON I/O | +| | pf/eDisGo_OPF | | +| | .jl/Main.jl`` | | ++-----------------+---------------+------------------------------------+ +| **OPF Problem** | ``edisgo | ``build_mn_opf_bf_flex()`` | +| | /opf/eDisGo_O | | +| | PF.jl/src/pro | | +| | b/opf_bf.jl`` | | ++-----------------+---------------+------------------------------------+ +| **Variables** | ``edisgo/op | Alle Variablendefinitionen | +| | f/eDisGo_OPF. | | +| | jl/src/core/v | | +| | ariables.jl`` | | ++-----------------+---------------+------------------------------------+ +| **Constraints** | ``edisgo/opf | Standard-Constraints | +| | /eDisGo_OPF.j | | +| | l/src/core/co | | +| | nstraint.jl`` | | ++-----------------+---------------+------------------------------------+ +| **§14a HP | ``edis | WP-§14a-Constraints | +| Constraints** | go/opf/eDisGo | | +| | _OPF.jl/src/c | | +| | ore/constrain | | +| | t_hp_14a.jl`` | | ++-----------------+---------------+------------------------------------+ +| **§14a CP | ``edis | CP-§14a-Constraints | +| Constraints** | go/opf/eDisGo | | +| | _OPF.jl/src/c | | +| | ore/constrain | | +| | t_cp_14a.jl`` | | ++-----------------+---------------+------------------------------------+ +| **Objective** | ``edisgo/op | Zielfunktionen (4 Versionen) | +| | f/eDisGo_OPF. | | +| | jl/src/core/o | | +| | bjective.jl`` | | ++-----------------+---------------+------------------------------------+ +| **Branch Flow** | ``ed | Branch-Flow-Formulierung | +| | isgo/opf/eDis | | +| | Go_OPF.jl/src | | +| | /form/bf.jl`` | | ++-----------------+---------------+------------------------------------+ +| **Data | ``edis | Datenstrukturen | +| Handling** | go/opf/eDisGo | | +| | _OPF.jl/src/c | | +| | ore/data.jl`` | | ++-----------------+---------------+------------------------------------+ +| **Solution | ``edisgo/o | Ergebnis-Extraktion | +| Processing** | pf/eDisGo_OPF | | +| | .jl/src/core/ | | +| | solution.jl`` | | ++-----------------+---------------+------------------------------------+ + +Beispiele +~~~~~~~~~ + ++-----------------+---------------+------------------------------------+ +| Datei | Pfad | Beschreibung | ++=================+===============+====================================+ +| **§14a | ``example | Beispiel für §14a-Optimierung und | +| Analyse** | s/example_ana | Auswertung | +| | lyze_14a.py`` | | ++-----------------+---------------+------------------------------------+ +| **Standard | ``exam | Standard-OPF ohne §14a | +| Optimierung** | ples/example_ | | +| | optimize.py`` | | ++-----------------+---------------+------------------------------------+ +| ** | ``examp | Netzausbau-Beispiel | +| Reinforcement** | les/example_r | | +| | einforce.py`` | | ++-----------------+---------------+------------------------------------+ + +Konfigurationsdateien +~~~~~~~~~~~~~~~~~~~~~ + ++-----------------+---------------+------------------------------------+ +| Datei | Pfad | Beschreibung | ++=================+===============+====================================+ +| **Config** | ``edisgo/c | Default-Einstellungen | +| | onfig/config_ | (Spannungsgrenzen, | +| | default.cfg`` | Kostenparameter) | ++-----------------+---------------+------------------------------------+ +| **Equipment | ``edisgo/c | Leitungs-/Trafo-Typen mit techn. | +| Data** | onfig/equipme | Daten | +| | nt_data.csv`` | | ++-----------------+---------------+------------------------------------+ diff --git a/doc/whatsnew/v0-3-0.rst b/doc/whatsnew/v0-3-0.rst index 1335a26c7..fb1b0bb91 100644 --- a/doc/whatsnew/v0-3-0.rst +++ b/doc/whatsnew/v0-3-0.rst @@ -28,4 +28,7 @@ Changes * Move function to assign feeder to Topology class and add methods to the Grid class to get information on the feeders `#360 `_ * Added a storage operation strategy where the storage is charged when PV feed-in is higher than electricity demand of the household and discharged when electricity demand exceeds PV generation `#386 `_ * Added an estimation of the voltage deviation over a cable when selecting a suitable cable to connect a new component `#411 `_ -* Added clipping of heat pump electrical power at its maximum value #428 +* Added clipping of heat pump electrical power at its maximum value `#428 `_ +* Loading predefined time series now automatically sets the timeindex to the default year of the database if it is empty. `#457 `_ +* Made OEP database call optional in get_database_alias_dictionaries, allowing setup without OEP when using an alternative eGon-data database. `#451 `_ +* Fixed database import issues by addressing table naming assumptions and added support for external SSH tunneling in eGon-data configurations. `#451 `_ diff --git a/edisgo/config/config_opf_julia_default.cfg b/edisgo/config/config_opf_julia_default.cfg index 5a24d842e..e0c1eacee 100644 --- a/edisgo/config/config_opf_julia_default.cfg +++ b/edisgo/config/config_opf_julia_default.cfg @@ -11,4 +11,4 @@ [julia_dir] -julia_bin = julia-1.1.0/bin +julia_bin = julia/bin diff --git a/edisgo/edisgo.py b/edisgo/edisgo.py index eaa1111ef..aad8c2d8b 100755 --- a/edisgo/edisgo.py +++ b/edisgo/edisgo.py @@ -41,6 +41,7 @@ ) from edisgo.io.heat_pump_import import oedb as import_heat_pumps_oedb from edisgo.io.storage_import import home_batteries_oedb +from edisgo.io.timeseries_import import _timeindex_helper_func from edisgo.network import timeseries from edisgo.network.dsm import DSM from edisgo.network.electromobility import Electromobility @@ -71,6 +72,11 @@ class EDisGo: ---------- ding0_grid : :obj:`str` Path to directory containing csv files of network to be loaded. + engine : :sqlalchemy:`sqlalchemy.Engine` or None + Database engine for connecting to the `OpenEnergy DataBase OEDB + `_ or other eGon-data + databases. Defaults to the OEDB engine. Can be set to None if no scenario is to + be loaded. generator_scenario : None or :obj:`str`, optional If None, the generator park of the imported grid is kept as is. Otherwise defines which scenario of future generator park to use @@ -158,8 +164,10 @@ class EDisGo: """ def __init__(self, **kwargs): + # Set database engine for future scenarios + self.engine: Engine | None = kwargs.pop("engine", egon_engine()) # load configuration - self._config = Config(**kwargs) + self._config = Config(engine=self.engine, **kwargs) # instantiate topology object and load grid data self.topology = Topology(config=self.config) @@ -418,12 +426,9 @@ def set_time_series_active_power_predefined( Technology- and weather cell-specific hourly feed-in time series are obtained from the `OpenEnergy DataBase - `_. See - :func:`edisgo.io.timeseries_import.feedin_oedb` for more information. - - This option requires that the parameter `engine` is provided in case - new ding0 grids with geo-referenced LV grids are used. For further - settings, the parameter `timeindex` can also be provided. + `_ or other eGon-data + databases. See :func:`edisgo.io.timeseries_import.feedin_oedb` for more + information. * :pandas:`pandas.DataFrame` @@ -536,9 +541,6 @@ def set_time_series_active_power_predefined( Other Parameters ------------------ - engine : :sqlalchemy:`sqlalchemy.Engine` - Database engine. This parameter is only required in case - `conventional_loads_ts` or `fluctuating_generators_ts` is 'oedb'. scenario : str Scenario for which to retrieve demand data. Possible options are 'eGon2035' and 'eGon100RE'. This parameter is only required in case @@ -556,15 +558,45 @@ def set_time_series_active_power_predefined( is indexed using a default year and set for the whole year. """ + timeindex = kwargs.get("timeindex", None) engine = kwargs["engine"] if "engine" in kwargs else egon_engine() - if self.timeseries.timeindex.empty: + if timeindex is not None and not self.timeseries.timeindex.empty: + logger.warning( + "The given timeindex is different from the EDisGo.TimeSeries.timeindex." + " Therefore the EDisGo.TimeSeries.timeindex will be overwritten by the " + "given timeindex." + ) + + set_timeindex = True + + elif self.timeseries.timeindex.empty: logger.warning( - "When setting time series using predefined profiles it is better to " - "set a time index as all data in TimeSeries class is indexed by the" - "time index. You can set the time index upon initialisation of " - "the EDisGo object by providing the input parameter 'timeindex' or by " - "using the function EDisGo.set_timeindex()." + "The EDisGo.TimeSeries.timeindex is empty. By default, this function " + "will set the timeindex to the default year of the provided database " + "connection. To ensure expected behavior, consider setting the " + "timeindex explicitly before running this function using " + "EDisGo.set_timeindex()." ) + + set_timeindex = True + + else: + set_timeindex = False + + if set_timeindex: + if timeindex is None: + timeindex, _ = _timeindex_helper_func( + self, timeindex, allow_leap_year=True + ) + + logger.warning(f"Setting EDisGo.TimeSeries.timeindex to {timeindex}.") + + self.set_timeindex(timeindex) + + logger.info( + f"Trying to set predefined timeseries for {self.timeseries.timeindex}" + ) + if fluctuating_generators_ts is not None: self.timeseries.predefined_fluctuating_generators_by_technology( self, @@ -796,6 +828,7 @@ def to_powermodels( flexible_loads=None, flexible_storage_units=None, opf_version=1, + curtailment_14a=False, ): """ Convert eDisGo representation of the network topology and timeseries to @@ -822,6 +855,11 @@ def to_powermodels( Version of optimization models to choose from. Must be one of [1, 2, 3, 4]. For more information see :func:`edisgo.opf.powermodels_opf.pm_optimize`. Default: 1. + curtailment_14a : bool + If True, enables §14a EnWG curtailment for heat pumps with virtual + generators. Heat pumps can be curtailed down to 4.2 kW with time budget + constraints. + Default: False. Returns ------- @@ -838,6 +876,7 @@ def to_powermodels( flexible_loads=flexible_loads, flexible_storage_units=flexible_storage_units, opf_version=opf_version, + curtailment_14a=curtailment_14a, ) def pm_optimize( @@ -854,6 +893,7 @@ def pm_optimize( save_heat_storage=True, save_slack_gen=True, save_slacks=True, + curtailment_14a=False, ): """ Run OPF in julia subprocess and write results of OPF back to edisgo object. @@ -901,6 +941,11 @@ def pm_optimize( hence there will be no logging coming from julia subprocess in python process. Default: False. + curtailment_14a : bool + If True, enables §14a EnWG curtailment for heat pumps with virtual + generators. Heat pumps can be curtailed down to 4.2 kW with time budget + constraints. + Default: False. """ return powermodels_opf.pm_optimize( self, @@ -913,6 +958,7 @@ def pm_optimize( method=method, warm_start=warm_start, silence_moi=silence_moi, + curtailment_14a=curtailment_14a, ) def to_graph(self): @@ -972,9 +1018,7 @@ def import_generators(self, generator_scenario=None, **kwargs): Other Parameters ---------------- kwargs : - In case you are using new ding0 grids, where the LV is geo-referenced, a - database engine needs to be provided through keyword argument `engine`. - In case you are using old ding0 grids, where the LV is not geo-referenced, + If you are using old ding0 grids, where the LV is not geo-referenced, you can check :func:`edisgo.io.generators_import.oedb_legacy` for possible keyword arguments. @@ -1352,7 +1396,7 @@ def reinforce( """ if copy_grid: - edisgo_obj = copy.deepcopy(self) + edisgo_obj = self.copy() else: edisgo_obj = self @@ -1921,9 +1965,8 @@ def _aggregate_time_series(attribute, groups, naming): def import_electromobility( self, - data_source: str, + data_source: str = "oedb", scenario: str = None, - engine: Engine = None, charging_processes_dir: PurePath | str = None, potential_charging_points_dir: PurePath | str = None, import_electromobility_data_kwds=None, @@ -1965,10 +2008,8 @@ def import_electromobility( * "oedb" Electromobility data is obtained from the `OpenEnergy DataBase - `_. - - This option requires that the parameters `scenario` and `engine` are - provided. + `_ or other eGon-data + databases depending on the provided Engine. * "directory" @@ -1978,9 +2019,6 @@ def import_electromobility( scenario : str Scenario for which to retrieve electromobility data in case `data_source` is set to "oedb". Possible options are "eGon2035" and "eGon100RE". - engine : :sqlalchemy:`sqlalchemy.Engine` - Database engine. Needs to be provided in case `data_source` is set to - "oedb". charging_processes_dir : str or pathlib.PurePath Directory holding data on charging processes (standing times, charging demand, etc. per vehicle), including metadata, from SimBEV. @@ -2042,7 +2080,7 @@ def import_electromobility( import_electromobility_from_oedb( self, scenario=scenario, - engine=engine, + engine=self.engine, **import_electromobility_data_kwds, ) elif data_source == "directory": @@ -2135,10 +2173,11 @@ def apply_charging_strategy(self, strategy="dumb", **kwargs): """ charging_strategy(self, strategy=strategy, **kwargs) - def import_heat_pumps(self, scenario, engine, timeindex=None, import_types=None): + def import_heat_pumps(self, scenario, timeindex=None, import_types=None): """ - Gets heat pump data for specified scenario from oedb and integrates the heat - pumps into the grid. + Gets heat pump data for specified scenario from the OEDB or other eGon-data + databases depending on the provided Engine and integrates the heat pumps into + the grid. Besides heat pump capacity the heat pump's COP and heat demand to be served are as well retrieved. @@ -2193,8 +2232,6 @@ def import_heat_pumps(self, scenario, engine, timeindex=None, import_types=None) scenario : str Scenario for which to retrieve heat pump data. Possible options are 'eGon2035' and 'eGon100RE'. - engine : :sqlalchemy:`sqlalchemy.Engine` - Database engine. timeindex : :pandas:`pandas.DatetimeIndex` or None Specifies time steps for which to set COP and heat demand data. Leap years can currently not be handled. In case the given @@ -2235,7 +2272,7 @@ def import_heat_pumps(self, scenario, engine, timeindex=None, import_types=None) year = tools.get_year_based_on_scenario(scenario) return self.import_heat_pumps( scenario, - engine, + self.engine, timeindex=pd.date_range(f"1/1/{year}", periods=8760, freq="H"), import_types=import_types, ) @@ -2243,7 +2280,7 @@ def import_heat_pumps(self, scenario, engine, timeindex=None, import_types=None) integrated_heat_pumps = import_heat_pumps_oedb( edisgo_object=self, scenario=scenario, - engine=engine, + engine=self.engine, import_types=import_types, ) if len(integrated_heat_pumps) > 0: @@ -2251,7 +2288,7 @@ def import_heat_pumps(self, scenario, engine, timeindex=None, import_types=None) self, "oedb", heat_pump_names=integrated_heat_pumps, - engine=engine, + engine=self.engine, scenario=scenario, timeindex=timeindex, ) @@ -2259,7 +2296,7 @@ def import_heat_pumps(self, scenario, engine, timeindex=None, import_types=None) self, "oedb", heat_pump_names=integrated_heat_pumps, - engine=engine, + engine=self.engine, timeindex=timeindex, ) @@ -2307,7 +2344,7 @@ def apply_heat_pump_operating_strategy( """ hp_operating_strategy(self, strategy=strategy, heat_pump_names=heat_pump_names) - def import_dsm(self, scenario: str, engine: Engine, timeindex=None): + def import_dsm(self, scenario: str, timeindex=None): """ Gets industrial and CTS DSM profiles from the `OpenEnergy DataBase `_. @@ -2326,8 +2363,6 @@ def import_dsm(self, scenario: str, engine: Engine, timeindex=None): scenario : str Scenario for which to retrieve DSM data. Possible options are 'eGon2035' and 'eGon100RE'. - engine : :sqlalchemy:`sqlalchemy.Engine` - Database engine. timeindex : :pandas:`pandas.DatetimeIndex` or None Specifies time steps for which to get data. Leap years can currently not be handled. In case the given timeindex contains a leap year, the data will be @@ -2340,7 +2375,7 @@ def import_dsm(self, scenario: str, engine: Engine, timeindex=None): """ dsm_profiles = dsm_import.oedb( - edisgo_obj=self, scenario=scenario, engine=engine, timeindex=timeindex + edisgo_obj=self, scenario=scenario, engine=self.engine, timeindex=timeindex ) self.dsm.p_min = dsm_profiles["p_min"] self.dsm.p_max = dsm_profiles["p_max"] @@ -2350,7 +2385,6 @@ def import_dsm(self, scenario: str, engine: Engine, timeindex=None): def import_home_batteries( self, scenario: str, - engine: Engine, ): """ Gets home battery data for specified scenario and integrates the batteries into @@ -2361,7 +2395,8 @@ def import_home_batteries( between two scenarios: 'eGon2035' and 'eGon100RE'. The data is retrieved from the - `open energy platform `_. + `open energy platform `_ or other eGon-data + databases depending on the given Engine. The batteries are integrated into the grid (added to :attr:`~.network.topology.Topology.storage_units_df`) based on their building @@ -2378,14 +2413,12 @@ def import_home_batteries( scenario : str Scenario for which to retrieve home battery data. Possible options are 'eGon2035' and 'eGon100RE'. - engine : :sqlalchemy:`sqlalchemy.Engine` - Database engine. """ home_batteries_oedb( edisgo_obj=self, scenario=scenario, - engine=engine, + engine=self.engine, ) def plot_mv_grid_topology(self, technologies=False, **kwargs): @@ -2412,6 +2445,7 @@ def plot_mv_grid_topology(self, technologies=False, **kwargs): xlim=kwargs.get("xlim", None), ylim=kwargs.get("ylim", None), title=kwargs.get("title", ""), + **kwargs, ) def plot_mv_voltages(self, **kwargs): @@ -3135,7 +3169,7 @@ def spatial_complexity_reduction( """ if copy_edisgo is True: - edisgo_obj = copy.deepcopy(self) + edisgo_obj = self.copy() else: edisgo_obj = self busmap_df, linemap_df = spatial_complexity_reduction( @@ -3349,6 +3383,44 @@ def resample_timeseries( self.heat_pump.resample_timeseries(method=method, freq=freq) self.overlying_grid.resample(method=method, freq=freq) + def copy(self, deep=True): + """ + Returns a copy of the object, with an option for a deep copy. + + The SQLAlchemy engine is excluded from the copying process and restored + afterward. + + Parameters + ---------- + deep : bool + If True, performs a deep copy; otherwise, performs a shallow copy. + + Returns + --------- + :class:`~.EDisGo` + Copied EDisGo object. + + """ + tmp_engine = ( + getattr(self, "engine", None) + if isinstance(getattr(self, "engine", None), Engine) + else None + ) + + if tmp_engine: + logging.info("Temporarily removing the SQLAlchemy engine before copying.") + self.engine = self.config._engine = None + + cpy = copy.deepcopy(self) if deep else copy.copy(self) + + if tmp_engine: + logging.info("Restoring the SQLAlchemy engine after copying.") + self.engine = self.config._engine = cpy.engine = cpy.config._engine = ( + tmp_engine + ) + + return cpy + def import_edisgo_from_pickle(filename, path=""): """ diff --git a/edisgo/flex_opt/battery_storage_operation.py b/edisgo/flex_opt/battery_storage_operation.py index 64447a8fd..086dec1f8 100644 --- a/edisgo/flex_opt/battery_storage_operation.py +++ b/edisgo/flex_opt/battery_storage_operation.py @@ -164,6 +164,41 @@ def apply_reference_operation( if storage_units_names is None: storage_units_names = edisgo_obj.topology.storage_units_df.index + if ( + edisgo_obj.topology.storage_units_df.loc[ + storage_units_names, "efficiency_store" + ] + .isna() + .all() + ): + logger.warning( + "The efficiency of storage units charge is not specified in the " + "storage_units_df. By default, it is set to 95%. To change this behavior, " + "first set the 'efficiency_store' parameter in topology.storage_units_df." + ) + + edisgo_obj.topology.storage_units_df.loc[ + storage_units_names, "efficiency_store" + ] = 0.95 + + if ( + edisgo_obj.topology.storage_units_df.loc[ + storage_units_names, "efficiency_dispatch" + ] + .isna() + .all() + ): + logger.warning( + "The efficiency of storage units discharge is not specified in the " + "storage_units_df. By default, it is set to 95%. To change this behavior, " + "first set the 'efficiency_dispatch' parameter in " + "topology.storage_units_df." + ) + + edisgo_obj.topology.storage_units_df.loc[ + storage_units_names, "efficiency_dispatch" + ] = 0.95 + storage_units = edisgo_obj.topology.storage_units_df.loc[storage_units_names] soe_df = pd.DataFrame(index=edisgo_obj.timeseries.timeindex) diff --git a/edisgo/flex_opt/reinforce_grid.py b/edisgo/flex_opt/reinforce_grid.py index e56eb58b4..6da2449f5 100644 --- a/edisgo/flex_opt/reinforce_grid.py +++ b/edisgo/flex_opt/reinforce_grid.py @@ -519,7 +519,8 @@ def reinforce_grid( ) raise exceptions.MaximumIterationError( "Over-voltage issues for the following nodes in LV grids " - f"could not be solved: {crit_nodes}" + f"could not be solved within {max_while_iterations} iterations: " + f"{crit_nodes}" ) else: logger.info( diff --git a/edisgo/io/db.py b/edisgo/io/db.py index 138ebe735..7fc794624 100644 --- a/edisgo/io/db.py +++ b/edisgo/io/db.py @@ -178,7 +178,7 @@ def engine( """ - if not ssh: + if path is None: # Github Actions KHs token if "OEP_TOKEN_KH" in os.environ: token = os.environ["OEP_TOKEN_KH"] @@ -227,7 +227,8 @@ def engine( ) cred = credentials(path=path) - local_port = ssh_tunnel(cred) + + local_port = ssh_tunnel(cred) if ssh else int(cred["--database-port"]) return create_engine( f"postgresql+psycopg2://{cred['POSTGRES_USER']}:" diff --git a/edisgo/io/ding0_import.py b/edisgo/io/ding0_import.py index 326553111..ffdf740df 100644 --- a/edisgo/io/ding0_import.py +++ b/edisgo/io/ding0_import.py @@ -116,9 +116,9 @@ def sort_hvmv_transformer_buses(transformers_df): columns={"r": "r_pu", "x": "x_pu"} ) ) - edisgo_obj.topology.switches_df = pd.read_csv( - os.path.join(path, "switches.csv"), index_col=[0] - ) + # edisgo_obj.topology.switches_df = pd.read_csv( + # os.path.join(path, "switches.csv"), index_col=[0] + # ) edisgo_obj.topology.grid_district = { "population": grid.mv_grid_district_population, diff --git a/edisgo/io/electromobility_import.py b/edisgo/io/electromobility_import.py index 68c17e214..d248530a6 100644 --- a/edisgo/io/electromobility_import.py +++ b/edisgo/io/electromobility_import.py @@ -15,6 +15,7 @@ from sklearn import preprocessing from sqlalchemy.engine.base import Engine +from edisgo.io.db import engine as egon_engine from edisgo.io.db import get_srid_of_db_table, session_scope_egon_data from edisgo.tools.config import Config @@ -1079,11 +1080,11 @@ def distribute_public_charging_demand(edisgo_obj, **kwargs): idx, "charging_point_id" ] = charging_point_id - available_charging_points_df.loc[ - charging_point_id - ] = edisgo_obj.electromobility.charging_processes_df.loc[ - idx, available_charging_points_df.columns - ].tolist() + available_charging_points_df.loc[charging_point_id] = ( + edisgo_obj.electromobility.charging_processes_df.loc[ + idx, available_charging_points_df.columns + ].tolist() + ) designated_charging_point_capacity_df.at[ charging_park_id, "designated_charging_point_capacity" @@ -1314,6 +1315,9 @@ def charging_processes_from_oedb( more information. """ + if not engine: + engine = egon_engine() + config = Config() egon_ev_mv_grid_district, egon_ev_trip = config.import_tables_from_oep( engine, ["egon_ev_mv_grid_district", "egon_ev_trip"], "demand" diff --git a/edisgo/io/heat_pump_import.py b/edisgo/io/heat_pump_import.py index 921461514..de2f6373c 100644 --- a/edisgo/io/heat_pump_import.py +++ b/edisgo/io/heat_pump_import.py @@ -327,7 +327,7 @@ def _get_individual_heat_pump_capacity(): "boundaries", ) egon_etrago_bus, egon_etrago_link = config.import_tables_from_oep( - engine, ["egon_etrago_bus", "egon_etrago_link"], "supply" + engine, ["egon_etrago_bus", "egon_etrago_link"], "grid" ) building_ids = edisgo_object.topology.loads_df.building_id.unique() diff --git a/edisgo/io/powermodels_io.py b/edisgo/io/powermodels_io.py index 5d68f35b3..9c2553a6d 100644 --- a/edisgo/io/powermodels_io.py +++ b/edisgo/io/powermodels_io.py @@ -31,6 +31,7 @@ def to_powermodels( flexible_loads=None, flexible_storage_units=None, opf_version=1, + curtailment_14a=None, ): """ Convert eDisGo representation of the network topology and timeseries to @@ -58,6 +59,12 @@ def to_powermodels( Version of optimization models to choose from. Must be one of [1, 2, 3, 4]. For more information see :func:`edisgo.opf.powermodels_opf.pm_optimize`. Default: 1. + curtailment_14a : dict or None + Dictionary with §14a EnWG curtailment settings. Keys: + - 'max_power_mw': float, minimum power in MW (default 0.0042) + - 'components': list, heat pump names to apply curtailment to (empty = all) + - 'max_hours_per_day': float, maximum curtailment hours per day (default 2.0) + Default: None (no §14a curtailment). Returns ------- @@ -90,6 +97,7 @@ def to_powermodels( if (flex not in opf_flex) & (len(loads) != 0): logger.info("{} will be optimized.".format(text)) opf_flex.append(flex) + hv_flex_dict = dict() # Sorts buses such that bus0 is always the upstream bus edisgo_object.topology.sort_buses() @@ -118,6 +126,22 @@ def to_powermodels( ) # length of timesteps in hours pm["baseMVA"] = s_base pm["source_version"] = 2 + + # Add §14a curtailment flexibility if enabled (must be BEFORE pm["flexibilities"] assignment) + if curtailment_14a is not None and curtailment_14a is not False: + all_hps = edisgo_object.topology.loads_df[ + edisgo_object.topology.loads_df['type'] == 'heat_pump' + ] + if len(all_hps) > 0: + logger.info("§14a heat pump curtailment will be optimized.") + opf_flex.append("hp_14a") + + # Check for charging points + all_cps = edisgo_object.topology.charging_points_df + if len(all_cps) > 0: + logger.info("§14a charging point curtailment will be optimized.") + opf_flex.append("cp_14a") + pm["flexibilities"] = opf_flex logger.info("Transforming busses into PowerModels dictionary format.") _build_bus(psa_net, edisgo_object, pm, flexible_storage_units) @@ -141,15 +165,53 @@ def to_powermodels( s_base, flexible_cps, ) - if len(flexible_hps) > 0: + + # Get all heat pumps for §14a support (needed before flexible_hps check) + all_hps = edisgo_object.topology.loads_df[ + edisgo_object.topology.loads_df['type'] == 'heat_pump' + ].index.to_numpy() + + # Determine which heat pumps need to be in PowerModels dict + # Either flexible_hps OR §14a curtailment requires heatpump dict + hps_for_pm = flexible_hps if len(flexible_hps) > 0 else [] + if curtailment_14a is not None and curtailment_14a is not False and len(all_hps) > 0: + # For §14a, we need ALL heat pumps in the PM dict + hps_for_pm = all_hps + + if len(hps_for_pm) > 0: logger.info("Transforming heatpumps into PowerModels dictionary format.") - _build_heatpump(psa_net, pm, edisgo_object, s_base, flexible_hps) + _build_heatpump(psa_net, pm, edisgo_object, s_base, hps_for_pm) logger.info( "Transforming heat storage units into PowerModels dictionary format." ) _build_heat_storage( - psa_net, pm, edisgo_object, s_base, flexible_hps, opf_version + psa_net, pm, edisgo_object, s_base, hps_for_pm, opf_version ) + + # Create virtual generators for §14a curtailment if enabled + # This applies to ALL heat pumps, not just flexible ones + if curtailment_14a is not None and curtailment_14a is not False: + # Convert bool to dict if needed + if curtailment_14a is True: + curtailment_14a_config = {} + else: + curtailment_14a_config = curtailment_14a + + if len(all_hps) > 0: + logger.info(f"Creating virtual generators for §14a heat pump support ({len(all_hps)} heat pumps).") + _build_gen_hp_14a_support( + psa_net, pm, edisgo_object, s_base, all_hps, curtailment_14a_config + ) + + # Build §14a support for charging points + # Use ALL charging points for §14a, not just flexible ones + all_cps = edisgo_object.topology.charging_points_df.index.to_numpy() + if len(all_cps) > 0: + logger.info(f"Creating virtual generators for §14a charging point support ({len(all_cps)} CPs).") + _build_gen_cp_14a_support( + psa_net, pm, edisgo_object, s_base, all_cps, curtailment_14a_config + ) + if len(flexible_loads) > 0: logger.info("Transforming DSM loads into PowerModels dictionary format.") flexible_loads = _build_dsm(edisgo_object, psa_net, pm, s_base, flexible_loads) @@ -271,17 +333,44 @@ def from_powermodels( "cp": ["electromobility", "pcp"], "storage": ["storage", "pf"], "dsm": ["dsm", "pdsm"], + "hp_14a": ["gen_hp_14a", "p"], # §14a virtual generators (uses "p" not "php14a") + "cp_14a": ["gen_cp_14a", "p"], # §14a virtual generators for charging points } timesteps = pd.Series([int(k) for k in pm["nw"].keys()]).sort_values().values logger.info("Writing OPF results to eDisGo object.") + + print("\n" + "="*80) + print("🔍 PYTHON DEBUG: Processing OPF Results") + print("="*80) + print(f"Flexibilities in results: {pm_results['nw']['1']['flexibilities']}") + print("="*80 + "\n") + # write active power OPF results to edisgo object for flexibility in pm_results["nw"]["1"]["flexibilities"]: + print(f" → Processing flexibility: {flexibility}") flex, variable = flex_dicts[flexibility] + print(f" flex={flex}, variable={variable}") + + # Check if flex exists in network + if flex not in pm["nw"]["1"]: + print(f" ⚠ WARNING: '{flex}' not found in pm['nw']['1']!") + continue + names = [ pm["nw"]["1"][flex][flex_comp]["name"] for flex_comp in list(pm["nw"]["1"][flex].keys()) ] + print(f" Found {len(names)} components: {names[:5]}...") # Show first 5 + + # Check if results exist in pm_results + if flex not in pm_results["nw"]["1"]: + print(f" ⚠ WARNING: '{flex}' not found in pm_results['nw']['1']!") + print(f" Available keys in pm_results['nw']['1']: {list(pm_results['nw']['1'].keys())}") + continue + + print(f" ✓ '{flex}' found in pm_results, extracting data...") + # replace storage power values by branch power values of virtual branch to # account for losses if flex == "storage": @@ -299,17 +388,36 @@ def from_powermodels( else: data = [ [ - pm["nw"][str(t)][flex][flex_comp][variable] * s_base + pm_results["nw"][str(t)][flex][flex_comp][variable] * s_base for flex_comp in list(pm["nw"]["1"][flex].keys()) ] for t in timesteps ] + + print(f" ✓ Extracted {len(data)} timesteps x {len(data[0])} components") results = pd.DataFrame(index=timesteps, columns=names, data=data) if (flex == "gen_nd") & (pm["nw"]["1"]["opf_version"] in [3, 4]): edisgo_object.timeseries._generators_active_power.loc[:, names] = ( edisgo_object.timeseries.generators_active_power.loc[:, names].values - results[names].values ) + elif flex == "gen_hp_14a": + # §14a virtual generators: write as positive generation + print(f" → Writing {len(names)} gen_hp_14a generators to generators_active_power") + print(f" → Sample values: {results.iloc[0, :3].to_dict()}") + edisgo_object.timeseries._generators_active_power.loc[:, names] = results[ + names + ].values + print(f" ✓ Written successfully!") + + elif flex == "gen_cp_14a": + # §14a virtual generators for CPs: write as positive generation + print(f" → Writing {len(names)} gen_cp_14a generators to generators_active_power") + print(f" → Sample values: {results.iloc[0, :3].to_dict()}") + edisgo_object.timeseries._generators_active_power.loc[:, names] = results[ + names + ].values + print(f" ✓ Written successfully!") elif flex in ["heatpumps", "electromobility"]: edisgo_object.timeseries._loads_active_power.loc[:, names] = results[ names @@ -523,6 +631,8 @@ def _init_pm(): "heat_storage": dict(), "dsm": dict(), "HV_requirements": dict(), + "gen_hp_14a": dict(), # Virtual generators for §14a heat pump support + "gen_cp_14a": dict(), # Virtual generators for §14a charging point support "baseMVA": 1, "source_version": 2, "shunt": dict(), @@ -537,6 +647,8 @@ def _init_pm(): "heatpumps": dict(), "dsm": dict(), "HV_requirements": dict(), + "gen_hp_14a": dict(), # Timeseries for virtual HP support generators + "gen_cp_14a": dict(), # Timeseries for virtual CP support generators "num_steps": int, }, } @@ -1236,6 +1348,198 @@ def _build_heatpump(psa_net, pm, edisgo_obj, s_base, flexible_hps): } +def _build_gen_hp_14a_support(psa_net, pm, edisgo_obj, s_base, flexible_hps, curtailment_14a): + """ + Build virtual generator dictionary for §14a heat pump support and add it to + PowerModels dictionary 'pm'. + + Creates one virtual generator per heat pump at the same bus. The generator + can reduce the net electrical load to simulate §14a curtailment. + + Parameters + ---------- + psa_net : :pypsa:`PyPSA.Network` + :pypsa:`PyPSA.Network` representation of network. + pm : dict + (PowerModels) dictionary. + edisgo_obj : :class:`~.EDisGo` + s_base : int + Base value of apparent power for per unit system. + flexible_hps : :numpy:`numpy.ndarray` or list + Array containing all heat pumps that allow for flexible operation. + curtailment_14a : dict + Dictionary with §14a EnWG curtailment settings. + + """ + # Extract curtailment settings + p_min_14a = curtailment_14a.get("max_power_mw", 0.0042) # MW + max_hours_per_day = curtailment_14a.get("max_hours_per_day", 2.0) # hours + specific_components = curtailment_14a.get("components", []) + + # Filter heat pumps if specific components are defined + if len(specific_components) > 0: + hps_14a = np.intersect1d(flexible_hps, specific_components) + else: + hps_14a = flexible_hps + + if len(hps_14a) == 0: + logger.warning("No heat pumps selected for §14a curtailment.") + return + + heat_df = psa_net.loads.loc[hps_14a] + hp_p_nom = edisgo_obj.topology.loads_df.p_set[hps_14a] + + # Filter out heat pumps with nominal power <= §14a minimum + # These cannot be curtailed to the minimum and would make constraints infeasible + hps_eligible = [hp for hp in hps_14a if hp_p_nom[hp] > p_min_14a] + + if len(hps_eligible) < len(hps_14a): + excluded_hps = set(hps_14a) - set(hps_eligible) + logger.warning( + f"Excluded {len(excluded_hps)} heat pump(s) from §14a curtailment due to " + f"nominal power <= {p_min_14a*1000:.1f} kW: {excluded_hps}" + ) + + if len(hps_eligible) == 0: + logger.warning("No heat pumps eligible for §14a curtailment after filtering by minimum power.") + return + + # Create mapping from HP name to its index in pm["heatpumps"] + # The heatpumps dict is built by iterating over flexible_hps with indices 1, 2, 3, ... + hp_name_to_index = {hp_name: i + 1 for i, hp_name in enumerate(flexible_hps)} + + for hp_i, hp_name in enumerate(hps_eligible): + # Bus of the heat pump + idx_bus = _mapping(psa_net, edisgo_obj, heat_df.bus[hp_name]) + + # Nominal power of HP + p_nominal = hp_p_nom[hp_name] # MW + + # Maximum support = difference between nominal and §14a limit + # This is how much the load can be virtually reduced + p_max_support = p_nominal - p_min_14a # Now guaranteed > 0 + + pm["gen_hp_14a"][str(hp_i + 1)] = { + "name": f"hp_14a_support_{hp_name}", + "gen_bus": idx_bus, + "pmin": 0.0, + "pmax": p_max_support / s_base, + "qmin": 0.0, + "qmax": 0.0, + "pf": 1.0, + "sign": 1, + "gen_status": 1, + "hp_name": hp_name, # Reference to heat pump + "hp_index": hp_name_to_index[hp_name], # Correct index in heatpumps dict + "p_min_14a": p_min_14a / s_base, # §14a minimum power + "max_hours_per_day": max_hours_per_day, # Time budget + "index": hp_i + 1, + } + + +def _build_gen_cp_14a_support(psa_net, pm, edisgo_obj, s_base, all_cps, curtailment_14a): + """ + Build virtual generator dictionary for §14a charging point support and add it to + PowerModels dictionary 'pm'. + + Creates one virtual generator per charging point at the same bus. The generator + can reduce the net electrical load to simulate §14a curtailment. + + Parameters + ---------- + psa_net : :pypsa:`PyPSA.Network` + :pypsa:`PyPSA.Network` representation of network. + pm : dict + (PowerModels) dictionary. + edisgo_obj : :class:`~.EDisGo` + s_base : int + Base value of apparent power for per unit system. + all_cps : :numpy:`numpy.ndarray` or list + Array containing all charging points in the grid (for §14a curtailment). + curtailment_14a : dict + Dictionary with §14a EnWG curtailment settings. + + """ + # Extract curtailment settings + p_min_14a = curtailment_14a.get("max_power_mw", 0.0042) # MW (same as HPs) + max_hours_per_day = curtailment_14a.get("max_hours_per_day", 2.0) # hours + specific_components = curtailment_14a.get("components", []) + + # DEBUG: Print all CPs in topology + all_cps_in_topology = edisgo_obj.topology.charging_points_df + logger.info(f"DEBUG: Found {len(all_cps_in_topology)} charging points in topology.charging_points_df") + logger.info(f"DEBUG: all_cps parameter has {len(all_cps)} entries") + if len(all_cps_in_topology) > 0: + logger.info(f"DEBUG: Sample CP names: {list(all_cps_in_topology.index[:3])}") + + # Filter charging points if specific components are defined + if len(specific_components) > 0: + cps_14a = np.intersect1d(all_cps, specific_components) + else: + cps_14a = all_cps + + if len(cps_14a) == 0: + logger.warning("No charging points selected for §14a curtailment.") + return + + cp_df = edisgo_obj.topology.charging_points_df.loc[cps_14a] + logger.info(f"DEBUG: After filtering, cp_df has {len(cp_df)} charging points") + cp_p_nom = cp_df.p_set # Nominal charging power in MW + + # Filter out CPs with nominal power <= §14a minimum + # These cannot be curtailed to the minimum and would make constraints infeasible + cps_eligible = [cp for cp in cps_14a if cp_p_nom[cp] > p_min_14a] + + if len(cps_eligible) < len(cps_14a): + excluded_cps = set(cps_14a) - set(cps_eligible) + logger.warning( + f"Excluded {len(excluded_cps)} charging point(s) from §14a curtailment due to " + f"nominal power <= {p_min_14a*1000:.1f} kW: {excluded_cps}" + ) + + if len(cps_eligible) == 0: + logger.warning("No charging points eligible for §14a curtailment after filtering by minimum power.") + return + + # §14a curtailment works for ALL CPs (like HPs), not just flexible ones + # The virtual generator reduces the load, independent of flexibility optimization + cps_final = cps_eligible + + logger.info(f"Creating §14a support for {len(cps_final)} charging points.") + + # Create a simple index mapping for CPs (needed by Julia constraints) + # For non-flexible CPs, we create a sequential index starting from 1 + cp_name_to_index = {cp_name: idx + 1 for idx, cp_name in enumerate(cps_final)} + + for cp_i, cp_name in enumerate(cps_final): + # Bus of the charging point + idx_bus = _mapping(psa_net, edisgo_obj, cp_df.loc[cp_name, 'bus']) + + # Nominal power of CP + p_nominal = cp_p_nom[cp_name] # MW + + # Maximum support = difference between nominal and §14a limit + # This is how much the load can be virtually reduced + p_max_support = p_nominal - p_min_14a # Now guaranteed > 0 + + pm["gen_cp_14a"][str(cp_i + 1)] = { + "name": f"cp_14a_support_{cp_name}", + "gen_bus": idx_bus, + "pmin": 0.0, + "pmax": p_max_support / s_base, + "qmin": 0.0, + "qmax": 0.0, + "pf": 1.0, + "sign": 1, + "gen_status": 1, + "cp_name": cp_name, # Reference to charging point + "cp_index": cp_name_to_index[cp_name], # Sequential index for Julia + "p_min_14a": p_min_14a / s_base, # §14a minimum power + "max_hours_per_day": max_hours_per_day, # Time budget + "index": cp_i + 1, + } + + def _build_heat_storage(psa_net, pm, edisgo_obj, s_base, flexible_hps, opf_version): """ Build heat storage dictionary and add it to PowerModels dictionary 'pm'. @@ -1639,6 +1943,7 @@ def _build_timeseries( "load", "electromobility", "heatpumps", + "gen_hp_14a", "dsm", "HV_requirements", ]: @@ -1891,6 +2196,11 @@ def _build_component_timeseries( "pd": p_set[comp].values.tolist(), "cop": cop[comp].values.tolist(), } + elif kind == "gen_hp_14a": + # Timeseries for virtual §14a support generators (currently just bounds) + # The power bounds are static (defined in gen_hp_14a dict) + # No time-varying timeseries needed for now + pass elif kind == "dsm": if len(flexible_loads) > 0: p_set = (edisgo_obj.dsm.p_max[flexible_loads] / s_base).round(20) diff --git a/edisgo/io/storage_import.py b/edisgo/io/storage_import.py index 2ba1716cf..e5031ed24 100644 --- a/edisgo/io/storage_import.py +++ b/edisgo/io/storage_import.py @@ -73,7 +73,13 @@ def home_batteries_oedb( ) batteries_df = pd.read_sql(sql=query.statement, con=engine, index_col=None) - return _home_batteries_grid_integration(edisgo_obj, batteries_df) + names = _home_batteries_grid_integration(edisgo_obj, batteries_df) + + edisgo_obj.topology.storage_units_df.building_id = ( + edisgo_obj.topology.storage_units_df.building_id.astype(int) + ) + + return names def _home_batteries_grid_integration(edisgo_obj, batteries_df): diff --git a/edisgo/network/timeseries.py b/edisgo/network/timeseries.py index 8fb3c2faf..5c7f4fb6f 100644 --- a/edisgo/network/timeseries.py +++ b/edisgo/network/timeseries.py @@ -14,6 +14,7 @@ from edisgo.io import timeseries_import from edisgo.tools.tools import assign_voltage_level_to_component, resample +from edisgo.io.db import engine as egon_engine if TYPE_CHECKING: from edisgo import EDisGo @@ -1254,6 +1255,10 @@ def predefined_fluctuating_generators_by_technology( are used. """ + if not engine: + engine = egon_engine() + + # in case time series from oedb are used, retrieve oedb time series if isinstance(ts_generators, str) and ts_generators == "oedb": if edisgo_object.legacy_grids is True: @@ -1524,6 +1529,76 @@ def predefined_charging_points_by_use_case( ).T self.add_component_time_series("loads_active_power", ts_scaled) + def active_power_p_max_pu( + self, edisgo_object, ts_generators_p_max_pu, generator_names=None + ): + """ + Set active power feed-in time series for generators using p_max_pu time series. + + This function reads generator-specific p_max_pu time series (normalized to + nominal capacity) and scales them by the nominal power (p_nom) of each + generator to obtain absolute active power time series. + + Parameters + ---------- + edisgo_object : :class:`~.EDisGo` + ts_generators_p_max_pu : :pandas:`pandas.DataFrame` + DataFrame with generator-specific p_max_pu time series normalized to + a nominal capacity of 1. Each column represents a specific generator + and should match the generator names in the network. + Index needs to be a :pandas:`pandas.DatetimeIndex`. + Column names should correspond to generator names in + :attr:`~.network.topology.Topology.generators_df`. + generator_names : list(str), optional + Defines for which generators to set p_max_pu time series. If None, + all generators for which p_max_pu time series are provided in + `ts_generators_p_max_pu` are used. Default: None. + + Notes + ----- + This function is useful when you have generator-specific capacity factors + or availability profiles that differ from technology-wide profiles. + + """ + if not isinstance(ts_generators_p_max_pu, pd.DataFrame): + raise ValueError( + "Parameter 'ts_generators_p_max_pu' must be a pandas DataFrame." + ) + elif ts_generators_p_max_pu.empty: + logger.warning("Provided time series dataframe is empty.") + return + + # set generator_names if None + if generator_names is None: + generator_names = ts_generators_p_max_pu.columns.tolist() + + generator_names = self._check_if_components_exist( + edisgo_object, generator_names, "generators" + ) + + # Filter to only include generators that have time series provided + generators_with_ts = [ + gen for gen in generator_names if gen in ts_generators_p_max_pu.columns + ] + + if not generators_with_ts: + logger.warning( + "None of the specified generators have time series in " + "ts_generators_p_max_pu." + ) + return + + generators_df = edisgo_object.topology.generators_df.loc[generators_with_ts, :] + + # scale time series by nominal power + ts_scaled = generators_df.apply( + lambda x: ts_generators_p_max_pu[x.name] * x.p_nom, + axis=1, + ).T + + if not ts_scaled.empty: + self.add_component_time_series("generators_active_power", ts_scaled) + def fixed_cosphi( self, edisgo_object, diff --git a/edisgo/opf/eDisGo_OPF.jl/src/core/base.jl b/edisgo/opf/eDisGo_OPF.jl/src/core/base.jl index 3704f4c98..b37bd220b 100644 --- a/edisgo/opf/eDisGo_OPF.jl/src/core/base.jl +++ b/edisgo/opf/eDisGo_OPF.jl/src/core/base.jl @@ -139,6 +139,14 @@ function ref_add_core!(ref::Dict{Symbol,Any}) end nw_ref[:bus_hps] = bus_hps + bus_gen_hp_14a = Dict((i, Int[]) for (i,bus) in nw_ref[:bus]) + if haskey(nw_ref, :gen_hp_14a) + for (i,gen) in nw_ref[:gen_hp_14a] + push!(bus_gen_hp_14a[gen["gen_bus"]], i) + end + end + nw_ref[:bus_gen_hp_14a] = bus_gen_hp_14a + bus_gens_nd = Dict((i, Int[]) for (i,bus) in nw_ref[:bus]) for (i,gen) in nw_ref[:gen_nd] push!(bus_gens_nd[gen["gen_bus"]], i) diff --git a/edisgo/opf/eDisGo_OPF.jl/src/core/constraint_cp_14a.jl b/edisgo/opf/eDisGo_OPF.jl/src/core/constraint_cp_14a.jl new file mode 100644 index 000000000..03c9afd9b --- /dev/null +++ b/edisgo/opf/eDisGo_OPF.jl/src/core/constraint_cp_14a.jl @@ -0,0 +1,173 @@ +""" +Constraints for §14a EnWG charging point curtailment using virtual generators. + +This file implements §14a curtailment by modeling virtual generators at each +charging point bus. The virtual generator can reduce the net electrical load, +simulating the effect of curtailment while maintaining a minimum power level. +""" + +""" + constraint_cp_14a_binary_coupling(pm, i, nw) + +Couples binary variable with power variable for §14a support generator. +When binary variable is 0, power must be 0. When binary is 1, power can be between 0 and pmax. +This ensures time budget tracking works correctly. + +# Arguments +- `pm::AbstractBFModelEdisgo`: PowerModels model +- `i::Int`: Generator index +- `nw::Int`: Network (timestep) index +""" +function constraint_cp_14a_binary_coupling(pm::AbstractBFModelEdisgo, i::Int, nw::Int=nw_id_default) + gen_cp14a = PowerModels.ref(pm, nw, :gen_cp_14a, i) + p_cp14a = PowerModels.var(pm, nw, :p_cp14a, i) + z_cp14a = PowerModels.var(pm, nw, :z_cp14a, i) + + # p ≤ pmax × z (if z=0 then p=0, if z=1 then p can be 0..pmax) + JuMP.@constraint(pm.model, p_cp14a <= gen_cp14a["pmax"] * z_cp14a) +end + + +""" + constraint_cp_14a_min_net_load(pm, i, nw) + +Ensures that the net electrical load (charging point load - virtual generator support) +stays above the §14a minimum power level (typically 4.2 kW = 0.0042 MW). + +# Arguments +- `pm::AbstractBFModelEdisgo`: PowerModels model +- `i::Int`: Virtual generator index +- `nw::Int`: Network (timestep) index +""" +function constraint_cp_14a_min_net_load(pm::AbstractBFModelEdisgo, i::Int, nw::Int=nw_id_default) + gen_cp14a = PowerModels.ref(pm, nw, :gen_cp_14a, i) + cp_idx = gen_cp14a["cp_index"] + + # Check if CP is flexible (in electromobility dict) or simple load + p_cp_load = nothing + if haskey(PowerModels.ref(pm, nw), :electromobility) && haskey(PowerModels.ref(pm, nw, :electromobility), cp_idx) + # Flexible CP: use electromobility variable + cp = PowerModels.ref(pm, nw, :electromobility, cp_idx) + p_cp_load = cp["pcp"] # Charging power variable (optimization variable) + else + # Non-flexible CP: use fixed load timeseries + # Find the load by CP name + cp_name = gen_cp14a["cp_name"] + # Search for load with matching name + load_found = false + for (load_id, load) in PowerModels.ref(pm, nw, :load) + if haskey(load, "name") && load["name"] == cp_name + p_cp_load = load["pd"] # Fixed load value (parameter, not variable) + load_found = true + break + end + end + + if !load_found + @warn "Could not find load for charging point $(cp_name), skipping constraint" + return + end + end + + # Virtual generator support + p_cp14a = PowerModels.var(pm, nw, :p_cp14a, i) + + # §14a minimum power (per unit) + p_min_14a = gen_cp14a["p_min_14a"] + + # Maximum support capacity (matches Python field name "pmax") + p_max_support = gen_cp14a["pmax"] + + # Net load must stay ≥ minimum net load allowed + # The minimum is the LOWER of: current load or §14a limit + # This handles cases where CP draws less than 4.2 kW (e.g., 3 kW due to low charging demand) + # p_cp_load - p_cp14a ≥ min(p_cp_load, p_min_14a) + # + # Special cases: + # - If p_max_support ≈ 0 (CP too small), force virtual gen to zero + # - If CP is off (p_cp_load ≈ 0), no support needed + if p_max_support < 1e-6 + # Charging point too small for §14a curtailment, disable virtual generator + JuMP.@constraint(pm.model, p_cp14a == 0.0) + elseif p_cp_load > 1e-6 + # Normal case: enforce minimum net load + # Net load cannot go below current load or §14a minimum, whichever is lower + p_min_net = min(p_cp_load, p_min_14a) + JuMP.@constraint(pm.model, p_cp14a <= p_cp_load - p_min_net) + else + # Charging point is off, no support needed + JuMP.@constraint(pm.model, p_cp14a == 0.0) + end +end + + +""" + constraint_cp_14a_time_budget_daily(pm, day_start, day_end, i) + +Limits the usage of §14a support generator to a maximum number of hours per day. +This is implemented by counting the number of timesteps where the binary variable is 1. + +# Arguments +- `pm::AbstractBFModelEdisgo`: PowerModels model +- `day_start::Int`: First timestep of the day +- `day_end::Int`: Last timestep of the day +- `i::Int`: Virtual generator index +""" +function constraint_cp_14a_time_budget_daily(pm::AbstractBFModelEdisgo, day_start::Int, day_end::Int, i::Int) + # Get time step duration in hours + if haskey(PowerModels.ref(pm, day_start), :time_elapsed) + time_elapsed = PowerModels.ref(pm, day_start, :time_elapsed) + else + Memento.warn(_LOGGER, "network data should specify time_elapsed, using 1.0 as default") + time_elapsed = 1.0 + end + + gen_cp14a = PowerModels.ref(pm, day_start, :gen_cp_14a, i) + max_hours = gen_cp14a["max_hours_per_day"] + + # Collect binary variables for all timesteps of the day + z_cp14a_day = [PowerModels.var(pm, t, :z_cp14a, i) for t in day_start:day_end] + + # Maximum number of active timesteps + max_active_steps = max_hours / time_elapsed + + # Sum of binary variables must not exceed budget + JuMP.@constraint(pm.model, sum(z_cp14a_day) <= max_active_steps) +end + + +""" + constraint_cp_14a_time_budget_total(pm, i, nws) + +Alternative to daily budget: Limits total usage over entire optimization horizon. +Can be used instead of daily budget for simpler formulation. + +# Arguments +- `pm::AbstractBFModelEdisgo`: PowerModels model +- `i::Int`: Virtual generator index +- `nws`: Network IDs (all timesteps) +""" +function constraint_cp_14a_time_budget_total(pm::AbstractBFModelEdisgo, i::Int, nws) + # Get time step duration + if haskey(PowerModels.ref(pm, first(nws)), :time_elapsed) + time_elapsed = PowerModels.ref(pm, first(nws), :time_elapsed) + else + time_elapsed = 1.0 + end + + gen_cp14a = PowerModels.ref(pm, first(nws), :gen_cp_14a, i) + max_hours_per_day = gen_cp14a["max_hours_per_day"] + + # Calculate total hours available (number of days × hours per day) + num_timesteps = length(nws) + num_days = ceil(num_timesteps * time_elapsed / 24.0) + total_max_hours = max_hours_per_day * num_days + + # Collect all binary variables + z_cp14a_all = [PowerModels.var(pm, t, :z_cp14a, i) for t in nws] + + # Total active timesteps + max_active_steps = total_max_hours / time_elapsed + + JuMP.@constraint(pm.model, sum(z_cp14a_all) <= max_active_steps) +end diff --git a/edisgo/opf/eDisGo_OPF.jl/src/core/constraint_hp_14a.jl b/edisgo/opf/eDisGo_OPF.jl/src/core/constraint_hp_14a.jl new file mode 100644 index 000000000..ae2f62663 --- /dev/null +++ b/edisgo/opf/eDisGo_OPF.jl/src/core/constraint_hp_14a.jl @@ -0,0 +1,151 @@ +""" +Constraints for §14a EnWG heat pump curtailment using virtual generators. + +This file implements §14a curtailment by modeling virtual generators at each +heat pump bus. The virtual generator can reduce the net electrical load, +simulating the effect of curtailment while maintaining a minimum power level. +""" + +""" + constraint_hp_14a_binary_coupling(pm, i, nw) + +Couples binary variable with power variable for §14a support generator. +When binary variable is 0, power must be 0. When binary is 1, power can be between 0 and pmax. +This ensures time budget tracking works correctly. + +# Arguments +- `pm::AbstractBFModelEdisgo`: PowerModels model +- `i::Int`: Generator index +- `nw::Int`: Network (timestep) index +""" +function constraint_hp_14a_binary_coupling(pm::AbstractBFModelEdisgo, i::Int, nw::Int=nw_id_default) + gen_hp14a = PowerModels.ref(pm, nw, :gen_hp_14a, i) + p_hp14a = PowerModels.var(pm, nw, :p_hp14a, i) + z_hp14a = PowerModels.var(pm, nw, :z_hp14a, i) + + # p ≤ pmax × z (if z=0 then p=0, if z=1 then p can be 0..pmax) + JuMP.@constraint(pm.model, p_hp14a <= gen_hp14a["pmax"] * z_hp14a) +end + + +""" + constraint_hp_14a_min_net_load(pm, i, nw) + +Ensures that the net electrical load (heat pump load - virtual generator support) +stays above the §14a minimum power level (typically 4.2 kW = 0.0042 MW). + +# Arguments +- `pm::AbstractBFModelEdisgo`: PowerModels model +- `i::Int`: Virtual generator index +- `nw::Int`: Network (timestep) index +""" +function constraint_hp_14a_min_net_load(pm::AbstractBFModelEdisgo, i::Int, nw::Int=nw_id_default) + gen_hp14a = PowerModels.ref(pm, nw, :gen_hp_14a, i) + hp_idx = gen_hp14a["hp_index"] + hp = PowerModels.ref(pm, nw, :heatpumps, hp_idx) + + # Electrical power demand of heat pump (thermal demand / COP) + p_hp_load = hp["pd"] / hp["cop"] + + # Virtual generator support + p_hp14a = PowerModels.var(pm, nw, :p_hp14a, i) + + # §14a minimum power (per unit) + p_min_14a = gen_hp14a["p_min_14a"] + + # Maximum support capacity (matches Python field name "pmax") + p_max_support = gen_hp14a["pmax"] + + # Net load must stay ≥ minimum net load allowed + # The minimum is the LOWER of: current load or §14a limit + # This handles cases where HP draws less than 4.2 kW (e.g., 3 kW due to low thermal demand) + # p_hp_load - p_hp14a ≥ min(p_hp_load, p_min_14a) + # + # Special cases: + # - If p_max_support ≈ 0 (HP too small), force virtual gen to zero + # - If HP is off (p_hp_load ≈ 0), no support needed + if p_max_support < 1e-6 + # Heat pump too small for §14a curtailment, disable virtual generator + JuMP.@constraint(pm.model, p_hp14a == 0.0) + elseif p_hp_load > 1e-6 + # Normal case: enforce minimum net load + # Net load cannot go below current load or §14a minimum, whichever is lower + p_min_net = min(p_hp_load, p_min_14a) + JuMP.@constraint(pm.model, p_hp_load - p_hp14a >= p_min_net) + else + # Heat pump is off, no support needed + JuMP.@constraint(pm.model, p_hp14a == 0.0) + end +end + + +""" + constraint_hp_14a_time_budget_daily(pm, day_start, day_end, i) + +Limits the usage of §14a support generator to a maximum number of hours per day. +This is implemented by counting the number of timesteps where the binary variable is 1. + +# Arguments +- `pm::AbstractBFModelEdisgo`: PowerModels model +- `day_start::Int`: First timestep of the day +- `day_end::Int`: Last timestep of the day +- `i::Int`: Virtual generator index +""" +function constraint_hp_14a_time_budget_daily(pm::AbstractBFModelEdisgo, day_start::Int, day_end::Int, i::Int) + # Get time step duration in hours + if haskey(PowerModels.ref(pm, day_start), :time_elapsed) + time_elapsed = PowerModels.ref(pm, day_start, :time_elapsed) + else + Memento.warn(_LOGGER, "network data should specify time_elapsed, using 1.0 as default") + time_elapsed = 1.0 + end + + gen_hp14a = PowerModels.ref(pm, day_start, :gen_hp_14a, i) + max_hours = gen_hp14a["max_hours_per_day"] + + # Collect binary variables for all timesteps of the day + z_hp14a_day = [PowerModels.var(pm, t, :z_hp14a, i) for t in day_start:day_end] + + # Maximum number of active timesteps + max_active_steps = max_hours / time_elapsed + + # Sum of binary variables must not exceed budget + JuMP.@constraint(pm.model, sum(z_hp14a_day) <= max_active_steps) +end + + +""" + constraint_hp_14a_time_budget_total(pm, i, nws) + +Alternative to daily budget: Limits total usage over entire optimization horizon. +Can be used instead of daily budget for simpler formulation. + +# Arguments +- `pm::AbstractBFModelEdisgo`: PowerModels model +- `i::Int`: Virtual generator index +- `nws`: Network IDs (all timesteps) +""" +function constraint_hp_14a_time_budget_total(pm::AbstractBFModelEdisgo, i::Int, nws) + # Get time step duration + if haskey(PowerModels.ref(pm, first(nws)), :time_elapsed) + time_elapsed = PowerModels.ref(pm, first(nws), :time_elapsed) + else + time_elapsed = 1.0 + end + + gen_hp14a = PowerModels.ref(pm, first(nws), :gen_hp_14a, i) + max_hours_per_day = gen_hp14a["max_hours_per_day"] + + # Calculate total hours available (number of days × hours per day) + num_timesteps = length(nws) + num_days = ceil(num_timesteps * time_elapsed / 24.0) + total_max_hours = max_hours_per_day * num_days + + # Collect all binary variables + z_hp14a_all = [PowerModels.var(pm, t, :z_hp14a, i) for t in nws] + + # Total active timesteps + max_active_steps = total_max_hours / time_elapsed + + JuMP.@constraint(pm.model, sum(z_hp14a_all) <= max_active_steps) +end diff --git a/edisgo/opf/eDisGo_OPF.jl/src/core/constraint_template.jl b/edisgo/opf/eDisGo_OPF.jl/src/core/constraint_template.jl index f0f05b6ae..1c6966bbf 100644 --- a/edisgo/opf/eDisGo_OPF.jl/src/core/constraint_template.jl +++ b/edisgo/opf/eDisGo_OPF.jl/src/core/constraint_template.jl @@ -11,6 +11,7 @@ function constraint_power_balance_bf(pm::AbstractBFModelEdisgo, i::Int; nw::Int= bus_dsm = PowerModels.ref(pm, nw, :bus_dsm, i) bus_hps = PowerModels.ref(pm, nw, :bus_hps, i) bus_cps = PowerModels.ref(pm, nw, :bus_cps, i) + bus_gen_hp_14a = get(PowerModels.ref(pm, nw), :bus_gen_hp_14a, Dict())[i] = get(get(PowerModels.ref(pm, nw), :bus_gen_hp_14a, Dict()), i, []) branch_r = Dict(k => PowerModels.ref(pm, nw, :branch, k, "br_r") for k in bus_lines_to) @@ -34,7 +35,7 @@ function constraint_power_balance_bf(pm::AbstractBFModelEdisgo, i::Int; nw::Int= bus_gen_d_pf = Dict(k => tan(acos(PowerModels.ref(pm, nw, :gen, k, "pf")))*PowerModels.ref(pm, nw, :gen, k, "sign") for k in bus_gens) bus_loads_pf = Dict(k => tan(acos(PowerModels.ref(pm, nw, :load, k, "pf")))*PowerModels.ref(pm, nw, :load, k, "sign") for k in bus_loads) - constraint_power_balance(pm, nw, i, bus_gens, bus_gens_nd, bus_gens_slack, bus_loads, bus_arcs_to, bus_arcs_from, bus_lines_to, bus_storage, bus_pg, bus_qg, bus_pg_nd, bus_qg_nd, bus_pd, bus_qd, branch_r, branch_x, bus_dsm, bus_hps, bus_cps, bus_storage_pf, bus_dsm_pf, bus_hps_pf, bus_cps_pf, bus_gen_nd_pf, bus_gen_d_pf, bus_loads_pf, branch_strg_pf) + constraint_power_balance(pm, nw, i, bus_gens, bus_gens_nd, bus_gens_slack, bus_loads, bus_arcs_to, bus_arcs_from, bus_lines_to, bus_storage, bus_pg, bus_qg, bus_pg_nd, bus_qg_nd, bus_pd, bus_qd, branch_r, branch_x, bus_dsm, bus_hps, bus_cps, bus_gen_hp_14a, bus_storage_pf, bus_dsm_pf, bus_hps_pf, bus_cps_pf, bus_gen_nd_pf, bus_gen_d_pf, bus_loads_pf, branch_strg_pf) end "" diff --git a/edisgo/opf/eDisGo_OPF.jl/src/core/objective.jl b/edisgo/opf/eDisGo_OPF.jl/src/core/objective.jl index 5a8470750..68f774f23 100644 --- a/edisgo/opf/eDisGo_OPF.jl/src/core/objective.jl +++ b/edisgo/opf/eDisGo_OPF.jl/src/core/objective.jl @@ -1,3 +1,4 @@ +# OPF Version 1: Minimize line losses and maximal line loading function objective_min_losses(pm::AbstractBFModelEdisgo) nws = PowerModels.nw_ids(pm) ccm = Dict(n => PowerModels.var(pm, n, :ccm) for n in nws) @@ -17,6 +18,7 @@ function objective_min_losses(pm::AbstractBFModelEdisgo) ) end +# OPF Version 2: Minimize line losses and grid related slacks function objective_min_losses_slacks(pm::AbstractBFModelEdisgo) nws = PowerModels.nw_ids(pm) ccm = Dict(n => PowerModels.var(pm, n, :ccm) for n in nws) @@ -28,7 +30,14 @@ function objective_min_losses_slacks(pm::AbstractBFModelEdisgo) phps = Dict(n => PowerModels.var(pm, n, :phps) for n in nws) phps2 = Dict(n => PowerModels.var(pm, n, :phps2) for n in nws) phss = Dict(n => PowerModels.var(pm, n, :phss) for n in nws) + + # §14a virtual generators for HPs and CPs + p_hp14a = Dict(n => get(PowerModels.var(pm, n), :p_hp14a, Dict()) for n in nws) + p_cp14a = Dict(n => get(PowerModels.var(pm, n), :p_cp14a, Dict()) for n in nws) + factor_slacks = 0.6 + factor_14a = 0.5 # Weight for §14a curtailment (between slacks and losses) + return JuMP.@objective(pm.model, Min, (1-factor_slacks) * sum(sum(ccm[n][b] * r[n][b] for (b,i,j) in PowerModels.ref(pm, n, :arcs_from) ) for n in nws) # minimize line losses incl. storage losses + factor_slacks * sum(sum(pgc[n][i] for i in keys(PowerModels.ref(pm,1 , :gen_nd))) for n in nws) # minimize non-dispatchable curtailment @@ -37,9 +46,12 @@ function objective_min_losses_slacks(pm::AbstractBFModelEdisgo) + factor_slacks * sum(sum(pcps[n][i] for i in keys(PowerModels.ref(pm,1 , :electromobility))) for n in nws) # minimize cp load sheddin + factor_slacks * sum(sum(phps[n][i] for i in keys(PowerModels.ref(pm,1 , :heatpumps))) for n in nws) # minimize hp load shedding + 1e4 * sum(sum(phss[n][i] + phps2[n][i] for i in keys(PowerModels.ref(pm, 1 , :heatpumps))) for n in nws) + + factor_14a * sum(sum(p_hp14a[n][i] for i in keys(p_hp14a[n])) for n in nws) # minimize §14a HP curtailment support + + factor_14a * sum(sum(p_cp14a[n][i] for i in keys(p_cp14a[n])) for n in nws) # minimize §14a CP curtailment support ) end +# OPF Version 3: Minimize line losses, maximal line loading and HV slacks function objective_min_line_loading_max(pm::AbstractBFModelEdisgo) nws = PowerModels.nw_ids(pm) ccm = Dict(n => PowerModels.var(pm, n, :ccm) for n in nws) @@ -48,15 +60,24 @@ function objective_min_line_loading_max(pm::AbstractBFModelEdisgo) l = Dict(n => Dict(i => get(branch, "length", 1.0) for (i,branch) in PowerModels.ref(pm, n, :branch)) for n in nws) c = Dict(n => Dict(i => get(branch, "cost", 1.0) for (i,branch) in PowerModels.ref(pm, n, :branch)) for n in nws) storage = Dict(i => get(branch, "storage", 1.0) for (i,branch) in PowerModels.ref(pm, 1, :branch)) + + # §14a virtual generators for HPs and CPs + p_hp14a = Dict(n => get(PowerModels.var(pm, n), :p_hp14a, Dict()) for n in nws) + p_cp14a = Dict(n => get(PowerModels.var(pm, n), :p_cp14a, Dict()) for n in nws) + factor_ll = 0.1 + factor_14a = 0.05 # Small penalty for §14a usage in line loading optimization + return JuMP.@objective(pm.model, Min, (1-factor_ll) * sum(sum(ccm[n][b] * r[n][b] for (b,i,j) in PowerModels.ref(pm, n, :arcs_from)) for n in nws) # minimize line losses + factor_ll * sum((ll[(b,i,j)]-1) * c[1][b] * l[1][b] for (b,i,j) in PowerModels.ref(pm, 1, :arcs_from) if storage[b] == 0) # minimize max line loading + + factor_14a * sum(sum(p_hp14a[n][i] for i in keys(p_hp14a[n])) for n in nws) # minimize §14a HP curtailment support + + factor_14a * sum(sum(p_cp14a[n][i] for i in keys(p_cp14a[n])) for n in nws) # minimize §14a CP curtailment support ) end -# OPF with overlying grid +# OPF Version 4: Minimize line losses, HV slacks and grid related slacks (with overlying grid) function objective_min_losses_slacks_OG(pm::AbstractBFModelEdisgo) nws = PowerModels.nw_ids(pm) ccm = Dict(n => PowerModels.var(pm, n, :ccm) for n in nws) @@ -88,6 +109,7 @@ function objective_min_losses_slacks_OG(pm::AbstractBFModelEdisgo) ) end +# OPF Version 3 (alternative): Minimize line losses, maximal line loading and HV slacks (with overlying grid) function objective_min_line_loading_max_OG(pm::AbstractBFModelEdisgo) nws = PowerModels.nw_ids(pm) ccm = Dict(n => PowerModels.var(pm, n, :ccm) for n in nws) diff --git a/edisgo/opf/eDisGo_OPF.jl/src/core/variables.jl b/edisgo/opf/eDisGo_OPF.jl/src/core/variables.jl index 5aec7f71c..ce0a270dd 100644 --- a/edisgo/opf/eDisGo_OPF.jl/src/core/variables.jl +++ b/edisgo/opf/eDisGo_OPF.jl/src/core/variables.jl @@ -395,6 +395,68 @@ function variable_cp_energy(pm::AbstractPowerModel; nw::Int=nw_id_default, bound report && PowerModels.sol_component_value(pm, nw, :electromobility, :cpe, PowerModels.ids(pm, nw, :electromobility), cpe) end +"§14a virtual generator continuous variables for curtailment support power" +function variable_gen_hp_14a_power(pm::AbstractPowerModel; nw::Int=nw_id_default, bounded::Bool=true, report::Bool=true) + p_hp14a = PowerModels.var(pm, nw)[:p_hp14a] = JuMP.@variable(pm.model, + [i in PowerModels.ids(pm, nw, :gen_hp_14a)], + base_name="$(nw)_p_hp14a", + lower_bound = 0.0 + ) + + if bounded + for (i, gen) in PowerModels.ref(pm, nw, :gen_hp_14a) + JuMP.set_upper_bound(p_hp14a[i], gen["pmax"]) + end + end + + if report + println(" 🔍 JULIA: Reporting gen_hp_14a power for nw=$nw, ids=$(PowerModels.ids(pm, nw, :gen_hp_14a))") + PowerModels.sol_component_value(pm, nw, :gen_hp_14a, :p, PowerModels.ids(pm, nw, :gen_hp_14a), p_hp14a) + end +end + +"§14a virtual generator binary variables for time budget tracking" +function variable_gen_hp_14a_binary(pm::AbstractPowerModel; nw::Int=nw_id_default, report::Bool=true) + z_hp14a = PowerModels.var(pm, nw)[:z_hp14a] = JuMP.@variable(pm.model, + [i in PowerModels.ids(pm, nw, :gen_hp_14a)], + base_name="$(nw)_z_hp14a", + binary = true + ) + + report && PowerModels.sol_component_value(pm, nw, :gen_hp_14a, :z, PowerModels.ids(pm, nw, :gen_hp_14a), z_hp14a) +end + +"§14a virtual generator power variables for charging points" +function variable_gen_cp_14a_power(pm::AbstractPowerModel; nw::Int=nw_id_default, bounded::Bool=true, report::Bool=true) + p_cp14a = PowerModels.var(pm, nw)[:p_cp14a] = JuMP.@variable(pm.model, + [i in PowerModels.ids(pm, nw, :gen_cp_14a)], + base_name="$(nw)_p_cp14a", + lower_bound = 0.0 + ) + + if bounded + for (i, gen) in PowerModels.ref(pm, nw, :gen_cp_14a) + JuMP.set_upper_bound(p_cp14a[i], gen["pmax"]) + end + end + + if report + println(" 🔍 JULIA: Reporting gen_cp_14a power for nw=$nw, ids=$(PowerModels.ids(pm, nw, :gen_cp_14a))") + PowerModels.sol_component_value(pm, nw, :gen_cp_14a, :p, PowerModels.ids(pm, nw, :gen_cp_14a), p_cp14a) + end +end + +"§14a virtual generator binary variables for charging point time budget tracking" +function variable_gen_cp_14a_binary(pm::AbstractPowerModel; nw::Int=nw_id_default, report::Bool=true) + z_cp14a = PowerModels.var(pm, nw)[:z_cp14a] = JuMP.@variable(pm.model, + [i in PowerModels.ids(pm, nw, :gen_cp_14a)], + base_name="$(nw)_z_cp14a", + binary = true + ) + + report && PowerModels.sol_component_value(pm, nw, :gen_cp_14a, :z, PowerModels.ids(pm, nw, :gen_cp_14a), z_cp14a) +end + "slack variables for grid restrictions" function variable_slack_grid_restrictions(pm::AbstractBFModelEdisgo; kwargs...) eDisGo_OPF.variable_hp_slack(pm; kwargs...) diff --git a/edisgo/opf/eDisGo_OPF.jl/src/eDisGo_OPF.jl b/edisgo/opf/eDisGo_OPF.jl/src/eDisGo_OPF.jl index 33df80742..2ba82c687 100644 --- a/edisgo/opf/eDisGo_OPF.jl/src/eDisGo_OPF.jl +++ b/edisgo/opf/eDisGo_OPF.jl/src/eDisGo_OPF.jl @@ -18,6 +18,8 @@ include("core/types.jl") include("core/base.jl") include("core/constraint.jl") include("core/constraint_template.jl") +include("core/constraint_hp_14a.jl") +include("core/constraint_cp_14a.jl") include("core/data.jl") include("core/objective.jl") include("core/solution.jl") diff --git a/edisgo/opf/eDisGo_OPF.jl/src/form/bf.jl b/edisgo/opf/eDisGo_OPF.jl/src/form/bf.jl index 1245fe7bf..b9ca87b3a 100644 --- a/edisgo/opf/eDisGo_OPF.jl/src/form/bf.jl +++ b/edisgo/opf/eDisGo_OPF.jl/src/form/bf.jl @@ -135,7 +135,7 @@ function constraint_max_line_loading(pm::AbstractNCBFModelEdisgo, n::Int) end -function constraint_power_balance(pm::AbstractBFModelEdisgo, n::Int, i, bus_gens, bus_gens_nd, bus_gens_slack, bus_loads, bus_arcs_to, bus_arcs_from, bus_lines_to, bus_storage, bus_pg, bus_qg, bus_pg_nd, bus_qg_nd, bus_pd, bus_qd, branch_r, branch_x, bus_dsm, bus_hps, bus_cps, bus_storage_pf, bus_dsm_pf, bus_hps_pf, bus_cps_pf, bus_gen_nd_pf, bus_gen_d_pf, bus_loads_pf, branch_strg_pf) +function constraint_power_balance(pm::AbstractBFModelEdisgo, n::Int, i, bus_gens, bus_gens_nd, bus_gens_slack, bus_loads, bus_arcs_to, bus_arcs_from, bus_lines_to, bus_storage, bus_pg, bus_qg, bus_pg_nd, bus_qg_nd, bus_pd, bus_qd, branch_r, branch_x, bus_dsm, bus_hps, bus_cps, bus_gen_hp_14a, bus_storage_pf, bus_dsm_pf, bus_hps_pf, bus_cps_pf, bus_gen_nd_pf, bus_gen_d_pf, bus_loads_pf, branch_strg_pf) pt = get(PowerModels.var(pm, n), :p, Dict()); PowerModels._check_var_keys(pt, bus_arcs_to, "active power", "branch") qt = get(PowerModels.var(pm, n), :q, Dict()); PowerModels._check_var_keys(qt, bus_arcs_to, "reactive power", "branch") pf = get(PowerModels.var(pm, n), :p, Dict()); PowerModels._check_var_keys(pf, bus_arcs_from, "active power", "branch") @@ -147,6 +147,7 @@ function constraint_power_balance(pm::AbstractBFModelEdisgo, n::Int, i, bus_gens pdsm = get(PowerModels.var(pm, n), :pdsm, Dict()); PowerModels._check_var_keys(pdsm, bus_dsm, "active power", "dsm") php = get(PowerModels.var(pm, n), :php, Dict()); PowerModels._check_var_keys(php, bus_hps, "active power", "heatpumps") pcp = get(PowerModels.var(pm, n), :pcp, Dict()); PowerModels._check_var_keys(pcp, bus_cps, "active power", "electromobility") + p_hp14a = get(PowerModels.var(pm, n), :p_hp14a, Dict()) # §14a virtual generators if PowerModels.ref(pm, 1, :opf_version) in(2, 4) # Eq. (3.3iii), (3.4iii) pgens = get(PowerModels.var(pm, n), :pgens, Dict()); PowerModels._check_var_keys(pgens, bus_gens, "active power slack", "curtailment") @@ -171,6 +172,7 @@ function constraint_power_balance(pm::AbstractBFModelEdisgo, n::Int, i, bus_gens + sum(pdsm[dsm] for dsm in bus_dsm) + sum(php[hp] - phps[hp] for hp in bus_hps) + sum(pcp[cp] - pcps[cp] for cp in bus_cps) + - sum(p_hp14a[g] for g in bus_gen_hp_14a) # Virtual generators reduce net load ) cstr_q = JuMP.@constraint(pm.model, sum(qt[a] for a in bus_arcs_to) @@ -187,7 +189,8 @@ function constraint_power_balance(pm::AbstractBFModelEdisgo, n::Int, i, bus_gens + sum(pgens[g] * bus_gen_d_pf[g] for g in bus_gens) + sum(pdsm[dsm] * bus_dsm_pf[dsm] for dsm in bus_dsm) + sum((php[hp] - phps[hp]) * bus_hps_pf[hp] for hp in bus_hps) - + sum((pcp[cp] - pcps[cp]) * bus_cps_pf[cp] for cp in bus_cps) + + sum((pcp[cp] - pcps[cp]) * bus_cps_pf[cp] for hp in bus_cps) + # §14a generators have pf=1, q=0 ) else # Eq. (3.3ii), (3.4ii) cstr_p = JuMP.@constraint(pm.model, @@ -203,6 +206,7 @@ function constraint_power_balance(pm::AbstractBFModelEdisgo, n::Int, i, bus_gens + sum(pdsm[dsm] for dsm in bus_dsm) + sum(php[hp] for hp in bus_hps) + sum(pcp[cp] for cp in bus_cps) + - sum(p_hp14a[g] for g in bus_gen_hp_14a) # Virtual generators reduce net load ) cstr_q = JuMP.@constraint(pm.model, sum(qt[a] for a in bus_arcs_to) @@ -217,6 +221,7 @@ function constraint_power_balance(pm::AbstractBFModelEdisgo, n::Int, i, bus_gens + sum(pdsm[dsm] * bus_dsm_pf[dsm] for dsm in bus_dsm) + sum(php[hp] * bus_hps_pf[hp] for hp in bus_hps) + sum(pcp[cp] * bus_cps_pf[cp] for cp in bus_cps) + # §14a generators have pf=1, q=0 ) end diff --git a/edisgo/opf/eDisGo_OPF.jl/src/prob/opf_bf.jl b/edisgo/opf/eDisGo_OPF.jl/src/prob/opf_bf.jl index aeaea8d65..c46f1c8cb 100644 --- a/edisgo/opf/eDisGo_OPF.jl/src/prob/opf_bf.jl +++ b/edisgo/opf/eDisGo_OPF.jl/src/prob/opf_bf.jl @@ -30,6 +30,18 @@ function build_mn_opf_bf_flex(pm::AbstractBFModelEdisgo) eDisGo_OPF.variable_cp_power(pm, nw=n) # Eq. (3.27), (3.28) eDisGo_OPF.variable_dsm_storage_power(pm, nw=n) # Eq. (3.34), (3.35) eDisGo_OPF.variable_slack_gen(pm, nw=n) # keine Bounds für Slack Generator + + # §14a EnWG virtual generators for heat pump support + if haskey(PowerModels.ref(pm, n), :gen_hp_14a) && !isempty(PowerModels.ref(pm, n, :gen_hp_14a)) + eDisGo_OPF.variable_gen_hp_14a_power(pm, nw=n) + eDisGo_OPF.variable_gen_hp_14a_binary(pm, nw=n) + end + + # §14a EnWG virtual generators for charging point support + if haskey(PowerModels.ref(pm, n), :gen_cp_14a) && !isempty(PowerModels.ref(pm, n, :gen_cp_14a)) + eDisGo_OPF.variable_gen_cp_14a_power(pm, nw=n) + eDisGo_OPF.variable_gen_cp_14a_binary(pm, nw=n) + end if PowerModels.ref(pm, 1, :opf_version) in(3, 4) # Nicht Teil der MA eDisGo_OPF.variable_slack_HV_requirements(pm, nw=n) @@ -57,6 +69,22 @@ function build_mn_opf_bf_flex(pm::AbstractBFModelEdisgo) for i in PowerModels.ids(pm, :heatpumps, nw=n) eDisGo_OPF.constraint_hp_operation(pm, i, n) # Eq. (3.19) end + + # §14a EnWG constraints for virtual generators + if haskey(PowerModels.ref(pm, n), :gen_hp_14a) && !isempty(PowerModels.ref(pm, n, :gen_hp_14a)) + for i in PowerModels.ids(pm, :gen_hp_14a, nw=n) + eDisGo_OPF.constraint_hp_14a_binary_coupling(pm, i, n) + eDisGo_OPF.constraint_hp_14a_min_net_load(pm, i, n) + end + end + + # §14a EnWG constraints for charging point virtual generators + if haskey(PowerModels.ref(pm, n), :gen_cp_14a) && !isempty(PowerModels.ref(pm, n, :gen_cp_14a)) + for i in PowerModels.ids(pm, :gen_cp_14a, nw=n) + eDisGo_OPF.constraint_cp_14a_binary_coupling(pm, i, n) + eDisGo_OPF.constraint_cp_14a_min_net_load(pm, i, n) + end + end end @@ -91,6 +119,84 @@ function build_mn_opf_bf_flex(pm::AbstractBFModelEdisgo) n_1 = n_2 end + # §14a EnWG daily time budget constraints + if haskey(PowerModels.ref(pm, 1), :gen_hp_14a) && !isempty(PowerModels.ref(pm, 1, :gen_hp_14a)) + println("\n" * "="^80) + println("🔍 JULIA DEBUG: §14a Generators") + println("="^80) + + gen_hp_14a_dict = PowerModels.ref(pm, 1, :gen_hp_14a) + println("Number of gen_hp_14a entries: ", length(gen_hp_14a_dict)) + + # Show first 5 generators + count = 0 + for (idx, gen) in gen_hp_14a_dict + count += 1 + if count <= 5 + println(" [$idx]: hp_name=$(get(gen, "hp_name", "N/A")), hp_index=$(get(gen, "hp_index", "N/A")), pmax=$(get(gen, "pmax", "N/A"))") + end + end + println("="^80 * "\n") + + # Determine timesteps per day based on time_elapsed (in hours) + n_first = network_ids[1] + time_elapsed = PowerModels.ref(pm, n_first, :time_elapsed) + timesteps_per_day = Int(round(24.0 / time_elapsed)) + + # Group network_ids into days + for day_start_idx in 1:timesteps_per_day:length(network_ids) + day_end_idx = min(day_start_idx + timesteps_per_day - 1, length(network_ids)) + day_network_ids = network_ids[day_start_idx:day_end_idx] + + # Apply daily time budget constraint for each §14a generator + for i in PowerModels.ids(pm, :gen_hp_14a, nw=network_ids[1]) + # Call with correct argument order: (pm, day_start, day_end, i) + eDisGo_OPF.constraint_hp_14a_time_budget_daily(pm, day_network_ids[1], day_network_ids[end], i) + end + end + else + println("\n⚠ JULIA DEBUG: No gen_hp_14a found or empty!\n") + end + + # §14a EnWG daily time budget constraints for charging points + if haskey(PowerModels.ref(pm, 1), :gen_cp_14a) && !isempty(PowerModels.ref(pm, 1, :gen_cp_14a)) + println("\n" * "="^80) + println("🔍 JULIA DEBUG: §14a Charging Point Generators") + println("="^80) + + gen_cp_14a_dict = PowerModels.ref(pm, 1, :gen_cp_14a) + println("Number of gen_cp_14a entries: ", length(gen_cp_14a_dict)) + + # Show first 5 generators + count = 0 + for (idx, gen) in gen_cp_14a_dict + count += 1 + if count <= 5 + println(" [$idx]: cp_name=$(get(gen, "cp_name", "N/A")), cp_index=$(get(gen, "cp_index", "N/A")), pmax=$(get(gen, "pmax", "N/A"))") + end + end + println("="^80 * "\n") + + # Determine timesteps per day based on time_elapsed (in hours) + n_first = network_ids[1] + time_elapsed = PowerModels.ref(pm, n_first, :time_elapsed) + timesteps_per_day = Int(round(24.0 / time_elapsed)) + + # Group network_ids into days + for day_start_idx in 1:timesteps_per_day:length(network_ids) + day_end_idx = min(day_start_idx + timesteps_per_day - 1, length(network_ids)) + day_network_ids = network_ids[day_start_idx:day_end_idx] + + # Apply daily time budget constraint for each §14a generator + for i in PowerModels.ids(pm, :gen_cp_14a, nw=network_ids[1]) + # Call with correct argument order: (pm, day_start, day_end, i) + eDisGo_OPF.constraint_cp_14a_time_budget_daily(pm, day_network_ids[1], day_network_ids[end], i) + end + end + else + println("\n⚠ JULIA DEBUG: No gen_cp_14a found or empty!\n") + end + # OBJECTIVE FUNCTION if PowerModels.ref(pm, 1, :opf_version) == 1 #eDisGo_OPF.objective_min_losses(pm) diff --git a/edisgo/opf/powermodels_opf.py b/edisgo/opf/powermodels_opf.py index 85da160a8..b4e62fb2a 100644 --- a/edisgo/opf/powermodels_opf.py +++ b/edisgo/opf/powermodels_opf.py @@ -26,6 +26,7 @@ def pm_optimize( method: str = "soc", warm_start: bool = False, silence_moi: bool = False, + curtailment_14a: bool = False, ) -> None: """ Run OPF for edisgo object in julia subprocess and write results of OPF to edisgo @@ -93,6 +94,11 @@ def pm_optimize( hence there will be no logging coming from julia subprocess in python process. Default: False. + curtailment_14a : bool + If True, enables §14a EnWG curtailment for heat pumps with virtual + generators. Heat pumps can be curtailed down to 4.2 kW with time budget + constraints. + Default: False. save_heat_storage : bool Indicates whether to save results of heat storage variables from the optimization to eDisGo object. @@ -118,6 +124,7 @@ def pm_optimize( flexible_loads=flexible_loads, flexible_storage_units=flexible_storage_units, opf_version=opf_version, + curtailment_14a=curtailment_14a, ) def _convert(o): diff --git a/edisgo/tools/config.py b/edisgo/tools/config.py index 89af603f2..1b0e01f16 100644 --- a/edisgo/tools/config.py +++ b/edisgo/tools/config.py @@ -132,6 +132,8 @@ class Config: """ def __init__(self, **kwargs): + self._engine = kwargs.get("engine", None) + if not kwargs.get("from_json", False): self._data = self.from_cfg(kwargs.get("config_path", "default")) else: @@ -175,7 +177,7 @@ def _ensure_db_mappings_loaded(self) -> None: def get_database_alias_dictionaries(self) -> tuple[dict[str, str], dict[str, str]]: """ - Retrieves the database alias dictionaries for table and schema mappings. + Retrieves the OEP database alias dictionaries for table and schema mappings. Returns ------- @@ -187,13 +189,13 @@ def get_database_alias_dictionaries(self) -> tuple[dict[str, str], dict[str, str names. """ engine = Engine() - dictionary_schema_name = "data" + dictionary_schema_name = "dataset" dictionary_table = self._get_module_attr( self._get_saio_module(dictionary_schema_name, engine), "edut_00", f"saio.{dictionary_schema_name}", ) - with session_scope_egon_data(engine) as session: + with session_scope_egon_data(self._engine) as session: query = session.query(dictionary_table) dictionary_entries = query.all() name_mapping = { diff --git a/edisgo/tools/plots.py b/edisgo/tools/plots.py index d1673a860..970ef9e43 100644 --- a/edisgo/tools/plots.py +++ b/edisgo/tools/plots.py @@ -391,7 +391,7 @@ def get_color_and_size(connected_components, colors_dict, sizes_dict): else: return colors_dict["else"], sizes_dict["else"] - def nodes_by_technology(buses, edisgo_obj): + def nodes_by_technology(buses, edisgo_obj, sizes_dict=None): bus_sizes = {} bus_colors = {} colors_dict = { @@ -405,17 +405,18 @@ def nodes_by_technology(buses, edisgo_obj): "DisconnectingPoint": "0.75", "else": "orange", } - sizes_dict = { - "BranchTee": 10000, - "GeneratorFluctuating": 100000, - "Generator": 100000, - "Load": 100000, - "LVStation": 50000, - "MVStation": 120000, - "Storage": 100000, - "DisconnectingPoint": 75000, - "else": 200000, - } + if sizes_dict is None: + sizes_dict = { + "BranchTee": 10000, + "GeneratorFluctuating": 100000, + "Generator": 100000, + "Load": 100000, + "LVStation": 50000, + "MVStation": 120000, + "Storage": 100000, + "DisconnectingPoint": 75000, + "else": 200000, + } for bus in buses: connected_components = ( edisgo_obj.topology.get_connected_components_from_bus(bus) @@ -582,7 +583,9 @@ def nodes_by_costs(buses, grid_expansion_costs, edisgo_obj): # bus colors and sizes if node_color == "technology": - bus_sizes, bus_colors = nodes_by_technology(pypsa_plot.buses.index, edisgo_obj) + bus_sizes, bus_colors = nodes_by_technology( + pypsa_plot.buses.index, edisgo_obj, kwargs.get("sizes_dict", None) + ) bus_cmap = None elif node_color == "voltage": bus_sizes, bus_colors = nodes_by_voltage( diff --git a/edisgo/tools/tools.py b/edisgo/tools/tools.py index 9e27ba37c..053d337d0 100644 --- a/edisgo/tools/tools.py +++ b/edisgo/tools/tools.py @@ -12,6 +12,8 @@ import pandas as pd from sqlalchemy.engine.base import Engine +from edisgo.io.db import engine as egon_engine + from edisgo.flex_opt import exceptions, q_control from edisgo.io.db import session_scope_egon_data, sql_grid_geom, sql_intersects @@ -730,6 +732,8 @@ def get_weather_cells_intersecting_with_grid_district( Set with weather cell IDs. """ + if engine is None: + engine = egon_engine() # Download geometries of weather cells sql_geom = sql_grid_geom(edisgo_obj) srid = edisgo_obj.topology.grid_district["srid"] diff --git a/examples/Workshop_LoMa.ipynb b/examples/Workshop_LoMa.ipynb new file mode 100644 index 000000000..3c6467a0a --- /dev/null +++ b/examples/Workshop_LoMa.ipynb @@ -0,0 +1,1155 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# LoMa EDisGo-Workshop 27.2.2025" + ] + }, + { + "cell_type": "markdown", + "id": "1", + "metadata": {}, + "source": [ + "Contents:\n", + "1. Topology Setup\n", + "2. Worst Case Time Series Creation\n", + "3. Grid Investigation\n", + "4. Results\n", + "5. Additional Time Series\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext jupyter_black" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import requests\n", + "import sys\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import networkx as nx\n", + "import pandas as pd\n", + "\n", + "from copy import deepcopy\n", + "from numpy.random import default_rng\n", + "from pathlib import Path\n", + "\n", + "from edisgo import EDisGo\n", + "from edisgo.io.db import engine\n", + "from edisgo.tools.logger import setup_logger\n", + "from edisgo.flex_opt.battery_storage_operation import apply_reference_operation\n", + "from edisgo.network.results import Results" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "# to make the notebook clearer. not recommendable\n", + "import warnings\n", + "\n", + "warnings.filterwarnings(\"ignore\")" + ] + }, + { + "cell_type": "markdown", + "id": "5", + "metadata": {}, + "source": [ + "## 1 Topology Setup" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "In this section we load all components into a newly created edisgo object. This includes the lines, buses, transformers, switches, generators, loads, heat pumps and battery storages." + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, + "source": [ + "### Standard components" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "Set up a new edisgo object:" + ] + }, + { + "cell_type": "markdown", + "id": "9", + "metadata": {}, + "source": [ + "conf_path and ding0_grid need to be set according to local storage location." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "10", + "metadata": {}, + "outputs": [], + "source": [ + "conf_path = Path.home() / \"Downloads\" / \"egon-data.configuration.yaml\"\n", + "assert conf_path.is_file()\n", + "\n", + "db_engine = engine(path=conf_path, ssh=True)\n", + "\n", + "ding0_grid = Path.home() / \".edisgo\" / \"husum_grids\" / \"35725\"\n", + "assert ding0_grid.is_dir()\n", + "\n", + "edisgo = EDisGo(ding0_grid=ding0_grid, legacy_ding0_grids=False, engine=db_engine)" + ] + }, + { + "cell_type": "markdown", + "id": "11", + "metadata": {}, + "source": [ + "ding0 and edisgo use different assumptions for the grid design and extension, respectively. This may cause that edisgo detects voltage deviations and line overloads. To avoid this the edisgo assumptions should be transferred to the ding0 grid by applying ```reinforce()``` after the grid import." + ] + }, + { + "cell_type": "markdown", + "id": "12", + "metadata": {}, + "source": [ + "Grids are reinforced for their worst case scenarios. The corresponding time series are created with ```set_time_series_worst_case_analysis()```. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.set_time_series_worst_case_analysis()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.reinforce()" + ] + }, + { + "cell_type": "markdown", + "id": "15", + "metadata": {}, + "source": [ + "### Plot grid topology (MV)" + ] + }, + { + "cell_type": "markdown", + "id": "16", + "metadata": {}, + "source": [ + "The topology can be visualized with the ```plot_mv_grid_topology()```. For ```technologies=True``` the buses sizes and colors are determined to the type and size of the technologies connected to it. \n", + "\n", + "- red: nodes with substation secondary side\n", + "- light blue: nodes distribution substations's primary side\n", + "- green: nodes with fluctuating generators\n", + "- black: nodes with conventional generators\n", + "- grey: disconnecting points\n", + "- dark blue: branch trees" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "17", + "metadata": {}, + "outputs": [], + "source": [ + "# adjust node sizes to make plot clearer\n", + "sizes_dict = {\n", + " \"BranchTee\": 10000,\n", + " \"GeneratorFluctuating\": 100000,\n", + " \"Generator\": 100000,\n", + " \"Load\": 100000,\n", + " \"LVStation\": 50000,\n", + " \"MVStation\": 120000,\n", + " \"Storage\": 100000,\n", + " \"DisconnectingPoint\": 75000,\n", + " \"else\": 200000,\n", + "}\n", + "\n", + "sizes_dict = {k: v / 10 for k, v in sizes_dict.items()}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "18", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.plot_mv_grid_topology(technologies=True, sizes_dict=sizes_dict)" + ] + }, + { + "cell_type": "markdown", + "id": "19", + "metadata": {}, + "source": [ + "### Topology-Module Data Structure" + ] + }, + { + "cell_type": "markdown", + "id": "20", + "metadata": {}, + "source": [ + "Let's get familiar with the topology module:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "21", + "metadata": {}, + "outputs": [], + "source": [ + "# generator types\n", + "edisgo.topology.generators_df[[\"p_nom\", \"type\"]].groupby(\"type\").sum()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "22", + "metadata": {}, + "outputs": [], + "source": [ + "# load types\n", + "edisgo.topology.loads_df[[\"p_set\", \"type\"]].groupby(\"type\").sum()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "23", + "metadata": {}, + "outputs": [], + "source": [ + "# load sectors\n", + "edisgo.topology.loads_df[[\"p_set\", \"sector\"]].groupby(\"sector\").sum()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "24", + "metadata": {}, + "outputs": [], + "source": [ + "# amount of lv grids inside the mv grid\n", + "len(list(edisgo.topology.mv_grid.lv_grids))" + ] + }, + { + "cell_type": "markdown", + "id": "25", + "metadata": {}, + "source": [ + "Total number of lines:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26", + "metadata": {}, + "outputs": [], + "source": [ + "# overall amount of lines\n", + "len(edisgo.topology.lines_df)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "27", + "metadata": {}, + "outputs": [], + "source": [ + "# amount of lines in one of the lv grids\n", + "len(edisgo.topology.grids[5].lines_df.index)" + ] + }, + { + "cell_type": "markdown", + "id": "28", + "metadata": {}, + "source": [ + "### Basic components addition and removal" + ] + }, + { + "cell_type": "markdown", + "id": "29", + "metadata": {}, + "source": [ + "To see how a loaded network can be adapted later on, we add a solar plant to a random bus.\n", + "\n", + "Components can also be added according to their geolocation with the function ```integrate_component_based_on_geolocation()```." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "30", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.topology.generators_df" + ] + }, + { + "cell_type": "markdown", + "id": "31", + "metadata": {}, + "source": [ + "Add a generator with the function ```add_component()``` or ```add_generator()```. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "32", + "metadata": {}, + "outputs": [], + "source": [ + "# determine a random bus\n", + "rng = default_rng(1)\n", + "rnd_bus = rng.choice(edisgo.topology.buses_df.index, size=1)[0]\n", + "generator_type = \"solar\"\n", + "\n", + "new_generator = edisgo.add_component(\n", + " comp_type=\"generator\", p_nom=0.01, bus=rnd_bus, generator_type=generator_type\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "33", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.topology.generators_df" + ] + }, + { + "cell_type": "markdown", + "id": "34", + "metadata": {}, + "source": [ + "Single components can be removed with ```remove_component()```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "35", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.remove_component(comp_type=\"generator\", comp_name=new_generator)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "36", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.topology.generators_df" + ] + }, + { + "cell_type": "markdown", + "id": "37", + "metadata": {}, + "source": [ + "### Task: \n", + "Add and remove a 'heat_pump' with the function ```add_component()``` and the function ```remove_component()```." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "38", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.topology.loads_df" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "39", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "40", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.topology.loads_df" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "41", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "42", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.topology.loads_df" + ] + }, + { + "cell_type": "markdown", + "id": "43", + "metadata": {}, + "source": [ + "### Add flexible components to grid " + ] + }, + { + "cell_type": "markdown", + "id": "44", + "metadata": {}, + "source": [ + "For realistic future grids we also add further components like additional generators, home batteries, (charging points) and heat pumps. The components are added according to the scenario \"eGon2035\" and the data from the oedb." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "45", + "metadata": {}, + "outputs": [], + "source": [ + "scenario = \"eGon2035\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "46", + "metadata": {}, + "outputs": [], + "source": [ + "# copy the edisgo object for later comparisons\n", + "edisgo_orig = edisgo.copy()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "47", + "metadata": {}, + "outputs": [], + "source": [ + "# clear initial reinfocement results from results module\n", + "edisgo.results = Results(edisgo)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "48", + "metadata": {}, + "outputs": [], + "source": [ + "# set timeindex to ensure that correct time series for COP and heat pump heat demand are downloaded\n", + "timeindex = pd.date_range(f\"1/1/{2011} 8:00\", periods=12, freq=\"H\")\n", + "edisgo.set_timeindex(timeindex=timeindex)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "49", + "metadata": {}, + "outputs": [], + "source": [ + "# Retry if running into \"Connection reset by peer\" error\n", + "\n", + "edisgo.import_generators(generator_scenario=scenario)\n", + "edisgo.import_home_batteries(scenario=scenario)\n", + "edisgo.import_heat_pumps(scenario=scenario)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "50", + "metadata": {}, + "outputs": [], + "source": [ + "# This takes too long for the workshop\n", + "# edisgo_obj.import_dsm(scenario=scenario)\n", + "# edisgo_obj.import_electromobility(\n", + "# data_source=\"oedb\", scenario=scenario\n", + "# )" + ] + }, + { + "cell_type": "markdown", + "id": "51", + "metadata": {}, + "source": [ + "## Task:\n", + "Determine the differnet generator types that were installed before and that are installed in the grid now." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "52", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "53", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "markdown", + "id": "54", + "metadata": {}, + "source": [ + "## Task:\n", + "Determine the added solar energy power." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "55", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "markdown", + "id": "56", + "metadata": {}, + "source": [ + "## Task:\n", + "Determine the amount of storage units added to the grid with a nominal power (p_nom) larger than 0.01." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "57", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "markdown", + "id": "58", + "metadata": {}, + "source": [ + "## Task:\n", + "Determine the buses of the heat pumps whose application ('sector') is not inidividual_heating." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "59", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "markdown", + "id": "60", + "metadata": {}, + "source": [ + "## 2 Worst Case Time Series Creation" + ] + }, + { + "cell_type": "markdown", + "id": "61", + "metadata": {}, + "source": [ + "Create timeseries for the four worst cases MV load case, LV load case, MV feed-in case, LV feed-in case with the function set_time_series_worst_case_analysis().\n", + "\n", + "In conventional grid expansion planning worst-cases, the heavy load flow and the reverse power flow, are used to determine grid expansion needs. eDisGo allows you to analyze these cases separately or together. Choose between the following options:\n", + "\n", + "* **’feed-in_case’** \n", + " \n", + " Feed-in and demand for the worst-case scenario \"reverse power flow\" are generated (e.g. conventional electricity demand is set to 15% of maximum demand for loads connected to the MV grid and 10% for loads connected to the LV grid and feed-in of all generators is set to the nominal power of the generator, except for PV systems where it is by default set to 85% of the nominal power)\n", + "\n", + " \n", + "* **’load_case’**\n", + "\n", + " Feed-in and demand for the worst-case scenario \"heavy load flow\" are generated (e.g. demand of all conventional loads is by default set to maximum demand and feed-in of all generators is set to zero)\n", + "\n", + "\n", + "* **[’feed-in_case’, ’load_case’]**\n", + "\n", + " Both cases are set up.\n", + " \n", + "By default both cases are set up.\n", + "\n", + "Feed-in and demand in the two worst-cases are defined in the [config file 'config_timeseries.cfg'](https://edisgo.readthedocs.io/en/latest/configs.html#config-timeseries) and can be changed by setting different values in the config file. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "62", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.set_time_series_worst_case_analysis()" + ] + }, + { + "cell_type": "markdown", + "id": "63", + "metadata": {}, + "source": [ + "The function creates time series for four time steps since both worst cases are defined seperately for the LV and the MV grid with individual simultanerity factors." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "64", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.timeseries.timeindex_worst_cases" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "65", + "metadata": {}, + "outputs": [], + "source": [ + "# indexing with worst case timeindex\n", + "edisgo.timeseries.loads_active_power.loc[\n", + " edisgo.timeseries.timeindex_worst_cases[\"load_case_mv\"]\n", + "]" + ] + }, + { + "cell_type": "markdown", + "id": "66", + "metadata": {}, + "source": [ + "## 3 Grid Investigation" + ] + }, + { + "cell_type": "markdown", + "id": "67", + "metadata": {}, + "source": [ + "Execute a power flow analysis to determine line overloads and voltage deviations for the MV load case timeseries with the function ```analyze()```:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "68", + "metadata": {}, + "outputs": [], + "source": [ + "# power flow analysis\n", + "edisgo.analyze(timesteps=edisgo.timeseries.timeindex_worst_cases[\"load_case_mv\"])" + ] + }, + { + "cell_type": "markdown", + "id": "69", + "metadata": {}, + "source": [ + "A geoplot with the bus and line colors based on the voltage deviations and line loadings repectively can be created with ```plot_mv_line_loading()```." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "70", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.plot_mv_line_loading(\n", + " node_color=\"voltage_deviation\",\n", + " timestep=edisgo.timeseries.timeindex_worst_cases[\"load_case_mv\"],\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "71", + "metadata": {}, + "source": [ + "For a better overview of the voltage deviations and line loads in the entire grid, edisgo provides histrogram plots." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "72", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.histogram_voltage(binwidth=0.005)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "73", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.histogram_relative_line_load(binwidth=0.1)" + ] + }, + { + "cell_type": "markdown", + "id": "74", + "metadata": {}, + "source": [ + "## 4 Results" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "75", + "metadata": {}, + "outputs": [], + "source": [ + "# Reinforce the grid\n", + "# mode = \"mvlv\" for a shorter run time. However, grid reinforcement should generally be conducted in mode=\"lv\" (default)\n", + "# since the majority of the reinforcement costs is caused in the lv grid part, especially for high load grids (much EV charging demand and low PV capacity)\n", + "# The lv mode is currently not applicable for the Husum grid. The newly added generators are concentrated in one LV grid. The reinforcement\n", + "# very long or cannot be resolved. This issue will be fixed soon. A possible workarounf for running the reinfocrement anyway is to remove the generators for the overloaded LV grid.\n", + "edisgo.reinforce(mode=\"mvlv\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "76", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.plot_mv_line_loading(\n", + " node_color=\"voltage_deviation\",\n", + " timestep=edisgo.timeseries.timeindex_worst_cases[\"load_case_mv\"],\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "77", + "metadata": {}, + "outputs": [], + "source": [ + "# power flow analysis to retrieve all bus voltages and line flows\n", + "edisgo.analyze(timesteps=edisgo.timeseries.timeindex_worst_cases[\"load_case_mv\"])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "78", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.histogram_voltage(binwidth=0.005)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "79", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.histogram_relative_line_load(binwidth=0.1)" + ] + }, + { + "cell_type": "markdown", + "id": "80", + "metadata": {}, + "source": [ + "The module ```results```holds the outputs of the reinforcement" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "81", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo_orig.results.equipment_changes" + ] + }, + { + "cell_type": "markdown", + "id": "82", + "metadata": {}, + "source": [ + "## Task:\n", + "Determine the total costs for the grid reinforcement. The costs for each added component are stored in the data frame ```edisgo.results.grid_expansion_costs```." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "83", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "markdown", + "id": "84", + "metadata": {}, + "source": [ + "## 5 Additional Time Series\n", + "\n", + "Besides setting worst case scenarios and the corresponding time series, component time series can also be set with the function ```predefined()```. Either standard profiles for different component types are loaded from a data base or type- (for generators) and sectorwise (for loads) time series can be determined manually and passed to the function. \n", + "\n", + "The function ```set_time_series_manual()``` can be used to set individual time series for components. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "85", + "metadata": {}, + "outputs": [], + "source": [ + "# determine interval time series are set for\n", + "# timeindex has to be set again to desired time interval because it was overwritten by set_time_series_worst_case()\n", + "timeindex = pd.date_range(f\"1/1/{2011} 8:00\", periods=12, freq=\"H\")\n", + "edisgo.set_timeindex(timeindex=timeindex)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "86", + "metadata": {}, + "outputs": [], + "source": [ + "# check which load sectors are included in the Husum grid\n", + "set(edisgo.topology.loads_df[\"sector\"])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "87", + "metadata": {}, + "outputs": [], + "source": [ + "# constant load for all time steps for all load sectors\n", + "timeseries_load = pd.DataFrame(\n", + " {\n", + " \"industrial\": [0.0001] * len(timeindex),\n", + " \"cts\": [0.0002] * len(timeindex),\n", + " \"residential\": [0.0002] * len(timeindex),\n", + " \"district_heating_resistive_heater\": [0.0002] * len(timeindex),\n", + " \"individual_heating\": [0.0002] * len(timeindex),\n", + " },\n", + " index=timeindex,\n", + ")\n", + "\n", + "# annual_consumption of loads is not set in Husum data set\n", + "edisgo.topology.loads_df[\"annual_consumption\"] = 700 * edisgo.topology.loads_df[\"p_set\"]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "88", + "metadata": {}, + "outputs": [], + "source": [ + "# check which generator types are included into the grid\n", + "set(edisgo.topology.generators_df[\"type\"])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "89", + "metadata": {}, + "outputs": [], + "source": [ + "# constant feed-in for dispatchable generators\n", + "timeseries_generation_dispatchable = pd.DataFrame(\n", + " {\n", + " \"biomass\": [1] * len(timeindex),\n", + " \"gas\": [1] * len(timeindex),\n", + " \"other\": [1] * len(timeindex),\n", + " },\n", + " index=timeindex,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "90", + "metadata": {}, + "outputs": [], + "source": [ + "# determine fluctuating generators, for which generator-type time series are loaded from a data base\n", + "fluctuating_generators = edisgo.topology.generators_df[\n", + " edisgo.topology.generators_df[\"type\"].isin([\"solar\", \"wind\"])\n", + "].index" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "91", + "metadata": {}, + "outputs": [], + "source": [ + "# set active power time series for loads and generators\n", + "edisgo.set_time_series_active_power_predefined(\n", + " fluctuating_generators=fluctuating_generators,\n", + " fluctuating_generators_ts=\"oedb\",\n", + " scenario=scenario,\n", + " timeindex=edisgo.timeseries.timeindex,\n", + " conventional_loads_ts=timeseries_load,\n", + " dispatchable_generators_ts=timeseries_generation_dispatchable,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "92", + "metadata": {}, + "source": [ + "## Task\n", + "Plot the time series for three solar generators and gas power plants in individual plots." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "93", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "94", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "markdown", + "id": "95", + "metadata": {}, + "source": [ + "## Task\n", + "Plot the time series of three conventional loads and of three heat pumps in individual plots." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "96", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "97", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "98", + "metadata": {}, + "outputs": [], + "source": [ + "# set heat pump time series\n", + "# set_time_series_active_power_predefined does not consider heat demand\n", + "edisgo.apply_heat_pump_operating_strategy()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "99", + "metadata": {}, + "outputs": [], + "source": [ + "timeseries_heat_pumps = edisgo.timeseries.loads_active_power.loc[\n", + " :, edisgo.topology.loads_df[\"type\"] == \"heat_pump\"\n", + "]\n", + "timeseries_heat_pumps.iloc[:, :4].plot()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "100", + "metadata": {}, + "outputs": [], + "source": [ + "# set battery storage time series (not included in set_time_series_active_power_predefined())\n", + "apply_reference_operation(edisgo)\n", + "# returns soe" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "101", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.timeseries.storage_units_active_power.head()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "102", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.timeseries.storage_units_active_power.iloc[:, :4].plot()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "103", + "metadata": {}, + "outputs": [], + "source": [ + "# set reactive power time series\n", + "edisgo.set_time_series_reactive_power_control()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "104", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.timeseries.generators_reactive_power.head()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "105", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.16" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/Workshop_LoMa_solutions.ipynb b/examples/Workshop_LoMa_solutions.ipynb new file mode 100644 index 000000000..bfd619095 --- /dev/null +++ b/examples/Workshop_LoMa_solutions.ipynb @@ -0,0 +1,1189 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# LoMa EDisGo-Workshop 27.2.2025" + ] + }, + { + "cell_type": "markdown", + "id": "1", + "metadata": {}, + "source": [ + "Contents:\n", + "1. Topology Setup\n", + "2. Worst Case Time Series Creation\n", + "3. Grid Investigation\n", + "4. Results\n", + "5. Additional Time Series\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext jupyter_black" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import requests\n", + "import sys\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import networkx as nx\n", + "import pandas as pd\n", + "\n", + "from copy import deepcopy\n", + "from numpy.random import default_rng\n", + "from pathlib import Path\n", + "\n", + "from edisgo import EDisGo\n", + "from edisgo.io.db import engine\n", + "from edisgo.tools.logger import setup_logger\n", + "from edisgo.flex_opt.battery_storage_operation import apply_reference_operation\n", + "from edisgo.network.results import Results" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "# to make the notebook clearer. not recommendable\n", + "import warnings\n", + "\n", + "warnings.filterwarnings(\"ignore\")" + ] + }, + { + "cell_type": "markdown", + "id": "5", + "metadata": {}, + "source": [ + "## 1 Topology Setup" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "In this section we load all components into a newly created edisgo object. This includes the lines, buses, transformers, switches, generators, loads, heat pumps and battery storages." + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, + "source": [ + "### Standard components" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "Set up a new edisgo object:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "conf_path = Path.home() / \"Downloads\" / \"egon-data.configuration.yaml\"\n", + "assert conf_path.is_file()\n", + "\n", + "db_engine = engine(path=conf_path, ssh=True)\n", + "\n", + "ding0_grid = Path.home() / \".edisgo\" / \"husum_grids\" / \"35725\"\n", + "assert ding0_grid.is_dir()\n", + "\n", + "edisgo = EDisGo(ding0_grid=ding0_grid, legacy_ding0_grids=False, engine=db_engine)" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "ding0 and edisgo use different assumptions for the grid design and extension, respectively. This may cause that edisgo detects voltage deviations and line overloads. To avoid this the edisgo assumptions should be transferred to the ding0 grid by applying ```reinforce()``` after the grid import." + ] + }, + { + "cell_type": "markdown", + "id": "11", + "metadata": {}, + "source": [ + "Grids are reinforced for their worst case scenarios. The corresponding time series are created with ```set_time_series_worst_case_analysis()```. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.set_time_series_worst_case_analysis()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.reinforce()" + ] + }, + { + "cell_type": "markdown", + "id": "14", + "metadata": {}, + "source": [ + "### Plot grid topology (MV)" + ] + }, + { + "cell_type": "markdown", + "id": "15", + "metadata": {}, + "source": [ + "The topology can be visualized with the ```plot_mv_grid_topology()```. For ```technologies=True``` the buses sizes and colors are determined to the type and size of the technologies connected to it. \n", + "\n", + "- red: nodes with substation secondary side\n", + "- light blue: nodes distribution substations's primary side\n", + "- green: nodes with fluctuating generators\n", + "- black: nodes with conventional generators\n", + "- grey: disconnecting points\n", + "- dark blue: branch trees" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "16", + "metadata": {}, + "outputs": [], + "source": [ + "# adjust node sizes to make plot clearer\n", + "sizes_dict = {\n", + " \"BranchTee\": 10000,\n", + " \"GeneratorFluctuating\": 100000,\n", + " \"Generator\": 100000,\n", + " \"Load\": 100000,\n", + " \"LVStation\": 50000,\n", + " \"MVStation\": 120000,\n", + " \"Storage\": 100000,\n", + " \"DisconnectingPoint\": 75000,\n", + " \"else\": 200000,\n", + "}\n", + "\n", + "sizes_dict = {k: v / 10 for k, v in sizes_dict.items()}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "17", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.plot_mv_grid_topology(technologies=True, sizes_dict=sizes_dict)" + ] + }, + { + "cell_type": "markdown", + "id": "18", + "metadata": {}, + "source": [ + "### Topology-Module Data Structure" + ] + }, + { + "cell_type": "markdown", + "id": "19", + "metadata": {}, + "source": [ + "Let's get familiar with the topology module:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "20", + "metadata": {}, + "outputs": [], + "source": [ + "# generator types\n", + "edisgo.topology.generators_df[[\"p_nom\", \"type\"]].groupby(\"type\").sum()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "21", + "metadata": {}, + "outputs": [], + "source": [ + "# load types\n", + "edisgo.topology.loads_df[[\"p_set\", \"type\"]].groupby(\"type\").sum()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "22", + "metadata": {}, + "outputs": [], + "source": [ + "# load sectors\n", + "edisgo.topology.loads_df[[\"p_set\", \"sector\"]].groupby(\"sector\").sum()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "23", + "metadata": {}, + "outputs": [], + "source": [ + "# amount of lv grids inside the mv grid\n", + "len(list(edisgo.topology.mv_grid.lv_grids))" + ] + }, + { + "cell_type": "markdown", + "id": "24", + "metadata": {}, + "source": [ + "Total number of lines:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "25", + "metadata": {}, + "outputs": [], + "source": [ + "# overall amount of lines\n", + "len(edisgo.topology.lines_df)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26", + "metadata": {}, + "outputs": [], + "source": [ + "# amount of lines in one of the lv grids\n", + "len(edisgo.topology.grids[5].lines_df.index)" + ] + }, + { + "cell_type": "markdown", + "id": "27", + "metadata": {}, + "source": [ + "### Basic components addition and removal" + ] + }, + { + "cell_type": "markdown", + "id": "28", + "metadata": {}, + "source": [ + "To see how a loaded network can be adapted later on, we add a solar plant to a random bus.\n", + "\n", + "Components can also be added according to their geolocation with the function ```integrate_component_based_on_geolocation()```." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "29", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.topology.generators_df" + ] + }, + { + "cell_type": "markdown", + "id": "30", + "metadata": {}, + "source": [ + "Add a generator with the function ```add_component()``` or ```add_generator()```. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "31", + "metadata": {}, + "outputs": [], + "source": [ + "# determine a random bus\n", + "rng = default_rng(1)\n", + "rnd_bus = rng.choice(edisgo.topology.buses_df.index, size=1)[0]\n", + "generator_type = \"solar\"\n", + "\n", + "new_generator = edisgo.add_component(\n", + " comp_type=\"generator\", p_nom=0.01, bus=rnd_bus, generator_type=generator_type\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "32", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.topology.generators_df" + ] + }, + { + "cell_type": "markdown", + "id": "33", + "metadata": {}, + "source": [ + "Single components can be removed with ```remove_component()```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "34", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.remove_component(comp_type=\"generator\", comp_name=new_generator)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "35", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.topology.generators_df" + ] + }, + { + "cell_type": "markdown", + "id": "36", + "metadata": {}, + "source": [ + "### Task: \n", + "Add and remove a 'heat_pump' with the function ```add_component()``` and the function ```remove_component()```." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "37", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.topology.loads_df" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "38", + "metadata": {}, + "outputs": [], + "source": [ + "new_load = edisgo.add_component(\n", + " comp_type=\"load\", p_set=0.01, bus=rnd_bus, type=\"heat_pump\"\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "39", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.topology.loads_df" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "40", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.remove_component(comp_type=\"load\", comp_name=new_load)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "41", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.topology.loads_df" + ] + }, + { + "cell_type": "markdown", + "id": "42", + "metadata": {}, + "source": [ + "### Add flexible components to grid " + ] + }, + { + "cell_type": "markdown", + "id": "43", + "metadata": {}, + "source": [ + "For realistic future grids we also add further components like additional generators, home batteries, (charging points) and heat pumps. The components are added according to the scenario \"eGon2035\" and the data from the oedb." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "44", + "metadata": {}, + "outputs": [], + "source": [ + "scenario = \"eGon2035\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "45", + "metadata": {}, + "outputs": [], + "source": [ + "# copy the edisgo object for later comparisons\n", + "edisgo_orig = edisgo.copy()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "46", + "metadata": {}, + "outputs": [], + "source": [ + "# clear initial reinfocement results from results module\n", + "edisgo.results = Results(edisgo)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "47", + "metadata": {}, + "outputs": [], + "source": [ + "# set timeindex to ensure that correct time series for COP and heat pump heat demand are downloaded\n", + "timeindex = pd.date_range(f\"1/1/{2011} 8:00\", periods=12, freq=\"H\")\n", + "edisgo.set_timeindex(timeindex=timeindex)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "48", + "metadata": {}, + "outputs": [], + "source": [ + "# Retry if running into \"Connection reset by peer\" error\n", + "\n", + "edisgo.import_generators(generator_scenario=scenario)\n", + "edisgo.import_home_batteries(scenario=scenario)\n", + "edisgo.import_heat_pumps(scenario=scenario)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "49", + "metadata": {}, + "outputs": [], + "source": [ + "# This takes too long for the workshop\n", + "# edisgo_obj.import_dsm(scenario=scenario)\n", + "# edisgo_obj.import_electromobility(\n", + "# data_source=\"oedb\", scenario=scenario\n", + "# )" + ] + }, + { + "cell_type": "markdown", + "id": "50", + "metadata": {}, + "source": [ + "## Task:\n", + "Determine the differnet generator types that were installed before and that are installed in the grid now." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "51", + "metadata": {}, + "outputs": [], + "source": [ + "set(edisgo.topology.generators_df[\"type\"])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "52", + "metadata": {}, + "outputs": [], + "source": [ + "set(edisgo_orig.topology.generators_df[\"type\"])" + ] + }, + { + "cell_type": "markdown", + "id": "53", + "metadata": {}, + "source": [ + "## Task:\n", + "Determine the added solar energy power." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "54", + "metadata": {}, + "outputs": [], + "source": [ + "solar_power_new = edisgo.topology.generators_df[\n", + " edisgo.topology.generators_df[\"type\"] == \"solar\"\n", + "][\"p_nom\"].sum()\n", + "\n", + "solar_power_old = edisgo_orig.topology.generators_df[\n", + " edisgo_orig.topology.generators_df[\"type\"] == \"solar\"\n", + "][\"p_nom\"].sum()\n", + "\n", + "solar_power_new - solar_power_old" + ] + }, + { + "cell_type": "markdown", + "id": "55", + "metadata": {}, + "source": [ + "## Task:\n", + "Determine the amount of storage units added to the grid with a nominal power (p_nom) larger than 0.01." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "56", + "metadata": {}, + "outputs": [], + "source": [ + "sum(edisgo.topology.storage_units_df[\"p_nom\"] > 0.01)" + ] + }, + { + "cell_type": "markdown", + "id": "57", + "metadata": {}, + "source": [ + "## Task:\n", + "Determine the buses of the heat pumps whose application ('sector') is not inidividual_heating." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "58", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.topology.loads_df.loc[\n", + " (edisgo.topology.loads_df[\"type\"] == \"heat_pump\")\n", + " & (edisgo.topology.loads_df[\"sector\"] != \"individual_heating\"),\n", + " \"bus\",\n", + "]" + ] + }, + { + "cell_type": "markdown", + "id": "59", + "metadata": {}, + "source": [ + "## 2 Worst Case Time Series Creation" + ] + }, + { + "cell_type": "markdown", + "id": "60", + "metadata": {}, + "source": [ + "Create timeseries for the four worst cases MV load case, LV load case, MV feed-in case, LV feed-in case with the function set_time_series_worst_case_analysis().\n", + "\n", + "In conventional grid expansion planning worst-cases, the heavy load flow and the reverse power flow, are used to determine grid expansion needs. eDisGo allows you to analyze these cases separately or together. Choose between the following options:\n", + "\n", + "* **’feed-in_case’** \n", + " \n", + " Feed-in and demand for the worst-case scenario \"reverse power flow\" are generated (e.g. conventional electricity demand is set to 15% of maximum demand for loads connected to the MV grid and 10% for loads connected to the LV grid and feed-in of all generators is set to the nominal power of the generator, except for PV systems where it is by default set to 85% of the nominal power)\n", + "\n", + " \n", + "* **’load_case’**\n", + "\n", + " Feed-in and demand for the worst-case scenario \"heavy load flow\" are generated (e.g. demand of all conventional loads is by default set to maximum demand and feed-in of all generators is set to zero)\n", + "\n", + "\n", + "* **[’feed-in_case’, ’load_case’]**\n", + "\n", + " Both cases are set up.\n", + " \n", + "By default both cases are set up.\n", + "\n", + "Feed-in and demand in the two worst-cases are defined in the [config file 'config_timeseries.cfg'](https://edisgo.readthedocs.io/en/latest/configs.html#config-timeseries) and can be changed by setting different values in the config file. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "61", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.set_time_series_worst_case_analysis()" + ] + }, + { + "cell_type": "markdown", + "id": "62", + "metadata": {}, + "source": [ + "The function creates time series for four time steps since both worst cases are defined seperately for the LV and the MV grid with individual simultanerity factors." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "63", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.timeseries.timeindex_worst_cases" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "64", + "metadata": {}, + "outputs": [], + "source": [ + "# indexing with worst case timeindex\n", + "edisgo.timeseries.loads_active_power.loc[\n", + " edisgo.timeseries.timeindex_worst_cases[\"load_case_mv\"]\n", + "]" + ] + }, + { + "cell_type": "markdown", + "id": "65", + "metadata": {}, + "source": [ + "## 3 Grid Investigation" + ] + }, + { + "cell_type": "markdown", + "id": "66", + "metadata": {}, + "source": [ + "Execute a power flow analysis to determine line overloads and voltage deviations for the MV load case timeseries with the function ```analyze()```:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "67", + "metadata": {}, + "outputs": [], + "source": [ + "# power flow analysis\n", + "edisgo.analyze(timesteps=edisgo.timeseries.timeindex_worst_cases[\"load_case_mv\"])" + ] + }, + { + "cell_type": "markdown", + "id": "68", + "metadata": {}, + "source": [ + "A geoplot with the bus and line colors based on the voltage deviations and line loadings repectively can be created with ```plot_mv_line_loading()```." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "69", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.plot_mv_line_loading(\n", + " node_color=\"voltage_deviation\",\n", + " timestep=edisgo.timeseries.timeindex_worst_cases[\"load_case_mv\"],\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "70", + "metadata": {}, + "source": [ + "For a better overview of the voltage deviations and line loads in the entire grid, edisgo provides histrogram plots." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "71", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.histogram_voltage(binwidth=0.005)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "72", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.histogram_relative_line_load(binwidth=0.1)" + ] + }, + { + "cell_type": "markdown", + "id": "73", + "metadata": {}, + "source": [ + "## 4 Results" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "74", + "metadata": {}, + "outputs": [], + "source": [ + "# Reinforce the grid\n", + "# mode = \"mvlv\" for a shorter run time. However, grid reinforcement should generally be conducted in mode=\"lv\" (default)\n", + "# since the majority of the reinforcement costs is caused in the lv grid part, especially for high load grids (much EV charging demand and low PV capacity)\n", + "# The lv mode is currently not applicable for the Husum grid. The newly added generators are concentrated in one LV grid. The reinforcement\n", + "# very long or cannot be resolved. This issue will be fixed soon. A possible workarounf for running the reinfocrement anyway is to remove the generators for the overloaded LV grid.\n", + "edisgo.reinforce(mode=\"mvlv\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "75", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.plot_mv_line_loading(\n", + " node_color=\"voltage_deviation\",\n", + " timestep=edisgo.timeseries.timeindex_worst_cases[\"load_case_mv\"],\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "76", + "metadata": {}, + "outputs": [], + "source": [ + "# power flow analysis to retrieve all bus voltages and line flows\n", + "edisgo.analyze(timesteps=edisgo.timeseries.timeindex_worst_cases[\"load_case_mv\"])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "77", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.histogram_voltage(binwidth=0.005)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "78", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.histogram_relative_line_load(binwidth=0.1)" + ] + }, + { + "cell_type": "markdown", + "id": "79", + "metadata": {}, + "source": [ + "The module ```results```holds the outputs of the reinforcement" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "80", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo_orig.results.equipment_changes" + ] + }, + { + "cell_type": "markdown", + "id": "81", + "metadata": {}, + "source": [ + "## Task:\n", + "Determine the total costs for the grid reinforcement. The costs for each added component are stored in the data frame ```edisgo.results.grid_expansion_costs```." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "82", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.results.grid_expansion_costs[\"total_costs\"].sum()" + ] + }, + { + "cell_type": "markdown", + "id": "83", + "metadata": {}, + "source": [ + "## 5 Additional Time Series\n", + "\n", + "Besides setting worst case scenarios and the corresponding time series, component time series can also be set with the function ```predefined()```. Either standard profiles for different component types are loaded from a data base or type- (for generators) and sectorwise (for loads) time series can be determined manually and passed to the function. \n", + "\n", + "The function ```set_time_series_manual()``` can be used to set individual time series for components. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "84", + "metadata": {}, + "outputs": [], + "source": [ + "# determine interval time series are set for\n", + "# timeindex has to be set again to desired time interval because it was overwritten by set_time_series_worst_case()\n", + "timeindex = pd.date_range(f\"1/1/{2011} 8:00\", periods=12, freq=\"H\")\n", + "edisgo.set_timeindex(timeindex=timeindex)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "85", + "metadata": {}, + "outputs": [], + "source": [ + "# check which load sectors are included in the Husum grid\n", + "set(edisgo.topology.loads_df[\"sector\"])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "86", + "metadata": {}, + "outputs": [], + "source": [ + "# constant load for all time steps for all load types\n", + "timeseries_load = pd.DataFrame(\n", + " {\n", + " \"industrial\": [0.0001] * len(timeindex),\n", + " \"cts\": [0.0002] * len(timeindex),\n", + " \"residential\": [0.0002] * len(timeindex),\n", + " \"district_heating_resistive_heater\": [0.0002] * len(timeindex),\n", + " \"individual_heating\": [0.0002] * len(timeindex),\n", + " },\n", + " index=timeindex,\n", + ")\n", + "\n", + "# annual_consumption of loads is not set in Husum data set\n", + "edisgo.topology.loads_df[\"annual_consumption\"] = 700 * edisgo.topology.loads_df[\"p_set\"]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "87", + "metadata": {}, + "outputs": [], + "source": [ + "# check which generator types are included into the grid\n", + "set(edisgo.topology.generators_df[\"type\"])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "88", + "metadata": {}, + "outputs": [], + "source": [ + "# constant feed-in for dispatchable generators\n", + "timeseries_generation_dispatchable = pd.DataFrame(\n", + " {\n", + " \"biomass\": [1] * len(timeindex),\n", + " \"gas\": [1] * len(timeindex),\n", + " \"other\": [1] * len(timeindex),\n", + " },\n", + " index=timeindex,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "89", + "metadata": {}, + "outputs": [], + "source": [ + "# determine fluctuating generators, for which generator-type time series are loaded from a data base\n", + "fluctuating_generators = edisgo.topology.generators_df[\n", + " edisgo.topology.generators_df[\"type\"].isin([\"solar\", \"wind\"])\n", + "].index" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "90", + "metadata": {}, + "outputs": [], + "source": [ + "# set active power time series for loads and generators\n", + "edisgo.set_time_series_active_power_predefined(\n", + " fluctuating_generators=fluctuating_generators,\n", + " fluctuating_generators_ts=\"oedb\",\n", + " scenario=scenario,\n", + " timeindex=edisgo.timeseries.timeindex,\n", + " conventional_loads_ts=timeseries_load,\n", + " dispatchable_generators_ts=timeseries_generation_dispatchable,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "91", + "metadata": {}, + "source": [ + "## Task\n", + "Plot the time series for three solar generators and gas power plants in individual plots." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "92", + "metadata": {}, + "outputs": [], + "source": [ + "timeseries_solar_generators = edisgo.timeseries.generators_active_power.loc[\n", + " :, edisgo.topology.generators_df[\"type\"] == \"solar\"\n", + "]\n", + "timeseries_solar_generators.iloc[:, :5].plot()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "93", + "metadata": {}, + "outputs": [], + "source": [ + "timeseries_gas_generators = edisgo.timeseries.generators_active_power.loc[\n", + " :, edisgo.topology.generators_df[\"type\"] == \"gas\"\n", + "]\n", + "timeseries_gas_generators.iloc[:, :5].plot()" + ] + }, + { + "cell_type": "markdown", + "id": "94", + "metadata": {}, + "source": [ + "## Task\n", + "Plot the time series of three conventional loads and of three heat pumps in individual plots." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "95", + "metadata": {}, + "outputs": [], + "source": [ + "timeseries_heat_pumps = edisgo.timeseries.loads_active_power.loc[\n", + " :, edisgo.topology.loads_df[\"type\"] == \"conventional_load\"\n", + "]\n", + "timeseries_heat_pumps.iloc[:, :4].plot()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "96", + "metadata": {}, + "outputs": [], + "source": [ + "timeseries_heat_pumps = edisgo.timeseries.loads_active_power.loc[\n", + " :, edisgo.topology.loads_df[\"type\"] == \"heat_pump\"\n", + "]\n", + "timeseries_heat_pumps.iloc[:, :4].plot()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "97", + "metadata": {}, + "outputs": [], + "source": [ + "# set heat pump time series\n", + "# set_time_series_active_power_predefined does not consider heat demand\n", + "edisgo.apply_heat_pump_operating_strategy()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "98", + "metadata": {}, + "outputs": [], + "source": [ + "timeseries_heat_pumps = edisgo.timeseries.loads_active_power.loc[\n", + " :, edisgo.topology.loads_df[\"type\"] == \"heat_pump\"\n", + "]\n", + "timeseries_heat_pumps.iloc[:, :4].plot()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "99", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.timeseries.loads_active_power" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "100", + "metadata": {}, + "outputs": [], + "source": [ + "# set battery storage time series (not inluded in set_time_series_active_power_predefined())\n", + "apply_reference_operation(edisgo)\n", + "# returns soe" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "101", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.timeseries.storage_units_active_power.iloc[:, :4].plot()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "102", + "metadata": {}, + "outputs": [], + "source": [ + "# set reactive power time series\n", + "edisgo.set_time_series_reactive_power_control()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "103", + "metadata": {}, + "outputs": [], + "source": [ + "edisgo.timeseries.generators_reactive_power" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.16" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/edisgo_simple_example.ipynb b/examples/edisgo_simple_example.ipynb index c7ee79ce1..31b493ead 100644 --- a/examples/edisgo_simple_example.ipynb +++ b/examples/edisgo_simple_example.ipynb @@ -892,7 +892,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.5" + "version": "3.11.0" }, "toc": { "base_numbering": 1, diff --git a/examples/electromobility_example.ipynb b/examples/electromobility_example.ipynb index 9ab632b71..259437467 100644 --- a/examples/electromobility_example.ipynb +++ b/examples/electromobility_example.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "e9100083", + "id": "0", "metadata": {}, "source": [ "# Electromobility example\n", @@ -17,7 +17,7 @@ }, { "cell_type": "markdown", - "id": "c74c4450", + "id": "1", "metadata": {}, "source": [ "## Installation and setup\n", @@ -27,7 +27,7 @@ }, { "cell_type": "markdown", - "id": "ecefffc4", + "id": "2", "metadata": {}, "source": [ "### Import packages" @@ -36,7 +36,7 @@ { "cell_type": "code", "execution_count": null, - "id": "6898e8bd", + "id": "3", "metadata": { "tags": [] }, @@ -65,7 +65,7 @@ { "cell_type": "code", "execution_count": null, - "id": "6b5c46ca", + "id": "4", "metadata": { "tags": [] }, @@ -77,7 +77,7 @@ }, { "cell_type": "markdown", - "id": "488bfb8c", + "id": "5", "metadata": {}, "source": [ "### Set up logger" @@ -86,7 +86,7 @@ { "cell_type": "code", "execution_count": null, - "id": "e3b60c43", + "id": "6", "metadata": { "tags": [] }, @@ -104,7 +104,7 @@ }, { "cell_type": "markdown", - "id": "fd735589", + "id": "7", "metadata": {}, "source": [ "### Download example grid" @@ -113,7 +113,7 @@ { "cell_type": "code", "execution_count": null, - "id": "afe44b3f", + "id": "8", "metadata": { "tags": [] }, @@ -155,7 +155,7 @@ }, { "cell_type": "markdown", - "id": "abddc320", + "id": "9", "metadata": {}, "source": [ "### Set up edisgo object" @@ -164,7 +164,7 @@ { "cell_type": "code", "execution_count": null, - "id": "b8a406ae", + "id": "10", "metadata": { "tags": [] }, @@ -191,7 +191,7 @@ { "cell_type": "code", "execution_count": null, - "id": "716fa083-0409-46a4-a55c-07cac583e387", + "id": "11", "metadata": { "tags": [] }, @@ -213,7 +213,7 @@ }, { "cell_type": "markdown", - "id": "4269ad12", + "id": "12", "metadata": {}, "source": [ "## Prerequisite data\n", @@ -225,7 +225,7 @@ }, { "cell_type": "markdown", - "id": "0ba78c69", + "id": "13", "metadata": {}, "source": [ "### Download 'Verwaltungsgebiete' data\n", @@ -235,7 +235,7 @@ }, { "cell_type": "markdown", - "id": "ccb74f72", + "id": "14", "metadata": {}, "source": [ "```python\n", @@ -265,7 +265,7 @@ { "cell_type": "code", "execution_count": null, - "id": "3fdf5534", + "id": "15", "metadata": {}, "outputs": [], "source": [ @@ -304,7 +304,7 @@ }, { "cell_type": "markdown", - "id": "b2e81602", + "id": "16", "metadata": {}, "source": [ "### Check which 'Verwaltungsgebiete' intersect MV grid" @@ -313,7 +313,7 @@ { "cell_type": "code", "execution_count": null, - "id": "d6bdc1f4", + "id": "17", "metadata": {}, "outputs": [], "source": [ @@ -330,7 +330,7 @@ { "cell_type": "code", "execution_count": null, - "id": "38e067dd", + "id": "18", "metadata": {}, "outputs": [], "source": [ @@ -345,7 +345,7 @@ }, { "cell_type": "markdown", - "id": "e2082ea8-3be5-4e69-8b3b-26023bedc71b", + "id": "19", "metadata": {}, "source": [ "As most municipalities only intersect the grid district at its border, only the electromobility data for one municipality needs to be generated." @@ -354,7 +354,7 @@ { "cell_type": "code", "execution_count": null, - "id": "0d4e721d-6be2-4e41-b6d0-349f9bbc2f5b", + "id": "20", "metadata": {}, "outputs": [], "source": [ @@ -369,7 +369,7 @@ }, { "cell_type": "markdown", - "id": "bfc8a701", + "id": "21", "metadata": {}, "source": [ "## Add electromobility to EDisGo object\n", @@ -410,7 +410,7 @@ { "cell_type": "code", "execution_count": null, - "id": "c8f2e17e", + "id": "22", "metadata": {}, "outputs": [], "source": [ @@ -453,7 +453,7 @@ { "cell_type": "code", "execution_count": null, - "id": "8421b212", + "id": "23", "metadata": {}, "outputs": [], "source": [ @@ -490,7 +490,7 @@ { "cell_type": "code", "execution_count": null, - "id": "1d65e6d6", + "id": "24", "metadata": {}, "outputs": [], "source": [ @@ -503,7 +503,7 @@ }, { "cell_type": "markdown", - "id": "ae9955f1", + "id": "25", "metadata": {}, "source": [ "### eDisGo electromobility data structure \n", @@ -526,7 +526,7 @@ { "cell_type": "code", "execution_count": null, - "id": "0e859c1e-6aba-4457-92f5-59b1a4b4ddae", + "id": "26", "metadata": {}, "outputs": [], "source": [ @@ -537,7 +537,7 @@ { "cell_type": "code", "execution_count": null, - "id": "964916d6-82fc-47fb-8ff4-d28173113128", + "id": "27", "metadata": {}, "outputs": [], "source": [ @@ -548,7 +548,7 @@ { "cell_type": "code", "execution_count": null, - "id": "db648528-06dd-40cf-9fc0-4137280f21cb", + "id": "28", "metadata": {}, "outputs": [], "source": [ @@ -559,7 +559,7 @@ { "cell_type": "code", "execution_count": null, - "id": "f6663f9f-2481-403d-b1d8-c0cf364d3eba", + "id": "29", "metadata": {}, "outputs": [], "source": [ @@ -570,7 +570,7 @@ { "cell_type": "code", "execution_count": null, - "id": "c71977c0-e4e0-443e-afa1-ed632c30c54b", + "id": "30", "metadata": {}, "outputs": [], "source": [ @@ -580,7 +580,7 @@ { "cell_type": "code", "execution_count": null, - "id": "1b156984-4431-4312-a617-a23441e0d153", + "id": "31", "metadata": {}, "outputs": [], "source": [ @@ -625,7 +625,7 @@ }, { "cell_type": "markdown", - "id": "b82b9f8f", + "id": "32", "metadata": {}, "source": [ "## Applying different charging strategies\n", @@ -635,7 +635,7 @@ }, { "cell_type": "markdown", - "id": "0cc6707b", + "id": "33", "metadata": {}, "source": [ "The eDisGo tool currently offers three different charging strategies: `dumb`, `reduced` and `residual`.\n", @@ -656,7 +656,7 @@ { "cell_type": "code", "execution_count": null, - "id": "18455dcc-0db7-4ade-9003-6c183552a12b", + "id": "34", "metadata": {}, "outputs": [], "source": [ @@ -668,7 +668,7 @@ { "cell_type": "code", "execution_count": null, - "id": "685108f9-f15b-459e-8f22-2d99c678fb1c", + "id": "35", "metadata": {}, "outputs": [], "source": [ @@ -679,7 +679,7 @@ { "cell_type": "code", "execution_count": null, - "id": "b56ebbd4", + "id": "36", "metadata": {}, "outputs": [], "source": [ @@ -695,7 +695,7 @@ }, { "cell_type": "markdown", - "id": "b9cd3434", + "id": "37", "metadata": {}, "source": [ "To change the charging strategy from the default `dumb` to one of the other strategies, the `strategy` parameter has to be set accordingly:" @@ -704,7 +704,7 @@ { "cell_type": "code", "execution_count": null, - "id": "a15eece2-951e-4749-9ab4-eaf3c22b0077", + "id": "38", "metadata": {}, "outputs": [], "source": [ @@ -715,7 +715,7 @@ { "cell_type": "code", "execution_count": null, - "id": "2b61d2e2", + "id": "39", "metadata": {}, "outputs": [], "source": [ @@ -725,7 +725,7 @@ }, { "cell_type": "markdown", - "id": "3bd366aa-ea6e-4d1f-a66b-fee6bcaf3f4f", + "id": "40", "metadata": {}, "source": [ "**Plot charging time series for different charging strategies**" @@ -734,7 +734,7 @@ { "cell_type": "code", "execution_count": null, - "id": "20d98ca8", + "id": "41", "metadata": { "tags": [] }, @@ -774,7 +774,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.18" + "version": "3.10.16" }, "toc": { "base_numbering": 1, diff --git a/examples/example_analyze_14a.py b/examples/example_analyze_14a.py new file mode 100644 index 000000000..be377d68e --- /dev/null +++ b/examples/example_analyze_14a.py @@ -0,0 +1,1059 @@ +""" +§14a EnWG Heat Pump Curtailment Analysis - Monthly Simulation + +This script performs a monthly optimization with §14a heat pump curtailment +and generates comprehensive analysis plots and statistics. + +Usage: + python analyze_14a_full_year.py --grid_path --scenario eGon2035 --num_days 30 +""" + +import os +import sys +import argparse +import pandas as pd +import numpy as np +import matplotlib.pyplot as plt +from datetime import datetime +from pathlib import Path + +from edisgo import EDisGo +from edisgo.io.db import engine as egon_engine + + +def add_charging_points_manually(edisgo, num_cps=30, seed=42): + """ + Add charging points manually to the grid with realistic size distribution. + + Parameters + ---------- + edisgo : EDisGo + EDisGo object + num_cps : int + Number of charging points to add (default: 30) + seed : int + Random seed for reproducibility (default: 42) + + Returns + ------- + EDisGo + EDisGo object with added charging points + """ + print(f"\n3a. Adding {num_cps} charging points manually...") + + np.random.seed(seed + 100) # Different seed than HPs + + # Get random LV buses (different from HP buses if possible) + lv_buses = edisgo.topology.buses_df[ + edisgo.topology.buses_df.v_nom < 1.0 # LV buses + ] + + if len(lv_buses) < num_cps: + print(f" ⚠ Warning: Only {len(lv_buses)} LV buses available, using all") + num_cps = len(lv_buses) + + selected_buses = lv_buses.sample(n=num_cps, random_state=seed + 100) + + # Realistic distribution based on typical EV charging points: + # - 50% home charging (3.7-11 kW, typically 11 kW) + # - 30% work/public charging (11-22 kW) + # - 20% fast charging (22-50 kW, but curtailed to grid limits) + num_home = int(num_cps * 0.5) + num_work = int(num_cps * 0.3) + num_fast = num_cps - num_home - num_work + + cp_data = [] + cp_names = [] + + # Home charging points (3.7-11 kW) + for i in range(num_home): + p_set = np.random.uniform(0.0037, 0.011) # 3.7-11 kW + cp_data.append({ + 'bus': selected_buses.index[i], + 'p_set': p_set, + 'type': 'charging_point', + 'sector': 'home' + }) + cp_names.append(f'CP_home_{i+1}') + + # Work/public charging points (11-22 kW) + for i in range(num_work): + p_set = np.random.uniform(0.011, 0.022) # 11-22 kW + cp_data.append({ + 'bus': selected_buses.index[num_home + i], + 'p_set': p_set, + 'type': 'charging_point', + 'sector': 'work' + }) + cp_names.append(f'CP_work_{i+1}') + + # Fast charging points (22-50 kW) + for i in range(num_fast): + p_set = np.random.uniform(0.022, 0.050) # 22-50 kW + cp_data.append({ + 'bus': selected_buses.index[num_home + num_work + i], + 'p_set': p_set, + 'type': 'charging_point', + 'sector': 'public' + }) + cp_names.append(f'CP_fast_{i+1}') + + # Add to topology + cp_df = pd.DataFrame(cp_data, index=cp_names) + edisgo.topology.loads_df = pd.concat([edisgo.topology.loads_df, cp_df]) + + print(f" ✓ Added {len(cp_names)} charging points:") + print(f" - {num_home} home (3.7-11 kW)") + print(f" - {num_work} work/public (11-22 kW)") + print(f" - {num_fast} fast charging (22-50 kW)") + print(f" - §14a eligible (>4.2 kW): {len(cp_df[cp_df['p_set'] > 0.0042])}") + + return edisgo + + +def add_heat_pumps_manually(edisgo, num_hps=50, seed=42): + """ + Add heat pumps manually to the grid with realistic size distribution. + + Parameters + ---------- + edisgo : EDisGo + EDisGo object + num_hps : int + Number of heat pumps to add (default: 50) + seed : int + Random seed for reproducibility (default: 42) + + Returns + ------- + EDisGo + EDisGo object with added heat pumps + """ + print(f"\n3. Adding {num_hps} heat pumps manually...") + + np.random.seed(seed) + + # Get random LV buses + lv_buses = edisgo.topology.buses_df[ + edisgo.topology.buses_df.v_nom < 1.0 # LV buses + ] + + if len(lv_buses) < num_hps: + print(f" ⚠ Warning: Only {len(lv_buses)} LV buses available, using all") + num_hps = len(lv_buses) + + selected_buses = lv_buses.sample(n=num_hps, random_state=seed) + + # Realistic distribution based on German residential heat pumps: + # - 60% large (11-20 kW) - typical for older/larger houses + # - 30% medium (5-11 kW) - typical for modern houses + # - 10% small (3-5 kW) - typical for well-insulated new buildings + num_large = int(num_hps * 0.6) + num_medium = int(num_hps * 0.3) + num_small = num_hps - num_large - num_medium + + hp_data = [] + hp_names = [] + + # Large heat pumps (11-20 kW) + for i in range(num_large): + p_set = np.random.uniform(0.011, 0.020) # 11-20 kW + hp_data.append({ + 'bus': selected_buses.index[i], + 'p_set': p_set, + 'type': 'heat_pump', + 'sector': 'residential' + }) + hp_names.append(f'HP_large_{i+1}') + + # Medium heat pumps (5-11 kW) + for i in range(num_medium): + p_set = np.random.uniform(0.005, 0.011) # 5-11 kW + hp_data.append({ + 'bus': selected_buses.index[num_large + i], + 'p_set': p_set, + 'type': 'heat_pump', + 'sector': 'residential' + }) + hp_names.append(f'HP_medium_{i+1}') + + # Small heat pumps (3-5 kW) + for i in range(num_small): + p_set = np.random.uniform(0.003, 0.005) # 3-5 kW + hp_data.append({ + 'bus': selected_buses.index[num_large + num_medium + i], + 'p_set': p_set, + 'type': 'heat_pump', + 'sector': 'residential' + }) + hp_names.append(f'HP_small_{i+1}') + + # Add to topology + hp_df = pd.DataFrame(hp_data, index=hp_names) + edisgo.topology.loads_df = pd.concat([edisgo.topology.loads_df, hp_df]) + + print(f" ✓ Added {len(hp_names)} heat pumps:") + print(f" - {num_large} large (11-20 kW)") + print(f" - {num_medium} medium (5-11 kW)") + print(f" - {num_small} small (3-5 kW)") + print(f" - §14a eligible (>4.2 kW): {num_large + num_medium}") + + return edisgo + + +def create_hp_timeseries(edisgo, scenario="eGon2035", num_days=30): + """ + Create realistic heat demand and COP time series for heat pumps. + + Parameters + ---------- + edisgo : EDisGo + EDisGo object with heat pumps + scenario : str + Scenario name for time index + num_days : int + Number of days to simulate (default: 30 for one month) + + Returns + ------- + EDisGo + EDisGo object with HP time series + """ + print(f"\n4. Creating heat pump time series for {num_days} days...") + + # Get heat pumps + heat_pumps = edisgo.topology.loads_df[edisgo.topology.loads_df['type'] == 'heat_pump'] + + if len(heat_pumps) == 0: + print(" ⚠ Warning: No heat pumps found") + return edisgo + + # Create time index (hourly for specified days) + num_timesteps = num_days * 24 + timeindex = pd.date_range('2035-01-15', periods=num_timesteps, freq='h') # Mid-winter + edisgo.timeseries.timeindex = timeindex + + print(f" Creating time series for {len(heat_pumps)} heat pumps...") + print(f" Timeindex: {len(timeindex)} timesteps (hourly, {num_days} days)") + + # Create realistic heat demand profile for winter month + hours = np.arange(len(timeindex)) + hour_of_day = timeindex.hour.values + day_of_week = timeindex.dayofweek.values # 0=Monday, 6=Sunday + + # Winter season - high base load (mid-January) + seasonal_factor = 0.9 # High demand in winter + + # Daily pattern (higher demand morning and evening) + daily_factor = 0.7 + 0.3 * ( + np.exp(-((hour_of_day - 7) ** 2) / 8) + # Morning peak + np.exp(-((hour_of_day - 19) ** 2) / 8) # Evening peak + ) + + # Weekend pattern (slightly different - later morning, more evening) + weekend_mask = day_of_week >= 5 + daily_factor[weekend_mask] = 0.6 + 0.4 * ( + np.exp(-((hour_of_day[weekend_mask] - 9) ** 2) / 10) + # Later morning + np.exp(-((hour_of_day[weekend_mask] - 20) ** 2) / 10) # Evening peak + ) + + # Combine patterns + base_profile = seasonal_factor * daily_factor + + # Create COP profile (winter - lower COP due to cold temperatures) + # Typical air-source heat pump COP in winter: 2.5-3.5 + cop_profile = 3.0 + np.random.normal(0, 0.2, len(timeindex)) + cop_profile = np.clip(cop_profile, 2.5, 3.5) + + # Create individual profiles for each HP + heat_demand_data = {} + cop_data = {} + + for hp_name in heat_pumps.index: + p_set = heat_pumps.loc[hp_name, 'p_set'] + + # Heat demand: base profile scaled by nominal power with random variation + # Assume average COP of 3.5, so thermal = electrical * 3.5 + base_thermal = p_set * 3.5 + + # Add individual variation (±20%) + individual_factor = 0.8 + 0.4 * np.random.random(len(timeindex)) + heat_demand = base_profile * base_thermal * individual_factor + + # Add some random noise + heat_demand += np.random.normal(0, 0.001, len(timeindex)) + heat_demand = np.maximum(heat_demand, 0) # No negative demand + + heat_demand_data[hp_name] = heat_demand + + # Individual COP with small variation + individual_cop = cop_profile + np.random.normal(0, 0.1, len(timeindex)) + individual_cop = np.clip(individual_cop, 2.5, 4.5) + cop_data[hp_name] = individual_cop + + # Set data + edisgo.heat_pump.heat_demand_df = pd.DataFrame(heat_demand_data, index=timeindex) + edisgo.heat_pump.cop_df = pd.DataFrame(cop_data, index=timeindex) + + print(f" ✓ Created time series:") + print(f" Heat demand range: {edisgo.heat_pump.heat_demand_df.min().min():.6f} - {edisgo.heat_pump.heat_demand_df.max().max():.6f} MW") + print(f" COP range: {edisgo.heat_pump.cop_df.min().min():.2f} - {edisgo.heat_pump.cop_df.max().max():.2f}") + + return edisgo + + +def create_cp_timeseries(edisgo, scenario="eGon2035", num_days=30): + """ + Create realistic charging demand time series for charging points. + + Parameters + ---------- + edisgo : EDisGo + EDisGo object with charging points + scenario : str + Scenario name for time index + num_days : int + Number of days to simulate (default: 30 for one month) + + Returns + ------- + EDisGo + EDisGo object with CP time series + """ + print(f"\n4a. Creating charging point time series for {num_days} days...") + + # Get charging points + charging_points = edisgo.topology.loads_df[edisgo.topology.loads_df['type'] == 'charging_point'] + + if len(charging_points) == 0: + print(" ⚠ Warning: No charging points found") + return edisgo + + # Use same time index as heat pumps + timeindex = edisgo.timeseries.timeindex + + print(f" Creating time series for {len(charging_points)} charging points...") + print(f" Timeindex: {len(timeindex)} timesteps (hourly, {num_days} days)") + + # Create realistic charging profiles based on use case + hours = np.arange(len(timeindex)) + hour_of_day = timeindex.hour.values + day_of_week = timeindex.dayofweek.values # 0=Monday, 6=Sunday + + cp_load_data = {} + + for cp_name in charging_points.index: + p_set = charging_points.loc[cp_name, 'p_set'] + sector = charging_points.loc[cp_name, 'sector'] + + # Different profiles based on sector + if sector == 'home': + # Home charging: evening/night (18:00-07:00), higher on weekends + peak_hours = ((hour_of_day >= 18) | (hour_of_day <= 7)) + base_profile = np.where(peak_hours, 0.7, 0.1) + # Higher usage on weekends + weekend_mask = day_of_week >= 5 + base_profile[weekend_mask] *= 1.3 + + elif sector == 'work': + # Work charging: daytime on weekdays (08:00-17:00) + work_hours = (hour_of_day >= 8) & (hour_of_day <= 17) + weekday_mask = day_of_week < 5 + base_profile = np.where(work_hours & weekday_mask, 0.6, 0.05) + + else: # public/fast charging + # Public charging: distributed throughout day, peaks at noon and evening + base_profile = 0.3 + 0.4 * ( + np.exp(-((hour_of_day - 12) ** 2) / 12) + # Noon peak + np.exp(-((hour_of_day - 18) ** 2) / 12) # Evening peak + ) + + # Add randomness and individual variation + random_factor = 0.7 + 0.6 * np.random.random(len(timeindex)) + cp_load = base_profile * p_set * random_factor + + # Add some random noise + cp_load += np.random.normal(0, p_set * 0.05, len(timeindex)) + cp_load = np.maximum(cp_load, 0) # No negative load + cp_load = np.minimum(cp_load, p_set) # Cap at nominal power + + cp_load_data[cp_name] = cp_load + + # Add CP loads to timeseries (they will be added to loads_active_power) + cp_load_df = pd.DataFrame(cp_load_data, index=timeindex) + + # Store for later use + if not hasattr(edisgo, 'charging_point_loads'): + edisgo.charging_point_loads = cp_load_df + else: + edisgo.charging_point_loads = cp_load_df + + print(f" ✓ Created time series:") + print(f" Load range: {cp_load_df.min().min():.6f} - {cp_load_df.max().max():.6f} MW") + print(f" Average load: {cp_load_df.mean().mean():.6f} MW") + + return edisgo + + +def setup_edisgo(grid_path, scenario="eGon2035", num_hps=50, num_cps=30, num_days=30): + """ + Load grid and setup components for time series analysis. + + Parameters + ---------- + grid_path : str + Path to ding0 grid folder + scenario : str + Scenario name (default: eGon2035) + num_hps : int + Number of heat pumps to add (default: 50) + num_cps : int + Number of charging points to add (default: 30) + num_days : int + Number of days to simulate (default: 30) + + Returns + ------- + EDisGo + Initialized EDisGo object with time series + """ + print(f"\n{'='*80}") + print(f"🔧 Setting up EDisGo Grid") + print(f"{'='*80}") + print(f"Grid path: {grid_path}") + print(f"Scenario: {scenario}") + + # Load grid + print("\n1. Loading ding0 grid...") + edisgo = EDisGo(ding0_grid=grid_path, legacy_ding0_grids=False) + + # Import generators + print("2. Importing generators...") + edisgo.import_generators(generator_scenario=scenario) + + # Add heat pumps manually + edisgo = add_heat_pumps_manually(edisgo, num_hps=num_hps) + + # Add charging points manually + edisgo = add_charging_points_manually(edisgo, num_cps=num_cps) + + # Create HP time series (this sets the timeindex) + edisgo = create_hp_timeseries(edisgo, scenario=scenario, num_days=num_days) + + # Create CP time series + edisgo = create_cp_timeseries(edisgo, scenario=scenario, num_days=num_days) + + # Store HP timeindex + hp_timeindex = edisgo.timeseries.timeindex + num_timesteps = len(hp_timeindex) + + # Set time series for other components (generators, loads) + print("\n5. Setting time series for generators and loads...") + + # Create simple time series for generators (use nominal power) + generators = edisgo.topology.generators_df + if len(generators) > 0: + # Active power: use p_nom for all timesteps + gen_active = pd.DataFrame( + data=generators['p_nom'].values.reshape(1, -1).repeat(num_timesteps, axis=0), + columns=generators.index, + index=hp_timeindex + ) + edisgo.timeseries._generators_active_power = gen_active + + # Reactive power: zeros + gen_reactive = pd.DataFrame( + data=0.0, + columns=generators.index, + index=hp_timeindex + ) + edisgo.timeseries._generators_reactive_power = gen_reactive + print(f" ✓ Created time series for {len(generators)} generators") + + # Create simple time series for other loads (use nominal power) + other_loads = edisgo.topology.loads_df[edisgo.topology.loads_df['type'] != 'heat_pump'] + if len(other_loads) > 0: + # Active power: use p_set for all timesteps + load_active = pd.DataFrame( + data=other_loads['p_set'].values.reshape(1, -1).repeat(num_timesteps, axis=0), + columns=other_loads.index, + index=hp_timeindex + ) + edisgo.timeseries._loads_active_power = load_active + + # Reactive power: zeros + load_reactive = pd.DataFrame( + data=0.0, + columns=other_loads.index, + index=hp_timeindex + ) + edisgo.timeseries._loads_reactive_power = load_reactive + print(f" ✓ Created time series for {len(other_loads)} other loads") + + # Calculate HP loads from heat demand and COP + print("6. Calculating heat pump electrical loads...") + heat_pumps = edisgo.topology.loads_df[edisgo.topology.loads_df['type'] == 'heat_pump'] + + # Initialize loads_active_power with existing data if any, or create empty + if not hasattr(edisgo.timeseries, '_loads_active_power') or edisgo.timeseries._loads_active_power is None: + edisgo.timeseries._loads_active_power = pd.DataFrame(index=hp_timeindex) + + if not hasattr(edisgo.timeseries, '_loads_reactive_power') or edisgo.timeseries._loads_reactive_power is None: + edisgo.timeseries._loads_reactive_power = pd.DataFrame(index=hp_timeindex) + + # Add HP electrical loads + for hp_name in heat_pumps.index: + hp_load = edisgo.heat_pump.heat_demand_df[hp_name] / edisgo.heat_pump.cop_df[hp_name] + edisgo.timeseries._loads_active_power[hp_name] = hp_load.values + edisgo.timeseries._loads_reactive_power[hp_name] = 0.0 + + print(f" ✓ Calculated electrical loads for {len(heat_pumps)} heat pumps") + + # Add charging point loads + if hasattr(edisgo, 'charging_point_loads'): + charging_points = edisgo.topology.loads_df[edisgo.topology.loads_df['type'] == 'charging_point'] + for cp_name in charging_points.index: + edisgo.timeseries._loads_active_power[cp_name] = edisgo.charging_point_loads[cp_name].values + edisgo.timeseries._loads_reactive_power[cp_name] = 0.0 + print(f" ✓ Added loads for {len(charging_points)} charging points") + + # Initial analysis + print("7. Running initial power flow analysis...") + edisgo.analyze() + + print("\n✓ Grid setup complete!") + print(f" Timeindex: {len(edisgo.timeseries.timeindex)} timesteps") + print(f" Start: {edisgo.timeseries.timeindex[0]}") + print(f" End: {edisgo.timeseries.timeindex[-1]}") + + # Heat pump statistics + print(f"\n Heat pumps: {len(heat_pumps)}") + print(f" Power range: {heat_pumps['p_set'].min()*1000:.1f} - {heat_pumps['p_set'].max()*1000:.1f} kW") + print(f" §14a eligible (>4.2 kW): {len(heat_pumps[heat_pumps['p_set'] > 0.0042])}") + + # Charging point statistics + charging_points = edisgo.topology.loads_df[edisgo.topology.loads_df['type'] == 'charging_point'] + if len(charging_points) > 0: + print(f"\n Charging points: {len(charging_points)}") + print(f" Power range: {charging_points['p_set'].min()*1000:.1f} - {charging_points['p_set'].max()*1000:.1f} kW") + print(f" §14a eligible (>4.2 kW): {len(charging_points[charging_points['p_set'] > 0.0042])}") + + # DEBUG: Verify charging_points_df property works + cp_via_property = edisgo.topology.charging_points_df + print(f" DEBUG: topology.charging_points_df returns {len(cp_via_property)} CPs") + if len(cp_via_property) != len(charging_points): + print(f" ⚠️ WARNING: Mismatch between direct query and property!") + + return edisgo + + +def run_optimization_14a(edisgo): + """ + Run optimization with §14a curtailment enabled. + + Uses opf_version=3 which minimizes line losses, maximal line loading, and HV slacks. + + Parameters + ---------- + edisgo : EDisGo + EDisGo object with time series + + Returns + ------- + EDisGo + EDisGo object with optimization results + """ + print(f"\n{'='*80}") + print(f"⚡ Running OPF with §14a Curtailment") + print(f"{'='*80}") + print(f"\nUsing OPF version 3:") + print(f" - Minimize line losses") + print(f" - Minimize maximal line loading") + print(f" - Minimize HV slacks") + print(f" - §14a curtailment enabled for heat pumps and charging points") + + start_time = datetime.now() + + # Run optimization + edisgo.pm_optimize(opf_version=2, curtailment_14a=True) + + duration = (datetime.now() - start_time).total_seconds() + + print(f"\n✓ Optimization complete!") + print(f" Duration: {duration:.1f} seconds ({duration/60:.1f} minutes)") + + return edisgo + + +def analyze_curtailment_results(edisgo, output_dir="results_14a"): + """ + Analyze §14a curtailment results and generate statistics. + + Parameters + ---------- + edisgo : EDisGo + EDisGo object with optimization results + output_dir : str + Directory to save results + + Returns + ------- + dict + Dictionary with analysis results + """ + print(f"\n{'='*80}") + print(f"📊 Analyzing §14a Curtailment Results") + print(f"{'='*80}") + + # Create output directory + Path(output_dir).mkdir(parents=True, exist_ok=True) + + # Get curtailment data for both heat pumps and charging points + hp_gen_cols = [col for col in edisgo.timeseries.generators_active_power.columns + if 'hp_14a_support' in col] + cp_gen_cols = [col for col in edisgo.timeseries.generators_active_power.columns + if 'cp_14a_support' in col or 'charging_point_14a_support' in col] + + all_gen_cols = hp_gen_cols + cp_gen_cols + + if len(all_gen_cols) == 0: + print("⚠ WARNING: No §14a virtual generators found in results!") + return {} + + curtailment = edisgo.timeseries.generators_active_power[all_gen_cols] + + # Get heat pump and charging point load data + heat_pumps = edisgo.topology.loads_df[edisgo.topology.loads_df['type'] == 'heat_pump'] + charging_points = edisgo.topology.loads_df[edisgo.topology.loads_df['type'] == 'charging_point'] + + all_flexible_loads = pd.concat([heat_pumps, charging_points]) + flexible_loads = edisgo.timeseries.loads_active_power[all_flexible_loads.index] + + # Separate for detailed analysis + hp_loads = edisgo.timeseries.loads_active_power[heat_pumps.index] if len(heat_pumps) > 0 else pd.DataFrame() + cp_loads = edisgo.timeseries.loads_active_power[charging_points.index] if len(charging_points) > 0 else pd.DataFrame() + + # Calculate statistics + total_curtailment = curtailment.sum().sum() + total_flexible_load = flexible_loads.sum().sum() + total_hp_load = hp_loads.sum().sum() if len(hp_loads) > 0 else 0 + total_cp_load = cp_loads.sum().sum() if len(cp_loads) > 0 else 0 + curtailment_percentage = (total_curtailment / total_flexible_load * 100) if total_flexible_load > 0 else 0 + + flexible_curtailment_total = curtailment.sum() + curtailed_units = flexible_curtailment_total[flexible_curtailment_total > 0] + + # Separate HP and CP curtailment + hp_curtailment_total = curtailment[hp_gen_cols].sum() if len(hp_gen_cols) > 0 else pd.Series() + cp_curtailment_total = curtailment[cp_gen_cols].sum() if len(cp_gen_cols) > 0 else pd.Series() + curtailed_hps = hp_curtailment_total[hp_curtailment_total > 0] if len(hp_curtailment_total) > 0 else pd.Series() + curtailed_cps = cp_curtailment_total[cp_curtailment_total > 0] if len(cp_curtailment_total) > 0 else pd.Series() + + # Time series statistics + curtailment_per_timestep = curtailment.sum(axis=1) + max_curtailment_timestep = curtailment_per_timestep.idxmax() + max_curtailment_value = curtailment_per_timestep.max() + + # Daily statistics + curtailment_daily = curtailment_per_timestep.resample('D').sum() + + # Monthly statistics + curtailment_monthly = curtailment_per_timestep.resample('M').sum() + + results = { + 'total_curtailment_MWh': total_curtailment, + 'total_flexible_load_MWh': total_flexible_load, + 'total_hp_load_MWh': total_hp_load, + 'total_cp_load_MWh': total_cp_load, + 'curtailment_percentage': curtailment_percentage, + 'num_virtual_gens': len(all_gen_cols), + 'num_hp_gens': len(hp_gen_cols), + 'num_cp_gens': len(cp_gen_cols), + 'num_curtailed_hps': len(curtailed_hps), + 'num_curtailed_cps': len(curtailed_cps), + 'max_curtailment_MW': curtailment.max().max(), + 'max_curtailment_timestep': max_curtailment_timestep, + 'max_curtailment_value_MW': max_curtailment_value, + 'curtailment_per_timestep': curtailment_per_timestep, + 'curtailment_daily': curtailment_daily, + 'curtailment_monthly': curtailment_monthly, + 'curtailment_data': curtailment, + 'hp_curtailment_data': curtailment[hp_gen_cols] if len(hp_gen_cols) > 0 else pd.DataFrame(), + 'cp_curtailment_data': curtailment[cp_gen_cols] if len(cp_gen_cols) > 0 else pd.DataFrame(), + 'hp_loads': hp_loads, + 'cp_loads': cp_loads, + 'flexible_loads': flexible_loads, + 'hp_curtailment_total': hp_curtailment_total, + 'cp_curtailment_total': cp_curtailment_total, + 'curtailed_hps': curtailed_hps, + 'curtailed_cps': curtailed_cps + } + + # Print summary + print(f"\n📈 Summary Statistics:") + print(f" Virtual generators: {len(all_gen_cols)} (HPs: {len(hp_gen_cols)}, CPs: {len(cp_gen_cols)})") + print(f" Heat pumps curtailed: {len(curtailed_hps)} / {len(heat_pumps)}") + print(f" Charging points curtailed: {len(curtailed_cps)} / {len(charging_points)}") + print(f" Total curtailment: {total_curtailment:.2f} MWh") + print(f" Total flexible load: {total_flexible_load:.2f} MWh (HP: {total_hp_load:.2f}, CP: {total_cp_load:.2f})") + print(f" Curtailment ratio: {curtailment_percentage:.2f}%") + print(f" Max curtailment: {curtailment.max().max():.4f} MW") + print(f" Max total curtailment (timestep): {max_curtailment_value:.4f} MW at {max_curtailment_timestep}") + + if len(curtailed_hps) > 0: + print(f"\n Top 5 curtailed heat pumps:") + for i, (hp, value) in enumerate(curtailed_hps.sort_values(ascending=False).head().items(), 1): + hp_name = hp.replace('hp_14a_support_', '') + print(f" {i}. {hp_name}: {value:.4f} MWh") + + if len(curtailed_cps) > 0: + print(f"\n Top 5 curtailed charging points:") + for i, (cp, value) in enumerate(curtailed_cps.sort_values(ascending=False).head().items(), 1): + cp_name = cp.replace('cp_14a_support_', '').replace('charging_point_14a_support_', '') + print(f" {i}. {cp_name}: {value:.4f} MWh") + + # Save statistics to CSV + stats_df = pd.DataFrame({ + 'Metric': [ + 'Total Curtailment (MWh)', + 'Total Flexible Load (MWh)', + 'Total HP Load (MWh)', + 'Total CP Load (MWh)', + 'Curtailment Percentage (%)', + 'Virtual Generators (Total)', + 'Virtual Generators (HPs)', + 'Virtual Generators (CPs)', + 'Curtailed HPs', + 'Curtailed CPs', + 'Max Curtailment (MW)', + 'Max Total Curtailment (MW)' + ], + 'Value': [ + f"{total_curtailment:.2f}", + f"{total_flexible_load:.2f}", + f"{total_hp_load:.2f}", + f"{total_cp_load:.2f}", + f"{curtailment_percentage:.2f}", + len(all_gen_cols), + len(hp_gen_cols), + len(cp_gen_cols), + len(curtailed_hps), + len(curtailed_cps), + f"{curtailment.max().max():.4f}", + f"{max_curtailment_value:.4f}" + ] + }) + stats_df.to_csv(f"{output_dir}/summary_statistics.csv", index=False) + print(f"\n✓ Summary statistics saved to {output_dir}/summary_statistics.csv") + + # Save detailed curtailment data + curtailment.to_csv(f"{output_dir}/curtailment_timeseries.csv") + curtailment_daily.to_csv(f"{output_dir}/curtailment_daily.csv") + curtailment_monthly.to_csv(f"{output_dir}/curtailment_monthly.csv") + if len(hp_curtailment_total) > 0: + hp_curtailment_total.to_csv(f"{output_dir}/hp_curtailment_total.csv") + if len(cp_curtailment_total) > 0: + cp_curtailment_total.to_csv(f"{output_dir}/cp_curtailment_total.csv") + + print(f"✓ Detailed data saved to {output_dir}/") + + return results + + +def create_plots(results, output_dir="results_14a"): + """ + Create comprehensive visualization plots. + + Parameters + ---------- + results : dict + Results dictionary from analyze_curtailment_results + output_dir : str + Directory to save plots + """ + print(f"\n{'='*80}") + print(f"📊 Creating Visualization Plots") + print(f"{'='*80}") + + curtailment = results['curtailment_data'] + hp_loads = results['hp_loads'] + cp_loads = results.get('cp_loads', pd.DataFrame()) + curtailment_per_timestep = results['curtailment_per_timestep'] + curtailment_daily = results['curtailment_daily'] + curtailment_monthly = results['curtailment_monthly'] + hp_curtailment_total = results['hp_curtailment_total'] + cp_curtailment_total = results.get('cp_curtailment_total', pd.Series()) + curtailed_hps = results['curtailed_hps'] + curtailed_cps = results.get('curtailed_cps', pd.Series()) + + # Plot 1: Time series curtailment + num_days = len(curtailment_per_timestep) // 24 + print(f"1. Creating {num_days}-day curtailment plot...") + fig, ax = plt.subplots(figsize=(16, 5)) + ax.plot(curtailment_per_timestep.index, curtailment_per_timestep.values, + 'r-', linewidth=1.5, alpha=0.7, marker='o', markersize=3) + ax.set_xlabel('Time', fontsize=12) + ax.set_ylabel('Total Curtailment (MW)', fontsize=12) + ax.set_title(f'§14a Heat Pump & Charging Point Curtailment - {num_days} Days', fontsize=14, fontweight='bold') + ax.grid(True, alpha=0.3) + plt.tight_layout() + plt.savefig(f"{output_dir}/01_curtailment_timeseries.png", dpi=300, bbox_inches='tight') + plt.close() + + # Plot 2: Daily curtailment + print("2. Creating daily curtailment plot...") + fig, ax = plt.subplots(figsize=(16, 5)) + ax.bar(curtailment_daily.index, curtailment_daily.values, width=1, color='red', alpha=0.7) + ax.set_xlabel('Date', fontsize=12) + ax.set_ylabel('Daily Curtailment (MWh)', fontsize=12) + ax.set_title('§14a Daily Curtailment', fontsize=14, fontweight='bold') + ax.grid(True, alpha=0.3, axis='y') + plt.tight_layout() + plt.savefig(f"{output_dir}/02_curtailment_daily.png", dpi=300, bbox_inches='tight') + plt.close() + + # Plot 3: Monthly curtailment + print("3. Creating monthly curtailment plot...") + fig, ax = plt.subplots(figsize=(12, 6)) + months = [d.strftime('%b %Y') for d in curtailment_monthly.index] + ax.bar(months, curtailment_monthly.values, color='red', alpha=0.7) + ax.set_xlabel('Month', fontsize=12) + ax.set_ylabel('Monthly Curtailment (MWh)', fontsize=12) + ax.set_title('§14a Monthly Curtailment', fontsize=14, fontweight='bold') + ax.grid(True, alpha=0.3, axis='y') + plt.xticks(rotation=45, ha='right') + plt.tight_layout() + plt.savefig(f"{output_dir}/03_curtailment_monthly.png", dpi=300, bbox_inches='tight') + plt.close() + + # Plot 4: Top 10 curtailed units (HPs and CPs combined) + print("4. Creating top curtailed units plot...") + fig, ax = plt.subplots(figsize=(12, 6)) + all_curtailed_list = [] + if len(curtailed_hps) > 0: + all_curtailed_list.append(curtailed_hps) + if len(curtailed_cps) > 0: + all_curtailed_list.append(curtailed_cps) + + if len(all_curtailed_list) > 0: + all_curtailed = pd.concat(all_curtailed_list) + top10 = all_curtailed.sort_values(ascending=False).head(10) + unit_names = [name.replace('hp_14a_support_', 'HP: ').replace('cp_14a_support_', 'CP: ').replace('charging_point_14a_support_', 'CP: ') for name in top10.index] + colors = ['blue' if 'HP:' in name else 'green' for name in unit_names] + ax.barh(unit_names, top10.values, color=colors, alpha=0.7) + else: + ax.text(0.5, 0.5, 'No curtailed units found', ha='center', va='center', transform=ax.transAxes) + + ax.set_xlabel('Total Curtailment (MWh)', fontsize=12) + ax.set_ylabel('Unit', fontsize=12) + ax.set_title('Top 10 Curtailed Units (Heat Pumps & Charging Points)', fontsize=14, fontweight='bold') + ax.grid(True, alpha=0.3, axis='x') + ax.invert_yaxis() + plt.tight_layout() + plt.savefig(f"{output_dir}/04_top10_curtailed_hps.png", dpi=300, bbox_inches='tight') + plt.close() + + # Plot 5: Curtailment distribution (histogram) + print("5. Creating curtailment distribution plot...") + fig, ax = plt.subplots(figsize=(10, 6)) + curtailment_nonzero = curtailment_per_timestep[curtailment_per_timestep > 0] + ax.hist(curtailment_nonzero.values, bins=50, color='red', alpha=0.7, edgecolor='black') + ax.set_xlabel('Curtailment (MW)', fontsize=12) + ax.set_ylabel('Frequency', fontsize=12) + ax.set_title('Distribution of Non-Zero Curtailment Events', fontsize=14, fontweight='bold') + ax.grid(True, alpha=0.3, axis='y') + plt.tight_layout() + plt.savefig(f"{output_dir}/05_curtailment_distribution.png", dpi=300, bbox_inches='tight') + plt.close() + + # Plot 6: Detailed view of most curtailed HP + print("6. Creating detailed HP profile plot...") + most_curtailed = hp_curtailment_total.idxmax() + hp_original_name = most_curtailed.replace('hp_14a_support_', '') + + if hp_original_name in hp_loads.columns: + fig, axes = plt.subplots(2, 1, figsize=(16, 10)) + + # Full year + original_load = hp_loads[hp_original_name] + curtailment_power = curtailment[most_curtailed] + net_load = original_load - curtailment_power + + ax1 = axes[0] + ax1.plot(original_load.index, original_load.values, 'b-', linewidth=0.5, + label='Original Load', alpha=0.7) + ax1.plot(net_load.index, net_load.values, 'g-', linewidth=0.5, + label='Net Load (after curtailment)', alpha=0.7) + ax1.axhline(y=0.0042, color='orange', linestyle='--', linewidth=1, + label='§14a Minimum (4.2 kW)') + ax1.set_xlabel('Time', fontsize=12) + ax1.set_ylabel('Power (MW)', fontsize=12) + ax1.set_title(f'{hp_original_name} - Full Year Profile', fontsize=14, fontweight='bold') + ax1.grid(True, alpha=0.3) + ax1.legend() + + # Sample week (first week with curtailment) + curtailment_weeks = curtailment_power.resample('W').sum() + first_curtailment_week = curtailment_weeks[curtailment_weeks > 0].index[0] + week_start = first_curtailment_week + week_end = week_start + pd.Timedelta(days=7) + + ax2 = axes[1] + week_mask = (original_load.index >= week_start) & (original_load.index < week_end) + ax2.plot(original_load.index[week_mask], original_load.values[week_mask], + 'b-', marker='o', linewidth=2, label='Original Load', markersize=3) + ax2.plot(net_load.index[week_mask], net_load.values[week_mask], + 'g-', marker='s', linewidth=2, label='Net Load', markersize=3) + ax2.fill_between(original_load.index[week_mask], + net_load.values[week_mask], + original_load.values[week_mask], + alpha=0.3, color='red', label='Curtailed Power') + ax2.axhline(y=0.0042, color='orange', linestyle='--', linewidth=2, + label='§14a Minimum (4.2 kW)') + ax2.set_xlabel('Time', fontsize=12) + ax2.set_ylabel('Power (MW)', fontsize=12) + ax2.set_title(f'{hp_original_name} - Sample Week with Curtailment', + fontsize=14, fontweight='bold') + ax2.grid(True, alpha=0.3) + ax2.legend() + + plt.tight_layout() + plt.savefig(f"{output_dir}/detailed_hp_profiles.png", dpi=300, bbox_inches='tight') + plt.close() + + # Plot 3: Charging Point Analysis (if CPs were curtailed) + if 'curtailed_cps' in results and len(results['curtailed_cps']) > 0: + print("3. Creating detailed charging point profile plot...") + + cp_curtailment = results['cp_curtailment_data'] + cp_loads = results['cp_loads'] + curtailed_cps = results['curtailed_cps'] + + # Most curtailed CP detail + most_curtailed_cp = curtailed_cps.idxmax() + cp_original_name = most_curtailed_cp.replace('cp_14a_support_', '') + + if cp_original_name in cp_loads.columns: + fig, axes = plt.subplots(2, 1, figsize=(16, 10)) + + original_load = cp_loads[cp_original_name] + curtailment_power = cp_curtailment[most_curtailed_cp] + net_load = original_load - curtailment_power + + # Full period + ax1 = axes[0] + ax1.plot(original_load.index, original_load.values, 'b-', linewidth=0.5, + label='Original Load', alpha=0.7) + ax1.plot(net_load.index, net_load.values, 'g-', linewidth=0.5, + label='Net Load (after curtailment)', alpha=0.7) + ax1.axhline(y=0.0042, color='orange', linestyle='--', linewidth=1, + label='§14a Minimum (4.2 kW)') + ax1.set_xlabel('Time', fontsize=12) + ax1.set_ylabel('Power (MW)', fontsize=12) + ax1.set_title(f'{cp_original_name} - Full Period Profile', fontsize=14, fontweight='bold') + ax1.grid(True, alpha=0.3) + ax1.legend() + + # Sample week with curtailment + curtailment_weeks = curtailment_power.resample('W').sum() + first_curtailment_week = curtailment_weeks[curtailment_weeks > 0].index[0] if any(curtailment_weeks > 0) else curtailment_weeks.index[0] + week_start = first_curtailment_week + week_end = week_start + pd.Timedelta(days=7) + + ax2 = axes[1] + week_mask = (original_load.index >= week_start) & (original_load.index < week_end) + ax2.plot(original_load.index[week_mask], original_load.values[week_mask], + 'b-', marker='o', linewidth=2, label='Original Load', markersize=3) + ax2.plot(net_load.index[week_mask], net_load.values[week_mask], + 'g-', marker='s', linewidth=2, label='Net Load', markersize=3) + ax2.fill_between(original_load.index[week_mask], + net_load.values[week_mask], + original_load.values[week_mask], + alpha=0.3, color='orange', label='Curtailed Power') + ax2.axhline(y=0.0042, color='orange', linestyle='--', linewidth=2, + label='§14a Minimum (4.2 kW)') + ax2.set_xlabel('Time', fontsize=12) + ax2.set_ylabel('Power (MW)', fontsize=12) + ax2.set_title(f'{cp_original_name} - Sample Week', fontsize=14, fontweight='bold') + ax2.grid(True, alpha=0.3) + ax2.legend() + + plt.tight_layout() + plt.savefig(f"{output_dir}/detailed_cp_profiles.png", dpi=300, bbox_inches='tight') + plt.close() + + print(f"\n✓ All plots saved to {output_dir}/") + + +def main(): + # ============================================================================ + # CONFIGURATION - Edit these values directly + # ============================================================================ + + # Grid configuration + GRID_PATH = "../../30879" + SCENARIO = "eGon2035" + + # Simulation parameters + NUM_DAYS = 7 # Number of days to simulate (e.g., 7, 30, 365) + NUM_HEAT_PUMPS = 50 # Number of heat pumps to add + NUM_CHARGING_POINTS = 30 # Number of charging points to add + + # Output + OUTPUT_DIR = "./" + + # ============================================================================ + # END CONFIGURATION + # ============================================================================ + # Create directory name from configuration parameters + output_dir = f"{OUTPUT_DIR}/results_{NUM_DAYS}d_HP{NUM_HEAT_PUMPS}_CP{NUM_CHARGING_POINTS}_14a" + print(f"\n{'#'*80}") + print(f"# §14a EnWG Heat Pump Curtailment Analysis - {NUM_DAYS} Days") + print(f"{'#'*80}") + print(f"\nStarted at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + + try: + # Setup grid and load data + edisgo = setup_edisgo( + GRID_PATH, + scenario=SCENARIO, + num_hps=NUM_HEAT_PUMPS, + num_cps=NUM_CHARGING_POINTS, + num_days=NUM_DAYS + ) + + # Run optimization with §14a + edisgo = run_optimization_14a(edisgo) + + # Analyze results + results = analyze_curtailment_results(edisgo, output_dir=output_dir) + + if results: + # Create plots + create_plots(results, output_dir=output_dir) + + print(f"\n{'='*80}") + print(f"✓ Analysis Complete!") + print(f"{'='*80}") + print(f"\nResults saved to: {output_dir}/") + print(f" - summary_statistics.csv") + print(f" - curtailment_timeseries.csv") + print(f" - curtailment_daily.csv") + print(f" - curtailment_monthly.csv") + print(f" - hp_curtailment_total.csv") + print(f" - cp_curtailment_total.csv") + print(f" - curtailment_timeseries.png") + print(f" - detailed_hp_profiles.png") + print(f" - detailed_cp_profiles.png (if CPs curtailed)") + + print(f"\nCompleted at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + + except Exception as e: + print(f"\n❌ ERROR: {str(e)}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/examples/plot_example.ipynb b/examples/plot_example.ipynb index 4c71ecc1d..696c2a5a0 100644 --- a/examples/plot_example.ipynb +++ b/examples/plot_example.ipynb @@ -411,7 +411,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.16" + "version": "3.10.16" }, "toc": { "base_numbering": 1, diff --git a/tests/test_edisgo.py b/tests/test_edisgo.py index bbdbddfb5..96d4f4f1b 100755 --- a/tests/test_edisgo.py +++ b/tests/test_edisgo.py @@ -207,7 +207,7 @@ def test_set_time_series_active_power_predefined(self, caplog): # check warning self.edisgo.set_time_series_active_power_predefined() assert ( - "When setting time series using predefined profiles it is better" + "The EDisGo.TimeSeries.timeindex is empty. By default, this function" in caplog.text ) @@ -935,9 +935,9 @@ def test_aggregate_components(self): # ##### test without any aggregation - self.edisgo.topology._loads_df.at[ - "Load_residential_LVGrid_1_4", "bus" - ] = "Bus_BranchTee_LVGrid_1_10" + self.edisgo.topology._loads_df.at["Load_residential_LVGrid_1_4", "bus"] = ( + "Bus_BranchTee_LVGrid_1_10" + ) # save original values number_gens_before = len(self.edisgo.topology.generators_df) @@ -1055,9 +1055,9 @@ def test_aggregate_components(self): ) # manipulate grid so that more than one load of the same sector is # connected at the same bus - self.edisgo.topology._loads_df.at[ - "Load_residential_LVGrid_1_4", "bus" - ] = "Bus_BranchTee_LVGrid_1_10" + self.edisgo.topology._loads_df.at["Load_residential_LVGrid_1_4", "bus"] = ( + "Bus_BranchTee_LVGrid_1_10" + ) # save original values (only loads, as generators did not change) loads_p_set_before = self.edisgo.topology.loads_df.p_set.sum() @@ -1137,9 +1137,9 @@ def test_aggregate_components(self): # manipulate grid so that two generators of different types are # connected at the same bus - self.edisgo.topology._generators_df.at[ - "GeneratorFluctuating_13", "type" - ] = "misc" + self.edisgo.topology._generators_df.at["GeneratorFluctuating_13", "type"] = ( + "misc" + ) # save original values (values of loads were changed in previous aggregation) loads_p_set_before = self.edisgo.topology.loads_df.p_set.sum()