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..82a41b5d2 100755 --- a/edisgo/edisgo.py +++ b/edisgo/edisgo.py @@ -35,12 +35,16 @@ from edisgo.io.ding0_import import import_ding0_grid from edisgo.io.electromobility_import import ( distribute_charging_demand, + distribute_charging_demand_14a, import_electromobility_from_dir, import_electromobility_from_oedb, + import_electromobility_from_oedb_14a, integrate_charging_parks, + integrate_charging_parks_14a, ) 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 @@ -52,7 +56,7 @@ from edisgo.opf.results.opf_result_class import OPFResults from edisgo.tools import plots, tools from edisgo.tools.config import Config -from edisgo.tools.geo import find_nearest_bus +from edisgo.tools.geo import find_nearest_bus, find_nearest_bus_14a from edisgo.tools.spatial_complexity_reduction import spatial_complexity_reduction from edisgo.tools.tools import determine_grid_integration_voltage_level @@ -71,6 +75,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,23 +167,29 @@ 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) - self.import_ding0_grid( - path=kwargs.get("ding0_grid", None), - legacy_ding0_grids=kwargs.get("legacy_ding0_grids", True), - ) + if kwargs.get("ding0_grid", None) is not None: + self.import_ding0_grid( + path=kwargs.get("ding0_grid", None), + legacy_ding0_grids=kwargs.get("legacy_ding0_grids", True), + ) + elif kwargs.get("pypsa_csv_dir", None) is not None: + self.import_pypsa_csv(kwargs.get("pypsa_csv_dir"), + snapshot_range=kwargs.get("snapshot_range")) self.legacy_grids = kwargs.get("legacy_ding0_grids", True) - # instantiate other data classes self.results = Results(self) self.opf_results = OPFResults() - self.timeseries = timeseries.TimeSeries( - timeindex=kwargs.get("timeindex", pd.DatetimeIndex([])) - ) + if kwargs.get("pypsa_csv_dir", None) is None: + self.timeseries = timeseries.TimeSeries( + timeindex=kwargs.get("timeindex", pd.DatetimeIndex([])) + ) self.electromobility = Electromobility(edisgo_obj=self) self.heat_pump = HeatPump() self.dsm = DSM() @@ -418,12 +433,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 +548,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 +565,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( - "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 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( + "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 +835,8 @@ def to_powermodels( flexible_loads=None, flexible_storage_units=None, opf_version=1, + curtailment_14a=False, + hours_limit_14a=24 ): """ Convert eDisGo representation of the network topology and timeseries to @@ -822,6 +863,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 +884,8 @@ def to_powermodels( flexible_loads=flexible_loads, flexible_storage_units=flexible_storage_units, opf_version=opf_version, + curtailment_14a=curtailment_14a, + hours_limit_14a=hours_limit_14a, ) def pm_optimize( @@ -854,6 +902,8 @@ def pm_optimize( save_heat_storage=True, save_slack_gen=True, save_slacks=True, + curtailment_14a=False, + hours_limit_14a=24, ): """ Run OPF in julia subprocess and write results of OPF back to edisgo object. @@ -901,6 +951,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 +968,8 @@ def pm_optimize( method=method, warm_start=warm_start, silence_moi=silence_moi, + curtailment_14a=curtailment_14a, + hours_limit_14a=hours_limit_14a, ) def to_graph(self): @@ -972,9 +1029,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 +1407,7 @@ def reinforce( """ if copy_grid: - edisgo_obj = copy.deepcopy(self) + edisgo_obj = self.copy() else: edisgo_obj = self @@ -1752,6 +1807,91 @@ def integrate_component_based_on_geolocation( ) return comp_name + + def integrate_component_based_on_geolocation_14a( + self, + comp_type, + geolocation, + voltage_level=None, + add_ts=True, + ts_active_power=None, + ts_reactive_power=None, + **kwargs, + ): + """ + 14a variant of integrate_component_based_on_geolocation. + + Uses explicit _14a helper functions where custom behavior was introduced, + while leaving the original integrate_component_based_on_geolocation + unchanged for standard users. + """ + supported_voltage_levels = {4, 5, 6, 7} + p_nom = kwargs.get("p_nom", None) + p_set = kwargs.get("p_set", None) + + p = p_nom if p_set is None else p_set + kwargs["p"] = p + + if voltage_level not in supported_voltage_levels: + if p is None: + raise ValueError( + "Neither appropriate voltage level nor nominal power were supplied." + ) + voltage_level = determine_grid_integration_voltage_level(self, p) + + # convert geolocation to shapely Point if needed + if type(geolocation) is not Point: + geolocation = Point(geolocation) + + kwargs["geom"] = geolocation + kwargs["voltage_level"] = voltage_level + + # ------------------------- + # Connect in MV + # ------------------------- + if voltage_level in [4, 5]: + comp_name = self.topology.connect_to_mv_14a(self, kwargs, comp_type) + + # ------------------------- + # Connect in LV + # ------------------------- + else: + lv_buses = self.topology.buses_df.drop(self.topology.mv_grid.buses_df.index) + lv_buses_dropna = lv_buses.dropna(axis=0, subset=["x", "y"]) + + # fallback path for non-georeferenced LV grids + if len(lv_buses_dropna) < len(lv_buses): + if kwargs.get("mvlv_subst_id", None) is None: + substations = self.topology.buses_df.loc[ + self.topology.transformers_df.bus1.unique() + ] + nearest_substation, _ = find_nearest_bus_14a(geolocation, substations) + kwargs["mvlv_subst_id"] = int(nearest_substation.split("_")[-2]) + + comp_name = self.topology.connect_to_lv_14a(self, kwargs, comp_type) + + else: + max_distance_from_target_bus = kwargs.pop( + "max_distance_from_target_bus", 0.3 #CHANGED #14a + ) + comp_name = self.topology.connect_to_lv_based_on_geolocation_14a( + self, kwargs, comp_type, max_distance_from_target_bus + ) + + if add_ts: + if comp_type == "generator": + self.set_time_series_manual( + generators_p=pd.DataFrame({comp_name: ts_active_power}), + generators_q=pd.DataFrame({comp_name: ts_reactive_power}), + ) + else: + self.set_time_series_manual( + loads_p=pd.DataFrame({comp_name: ts_active_power}), + loads_q=pd.DataFrame({comp_name: ts_reactive_power}), + ) + + return comp_name + def remove_component(self, comp_type, comp_name, drop_ts=True): """ @@ -1921,9 +2061,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 +2104,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 +2115,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 +2176,7 @@ def import_electromobility( import_electromobility_from_oedb( self, scenario=scenario, - engine=engine, + engine=self.engine, **import_electromobility_data_kwds, ) elif data_source == "directory": @@ -2065,6 +2199,58 @@ def import_electromobility( integrate_charging_parks(self) + def import_electromobility_14a( + self, + data_source: str = "oedb", + scenario: str = None, + import_electromobility_data_kwds=None, + allocate_charging_demand_kwds=None, + ): + """ + 14a variant of import_electromobility. + + This method uses the specific 14a EV integration path. + """ + if data_source != "oedb": + raise ValueError( + "Invalid input for parameter 'data_source'. Currently only 'oedb' is supported." + ) + + valid_scenarios = {"eGon2035", "eGon100RE"} + if scenario not in valid_scenarios: + raise ValueError( + f"Invalid scenario '{scenario}'. Possible options are {sorted(valid_scenarios)}." + ) + + if self.engine is None: + raise ValueError( + "No database engine available. Please set 'self.engine' before calling " + "import_electromobility_14a()." + ) + + if import_electromobility_data_kwds is None: + import_electromobility_data_kwds = {} + + if "shapefile_path" not in import_electromobility_data_kwds: + raise ValueError( + "For import_electromobility_14a with data_source='oedb', " + "'shapefile_path' must be provided in import_electromobility_data_kwds." + ) + + if allocate_charging_demand_kwds is None: + allocate_charging_demand_kwds = {} + + import_electromobility_from_oedb_14a( + self, + scenario=scenario, + engine=self.engine, + **import_electromobility_data_kwds, + ) + + distribute_charging_demand_14a(self, **allocate_charging_demand_kwds) + + integrate_charging_parks_14a(self) + def apply_charging_strategy(self, strategy="dumb", **kwargs): """ Applies charging strategy to set EV charging time series at charging parks. @@ -2135,10 +2321,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 +2380,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 +2420,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 +2428,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 +2436,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 +2444,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 +2492,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 +2511,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 +2523,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 +2533,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 +2543,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 +2561,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 +2593,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 +3317,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 +3531,54 @@ 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_pypsa_csv(self, path, snapshot_range): + """ + Imports grid topology and timeseries from a PyPSA CSV export. + """ + # Lazy import to avoid circular dependencies + from edisgo.io.pypsa_csv_import import populate_edisgo_from_pypsa_csv + + if path is not None: + populate_edisgo_from_pypsa_csv(self, path, snapshot_range=snapshot_range) + 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..a73ff71d6 100644 --- a/edisgo/io/electromobility_import.py +++ b/edisgo/io/electromobility_import.py @@ -15,9 +15,12 @@ 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 +from edisgo.tools.loma_tools import _get_intersecting_mv_grid_ids_from_shapefile + if "READTHEDOCS" not in os.environ: import geopandas as gpd @@ -519,6 +522,16 @@ def distribute_charging_demand(edisgo_obj, **kwargs): distribute_private_charging_demand(edisgo_obj) distribute_public_charging_demand(edisgo_obj, **kwargs) + + +def distribute_charging_demand_14a(edisgo_obj, **kwargs): + """ + 14a variant of distribute_charging_demand. + + """ + distribute_private_charging_demand_14a(edisgo_obj) + + distribute_public_charging_demand_14a(edisgo_obj, **kwargs) def get_weights_df(edisgo_obj, potential_charging_park_indices, **kwargs): @@ -947,6 +960,134 @@ def distribute_private_charging_demand(edisgo_obj): raise ValueError(f"Destination {destination} is unknown.") +def distribute_private_charging_demand_14a(edisgo_obj): + """ + 14a variant of distribute_charging_demand. + + """ + try: + rng = default_rng(seed=edisgo_obj.topology.id) + except Exception: + rng = None + + private_charging_df = edisgo_obj.electromobility.charging_processes_df.loc[ + (edisgo_obj.electromobility.charging_processes_df.chargingdemand_kWh > 0) + & edisgo_obj.electromobility.charging_processes_df.use_case.isin( + ["home", "work"] + ) + ] + + charging_point_id = 0 + + user_centric_weights_df = get_weights_df( + edisgo_obj, edisgo_obj.electromobility.potential_charging_parks_gdf.index + ) + + designated_charging_point_capacity_df = pd.DataFrame( + index=user_centric_weights_df.index, + columns=["designated_charging_point_capacity"], + data=0.0, + dtype=float, + ) + + for destination in private_charging_df.destination.sort_values().unique(): + private_charging_destination_df = private_charging_df.loc[ + private_charging_df.destination == destination + ] + + use_case = PRIVATE_DESTINATIONS[destination] + + if use_case == "work": + potential_charging_park_indices = ( + edisgo_obj.electromobility.potential_charging_parks_gdf.loc[ + edisgo_obj.electromobility.potential_charging_parks_gdf.use_case + == use_case + ].index + ) + + for car_id in private_charging_destination_df.car_id.sort_values().unique(): + weights = combine_weights( + potential_charging_park_indices, + designated_charging_point_capacity_df, + user_centric_weights_df, + ) + + charging_park_id = weighted_random_choice( + edisgo_obj, + potential_charging_park_indices, + car_id, + destination, + charging_point_id, + weights, + rng=rng, + ) + + charging_capacity = ( + private_charging_destination_df.loc[ + (private_charging_destination_df.car_id == car_id) + & (private_charging_destination_df.destination == "0_work") + ].nominal_charging_capacity_kW.iat[0] + / edisgo_obj.electromobility.eta_charging_points + ) + + designated_charging_point_capacity_df.at[ + charging_park_id, "designated_charging_point_capacity" + ] += charging_capacity + + charging_point_id += 1 + + elif use_case == "home": + for ags in private_charging_destination_df.ags.sort_values().unique(): + private_charging_ags_df = private_charging_destination_df.loc[ + private_charging_destination_df.ags == ags + ] + + # fmt: off + potential_charging_park_indices = edisgo_obj.electromobility.\ + potential_charging_parks_gdf.loc[ + ( + edisgo_obj.electromobility.potential_charging_parks_gdf.ags + == ags + ) + & ( + edisgo_obj.electromobility.potential_charging_parks_gdf. + use_case == use_case + ) + ].index + # fmt: on + + for car_id in private_charging_ags_df.car_id.sort_values().unique(): + weights = combine_weights( + potential_charging_park_indices, + designated_charging_point_capacity_df, + user_centric_weights_df, + ) + + charging_park_id = weighted_random_choice( #changed14a + edisgo_obj, + potential_charging_park_indices, + car_id, + destination, + charging_point_id, + weights, + rng=rng, + ) + + charging_capacity = private_charging_destination_df.loc[ + (private_charging_destination_df.car_id == car_id) + & (private_charging_destination_df.destination == "6_home") + ].nominal_charging_capacity_kW.iat[0] + + designated_charging_point_capacity_df.at[ + charging_park_id, "designated_charging_point_capacity" + ] += charging_capacity + + charging_point_id += 1 + + else: + raise ValueError(f"Destination {destination} is unknown.") + + def distribute_public_charging_demand(edisgo_obj, **kwargs): """ Distributes all public charging processes. For each process it is @@ -1079,11 +1220,154 @@ 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" + ] += nominal_charging_capacity_kW + + +def distribute_public_charging_demand_14a(edisgo_obj, **kwargs): #CHANGED14a + """ + 14a variant of distribute_pubic_charging_demand. + + """ + #bessere sortierung für reproduzierbarkeit + public_charging_df = edisgo_obj.electromobility.charging_processes_df.loc[ + (edisgo_obj.electromobility.charging_processes_df.chargingdemand_kWh > 0) + & edisgo_obj.electromobility.charging_processes_df.use_case.isin( + ["public", "hpc"] + ) + ].sort_values( + by=[ + "park_start_timesteps", + "park_end_timesteps", + "nominal_charging_capacity_kW", + "car_id", + ], + ascending=[True, True, True, True], + ) + + try: + rng = default_rng(seed=edisgo_obj.topology.id) + except Exception: + rng = default_rng(seed=1) + + available_charging_points_df = pd.DataFrame( + columns=COLUMNS["available_charging_points_df"] + ) + + grid_and_user_centric_weights_df = get_weights_df( + edisgo_obj, + edisgo_obj.electromobility.potential_charging_parks_gdf.index, + **kwargs, + ) + + designated_charging_point_capacity_df = pd.DataFrame( + index=grid_and_user_centric_weights_df.index, + columns=["designated_charging_point_capacity"], + data=0.0, + dtype=float, + ) + + columns = [ + "destination", + "use_case", + "park_start_timesteps", + "park_end_timesteps", + "nominal_charging_capacity_kW", + ] + + for ( + idx, + destination, + use_case, + park_start_timesteps, + park_end_timesteps, + nominal_charging_capacity_kW, + ) in public_charging_df[columns].itertuples(): + matching_charging_points_df = available_charging_points_df.loc[ + (available_charging_points_df.park_end_timesteps < park_start_timesteps) + & ( + available_charging_points_df.nominal_charging_capacity_kW.round(1) + == round(nominal_charging_capacity_kW, 1) + ) + ] + + if len(matching_charging_points_df) > 0: + potential_charging_park_indices = matching_charging_points_df.index + + weights = normalize( + grid_and_user_centric_weights_df.loc[ + matching_charging_points_df.charging_park_id + ] + ) + + charging_point_s = matching_charging_points_df.loc[ + rng.choice(a=potential_charging_park_indices, p=weights) + ] + + edisgo_obj.electromobility.charging_processes_df.at[ + idx, "charging_park_id" + ] = charging_point_s["charging_park_id"] + + edisgo_obj.electromobility.charging_processes_df.at[ + idx, "charging_point_id" + ] = charging_point_s.name + + available_charging_points_df.at[ + charging_point_s.name, "park_end_timesteps" + ] = park_end_timesteps + + else: + potential_charging_park_indices = ( + edisgo_obj.electromobility.potential_charging_parks_gdf.loc[ + ( + edisgo_obj.electromobility.potential_charging_parks_gdf.use_case + == use_case + ) + ].index + ) + + weights = combine_weights( + potential_charging_park_indices, + designated_charging_point_capacity_df, + grid_and_user_centric_weights_df, + ) + + charging_park_id = rng.choice( + a=potential_charging_park_indices, + p=weights, + ) + + # fmt: off + charging_point_id = ( + edisgo_obj.electromobility.charging_processes_df.charging_point_id + .max() + + 1 + ) + # fmt: on + + if charging_point_id != charging_point_id: + charging_point_id = 0 + + edisgo_obj.electromobility.charging_processes_df.at[ + idx, "charging_park_id" + ] = charging_park_id + + edisgo_obj.electromobility.charging_processes_df.at[ + 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() + ) designated_charging_point_capacity_df.at[ charging_park_id, "designated_charging_point_capacity" @@ -1147,6 +1431,108 @@ def integrate_charging_parks(edisgo_obj): index=charging_park_ids, ) +def integrate_charging_parks_14a(edisgo_obj): + """ + 14a variant of integrate_charging_parks. + + """ + import time + from collections import defaultdict + + charging_parks = list(edisgo_obj.electromobility.potential_charging_parks) + + #temp1 + total_parks = len(charging_parks) + + parks_demand_gt_0 = [ + cp for cp in charging_parks + if cp.designated_charging_point_capacity > 0 + ] + parks_within_grid = [ + cp for cp in charging_parks + if cp.within_grid + ] + #temp2 + + designated_charging_parks = [ + cp + for cp in charging_parks + if (cp.designated_charging_point_capacity > 0) and cp.within_grid + ] + + #temp1 + print("\nCharging park filter check (14a)") + print("-" * 44) + print(f"{'Total parks in all mv_grid_ids':<30}{total_parks:>6}") + print(f"{'CP with Demand > 0':<30}{len(parks_demand_gt_0):>6}") + print(f"{'CP within grid':<30}{len(parks_within_grid):>6}") + print(f"{'Designated CP (both true)':<30}{len(designated_charging_parks):>6}") + #temp2 + + charging_park_ids = [] + edisgo_ids = [] + comp_type = "charging_point" + + stats = { + "integrate_component_based_on_geolocation_14a": {"count": 0, "total_time": 0.0}, + "parks": {"count": 0, "total_time": 0.0}, + } + voltage_level_counter = defaultdict(int) + + print("\nIntegrating charging parks (14a)") + print("-" * 44) + print(f"{'Designated parks':<30}{len(designated_charging_parks):>6}\n") + + for i, cp in enumerate(designated_charging_parks, start=1): + park_start = time.perf_counter() + try: + voltage_level_counter[cp.voltage_level] += 1 if hasattr(cp, "voltage_level") else 0 + + t0 = time.perf_counter() + edisgo_id = edisgo_obj.integrate_component_based_on_geolocation_14a( + comp_type=comp_type, + geolocation=cp.geometry, + sector=cp.use_case, + add_ts=False, + p_set=cp.grid_connection_capacity, + ) + dt_integrate = time.perf_counter() - t0 + + stats["integrate_component_based_on_geolocation_14a"]["count"] += 1 + stats["integrate_component_based_on_geolocation_14a"]["total_time"] += dt_integrate + + park_dt = time.perf_counter() - park_start + stats["parks"]["count"] += 1 + stats["parks"]["total_time"] += park_dt + + if edisgo_id is None: + continue + + charging_park_ids.append(cp.id) + edisgo_ids.append(edisgo_id) + + if i % 500 == 0: + avg_park = stats["parks"]["total_time"] / stats["parks"]["count"] + print(f"[{i:>5}/{len(designated_charging_parks)}] last={park_dt:8.4f}s | avg={avg_park:8.4f}s") + + except Exception as e: + print("\n!!!!!!!!!!!!!! FAILED PARK !!!!!!!!!!!!!!") + print("Park ID: ", cp.id) + print("Grid connection capacity [MVA]: ", cp.grid_connection_capacity) + print("Geometry: ", cp.geometry) + print("Exception: ", e) + print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n") + raise + + print("\nIntegration finished (14a)") + print("-" * 44) + print(f"{'Integrated parks':<30}{len(charging_park_ids):>6}") + + edisgo_obj.electromobility.integrated_charging_parks_df = pd.DataFrame( + columns=COLUMNS["integrated_charging_parks_df"], + data=edisgo_ids, + index=charging_park_ids, + ) def import_electromobility_from_oedb( edisgo_obj: EDisGo, @@ -1196,6 +1582,40 @@ def import_electromobility_from_oedb( ) +def import_electromobility_from_oedb_14a( + edisgo_obj: EDisGo, + scenario: str, + engine: Engine, + shapefile_path: str, + **kwargs, +): + """ + 14a variant of import_electromobility_from_oedb. + + """ + edisgo_obj.electromobility.charging_processes_df = charging_processes_from_oedb_14a( + edisgo_obj=edisgo_obj, + engine=engine, + scenario=scenario, + shapefile_path=shapefile_path, + **kwargs + ) + edisgo_obj.electromobility.simbev_config_df = simbev_config_from_oedb( + scenario=scenario, engine=engine + ) + potential_charging_parks_gdf = potential_charging_parks_from_oedb_14a( + edisgo_obj=edisgo_obj, + engine=engine, + shapefile_path=shapefile_path, + ) + edisgo_obj.electromobility.potential_charging_parks_gdf = ( + assure_minimum_potential_charging_parks( + edisgo_obj=edisgo_obj, + potential_charging_parks_gdf=potential_charging_parks_gdf, + **kwargs, + ) + ) + def simbev_config_from_oedb( scenario: str, engine: Engine, @@ -1283,6 +1703,77 @@ def potential_charging_parks_from_oedb( return gdf.assign(ags=0) +def potential_charging_parks_from_oedb_14a( + edisgo_obj: EDisGo, + engine: Engine, + shapefile_path: str | None = None, +): + """ + 14a variant of potential_charging_parks_from_oedb. + + Does not filter charging parks directly by shapefile geometry. + Instead, determine intersecting mv_grid_ids from the shapefile and then + load all charging parks belonging to those mv_grid_ids. + + Later on in integrate_charging_parks_14a only the charging parks that are + within_grid are kept. + """ + if shapefile_path is None: + raise ValueError("shapefile_path must be provided.") + + config = Config() + (egon_emob_charging_infrastructure,) = config.import_tables_from_oep( + engine, ["egon_emob_charging_infrastructure"], "grid" + ) + + crs = edisgo_obj.topology.grid_district["srid"] + + mv_grid_ids_in_grid = _get_intersecting_mv_grid_ids_from_shapefile( + engine=engine, + shapefile_path=shapefile_path, + ) + + if not mv_grid_ids_in_grid: + return gpd.GeoDataFrame( + columns=["use_case", "user_centric_weight", "mv_grid_id", "geometry", "ags"], + geometry="geometry", + crs=f"EPSG:{crs}", + ) + + with session_scope_egon_data(engine) as session: + srid = get_srid_of_db_table(session, egon_emob_charging_infrastructure.geometry) + + query = session.query( + egon_emob_charging_infrastructure.cp_id, + egon_emob_charging_infrastructure.use_case, + egon_emob_charging_infrastructure.weight.label("user_centric_weight"), + egon_emob_charging_infrastructure.mv_grid_id, + egon_emob_charging_infrastructure.geometry.label("geom"), + ).filter( + egon_emob_charging_infrastructure.mv_grid_id.in_(mv_grid_ids_in_grid) + ) + + gdf = gpd.read_postgis( + sql=query.statement, + con=session.bind, + geom_col="geom", + crs=f"EPSG:{srid}", + index_col="cp_id", + ) + + gdf = gdf.to_crs(crs).assign(ags=0) + + gdf = gdf.sort_values( + by=["use_case", "mv_grid_id", "user_centric_weight"], + ascending=[True, True, False], + kind="mergesort", + ) + + print( + f"Loaded {len(gdf)} charging parks from intersecting mv_grid_ids: {mv_grid_ids_in_grid}" + ) + + return gdf.sort_index() def charging_processes_from_oedb( edisgo_obj: EDisGo, engine: Engine, scenario: str, **kwargs @@ -1314,6 +1805,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" @@ -1367,10 +1861,121 @@ def charging_processes_from_oedb( # make sure count starts at 0 if df.park_start_timesteps.min() == 1: df.loc[:, ["park_start_timesteps", "park_end_timesteps"]] -= 1 - + return df.assign( ags=0, park_time_timesteps=df.park_end_timesteps - df.park_start_timesteps + 1, charging_park_id=np.nan, charging_point_id=np.nan, ).astype(DTYPES["charging_processes_df"]) + +def charging_processes_from_oedb_14a( + edisgo_obj: EDisGo, + engine: Engine, + scenario: str, + shapefile_path: str | None = None, + **kwargs, +): + """ + 14a variant of charging_processes_from_oedb. + + Uses the same intersecting mv_grid_ids as potential_charging_parks_from_oedb_14a. + """ + 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" + ) + + if shapefile_path: + mv_grid_ids_in_grid = _get_intersecting_mv_grid_ids_from_shapefile( + engine=engine, + shapefile_path=shapefile_path, + ) + print("INFO: mv_grid_ids_in_grid:", mv_grid_ids_in_grid) + else: + mv_grid_ids_in_grid = [edisgo_obj.topology.id] + + scenario_variation = {"eGon2035": "NEP C 2035", "eGon100RE": "Reference 2050"} + + with session_scope_egon_data(engine) as session: + query = session.query(egon_ev_mv_grid_district.egon_ev_pool_ev_id).filter( + egon_ev_mv_grid_district.scenario == scenario, + egon_ev_mv_grid_district.scenario_variation == scenario_variation[scenario], + egon_ev_mv_grid_district.bus_id.in_(mv_grid_ids_in_grid), + ) + pool_df = pd.read_sql(sql=query.statement, con=engine) + + if pool_df.empty: + print("WARNING: EV-Pool für die gewählten MV-Grids ist leer!") + + pool = Counter(pool_df.egon_ev_pool_ev_id) + + with session_scope_egon_data(engine) as session: + query = session.query( + egon_ev_trip.egon_ev_pool_ev_id.label("car_id"), + egon_ev_trip.use_case, + egon_ev_trip.location.label("destination"), + egon_ev_trip.charging_capacity_nominal.label("nominal_charging_capacity_kW"), + egon_ev_trip.charging_capacity_grid.label("grid_charging_capacity_kW"), + egon_ev_trip.charging_demand.label("chargingdemand_kWh"), + egon_ev_trip.park_start.label("park_start_timesteps"), + egon_ev_trip.park_end.label("park_end_timesteps"), + ).filter( + egon_ev_trip.scenario == scenario, + egon_ev_trip.egon_ev_pool_ev_id.in_(pool.keys()), + ) + + if kwargs.get("mode_parking_times", "frugal") == "frugal": + query = query.filter(egon_ev_trip.charging_demand > 0) + + ev_trips_df = pd.read_sql(sql=query.statement, con=engine) + + ev_trips_df = ev_trips_df.sort_values( + by=[ + "car_id", + "park_start_timesteps", + "park_end_timesteps", + "use_case", + "destination", + "nominal_charging_capacity_kW", + ], + ascending=[True, True, True, True, True, True], + kind="mergesort", + ).reset_index(drop=True) + + print("INFO: Unique car_ids:", ev_trips_df.car_id.nunique()) + + # duplicate EVs that were chosen more than once from EV pool + df_list = [] + last_id = 0 + n_max = max(pool.values()) if pool else 0 + + for i in range(n_max, 0, -1): + evs = sorted([ev_id for ev_id, count in pool.items() if count >= i]) + df = ev_trips_df.loc[ev_trips_df.car_id.isin(evs)].copy() + mapping = {ev: count + last_id for count, ev in enumerate(evs)} + df.car_id = df.car_id.map(mapping) + last_id = max(mapping.values()) + 1 + df_list.append(df) + + if not df_list: + return pd.DataFrame(columns=COLUMNS["charging_processes_df"] + ["charging_park_id", "charging_point_id"]).astype( + {**DTYPES["charging_processes_df"]} + ) + + df = pd.concat(df_list, ignore_index=True) + + if df.park_start_timesteps.min() == 1: + df.loc[:, ["park_start_timesteps", "park_end_timesteps"]] -= 1 + + print("INFO: Final car_ids:", df.car_id.nunique()) + + return df.assign( + ags=0, + park_time_timesteps=df.park_end_timesteps - df.park_start_timesteps + 1, + charging_park_id=np.nan, + charging_point_id=np.nan, + ).astype(DTYPES["charging_processes_df"]) \ No newline at end of file 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..06d0ffa31 100644 --- a/edisgo/io/powermodels_io.py +++ b/edisgo/io/powermodels_io.py @@ -31,44 +31,164 @@ def to_powermodels( flexible_loads=None, flexible_storage_units=None, opf_version=1, + curtailment_14a=None, + hours_limit_14a: int = 24, ): """ - Convert eDisGo representation of the network topology and timeseries to - PowerModels network data format. + Convert eDisGo network to PowerModels dictionary format via 3-stage pipeline. + + This function performs a multi-stage conversion to transform eDisGo's network + representation into a format suitable for Julia-based PowerModels optimization: + + **Conversion Pipeline:** + + 1. **eDisGo → PyPSA** (via :meth:`edisgo.EDisGo.to_pypsa`) + - Converts pandas DataFrame topology to PyPSA network objects + - Aggregates parallel transformers + - Calculates per-unit values and dependent parameters + + 2. **PyPSA → PyPower** (via internal helper functions) + - Converts PyPSA's DataFrame format to numpy arrays + - Uses PyPower's standard indexing (idx_bus, idx_gen, idx_branch) + - Creates multi-period optimization structure + + 3. **PyPower → PowerModels** (via :func:`pypsa2ppc` and :func:`ppc2pm`) + - Converts numpy arrays to nested dictionaries (JSON-serializable) + - Changes from 0-indexed (Python) to 1-indexed (Julia) + - Adds PowerModels metadata (per_unit flag, baseMVA, source_version) + + **Why PyPower as Intermediate Format?** + + - PyPSA uses pandas DataFrames (not JSON-serializable) + - PyPower uses standardized numpy array structure for power systems + - PowerModels expects nested dictionaries with 1-based indexing + - PyPower serves as a well-defined bridge between these formats + + **§14a EnWG Virtual Generator Creation:** + + If `curtailment_14a` is provided, virtual generators are created for heat pumps + and charging points to model §14a curtailment capability: + + - Each eligible HP/CP (>4.2 kW) gets a virtual generator at the same bus + - Virtual generator "produces" power to reduce net electrical load + - Example: 10 kW HP with 4.2 kW minimum → 5.8 kW virtual generator + - Julia constraints enforce: net_load ≥ 4.2 kW when curtailment active Parameters ---------- edisgo_object : :class:`~.EDisGo` + eDisGo network object containing topology and timeseries. s_base : int Base value of apparent power for per unit system. Default: 1 MVA. flexible_cps : :numpy:`numpy.ndarray` or None - Array containing all charging points that allow for flexible charging. + Charging points allowing flexible charging (optimization variable `pcp`). + If None, all CPs have fixed load profiles. + Default: None. flexible_hps : :numpy:`numpy.ndarray` or None - Array containing all heat pumps that allow for flexible operation due to an - attached heat storage. + Heat pumps with heat storage allowing flexible operation (optimization + variable `php`). If None, all HPs follow heat demand / COP. + Default: None. flexible_loads : :numpy:`numpy.ndarray` or None - Array containing all flexible loads that allow for application of demand side - management strategy. + Loads allowing demand-side management (DSM) strategy (optimization variable + `pdsm`). If None, all loads have fixed profiles. + Default: None. flexible_storage_units : :numpy:`numpy.ndarray` or None - Array containing all flexible storages. Non-flexible storage units operate to - optimize self consumption. + Storage units with optimization control (optimization variable `ps`). + Non-flexible storage units optimize self-consumption only. Default: None. opf_version : int - 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`. + Optimization variant selecting constraints and objective function: + + - **1**: Minimize line losses + max line loading (no grid restrictions) + - **2**: Minimize line losses + slacks (with voltage/current limits) + - **3**: Minimize line loading + HV slacks (overlying grid requirements) + - **4**: Minimize losses + HV slacks + grid slacks + - **5**: §14a only (HPs/CPs fixed, only §14a curtailment allowed) + + For details see :func:`edisgo.opf.powermodels_opf.pm_optimize`. Default: 1. + curtailment_14a : dict, bool, or None + §14a EnWG curtailment settings controlling mandatory load reduction: + + - If **dict**, keys are: + + - 'min_power_mw': float, minimum power during curtailment (default: 0.0042 = 4.2 kW) + - 'max_hours_per_day': float, daily time budget (default: 2.0 hours) + - 'components': list of str, specific HPs/CPs to curtail (default: [] = all eligible) + + - If **True**: Uses default settings (4.2 kW min, 2 hours/day, all components) + - If **False** or **None**: No §14a curtailment + + Default: None. Returns ------- - (dict, dict) - First dictionary contains all network data in PowerModels network data - format. Second dictionary contains time series of HV requirement for each - flexibility retrieved from overlying_grid component of edisgo object and - reduced by non-flexible components. + tuple of (dict, dict) + **pm** : dict + PowerModels network dictionary (JSON-serializable) with structure: + + - 'name': str, network identifier + - 'per_unit': bool, always True + - 'baseMVA': float, base power (equals s_base) + - 'bus': dict, buses with voltage bounds + - 'gen': dict, generators including virtual §14a generators + - 'load': dict, loads with timeseries + - 'branch': dict, lines and transformers + - 'nw': dict, multi-period network data (one entry per timestep) + - 'gen_hp_14a': dict, virtual generators for HP §14a support (if enabled) + - 'gen_cp_14a': dict, virtual generators for CP §14a support (if enabled) + - 'heatpumps': dict, heat pump parameters and heat demand + - 'electromobility': dict, charging point parameters + + **hv_flex_dict** : dict + HV grid flexibility timeseries for validation. Contains time series of + overlying grid requirements for each flexibility type, reduced by + non-flexible components. + + Notes + ----- + **Index Conventions:** + + - **eDisGo/PyPSA**: String-based component names (e.g., "HP_123", "Bus_456") + - **PyPower**: 0-indexed integer arrays (bus[0], gen[0], branch[0]) + - **PowerModels/Julia**: 1-indexed dictionary keys ("1", "2", "3") + + **Critical for §14a:** + + - gen_hp_14a["hp_index"] references position in pm["heatpumps"] dict (1-indexed) + - gen_cp_14a["cp_index"] references position in pm["electromobility"] or pm["load"] dict + - Misaligned indices cause Julia constraint failures (variable not found error) + - Validation warnings are logged if indices are out of range + + See Also + -------- + from_powermodels : Converts optimization results back to eDisGo format + pypsa2ppc : Stage 1 - PyPSA to PyPower conversion + ppc2pm : Stage 2 - PyPower to PowerModels conversion + _build_gen_hp_14a_support : Creates virtual generators for HP §14a curtailment + _build_gen_cp_14a_support : Creates virtual generators for CP §14a curtailment + + Examples + -------- + Convert network with §14a curtailment enabled: + + >>> curtailment_settings = { + ... 'min_power_mw': 0.0042, # 4.2 kW minimum + ... 'max_hours_per_day': 2.0, # 2 hours/day max + ... 'components': [] # All eligible HPs/CPs + ... } + >>> pm, hv_flex = to_powermodels( + ... edisgo_obj, + ... flexible_hps=hp_names, + ... opf_version=5, + ... curtailment_14a=curtailment_settings + ... ) + >>> # pm now contains gen_hp_14a with virtual generators + >>> print(len(pm['gen_hp_14a'])) # Number of HPs eligible for §14a """ - if opf_version in [2, 3, 4]: + if opf_version in [2, 3, 4, 5]: opf_flex = ["curt"] else: opf_flex = [] @@ -90,6 +210,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() @@ -103,7 +224,7 @@ def to_powermodels( psa_net.lines.costs_earthworks + psa_net.lines.costs_cable ) # aggregate parallel transformers - aggregate_parallel_transformers(psa_net) + #aggregate_parallel_transformers(psa_net) psa_net.transformers.capital_cost = edisgo_object.config._data[ "costs_transformers" ]["lv"] @@ -118,6 +239,31 @@ 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") + # CRITICAL: Also add "hp" so from_powermodels writes optimized php + # back to loads_active_power. Without this, loads_active_power + # retains original values which may differ from Julia's pd/cop, + # causing incorrect net load calculations in post-processing. + if "hp" not in opf_flex: + opf_flex.append("hp") + + # 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") + # NOTE: Do NOT add "cp" to opf_flex here. CPs without electromobility + # flexibility bands stay in pm["load"] dict (not pm["electromobility"]). + # There is no pcp variable to write back. Only p_cp14a (virtual gen) changes. + pm["flexibilities"] = opf_flex logger.info("Transforming busses into PowerModels dictionary format.") _build_bus(psa_net, edisgo_object, pm, flexible_storage_units) @@ -141,31 +287,91 @@ 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, hours_limit_14a + ) + + # 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, hours_limit_14a + ) + 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) if len(psa_net.loads) > 0: logger.info("Transforming loads into PowerModels dictionary format.") + # Use hps_for_pm instead of flexible_hps to exclude HPs from load dict + # when they're already in the heatpumps dict (prevents double-counting + # in Julia power balance which sums BOTH bus_pd from loads AND php from heatpumps) _build_load( edisgo_object, psa_net, pm, s_base, flexible_cps, - flexible_hps, + hps_for_pm if len(hps_for_pm) > 0 else flexible_hps, flexible_storage_units, ) else: logger.warning("No loads found in network.") + + # Post-build validation: verify §14a CP references exist in load dict + # (must run AFTER _build_load populates pm["load"]) + if "gen_cp_14a" in pm and len(pm["gen_cp_14a"]) > 0: + for gen_id, gen_data in pm["gen_cp_14a"].items(): + cp_idx = gen_data["cp_index"] + cp_name = gen_data["cp_name"] + in_electromobility = str(cp_idx) in pm.get("electromobility", {}) + cp_in_loads = any( + load.get("name") == cp_name for load in pm.get("load", {}).values() + ) + if not in_electromobility and not cp_in_loads: + logger.warning( + f"§14a validation: CP '{cp_name}' (cp_index={cp_idx}) not found in " + f"electromobility ({len(pm.get('electromobility', {}))} entries) or " + f"load dict ({len(pm.get('load', {}))} entries). " + f"Julia constraint_cp_14a_min_net_load may fail." + ) + if (opf_version == 3) | (opf_version == 4): if edisgo_object.overlying_grid.heat_pump_central_active_power.isna().iloc[0]: edisgo_object.overlying_grid.heat_pump_central_active_power[:] = 0 @@ -212,13 +418,14 @@ def to_powermodels( logger.info( "Transforming components timeseries into PowerModels dictionary format." ) + # Use hps_for_pm for timeseries too (must match _build_load exclusion) _build_timeseries( psa_net, pm, edisgo_object, s_base, flexible_cps, - flexible_hps, + hps_for_pm if len(hps_for_pm) > 0 else flexible_hps, flexible_loads, flexible_storage_units, opf_flex, @@ -234,21 +441,98 @@ def from_powermodels( s_base=1, ): """ - Convert results from optimization in PowerModels network data format to eDisGo data - format and updates timeseries values of flexibilities on eDisGo object. + Convert PowerModels optimization results back to eDisGo format and update timeseries. + + This function performs the reverse transformation of :func:`to_powermodels`, extracting + optimized flexibility schedules from Julia's PowerModels results and writing them to + the eDisGo object's timeseries. + + **Data Flow:** + + 1. **Load Results:** Parse PowerModels JSON dict (from Julia stdout) + 2. **Index Unwrapping:** Convert 1-indexed Julia results → 0-indexed Python arrays + 3. **Extract Schedules:** For each flexibility type, extract optimized power schedules + 4. **§14a Handling:** Virtual generator output (gen_hp_14a, gen_cp_14a) represents + curtailment and is written to generators_active_power with positive values + 5. **Update Timeseries:** Write schedules to edisgo_object.timeseries + + **Flexibility Types Processed:** + + - **curt**: Generator curtailment (renewable generation reduction) + - **hp**: Heat pump electrical power (optimized schedule) + - **cp**: Charging point power (EV charging schedule) + - **storage**: Battery storage charge/discharge + - **dsm**: Demand-side management load shifting + - **hp_14a**: §14a virtual generator support for heat pumps (curtailment) + - **cp_14a**: §14a virtual generator support for charging points (curtailment) + + **§14a Result Handling:** + + Virtual generators for §14a curtailment are treated differently: + - They appear in results as generators (gen_hp_14a, gen_cp_14a) + - Power output represents how much load was reduced + - Written to edisgo_object.timeseries.generators_active_power (not loads) + - Example: gen_hp_14a produces 2 kW → HP load reduced by 2 kW from original Parameters ---------- edisgo_object : :class:`~.EDisGo` + eDisGo network object to update with optimization results. pm_results : dict or str - Dictionary or path to json file that contains all optimization results in - PowerModels network data format. + PowerModels optimization results. Either: + + - **dict**: Direct PowerModels result dictionary from Julia + - **str**: Path to JSON file containing results + + Expected structure: {'nw': {timestep: {flex_type: {component_id: {variable: value}}}}} hv_flex_dict : dict - Dictionary containing time series of HV requirement for each flexibility - retrieved from overlying grid component of edisgo object. + HV grid flexibility timeseries from overlying grid component. Used for + validation and HV requirement constraints. s_base : int - Base value of apparent power for per unit system. + Base value of apparent power for per unit system (must match to_powermodels). Default: 1 MVA. + + Raises + ------ + ValueError + If pm_results is neither dict nor valid file path. + InfeasibleModelError + If Julia optimization failed (no 'solve_time' in results). + + Notes + ----- + **Index Convention:** + + - PowerModels/Julia: 1-indexed dicts with string keys ("1", "2", "3") + - eDisGo/Python: 0-indexed pandas DataFrames with component names as index + + **Validation:** + + - Checks for 'solve_time' key to verify Julia process succeeded + - Logs warnings if expected flexibility types are missing + - Skips missing flexibility types gracefully (continues with others) + + **§14a Virtual Generator Variable Name:** + + - gen_hp_14a and gen_cp_14a use variable "p" (not "php14a"/"pcp14a") + - This is because they are actual generators in PowerModels, not modified loads + - See flex_dicts mapping (lines 442-450) + + See Also + -------- + to_powermodels : Converts eDisGo network to PowerModels format + pm_optimize : Runs Julia optimization and calls this function + + Examples + -------- + Process optimization results from Julia: + + >>> pm_results = {'nw': {...}, 'solve_time': 12.5, 'status': 'Optimal'} + >>> from_powermodels(edisgo_obj, pm_results, hv_flex_dict, s_base=1) + >>> # edisgo_obj.timeseries now contains optimized schedules + >>> curtailment = edisgo_obj.timeseries.generators_active_power[['hp_14a_support_HP1']] + >>> print(curtailment.sum()) # Total HP curtailment in MWh + """ if type(pm_results) == str: with open(pm_results) as f: @@ -271,20 +555,51 @@ 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 } + # Extract timesteps and sort them (Julia uses 1-indexed string keys: "1", "2", "3", ...) timesteps = pd.Series([int(k) for k in pm["nw"].keys()]).sort_values().values logger.info("Writing OPF results to eDisGo object.") - # write active power OPF results to edisgo object + + # Process each flexibility type and extract optimized schedules + # ============================================================ + # Loop through all flexibility types that were optimized (stored in pm_results['nw']['1']['flexibilities']) + # For each type, extract the power schedules across all timesteps and write to eDisGo timeseries for flexibility in pm_results["nw"]["1"]["flexibilities"]: + # Map flexibility type to PowerModels dict name and variable name + # Example: "hp_14a" → flex="gen_hp_14a", variable="p" flex, variable = flex_dicts[flexibility] + + # Validate that flexibility exists in both network definition and results + if flex not in pm["nw"]["1"]: + logger.warning( + f"Flexibility '{flex}' not found in network definition (pm['nw']['1']), skipping." + ) + continue + + # Get component names from network definition + # These will become column names in the result DataFrame names = [ pm["nw"]["1"][flex][flex_comp]["name"] for flex_comp in list(pm["nw"]["1"][flex].keys()) ] - # replace storage power values by branch power values of virtual branch to - # account for losses + + # Validate that results exist for this flexibility type + if flex not in pm_results["nw"]["1"]: + logger.warning( + f"Flexibility '{flex}' not found in optimization results (pm_results['nw']['1']), skipping. " + f"Available: {list(pm_results['nw']['1'].keys())}" + ) + continue + + # Extract power schedules for all timesteps + # ========================================== + # Storage: Use branch power to account for losses (virtual branch connects storage to bus) + # Others: Use component variable directly (php, pcp, pdsm, pgc, p) if flex == "storage": + # Storage uses virtual branch power (accounts for charge/discharge losses) branches = [ pm["nw"]["1"][flex][flex_comp]["virtual_branch"] for flex_comp in list(pm["nw"]["1"][flex].keys()) @@ -297,19 +612,62 @@ def from_powermodels( for t in timesteps ] else: + # All other flexibilities: Extract variable directly from results + # Convert from per-unit to MW by multiplying with s_base 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 ] results = pd.DataFrame(index=timesteps, columns=names, data=data) - if (flex == "gen_nd") & (pm["nw"]["1"]["opf_version"] in [3, 4]): + if (flex == "gen_nd") & (pm["nw"]["1"]["opf_version"] in [2, 3, 4, 5]): 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 + #implement 14a-gens in topology dataframe + for gen_name in names: + load_name = gen_name.replace("hp_14a_support_", "") + bus_name = edisgo_object.topology.loads_df.loc[load_name].bus + if gen_name not in edisgo_object.topology.generators_df.index: + edisgo_object.topology.generators_df.loc[gen_name] = { + "type": "hp_14a", + "p_nom": results[gen_name].max(), + "bus": bus_name, + "control": "PQ", + } + print(f"{len(names)} 14a generators for HPs has been implemented ✓") + 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!") + # Also reduce corresponding charging point loads by the curtailment amount + #implement 14a-gens in topology dataframe + for gen_name in names: + load_name = gen_name.replace("cp_14a_support_", "") + bus_name = edisgo_object.topology.loads_df.loc[load_name].bus + if gen_name not in edisgo_object.topology.generators_df.index: + edisgo_object.topology.generators_df.loc[gen_name] = { + "type": "cp_14a", + "p_nom": results[gen_name].max(), + "bus": bus_name, + "control": "PQ", + } + + print(f"{len(names)} 14a generators for CPs has been implemented ✓") elif flex in ["heatpumps", "electromobility"]: edisgo_object.timeseries._loads_active_power.loc[:, names] = results[ names @@ -340,8 +698,7 @@ def from_powermodels( columns=names, data=results[names].values, ), - ) - + ) # calculate corresponding reactive power values edisgo_object.set_time_series_reactive_power_control() @@ -403,55 +760,57 @@ def from_powermodels( edisgo_object.opf_results.slack_generator_t = df # save internal battery storage variable to edisgo object - df = _result_df( - pm, - "storage", - "ps", - timesteps, - edisgo_object.timeseries.timeindex, - s_base, - ) - edisgo_object.opf_results.battery_storage_t.p = df + # Version 5 doesn't have storage flexibilities - skip reading + if pm["nw"]["1"]["opf_version"] != 5: + df = _result_df( + pm, + "storage", + "ps", + timesteps, + edisgo_object.timeseries.timeindex, + s_base, + ) + edisgo_object.opf_results.battery_storage_t.p = df - df = _result_df( - pm, - "storage", - "se", - timesteps, - edisgo_object.timeseries.timeindex, - s_base, - ) - edisgo_object.opf_results.battery_storage_t.e = df - # save heat storage variables to edisgo object - df = _result_df( - pm, - "heat_storage", - "phs", - timesteps, - edisgo_object.timeseries.timeindex, - s_base, - ) - edisgo_object.opf_results.heat_storage_t.p = df - df = _result_df( - pm, - "heat_storage", - "hse", - timesteps, - edisgo_object.timeseries.timeindex, - s_base, - ) - edisgo_object.opf_results.heat_storage_t.e = df - df = _result_df( - pm, - "heat_storage", - "phss", - timesteps, - edisgo_object.timeseries.timeindex, - s_base, - ) - edisgo_object.opf_results.heat_storage_t.p_slack = df + df = _result_df( + pm, + "storage", + "se", + timesteps, + edisgo_object.timeseries.timeindex, + s_base, + ) + edisgo_object.opf_results.battery_storage_t.e = df + # save heat storage variables to edisgo object + df = _result_df( + pm, + "heat_storage", + "phs", + timesteps, + edisgo_object.timeseries.timeindex, + s_base, + ) + edisgo_object.opf_results.heat_storage_t.p = df + df = _result_df( + pm, + "heat_storage", + "hse", + timesteps, + edisgo_object.timeseries.timeindex, + s_base, + ) + edisgo_object.opf_results.heat_storage_t.e = df + df = _result_df( + pm, + "heat_storage", + "phss", + timesteps, + edisgo_object.timeseries.timeindex, + s_base, + ) + edisgo_object.opf_results.heat_storage_t.p_slack = df - if pm["nw"]["1"]["opf_version"] in [2, 4]: + if pm["nw"]["1"]["opf_version"] in [2, 4, 5]: slacks = [ ("gen", "pgens"), ("gen_nd", "pgc"), @@ -460,11 +819,24 @@ def from_powermodels( ("heatpumps", "phps"), ("heatpumps", "phps2"), ] + + # Track total slack usage for version 5 warning + total_slack_usage = 0.0 + slack_details = [] + for comp, var in slacks: # save slacks to edisgo object df = _result_df( pm, comp, var, timesteps, edisgo_object.timeseries.timeindex, s_base ) + + # For version 5: Check if any slacks are used (they should be ~0) + if pm["nw"]["1"]["opf_version"] == 5: + slack_sum = df.abs().sum().sum() + if slack_sum > 1e-6: # Tolerance for numerical noise + total_slack_usage += slack_sum + slack_details.append(f"{comp}/{var}: {slack_sum:.6f} MW") + if comp == "gen": edisgo_object.opf_results.grid_slacks_t.gen_d_crt = df elif comp == "gen_nd": @@ -479,6 +851,15 @@ def from_powermodels( elif var == "phps2": edisgo_object.opf_results.grid_slacks_t.hp_operation_slack = df + # Version 5: Warn if slacks were used (indicates §14a was insufficient) + if pm["nw"]["1"]["opf_version"] == 5 and total_slack_usage > 1e-6: + logger.warning( + f"§14a OPF (version 5): Feasibility slacks were used! " + f"This means §14a curtailment alone was INSUFFICIENT to maintain grid limits. " + f"Total slack usage: {total_slack_usage:.6f} MW. " + f"Details: {', '.join(slack_details)}" + ) + # save line flows and currents to edisgo object for variable in ["pf", "qf", "ccm"]: df = _result_df( @@ -523,6 +904,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 +920,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, }, } @@ -582,6 +967,7 @@ def _build_bus(psa_net, edisgo_obj, pm, flexible_storage_units): "base_kv": psa_net.buses.v_nom.iloc[bus_i], "grid_level": grid_level[psa_net.buses.v_nom.iloc[bus_i]], } + # add virtual busses for storage units for stor_i in np.arange(len(flexible_storage_units)): idx_bus = _mapping( @@ -604,6 +990,7 @@ def _build_bus(psa_net, edisgo_obj, pm, flexible_storage_units): "base_kv": psa_net.buses.v_nom.iloc[idx_bus - 1], "grid_level": grid_level[psa_net.buses.v_nom.iloc[idx_bus - 1]], } + def _build_gen(edisgo_obj, psa_net, pm, flexible_storage_units, s_base): @@ -999,7 +1386,7 @@ def _build_battery_storage( s_base : int Base value of apparent power for per unit system. opf_version : int - Version of optimization models to choose from. Must be one of [1, 2, 3, 4]. + Version of optimization models to choose from. Must be one of [1, 2, 3, 4, 5]. For more information see :func:`edisgo.opf.powermodels_opf.pm_optimize`. """ @@ -1236,6 +1623,255 @@ 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, hours_limit_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 from dict + # NOTE: "max_power_mw" is LEGACY naming (now means minimum power during curtailment) + # Correct key is "min_power_mw" but we fall back to legacy key for backwards compatibility + # §14a EnWG requirement: Devices must maintain at least 4.2 kW when curtailed + p_min_14a = curtailment_14a.get("min_power_mw", curtailment_14a.get("max_power_mw", 0.0042)) # MW + max_hours_per_day = curtailment_14a.get("max_hours_per_day", hours_limit_14a) # hours per day + specific_components = curtailment_14a.get("components", []) # empty list = all eligible HPs + + # 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 + # ============================================================ + # Reasoning: A 3 kW heat pump cannot be curtailed to 4.2 kW minimum + # Including such HPs would create infeasible optimization constraints: + # constraint: php - p_hp14a >= 4.2 kW + # but php_max = 3 kW → infeasible even with p_hp14a = 0 + # Solution: Exclude small HPs from §14a support (they keep original load profile) + 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"] + # ============================================================ + # CRITICAL: This mapping must EXACTLY match the heatpumps dict creation order + # + # The heatpumps dict is built by iterating over flexible_hps with indices 1, 2, 3, ... + # (see _build_heat_pump() function which uses enumerate(flexible_hps)) + # + # Julia constraints reference: php = PowerModels.var(pm, nw, :php, hp_idx) + # where hp_idx comes from gen_hp_14a["hp_index"] + # + # If this mapping is WRONG: + # - Julia will try to access non-existent variable index → KeyError + # - Or worse: reference wrong HP → incorrect curtailment constraints + # + # Validation added below catches mismatches and logs warnings. + 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 + + if p_nominal > 0.011: + p_min_14a = p_nominal * 0.40 + else: + p_min_14a = 0.0042 + + + # Maximum support = difference between nominal and §14a limit + # ============================================================ + # This is how much the virtual generator can "produce" to reduce net load + # + # Example: 10 kW heat pump with 4.2 kW §14a minimum + # - Original load: 10 kW + # - §14a minimum: 4.2 kW + # - Maximum curtailment: 10 - 4.2 = 5.8 kW + # - Virtual generator: 0 to 5.8 kW (reduces net load from 10 → 4.2 kW) + # - for HP's bigger than 11 kW the load can be reduced up to 40 % of the nominal capacity + # Julia constraint enforces: net_load = original_load - virtual_gen >= 4.2 kW + # Guaranteed > 0 because hps_eligible filtered out HPs with p_nominal <= p_min_14a + p_max_support = p_nominal - p_min_14a + + 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, + } + + # Validation: Check that all hp_index references exist in heatpumps dict + # This catches index mapping errors that would cause Julia constraint failures + for gen_id, gen_data in pm["gen_hp_14a"].items(): + hp_idx = gen_data["hp_index"] + hp_name = gen_data["hp_name"] + if str(hp_idx) not in pm.get("heatpumps", {}): + logger.warning( + f"§14a validation: hp_index {hp_idx} for {hp_name} not found in " + f"heatpumps dict (has {len(pm.get('heatpumps', {}))} entries). " + f"This may cause Julia constraint failures." + ) + + +def _build_gen_cp_14a_support(psa_net, pm, edisgo_obj, s_base, all_cps, curtailment_14a, hours_limit_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 from dict (same as for HPs) + # NOTE: "max_power_mw" is LEGACY naming (now means minimum power during curtailment) + # Correct key is "min_power_mw" but we fall back to legacy key for backwards compatibility + # §14a EnWG requirement: Devices must maintain at least 4.2 kW when curtailed + p_min_14a = curtailment_14a.get("min_power_mw", curtailment_14a.get("max_power_mw", 0.0042)) # MW + max_hours_per_day = curtailment_14a.get("max_hours_per_day", hours_limit_14a) # hours per day + specific_components = curtailment_14a.get("components", []) # empty list = all eligible CPs + + # 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, + } + + # NOTE: Validation of cp_index references is deferred to after _build_load() + # because pm["load"] is not yet populated at this point. The Julia constraint + # (constraint_cp_14a_min_net_load) searches loads by name at runtime, which works + # correctly because by then all dicts are populated. + + 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'. @@ -1253,7 +1889,7 @@ def _build_heat_storage(psa_net, pm, edisgo_obj, s_base, flexible_hps, opf_versi Array containing all heat pumps that allow for flexible operation due to an attached heat storage. opf_version : int - Version of optimization models to choose from. Must be one of [1, 2, 3, 4]. + Version of optimization models to choose from. Must be one of [1, 2, 3, 4, 5]. For more information see :func:`edisgo.opf.powermodels_opf.pm_optimize`. """ @@ -1639,6 +2275,7 @@ def _build_timeseries( "load", "electromobility", "heatpumps", + "gen_hp_14a", "dsm", "HV_requirements", ]: @@ -1891,6 +2528,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/pypsa_csv_import.py b/edisgo/io/pypsa_csv_import.py new file mode 100644 index 000000000..00ac6a0e4 --- /dev/null +++ b/edisgo/io/pypsa_csv_import.py @@ -0,0 +1,508 @@ +""" +pypsa_csv_import.py +=================== +Populate an eDisGo object from a PyPSA network exported via +``network.export_to_csv_folder()``. + +This module is called internally by ``EDisGo.import_pypsa_csv()``, which is +triggered from ``EDisGo.__init__()`` when the ``pypsa_csv_dir`` kwarg is +passed: + + edisgo_obj = EDisGo(pypsa_csv_dir="/path/to/csv_folder") + +Column mappings and component transformations follow the structure validated +in ``adjust_network_shape()`` (see project history). + +Tested against: + - PyPSA >= 0.26 (CSV export version 1.1.2) + - eDisGo >= 0.3 (topology + timeseries attribute layout) +""" + +import logging +import os +from pathlib import Path + +import numpy as np +import pandas as pd +from pyproj import Transformer as CrsTransformer + +from edisgo.network.grids import MVGrid +from edisgo.network.timeseries import TimeSeries + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Column-name mappings: PyPSA name → eDisGo name +# +# Only columns consumed by eDisGo setters are listed; everything else is +# handled explicitly in the body of populate_edisgo_from_pypsa_csv(). +# --------------------------------------------------------------------------- + +# Buses: coordinate reprojection and extra fields are added separately. +BUSES_RENAME: dict[str, str] = { + "v_nom": "v_nom", + "carrier": "carrier", +} + +LINES_RENAME: dict[str, str] = { + "bus0": "bus0", + "bus1": "bus1", + "x": "x", + "r": "r", + "b": "b", + "s_nom": "s_nom", + "length": "length", + "num_parallel": "num_parallel", + "carrier": "carrier", + "type": "type", + # cable_type → type_info handled explicitly below +} + +# PyPSA exports x/r in physical units; eDisGo stores them as x_pu/r_pu. +TRANSFORMERS_RENAME: dict[str, str] = { + "bus0": "bus0", + "bus1": "bus1", + "x": "x_pu", + "r": "r_pu", + "s_nom": "s_nom", +} + +GENERATORS_RENAME: dict[str, str] = { + "bus": "bus", + "carrier": "carrier", + "p_nom": "p_nom", + "control": "control", + "marginal_cost": "marginal_cost", + "efficiency": "efficiency", +} + +LOADS_RENAME: dict[str, str] = { + "bus": "bus", + "carrier": "type", + "p_set": "p_set", +} + +STORAGE_RENAME: dict[str, str] = { + "bus": "bus", + "carrier": "carrier", + "p_nom": "p_nom", + "max_hours": "max_hours", + "control": "control", +} + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _read( + folder: Path, filename: str, index_col: int = 0, **kwargs +) -> pd.DataFrame: + """Read a CSV from the export folder; return an empty DataFrame if missing.""" + p = folder / filename + if not p.exists(): + logger.warning("File not found, skipping: %s", p) + return pd.DataFrame() + df = pd.read_csv(p, index_col=index_col, **kwargs) + logger.debug("Loaded %s -> %d rows, %d cols", filename, len(df), len(df.columns)) + return df + + +def _read_ts( + folder: Path, + filename: str, + ts_slice: slice, +) -> pd.DataFrame: + """ + Read a timeseries CSV and immediately apply *ts_slice* to rows. + + Uses ``skiprows`` / ``nrows`` so that only the requested rows are loaded + into memory — avoids reading all 8760 rows when a subset is sufficient. + Returns an empty DataFrame if the file does not exist. + """ + p = folder / filename + if not p.exists(): + logger.warning("File not found, skipping: %s", p) + return pd.DataFrame() + + start = ts_slice.start or 0 + stop = ts_slice.stop # None means read to end + + # skiprows skips data rows (header is never skipped by pandas here). + # Range(1, start+1) skips the first `start` data rows. + skip = list(range(1, start + 1)) if start > 0 else None + nrows = (stop - start) if stop is not None else None + + df = pd.read_csv(p, index_col=0, skiprows=skip, nrows=nrows) + logger.debug( + "Loaded %s [rows %s:%s] -> %d rows, %d cols", + filename, start, stop, len(df), len(df.columns), + ) + return df + + +def _keep_and_rename(df: pd.DataFrame, rename_map: dict[str, str]) -> pd.DataFrame: + """ + Keep only columns present in *rename_map* AND in *df*, then rename them. + Extra PyPSA-internal columns (solver artefacts, etc.) are silently dropped. + """ + available = {k: v for k, v in rename_map.items() if k in df.columns} + return df[list(available.keys())].rename(columns=available) + + +def _assign_timeseries( + ts_df: pd.DataFrame, timeindex: pd.DatetimeIndex | None +) -> pd.DataFrame: + """Align a raw time-series DataFrame to *timeindex* when lengths match.""" + if timeindex is not None and len(ts_df) == len(timeindex): + ts_df.index = timeindex + return ts_df + + +def _parse_timestamps(series: pd.Series) -> pd.DatetimeIndex: + idx = pd.DatetimeIndex(pd.to_datetime(series.values), name="snapshot") + return idx.freq and idx or pd.DatetimeIndex(idx, freq="h", name="snapshot") + + +# --------------------------------------------------------------------------- +# Main entry point +# --------------------------------------------------------------------------- + +def populate_edisgo_from_pypsa_csv( + edisgo_obj, + csv_folder: str | os.PathLike, + mv_grid_id: int = 1, + source_crs: str = "EPSG:32632", + target_crs: str = "EPSG:4326", + snapshot_range: tuple[int, int] | None = None, +) -> None: + """ + Populate *edisgo_obj* in-place from a PyPSA CSV export folder. + + Called by ``EDisGo.import_pypsa_csv()`` during ``__init__``. + + Parameters + ---------- + edisgo_obj : edisgo.EDisGo + Already-constructed (but empty) EDisGo instance. + csv_folder : str or Path + Directory produced by ``pypsa.Network.export_to_csv_folder()``. + mv_grid_id : int + MV grid ID to embed across all component tables and the MVGrid object. + Defaults to 1. + source_crs : str + EPSG string of the coordinate system used in the PyPSA export + (buses x/y columns). Defaults to ``"EPSG:32632"`` (UTM zone 32N). + target_crs : str + EPSG string that eDisGo expects for bus coordinates. + Defaults to ``"EPSG:4326"`` (WGS-84 lon/lat). + snapshot_range : tuple[int, int] or None + Inclusive ``(start, end)`` row indices into the snapshot list to load. + Useful for quick testing without reading all 8760 rows of large + timeseries CSVs. For example, ``snapshot_range=(120, 148)`` loads + 29 timesteps (indices 120 through 148 inclusive). + ``None`` (default) loads all available snapshots. + + Examples + -------- + Load the full year (default):: + + EDisGo(pypsa_csv_dir=path) + + Load a quick 29-step test window:: + + EDisGo(pypsa_csv_dir=path, snapshot_range=(120, 148)) + """ + folder = Path(csv_folder) + if not folder.is_dir(): + raise FileNotFoundError(f"PyPSA CSV folder not found: {folder}") + # Build the row slice used for all timeseries reads. + # snapshot_range is inclusive on both ends: (120, 148) -> slice(120, 149). + if snapshot_range is not None: + start, end = snapshot_range + if start < 0 or end < start: + raise ValueError( + f"snapshot_range must satisfy 0 <= start <= end, got {snapshot_range}" + ) + ts_slice = slice(start, end + 1) + logger.info( + "Loading snapshot subset: rows %d - %d (%d steps)", + start, end, end - start + 1, + ) + else: + ts_slice = slice(None) # all rows + + topo = edisgo_obj.topology + + # ------------------------------------------------------------------ # + # 1. TOPOLOGY + # ------------------------------------------------------------------ # + + # --- Buses --------------------------------------------------------- # + buses_raw = _read(folder, "buses.csv") + buses_raw = buses_raw[buses_raw["v_nom"] <= 20] + buses = _keep_and_rename(buses_raw, BUSES_RENAME) + + # Reproject coordinates from source CRS to WGS-84 + crs_transformer = CrsTransformer.from_crs(source_crs, target_crs, always_xy=True) + buses["x"], buses["y"] = crs_transformer.transform( + buses_raw["x"].values, buses_raw["y"].values + ) + + # Parse geometry strings into Shapely objects when present + if "geom" in buses_raw.columns: + try: + from shapely import wkt as shapely_wkt + buses["geom"] = buses_raw["geom"].apply( + lambda g: shapely_wkt.loads(g) if pd.notna(g) else None + ) + except ImportError: + logger.warning("shapely not installed - geom kept as plain strings.") + buses["geom"] = buses_raw["geom"] + + # Grid ID columns (required by eDisGo internals) + buses["mv_grid_id"] = mv_grid_id + if "lv_grid_id" in buses_raw.columns: + buses["lv_grid_id"] = buses_raw["lv_grid_id"] + else: + # MV and HV buses (identified by "MS" or "MV" in the name) get NaN; + # all LV buses get lv_grid_id = 1. + buses["lv_grid_id"] = buses.index.to_series().apply( + lambda name: np.nan if ("MS" in str(name) or "MV" in str(name)) else 1 + ) + + # eDisGo's buses_df setter unconditionally accesses this column + buses["in_building"] = False + + # Pass-through metadata columns that eDisGo tolerates as extras + for col in ("comp_type", "household_count", "trafo_cap", "location", "HP"): + if col in buses_raw.columns: + buses[col] = buses_raw[col] + + topo.buses_df = buses + + # --- Lines --------------------------------------------------------- # + lines_raw = _read(folder, "lines.csv") + lines = _keep_and_rename(lines_raw, LINES_RENAME) + + # b and num_parallel are required by edisgo's to_pypsa() but are not + # always present in PyPSA CSV exports — provide safe defaults. + if "b" not in lines.columns: + lines["b"] = 0.0 + logger.warning("lines.csv has no 'b' column - defaulting to 0.0.") + if "num_parallel" not in lines.columns: + lines["num_parallel"] = 1 + logger.warning("lines.csv has no 'num_parallel' column - defaulting to 1.") + + # cable_type -> type_info (proven naming used by eDisGo) + if "cable_type" in lines_raw.columns: + lines["type_info"] = lines_raw["cable_type"] + lines["kind"] = "cable" + + # Pass-through columns + for col in ("comp_type", "geom", "x_pu", "r_pu"): + if col in lines_raw.columns: + lines[col] = lines_raw[col] + + lines["mv_grid_id"] = mv_grid_id + topo.lines_df = lines + + # --- Transformers -------------------------------------------------- # + # eDisGo splits LV/MV trafos (transformers_df) from the HV/MV station + # transformer (transformers_hvmv_df), distinguished by comp_type. + trafos_raw = _read(folder, "transformers.csv") + trafos_base = _keep_and_rename(trafos_raw, TRANSFORMERS_RENAME) + + def _build_lv_trafos(raw: pd.DataFrame, base: pd.DataFrame) -> pd.DataFrame: + df = base.copy() + df["type_info"] = (raw["s_nom"] * 1e3).astype(int).astype(str) + " kVA" + df["type"] = df["type_info"] + df["mv_grid_id"] = mv_grid_id + return df + + def _build_hv_trafos(raw: pd.DataFrame, base: pd.DataFrame) -> pd.DataFrame: + df = base.copy() + # HV/MV dummy trafo: impedance values are not meaningful + df["x_pu"] = np.nan + df["r_pu"] = np.nan + df["type_info"] = raw["s_nom"].astype(int).astype(str) + " MVA" + df["type"] = df["type_info"] + df["mv_grid_id"] = mv_grid_id + return df + + if "comp_type" in trafos_raw.columns: + mask_hv = trafos_raw["comp_type"] == "trafo_HV" + topo.transformers_df = _build_lv_trafos(trafos_raw[~mask_hv], trafos_base[~mask_hv]) + topo.transformers_hvmv_df = _build_hv_trafos(trafos_raw[mask_hv], trafos_base[mask_hv]) + else: + logger.warning( + "transformers.csv has no 'comp_type' column; all transformers " + "placed in transformers_df. Verify HV/MV trafos manually." + ) + topo.transformers_df = _build_lv_trafos(trafos_raw, trafos_base) + topo.transformers_hvmv_df = pd.DataFrame(columns=trafos_base.columns) + + # --- Generators ---------------------------------------------------- # + gens_raw = _read(folder, "generators.csv") + gens = _keep_and_rename(gens_raw, GENERATORS_RENAME) + + if "carrier" in gens_raw.columns: + gens["type"] = gens_raw["carrier"].apply( + lambda c: ( + "solar" if "solar_rooftop" in c else + "station" if c == "AC" else + "conventional" + ) + ) + gens["subtype"] = gens_raw["carrier"].apply( + lambda c: ( + "pv_rooftop" if "solar_rooftop" in c else + "mv_substation" if c == "AC" else + "unknown" + ) + ) + else: + gens["type"] = "conventional" + gens["subtype"] = "unknown" + + gens["voltage_level"] = gens.index.to_series().apply( + lambda name: "mv" if "MS" in str(name) else "lv" + ) + gens["weather_cell_id"] = None + gens["source_id"] = None + gens["mv_grid_id"] = mv_grid_id + topo.generators_df = gens + + # --- Loads --------------------------------------------------------- # + loads_raw = _read(folder, "loads.csv") + + loads = _keep_and_rename(loads_raw, LOADS_RENAME) + + # p_set: static baseline, overwritten with time-series max where available. + # The full loads-p_set.csv is read here (topology phase) regardless of + # snapshot_range — we need the column-wise max across all timesteps. + loads["p_set"] = loads_raw["p_set"].astype(float) if "p_set" in loads_raw.columns else 0.0 + load_ts_full = _read(folder, "loads-p_set.csv", index_col=0) + if not load_ts_full.empty: + common = loads.index.intersection(load_ts_full.columns) + if not common.empty: + loads.loc[common, "p_set"] = load_ts_full[common].max(axis=0) + + loads["sector"] = loads.index.to_series().apply( + lambda name: ( + "cts" if "cts" in str(name).lower() else + "industrial" if "ind" in str(name).lower() else + "residential" + ) + ) + loads["voltage_level"] = "lv" # adjust here if MV loads are present + loads["annual_consumption"] = None + loads["number_households"] = 1 + loads["building_id"] = None + loads["mv_grid_id"] = mv_grid_id + topo.loads_df = loads + + # --- Storage units ------------------------------------------------- # + stor_raw = _read(folder, "storage_units.csv") + if "control" not in stor_raw.columns: + stor_raw["control"] = "PQ" # default control mode if not specified + stor = _keep_and_rename(stor_raw, STORAGE_RENAME) + stor["mv_grid_id"] = mv_grid_id + topo.storage_units_df = stor + + # ------------------------------------------------------------------ # + # 2. TIME SERIES + # ------------------------------------------------------------------ # + edisgo_obj.timeseries = TimeSeries() + ts = edisgo_obj.timeseries + + # Resolve the timeindex from snapshots.csv, applying snapshot_range. + snap = _read(folder, "snapshots.csv", index_col=None) + if not snap.empty: + timeindex = _parse_timestamps(snap.loc[:, "snapshot"].iloc[ts_slice]) + ts.timeindex = pd.DatetimeIndex(timeindex, name="snapshot", + freq="h") + else: + timeindex = None + + # All timeseries files go through _read_ts() which applies ts_slice at + # the CSV reader level — only the requested rows are loaded into memory. + + # Active power - generators + # generators-p_max_pu.csv stores values in pu relative to p_nom; + # eDisGo expects MW, so multiply by p_nom for each generator. + gen_p = _read_ts(folder, "generators-p_max_pu.csv", ts_slice) + if not gen_p.empty: + gen_p_mw = _assign_timeseries(gen_p, timeindex) + p_nom = topo.generators_df["p_nom"] + common = gen_p_mw.columns.intersection(p_nom.index) + gen_p_mw[common] = gen_p_mw[common] * p_nom[common] + ts.generators_active_power = gen_p_mw + + # Reactive power - generators (optional) + gen_q = _read_ts(folder, "generators-q_set.csv", ts_slice) + if not gen_q.empty: + ts.generators_reactive_power = _assign_timeseries(gen_q, timeindex) + + # Active power - loads + load_p = _read_ts(folder, "loads-p_set.csv", ts_slice) + #load_p.loc[:,load_p.columns.str.contains("heat_")] = 0.1 # to force usage of 14a + #edisgo_obj.topology.loads_df.loc[edisgo_obj.topology.loads_df.index.str.contains("heat_"),"p_set"] = 0.1 # to force usage of 14a + + if not load_p.empty: + ts.loads_active_power = _assign_timeseries(load_p, timeindex) + + # Reactive power - loads (optional) + load_q = _read_ts(folder, "loads-q_set.csv", ts_slice) + if not load_q.empty: + ts.loads_reactive_power = _assign_timeseries(load_q, timeindex) + + # Storage (optional) + stor_p = _read_ts(folder, "storage_units-p_set.csv", ts_slice) + if not stor_p.empty: + ts.storage_units_active_power = _assign_timeseries(stor_p, timeindex) + + # ------------------------------------------------------------------ # + # 3. GRID METADATA + # ------------------------------------------------------------------ # + net_meta = _read(folder, "network.csv") + srid = ( + int(net_meta["srid"].iloc[0]) + if not net_meta.empty and "srid" in net_meta.columns + else int(target_crs.split(":")[1]) + ) + topo.grid_district = {"srid": srid, "mv_grid_id": mv_grid_id, "population":22227} + + # ------------------------------------------------------------------ # + # 4. STRUCTURAL WIRING + # Wire the MVGrid object and all back-references that eDisGo internals + # rely on (mirrors what import_ding0_grid() sets up). + # ------------------------------------------------------------------ # + try: + topo._mv_grid = MVGrid(id=mv_grid_id, topology=topo) + except TypeError: + # Older eDisGo versions do not accept the topology kwarg + topo._mv_grid = MVGrid(id=mv_grid_id) + + topo._edisgo_obj = edisgo_obj + topo._mv_grid._topology = topo + topo._mv_grid._edisgo_obj = edisgo_obj + topo._lv_grids = [] + + # ------------------------------------------------------------------ # + # 5. SUMMARY LOG + # ------------------------------------------------------------------ # + logger.info( + "PyPSA CSV import complete | " + "%d buses | %d lines | %d trafos LV/MV | %d trafos HV/MV | " + "%d generators | %d loads | %d storage units | %d timesteps", + len(topo.buses_df), + len(topo.lines_df), + len(topo.transformers_df), + len(topo.transformers_hvmv_df), + len(topo.generators_df), + len(topo.loads_df), + len(topo.storage_units_df), + len(timeindex) if timeindex is not None else 0, + ) \ No newline at end of file diff --git a/edisgo/io/pypsa_io.py b/edisgo/io/pypsa_io.py index 023e5fd69..7bba02ada 100755 --- a/edisgo/io/pypsa_io.py +++ b/edisgo/io/pypsa_io.py @@ -50,7 +50,6 @@ def to_pypsa(edisgo_object, mode=None, timesteps=None, **kwargs): :pypsa:`PyPSA.Network` representation. """ - def _set_slack(grid): """ Sets slack at given grid's station secondary side. 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/network/topology.py b/edisgo/network/topology.py index a75d0d609..24cb0b57e 100755 --- a/edisgo/network/topology.py +++ b/edisgo/network/topology.py @@ -2003,6 +2003,217 @@ def connect_to_mv(self, edisgo_object, comp_data, comp_type="generator"): ) return comp_name + + # def connect_to_mv_14a(self, edisgo_object, comp_data, comp_type="generator"): #CHANGED + # """ + # 14a variant of connect_to_mv. + + # """ + # if "p" not in comp_data.keys(): + # comp_data["p"] = ( + # comp_data["p_set"] + # if "p_set" in comp_data.keys() + # else comp_data["p_nom"] + # ) + + # voltage_level = comp_data.pop("voltage_level") + # power = comp_data.pop("p") + + # # create new bus for new component + # if not isinstance(comp_data["geom"], Point): + # geom = wkt_loads(comp_data["geom"]) + # else: + # geom = comp_data["geom"] + + # if comp_type == "charging_point": + + # mv_buses = self.mv_grid.buses_df + # target_bus, target_bus_distance = geo.find_nearest_bus_14a(geom, mv_buses) + + # MAX_DIST = 0.3 # km + + # if target_bus_distance >= MAX_DIST: + # print( + # f"SKIP CP (too far from MV bus): dist={target_bus_distance:.3f} km" + # ) + # return None + + # # attach directly to existing MV bus + # comp_data.pop("geom", None) + # comp_data.pop("p", None) + + # return self.add_load( + # bus=target_bus, + # type="charging_point", + # **comp_data + # ) + + # # ===== FIND NEAREST MV BUS ===== + # mv_buses = self.mv_grid.buses_df + # target_bus, target_bus_distance = geo.find_nearest_bus_14a(geom, mv_buses) + + # MAX_DIST = 0.3 + + # if comp_type == "charging_point" and target_bus_distance < MAX_DIST: + # bus = target_bus + # else: + # print("comp_type has to be charging_point in _14a workflow") + + # # add component to newly created bus + # comp_data.pop("geom") + # if comp_type == "generator": + # comp_name = self.add_generator(bus=bus, **comp_data) + # elif comp_type == "charging_point": + # comp_name = self.add_load(bus=bus, type="charging_point", **comp_data) + # elif comp_type == "heat_pump": + # comp_name = self.add_load(bus=bus, type="heat_pump", **comp_data) + # else: + # comp_name = self.add_storage_unit(bus=bus, **comp_data) + + # # ===== voltage level 4: component is connected to MV station ===== + # if voltage_level == 4: + # print("Voltage_level 4 in connect_t_mv_14a has no 14a workflow yet.") + + # elif voltage_level == 5: + # # get branches within the predefined `connection_buffer_radius` + # lines = geo.calc_geo_lines_in_buffer( + # grid_topology=self, + # bus=self.buses_df.loc[bus, :], + # grid=self.mv_grid, + # buffer_radius=int( + # edisgo_object.config["grid_connection"]["conn_buffer_radius"] + # ), + # buffer_radius_inc=int( + # edisgo_object.config["grid_connection"]["conn_buffer_radius_inc"] + # ), + # ) + + # # calc distance between component and grid's lines -> find nearest line + # conn_objects_min_stack = geo.find_nearest_conn_objects( + # grid_topology=self, + # bus=self.buses_df.loc[bus, :], + # lines=lines, + # conn_diff_tolerance=edisgo_object.config["grid_connection"][ + # "conn_diff_tolerance" + # ], + # ) + + # # connect + # # go through the stack (from nearest to farthest connection target + # # object) + # comp_connected = False + # for dist_min_obj in conn_objects_min_stack: + # # do not allow connection to virtual busses + # if "virtual" not in dist_min_obj["repr"]: + # target_obj_result = self._connect_mv_bus_to_target_object( + # edisgo_object=edisgo_object, + # bus=self.buses_df.loc[bus, :], + # target_obj=dist_min_obj, + # comp_type=comp_type, + # power=power, + # ) + + # if target_obj_result is not None: + # comp_connected = True + # break + + # if not comp_connected: + # logger.error( + # f"Component {comp_name} could not be connected. Try to increase the" + # f" parameter `conn_buffer_radius` in config file `config_grid.cfg` " + # f"to gain more possible connection points." + # ) + + # return comp_name + + def connect_to_mv_14a(self, edisgo_object, comp_data, comp_type="generator"): + """ + Connect a charging point directly to the nearest existing MV bus. + + This 14a variant is intended exclusively for charging points. + It never creates a new bus. If no MV bus is found within the + maximum allowed distance, an error is raised. + + Parameters + ---------- + edisgo_object : object + Included for interface compatibility. Not used here except to + keep the function signature aligned with similar methods. + comp_data : dict + Component data. Must contain at least: + - "geom": geometry of the charging point + - "voltage_level": retained for compatibility, but not used + Optional: + - "p", "p_set", or "p_nom" + comp_type : str, default "generator" + Must be "charging_point". + + Returns + ------- + str + Name of the created load. + + Raises + ------ + ValueError + If comp_type is not "charging_point", required fields are missing, + geometry is invalid, or no MV bus is within the maximum distance. + """ + from shapely.geometry import Point + from shapely.wkt import loads as wkt_loads + + MAX_DIST_KM = 0.3 + + if comp_type != "charging_point": + raise ValueError( + f"connect_to_mv_14a only supports comp_type='charging_point', " + f"got {comp_type!r}." + ) + + if "geom" not in comp_data: + raise ValueError("comp_data must contain a 'geom' entry.") + + if "voltage_level" not in comp_data: + raise ValueError("comp_data must contain a 'voltage_level' entry.") + + # Work on a copy so the caller's dict is not modified unexpectedly. + comp_data = comp_data.copy() + + # Keep old p-resolution logic for compatibility, even though it is + # not used for the direct MV-bus attachment itself. + if "p" not in comp_data: + if "p_set" in comp_data: + comp_data["p"] = comp_data["p_set"] + elif "p_nom" in comp_data: + comp_data["p"] = comp_data["p_nom"] + + # Pop fields that are not needed by add_load(...) + comp_data.pop("voltage_level") + geom_raw = comp_data.pop("geom") + + # Parse geometry + if isinstance(geom_raw, Point): + geom = geom_raw + else: + geom = wkt_loads(geom_raw) + + # Find nearest existing MV bus + mv_buses = self.mv_grid.buses_df + target_bus, target_bus_distance = geo.find_nearest_bus_14a(geom, mv_buses) + + if target_bus_distance >= MAX_DIST_KM: + raise ValueError( + f"Charging point cannot be connected: nearest MV bus is " + f"{target_bus_distance:.3f} km away, exceeding the maximum " + f"allowed distance of {MAX_DIST_KM:.3f} km." + ) + + # 'p' is not passed explicitly here because add_load gets the rest of comp_data + return self.add_load( + bus=target_bus, + type="charging_point", + **comp_data, + ) def connect_to_lv( self, @@ -2398,8 +2609,7 @@ def connect_to_lv_based_on_geolocation( :attr:`~.network.topology.Topology.storage_units_df`, depending on component type. - """ - + """ if "p" not in comp_data.keys(): comp_data["p"] = ( comp_data["p_set"] @@ -2454,6 +2664,196 @@ def connect_to_lv_based_on_geolocation( comp_name = add_func(bus=bus, **comp_data) return comp_name + + def connect_to_lv_based_on_geolocation_14a( + self, + edisgo_object, + comp_data, + comp_type, + max_distance_from_target_bus=0.3, + ): + """ + Custom 14a variant of connect_to_lv_based_on_geolocation. + + Differences to the standard function: + - larger default `max_distance_from_target_bus` + - different algorithm for finding the closest bus + - optional filtering of eligible LV buses by charging-point use case + and bus comp_type + """ + import numpy as np + from scipy.spatial import cKDTree + + # ------------------------------------------------------------- + # Charging-point bus eligibility by use case + # Extend this dictionary later as needed. + # None means: keep previous default behavior (= all LV buses) + # ------------------------------------------------------------- + CHARGING_USE_CASE_BUS_COMP_TYPES = { + "home": ["house_connection"], + "work": None, + "public": None, + "hpc": None, + } + + # ------------------------------------------------------------- + # Helper: build KDTree cache + # ------------------------------------------------------------- + def _build_cache(bus_df): + coords = bus_df[["x", "y"]].dropna() + + if len(coords) == 0: + raise ValueError("No valid coordinates for KDTree.") + + xs = coords["x"].to_numpy(dtype=float) + ys = coords["y"].to_numpy(dtype=float) + + lat0 = np.deg2rad(np.mean(ys)) + + x_km = xs * 111.320 * np.cos(lat0) + y_km = ys * 110.574 + + tree = cKDTree(np.column_stack((x_km, y_km))) + + return { + "tree": tree, + "index": coords.index.to_numpy(), + "lat0": lat0, + "size": len(coords), + } + + # ------------------------------------------------------------- + # Helper: query KDTree + # ------------------------------------------------------------- + def _query_cache(point, cache): + px = point.x * 111.320 * np.cos(cache["lat0"]) + py = point.y * 110.574 + + dist, idx = cache["tree"].query([px, py], k=1) + + return cache["index"][idx], float(dist) + + # ------------------------------------------------------------- + # Helper: cache key for filtered LV buses + # ------------------------------------------------------------- + def _cache_attr_name_for_use_case(use_case): + if use_case is None: + return "_geo_cache_lv_14a_all" + return f"_geo_cache_lv_14a_{use_case}" + + # ------------------------------------------------------------- + # Setup + # ------------------------------------------------------------- + if "p" not in comp_data: + comp_data["p"] = comp_data.get("p_set", comp_data.get("p_nom")) + + voltage_level = comp_data.pop("voltage_level") + + if voltage_level not in [6, 7]: + raise ValueError(f"Invalid voltage level {voltage_level}") + + geolocation = comp_data.get("geom") + + if comp_type == "generator": + add_func = self.add_generator + elif comp_type in ["charging_point", "heat_pump"]: + add_func = self.add_load + comp_data["type"] = comp_type + elif comp_type == "storage_unit": + add_func = self.add_storage_unit + else: + logger.error(f"Invalid component type {comp_type}") + return + + # ------------------------------------------------------------- + # Voltage level 6 → substations + # ------------------------------------------------------------- + if voltage_level == 6: + subst = self.buses_df.loc[self.transformers_df.bus1.unique()] + + if ( + not hasattr(self, "_geo_cache_substations_14a") + or self._geo_cache_substations_14a["size"] != len(subst[["x", "y"]].dropna()) + ): + self._geo_cache_substations_14a = _build_cache(subst) + + target_bus, dist = _query_cache( + geolocation, self._geo_cache_substations_14a + ) + + # ------------------------------------------------------------- + # Voltage level 7 → LV buses + # ------------------------------------------------------------- + else: + lv_buses = self.buses_df.drop(self.mv_grid.buses_df.index) + + # --------------------------------------------------------- + # Optional use-case-specific filtering for charging points + # --------------------------------------------------------- + if comp_type == "charging_point": + use_case = comp_data.get("sector") + allowed_comp_types = CHARGING_USE_CASE_BUS_COMP_TYPES.get(use_case, None) + + if allowed_comp_types is not None: + if "comp_type" not in lv_buses.columns: + raise KeyError( + "Column 'comp_type' not found in buses_df, but it is " + f"required for charging-point use case '{use_case}'." + ) + + lv_buses = lv_buses.loc[ + lv_buses["comp_type"].isin(allowed_comp_types) + ] + + if lv_buses.empty: + raise ValueError( + f"No eligible LV buses found for charging use case " + f"'{use_case}' with allowed comp_type values " + f"{allowed_comp_types}." + ) + + cache_attr = _cache_attr_name_for_use_case(use_case) + + else: + cache_attr = _cache_attr_name_for_use_case(None) + + valid_coords_count = len(lv_buses[["x", "y"]].dropna()) + + if ( + not hasattr(self, cache_attr) + or getattr(self, cache_attr)["size"] != valid_coords_count + ): + setattr(self, cache_attr, _build_cache(lv_buses)) + + target_bus, dist = _query_cache( + geolocation, getattr(self, cache_attr) + ) + + # ------------------------------------------------------------- + # Connection logic + # ------------------------------------------------------------- + if comp_type == "charging_point": + bus = target_bus + else: + if dist > max_distance_from_target_bus: + bus = self._connect_to_lv_bus( + edisgo_object, target_bus, comp_type, comp_data + ) + + # topology changed → invalidate LV caches + for attr in list(vars(self).keys()): + if attr.startswith("_geo_cache_lv_14a_"): + delattr(self, attr) + + else: + bus = target_bus + + comp_data.pop("geom") + comp_data.pop("p") + + return add_func(bus=bus, **comp_data) + + def _connect_mv_bus_to_target_object( self, edisgo_object, diff --git a/edisgo/opf/eDisGo_OPF.jl/Main.jl b/edisgo/opf/eDisGo_OPF.jl/Main.jl index a2b64ca2f..209549b3b 100644 --- a/edisgo/opf/eDisGo_OPF.jl/Main.jl +++ b/edisgo/opf/eDisGo_OPF.jl/Main.jl @@ -33,7 +33,7 @@ warm_start = ARGS[5].=="True" const ipopt = optimizer_with_attributes(Ipopt.Optimizer, MOI.Silent() => silence_moi, "sb" => "yes", "tol"=>1e-6) function optimize_edisgo() # read in data and create multinetwork - gurobi = optimizer_with_attributes(Gurobi.Optimizer, MOI.Silent() => silence_moi, "FeasibilityTol"=>1e-4, "BarQCPConvTol"=>1e-4, "BarConvTol"=>1e-4, "BarHomogeneous"=>1) + gurobi = optimizer_with_attributes(Gurobi.Optimizer, MOI.Silent() => silence_moi, "FeasibilityTol"=>1e-4, "BarQCPConvTol"=>1e-4, "BarConvTol"=>1e-4, "BarHomogeneous"=>1, "MIPGap"=>0.01, "Threads"=>4) data_edisgo = eDisGo_OPF.parse_json(json_str) data_edisgo_mn = PowerModels.make_multinetwork(data_edisgo) diff --git a/edisgo/opf/eDisGo_OPF.jl/src/core/base.jl b/edisgo/opf/eDisGo_OPF.jl/src/core/base.jl index 3704f4c98..f5b449911 100644 --- a/edisgo/opf/eDisGo_OPF.jl/src/core/base.jl +++ b/edisgo/opf/eDisGo_OPF.jl/src/core/base.jl @@ -139,6 +139,22 @@ 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_gen_cp_14a = Dict((i, Int[]) for (i,bus) in nw_ref[:bus]) + if haskey(nw_ref, :gen_cp_14a) + for (i,gen) in nw_ref[:gen_cp_14a] + push!(bus_gen_cp_14a[gen["gen_bus"]], i) + end + end + nw_ref[:bus_gen_cp_14a] = bus_gen_cp_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..e779a588c --- /dev/null +++ b/edisgo/opf/eDisGo_OPF.jl/src/core/constraint_cp_14a.jl @@ -0,0 +1,188 @@ +""" +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). + +For flexible CPs (in electromobility dict): uses optimization variable `pcp`. +For fixed CPs (in load dict): uses fixed load parameter `load["pd"]`. + +Big-M formulation: + p_cp - p_cp14a >= p_min_14a - M * (1 - z_cp14a) + When z=0 (inactive): constraint relaxed + When z=1 (active): p_cp - p_cp14a >= p_min_14a (net load >= 4.2 kW) + +# 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"] + + # Virtual generator support variable + 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 + p_max_support = gen_cp14a["pmax"] + + if p_max_support < 1e-6 + # Charging point too small for §14a curtailment, disable virtual generator + JuMP.@constraint(pm.model, p_cp14a == 0.0) + return + end + + # Binary variable for Big-M formulation + z_cp14a = PowerModels.var(pm, nw, :z_cp14a, i) + M = p_max_support + p_min_14a + + # Check if CP is flexible (in electromobility dict) or simple load + if haskey(PowerModels.ref(pm, nw), :electromobility) && haskey(PowerModels.ref(pm, nw, :electromobility), cp_idx) + # Flexible CP: use optimization VARIABLE + pcp = PowerModels.var(pm, nw, :pcp, cp_idx) + JuMP.@constraint(pm.model, pcp - p_cp14a >= p_min_14a - M * (1 - z_cp14a)) + else + # Non-flexible CP: use fixed load timeseries + # ============================================ + # NOTE: This is an O(n) linear search through all loads + # + # Why this search is necessary: + # - cp_index may reference electromobility dict (flexible CPs with optimization vars) + # - OR it may reference load dict (fixed CPs with constant power draw) + # - No unified index mapping exists between these two data structures + # - Load name matching is the only reliable way to find the correct load + # + # Performance impact: + # - Acceptable for <1,000 loads per network + # - For larger networks, consider pre-indexing loads by name in Python + # before serializing to PowerModels dict + # ============================================ + cp_name = gen_cp14a["cp_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"] + load_found = true + if p_cp_load > 1e-6 + # Fixed load with Big-M: when z=1, enforce min net load + JuMP.@constraint(pm.model, p_cp_load - p_cp14a >= p_min_14a - M * (1 - z_cp14a)) + else + # CP is off (p_cp_load ≈ 0), no support needed + JuMP.@constraint(pm.model, p_cp14a == 0.0) + end + break + end + end + + if !load_found + @warn "Could not find load for charging point $(cp_name), skipping constraint" + return + end + 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..a8975284d --- /dev/null +++ b/edisgo/opf/eDisGo_OPF.jl/src/core/constraint_hp_14a.jl @@ -0,0 +1,178 @@ +""" +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). + +Uses the HP optimization variable `php` (not the fixed parameter) so the constraint +correctly tracks the actual HP electrical draw after heat storage optimization. + +Big-M formulation: + php - p_hp14a >= p_min_14a - M * (1 - z_hp14a) + When z=0 (inactive): constraint relaxed (always satisfied) + When z=1 (active): php - p_hp14a >= p_min_14a (net load >= 4.2 kW) + +If php < p_min_14a at a timestep, z is forced to 0 (no curtailment possible), +which in turn forces p_hp14a = 0 via binary coupling. This correctly handles +cases where the HP draws less than 4.2 kW. + +# 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"] + + # Get the actual HP electrical power VARIABLE (not the fixed parameter) + php = PowerModels.var(pm, nw, :php, hp_idx) + + # Virtual generator support variable + 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 + p_max_support = gen_hp14a["pmax"] + + # --- DEBUG PRINT START --- + if i == 12 # ID bei Bedarf anpassen + println("\n[DEBUG §14a Min Net Load | NW $nw | HP $i]") + println(" > hp_idx: $hp_idx") + println(" > php (Var): $(php)") + println(" > p_hp14a (Var): $(p_hp14a)") + println(" > p_min_14a: $p_min_14a") + println(" > p_max_support: $p_max_support") + println(keys(gen_hp14a)) + #println(" > Aktuelle Last (pd p.u.): ", gen_hp14a["pd"]) + #println(" > Last: $(gen_hp14a["pd"])") + + if p_max_support >= 1e-6 + z_hp14a = PowerModels.var(pm, nw, :z_hp14a, i) + M = p_max_support + p_min_14a + println(" > z_hp14a (Var): $(z_hp14a)") + println(" > Big-M: $M") + # Zeigt die mathematische Formel der Constraint vor der Lösung + println(" > Constraint: php - p_hp14a >= $p_min_14a - $M * (1 - z_hp14a)") + else + println(" > Status: Small HP (Constraint: p_hp14a == 0)") + end + println("-"^40) + end + # --- DEBUG PRINT ENDE --- + + if p_max_support < 1e-6 + # Heat pump too small for §14a curtailment, disable virtual generator + JuMP.@constraint(pm.model, p_hp14a == 0.0) + else + # Big-M formulation: when z=1 (curtailment active), enforce min net load + # when z=0 (curtailment inactive), constraint is relaxed + z_hp14a = PowerModels.var(pm, nw, :z_hp14a, i) + M = p_max_support + p_min_14a + JuMP.@constraint(pm.model, php - p_hp14a >= p_min_14a - M * (1 - z_hp14a)) + 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..736d37f65 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,8 @@ 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, []) + bus_gen_cp_14a = get(PowerModels.ref(pm, nw), :bus_gen_cp_14a, Dict())[i] = get(get(PowerModels.ref(pm, nw), :bus_gen_cp_14a, Dict()), i, []) branch_r = Dict(k => PowerModels.ref(pm, nw, :branch, k, "br_r") for k in bus_lines_to) @@ -34,7 +36,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_gen_cp_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..c7801f445 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,44 @@ function objective_min_losses_slacks_OG(pm::AbstractBFModelEdisgo) ) end +# OPF Version 5: Minimize line losses, use ONLY §14a curtailment as flexibility +# OPF Version 5: §14a as primary flexibility; generation curtailment and load shedding +# as escalating last resorts so the model always finds a feasible solution. +# Inspect pds/phps/pcps in the results — non-zero values signal a pre-existing grid violation. +function objective_min_losses_14a_only(pm::AbstractBFModelEdisgo) + nws = PowerModels.nw_ids(pm) + + 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) + pgc = Dict(n => PowerModels.var(pm, n, :pgc) for n in nws) + pgens = Dict(n => PowerModels.var(pm, n, :pgens) for n in nws) + pds = Dict(n => PowerModels.var(pm, n, :pds) for n in nws) + phps = Dict(n => PowerModels.var(pm, n, :phps) for n in nws) + pcps = Dict(n => PowerModels.var(pm, n, :pcps) for n in nws) + + factor_14a = 1e4 # §14a curtailment: primary flexibility + factor_pgc = 1e5 # non-dispatchable curtailment: second resort + factor_pgens = 1e5 # dispatchable curtailment: third resort (same tier as pgc) + factor_shed = 1e8 # load/HP/CP shedding: absolute last resort + + println("factor_14a = ", factor_14a) + println("factor_pgc = ", factor_pgc) + println("factor_pgens = ", factor_pgens) + println("factor_shed = ", factor_shed) + + return JuMP.@objective(pm.model, Min, + + factor_14a * sum(sum(p_hp14a[n][i] for i in keys(p_hp14a[n])) for n in nws) + + factor_14a * sum(sum(p_cp14a[n][i] for i in keys(p_cp14a[n])) for n in nws) + + factor_pgc * sum(sum(pgc[n][i] for i in keys(PowerModels.ref(pm, 1, :gen_nd))) for n in nws) + + factor_pgens * sum(sum(pgens[n][i] for i in keys(PowerModels.ref(pm, 1, :gen))) for n in nws) + + factor_shed * sum(sum(pds[n][i] for i in keys(PowerModels.ref(pm, 1, :load))) for n in nws) + + factor_shed * sum(sum(phps[n][i] for i in keys(PowerModels.ref(pm, 1, :heatpumps))) for n in nws) + + factor_shed * sum(sum(pcps[n][i] for i in keys(PowerModels.ref(pm, 1, :electromobility))) for n in nws) + ) +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..1324f0ed2 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...) @@ -534,4 +596,69 @@ function variable_slack_HV_requirements_imaginary(pm::AbstractPowerModel; nw::In end -"" + +### Version 5: Fixed variables (no flexibility except §14a) + +""" + variable_heat_pump_power_fixed(pm; nw, report) + +For OPF version 5: Fix heat pump power to pd/cop (current demand). +No optimization flexibility - only §14a can reduce load. +""" +function variable_heat_pump_power_fixed(pm::AbstractPowerModel; nw::Int=nw_id_default, report::Bool=true) + # Create php variable fixed to pd/cop for each heat pump + php = PowerModels.var(pm, nw)[:php] = JuMP.@variable(pm.model, + [i in PowerModels.ids(pm, nw, :heatpumps)], base_name="$(nw)_php" + ) + + # Fix to current demand: php = pd/cop + for (i, hp) in PowerModels.ref(pm, nw, :heatpumps) + p_demand = hp["pd"] / hp["cop"] + JuMP.fix(php[i], p_demand; force=true) + end + + report && PowerModels.sol_component_value(pm, nw, :heatpumps, :php, PowerModels.ids(pm, nw, :heatpumps), php) +end + +""" + variable_cp_power_fixed(pm; nw, report) + +For OPF version 5: Fix charging point power to pd (current demand from load). +No optimization flexibility - only §14a can reduce load. +Also creates cpe (energy) variable fixed at mid-range. +""" +function variable_cp_power_fixed(pm::AbstractPowerModel; nw::Int=nw_id_default, report::Bool=true) + # Check if electromobility dict exists and has entries + if !haskey(PowerModels.ref(pm, nw), :electromobility) || isempty(PowerModels.ref(pm, nw, :electromobility)) + # Create empty variables to avoid errors in power balance + PowerModels.var(pm, nw)[:pcp] = Dict{Int, JuMP.VariableRef}() + PowerModels.var(pm, nw)[:cpe] = Dict{Int, JuMP.VariableRef}() + return + end + + # Create pcp variable fixed to pd for each charging point + pcp = PowerModels.var(pm, nw)[:pcp] = JuMP.@variable(pm.model, + [i in PowerModels.ids(pm, nw, :electromobility)], base_name="$(nw)_pcp" + ) + + # Fix to current demand: pcp = pd + for (i, cp) in PowerModels.ref(pm, nw, :electromobility) + p_demand = cp["pd"] + JuMP.fix(pcp[i], p_demand; force=true) + end + + report && PowerModels.sol_component_value(pm, nw, :electromobility, :pcp, PowerModels.ids(pm, nw, :electromobility), pcp) + + # Also need cpe (energy) variable for constraints - fix at midpoint + cpe = PowerModels.var(pm, nw)[:cpe] = JuMP.@variable(pm.model, + [i in PowerModels.ids(pm, nw, :electromobility)], base_name="$(nw)_cpe" + ) + + for (i, cp) in PowerModels.ref(pm, nw, :electromobility) + e_mid = 0.5 * (cp["e_min"] + cp["e_max"]) + JuMP.fix(cpe[i], e_mid; force=true) + end + + report && PowerModels.sol_component_value(pm, nw, :electromobility, :cpe, PowerModels.ids(pm, nw, :electromobility), cpe) +end + 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..87413b052 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_gen_cp_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,8 +147,10 @@ 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 for heat pumps + p_cp14a = get(PowerModels.var(pm, n), :p_cp14a, Dict()) # §14a virtual generators for charging points - if PowerModels.ref(pm, 1, :opf_version) in(2, 4) # Eq. (3.3iii), (3.4iii) + if PowerModels.ref(pm, 1, :opf_version) in(2, 4, 5) # Eq. (3.3iii), (3.4iii) pgens = get(PowerModels.var(pm, n), :pgens, Dict()); PowerModels._check_var_keys(pgens, bus_gens, "active power slack", "curtailment") pds = get(PowerModels.var(pm, n), :pds, Dict()); PowerModels._check_var_keys(pds, bus_loads, "active power slack", "load") pcps = get(PowerModels.var(pm, n), :pcps, Dict()); PowerModels._check_var_keys(pcps, bus_cps, "active power slack", "charging point") @@ -171,6 +173,8 @@ 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 (heat pumps) + - sum(p_cp14a[g] for g in bus_gen_cp_14a) # Virtual generators reduce net load (charging points) ) cstr_q = JuMP.@constraint(pm.model, sum(qt[a] for a in bus_arcs_to) @@ -188,6 +192,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] - 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) + # §14a generators have pf=1, q=0 ) else # Eq. (3.3ii), (3.4ii) cstr_p = JuMP.@constraint(pm.model, @@ -203,6 +208,8 @@ 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 (heat pumps) + - sum(p_cp14a[g] for g in bus_gen_cp_14a) # Virtual generators reduce net load (charging points) ) cstr_q = JuMP.@constraint(pm.model, sum(qt[a] for a in bus_arcs_to) @@ -217,6 +224,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..bb37fea6f 100644 --- a/edisgo/opf/eDisGo_OPF.jl/src/prob/opf_bf.jl +++ b/edisgo/opf/eDisGo_OPF.jl/src/prob/opf_bf.jl @@ -6,34 +6,70 @@ end "Build multinetwork branch flow OPF with multiple flexibilities" function build_mn_opf_bf_flex(pm::AbstractBFModelEdisgo) - if PowerModels.ref(pm, 1, :opf_version) in(1, 3) + opf_version = PowerModels.ref(pm, 1, :opf_version) + + if opf_version in(1, 3) eDisGo_OPF.variable_max_line_loading(pm, nw=1) # Eq. (3.41) (nur für Version 1 und 3) end + for (n, network) in PowerModels.nws(pm) # VARIABLES - if PowerModels.ref(pm, 1, :opf_version) in(1, 2, 3, 4) + if opf_version in(1, 2, 3, 4, 5) eDisGo_OPF.variable_branch_power_radial(pm, nw=n, bounded=false) # keine Begrenzung für Leistung auf Leitungen/Trafos (Strombegrenzung stattdessen) - if PowerModels.ref(pm, 1, :opf_version) in(1, 3) # nur für Version 1 und 3 (ohne Netzrestriktionen) + + if opf_version in(1, 3) # nur für Version 1 und 3 (ohne Netzrestriktionen) eDisGo_OPF.variable_branch_current(pm, nw=n, bounded=false) # keine Eq. (3.7)! eDisGo_OPF.variable_bus_voltage(pm, nw=n, bounded=false) # keine Eq. (3.8)! eDisGo_OPF.constraint_max_line_loading(pm, n) # Eq. (3.40) - else # nur für Version 2 und 4 (mit Netzrestriktionen) + else # Version 2, 4, 5 (mit Netzrestriktionen) eDisGo_OPF.variable_branch_current(pm, nw=n) # Eq. (3.7) und (3.7i) eDisGo_OPF.variable_gen_power_curt(pm, nw=n) # Eq. (3.44) für non-dispatchable Generators eDisGo_OPF.variable_slack_grid_restrictions(pm, nw=n) # Eq. (3.44)-(3.47) eDisGo_OPF.variable_bus_voltage(pm, nw=n) # Eq. (3.8) end + eDisGo_OPF.variable_slack_heat_pump_storage(pm, nw=n) # Eq. (3.44)-(3.47) - eDisGo_OPF.variable_battery_storage(pm, nw=n) # Eq. (3.11) und (3.12) - eDisGo_OPF.variable_heat_storage(pm, nw=n) # Eq. (3.24) - eDisGo_OPF.variable_heat_pump_power(pm, nw=n) # Eq. (3.20) - 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 - if PowerModels.ref(pm, 1, :opf_version) in(3, 4) # Nicht Teil der MA + # Version 5: ONLY §14a as flexibility + # - HP power (php) is FIXED to pd/cop (current demand) + # - CP power (pcp) is FIXED to pd (current demand from load) + # - No battery storage, heat storage, or DSM flexibility + # - Only p_hp14a and p_cp14a can change to reduce load + if opf_version == 5 + eDisGo_OPF.variable_heat_pump_power_fixed(pm, nw=n) + eDisGo_OPF.variable_cp_power_fixed(pm, nw=n) + # No storage/DSM - create empty dicts for power balance compatibility + PowerModels.var(pm, n)[:ps] = Dict{Int, JuMP.VariableRef}() + PowerModels.var(pm, n)[:phs] = Dict{Int, JuMP.VariableRef}() + PowerModels.var(pm, n)[:pdsm] = Dict{Int, JuMP.VariableRef}() + + # All slacks are left free in v5 — they carry extreme objective penalties + # (see objective_min_losses_14a_only) so the solver uses them only as a + # last resort when no generation-side or §14a action can satisfy grid limits. + else + eDisGo_OPF.variable_battery_storage(pm, nw=n) # Eq. (3.11) und (3.12) + eDisGo_OPF.variable_heat_storage(pm, nw=n) # Eq. (3.24) + eDisGo_OPF.variable_heat_pump_power(pm, nw=n) # Eq. (3.20) + 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) + end + + # §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 opf_version in(3, 4) # Nicht Teil der MA eDisGo_OPF.variable_slack_HV_requirements(pm, nw=n) - if PowerModels.ref(pm, 1, :opf_version) in(3) + if opf_version in(3) eDisGo_OPF.variable_gen_power_curt(pm, nw=n) end for i in PowerModels.ids(pm, :HV_requirements, nw=n) @@ -41,7 +77,7 @@ function build_mn_opf_bf_flex(pm::AbstractBFModelEdisgo) end end else - throw(ArgumentError("OPF version $(PowerModels.ref(pm, 1, :opf_version)) is not implemented! Choose between version 1 to 4.")) + throw(ArgumentError("OPF version $(opf_version) is not implemented! Choose between version 1 to 5.")) end # CONSTRAINTS @@ -53,53 +89,124 @@ function build_mn_opf_bf_flex(pm::AbstractBFModelEdisgo) end eDisGo_OPF.constraint_model_current(pm, nw=n) # Eq. (3.6) bzw. (3.6i) (je nachdem ob nicht-konvex oder konvex gelöst wird) und (3.6ii) + # HP operation constraint only for versions with flexible HPs (not v5) + if opf_version != 5 + for i in PowerModels.ids(pm, :heatpumps, nw=n) + eDisGo_OPF.constraint_hp_operation(pm, i, n) # Eq. (3.19) + end + 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 - for i in PowerModels.ids(pm, :heatpumps, nw=n) - eDisGo_OPF.constraint_hp_operation(pm, i, n) # Eq. (3.19) + # §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 - # CONSTRAINTS + # Storage/DSM state constraints - only for versions with flexible storage (not v5) network_ids = sort(collect(PowerModels.nw_ids(pm))) - for kind in ["storage", "heat_storage", "dsm"] + + if opf_version != 5 + for kind in ["storage", "heat_storage", "dsm"] + n_1 = network_ids[1] + for i in PowerModels.ids(pm, Symbol(kind), nw=n_1) + eDisGo_OPF.constraint_store_state(pm, i, nw=n_1, kind=kind) # Eq. (3.9)+(3.10), (3.22)+(3.23), (3.32)+(3.33) + end + + for n_2 in network_ids[2:end] + for i in PowerModels.ids(pm, Symbol(kind), nw=n_2) + eDisGo_OPF.constraint_store_state(pm, i, n_1, n_2, kind) # Eq. (3.10), (3.23), (3.33) + end + n_1 = n_2 + end + end + n_1 = network_ids[1] - for i in PowerModels.ids(pm, Symbol(kind), nw=n_1) - eDisGo_OPF.constraint_store_state(pm, i, nw=n_1, kind=kind) # Eq. (3.9)+(3.10), (3.22)+(3.23), (3.32)+(3.33) + + for i in PowerModels.ids(pm, :electromobility, nw=n_1) + eta = PowerModels.ref(pm, 1, :electromobility)[i]["eta"] + eDisGo_OPF.constraint_cp_state_initial(pm, n_1, i, eta) # Eq. (3.25) end for n_2 in network_ids[2:end] - for i in PowerModels.ids(pm, Symbol(kind), nw=n_2) - eDisGo_OPF.constraint_store_state(pm, i, n_1, n_2, kind) # Eq. (3.10), (3.23), (3.33) + for i in PowerModels.ids(pm, :electromobility, nw=n_2) + eta = PowerModels.ref(pm, 1, :electromobility)[i]["eta"] + eDisGo_OPF.constraint_cp_state(pm, n_1, n_2, i, eta) # Eq. (3.26) (und (3.25) für letzten Zeitschritt) end n_1 = n_2 end end - n_1 = network_ids[1] + # §14a EnWG daily time budget constraints for heat pumps + # Apply time budget constraint (max 2 hours/day) for each day in the optimization horizon + # Groups timesteps into 24-hour periods based on time_elapsed and constrains z_hp14a binary + if haskey(PowerModels.ref(pm, 1), :gen_hp_14a) && !isempty(PowerModels.ref(pm, 1, :gen_hp_14a)) + # Determine timesteps per day based on time_elapsed (in hours) + # Example: time_elapsed = 1.0 → 24 timesteps/day, time_elapsed = 0.5 → 48 timesteps/day + 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 and apply constraint per day per generator + # This ensures the §14a time budget (typically 2 hours/day) is respected + 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] - for i in PowerModels.ids(pm, :electromobility, nw=n_1) - eta = PowerModels.ref(pm, 1, :electromobility)[i]["eta"] - eDisGo_OPF.constraint_cp_state_initial(pm, n_1, i, eta) # Eq. (3.25) + # Apply daily time budget constraint for each §14a generator + # Constraint: sum(z_hp14a[t] * time_elapsed for t in day) <= max_hours_per_day + for i in PowerModels.ids(pm, :gen_hp_14a, nw=network_ids[1]) + eDisGo_OPF.constraint_hp_14a_time_budget_daily(pm, day_network_ids[1], day_network_ids[end], i) + end + end end - for n_2 in network_ids[2:end] - for i in PowerModels.ids(pm, :electromobility, nw=n_2) - eta = PowerModels.ref(pm, 1, :electromobility)[i]["eta"] - eDisGo_OPF.constraint_cp_state(pm, n_1, n_2, i, eta) # Eq. (3.26) (und (3.25) für letzten Zeitschritt) + # §14a EnWG daily time budget constraints for charging points + # Apply time budget constraint (max 2 hours/day) for each day in the optimization horizon + # Groups timesteps into 24-hour periods based on time_elapsed and constrains z_cp14a binary + if haskey(PowerModels.ref(pm, 1), :gen_cp_14a) && !isempty(PowerModels.ref(pm, 1, :gen_cp_14a)) + # Determine timesteps per day based on time_elapsed (in hours) + # Example: time_elapsed = 1.0 → 24 timesteps/day, time_elapsed = 0.5 → 48 timesteps/day + 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 and apply constraint per day per generator + # This ensures the §14a time budget (typically 2 hours/day) is respected + 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 CP generator + # Constraint: sum(z_cp14a[t] * time_elapsed for t in day) <= max_hours_per_day + for i in PowerModels.ids(pm, :gen_cp_14a, nw=network_ids[1]) + eDisGo_OPF.constraint_cp_14a_time_budget_daily(pm, day_network_ids[1], day_network_ids[end], i) + end end - n_1 = n_2 end # OBJECTIVE FUNCTION - if PowerModels.ref(pm, 1, :opf_version) == 1 + if opf_version == 1 #eDisGo_OPF.objective_min_losses(pm) eDisGo_OPF.objective_min_line_loading_max(pm) # Eq. (3.2 ii) - elseif (PowerModels.ref(pm, 1, :opf_version) == 3) # Nicht Teil der MA + elseif opf_version == 3 # Nicht Teil der MA eDisGo_OPF.objective_min_line_loading_max_OG(pm) - elseif PowerModels.ref(pm, 1, :opf_version) == 2 + elseif opf_version == 2 eDisGo_OPF.objective_min_losses_slacks(pm) # Eq. (3.2 iii) - elseif PowerModels.ref(pm, 1, :opf_version) == 4 + elseif opf_version == 4 eDisGo_OPF.objective_min_losses_slacks_OG(pm) # Nicht Teil der MA + elseif opf_version == 5 + eDisGo_OPF.objective_min_losses_14a_only(pm) # §14a als einzige Flexibilität, Feasibility-Slacks extrem bestraft end end diff --git a/edisgo/opf/powermodels_opf.py b/edisgo/opf/powermodels_opf.py index 85da160a8..a29a5a371 100644 --- a/edisgo/opf/powermodels_opf.py +++ b/edisgo/opf/powermodels_opf.py @@ -26,6 +26,8 @@ def pm_optimize( method: str = "soc", warm_start: bool = False, silence_moi: bool = False, + curtailment_14a: bool = False, + hours_limit_14a: int = 24, ) -> None: """ Run OPF for edisgo object in julia subprocess and write results of OPF to edisgo @@ -70,8 +72,11 @@ def pm_optimize( * 4 * Additional constraints: high voltage requirements * Objective: minimize line losses, HV slacks and grid related slacks + * 5 + * §14a curtailment as only flexibility tool + * Objective: minimize line losses + §14a usage, feasibility slacks at 1e8 - Must be one of [1, 2, 3, 4]. + Must be one of [1, 2, 3, 4, 5]. Default: 1. method : str Optimization method to use. Must be either "soc" (Second Order Cone) or "nc" @@ -93,6 +98,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. @@ -108,6 +118,7 @@ def pm_optimize( Default: True. """ + Topology.find_meshes(edisgo_obj) opf_dir = os.path.dirname(os.path.abspath(__file__)) solution_dir = os.path.join(opf_dir, "opf_solutions") @@ -118,6 +129,8 @@ def pm_optimize( flexible_loads=flexible_loads, flexible_storage_units=flexible_storage_units, opf_version=opf_version, + curtailment_14a=curtailment_14a, + hours_limit_14a = hours_limit_14a, ) def _convert(o): @@ -156,6 +169,23 @@ def _convert(o): break if out.rstrip().startswith('{"name"'): pm_opf = json.loads(out) + + ####check voltage values + import math + import statistics + all_voltages = [] + for t in pm_opf["nw"].keys(): + for bid in pm_opf["nw"][t]["bus"].keys(): + v = math.sqrt(pm_opf["nw"][t]["bus"][bid]["w"]) + all_voltages.append(v) + + print(f"OPF Voltage Statistics (all {len(all_voltages)} values):") + print(f" Min: {min(all_voltages):.4f} p.u.") + print(f" Max: {max(all_voltages):.4f} p.u.") + print(f" Mean: {statistics.mean(all_voltages):.4f} p.u.") + print(f" Violations (V<0.9 or V>1.1): {sum(1 for v in all_voltages if v<0.9 or v>1.1)} / {len(all_voltages)}") + ##### + # write results to edisgo object from_powermodels( edisgo_obj, 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/geo.py b/edisgo/tools/geo.py index 79b880a6b..c26fe3390 100755 --- a/edisgo/tools/geo.py +++ b/edisgo/tools/geo.py @@ -220,6 +220,40 @@ def find_nearest_bus(point, bus_target): return bus_target["dist"].idxmin(), bus_target["dist"].min() +def find_nearest_bus_14a(point, bus_target): + """ + 14a variant of find_nearest_bus. + + Faster nearest-bus lookup using a local planar approximation and KD-tree. + """ + import numpy as np + from scipy.spatial import cKDTree + + if len(bus_target) == 0: + raise ValueError("bus_target is empty.") + + coords = bus_target[["x", "y"]].dropna() + if len(coords) == 0: + raise ValueError("bus_target has no valid coordinates.") + + xs = coords["x"].to_numpy(dtype=float) + ys = coords["y"].to_numpy(dtype=float) + + # local projection (fast + sufficient accuracy) + lat0 = np.deg2rad(point.y) + x_km = xs * 111.320 * np.cos(lat0) + y_km = ys * 110.574 + + tree = cKDTree(np.column_stack((x_km, y_km))) + + px_km = point.x * 111.320 * np.cos(lat0) + py_km = point.y * 110.574 + + dist, idx = tree.query([px_km, py_km], k=1) + + return coords.index[idx], float(dist) + + def find_nearest_conn_objects(grid_topology, bus, lines, conn_diff_tolerance=0.0001): """ Searches all lines for the nearest possible connection object per line. diff --git a/edisgo/tools/loma_tools.py b/edisgo/tools/loma_tools.py new file mode 100644 index 000000000..c17df45ea --- /dev/null +++ b/edisgo/tools/loma_tools.py @@ -0,0 +1,1571 @@ +import logging +import os + +import contextily as ctx +import geopandas as gpd +import imageio.v2 as imageio +import matplotlib.colors as mcolors +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +from shapely.geometry import Point +from shapely.strtree import STRtree + +from sqlalchemy.engine.base import Engine + +from edisgo.flex_opt.battery_storage_operation import _reference_operation +from edisgo.io.db import get_srid_of_db_table, session_scope_egon_data +from edisgo.tools.config import Config + +logger = logging.getLogger(__name__) + +# ========================== +# Transferring timeseries from new edisgo cp to matched existing/additional cp +# ========================== +def transfer_ts_from_new_to_existing_cp( + edisgo, + *, + existing_markers=("Existing", "Additional"), + radius_1=2000.0, + tol_1=0.15, + radius_2=2000.0, + tol_2=0.9, + metric_epsg=32632, + keep_existing_bus_and_pset=True, + drop_new=True, +): + buses = edisgo.topology.buses_df[["x", "y"]].copy() + + loads = edisgo.topology.loads_df.copy() + charging_points = loads[loads["type"] == "charging_point"].copy() + + existing_mask = charging_points.index.astype(str).str.contains( + "|".join(existing_markers), + regex=True, + ) + + existing_cp = charging_points[existing_mask].copy() + new_cp = charging_points[~existing_mask].copy() + + grid_srid = int(edisgo.topology.grid_district.get("srid", 4326)) + + existing_gdf = _cp_points_gdf(existing_cp, buses=buses, crs_epsg=grid_srid) + new_gdf = _cp_points_gdf(new_cp, buses=buses, crs_epsg=grid_srid) + + existing_m = existing_gdf.to_crs(epsg=metric_epsg) + new_m = new_gdf.to_crs(epsg=metric_epsg) + + matches, unmatched_existing, unused_new = _match_two_step_nearest( + existing_m, + new_m, + radius_1=radius_1, + tol_1=tol_1, + radius_2=radius_2, + tol_2=tol_2, + ) + + _transfer_ts_and_replace_new_with_existing( + edisgo, + matches, + keep_existing_bus_and_pset=keep_existing_bus_and_pset, + drop_new=drop_new, + ) + + return { + "matches": matches, + "unmatched_existing": unmatched_existing, + "unused_new": unused_new, + "num_matches": len(matches), + "num_unmatched_existing": len(unmatched_existing), + "num_unused_new": len(unused_new), + } + +def _cp_points_gdf( + loads_df_subset: pd.DataFrame, + *, + buses: pd.DataFrame, + crs_epsg: int = 4326, + ) -> gpd.GeoDataFrame: + """ + Build a GeoDataFrame of CPs with geometry at their connected bus coordinates. + Assumes buses_df x/y are in EPSG:4326 if edisgo topology says srid=4326. + """ + if not {"x", "y"}.issubset(buses.columns): + raise KeyError("buses must contain 'x' and 'y' columns") + + df = loads_df_subset[["bus", "p_set"]].copy() + df = df.join(buses, on="bus", how="left") + + missing_xy = df["x"].isna() | df["y"].isna() + if missing_xy.any(): + missing_ids = df.index[missing_xy].tolist()[:10] + raise ValueError( + f"Missing bus coordinates for {missing_xy.sum()} loads. Example IDs: {missing_ids}" + ) + + gdf = gpd.GeoDataFrame( + df, + geometry=[Point(xy) for xy in zip(df["x"].values, df["y"].values)], + crs=f"EPSG:{crs_epsg}", + ) + return gdf + +# -------------------------- +# Matching EXISTING -> NEW by nearest bus location + p_set tolerance +# -------------------------- +def _match_two_step_nearest( + existing_m: gpd.GeoDataFrame, + new_m: gpd.GeoDataFrame, + *, + radius_1=2000.0, + tol_1=0.15, + radius_2=2000.0, + tol_2=0.90, + w_dist_2=0.3, + w_pset_2=0.7, +): + from scipy.optimize import linear_sum_assignment + + new_ids = np.array(new_m.index.to_list(), dtype=object) + new_geoms = np.array(new_m.geometry.values, dtype=object) + + tree = STRtree(new_geoms) + + new_pset = new_m["p_set"].astype(float) + ex_pset = existing_m["p_set"].astype(float) + + used_new = set() + + phase1_matches = [] + phase2_matches = [] + + def _candidate_pairs(existing_ids, max_radius_m, pset_tol): + """ + Build all admissible candidate pairs for the current phase. + + Note: + tree.query(ex_geom.buffer(max_radius_m)) is used only as a coarse + spatial preselection via bounding-box intersection. + We intentionally do NOT apply a hard dist <= max_radius_m cutoff here, + because slightly more distant candidates are allowed as fallback. + """ + pairs = [] + + for ex_id in existing_ids: + ex_geom = existing_m.loc[ex_id].geometry + p_ex = float(ex_pset.loc[ex_id]) + + idxs = tree.query(ex_geom.buffer(max_radius_m)) + if idxs is None or len(idxs) == 0: + continue + + p_lo, p_hi = p_ex * (1 - pset_tol), p_ex * (1 + pset_tol) + + for j in idxs: + new_id = new_ids[j] + + if new_id in used_new: + continue + + p_new = float(new_pset.loc[new_id]) + if not (p_lo <= p_new <= p_hi): + continue + + dist = float(ex_geom.distance(new_geoms[j])) + rel_dev = abs(p_new / p_ex - 1.0) if p_ex != 0 else 0.0 + + pairs.append((ex_id, new_id, dist, rel_dev)) + + return pairs + + def _greedy_phase1(existing_ids, max_radius_m, pset_tol): + """ + Phase 1: + Greedy nearest matching with tight p_set tolerance. + Primary criterion: distance + Secondary criterion: relative p_set deviation + """ + pass_matches = [] + pass_unmatched = [] + + for ex_id in existing_ids: + ex_geom = existing_m.loc[ex_id].geometry + p_ex = float(ex_pset.loc[ex_id]) + + idxs = tree.query(ex_geom.buffer(max_radius_m)) + if idxs is None or len(idxs) == 0: + pass_unmatched.append(ex_id) + continue + + p_lo, p_hi = p_ex * (1 - pset_tol), p_ex * (1 + pset_tol) + + best = None + for j in idxs: + new_id = new_ids[j] + + if new_id in used_new: + continue + + p_new = float(new_pset.loc[new_id]) + if not (p_lo <= p_new <= p_hi): + continue + + dist = float(ex_geom.distance(new_geoms[j])) + rel_dev = abs(p_new / p_ex - 1.0) if p_ex != 0 else 0.0 + + # distance-dominated in phase 1 + cand = (dist, rel_dev, new_id) + + if best is None or cand < best: + best = cand + + if best is None: + pass_unmatched.append(ex_id) + else: + dist_best, rel_dev_best, new_best = best + used_new.add(new_best) + pass_matches.append((ex_id, new_best, dist_best)) + + return pass_matches, pass_unmatched + + def _global_phase2(existing_ids, max_radius_m, pset_tol, w_dist, w_pset): + """ + Phase 2: + Global bipartite assignment for all remaining unmatched existing CPs. + + Objective: + weighted score of normalized distance and normalized p_set deviation. + """ + pairs = _candidate_pairs(existing_ids, max_radius_m, pset_tol) + + if len(existing_ids) == 0: + return [], [] + + if len(pairs) == 0: + return [], list(existing_ids) + + ex_list = list(existing_ids) + new_list = sorted({new_id for _, new_id, _, _ in pairs}) + + ex_pos = {ex_id: i for i, ex_id in enumerate(ex_list)} + new_pos = {new_id: j for j, new_id in enumerate(new_list)} + + BIG = 1e12 + cost = np.full((len(ex_list), len(new_list)), BIG, dtype=float) + + for ex_id, new_id, dist, rel_dev in pairs: + i = ex_pos[ex_id] + j = new_pos[new_id] + + dist_norm = dist / max_radius_m if max_radius_m > 0 else dist + pset_norm = rel_dev / pset_tol if pset_tol > 0 else rel_dev + + score = w_dist * dist_norm + w_pset * pset_norm + + if score < cost[i, j]: + cost[i, j] = score + + row_ind, col_ind = linear_sum_assignment(cost) + + pass_matches = [] + matched_existing = set() + + for i, j in zip(row_ind, col_ind): + if cost[i, j] >= BIG: + continue + + ex_id = ex_list[i] + new_id = new_list[j] + + ex_geom = existing_m.loc[ex_id].geometry + new_geom = new_m.loc[new_id].geometry + dist = float(ex_geom.distance(new_geom)) + + used_new.add(new_id) + matched_existing.add(ex_id) + pass_matches.append((ex_id, new_id, dist)) + + pass_unmatched = [ex_id for ex_id in ex_list if ex_id not in matched_existing] + return pass_matches, pass_unmatched + + def _log_phase(phase_name, phase_matches, tol): + print(f"[EV] {phase_name} matches (p_set tol={tol}): {len(phase_matches)}") + for i, (ex_id, new_id, dist) in enumerate(phase_matches, 1): + bus = existing_m.loc[ex_id, "bus"] + p_ex = float(existing_m.loc[ex_id, "p_set"]) + p_new = float(new_m.loc[new_id, "p_set"]) + rel_dev = (p_new / p_ex - 1.0) if p_ex != 0 else float("nan") + print( + f"[EV] [{phase_name.upper()}][{i}] " + f"{ex_id} (bus={bus}, p_set_old={p_ex:.6f}) " + f"-> {new_id} (p_set_new={p_new:.6f}) " + f"dist={dist:.1f} m rel_dev={rel_dev:+.2%}" + ) + + # ---- Phase 1: greedy, distance-dominated ---- + m1, u1 = _greedy_phase1(list(existing_m.index), radius_1, tol_1) + phase1_matches.extend(m1) + + # ---- Phase 2: one global pass for all remaining ---- + m2, u2 = _global_phase2(u1, radius_2, tol_2, w_dist_2, w_pset_2) + phase2_matches.extend(m2) + + matches = m1 + m2 + unused_new = [i for i in new_ids.tolist() if i not in used_new] + + _log_phase("Phase 2", phase2_matches, tol_2) + + print(f"[EV] Unmatched existing CPs after Phase 2: {len(u2)}") + for i, ex_id in enumerate(u2, 1): + bus = existing_m.loc[ex_id, "bus"] + p_ex = float(existing_m.loc[ex_id, "p_set"]) + print(f"[EV] [UNMATCHED][{i}] {ex_id} (bus={bus}, p_set_old={p_ex:.6f})") + + return matches, u2, unused_new + +# -------------------------- +# Transfer time series + electromobility mapping NEW -> EXISTING, then remove NEW loads +# -------------------------- +def _transfer_ts_and_replace_new_with_existing( + edisgo, + matches, + keep_existing_bus_and_pset=True, + drop_new=True, +): + tindex = edisgo.timeseries.timeindex + + icp = edisgo.electromobility.integrated_charging_parks_df + if icp is None or icp.empty: + raise ValueError("integrated_charging_parks_df missing/empty.") + if "edisgo_id" not in icp.columns: + raise KeyError(f"Missing 'edisgo_id' in integrated_charging_parks_df. Columns: {list(icp.columns)}") + + for existing_id, new_id, dist in matches: + # --- P --- + if new_id in edisgo.timeseries.loads_active_power.columns: + sP = edisgo.timeseries.loads_active_power[new_id].reindex(tindex) + + edisgo.timeseries.drop_component_time_series("loads_active_power", [existing_id]) + edisgo.timeseries.add_component_time_series( + "loads_active_power", + pd.DataFrame({existing_id: sP.values}, index=tindex), + ) + else: + print(f"[EV] new_id has no active TS: {new_id}") + + # --- Q (optional) --- + if new_id in edisgo.timeseries.loads_reactive_power.columns: + sQ = edisgo.timeseries.loads_reactive_power[new_id].reindex(tindex) + + edisgo.timeseries.drop_component_time_series("loads_reactive_power", [existing_id]) + edisgo.timeseries.add_component_time_series( + "loads_reactive_power", + pd.DataFrame({existing_id: sQ.values}, index=tindex), + ) + + # --- mapping: new -> existing --- + mask = icp["edisgo_id"] == new_id + if mask.any(): + icp.loc[mask, "edisgo_id"] = existing_id + + # keep existing bus+p_set: do NOT touch those columns + if keep_existing_bus_and_pset and existing_id in edisgo.topology.loads_df.index: + edisgo.topology.loads_df.at[existing_id, "type"] = "charging_point" + + # --- remove NEW load + TS to keep CP count constant --- + if drop_new and (new_id in edisgo.topology.loads_df.index): + if new_id in edisgo.timeseries.loads_active_power.columns: + edisgo.timeseries.drop_component_time_series("loads_active_power", [new_id]) + if new_id in edisgo.timeseries.loads_reactive_power.columns: + edisgo.timeseries.drop_component_time_series("loads_reactive_power", [new_id]) + + edisgo.remove_component("load", new_id) + + edisgo.electromobility.integrated_charging_parks_df = icp + +# ============================================================ +# Utilities for sensitivity analysis +# - supports charging points and heat pumps +# - target by absolute value or relative percentage +# - for charging points: existing ones can be removed last +# - duplicates/removes topology rows + power-flow time series +# ============================================================ + +# ============================================================ +# Basic helpers +# ============================================================ +def _make_unique_load_id(existing_index, base_name): + """ + Create a unique load ID not yet present in loads_df.index. + """ + if base_name not in existing_index: + return base_name + + i = 1 + while f"{base_name}_{i}" in existing_index: + i += 1 + return f"{base_name}_{i}" + +def buses_with_existing_loads(edisgo): + """ + Return buses that already host at least one load. + """ + return edisgo.topology.loads_df["bus"].dropna().unique() + +def buses_with_same_load_type(edisgo, load_type): + """ + Return buses that already host at least one load of the given type. + """ + df = edisgo.topology.loads_df + return df.loc[df["type"] == load_type, "bus"].dropna().unique() + +def get_load_ids_by_type(edisgo, load_type): + """ + Return all load IDs of one load type. + """ + loads_df = edisgo.topology.loads_df + return loads_df.index[loads_df["type"] == load_type].tolist() + +def split_ids_by_marker(load_ids, marker="Existing"): + """ + Split IDs into two groups based on a substring marker. + Helps with removin existing loads last. + Currently only used for charging points. + + Parameters + ---------- + load_ids : list-like + marker : str | None + + Returns + ------- + tuple[list[str], list[str]] + (marked_ids, unmarked_ids) + + Notes + ----- + If marker is None, everything is returned as unmarked. + """ + load_ids = list(load_ids) + + if marker is None: + return [], load_ids + + marked_ids = [i for i in load_ids if marker in str(i)] + unmarked_ids = [i for i in load_ids if marker not in str(i)] + return marked_ids, unmarked_ids + +def _resolve_target_total(current_count, *, target_total=None, percentage=None): + """ + Resolve desired final count. + + Exactly one of target_total or percentage must be provided. + + Parameters + ---------- + current_count : int + Current number of loads + target_total : int | None + Desired final absolute number + percentage : float | None + Relative change of current count: + +0.10 -> increase by 10% + -0.10 -> decrease by 10% + + Returns + ------- + int + Desired final load count + """ + if (target_total is None) == (percentage is None): + raise ValueError("Provide exactly one of 'target_total' or 'percentage'.") + + if target_total is not None: + if target_total < 0: + raise ValueError("target_total must be >= 0") + return int(target_total) + + desired_total = int(round(current_count * (1 + percentage))) + return max(0, desired_total) + +# ============================================================ +# Selection logic +# ============================================================ +def _select_keep_ids( + candidate_ids, + *, + target_total, + seed=42, +): + """ + Randomly select IDs to keep. + """ + rng = np.random.default_rng(seed) + candidate_ids = list(candidate_ids) + + if target_total < 0: + raise ValueError("target_total must be >= 0") + + if target_total >= len(candidate_ids): + return candidate_ids.copy() + + if target_total == 0: + return [] + + return rng.choice(np.array(candidate_ids), size=target_total, replace=False).tolist() + +def _select_keep_ids_by_removal_priority( + candidate_ids, + *, + target_total, + seed=42, + removal_priority=None, +): + """ + Select IDs to keep such that removal happens in priority stages. + + Removal order: + 1. IDs without any marker + 2. IDs matching earlier markers in `removal_priority` + 3. IDs matching later markers in `removal_priority` + + Example + ------- + removal_priority = ["Additional", "Existing"] + + -> removal order: + - no marker + - "Additional" + - "Existing" + + Parameters + ---------- + candidate_ids : list[str] + target_total : int + seed : int + removal_priority : list[str] | None + Markers ordered from earlier removable to later removable. + Later entries are protected longer. + + Returns + ------- + list[str] + IDs to keep + """ + rng = np.random.default_rng(seed) + candidate_ids = list(candidate_ids) + + if target_total < 0: + raise ValueError("target_total must be >= 0") + + if target_total >= len(candidate_ids): + return candidate_ids.copy() + + if target_total == 0: + return [] + + if not removal_priority: + return rng.choice(np.array(candidate_ids), size=target_total, replace=False).tolist() + + # Buckets: + # bucket 0 = IDs without any marker + # bucket 1 = first marker in removal_priority + # bucket 2 = second marker in removal_priority + # ... + buckets = {i: [] for i in range(len(removal_priority) + 1)} + + for load_id in candidate_ids: + load_id_str = str(load_id) + + matched_bucket = 0 + for i, marker in enumerate(removal_priority, start=1): + if marker in load_id_str: + matched_bucket = i + buckets[matched_bucket].append(load_id) + + # Keep from highest protection bucket first + keep_ids = [] + remaining = target_total + + for bucket_idx in range(len(removal_priority), -1, -1): + bucket_ids = buckets[bucket_idx] + + if remaining <= 0: + break + + if remaining >= len(bucket_ids): + keep_ids.extend(bucket_ids) + remaining -= len(bucket_ids) + else: + keep_ids.extend( + rng.choice(np.array(bucket_ids), size=remaining, replace=False).tolist() + ) + remaining = 0 + + return keep_ids + +def _select_source_ids_for_duplication( + candidate_ids, + *, + n_add, + seed=42, +): + """ + Select source IDs for duplication. + + Sampling is with replacement to allow arbitrary growth. + No special priority logic is applied. + """ + rng = np.random.default_rng(seed) + candidate_ids = list(candidate_ids) + + if len(candidate_ids) == 0: + raise ValueError("No candidate source IDs available for duplication.") + + return rng.choice(np.array(candidate_ids), size=n_add, replace=True) + +# ============================================================ +# Topology / time-series write helpers +# ============================================================ +def _duplicate_loads_from_source_ids( + edisgo, + *, + source_ids, + load_type, + eligible_buses=None, + seed=42, + copy_reactive_power=True, + name_prefix=None, + avoid_source_bus=False, + add_tracking_columns=False, +): + """ + Duplicate given source loads to eligible buses. + """ + rng = np.random.default_rng(seed) + + loads_df = edisgo.topology.loads_df + tindex = edisgo.timeseries.timeindex + + if name_prefix is None: + name_prefix = f"{load_type}_dup" + + if eligible_buses is None: + eligible_buses = buses_with_same_load_type(edisgo, load_type) + + eligible_buses = np.array(pd.Index(eligible_buses).dropna().unique()) + + if len(eligible_buses) == 0: + raise ValueError(f"No eligible buses available for load type '{load_type}'.") + + new_rows = [] + new_p_ts = {} + new_q_ts = {} + new_ids = [] + + for k, src_id in enumerate(source_ids, start=1): + if src_id not in loads_df.index: + raise KeyError(f"Source load '{src_id}' not found in topology.loads_df.") + + src_row = loads_df.loc[src_id].copy() + src_bus = src_row["bus"] + + if avoid_source_bus: + bus_pool = eligible_buses[eligible_buses != src_bus] + if len(bus_pool) == 0: + raise ValueError( + f"No eligible target buses left after excluding source bus '{src_bus}'." + ) + else: + bus_pool = eligible_buses + + tgt_bus = rng.choice(bus_pool) + + new_id_base = f"{name_prefix}_{k}" + new_id = _make_unique_load_id(loads_df.index.union(pd.Index(new_ids)), new_id_base) + + new_row = src_row.copy() + new_row.name = new_id + new_row["bus"] = tgt_bus + new_row["type"] = load_type + + if add_tracking_columns: + new_row["source_load_id"] = src_id + new_row["is_duplicate"] = True + + new_rows.append(new_row) + new_ids.append(new_id) + + if src_id in edisgo.timeseries.loads_active_power.columns: + new_p_ts[new_id] = ( + edisgo.timeseries.loads_active_power[src_id] + .reindex(tindex) + .values + ) + else: + raise KeyError(f"Source load '{src_id}' has no active power time series.") + + if copy_reactive_power and src_id in edisgo.timeseries.loads_reactive_power.columns: + new_q_ts[new_id] = ( + edisgo.timeseries.loads_reactive_power[src_id] + .reindex(tindex) + .values + ) + + new_rows_df = pd.DataFrame(new_rows) + edisgo.topology.loads_df = pd.concat([edisgo.topology.loads_df, new_rows_df], axis=0) + + new_p_df = pd.DataFrame(new_p_ts, index=tindex) + edisgo.timeseries.add_component_time_series("loads_active_power", new_p_df) + + if new_q_ts: + new_q_df = pd.DataFrame(new_q_ts, index=tindex) + edisgo.timeseries.add_component_time_series("loads_reactive_power", new_q_df) + + return new_ids + +def export_removed_loads_report( + edisgo, + removed_ids, + *, + output_dir, + file_prefix, +): + """ + Export removed loads to CSV and SHP using the bus coordinates they were + connected to before removal. + """ + os.makedirs(output_dir, exist_ok=True) + + if not removed_ids: + print("[EXPORT] No removed IDs to export.") + return None + + loads = edisgo.topology.loads_df.loc[removed_ids].copy() + + buses = edisgo.topology.buses_df[["x", "y"]].copy() + loads = loads.join(buses, on="bus", how="left") + + csv_path = os.path.join(output_dir, f"{file_prefix}.csv") + loads.to_csv(csv_path, index=True) + print(f"[EXPORT] Wrote CSV: {csv_path}") + + shp_path = None + if {"x", "y"}.issubset(loads.columns): + shp_df = loads.copy() + shp_df["geometry"] = shp_df.apply(lambda r: Point(r["x"], r["y"]), axis=1) + gdf = gpd.GeoDataFrame(shp_df, geometry="geometry", crs="EPSG:4326") + + shp_path = os.path.join(output_dir, f"{file_prefix}.shp") + gdf.to_file(shp_path, driver="ESRI Shapefile") + print(f"[EXPORT] Wrote SHP: {shp_path}") + + return { + "csv": csv_path, + "shp": shp_path, + "removed_ids": removed_ids, + } + +def _remove_load_ids( + edisgo, + remove_ids, + *, + export_removed=False, + export_dir=None, + export_prefix=None, +): + """ + Remove specified loads from topology and time series. + """ + if not remove_ids: + return [] + + if export_removed and export_dir is None: + raise ValueError("export_dir must be provided when export_removed=True") + + if export_removed: + export_removed_loads_report( + edisgo, + remove_ids, + output_dir=export_dir, + file_prefix=export_prefix or "removed_loads", + ) + + p_cols = [i for i in remove_ids if i in edisgo.timeseries.loads_active_power.columns] + q_cols = [i for i in remove_ids if i in edisgo.timeseries.loads_reactive_power.columns] + + if p_cols: + edisgo.timeseries.drop_component_time_series("loads_active_power", p_cols) + + if q_cols: + edisgo.timeseries.drop_component_time_series("loads_reactive_power", q_cols) + + for i in remove_ids: + if i in edisgo.topology.loads_df.index: + edisgo.remove_component("load", i) + + return remove_ids + +# ============================================================ +# Main generic function +# ============================================================ +def set_loads_to_target( + edisgo, + *, + load_type, + target_total=None, + percentage=None, + seed=42, + eligible_buses=None, + avoid_source_bus=False, + add_tracking_columns=False, + copy_reactive_power=True, + export_removed=False, + export_dir=None, + export_prefix=None, + name_prefix=None, + remove_marked_last=True, + removal_priority=None, +): + """ + Set final number of loads of one type to a target value. + + Supports: + - absolute control via target_total + - relative control via percentage + + Increase logic: + - duplicates are created from the full current population + - no existing/new priority is applied + + Reduction logic: + - remove_marked_last=False: fully random removal + - remove_marked_last=True: staged removal according to `removal_priority` + + Parameters + ---------- + edisgo : object + eDisGo object + load_type : str + e.g. "charging_point" or "heat_pump" + target_total : int | None + Final desired absolute number of loads + percentage : float | None + Relative change: + +0.10 -> final count = round(current * 1.10) + -0.10 -> final count = round(current * 0.90) + seed : int + RNG seed + eligible_buses : array-like | None + Eligible target buses for added duplicates + avoid_source_bus : bool + If True, duplicates will not be placed on their source bus + add_tracking_columns : bool + If True, write source_load_id and is_duplicate into new rows + copy_reactive_power : bool + export_removed : bool + export_dir : str | None + export_prefix : str | None + name_prefix : str | None + remove_marked_last : bool + If True, removal follows staged priority. + removal_priority : list[str] | None + Markers ordered from earlier removable to later removable. + Example: + ["Additional", "Existing"] + means: + no marker -> Additional -> Existing + """ + current_ids = get_load_ids_by_type(edisgo, load_type) + current_count = len(current_ids) + + if current_count == 0: + raise ValueError(f"No loads of type '{load_type}' found.") + + desired_total = _resolve_target_total( + current_count, + target_total=target_total, + percentage=percentage, + ) + + if name_prefix is None: + name_prefix = f"{load_type}_dup" + + print(f"[{load_type}] Current count: {current_count}") + print(f"[{load_type}] Target count: {desired_total}") + + # no change + if desired_total == current_count: + print(f"[{load_type}] Nothing to do.") + return { + "kept_ids": current_ids.copy(), + "removed_ids": [], + "new_ids": [], + "final_count": current_count, + } + + # reduction + if desired_total < current_count: + if remove_marked_last: + keep_ids = _select_keep_ids_by_removal_priority( + current_ids, + target_total=desired_total, + seed=seed, + removal_priority=removal_priority, + ) + else: + keep_ids = _select_keep_ids( + current_ids, + target_total=desired_total, + seed=seed, + ) + + keep_set = set(keep_ids) + remove_ids = [i for i in current_ids if i not in keep_set] + + _remove_load_ids( + edisgo, + remove_ids, + export_removed=export_removed, + export_dir=export_dir, + export_prefix=export_prefix or f"removed_{load_type}", + ) + + final_count = int((edisgo.topology.loads_df["type"] == load_type).sum()) + + print(f"[{load_type}] Removed count: {len(remove_ids)}") + print(f"[{load_type}] Final count: {final_count}") + + return { + "kept_ids": keep_ids, + "removed_ids": remove_ids, + "new_ids": [], + "final_count": final_count, + } + + # increase + n_add = desired_total - current_count + source_ids = _select_source_ids_for_duplication( + current_ids, + n_add=n_add, + seed=seed, + ) + + new_ids = _duplicate_loads_from_source_ids( + edisgo, + source_ids=source_ids, + load_type=load_type, + eligible_buses=eligible_buses, + seed=seed, + copy_reactive_power=copy_reactive_power, + name_prefix=name_prefix, + avoid_source_bus=avoid_source_bus, + add_tracking_columns=add_tracking_columns, + ) + + final_count = int((edisgo.topology.loads_df["type"] == load_type).sum()) + + print(f"[{load_type}] Added count: {len(new_ids)}") + print(f"[{load_type}] Final count: {final_count}") + + return { + "kept_ids": current_ids.copy(), + "removed_ids": [], + "new_ids": new_ids, + "final_count": final_count, + } + +# ============================================================ +# Convenience wrappers: charging points +# ============================================================ +def set_charging_points_to_target( + edisgo, + *, + target_total=None, + percentage=None, + seed=42, + eligible_buses=None, + removal_priority=None, + remove_existing_last=True, + avoid_source_bus=False, + add_tracking_columns=False, + export_removed=False, + export_dir=None, +): + """ + Set charging points to a final target count. + + Important + --------- + - when increasing: no existing/new priority is applied + - when reducing: removal follows staged priority + + Default removal order: + no marker -> Additional -> Existing + """ + if removal_priority is None: + removal_priority = ["Additional", "Existing"] + + return set_loads_to_target( + edisgo, + load_type="charging_point", + target_total=target_total, + percentage=percentage, + seed=seed, + eligible_buses=eligible_buses, + avoid_source_bus=avoid_source_bus, + add_tracking_columns=add_tracking_columns, + copy_reactive_power=True, + export_removed=export_removed, + export_dir=export_dir, + export_prefix="removed_charging_points", + name_prefix="cp_dup", + remove_marked_last=remove_existing_last, + removal_priority=removal_priority, + ) + +# ============================================================ +# Convenience wrappers: heat pumps +# ============================================================ +def set_heat_pumps_to_target( + edisgo, + *, + target_total=None, + percentage=None, + seed=42, + eligible_buses=None, + hp_type="heat_pump", + avoid_source_bus=False, + add_tracking_columns=False, + export_removed=False, + export_dir=None, +): + """ + Set heat pumps to a final target count. + + No existing/new prioritization is applied. + """ + return set_loads_to_target( + edisgo, + load_type=hp_type, + target_total=target_total, + percentage=percentage, + seed=seed, + eligible_buses=eligible_buses, + avoid_source_bus=avoid_source_bus, + add_tracking_columns=add_tracking_columns, + copy_reactive_power=True, + export_removed=export_removed, + export_dir=export_dir, + export_prefix=f"removed_{hp_type}", + name_prefix="hp_dup", + remove_marked_last=False, + removal_priority=None, + ) + + +# Helper function for 14a functions +def _get_intersecting_mv_grid_ids_from_shapefile( + engine: Engine, + shapefile_path: str, +): + """ + Determine all mv_grid_ids whose charging infrastructure intersects the given shapefile. + This is used as a common spatial selector for both cars and charging parks. + """ + from sqlalchemy import func + + if shapefile_path is None: + raise ValueError("shapefile_path must be provided.") + + config = Config() + (egon_emob_charging_infrastructure,) = config.import_tables_from_oep( + engine, ["egon_emob_charging_infrastructure"], "grid" + ) + + local_shape = gpd.read_file(shapefile_path) + + with session_scope_egon_data(engine) as session: + srid = get_srid_of_db_table(session, egon_emob_charging_infrastructure.geometry) + + local_shape = local_shape.to_crs(f"EPSG:{srid}") + shape_union = local_shape.unary_union + shape_wkt = shape_union.wkt + + query = ( + session.query(egon_emob_charging_infrastructure.mv_grid_id) + .filter( + func.ST_Intersects( + egon_emob_charging_infrastructure.geometry, + func.ST_GeomFromText(shape_wkt, srid), + ) + ) + .distinct() + ) + + mv_grid_ids = pd.read_sql(sql=query.statement, con=session.bind)[ + "mv_grid_id" + ].tolist() + + mv_grid_ids = sorted(set(mv_grid_ids)) + + if len(mv_grid_ids) == 0: + print( + "No intersecting mv_grid_ids found for shapefile %s.", shapefile_path + ) + + return mv_grid_ids + + +# ========================== +# Storage timeseries +# ========================== + + +def set_storage_timeseries_bus_level(edisgo, soe_init=0.0, freq=None): + """ + Set storage unit active power time series based on bus-level net balance. + + Charges when total generation at the bus exceeds total demand (surplus), + discharges when demand exceeds total generation (deficit). The battery + never exchanges energy with the grid — it only covers local surpluses and + deficits at its own bus. + + When multiple storage units share a bus the net balance signal is split + proportionally by p_nom so that their combined power never exceeds the + available surplus or deficit. + + Parameters + ---------- + edisgo : EDisGo + soe_init : float + Initial state of energy in MWh for all storage units. Default: 0. + freq : float or None + Timestep length in hours (e.g. 1.0 for hourly, 0.25 for 15-min). + Inferred from timeindex when None. + """ + stor_df = edisgo.topology.storage_units_df.copy() + if stor_df.empty: + return + + timeindex = edisgo.timeseries.timeindex + + if freq is None: + freq = ( + (timeindex[1] - timeindex[0]).total_seconds() / 3600 + if len(timeindex) > 1 + else 1.0 + ) + + for col in ("efficiency_store", "efficiency_dispatch"): + if col not in stor_df.columns: + stor_df[col] = float("nan") + mask = stor_df[col].isna() + if mask.any(): + logger.warning( + f"'{col}' not set for storage units {stor_df.index[mask].tolist()}. " + "Defaulting to 0.95." + ) + stor_df.loc[mask, col] = 0.95 + + gen_df = edisgo.topology.generators_df + load_df = edisgo.topology.loads_df + gen_ts = edisgo.timeseries.generators_active_power + load_ts = edisgo.timeseries.loads_active_power + + def _bus_sum(component_df, ts): + result = {} + for bus, group in component_df.groupby("bus"): + cols = [c for c in group.index if c in ts.columns] + result[bus] = ts[cols].sum(axis=1) if cols else pd.Series(0.0, index=timeindex) + return result + + all_gen_at_bus = _bus_sum(gen_df, gen_ts) + demand_at_bus = _bus_sum(load_df, load_ts) + + storage_ts_dict = {} + for bus, bus_stor in stor_df.groupby("bus"): + net_signal = all_gen_at_bus.get( + bus, pd.Series(0.0, index=timeindex) + ) - demand_at_bus.get(bus, pd.Series(0.0, index=timeindex)) + + if all_gen_at_bus.get(bus, pd.Series(0.0, index=timeindex)).abs().sum() == 0: + logger.warning( + f"Bus {bus} has no generation. " + f"Storage units {bus_stor.index.tolist()} will remain at zero dispatch." + ) + + total_p_nom = bus_stor["p_nom"].sum() + for stor_name, stor_data in bus_stor.iterrows(): + if pd.isna(stor_data["max_hours"]): + raise ValueError( + f"'max_hours' not set for storage unit {stor_name}." + ) + scale = stor_data["p_nom"] / total_p_nom + result = _reference_operation( + df=pd.DataFrame({"feedin_minus_demand": net_signal * scale}), + soe_init=soe_init, + soe_max=stor_data["p_nom"] * stor_data["max_hours"], + storage_p_nom=stor_data["p_nom"], + freq=freq, + efficiency_store=stor_data["efficiency_store"], + efficiency_dispatch=stor_data["efficiency_dispatch"], + ) + storage_ts_dict[stor_name] = result["storage_power"] + + edisgo.timeseries.storage_units_active_power = pd.DataFrame( + storage_ts_dict, index=timeindex + ) + + edisgo.set_time_series_reactive_power_control( + generators_parametrisation=None, + loads_parametrisation=None, + storage_units_parametrisation=pd.DataFrame( + { + "components": [stor_df.index.tolist()], + "mode": ["default"], + "power_factor": ["default"], + }, + index=[1], + ), + ) + + +# ========================== +# §14a visualization and curtailment utilities +# ========================== + + +def get_curtailment_data(edisgo): + """ + Return the §14a virtual generator curtailment time series. + + Returns a DataFrame of generators_active_power columns corresponding to + hp_14a_support and cp_14a_support virtual generators, transposed so that + the index is the generator name (ready for bus mapping). + """ + gen_cols = [ + col + for col in edisgo.timeseries.generators_active_power.columns + if "hp_14a_support" in col + or "cp_14a_support" in col + or "charging_point_14a_support" in col + ] + return edisgo.timeseries.generators_active_power[gen_cols] + + +def create_network_gif( + folder_path="./plots", output_name="network_evolution.gif", duration=1 +): + """ + Creates a GIF from PNG files in a folder. + duration: time in seconds between frames + """ + images = [] + + files = [ + f + for f in os.listdir(folder_path) + if f.endswith(".png") and f.startswith("grid_analysis_") + ] + files.sort() + + print(f"Found {len(files)} frames. Processing...") + + for filename in files: + file_path = os.path.join(folder_path, filename) + images.append(imageio.imread(file_path)) + print(f"Added: {filename}") + + imageio.mimsave(output_name, images, duration=duration, loop=0) + print(f"Success! GIF saved as {output_name}") + + +def plot_network( + edisgo, + snapshot: str = "2035-01-15 09:00:00", + show: bool = True, + save: bool = True, + base_bus_size=0.000000002, +): + results = edisgo.results + + n = edisgo.to_pypsa() + + coords = edisgo.topology.buses_df[["x", "y"]] + coords = coords.reindex(n.buses.index) + n.buses["x"] = coords["x"].values + n.buses["y"] = coords["y"].values + + line_columns = n.lines.index + loading_relative = results.s_res.loc[snapshot, line_columns] / n.lines.s_nom + + v_min, v_max = 0.0, 1.0 + norm_lines = mcolors.Normalize(vmin=v_min, vmax=v_max) + + bus_colors = edisgo.results.v_res.T[snapshot] + + norm_buses = mcolors.TwoSlopeNorm(vmin=0.9, vcenter=1.0, vmax=1.1) + voltage_cmap = mcolors.LinearSegmentedColormap.from_list( + "voltage", ["purple", "blue", "red"] + ) + + curt_14a = get_curtailment_data(edisgo).T + curt_14a["load"] = curt_14a.index + curt_14a["load"] = curt_14a["load"].apply( + lambda x: x.replace("cp_14a_support_", "").replace("hp_14a_support_", "") + ) + curt_14a["bus"] = curt_14a["load"].map(edisgo.topology.loads_df["bus"]) + grouped_14a = curt_14a.groupby("bus").sum() + grouped_14a.columns = grouped_14a.columns.map(str) + + bus_sizes = base_bus_size + (grouped_14a[snapshot] * 0.000001) + bus_sizes = bus_sizes.reindex(bus_colors.index, fill_value=base_bus_size) + + fig, ax = plt.subplots(figsize=(12, 8)) + + n.plot( + margin=0.05, + ax=ax, + geomap=False, + bus_colors=bus_colors, + bus_alpha=1, + bus_sizes=bus_sizes, + bus_cmap=voltage_cmap, + bus_norm=norm_buses, + line_colors=loading_relative, + line_widths=1.6, + line_cmap="jet", + line_norm=norm_lines, + title=f"Grid Analysis: {snapshot}", + geometry=False, + ) + + ctx.add_basemap(ax, crs=4326, source=ctx.providers.OpenStreetMap.Mapnik) + + sm_lines = plt.cm.ScalarMappable(cmap="jet", norm=norm_lines) + cb_lines = fig.colorbar( + sm_lines, ax=ax, orientation="vertical", location="left", pad=0.08, aspect=20 + ) + cb_lines.set_label("Line Loading [relative]", fontsize=8) + + sm_buses = plt.cm.ScalarMappable(cmap=voltage_cmap, norm=norm_buses) + cb_buses = fig.colorbar( + sm_buses, ax=ax, orientation="vertical", location="right", pad=0.02, aspect=20 + ) + cb_buses.set_label( + "Bus Voltage [p.u.] — blue: under, yellow: nominal, red: over", fontsize=8 + ) + + if save: + os.makedirs("plots", exist_ok=True) + plt.savefig(f"plots/grid_analysis_{snapshot}.png", dpi=300, bbox_inches="tight") + + if show: + plt.show() + + +def plot_storage_dispatch( + edisgo, day: str = None, show: bool = True, save: bool = True +): + """ + Plot total solar + wind generation against total storage charge/discharge. + + Two subplots sharing the x-axis: + - Top: stacked solar and wind generation. + - Bottom: storage discharge (positive) and charging (negative, shown below zero). + + Parameters + ---------- + edisgo : EDisGo + day : str or None + Date string e.g. "2035-01-15". If None, all snapshots are shown. + show : bool + save : bool + """ + ti = edisgo.timeseries.timeindex + if day is not None: + ti = ti[ti.normalize() == pd.Timestamp(day)] + + gen_df = edisgo.topology.generators_df + gen_ts = edisgo.timeseries.generators_active_power + stor_ts = edisgo.timeseries.storage_units_active_power + + solar_cols = [ + g + for g in gen_df[gen_df["type"] == "solar"].index + if g in gen_ts.columns + ] + wind_cols = [ + g + for g in gen_df[gen_df["type"] == "wind"].index + if g in gen_ts.columns + ] + solar = gen_ts[solar_cols].loc[ti].sum(axis=1) + wind = gen_ts[wind_cols].loc[ti].sum(axis=1) + + if stor_ts is not None and not stor_ts.empty: + stor = stor_ts.loc[ti].sum(axis=1) + else: + stor = pd.Series(0.0, index=ti) + discharge = stor.clip(lower=0) + charge = stor.clip(upper=0) + + fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 6), sharex=True) + + ax1.fill_between(ti, 0, solar.values, alpha=0.75, color="gold", label="Solar") + ax1.fill_between( + ti, + solar.values, + (solar + wind).values, + alpha=0.75, + color="steelblue", + label="Wind", + ) + ax1.set_ylabel("Generation [MW]") + ax1.legend(loc="upper right") + ax1.grid(True, alpha=0.3) + + ax2.fill_between( + ti, 0, discharge.values, alpha=0.75, color="darkorange", label="Discharge" + ) + ax2.fill_between( + ti, 0, charge.values, alpha=0.75, color="mediumpurple", label="Charge" + ) + ax2.axhline(0, color="black", linewidth=0.6) + ax2.set_ylabel("Storage Power [MW]") + ax2.legend(loc="upper right") + ax2.grid(True, alpha=0.3) + + if day is not None: + ax2.set_xlabel("Hour of day") + fig.autofmt_xdate(rotation=0) + ax2.xaxis.set_major_formatter( + plt.matplotlib.dates.DateFormatter("%H:%M") + ) + else: + ax2.set_xlabel("Timestamp") + fig.autofmt_xdate(rotation=45) + + title = f"Generation vs Storage Dispatch — {day if day else 'all snapshots'}" + fig.suptitle(title) + plt.tight_layout() + + if save: + os.makedirs("plots", exist_ok=True) + label = day if day else "all" + plt.savefig( + f"plots/storage_dispatch_{label}.png", dpi=300, bbox_inches="tight" + ) + if show: + plt.show() + plt.close() + + +def plot_load_before_after(edisgo, day: str, show: bool = True, save: bool = True): + """ + Plot aggregate CP + HP load before and after §14a curtailment for a 24h day. + + Parameters + ---------- + day : str + Date string, e.g. "2035-01-15". + """ + ti = edisgo.timeseries.timeindex + day_ti = ti[ti.normalize() == pd.Timestamp(day)] + + loads_df = edisgo.topology.loads_df + cp_loads = loads_df[loads_df["type"] == "charging_point"].index + hp_loads = loads_df[loads_df["type"] == "heat_pump"].index + conv_loads = loads_df[loads_df["type"] == "conventional_load"].index + + lap = edisgo.timeseries.loads_active_power + cp_ts = lap[[c for c in cp_loads if c in lap.columns]].loc[day_ti].sum(axis=1) + hp_ts = lap[[c for c in hp_loads if c in lap.columns]].loc[day_ti].sum(axis=1) + conv_ts = lap[[c for c in conv_loads if c in lap.columns]].loc[day_ti].sum(axis=1) + + curt = get_curtailment_data(edisgo).loc[day_ti] + cp_curt = curt[ + [ + c + for c in curt.columns + if "cp_14a_support" in c or "charging_point_14a_support" in c + ] + ].sum(axis=1) + hp_curt = curt[[c for c in curt.columns if "hp_14a_support" in c]].sum(axis=1) + + cp_opt = cp_ts - cp_curt + hp_opt = hp_ts - hp_curt + + stack_conv = conv_ts.values + stack_conv_hp = (conv_ts + hp_opt).values + stack_conv_hp_cp = (conv_ts + hp_opt + cp_opt).values + stack_with_hp_curt = (conv_ts + hp_ts + cp_opt).values + original_total = (conv_ts + hp_ts + cp_ts).values + + hours = [t.hour for t in day_ti] + + fig, ax = plt.subplots(figsize=(12, 5)) + + ax.fill_between( + hours, 0, stack_conv, alpha=0.6, color="gray", label="Conventional load" + ) + ax.fill_between( + hours, + stack_conv, + stack_conv_hp, + alpha=0.6, + color="mediumseagreen", + label="Heat pumps (optimized)", + ) + ax.fill_between( + hours, + stack_conv_hp, + stack_conv_hp_cp, + alpha=0.6, + color="steelblue", + label="Charging points (optimized)", + ) + ax.fill_between( + hours, + stack_conv_hp_cp, + stack_with_hp_curt, + alpha=0.55, + color="mediumseagreen", + label="§14a HP curtailment", + hatch="////", + edgecolor="darkgreen", + ) + ax.fill_between( + hours, + stack_with_hp_curt, + original_total, + alpha=0.55, + color="steelblue", + label="§14a CP curtailment", + hatch="////", + edgecolor="darkblue", + ) + ax.plot( + hours, + original_total, + color="black", + linewidth=1.5, + linestyle="--", + label="Original total (unoptimized)", + ) + + ax.set_xlabel("Hour of day") + ax.set_ylabel("Active Power [MW]") + ax.set_title(f"§14a Impact on Load by Type — {day}") + ax.set_xticks(range(24)) + ax.legend(loc="upper left") + ax.grid(True, alpha=0.3) + plt.tight_layout() + + if save: + os.makedirs("plots", exist_ok=True) + plt.savefig(f"plots/load_before_after_{day}.png", dpi=300, bbox_inches="tight") + if show: + plt.show() + plt.close() 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_Husum.py b/examples/example_analyze_14a_Husum.py new file mode 100644 index 000000000..190cad598 --- /dev/null +++ b/examples/example_analyze_14a_Husum.py @@ -0,0 +1,1146 @@ +""" +§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 geopandas as gpd +import pypsa +import numpy as np +import matplotlib.pyplot as plt +from datetime import datetime +from pathlib import Path +import matplotlib.colors as mcolors +import contextily as ctx +import imageio.v2 as imageio +import re + +from edisgo import EDisGo +from edisgo.io.db import engine as egon_engine + + +def import_generators_timeseries(grid_path, edisgo, cos_phi, start_date, end_date): + """ + Imports generator active power and initializes reactive power timeseries for EDisGo simulation. + + Parameters + ---------- + grid_path : str + Path to the grid results folder. + edisgo : EDisGo + EDisGo object. + start_date : str or datetime + Startzeitpunkt (z.B. '2023-01-01 00:00'). + end_date : str or datetime + Endzeitpunkt (z.B. '2023-01-31 23:45'). + """ + # 1. Daten laden + gen_ts_path = f"{grid_path}/timeseries/gen_p_max_pu_timeseries.csv" + generator_ts = pd.read_csv(gen_ts_path, index_col=0) + + # 2. Index in Datetime umwandeln (falls noch nicht geschehen) + generator_ts.index = pd.to_datetime(generator_ts.index) + + # 3. Zeitraum filtern + generator_ts = generator_ts.loc[start_date:end_date] + + start_dt = pd.to_datetime(start_date) + target_year = start_dt.year + + # Jahr im Index der geladenen Daten anpassen + generator_ts.index = generator_ts.index.map(lambda t: t.replace(year=target_year)) + + # 4. EDisGo-Index auf gewünschtes Jahr anpassen + edisgo.timeseries.timeindex = pd.DatetimeIndex(generator_ts.index, freq="H") + + # 4. Active Power in EDisGo setzen + edisgo.timeseries.active_power_p_max_pu( + edisgo_object=edisgo, ts_generators_p_max_pu=generator_ts + ) + + # 5. Reactive Power initialisieren (passend zum neuen Zeitraum) + pv_gens=edisgo.topology.generators_df[ + edisgo.topology.generators_df.index.str.contains('pv')].index + active_power = edisgo.timeseries.generators_active_power[pv_gens] + tan_phi = np.tan(np.arccos(cos_phi)) + reactive_power_ts = active_power * tan_phi + edisgo.timeseries._generators_reactive_power = reactive_power_ts + + return edisgo + + +def import_loads_timeseries(grid_path, edisgo, cos_phi, start_date, end_date): + """ + Imports load timeseries into EDisGo and initializes heat pump attributes + based on a specific time range. + + Parameters + ---------- + grid_path : str + Path to the grid folder containing timeseries CSV files. + edisgo : EDisGo + EDisGo instance to update. + cos_phi : float + Power factor for reactive power calculation. + start_date : str or datetime + Start of the simulation period. + end_date : str or datetime + End of the simulation period. + + Returns + ------- + EDisGo + Updated EDisGo object with loads and heat pump timeseries. + """ + # --- 1. Last-Zeitreihen laden und filtern --- + load_ts_path = f"{grid_path}/timeseries/load_timeseries.csv" + load_ts = pd.read_csv(load_ts_path, index_col=0) + load_ts.index = pd.to_datetime(load_ts.index) + # Zeitraum filtern + load_ts = load_ts.loc[start_date:end_date] + + # 3. DAS JAHR AUS DEINEM INPUT EXTRAHIEREN + # Wir wandeln start_date in ein Datetime-Objekt um, falls es ein String ist + start_dt = pd.to_datetime(start_date) + target_year = start_dt.year + + # Jahr im Index der geladenen Daten anpassen + load_ts.index = load_ts.index.map(lambda t: t.replace(year=target_year)) + + edisgo.timeseries.timeindex = pd.DatetimeIndex(load_ts.index, freq ='H') + + # Wirkleistung setzen + edisgo.timeseries.set_active_power_manual( + edisgo_object=edisgo, ts_loads=load_ts + ) + + # Blindleistung basierend auf cos_phi berechnen + active_power = edisgo.timeseries.loads_active_power + tan_phi = np.tan(np.arccos(cos_phi)) + reactive_power_ts = active_power * tan_phi + edisgo.timeseries._loads_reactive_power = reactive_power_ts + + # --- 2. Wärmepumpen-Logik --- + hp_loads = edisgo.topology.loads_df.query("type == 'heat_pump'") + + if not hp_loads.empty: + # COP Zeitreihen laden und analog filtern + cop_ts_path = f"{grid_path}/timeseries/cop_timeseries.csv" + cop_ts = pd.read_csv(cop_ts_path, index_col=0) + cop_ts.index = pd.to_datetime(cop_ts.index) + cop_ts.index = cop_ts.index.map(lambda t: t.replace(year=target_year)) + + cop_ts = cop_ts.loc[start_date:end_date] + cop_ts.index = edisgo.timeseries.timeindex + + cop_series = cop_ts.iloc[:, 0] + + # COP Matrix für alle Wärmepumpen erstellen + cop_hp_ts = pd.concat( + [cop_series] * len(hp_loads), axis=1, keys=hp_loads.index + ) + edisgo.heat_pump.cop_df = cop_hp_ts + + # Wärmebedarf berechnen (P_el * COP) + hp_p_el_ts = edisgo.timeseries.loads_active_power.loc[:, hp_loads.index] + edisgo.heat_pump.heat_demand_df = hp_p_el_ts * cop_hp_ts + else: + print("Info: Keine Wärmepumpen im Netzmodell gefunden. HP-Import wird übersprungen.") + + return edisgo + + +def setup_edisgo( + grid_path, + num_hps=50, + num_cps=30, + cos_phi_load=0.9, + cos_phi_gen=0.95, + start_date = '2023-01-01 00:00:00', + end_date='2023-01-01 09:00:00', +): + """ + Load grid and setup components for time series analysis. + + Parameters + ---------- + grid_path : str + Path to ding0 grid folder + num_hps : int + Number of heat pumps to add (default: 50) + num_cps : int + Number of charging points to add (default: 30) + cos_phi_load: int + Value for calculating reactive load timeseries + cos_phi_gen: int + Value for calculating reactive gen timeseries + + 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}") + + # Load grid + print("\n1. Loading ding0 grid...") + edisgo = EDisGo(ding0_grid=grid_path, legacy_ding0_grids=False) + # Define tiemindex + new_timeindex = pd.date_range(start=start_date, end=end_date, freq="H") + edisgo.timeseries.timeindex = pd.DatetimeIndex(new_timeindex, freq="H") + + # Import generators + print( + "2. Importing generators timeseries" + ) # ToDo implement own generator timeseires´ + edisgo = import_generators_timeseries( + grid_path, edisgo, cos_phi_gen, start_date, end_date + ) + + print("3. Importing load timeseries") + edisgo = import_loads_timeseries(grid_path, edisgo, cos_phi_load, start_date, end_date) + + return edisgo + + +def run_optimization_14a(edisgo, hours_limit): + """ + 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") + print(f"Hour limit for 14a usage: {hours_limit}") + start_time = datetime.now() + + # Run optimization + edisgo.pm_optimize(opf_version=5, curtailment_14a=True, hours_limit_14a=hours_limit) + + 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] + 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="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 = "/home/student/Execution/LoMa_exe/results/MGB_Model_V3" # ". + + # Simulation parameters + start_date = "2023-01-01 00:00:00" + end_date = "2023-01-01 10:00:00" + NUM_HEAT_PUMPS = 25 # Number of heat pumps to add + NUM_CHARGING_POINTS = 25 # Number of charging points to add + hours_limit_per_day = 24 # Limit the amount of hours per day for 14a usage + + # Output + OUTPUT_DIR = "./" + + # --- Datum für den Pfad formatieren (z.B. '20230101_0000') --- + # Wir wandeln den String kurz in ein Objekt um, um ihn sauber zu formatieren + start_clean = datetime.strptime(start_date, "%Y-%m-%d %H:%M:%S").strftime("%Y%m%d_%H%M") + end_clean = datetime.strptime(end_date, "%Y-%m-%d %H:%M:%S").strftime("%Y%m%d_%H%M") + + # --- Verzeichnisname erstellen --- + # Jetzt nutzen wir start_clean und end_clean statt der alten Variable {start}d + output_dir = f"{OUTPUT_DIR}/results_{start_clean}_to_{end_clean}_HP{NUM_HEAT_PUMPS}_CP{NUM_CHARGING_POINTS}_14a" + + print(f"\n{'#'*80}") + print(f"# §14a EnWG Analysis: {start_date} bis {end_date}") + print(f"{'#'*80}") + print(f"\nStarted at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"Output directory: {output_dir}") + + try: + # Setup grid and load data + edisgo = setup_edisgo( + GRID_PATH, + num_hps=NUM_HEAT_PUMPS, + num_cps=NUM_CHARGING_POINTS, + start_date=start_date, + end_date=end_date + ) + + edisgo.analyze() + + # Run optimization with §14a + print(f"\nStarted at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + edisgo = run_optimization_14a(edisgo, hours_limit_per_day) + print( + f"\nCompleted at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" + ) + + # update voltage- and line_loading values with results of 14a Optimization + edisgo.analyze() + + # 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')}" + ) + + for ts in edisgo.timeseries.timeindex: + plot_network(edisgo, show=False, snapshot=str(ts)) + create_network_gif( + output_name="network_evolution_Husum.gif", duration=500 + ) + + except Exception as e: + print(f"\n❌ ERROR: {str(e)}") + import traceback + + traceback.print_exc() + sys.exit(1) + + return edisgo + + +def create_network_gif( + folder_path="./plots", output_name="network_evolution.gif", duration=1 +): + """ + Creates a GIF from PNG files in a folder. + duration: time in seconds between frames + """ + images = [] + + # Get all png files that start with 'grid_analysis_' + files = [ + f + for f in os.listdir(folder_path) + if f.endswith(".png") and f.startswith("grid_analysis_") + ] + + # IMPORTANT: Sort files by time. + # Since your files are named 'grid_analysis_YYYY-MM-DD HH:MM:SS.png', + # standard string sorting works perfectly for chronological order. + files.sort() + + print(f"Found {len(files)} frames. Processing...") + + for filename in files: + file_path = os.path.join(folder_path, filename) + images.append(imageio.imread(file_path)) + print(f"Added: {filename}") + + # Save the GIF + # loop=0 means it will loop forever + imageio.mimsave(output_name, images, duration=duration, loop=0) + print(f"Success! GIF saved as {output_name}") + + +def plot_network( + edisgo, + snapshot: str = "2035-01-15 09:00:00", + show: bool = True, + save: bool = True, + base_bus_size=0.000000002, +): + results = edisgo.results + + n = edisgo.to_pypsa() + coords = edisgo.topology.buses_df[["x", "y"]] + coords = coords.reindex(n.buses.index) # secure that index is matching + n.buses["x"] = coords["x"].values + n.buses["y"] = coords["y"].values + + line_columns = n.lines.index + lines_t = results.s_res.loc[:, line_columns] + + # 1. Define limits for line loading + loading_relative = ( + results.s_res.loc[snapshot, line_columns] / n.lines.s_nom + ) + + # 1. Limits für Farbskala (jetzt auf 0% - 100% bezogen) + v_min, v_max = 0.0, 1.0 + norm_lines = mcolors.Normalize(vmin=v_min, vmax=v_max) + + # 2. Prepare bus data + # Calculating voltage deviation from nominal (1.0 p.u.) + bus_colors = (1 - edisgo.results.v_res.T[snapshot]).apply(abs) + + # Voltage limits (adjust vmin/vmax based on your bus_colors results) + norm_buses = mcolors.Normalize(vmin=0.0, vmax=0.3) + + # --- (Curtailment logic and bus_sizes calculation) --- + curt_14a = analyze_curtailment_results(edisgo, output_dir="results_14a")[ + "curtailment_data" + ].T + + # Clean up index names to match load names + curt_14a["load"] = curt_14a.index + curt_14a["load"] = curt_14a["load"].apply( + lambda x: x.replace("cp_14a_support_", "").replace( + "hp_14a_support_", "" + ) + ) + + # Map loads to their respective buses and aggregate curtailment per bus + curt_14a["bus"] = curt_14a["load"].map(edisgo.topology.loads_df["bus"]) + grouped_14a = curt_14a.groupby("bus").sum() + grouped_14a.columns = grouped_14a.columns.map(str) + + # Calculate bus sizes based on curtailment; reindex to include all buses in the network + bus_sizes = base_bus_size + (grouped_14a[snapshot] * 0.000001) + bus_sizes = bus_sizes.reindex(bus_colors.index, fill_value=base_bus_size) + # ------------------------------------------------------------- + + fig, ax = plt.subplots(figsize=(12, 8)) + + # Plot the grid + n.plot( + margin=0.05, + ax=ax, + geomap=False, + bus_colors=bus_colors, + bus_alpha=1, + bus_sizes=bus_sizes, + bus_cmap="jet", + bus_norm=norm_buses, + line_colors=loading_relative, + line_widths=1.6, + line_cmap="jet", + line_norm=norm_lines, + title=f"Grid Analysis: {snapshot}", + geometry=False, + ) + + # Add background basemap + ctx.add_basemap(ax, crs=4326, source=ctx.providers.OpenStreetMap.Mapnik) + + # --- COLORBAR 1: LINE LOADING (LEFT SIDE) --- + sm_lines = plt.cm.ScalarMappable(cmap="jet", norm=norm_lines) + # Use location='left' and a slightly larger pad to avoid overlap with axis labels + cb_lines = fig.colorbar( + sm_lines, + ax=ax, + orientation="vertical", + location="left", + pad=0.08, + aspect=20, + ) + cb_lines.set_label("Line Loading [relative]", fontsize=8) + + # --- COLORBAR 2: BUS VOLTAGE (RIGHT SIDE) --- + sm_buses = plt.cm.ScalarMappable(cmap="jet", norm=norm_buses) + # Default location is right + cb_buses = fig.colorbar( + sm_buses, + ax=ax, + orientation="vertical", + location="right", + pad=0.02, + aspect=20, + ) + cb_buses.set_label("Voltage Deviation |1 - V| [p.u.]", fontsize=8) + + if save: + os.makedirs("plots", exist_ok=True) + plt.savefig( + f"plots/grid_analysis_{snapshot}.png", dpi=300, bbox_inches="tight" + ) + + if show: + plt.show() + + +""" +if __name__ == "__main__": + edisgo = main() +""" diff --git a/examples/example_analyze_14a_ding0_grids.py b/examples/example_analyze_14a_ding0_grids.py new file mode 100644 index 000000000..8499d6e2b --- /dev/null +++ b/examples/example_analyze_14a_ding0_grids.py @@ -0,0 +1,1371 @@ +""" +§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 +""" + +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 + +import contextily as ctx +import pypsa +import geopandas as gpd +import imageio.v2 as imageio +import matplotlib.colors as mcolors + +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, start_date, end_date, scenario="eGon2035"): + """ + Create realistic heat demand and COP time series for heat pumps based on a date range. + + Parameters + ---------- + edisgo : EDisGo + EDisGo object with heat pumps + start_date : str or datetime + Start date of the simulation + end_date : str or datetime + End date of the simulation + scenario : str + Scenario name (optional) + + Returns + ------- + EDisGo + EDisGo object with HP time series + """ + # 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 + + # --- NEUE LOGIK FÜR DEN ZEITINDEX --- + # Wir erstellen den Index direkt aus start und end. + # pd.date_range sorgt dafür, dass die Frequenz ('h') korrekt gesetzt wird. + timeindex = pd.date_range(start=start_date, end=end_date, freq='h') + + # WICHTIG: Den Index als DatetimeIndex mit fester Frequenz zuweisen + edisgo.timeseries.timeindex = pd.DatetimeIndex(timeindex, freq='h') + + print(f" Creating time series for {len(heat_pumps)} heat pumps...") + print(f" Timeindex: {len(timeindex)} timesteps (hourly, from {start_date} to {end_date})") + + # 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"): + """ + Create realistic charging demand time series for charging points. + + Parameters + ---------- + edisgo : EDisGo + EDisGo object with charging points + scenario : str + Scenario name for time index + + Returns + ------- + EDisGo + EDisGo object with CP time series + """ + # 1. Zeitindex vom EDisGo-Objekt beziehen + # Dieser wurde bereits in create_hp_timeseries gesetzt + timeindex = edisgo.timeseries.timeindex + + # Ladepunkte filtern + 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 + + print(f" Creating time series for {len(charging_points)} charging points...") + print(f" Timeindex: {len(timeindex)} timesteps (hourly, covering {timeindex[0]} to {timeindex[-1]})") + + # 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, start_date, end_date, scenario="eGon2035", num_hps=50, num_cps=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) + 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, start_date, end_date, scenario=scenario) + + # Create CP time series + edisgo = create_cp_timeseries(edisgo, scenario=scenario) + + # 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, hours_limit): + """ + Run optimization with §14a curtailment enabled. + + Uses opf_version=5 which uses §14a curtailment as the only flexibility tool. + Minimizes line losses and §14a usage. Grid restrictions (voltage 0.9-1.1 p.u., + current limits) are enforced as hard constraints. Feasibility slacks exist but + are penalized at 1e8 to ensure the model remains feasible. + + 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 5:") + print(f" - §14a curtailment as only flexibility tool") + print(f" - Minimize line losses + §14a usage") + print(f" - Grid restrictions enforced (voltage 0.9-1.1, current limits)") + print(f" - Feasibility slacks penalized at 1e8") + + start_time = datetime.now() + + # Run optimization + edisgo.pm_optimize(opf_version=5, curtailment_14a=True, hours_limit_14a = hours_limit) + print("status:", edisgo.opf_results.status) + print("solver:", edisgo.opf_results.solver) + print("solve_time:", edisgo.opf_results.solution_time) + print("AC PF min/max:", edisgo.results.v_res.min().min(), edisgo.results.v_res.max().max()) + + 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") + + # Save HP curtailment (timeseries + totals) + if len(hp_curtailment_total) > 0: + hp_curtailment_total.to_csv(f"{output_dir}/hp_curtailment_total.csv") + if len(results['hp_curtailment_data']) > 0: + results['hp_curtailment_data'].to_csv(f"{output_dir}/hp_curtailment_timeseries.csv") + + # Save CP curtailment (timeseries + totals) + if len(cp_curtailment_total) > 0: + cp_curtailment_total.to_csv(f"{output_dir}/cp_curtailment_total.csv") + if len(results['cp_curtailment_data']) > 0: + results['cp_curtailment_data'].to_csv(f"{output_dir}/cp_curtailment_timeseries.csv") + + # Save HP and CP load timeseries (needed for 4.2 kW validation) + if len(hp_loads) > 0: + hp_loads.to_csv(f"{output_dir}/hp_loads_timeseries.csv") + if len(cp_loads) > 0: + cp_loads.to_csv(f"{output_dir}/cp_loads_timeseries.csv") + + # ========================================================================== + # Calculate and save RESULTING NET LOAD timeseries (load - curtailment) + # This shows the actual power after §14a curtailment is applied + # + # IMPORTANT: The Julia constraint ensures: + # php - p_hp14a >= 4.2 kW (where php = OPTIMIZED HP power) + # + # But hp_loads contains the ORIGINAL scheduled load, which may differ from + # the optimized php if the HP has a heat storage. In that case: + # - Julia may increase php to charge the storage + # - The net load (php - curtailment) in Julia is >= 4.2 kW + # - But (original_load - curtailment) may appear < 4.2 kW + # + # This is NOT a constraint violation - the grid sees the OPTIMIZED load! + # ========================================================================== + + P_MIN_14A = 0.0042 # 4.2 kW in MW + + # HP net load: HP_load - HP_curtailment + if len(hp_loads) > 0 and len(results['hp_curtailment_data']) > 0: + hp_curtailment = results['hp_curtailment_data'] + + # Build net load DataFrame with matching columns + hp_net_load = pd.DataFrame(index=hp_loads.index) + hp_net_load_curtailed_only = pd.DataFrame(index=hp_loads.index) + + # Track apparent violations (may be false positives due to heat storage optimization) + apparent_violations = [] + + for hp_col in hp_loads.columns: + # Find matching curtailment column (hp_14a_support_HP_large_1 -> HP_large_1) + curtail_col = f"hp_14a_support_{hp_col}" + + if curtail_col in hp_curtailment.columns: + # Net load = timeseries load - curtailment + # NOTE: This uses the TIMESERIES load, not the OPTIMIZED load from Julia + net_load = hp_loads[hp_col] - hp_curtailment[curtail_col] + hp_net_load[hp_col] = net_load + + # Version with NaN for non-curtailed timesteps + net_load_curtailed = net_load.copy() + curtailed_mask = hp_curtailment[curtail_col] > 1e-4 + net_load_curtailed[~curtailed_mask] = np.nan + hp_net_load_curtailed_only[hp_col] = net_load_curtailed + + # Check for apparent violations (net load < 4.2 kW during curtailment) + violation_mask = curtailed_mask & (net_load < P_MIN_14A - 1e-6) + if violation_mask.any(): + n_violations = violation_mask.sum() + min_net = net_load[violation_mask].min() + apparent_violations.append({ + 'hp': hp_col, + 'n_violations': n_violations, + 'min_net_kW': min_net * 1000 + }) + else: + # No curtailment for this HP - net load = original load + hp_net_load[hp_col] = hp_loads[hp_col] + hp_net_load_curtailed_only[hp_col] = np.nan # All NaN since never curtailed + + # Save both versions + hp_net_load.to_csv(f"{output_dir}/hp_net_load_timeseries.csv") + hp_net_load_curtailed_only.to_csv(f"{output_dir}/hp_net_load_curtailed_only.csv") + print(f"✓ HP net load timeseries saved (full + curtailed-only)") + + # Report apparent violations with explanation + if apparent_violations: + print(f"\n⚠ NOTE: {len(apparent_violations)} HP(s) show apparent net load < 4.2 kW:") + for v in apparent_violations[:5]: # Show first 5 + print(f" {v['hp']}: {v['n_violations']} timesteps, min={v['min_net_kW']:.2f} kW") + print(f" → This is likely due to heat storage optimization (php > original_load)") + print(f" → Julia constraint uses OPTIMIZED php, not original timeseries load") + print(f" → The actual grid load is >= 4.2 kW (constraint is satisfied)") + + # CP net load: CP_load - CP_curtailment + if len(cp_loads) > 0 and len(results['cp_curtailment_data']) > 0: + cp_curtailment = results['cp_curtailment_data'] + + # Build net load DataFrame with matching columns + cp_net_load = pd.DataFrame(index=cp_loads.index) + cp_net_load_curtailed_only = pd.DataFrame(index=cp_loads.index) + + for cp_col in cp_loads.columns: + # Find matching curtailment column + curtail_col = f"cp_14a_support_{cp_col}" + if curtail_col not in cp_curtailment.columns: + curtail_col = f"charging_point_14a_support_{cp_col}" + + if curtail_col in cp_curtailment.columns: + # Net load = original load - curtailment + net_load = cp_loads[cp_col] - cp_curtailment[curtail_col] + cp_net_load[cp_col] = net_load + + # Version with NaN for non-curtailed timesteps + net_load_curtailed = net_load.copy() + net_load_curtailed[cp_curtailment[curtail_col] < 1e-4] = np.nan + cp_net_load_curtailed_only[cp_col] = net_load_curtailed + else: + # No curtailment for this CP + cp_net_load[cp_col] = cp_loads[cp_col] + cp_net_load_curtailed_only[cp_col] = np.nan + + # Save both versions + cp_net_load.to_csv(f"{output_dir}/cp_net_load_timeseries.csv") + cp_net_load_curtailed_only.to_csv(f"{output_dir}/cp_net_load_curtailed_only.csv") + print(f"✓ CP net load timeseries saved (full + curtailed-only)") + + 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 - LOOP THROUGH ALL GRIDS + GRID_BASE_PATH = "/home/student/Execution/eDisGo_exe/test_grid" + SCENARIO = "eGon2035" + + # Simulation parameters + start_date = '2035-01-15 00:00:00' + end_date = '2035-01-15 09:00:00' # Quick test with 10 hours + NUM_HEAT_PUMPS = 20 # Reduced for faster testing + NUM_CHARGING_POINTS = 30 # Reduced for faster testing + hours_limit_per_day = 24 # Limit the amount of hours per day for 14a usage + + # Output + OUTPUT_DIR = "./test_results_all_grids" + + # ============================================================================ + # END CONFIGURATION + # ============================================================================ + + # Get all grid directories + import glob + grid_dirs = sorted(glob.glob(f"{GRID_BASE_PATH}/*")) + + print(f"\n{'#'*80}") + print(f"# §14a EnWG Multi-Grid Test - Testing {len(grid_dirs)} Grids") + print(f"{'#'*80}") + print(f"\nStarted at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"Testing from {start_date} to {end_date}") + print(f"Components: {NUM_HEAT_PUMPS} HPs, {NUM_CHARGING_POINTS} CPs per grid\n") + + # Track results + successful_grids = [] + failed_grids = [] + + # Loop through all grids + for i, grid_path in enumerate(grid_dirs, 1): + grid_id = os.path.basename(grid_path) + print(f"\n{'='*80}") + print(f"[{i}/{len(grid_dirs)}] Testing Grid: {grid_id}") + print(f"{'='*80}") + + output_dir = f"{OUTPUT_DIR}/grid_{grid_id}" + + try: + # Setup grid and load data + edisgo = setup_edisgo( + grid_path, + start_date, + end_date, + scenario=SCENARIO, + num_hps=NUM_HEAT_PUMPS, + num_cps=NUM_CHARGING_POINTS + ) + + # Run optimization with §14a + edisgo = run_optimization_14a(edisgo, hours_limit_per_day) + + #update line_loading and voltage values + edisgo.analyze() + + # Analyze results + results = analyze_curtailment_results(edisgo, output_dir=output_dir) + + if results: + # Create plots + create_plots(results, output_dir=output_dir) + + print(f"✓ Grid {grid_id} SUCCESS") + print(f" Curtailment: {results['total_curtailment_MWh']:.4f} MWh ({results['curtailment_percentage']:.2f}%)") + successful_grids.append(grid_id) + + #create a plots folder in your execution folder for executing this oart + for ts in edisgo.timeseries.timeindex: + plot_network(edisgo, show=False, snapshot=str(ts), base_bus_size=0.0000002) + create_network_gif(output_name=f'network_evolution_{grid_id}.gif', duration=500) + + except Exception as e: + print(f"✗ Grid {grid_id} FAILED: {str(e)}") + failed_grids.append((grid_id, str(e))) + + # Summary + print(f"\n{'#'*80}") + print(f"# FINAL SUMMARY") + print(f"{'#'*80}") + print(f"Total Grids Tested: {len(grid_dirs)}") + print(f"Successful: {len(successful_grids)}") + print(f"Failed: {len(failed_grids)}") + + + if failed_grids: + print(f"\nFailed Grids:") + for grid_id, error in failed_grids: + print(f" - {grid_id}: {error[:100]}") + + print(f"\nCompleted at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + + return edisgo + +def create_network_gif( + folder_path="./plots", output_name="network_evolution.gif", duration=1 +): + """ + Creates a GIF from PNG files in a folder. + duration: time in seconds between frames + """ + images = [] + + # Get all png files that start with 'grid_analysis_' + files = [ + f + for f in os.listdir(folder_path) + if f.endswith(".png") and f.startswith("grid_analysis_") + ] + + # IMPORTANT: Sort files by time. + # Since your files are named 'grid_analysis_YYYY-MM-DD HH:MM:SS.png', + # standard string sorting works perfectly for chronological order. + files.sort() + + print(f"Found {len(files)} frames. Processing...") + + for filename in files: + file_path = os.path.join(folder_path, filename) + images.append(imageio.imread(file_path)) + print(f"Added: {filename}") + + # Save the GIF + # loop=0 means it will loop forever + imageio.mimsave(output_name, images, duration=duration, loop=0) + print(f"Success! GIF saved as {output_name}") + + +def plot_network( + edisgo, + snapshot: str = '2035-01-15 09:00:00', + show: bool = True, + save: bool = True, + base_bus_size = 0.0000002 +): + results = edisgo.results + + n = edisgo.to_pypsa() + coords = edisgo.topology.buses_df[["x", "y"]] + coords = coords.reindex(n.buses.index) #secure that index is matching + n.buses["x"] = coords["x"].values + n.buses["y"] = coords["y"].values + + line_columns = n.lines.index + + # 1. Define limits for line loading + line_loading = results.s_res.loc[snapshot, line_columns] / n.lines.s_nom + + # 1. Limits für Farbskala (jetzt auf 0% - 100% bezogen) + v_min, v_max = 0.0, 1.0 + norm_lines = mcolors.Normalize(vmin=v_min, vmax=v_max) + + # 2. Prepare bus data + # Calculating voltage deviation from nominal (1.0 p.u.) + bus_colors = (1 - edisgo.results.v_res.T[snapshot]).apply(abs) + + # Voltage limits (adjust vmin/vmax based on your bus_colors results) + norm_buses = mcolors.Normalize( + vmin=bus_colors.min(), vmax=bus_colors.max() ) + + # --- (Curtailment logic and bus_sizes calculation) --- + curt_14a = analyze_curtailment_results(edisgo, output_dir="results_14a")[ + "curtailment_data" + ].T + + # Clean up index names to match load names + curt_14a["load"] = curt_14a.index + curt_14a["load"] = curt_14a["load"].apply( + lambda x: x.replace("cp_14a_support_", "").replace( + "hp_14a_support_", "" + ) + ) + + # Map loads to their respective buses and aggregate curtailment per bus + curt_14a["bus"] = curt_14a["load"].map(edisgo.topology.loads_df["bus"]) + grouped_14a = curt_14a.groupby("bus").sum() + grouped_14a.columns = grouped_14a.columns.map(str) + + # Calculate bus sizes based on curtailment; reindex to include all buses in the + bus_sizes = base_bus_size + (grouped_14a[snapshot] * 0.001) + bus_sizes = bus_sizes.reindex(bus_colors.index, fill_value=base_bus_size) + # ------------------------------------------------------------- + + fig, ax = plt.subplots(figsize=(12, 8)) + + # Plot the grid + n.plot( + margin=0.05, + ax=ax, + geomap=False, + bus_colors=bus_colors, + bus_alpha=1, + bus_sizes=bus_sizes, + bus_cmap="jet", + bus_norm=norm_buses, + line_colors=line_loading, + line_widths=1.6, + line_cmap="jet", + line_norm=norm_lines, + title=f"Grid Analysis: {snapshot}", + geometry=False, + ) + + # Add background basemap + ctx.add_basemap(ax, crs=4326, source=ctx.providers.OpenStreetMap.Mapnik) + + # --- COLORBAR 1: LINE LOADING (LEFT SIDE) --- + sm_lines = plt.cm.ScalarMappable(cmap="jet", norm=norm_lines) + # Use location='left' and a slightly larger pad to avoid overlap with axis labels + cb_lines = fig.colorbar( + sm_lines, + ax=ax, + orientation="vertical", + location="left", + pad=0.08, + aspect=20, + ) + cb_lines.set_label("Line Loading [relative]", fontsize=8) + + # --- COLORBAR 2: BUS VOLTAGE (RIGHT SIDE) --- + sm_buses = plt.cm.ScalarMappable(cmap="jet", norm=norm_buses) + # Default location is right + cb_buses = fig.colorbar( + sm_buses, + ax=ax, + orientation="vertical", + location="right", + pad=0.02, + aspect=20, + ) + cb_buses.set_label("Voltage Deviation |1 - V| [p.u.]", fontsize=8) + + if save: + os.makedirs("plots", exist_ok=True) + plt.savefig( + f"plots/grid_analysis_{snapshot}.png", dpi=300, bbox_inches="tight" + ) + + if show: + plt.show() + +""" +if __name__ == "__main__": + edisgo = main() +""" diff --git a/examples/loma-14a.py b/examples/loma-14a.py new file mode 100644 index 000000000..e373f6d66 --- /dev/null +++ b/examples/loma-14a.py @@ -0,0 +1,351 @@ +from datetime import datetime + +import geopandas as gpd +import pandas as pd + +from edisgo import EDisGo +from edisgo.tools.loma_tools import ( + buses_with_existing_loads, + create_network_gif, + get_curtailment_data, + plot_load_before_after, + plot_network, + plot_storage_dispatch, + set_charging_points_to_target, + set_heat_pumps_to_target, + set_storage_timeseries_bus_level, + transfer_ts_from_new_to_existing_cp, +) + + +def run_optimization_14a(edisgo): + """ + Run optimization with §14a curtailment enabled. + + Uses opf_version=5 which uses §14a curtailment as the only flexibility tool. + Minimizes line losses and §14a usage. Grid restrictions (voltage 0.9-1.1 p.u., + current limits) are enforced as hard constraints. Feasibility slacks exist but + are penalized at 1e8 to ensure the model remains feasible. + + Parameters + ---------- + edisgo : EDisGo + EDisGo object with time series + + Returns + ------- + EDisGo + EDisGo object with optimization results + """ + print(f"\n{'='*80}") + print("⚡ Running OPF with §14a Curtailment") + print(f"{'='*80}") + print("\nUsing OPF version 5:") + print(" - §14a curtailment as only flexibility tool") + print(" - Minimize line losses + §14a usage") + print(" - Grid restrictions enforced (voltage 0.9-1.1, current limits)") + print(" - Feasibility slacks penalized at 1e8") + + start_time = datetime.now() + + # Run optimization + edisgo.pm_optimize(opf_version=5, curtailment_14a=True) + + duration = (datetime.now() - start_time).total_seconds() + + print("\n✓ Optimization complete!") + print(f" Duration: {duration:.1f} seconds ({duration/60:.1f} minutes)") + + return edisgo + + +def integrate_ev_and_hp_for_14a(edisgo, *, shapefile_path, output_dir): + """Import EV charging points, apply charging strategy, and adjust CP/HP counts.""" + # Temporary Check: Amount of CPs before importing eDisGo CPs + names = edisgo.topology.loads_df.query("type == 'charging_point'").index.astype(str) + print( + { + "existing": names.str.contains("Existing", case=False).sum(), + "additional": names.str.contains("Additional", case=False).sum(), + "rest": ( + ~( + names.str.contains("Existing", case=False) + | names.str.contains("Additional", case=False) + ) + ).sum(), + "total": len(names), + } + ) + + """ + After this function there are no time series yet. Only charging points and + a overall demand which is then transferred into a time series in + apply_charging_strategy. + + Note: Afterwards there should be the Existing CP (411) and Additional CP (589) + from the LoMa side for the 2035 scenario and all new eDisGo CP (for whole Husum + there should be 2337). So the total should be 3337. + """ + edisgo.import_electromobility_14a( + scenario="eGon2035", + import_electromobility_data_kwds={"shapefile_path": shapefile_path}, + ) + + # Temporary Check: Amount of CPs after importing eDisGo CPs + names = edisgo.topology.loads_df.query("type == 'charging_point'").index.astype(str) + print( + { + "existing": names.str.contains("Existing", case=False).sum(), + "additional": names.str.contains("Additional", case=False).sum(), + "rest": ( + ~( + names.str.contains("Existing", case=False) + | names.str.contains("Additional", case=False) + ) + ).sum(), + "total": len(names), + } + ) + + """ + This step created the time series for the new eDisGo charging points. + Without the preparation of Q before charging strategy I got an error while + apply_charging_strategy which was caused by a deviating time index. + + Note: After this step ONLY the charging points from eDisGo have a time series. + """ + # Prepare Q before charging strategy + ti = edisgo.timeseries.timeindex + lap_cols = edisgo.timeseries.loads_active_power.columns + edisgo.timeseries.loads_reactive_power = pd.DataFrame( + 0.0, + index=ti, + columns=lap_cols, + ) + + edisgo.apply_charging_strategy(strategy="dumb") + + """ + This step then finally transfers the time series from suitable eDisGo + charging_points to Existing_ und Additional_ charging points which are + created on the LoMa side. + + Note: After this step there should be 411 Existing CP and 589 Additional for + the 2035 scenario and 1337 eDisGo CP as 1000 of those were used for matching + and transferring the time series and deleted afterwards. + """ + transfer_ts_from_new_to_existing_cp( + edisgo, + existing_markers=("Existing", "Additional"), + radius_1=2000.0, + tol_1=0.15, + radius_2=2000.0, + tol_2=0.9, + ) + + # Temporary Check: Amount of CPs after transferring time series + names = edisgo.topology.loads_df.query("type == 'charging_point'").index.astype(str) + print( + { + "existing": names.str.contains("Existing", case=False).sum(), + "additional": names.str.contains("Additional", case=False).sum(), + "rest": ( + ~( + names.str.contains("Existing", case=False) + | names.str.contains("Additional", case=False) + ) + ).sum(), + "total": len(names), + } + ) + + # ============================================================ + # Optional Utilities for sensitivity analysis/changing the amount of cp/hp + # - target by absolute value or relative percentage + # - Only use one option at a time (target_total, percentage) + # ============================================================ + """ + In this step the total amount of charging points or heat pumps can be adjusted. + Either by percentage or by a total amount including the infrastructure from + LoMa. When deleting CP/HP there is an option to export the deleted ones. + New CP/HP will have 'dup' in their name. + + Note: for the 2035 scenario the target total would need to be set to 1000. + CPs with the marker Additional and Existing in their name will be removed last. + This way only the remaining 1337 eDisGo CP would be deleted. + """ + cp_eligible_buses = buses_with_existing_loads(edisgo) + hp_eligible_buses = buses_with_existing_loads(edisgo) + + set_charging_points_to_target( + edisgo, + target_total=100, # sets total amount of CP to 1000 + # percentage=0.10, # increases total amount of CP by 10% + # percentage=-0.10, # decreases total amount of CP by 10% + eligible_buses=cp_eligible_buses, + removal_priority=["Additional", "Existing"], + add_tracking_columns=False, + export_removed=True, # only applies when there are deleted CP + export_dir=output_dir, # only applies when there are deleted CP + ) + + set_heat_pumps_to_target( + edisgo, + target_total=100, # sets total amount of HP to 50 + # percentage=0.10, # increases total amount of HP by 10% + # percentage=-0.10, # decreases total amount of HP by 10% + eligible_buses=hp_eligible_buses, + add_tracking_columns=False, + export_removed=True, # only applies when there are deleted HP + export_dir=output_dir, # only applies when there are deleted HP + ) + + # Temporary Check: Amount of CPs after total amount changed + names = edisgo.topology.loads_df.query("type == 'charging_point'").index.astype(str) + print( + { + "existing": names.str.contains("Existing", case=False).sum(), + "additional": names.str.contains("Additional", case=False).sum(), + "rest": ( + ~( + names.str.contains("Existing", case=False) + | names.str.contains("Additional", case=False) + ) + ).sum(), + "total": len(names), + } + ) + + +def prepare_edisgo_for_14a(edisgo, *, shapefile_path, output_dir): + """Apply topology fixes, EV integration, and pre-optimization setup.""" + edisgo.topology.generators_df = edisgo.topology.generators_df[ + edisgo.topology.generators_df.index != "HV_dummy_gen_slack" + ] + edisgo.topology.buses_df = edisgo.topology.buses_df[ + edisgo.topology.buses_df.v_nom <= 20 + ] + edisgo.topology.buses_df = edisgo.topology.buses_df[ + edisgo.topology.buses_df.index != "HV_dummy_bus" + ] + + integrate_ev_and_hp_for_14a( + edisgo, shapefile_path=shapefile_path, output_dir=output_dir + ) + + set_storage_timeseries_bus_level(edisgo) + + hp_names = list( + edisgo.topology.loads_df[edisgo.topology.loads_df["type"] == "heat_pump"].index + ) + timeindex = edisgo.timeseries.timeindex + cop = 3.0 # flat synthetic COP + edisgo.heat_pump.cop_df = pd.DataFrame( + cop, + index=timeindex, + columns=hp_names, + ) + edisgo.heat_pump.heat_demand_df = ( + edisgo.timeseries.loads_active_power[hp_names] * cop + ) + + edisgo.set_time_series_reactive_power_control() + + +def main(): + # grid_path = "/home/carlos/LoMa/exec_folder/results/MGB_quo_model_pypsa" + + # Whole husum paths + # grid_path = "/home/carlos/LoMa/exec_folder/results/Husum_SLP_CP_pypsa" + # path_husum_district_shp = ( + # "/home/carlos/LoMa/exec_folder/data/Input_files/MV_grid_district/husum_district.shp" + # ) + + # MGB paths + grid_path = "/home/carlos/LoMa/exec_folder/results/MGB_SLP_CP_pypsa" + path_husum_district_shp = "/home/carlos/LoMa/exec_folder/MGB_district" + + edisgo = EDisGo(pypsa_csv_dir=grid_path, snapshot_range=(0, 23)) + + mv_grid_geom = gpd.read_file(path_husum_district_shp).to_crs(4326) + edisgo.topology.grid_district["geom"] = mv_grid_geom.loc[0, "geometry"] + edisgo.topology.grid_district["srid"] = 4326 + + edisgo.topology.check_integrity() + pypsa_n = edisgo.to_pypsa() + edisgo.analyze() + + output_dir = "/home/carlos/LoMa/output_edisgo" + + prepare_edisgo_for_14a( + edisgo, + shapefile_path=path_husum_district_shp, + output_dir=output_dir, + ) + + edisgo = run_optimization_14a(edisgo) + edisgo.analyze() + + # ────────────────────────── Slack diagnosis ────────────────────────────── + slacks = edisgo.opf_results.grid_slacks_t + print("\n=== OPF Slack Diagnosis (v5) ===") + for name, df in [ + ("gen_nd_crt (renewable curtailment)", slacks.gen_nd_crt), + ("gen_d_crt (disp. gen curtailment)", slacks.gen_d_crt), + ("load_shed (load shedding)", slacks.load_shedding), + ("hp_shed (HP load shedding)", slacks.hp_load_shedding), + ]: + total = df.abs().sum(axis=1) + if (total > 5e-3).any(): + print(f" {name}: {total.sum():.4f} MW ← NON-ZERO") + else: + print(f" {name}: 0 (not used)") + + print("\n=== Voltage after OPF (edisgo.results.v_res) ===") + v = edisgo.results.v_res + print(f" Min: {v.min().min():.4f} p.u.") + print(f" Max: {v.max().max():.4f} p.u.") + viol = (v < 0.9) | (v > 1.1) + if viol.any().any(): + print(f" Violations:{viol.sum().sum()}") + print() + else: + print(" No voltage violations.") + # ────────────────────────── End diagnosis ──────────────────────────────── + + print("\n=== 14a analysis ===") + gen = edisgo.topology.generators_df + gen_t = edisgo.timeseries.generators_active_power + gen_14a = gen[gen.index.str.contains("14a")] + gen_t_14a = gen_t.loc[:, gen_14a.index] + print(f"Total use of 14a:{gen_t_14a.sum().sum()}") + print("\n=== end 14a analysis ===") + + # # Create gif + # for ts in edisgo.timeseries.timeindex: + # plot_network(edisgo, show=False, snapshot=str(ts)) + # create_network_gif(duration=500) + + # ── Presentation plots ─────────────────────────────────────────────────────── + # Select days that have non-trivial §14a curtailment (threshold: 1 kW total) + curt_daily = ( + get_curtailment_data(edisgo) + .groupby(edisgo.timeseries.timeindex.normalize()) + .sum() + .sum(axis=1) + ) + active_days = curt_daily[curt_daily > 1e-3].index.strftime("%Y-%m-%d").tolist() + + print(f"\n=== Days with §14a curtailment: {active_days} ===") + + for day in active_days: + print(f" Plotting {day}...") + plot_load_before_after(edisgo, day=day, show=False, save=True) + + print(f"Saved plots to ./plots/") + + return edisgo + + +if __name__ == "__main__": + edisgo = 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()