diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c9e3bbf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +name: CI + +on: + pull_request: + workflow_dispatch: + inputs: + upload_artifacts: + description: 'Upload build artifacts' + required: true + default: false + type: boolean + +permissions: { } + +jobs: + build: + name: Build on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + env: + DISPLAY: :99 + + steps: + - name: Checkout sources + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Xvfb (Linux only) + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update -y + sudo apt-get install -y xvfb + Xvfb -ac $DISPLAY -screen 0 1280x1024x24 > /dev/null 2>&1 & + + - name: Test with pytest + run: | + coverage run --branch -m pytest tests + coverage xml + coverage report + + - name: SonarCloud Scan (Linux only) + if: matrix.os == 'ubuntu-latest' + uses: SonarSource/sonarcloud-github-action@v3.1.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + - name: Build application with PyInstaller + run: pyinstaller -y yagat.spec + + - name: Upload application Artifact + if: ${{ github.event_name == 'workflow_dispatch' && inputs.upload_artifacts }} + uses: actions/upload-artifact@v4 + with: + name: yagat-${{ matrix.os }} + path: dist/yagat* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..73ecf0f --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*.iml +*.zip +.idea +venv +/build/ +/dist/ +__pycache__/ +/.coverage +/coverage.xml +/junit/ diff --git a/README.md b/README.md index 3ae414a..0745db4 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,97 @@ # YAGAT -Yet Another Grid Analysis Tool +**Y**et **A**nother **G**rid **A**nalysis **T**ool + +## Overview + +YAGAT provides a graphical user interface built on top of the [PowSyBl](https://www.powsybl.org) open source grid analysis libraries. + +With YAGAT no computer science skill is required: just download the application and run it. + +Today with YAGAT you can: +- Load grid models from the various formats supported by [PowSyBl](https://www.powsybl.org): + - CIM/CGMES + - UCTE-DEF + - IEEE-CDF + - MATPOWER + - Siemens PSS®E +- Display and navigate the grid model with electrical buses represented in tabular form +- Run a Load Flow, visualize solved bus voltages and branch flows + +## Installation + +### Binary releases + +Binary releases are provided for Windows, Linux and macOS on the [releases page](https://github.com/jeandemanged/yagat/releases). +No additional software is required for installation. +Download and extract the zip archive for your platform, then run YAGAT. + +### Building from source + +With Python 3.12 and e.g. using a Virtual Environment and `pip`. + +```bash +# clone the git repository +git clone https://github.com/jeandemanged/yagat.git +cd yagat +``` + +```bash +# install requirements +python -m venv venv +. venv/bin/activate +pip install -r requirements.txt +``` + +```bash +# build the application +pyinstaller -y yagat.spec +``` + +YAGAT is then available for your platform in the `dist` directory. + +## Quick Start + +- **Open a sample network**: Go to `File` | `Open Sample network` | `IEEE 9 Bus` to load a sample grid model. +- **Navigate the grid**: Use the tree view on the left to browse through the network model and its elements. +- **Run the Load Flow**: Select `Run` | `Load Flow` to execute the analysis. + - Once completed, view the solved bus voltages and branch flows for insights into the grid's state. + +## Roadmap + +YAGAT today lacks many features, but you may already find it useful. What is planned for the future is: + +### Short-Term to Mid-Term Plans: +- **General**: A log view +- **Grid Model Navigation**: Tabular views per equipment type +- **Grid Model Updates**: Adjust grid configurations, such as opening/closing switches, changing generator set points, etc. +- **Load Flow**: + - Add ability to save/load the Load Flow parameters + - View Load Flow reports in order to troubleshoot e.g. non-convergence + +### Future Plans: +- **Security Analysis**: + - Configure a list of contingencies to simulate + - Run contingency analysis + - View contingency violations + +## Under the Hood + +YAGAT is: +- **Written in Python**: a high-level, general-purpose programming language. +- **Using [PyPowSyBl](https://pypowsybl.readthedocs.io/en/latest/index.html)**: Provides the core grid analysis functionalities. +- **Using [Tkinter](https://wiki.python.org/moin/TkInter)**: Supplies the graphical user interface. +- **Using [Tksheet](https://github.com/ragardner/tksheet)**: An amazing tkinter table widget. +- **Using [PyInstaller](https://pyinstaller.org/en/stable/)**: Packages the application. + +## Data Confidentiality + +We take data confidentiality seriously. +All data processed by the application remains on the user's local machine and is not transmitted to any external servers. +This ensures complete data privacy for users working with sensitive grid models. + +## Contributing and Support + +Should you encounter any issues with YAGAT, please let us know. +We welcome contributions, ideas, and feedback. Please open an [issue](https://github.com/jeandemanged/yagat/issues) +or [pull request](https://github.com/jeandemanged/yagat/pulls) to get involved. diff --git a/requirements.in b/requirements.in new file mode 100644 index 0000000..8e2d5e1 Binary files /dev/null and b/requirements.in differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2b9f19f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,62 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile requirements.in +# +altgraph==0.17.4 + # via pyinstaller +colorama==0.4.6 + # via pytest +coverage[toml]==7.6.4 + # via pytest-cov +iniconfig==2.0.0 + # via pytest +networkx==3.4.2 + # via pypowsybl +numpy==2.1.3 + # via pandas +packaging==24.2 + # via + # pyinstaller + # pyinstaller-hooks-contrib + # pytest +pandas==2.2.3 + # via pypowsybl +pefile==2023.2.7 + # via pyinstaller +pillow==11.0.0 + # via -r requirements.in +pluggy==1.5.0 + # via pytest +prettytable==3.12.0 + # via pypowsybl +pyinstaller==6.11.0 + # via -r requirements.in +pyinstaller-hooks-contrib==2024.9 + # via pyinstaller +pypowsybl==1.8.1 + # via -r requirements.in +pytest==8.3.3 + # via + # -r requirements.in + # pytest-cov +pytest-cov==6.0.0 + # via -r requirements.in +python-dateutil==2.9.0.post0 + # via pandas +pytz==2024.2 + # via pandas +pywin32-ctypes==0.2.3 + # via pyinstaller +six==1.16.0 + # via python-dateutil +tksheet==7.2.21 + # via -r requirements.in +tzdata==2024.2 + # via pandas +wcwidth==0.2.13 + # via prettytable + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..9ccfe7b --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,6 @@ +sonar.projectKey=jeandemanged_yagat +sonar.organization=jeandemanged + +sonar.sources=yagat +sonar.tests=tests +sonar.python.coverage.reportPaths=coverage.xml diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..65327b4 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,7 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# diff --git a/tests/test_app_context.py b/tests/test_app_context.py new file mode 100644 index 0000000..92756e1 --- /dev/null +++ b/tests/test_app_context.py @@ -0,0 +1,99 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +import tkinter as tk + +import pypowsybl.network as pn +import pytest + +from yagat.app_context import AppContext + + +class TestListener: + + __test__ = False + + def __init__(self, context: AppContext): + self._status_text_from_listener = None + self._network_from_listener = None + self._selection_from_listener = None + self._selected_tab_from_listener = None + context.add_status_text_listener(lambda value: self.status_text_listener(value)) + context.add_network_changed_listener(lambda value: self.network_listener(value)) + context.add_selection_changed_listener(lambda value: self.selection_listener(value)) + context.add_tab_changed_listener(lambda value: self.selected_tab_listener(value)) + + def status_text_listener(self, value): + self._status_text_from_listener = value + + def network_listener(self, value): + self._network_from_listener = value + + def selection_listener(self, value): + self._selection_from_listener = value + + def selected_tab_listener(self, value): + self._selected_tab_from_listener = value + + @property + def status_text_from_listener(self): + return self._status_text_from_listener + + @property + def network_from_listener(self) -> pn.Network: + return self._network_from_listener + + @property + def selection_from_listener(self): + return self._selection_from_listener + + @property + def selected_tab_from_listener(self): + return self._selected_tab_from_listener + + +class TestAppContext: + + @pytest.fixture + def context(self): + context = AppContext(tk.Tk()) + yield context + + def test_initial_state(self, context): + assert context.tk_root is not None + assert context.network is None + assert context.network_structure is None + assert context.selection[0] is None + assert context.selection[1] is None + assert context.selection[2] is None + assert context.status_text == 'Welcome' + + def test_status_text(self, context): + test = TestListener(context) + context.status_text = 'test status text' + assert test.status_text_from_listener == 'test status text' + + def test_network(self, context): + test = TestListener(context) + context.network = pn.create_ieee9() + assert test.network_from_listener.name == 'ieee9cdf' + context.network = None + assert test.network_from_listener is None + + def test_selection(self, context): + test = TestListener(context) + context.selection = 'test selection' + assert test.selection_from_listener == 'test selection' + context.selection = None + assert test.selection_from_listener is None + + def test_selected_tab(self, context): + test = TestListener(context) + context.selected_tab = 'test selected tab' + assert test.selected_tab_from_listener == 'test selected tab' + context.selected_tab = None + assert test.selected_tab_from_listener is None diff --git a/tests/test_network_structure.py b/tests/test_network_structure.py new file mode 100644 index 0000000..89909c5 --- /dev/null +++ b/tests/test_network_structure.py @@ -0,0 +1,167 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +import pypowsybl.network as pn +import pypowsybl.loadflow as lf +import pytest +import numpy as np + +import yagat.networkstructure as ns + + +class TestNetworkStructureIeee9: + + @pytest.fixture + def setup(self): + network = pn.create_ieee9() + structure = ns.NetworkStructure(network) + yield network, structure + + def test_structure(self, setup): + _, structure = setup + assert len(structure.substations) == 6 + assert len(structure.voltage_levels) == 6 + assert structure.get_substation('not exists s') is None + assert structure.get_voltage_level('not exists vl') is None + s1 = structure.get_substation('S1') + assert s1 is not None + assert isinstance(s1, ns.Substation) + vl1 = structure.get_voltage_level('VL1') + assert vl1 is not None + assert isinstance(vl1, ns.VoltageLevel) + s2 = structure.get_substation_or_voltage_level('S2') + assert s2 is not None + assert isinstance(s2, ns.Substation) + vl2 = structure.get_substation_or_voltage_level('VL2') + assert vl2 is not None + assert isinstance(vl2, ns.VoltageLevel) + with pytest.raises(RuntimeError): + structure.get_substation_or_voltage_level('not_exists') + + def test_substation(self, setup): + _, structure = setup + s1 = structure.get_substation('S1') + assert s1.network_structure == structure + assert s1.substation_id == 'S1' + assert s1.name == 'S1' + assert len(s1.voltage_levels) == 1 + vl1 = s1.get_voltage_level('VL1') + assert vl1 is not None + assert isinstance(vl1, ns.VoltageLevel) + assert s1.get_voltage_level('not exists vl') is None + + def test_voltage_level(self, setup): + _, structure = setup + s1 = structure.get_substation('S1') + vl1 = structure.get_voltage_level('VL1') + assert vl1.network_structure == structure + assert vl1.substation == s1 + assert vl1.voltage_level_id == 'VL1' + assert vl1.name == 'VL1' + assert len(vl1.connections) == 5 + l540 = vl1.get_connection('L5-4-0', 2) + assert l540 is not None + assert isinstance(l540, ns.Connection) + assert vl1.get_connection('not exists c') is None + + def test_voltage_level_buses(self, setup): + _, structure = setup + vl1 = structure.get_voltage_level('VL1') + vl1_buses = vl1.get_buses(ns.BusView.BUS_BRANCH) + vl2 = structure.get_voltage_level('VL2') + vl2_buses = vl2.get_buses(ns.BusView.BUS_BRANCH) + assert len(vl1_buses) == 2 + assert len(vl2_buses) == 2 + assert 'VL1_0' in vl1_buses.index + assert 'VL1_1' in vl1_buses.index + assert 'VL2_0' in vl2_buses.index + assert 'VL2_1' in vl2_buses.index + assert 'VL2_0' not in vl1_buses.index + assert 'VL1_0' not in vl2_buses.index + vl1_0_connections = vl1.get_bus_connections(ns.BusView.BUS_BRANCH, 'VL1_0') + assert len(vl1_0_connections) == 2 + + def test_connection(self, setup): + _, structure = setup + s1 = structure.get_substation('S1') + vl1 = structure.get_voltage_level('VL1') + + l540 = vl1.get_connection('L5-4-0', 2) + assert l540.substation == s1 + assert l540.voltage_level == vl1 + assert l540.network_structure == structure + assert l540.equipment_id == 'L5-4-0' + assert l540.name == 'L5-4-0' + assert l540.equipment_type == 'LINE' + assert l540.side == 2 + + t410 = vl1.get_connection('T4-1-0', 1) + assert t410.equipment_id == 'T4-1-0' + assert t410.name == 'T4-1-0' + assert t410.equipment_type == ns.EquipmentType.TWO_WINDINGS_TRANSFORMER + assert t410.side == 1 + + b1g = vl1.get_connection('B1-G') + assert b1g.equipment_id == 'B1-G' + assert b1g.name == 'B1-G' + assert b1g.equipment_type == ns.EquipmentType.GENERATOR + assert b1g.side is None + + def test_connection_data(self, setup): + network, structure = setup + connection_data = structure.get_connection_data('L5-4-0', 2) + assert np.isnan(connection_data.p1) + lf.run_ac(network) + structure.refresh() + connection_data = structure.get_connection_data('L5-4-0', 2) + assert connection_data.p1 == pytest.approx(-40.7, 0.1) + + def test_connection_from_structure(self, setup): + _, structure = setup + t410_1 = structure.get_connection('T4-1-0', 1) + t410_2 = structure.get_connection('T4-1-0', 2) + assert t410_1.voltage_level.voltage_level_id == 'VL1' + assert t410_2.voltage_level.voltage_level_id == 'VL1' + + +class TestNetworkStructureMicroGridBe: + + @pytest.fixture + def setup(self): + network = pn.create_micro_grid_be_network() + structure = ns.NetworkStructure(network) + yield network, structure + + def test_structure(self, setup): + _, structure = setup + assert len(structure.substations) == 2 + assert len(structure.voltage_levels) == 6 + + anvers = structure.get_substation('87f7002b-056f-4a6a-a872-1744eea757e3') + assert anvers.name == 'Anvers' + assert len(anvers.voltage_levels) == 1 + + brussels = structure.get_substation('37e14a0f-5e34-4647-a062-8bfd9305fa9d') + assert brussels.name == 'PP_Brussels' + assert len(brussels.voltage_levels) == 5 + + brussels_380 = structure.get_voltage_level('469df5f7-058f-4451-a998-57a48e8a56fe') + assert brussels_380.name == '380.0' + assert len(brussels_380.connections) == 6 + tr3 = brussels_380.get_connection('84ed55f4-61f5-4d9d-8755-bba7b877a246', 1) + assert tr3.name == 'BE-TR3_1' + assert tr3.equipment_type == ns.EquipmentType.THREE_WINDINGS_TRANSFORMER + dangling_line3 = brussels_380.get_connection('78736387-5f60-4832-b3fe-d50daf81b0a6') + assert dangling_line3.name == 'BE-Line_3' + assert dangling_line3.equipment_type == ns.EquipmentType.DANGLING_LINE + + brussels_110 = structure.get_voltage_level('8bbd7e74-ae20-4dce-8780-c20f8e18c2e0') + assert brussels_110.name == '110.0' + assert len(brussels_110.connections) == 5 + s1 = brussels_110.get_connection('d771118f-36e9-4115-a128-cc3d9ce3e3da') + assert s1.name == 'BE_S1' + assert s1.equipment_type == ns.EquipmentType.SHUNT_COMPENSATOR diff --git a/yagat.spec b/yagat.spec new file mode 100644 index 0000000..6c541ec --- /dev/null +++ b/yagat.spec @@ -0,0 +1,63 @@ +# -*- mode: python ; coding: utf-8 -*- +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +import platform +import sys + +sys.path.append('./') +from yagat import __version__ + +from PyInstaller.utils.hooks import collect_dynamic_libs + +datas = [('yagat/images', 'yagat/images')] +binaries = [] +hiddenimports = ['PIL._tkinter_finder'] +binaries += collect_dynamic_libs('pypowsybl') + +a = Analysis( + ['yagat/app.py'], + pathex=['yagat'], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='YAGAT', + icon='yagat/images/logo.png', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name=f'yagat_{__version__}_{platform.system()}', +) diff --git a/yagat/__init__.py b/yagat/__init__.py new file mode 100644 index 0000000..82fd9a7 --- /dev/null +++ b/yagat/__init__.py @@ -0,0 +1,14 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +import os + +__version__ = '0.1.0-dev' + + +def get_app_path() -> os.path: + return os.path.dirname(os.path.abspath(__file__)) diff --git a/yagat/app.py b/yagat/app.py new file mode 100644 index 0000000..da7c403 --- /dev/null +++ b/yagat/app.py @@ -0,0 +1,49 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +import logging +import os +import tkinter as tk + +import pypowsybl as pp + +from yagat import get_app_path +from yagat.frames import SplashScreen, MainApplication + +pp.print_version() + +logging.getLogger('powsybl').setLevel(logging.INFO) +logging.basicConfig(format='%(asctime)s - %(levelname)s - %(message)s', level=logging.INFO) + +if __name__ == "__main__": + + splash_root = tk.Tk() + splash_root.iconphoto(True, tk.PhotoImage(file=os.path.join(get_app_path(), 'images/logo.png'))) + + + def main_window(): + splash_root.destroy() + root = tk.Tk() + + # Remove ttk Combobox Mousewheel Binding, see https://stackoverflow.com/questions/44268882/remove-ttk-combobox-mousewheel-binding + root.unbind_class("TCombobox", "") # Windows & OSX + root.unbind_class("TCombobox", "") # Linux and other *nix systems + root.unbind_class("TCombobox", "") # Linux and other *nix systems + + MainApplication(root) + root.mainloop() + + + if os.name == 'nt': + # Fixing the blur UI on Windows + from ctypes import windll + + windll.shcore.SetProcessDpiAwareness(2) + + splash = SplashScreen(splash_root) + splash.after(1500, main_window) + splash_root.mainloop() diff --git a/yagat/app_context.py b/yagat/app_context.py new file mode 100644 index 0000000..4dc5df4 --- /dev/null +++ b/yagat/app_context.py @@ -0,0 +1,134 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +import logging +import tkinter as tk +from typing import Callable, Optional + +import pypowsybl.network as pn +import pypowsybl.loadflow as lf + +import yagat.networkstructure as ns + + +class AppContext: + def __init__(self, root: tk.Tk): + self._root = root + self._network: Optional[pn.Network] = None + self._lf_parameters: lf.Parameters = lf.Parameters() + self._network_structure: Optional[ns.NetworkStructure] = None + self._selection: tuple[Optional[str], Optional[str], Optional[ns.Connection]] = (None, None, None) + self._status_text: str = 'Welcome' + self._selected_tab: str = '' + self._selected_view: str = '' + self.status_text_changed_listeners = [] + self.network_changed_listeners = [] + self.selection_changed_listeners = [] + self.tab_changed_listeners = [] + self.view_changed_listeners = [] + + @property + def tk_root(self) -> tk.Tk: + return self._root + + @property + def status_text(self) -> str: + return self._status_text + + @status_text.setter + def status_text(self, value: str) -> None: + logging.info(value) + self._status_text = value + self.notify_status_text_changed() + + @property + def selected_tab(self) -> str: + return self._selected_tab + + @selected_tab.setter + def selected_tab(self, value: str) -> None: + self._selected_tab = value + self.notify_tab_changed() + + @property + def selected_view(self) -> str: + return self._selected_view + + @selected_view.setter + def selected_view(self, value: str) -> None: + self._selected_view = value + self.notify_view_changed() + + @property + def network(self) -> Optional[pn.Network]: + return self._network + + @property + def lf_parameters(self) -> lf.Parameters: + return self._lf_parameters + + @property + def network_structure(self) -> Optional[ns.NetworkStructure]: + return self._network_structure + + @network.setter + def network(self, new_network: Optional[pn.Network]) -> None: + self._network = new_network + if new_network: + self._network_structure = ns.NetworkStructure(new_network) + else: + self._network_structure = None + self.selection = (None, None, None) + self.notify_network_changed() + + @property + def selection(self) -> tuple[Optional[str], Optional[str], Optional[ns.Connection]]: + return self._selection + + @selection.setter + def selection(self, value: tuple[Optional[str], Optional[str], Optional[ns.Connection]]) -> None: + logging.info(f'selection setter called {value}') + self._selection = value + self.notify_selection_changed() + + def reset_selected_connection(self): + self._selection = (self._selection[0], self._selection[1], None) + + def add_status_text_listener(self, listener: Callable[[str], None]) -> None: + self.status_text_changed_listeners.append(listener) + + def notify_status_text_changed(self) -> None: + for listener in self.status_text_changed_listeners: + listener(self.status_text) + + def add_network_changed_listener(self, listener: Callable[[Optional[pn.Network]], None]) -> None: + self.network_changed_listeners.append(listener) + + def notify_network_changed(self) -> None: + for listener in self.network_changed_listeners: + listener(self.network) + + def add_selection_changed_listener(self, listener: Callable[[tuple[Optional[str], Optional[str], Optional[ns.Connection]]], None]) -> None: + self.selection_changed_listeners.append(listener) + + def notify_selection_changed(self) -> None: + for listener in self.selection_changed_listeners: + listener(self.selection) + + def add_tab_changed_listener(self, listener: Callable[[str], None]) -> None: + self.tab_changed_listeners.append(listener) + + def notify_tab_changed(self) -> None: + for listener in self.tab_changed_listeners: + listener(self.selected_tab) + + def add_view_changed_listener(self, listener: Callable[[str], None]) -> None: + self.view_changed_listeners.append(listener) + + def notify_view_changed(self) -> None: + for listener in self.view_changed_listeners: + listener(self.selected_view) diff --git a/yagat/frames/__init__.py b/yagat/frames/__init__.py new file mode 100644 index 0000000..c1623e5 --- /dev/null +++ b/yagat/frames/__init__.py @@ -0,0 +1,14 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +from .impl.diagram_view import DiagramView +from .impl.load_flow_parameters import LoadFlowParametersView +from .impl.main_application import MainApplication +from .impl.splash_screen import SplashScreen +from .impl.status_bar import StatusBar +from .impl.tree_and_diagram import TreeAndDiagram +from .impl.tree_view import TreeView diff --git a/yagat/frames/impl/__init__.py b/yagat/frames/impl/__init__.py new file mode 100644 index 0000000..65327b4 --- /dev/null +++ b/yagat/frames/impl/__init__.py @@ -0,0 +1,7 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# diff --git a/yagat/frames/impl/diagram_view.py b/yagat/frames/impl/diagram_view.py new file mode 100644 index 0000000..6384ece --- /dev/null +++ b/yagat/frames/impl/diagram_view.py @@ -0,0 +1,45 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +import tkinter as tk +from tkinter import ttk + +import pypowsybl.network as pn + +from yagat.app_context import AppContext +from yagat.frames.impl.diagram_view_bus import DiagramViewBus +from yagat.networkstructure import BusView + + +class DiagramView(tk.Frame): + def __init__(self, parent, context: AppContext, *args, **kwargs): + tk.Frame.__init__(self, parent, *args, **kwargs) + self.context = context + self.tab_control = ttk.Notebook(self) + self.tab_control.bind("<>", lambda _: self.on_tab_changed()) + + # Bus-Breaker view tab + self.tab_bus_breaker = DiagramViewBus(self.tab_control, context, 'Bus-Breaker View', BusView.BUS_BREAKER) + self.tab_control.add(self.tab_bus_breaker, text=self.tab_bus_breaker.tab_name) + self.tab_control.pack(expand=True, fill=tk.BOTH) + + # Bus-Branch view tab + self.tab_bus_branch = DiagramViewBus(self.tab_control, context, 'Bus-Branch View', BusView.BUS_BRANCH) + self.tab_control.add(self.tab_bus_branch, text=self.tab_bus_branch.tab_name) + self.tab_control.pack(expand=True, fill=tk.BOTH) + + def on_tab_changed(self): + self.context.selected_tab = self.tab_control.tab(self.tab_control.select(), "text") + + +if __name__ == "__main__": + root = tk.Tk() + ctx = AppContext(root) + DiagramView(root, ctx).pack(fill="both", expand=True) + ctx.network = pn.create_ieee9() + ctx.selection = 'S1' + root.mainloop() diff --git a/yagat/frames/impl/diagram_view_bus.py b/yagat/frames/impl/diagram_view_bus.py new file mode 100644 index 0000000..f87b3ff --- /dev/null +++ b/yagat/frames/impl/diagram_view_bus.py @@ -0,0 +1,137 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +import logging +import os +import tkinter as tk +from typing import List, Optional + +import pypowsybl.network as pn + +import yagat.widgets as pw +from yagat.app_context import AppContext +from yagat.frames.impl.vertical_scrolled_frame import VerticalScrolledFrame +from yagat.networkstructure import Substation, VoltageLevel, BusView, Connection + + +class DiagramViewBus(VerticalScrolledFrame): + def __init__(self, parent, context: AppContext, tab_name: str, bus_view: 'BusView', *args, **kwargs): + VerticalScrolledFrame.__init__(self, parent, *args, **kwargs) + self.context = context + self._tab_name = tab_name + self.bus_view = bus_view + self.widgets = [] + self.context.add_selection_changed_listener(self.on_selection_changed) + self.context.add_tab_changed_listener(lambda _: self.on_selection_changed(self.context.selection)) + + def navigate(connection: Connection): + logging.info(f'Navigating to {connection.equipment_id} side {connection.side}') + selection_type, _, _ = self.context.selection + if selection_type == 'substation' and connection.substation is not None: + new_selection_type = 'substation' + new_selection_id = connection.substation.substation_id + else: + new_selection_type = 'voltage_level' + new_selection_id = connection.voltage_level.voltage_level_id + self.context.selection = (new_selection_type, new_selection_id, connection) + + self.navigate_command = navigate + + @property + def tab_name(self) -> str: + return self._tab_name + + def on_selection_changed(self, selection: tuple[Optional[str], Optional[str], Optional[Connection]]): + _, selection_id, selection_connection = selection + selected_connection_y = 0 + if self.context.selected_tab != self.tab_name: + return + logging.info('Start drawing bus view') + for w in self.widgets: + w.destroy() + self.widgets = [] + if not selection_id: + return + if self.context.selected_tab != self.tab_name: + return + network_structure = self.context.network_structure + what = network_structure.get_substation_or_voltage_level(selection_id) + substation: Substation + voltage_levels: List[VoltageLevel] + if isinstance(what, Substation): + substation = what + voltage_levels = substation.voltage_levels + elif isinstance(what, VoltageLevel): + voltage_levels = [what] + substation = what.substation + else: + raise RuntimeError(f'Selection {selection} not found') + if substation: + s = pw.Substation(self.interior, substation) + self.widgets.append(s) + s.pack(anchor=tk.NW, padx=(0, 0)) + pady_vl = 0 + for voltage_level in voltage_levels: + vl = pw.VoltageLevel(self.interior, voltage_level) + self.widgets.append(vl) + vl.pack(anchor=tk.NW, padx=(20, 0), pady=(pady_vl, 0)) + pady_vl = 20 + + buses = voltage_level.get_buses(self.bus_view) + pady_bus = 0 + for bus_idx, bus_s in buses.iterrows(): + bus_id = str(bus_idx) + b = pw.Bus(self.interior, bus_id, bus_s) + self.widgets.append(b) + b.pack(anchor=tk.NW, padx=(40, 0), pady=(pady_bus, 0)) + pady_bus = 20 + connections = voltage_level.get_bus_connections(self.bus_view, bus_id) + for connection in connections: + c = pw.Connection(self.interior, connection, self.navigate_command) + self.widgets.append(c) + c.pack(anchor=tk.NW, padx=(60, 0)) + if connection == selection_connection: + c.update() + logging.info(f'{connection.equipment_id} is selected connection. {c.winfo_geometry()}') + selected_connection_y = c.winfo_y() + c.highlight() + self.interior.update() + self.canvas.update() + logging.info(f'interior geometry {self.interior.winfo_geometry()}') + logging.info(f'canvas geometry {self.canvas.winfo_geometry()}') + interior_height = self.interior.winfo_height() + canvas_height = self.canvas.winfo_height() + + logging.info( + f'interior_height={interior_height}, canvas_height={canvas_height},' + f' selected_connection_y={selected_connection_y}, ') + if selection_connection and selected_connection_y > (canvas_height / 2) and interior_height: + # the selection is below visible range, scroll to it + y_move_to = (selected_connection_y - canvas_height / 2) / interior_height + logging.info(f'y_move_to={y_move_to}') + self.canvas.yview_moveto(y_move_to) + else: + self.canvas.yview_moveto(0) + logging.info("end drawing") + self.context.reset_selected_connection() + + +if __name__ == "__main__": + + if os.name == 'nt': + # Fixing the blur UI on Windows + from ctypes import windll + + windll.shcore.SetProcessDpiAwareness(2) + root = tk.Tk() + ctx = AppContext(root) + bw = DiagramViewBus(root, ctx, 'Bus-Branch View', BusView.BUS_BRANCH) + bw.pack(fill="both", expand=True) + ctx.network = pn.create_ieee9() + ctx.selection = 'S1' + ctx.selected_tab = bw.tab_name + root.mainloop() diff --git a/yagat/frames/impl/load_flow_parameters.py b/yagat/frames/impl/load_flow_parameters.py new file mode 100644 index 0000000..e73db26 --- /dev/null +++ b/yagat/frames/impl/load_flow_parameters.py @@ -0,0 +1,240 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +import logging +import os +import textwrap +import tkinter as tk + +import pypowsybl.loadflow as lf +import tksheet as tks +from pypowsybl._pypowsybl import BalanceType, VoltageInitMode, ConnectedComponentMode + +from yagat.app_context import AppContext + + +class LoadFlowParametersView(tk.Frame): + + def sheet_modified(self, event): + # FIXME: handling of invalid values + if event.eventname == 'edit_table': + row = event.selected.row + column = event.selected.column + new_value = self.sheet[row, column].data + param = self.sheet.get_index_data(row) + lf_parameters = self.context.lf_parameters + if param == 'distributedSlack': + lf_parameters.distributed_slack = new_value + elif param == 'balanceType': + lf_parameters.balance_type = BalanceType.__members__[new_value] + elif param == 'countriesToBalance': + lf_parameters.countries_to_balance = str(new_value).split(',') + elif param == 'voltageInitMode': + lf_parameters.voltage_init_mode = VoltageInitMode.__members__[new_value] + elif param == 'readSlackBus': + lf_parameters.read_slack_bus = new_value + elif param == 'writeSlackBus': + lf_parameters.write_slack_bus = new_value + elif param == 'useReactiveLimits': + lf_parameters.use_reactive_limits = new_value + elif param == 'phaseShifterRegulationOn': + lf_parameters.phase_shifter_regulation_on = new_value + elif param == 'transformerVoltageControlOn': + lf_parameters.transformer_voltage_control_on = new_value + elif param == 'shuntCompensatorVoltageControlOn': + lf_parameters.shunt_compensator_voltage_control_on = new_value + elif param == 'connectedComponentMode': + lf_parameters.connected_component_mode = ConnectedComponentMode.__members__[new_value] + elif param == 'twtSplitShuntAdmittance': + lf_parameters.twt_split_shunt_admittance = new_value + elif param == 'dcUseTransformerRatio': + lf_parameters.dc_use_transformer_ratio = new_value + elif param == 'dcPowerFactor': + lf_parameters.dc_power_factor = new_value + else: + new_value = str(new_value) + lf_parameters.provider_parameters[param] = str(new_value) + logging.info(f'Load Flow Parameter "{param}" set to {new_value}') + logging.info(f'Load Flow Parameters: {lf_parameters}') + + def __init__(self, parent, context: AppContext, *args, **kwargs): + tk.Frame.__init__(self, parent, *args, **kwargs) + self.sheet = tks.Sheet(self, index_align='left') + self.sheet.enable_bindings('edit_cell', + 'single_select', + 'drag_select', + 'row_select', + 'column_select', + 'copy', + 'column_width_resize', + 'double_click_column_resize', + 'double_click_row_resize', + 'row_width_resize', + 'column_height_resize', + 'arrowkeys', + ) + self.sheet.bind("<>", self.sheet_modified) + self.context = context + self.variables = [] + + i_row = 0 + self.sheet[i_row, 0].data = 'Enable distributed slack' + self.sheet[i_row, 1].checkbox(checked=self.context.lf_parameters.distributed_slack) + self.sheet.set_index_data(r=i_row, value='distributedSlack') + + i_row += 1 + self.sheet[i_row, 0].data = 'Slack distribution balance type' + self.sheet[i_row, 1].dropdown( + values=[BalanceType.PROPORTIONAL_TO_GENERATION_P.name, + BalanceType.PROPORTIONAL_TO_GENERATION_P_MAX.name, + BalanceType.PROPORTIONAL_TO_GENERATION_PARTICIPATION_FACTOR.name, + BalanceType.PROPORTIONAL_TO_GENERATION_REMAINING_MARGIN.name, + BalanceType.PROPORTIONAL_TO_LOAD.name, + BalanceType.PROPORTIONAL_TO_CONFORM_LOAD.name], + set_value=self.context.lf_parameters.balance_type.name + ) + self.sheet.set_index_data(r=i_row, value='balanceType') + + i_row += 1 + self.sheet[i_row, 0].data = 'Countries to balance' + self.sheet[i_row, 1].data = '' + self.sheet.set_index_data(r=i_row, value='countriesToBalance') + + i_row += 1 + self.sheet[i_row, 0].data = 'Voltage Initialization Mode' + self.sheet[i_row, 1].dropdown( + values=[VoltageInitMode.UNIFORM_VALUES.name, + VoltageInitMode.DC_VALUES.name, + VoltageInitMode.PREVIOUS_VALUES.name], + set_value=self.context.lf_parameters.voltage_init_mode.name + ) + self.sheet.set_index_data(r=i_row, value='voltageInitMode') + + i_row += 1 + self.sheet[i_row, 0].data = 'Read slack bus' + self.sheet[i_row, 1].checkbox(checked=self.context.lf_parameters.read_slack_bus) + self.sheet.set_index_data(r=i_row, value='readSlackBus') + + i_row += 1 + self.sheet[i_row, 0].data = 'Write slack bus' + self.sheet[i_row, 1].checkbox(checked=self.context.lf_parameters.write_slack_bus) + self.sheet.set_index_data(r=i_row, value='writeSlackBus') + + i_row += 1 + self.sheet[i_row, 0].data = 'Use reactive limits' + self.sheet[i_row, 1].checkbox(checked=self.context.lf_parameters.use_reactive_limits) + self.sheet.set_index_data(r=i_row, value='useReactiveLimits') + + i_row += 1 + self.sheet[i_row, 0].data = 'Enable Phase Shifter control' + self.sheet[i_row, 1].checkbox(checked=self.context.lf_parameters.phase_shifter_regulation_on) + self.sheet.set_index_data(r=i_row, value='phaseShifterRegulationOn') + + i_row += 1 + self.sheet[i_row, 0].data = 'Enable Transformer Voltage control' + self.sheet[i_row, 1].checkbox(checked=self.context.lf_parameters.transformer_voltage_control_on) + self.sheet.set_index_data(r=i_row, value='transformerVoltageControlOn') + + i_row += 1 + self.sheet[i_row, 0].data = 'Enable Shunt Compensator Voltage control' + self.sheet[i_row, 1].checkbox(checked=self.context.lf_parameters.shunt_compensator_voltage_control_on) + self.sheet.set_index_data(r=i_row, value='shuntCompensatorVoltageControlOn') + + i_row += 1 + self.sheet[i_row, 0].data = 'Connected component mode' + self.sheet[i_row, 1].dropdown( + values=[ConnectedComponentMode.MAIN.name, + ConnectedComponentMode.ALL.name], + set_value=self.context.lf_parameters.connected_component_mode.name + ) + self.sheet.set_index_data(r=i_row, value='connectedComponentMode') + + i_row += 1 + self.sheet[i_row, 0].data = 'Split transformers shunt admittance' + self.sheet[i_row, 1].checkbox(checked=self.context.lf_parameters.twt_split_shunt_admittance) + self.sheet.set_index_data(r=i_row, value='twtSplitShuntAdmittance') + + i_row += 1 + self.sheet[i_row, 0].data = 'Ratio of transformers should be used in the flow equations in a DC power flow' + self.sheet[i_row, 1].checkbox(checked=self.context.lf_parameters.dc_use_transformer_ratio) + self.sheet.set_index_data(r=i_row, value='dcUseTransformerRatio') + + i_row += 1 + self.sheet[ + i_row, 0].data = 'Power factor used to convert current limits into active power limits in DC calculations' + self.sheet[i_row, 1].data = self.context.lf_parameters.dc_power_factor + self.sheet.set_index_data(r=i_row, value='dcPowerFactor') + self.sheet.format_cell(i_row, 1, formatter_options=tks.float_formatter(decimals=5)) + + for param_idx, param_s in lf.get_provider_parameters().iterrows(): + i_col = -1 + i_row += 1 + param_name = str(param_idx) + param_description = param_s.description + param_type = param_s.type + param_default = param_s.default + if param_default == '[]': + param_default = '' + param_possible_values = None + if param_s.possible_values and param_s.possible_values != '[]': + param_possible_values = param_s.possible_values[1:-1].split(', ') + + i_col += 1 + self.sheet[i_row, i_col].data = '\n'.join(textwrap.wrap(param_description)) + self.sheet.set_index_data(r=i_row, value=param_name) + + i_col += 1 + if param_type == 'BOOLEAN': + self.sheet[i_row, i_col].checkbox(checked=param_default.lower() == 'true') + elif param_type == 'STRING': + if param_possible_values: + self.sheet[i_row, i_col].dropdown( + values=param_possible_values, + set_value=param_default, + ) + else: + self.sheet[i_row, i_col].data = param_default + elif param_type == 'STRING_LIST': + # FIXME: make use of param_possible_values. + # tksheet does not support multiple selection in dropdown. + # also careful of OLF param where order is significant such as voltageTargetPriorities. + if param_default.startswith('[') and param_default.endswith(']'): + # clean it, we should fix this in OLF + param_default = param_default[1:-1] + self.sheet[i_row, i_col].data = param_default + elif param_type == 'INTEGER': + self.sheet[i_row, i_col].data = param_default + self.sheet.format_cell(i_row, i_col, formatter_options=tks.int_formatter()) + elif param_type == 'DOUBLE': + self.sheet[i_row, i_col].data = param_default + self.sheet.format_cell(i_row, i_col, formatter_options=tks.float_formatter(decimals=5)) + + self.sheet.set_header_data(c=0, value="Description") + self.sheet["A"].readonly(readonly=True) + self.sheet.set_header_data(c=1, value="Value") + self.sheet.set_all_cell_sizes_to_text() + self.sheet.set_index_width(300) + self.sheet.pack(fill="both", expand=True) + + +if __name__ == "__main__": + if os.name == 'nt': + # Fixing the blur UI on Windows + from ctypes import windll + + windll.shcore.SetProcessDpiAwareness(2) + root = tk.Tk() + # Windows & OSX + root.unbind_class("TCombobox", "") + + # Linux and other *nix systems: + root.unbind_class("TCombobox", "") + root.unbind_class("TCombobox", "") + ctx = AppContext(root) + lfpv = LoadFlowParametersView(root, ctx) + lfpv.pack(fill="both", expand=True) + root.mainloop() diff --git a/yagat/frames/impl/main_application.py b/yagat/frames/impl/main_application.py new file mode 100644 index 0000000..1bb6f25 --- /dev/null +++ b/yagat/frames/impl/main_application.py @@ -0,0 +1,80 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +import logging +import tkinter as tk +import traceback +from tkinter.messagebox import showerror + +import yagat +from yagat.app_context import AppContext +from yagat.frames.impl.tree_and_diagram import TreeAndDiagram +from yagat.frames.impl.status_bar import StatusBar +from yagat.frames.impl.load_flow_parameters import LoadFlowParametersView +from yagat.menus import MenuBar +from yagat.utils import get_centered_geometry + + +class MainApplication(tk.Frame): + + @staticmethod + def show_error(*args): + logging.exception('Exception occurred') + err = traceback.format_exception(*args) + showerror(type(args[1]).__name__ + ' 😢: ' + str(args[1]), ''.join(err)) + + def __init__(self, parent, *args, **kwargs): + tk.Frame.__init__(self, parent, *args, **kwargs) + self.parent = parent + self.parent.report_callback_exception = self.show_error + # disable tear-off menus + self.parent.option_add('*tearOff', False) + self.parent.title('YAGAT v' + yagat.__version__) + self.parent.geometry(get_centered_geometry(parent, 1000, 600)) + self.context = AppContext(parent) + + self.context.add_view_changed_listener(self.on_view_changed) + + self.menubar = MenuBar(self, self.context) + self.parent.config(menu=self.menubar) + + # the container is where we'll stack a bunch of frames + # on top of each other, then the one we want visible + # will be raised above the others + self.container = container = tk.Frame(self.parent) + container.pack(side="top", fill="both", expand=True) + container.grid_rowconfigure(0, weight=1) + container.grid_columnconfigure(0, weight=1) + + self.frames = {} + + self.tree_and_diagram = TreeAndDiagram(container, self.context) + self.tree_and_diagram.paned_window.grid(row=0, column=0, sticky="nsew") + + self.lfp = LoadFlowParametersView(container, self.context) + self.lfp.grid(row=0, column=0, sticky="nsew") + + self.tree_and_diagram.paned_window.tkraise() + + self.statusbar = StatusBar(self.parent, self.context) + self.statusbar.pack(fill=tk.X) + + def on_view_changed(self, new_view): + if new_view == 'Diagram': + self.tree_and_diagram.paned_window.tkraise() + elif new_view == 'LoadFlowParameters': + self.lfp.tkraise() + else: + raise ValueError(f'Unknown view {new_view}') + + +if __name__ == "__main__": + root = tk.Tk() + # disable tear-off menus + root.option_add('*tearOff', False) + MainApplication(root) + root.mainloop() diff --git a/yagat/frames/impl/splash_screen.py b/yagat/frames/impl/splash_screen.py new file mode 100644 index 0000000..33e65bb --- /dev/null +++ b/yagat/frames/impl/splash_screen.py @@ -0,0 +1,31 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +import os +import tkinter as tk + +from yagat import get_app_path, __version__ +from yagat.utils import get_centered_geometry + + +class SplashScreen(tk.Frame): + def __init__(self, parent, *args, **kwargs): + tk.Frame.__init__(self, parent, *args, **kwargs) + parent.overrideredirect(True) + parent.geometry(get_centered_geometry(parent, 403, 302)) + splash_canvas = tk.Canvas(parent) + splash_canvas.pack(fill="both", expand=True) + self.img = tk.PhotoImage(file=os.path.join(get_app_path(), 'images/splash.png')) + splash_canvas.create_image(0, 0, image=self.img, anchor=tk.NW) + splash_canvas.create_text(10, 10, text='Yet Another Grid Analysis Tool', fill="black", anchor=tk.NW) + splash_canvas.create_text(10, 30, text=f'YAGAT v{__version__}', fill="black", anchor=tk.NW) + + +if __name__ == "__main__": + root = tk.Tk() + SplashScreen(root) + root.mainloop() diff --git a/yagat/frames/impl/status_bar.py b/yagat/frames/impl/status_bar.py new file mode 100644 index 0000000..cf3d99c --- /dev/null +++ b/yagat/frames/impl/status_bar.py @@ -0,0 +1,33 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +import tkinter as tk +from tkinter import ttk + +from yagat.app_context import AppContext + + +class StatusBar(tk.Frame): + def __init__(self, parent, context: AppContext, *args, **kwargs): + tk.Frame.__init__(self, parent, *args, **kwargs) + self.context = context + self.sizegrip = ttk.Sizegrip(self) + self.sizegrip.pack(side=tk.LEFT) + self.statusbar = ttk.Label(parent, text='Ready', borderwidth=1, relief=tk.SUNKEN) + self.statusbar.pack(side=tk.LEFT, fill=tk.X, expand=True) + self.context.add_status_text_listener(lambda value: self.statusbar.config(text=value)) + + +if __name__ == "__main__": + root = tk.Tk() + # disable tear-off menus + root.option_add('*tearOff', False) + ctx = AppContext(root) + label = ttk.Label(root, text='app goes here') + label.pack(expand=True, fill=tk.BOTH) + StatusBar(root, ctx).pack(fill=tk.X) + root.mainloop() diff --git a/yagat/frames/impl/tree_and_diagram.py b/yagat/frames/impl/tree_and_diagram.py new file mode 100644 index 0000000..0acdcda --- /dev/null +++ b/yagat/frames/impl/tree_and_diagram.py @@ -0,0 +1,36 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +import tkinter as tk + +from yagat.app_context import AppContext +from yagat.frames.impl.diagram_view import DiagramView +from yagat.frames.impl.tree_view import TreeView + + +class TreeAndDiagram(tk.Frame): + def __init__(self, parent, context, *args, **kwargs): + tk.Frame.__init__(self, parent, *args, **kwargs) + self.parent = parent + self.context = context + + self.paned_window = tk.PanedWindow(self.parent, orient=tk.HORIZONTAL, showhandle=False, sashrelief=tk.RAISED, + sashpad=4, sashwidth=8) + + self.tree_view = TreeView(self.paned_window, self.context) + self.right_frame = DiagramView(self.paned_window, self.context) + + self.paned_window.add(self.tree_view) + self.paned_window.add(self.right_frame) + + +if __name__ == "__main__": + root = tk.Tk() + ctx = AppContext(root) + v = TreeAndDiagram(root, ctx) + v.paned_window.pack(fill=tk.BOTH, expand=True) + root.mainloop() diff --git a/yagat/frames/impl/tree_view.py b/yagat/frames/impl/tree_view.py new file mode 100644 index 0000000..0cf85f9 --- /dev/null +++ b/yagat/frames/impl/tree_view.py @@ -0,0 +1,169 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +import logging +import threading +import tkinter as tk +from tkinter import ttk +from typing import Dict, Union, Optional + +import pypowsybl.network as pn + +import yagat.networkstructure as ns +from yagat.app_context import AppContext + + +class TreeView(tk.Frame): + def __init__(self, parent, context: AppContext, *args, **kwargs): + tk.Frame.__init__(self, parent, *args, **kwargs) + self.context = context + + self.search_var = tk.StringVar() + self.search_var.trace_add(mode='write', callback=lambda _1, _2, _3: self.on_search()) + self.search = ttk.Entry(self, textvariable=self.search_var) + self.search.pack(side=tk.TOP, fill=tk.X) + + # show='tree' => will not show header + # selectmode='browse' => single item + self.tree = ttk.Treeview(self, show='tree', selectmode='browse') + self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + self.tree_parent = None + self.nodes_mapping: Dict[Union['ns.Substation', 'ns.VoltageLevel'], str] = {} + self.selection_mapping: Dict[str, str] = {} + + self.scrollbar = ttk.Scrollbar(self, orient="vertical", command=self.tree.yview) + self.tree.configure(yscrollcommand=self.scrollbar.set) + self.scrollbar.pack(side=tk.LEFT, fill=tk.Y) + context.add_network_changed_listener(self.on_network_changed) + self.tree.bind("<>", self.on_tree_select) + + self.search_thread = None + self.search_pending = False + self.context.add_selection_changed_listener(self.on_selection_changed) + + def on_selection_changed(self, selection: tuple[Optional[str], Optional[str], Optional[ns.Connection]]): + _, selection_id, _ = selection + if not selection_id: + return + + existing_selection = None + tree_selection = [self.tree.item(item)["values"] for item in self.tree.selection()] + if tree_selection and tree_selection[0] != '': + existing_selection = tree_selection[0][1] + if selection_id != existing_selection: + node = self.selection_mapping[selection_id] + self.tree.focus(node) + self.tree.selection_set(node) + self.tree.see(node) + + def on_search(self): + if self.search_thread is not None: + self.search_pending = True + return + to_reattach = [] + self.search_thread = threading.Thread(target=self.on_search_background, args=(to_reattach,)) + self.search_thread.start() + + def schedule_check(t): + self.after(200, check_if_done, t) + + def check_if_done(t): + if not t.is_alive(): + logging.info("Updating tree view...") + self._detach_all() + for item, parent, index in to_reattach: + self.tree.reattach(item, parent, index) + logging.info("Done updating tree view") + self.search_thread = None + if self.search_pending: + self.search_pending = False + self.on_search() + else: + schedule_check(t) + + schedule_check(self.search_thread) + + def on_search_background(self, to_reattach): + if not self.tree_parent: + return + to_search = self.search_var.get().lower() + + included_substations, included_voltage_levels = self._get_included(to_search) + + index = -1 + for substation in self.context.network_structure.substations: + if substation in included_substations: + index += 1 + to_reattach.append((self.nodes_mapping[substation], self.tree_parent, index)) + + for voltage_level in self.context.network_structure.voltage_levels: + if voltage_level in included_voltage_levels: + voltage_level_node = self.nodes_mapping[voltage_level] + parent_node = self.tree_parent + if voltage_level.substation: + parent_node = self.nodes_mapping[voltage_level.substation] + index += 1 + to_reattach.append((voltage_level_node, parent_node, index)) + + def _get_included(self, to_search): + logging.info(f'Searching {to_search}...') + included_substations = set() + for substation in self.context.network_structure.substations: + if to_search in substation.substation_id.lower() or to_search in substation.name.lower(): + included_substations.add(substation) + included_voltage_levels = set() + for voltage_level in self.context.network_structure.voltage_levels: + if to_search in voltage_level.voltage_level_id.lower() or to_search in voltage_level.name.lower(): + included_voltage_levels.add(voltage_level) + if voltage_level.substation: + included_substations.add(voltage_level.substation) + logging.info( + f'Searching {to_search}: {len(included_substations)} substations, ' + f'{len(included_voltage_levels)} voltage levels') + return included_substations, included_voltage_levels + + def _detach_all(self): + for level_1_item in self.tree.get_children(self.tree_parent): + for level_2_item in self.tree.get_children(level_1_item): + self.tree.detach(level_2_item) + self.tree.detach(level_1_item) + + def on_network_changed(self, network: pn.Network): + self.tree.delete(*self.tree.get_children()) + self.tree_parent = None + if not network: + return + self.tree_parent = self.tree.insert('', 'end', text=network.name, open=True) + + for substation in self.context.network_structure.substations: + node = self.tree.insert(self.tree_parent, "end", text=f"{substation.name} ({substation.substation_id})", + values=['substation', substation.substation_id], open=True) + self.nodes_mapping[substation] = node + self.selection_mapping[substation.substation_id] = node + + for voltage_level in self.context.network_structure.voltage_levels: + parent_node = self.tree_parent + if voltage_level.substation: + parent_node = self.nodes_mapping[voltage_level.substation] + node = self.tree.insert(parent_node, "end", text=f"{voltage_level.name} ({voltage_level.voltage_level_id})", + values=['voltage_level', voltage_level.voltage_level_id]) + self.nodes_mapping[voltage_level] = node + self.selection_mapping[voltage_level.voltage_level_id] = node + + def on_tree_select(self, event): + tree = event.widget + selection = [tree.item(item)["values"] for item in tree.selection()] + if selection and selection[0] != '' and selection[0][1] != self.context.selection[1]: + self.context.selection = (selection[0][0], selection[0][1], None) + + +if __name__ == "__main__": + root = tk.Tk() + ctx = AppContext(root) + TreeView(root, ctx).pack(fill="both", expand=True) + ctx.network = pn.create_ieee9() + root.mainloop() diff --git a/yagat/frames/impl/vertical_scrolled_frame.py b/yagat/frames/impl/vertical_scrolled_frame.py new file mode 100644 index 0000000..70706d5 --- /dev/null +++ b/yagat/frames/impl/vertical_scrolled_frame.py @@ -0,0 +1,105 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +import tkinter as tk +from tkinter import ttk + + +# https://stackoverflow.com/questions/16188420/tkinter-scrollbar-for-frame +# Based on +# https://web.archive.org/web/20170514022131id_/http://tkinter.unpythonic.net/wiki/VerticalScrolledFrame + +class VerticalScrolledFrame(ttk.Frame): + """A pure Tkinter scrollable frame that actually works! + * Use the 'interior' attribute to place widgets inside the scrollable frame. + * Construct and pack/place/grid normally. + * This frame only allows vertical scrolling. + """ + + def __init__(self, parent, *args, **kw): + ttk.Frame.__init__(self, parent, *args, **kw) + + # Create a canvas object and a vertical scrollbar for scrolling it. + self.vsb = ttk.Scrollbar(self, orient=tk.VERTICAL) + self.vsb.pack(fill=tk.Y, side=tk.RIGHT, expand=tk.FALSE) + self.canvas = canvas = tk.Canvas(self, bd=0, highlightthickness=0, + yscrollcommand=self.vsb.set) + canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=tk.TRUE) + self.vsb.config(command=canvas.yview) + + # Reset the view + canvas.xview_moveto(0) + canvas.yview_moveto(0) + + events = ['', '', ''] + + def bind(): + for e in events: + canvas.bind_all(e, self._on_mousewheel) + + def unbind(): + for e in events: + canvas.unbind_all(e) + + # Activate / deactivate mousewheel scrolling when cursor is over / not over the canvas. + canvas.bind("", lambda _: bind()) + canvas.bind("", lambda _: unbind()) + + # Create a frame inside the canvas which will be scrolled with it. + self.interior = interior = ttk.Frame(canvas) + interior_id = canvas.create_window(0, 0, window=interior, + anchor=tk.NW) + + # Track changes to the canvas and frame width and sync them, + # also updating the scrollbar. + def _configure_interior(event): + # Update the scrollbars to match the size of the inner frame. + size = (interior.winfo_reqwidth(), interior.winfo_reqheight()) + canvas.config(scrollregion="0 0 %s %s" % size) + if interior.winfo_reqwidth() != canvas.winfo_width(): + # Update the canvas's width to fit the inner frame. + canvas.config(width=interior.winfo_reqwidth()) + + interior.bind('', _configure_interior) + + def _configure_canvas(event): + if interior.winfo_reqwidth() != canvas.winfo_width(): + # Update the inner frame's width to fill the canvas. + canvas.itemconfigure(interior_id, width=canvas.winfo_width()) + + canvas.bind('', _configure_canvas) + + def _on_mousewheel(self, event): + y1, y2 = self.vsb.get() + direction = 0 + # linux / windows / macOS + if event.num == 5 or event.delta == -120 or event.delta == -1: + direction = +1 + if event.num == 4 or event.delta == 120 or event.delta == 1: + direction = -1 + if y1 != 0 or y2 != 1: + self.canvas.yview_scroll(direction, "units") + + +if __name__ == "__main__": + + class SampleApp(tk.Tk): + def __init__(self, *args, **kwargs): + tk.Tk.__init__(self, *args, **kwargs) + + self.frame = VerticalScrolledFrame(self) + self.frame.pack() + self.label = ttk.Label(self, text="Shrink the window to activate the scrollbar.") + self.label.pack() + buttons = [] + for i in range(10): + buttons.append(ttk.Button(self.frame.interior, text="Button " + str(i))) + buttons[-1].pack() + + + app = SampleApp() + app.mainloop() diff --git a/yagat/images/logo.png b/yagat/images/logo.png new file mode 100644 index 0000000..1038364 Binary files /dev/null and b/yagat/images/logo.png differ diff --git a/yagat/images/splash.png b/yagat/images/splash.png new file mode 100644 index 0000000..093dffd Binary files /dev/null and b/yagat/images/splash.png differ diff --git a/yagat/menus/__init__.py b/yagat/menus/__init__.py new file mode 100644 index 0000000..07d8964 --- /dev/null +++ b/yagat/menus/__init__.py @@ -0,0 +1,8 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +from .impl.bar import MenuBar diff --git a/yagat/menus/impl/__init__.py b/yagat/menus/impl/__init__.py new file mode 100644 index 0000000..65327b4 --- /dev/null +++ b/yagat/menus/impl/__init__.py @@ -0,0 +1,7 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# diff --git a/yagat/menus/impl/bar.py b/yagat/menus/impl/bar.py new file mode 100644 index 0000000..3eaa1e3 --- /dev/null +++ b/yagat/menus/impl/bar.py @@ -0,0 +1,22 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +import tkinter as tk + +from .file import FileMenu +from .help import HelpMenu +from .run import RunMenu +from .view import ViewMenu + + +class MenuBar(tk.Menu): + def __init__(self, parent, context, *args, **kwargs): + tk.Menu.__init__(self, parent, *args, **kwargs) + self.file_menu = FileMenu(self, context) + self.run_menu = RunMenu(self, context) + self.view_menu = ViewMenu(self, context) + self.help_menu = HelpMenu(self) diff --git a/yagat/menus/impl/file.py b/yagat/menus/impl/file.py new file mode 100644 index 0000000..b86bfb8 --- /dev/null +++ b/yagat/menus/impl/file.py @@ -0,0 +1,83 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +import tkinter as tk +from tkinter import filedialog as fd + +import pypowsybl as pp +import pypowsybl.network as pn + +from yagat.app_context import AppContext + + +class FileMenu(tk.Menu): + def __init__(self, parent, context: AppContext, *args, **kwargs): + tk.Menu.__init__(self, parent, *args, **kwargs) + + self.parent = parent + self.context = context + + parent.add_cascade( + label="File", + menu=self, + underline=0, + ) + + self.add_command(label='Open...', command=self.open_network) + + self.sample_networks_menu = tk.Menu(self) + self.add_cascade(label='Open sample network', menu=self.sample_networks_menu) + + def load_sample_network(network: pn.Network): + context.network = network + context.status_text = 'Network ' + network.name + ' loaded' + + self.sample_networks_menu.add_command(label='IEEE 9 Bus', + command=lambda: load_sample_network(pn.create_ieee9())) + self.sample_networks_menu.add_command(label='IEEE 14 Bus', + command=lambda: load_sample_network(pn.create_ieee14())) + self.sample_networks_menu.add_command(label='IEEE 30 Bus', + command=lambda: load_sample_network(pn.create_ieee30())) + self.sample_networks_menu.add_command(label='IEEE 57 Bus', + command=lambda: load_sample_network(pn.create_ieee57())) + self.sample_networks_menu.add_command(label='IEEE 118 Bus', + command=lambda: load_sample_network(pn.create_ieee118())) + self.sample_networks_menu.add_command(label='IEEE 300 Bus', + command=lambda: load_sample_network(pn.create_ieee300())) + self.sample_networks_menu.add_command(label='CGMES MicroGrid BE', + command=lambda: load_sample_network(pn.create_micro_grid_be_network())) + self.sample_networks_menu.add_command(label='CGMES MicroGrid NL', + command=lambda: load_sample_network(pn.create_micro_grid_nl_network())) + self.sample_networks_menu.add_command(label='PowSyBl Metrix 6 Bus', command=lambda: load_sample_network( + pn.create_metrix_tutorial_six_buses_network())) + self.sample_networks_menu.add_command(label='Eurostag Tutorial', command=lambda: load_sample_network( + pn.create_eurostag_tutorial_example1_network())) + self.sample_networks_menu.add_command(label='Eurostag Tutorial with power limits', + command=lambda: load_sample_network( + pn.create_eurostag_tutorial_example1_with_power_limits_network())) + self.sample_networks_menu.add_command(label='Four Substations Node-Breaker', + command=lambda: load_sample_network( + pn.create_four_substations_node_breaker_network())) + self.sample_networks_menu.add_command(label='Four Substations Node-Breaker with extensions', + command=lambda: load_sample_network( + pn.create_four_substations_node_breaker_network_with_extensions())) + + self.add_separator() + self.add_command( + label='Exit', + command=context.tk_root.destroy, + underline=1, + ) + + def open_network(self): + filename = fd.askopenfilename() + if not filename: + self.context.status_text = 'File opening cancelled by user' + else: + self.context.status_text = 'Opening ' + filename + self.context.network = pp.network.load(filename) + self.context.status_text = 'Network ' + self.context.network.name + ' loaded' diff --git a/yagat/menus/impl/help.py b/yagat/menus/impl/help.py new file mode 100644 index 0000000..b24f05b --- /dev/null +++ b/yagat/menus/impl/help.py @@ -0,0 +1,26 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +import tkinter as tk +from tkinter.messagebox import showinfo + +import pypowsybl + +import yagat + + +class HelpMenu(tk.Menu): + def __init__(self, parent, *args, **kwargs): + tk.Menu.__init__(self, parent, *args, **kwargs) + + self.parent = parent + parent.add_cascade(label="Help", menu=self) + self.add_command(label='About...', command=lambda: showinfo('About YAGAT', + 'Yet Another Grid Analysis Tool' + + '\nhttps://github.com/jeandemanged/yagat/wiki' + + '\nYAGAT v' + yagat.__version__ + + '\nBased on PyPowSyBl v' + pypowsybl.__version__)) diff --git a/yagat/menus/impl/run.py b/yagat/menus/impl/run.py new file mode 100644 index 0000000..7ebbd17 --- /dev/null +++ b/yagat/menus/impl/run.py @@ -0,0 +1,33 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +import tkinter as tk + +import pypowsybl.loadflow as lf +import pypowsybl.report as pr + +from yagat.app_context import AppContext + + +class RunMenu(tk.Menu): + def __init__(self, parent, context: AppContext, *args, **kwargs): + tk.Menu.__init__(self, parent, *args, **kwargs) + + self.parent = parent + self.context = context + parent.add_cascade(label="Run", menu=self) + self.add_command(label='Load Flow', command=self.run_load_flow) + + def run_load_flow(self): + reporter = pr.Reporter() + self.context.status_text = 'Starting Load Flow' + results = lf.run_ac(self.context.network, parameters=self.context.lf_parameters, reporter=reporter) + self.context.status_text = 'Load Flow completed' + self.context.network_structure.refresh() + self.context.notify_selection_changed() # hack to trigger refresh + print(results) + print(reporter) diff --git a/yagat/menus/impl/view.py b/yagat/menus/impl/view.py new file mode 100644 index 0000000..d3b835a --- /dev/null +++ b/yagat/menus/impl/view.py @@ -0,0 +1,28 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +import tkinter as tk + +from yagat.app_context import AppContext + + +class ViewMenu(tk.Menu): + def __init__(self, parent, context: AppContext, *args, **kwargs): + tk.Menu.__init__(self, parent, *args, **kwargs) + + self.parent = parent + self.context = context + parent.add_cascade(label="View", menu=self) + self.add_command(label='Diagram', command=self.view_diagram) + self.add_separator() + self.add_command(label='Load Flow Parameters', command=self.view_load_flow_parameters) + + def view_diagram(self): + self.context.selected_view = 'Diagram' + + def view_load_flow_parameters(self): + self.context.selected_view = 'LoadFlowParameters' diff --git a/yagat/networkstructure/__init__.py b/yagat/networkstructure/__init__.py new file mode 100644 index 0000000..2754618 --- /dev/null +++ b/yagat/networkstructure/__init__.py @@ -0,0 +1,13 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +from .impl.bus_views import BusView +from .impl.connection import Connection +from .impl.equipment_type import EquipmentType, ShuntCompensatorType +from .impl.network_structure import NetworkStructure +from .impl.substation import Substation +from .impl.voltage_level import VoltageLevel diff --git a/yagat/networkstructure/impl/__init__.py b/yagat/networkstructure/impl/__init__.py new file mode 100644 index 0000000..65327b4 --- /dev/null +++ b/yagat/networkstructure/impl/__init__.py @@ -0,0 +1,7 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# diff --git a/yagat/networkstructure/impl/bus_views.py b/yagat/networkstructure/impl/bus_views.py new file mode 100644 index 0000000..f7c53c8 --- /dev/null +++ b/yagat/networkstructure/impl/bus_views.py @@ -0,0 +1,13 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +from enum import StrEnum + + +class BusView(StrEnum): + BUS_BREAKER = 'BUS_BREAKER' + BUS_BRANCH = 'BUS_BRANCH' diff --git a/yagat/networkstructure/impl/connection.py b/yagat/networkstructure/impl/connection.py new file mode 100644 index 0000000..8a4e295 --- /dev/null +++ b/yagat/networkstructure/impl/connection.py @@ -0,0 +1,98 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +import math +from typing import Optional + +import pandas as pd + +import yagat.networkstructure as ns + + +class Connection: + def __init__(self, network_structure: 'ns.NetworkStructure', voltage_level: 'ns.VoltageLevel', equipment_id: str, + equipment_type: 'ns.EquipmentType', + side: Optional[int] = None, name: Optional[str] = None): + self._network_structure = network_structure + self._voltage_level = voltage_level + self._equipment_id = equipment_id + self._name = name + self._equipment_type = equipment_type + self._side = side + + @property + def equipment_id(self) -> str: + return self._equipment_id + + @property + def name(self) -> str: + if self._name: + return self._name + return self._equipment_id + + @property + def equipment_type(self) -> 'ns.EquipmentType': + return self._equipment_type + + @property + def side(self) -> int: + return self._side + + @property + def voltage_level(self) -> 'ns.VoltageLevel': + return self._voltage_level + + @property + def substation(self) -> 'Optional[ns.Substation]': + return self._voltage_level.substation + + @property + def network_structure(self) -> 'ns.NetworkStructure': + return self._network_structure + + def _side_char(self): + if not self.side: + return '' + return f'{self.side}' + + def get_bus_id(self, bus_view: 'ns.BusView') -> str: + if self.equipment_type == ns.EquipmentType.SWITCH: + if bus_view == ns.BusView.BUS_BRANCH: + return '' + elif bus_view == ns.BusView.BUS_BREAKER: + if self.network_structure.is_retained(self): + # omg + return self.network_structure.network.get_bus_breaker_topology(self.voltage_level.voltage_level_id).switches.loc[self.equipment_id][f'bus{self._side_char()}_id'] + else: + return '' + prefix = '' + if bus_view == ns.BusView.BUS_BREAKER: + prefix = 'bus_breaker_' + return self.get_data()[f'{prefix}bus{self._side_char()}_id'] + + def get_p(self) -> float: + if self.equipment_type == ns.EquipmentType.SWITCH: + return math.nan + return self.get_data()[f'p{self._side_char()}'] + + def get_q(self) -> float: + if self.equipment_type == ns.EquipmentType.SWITCH: + return math.nan + return self.get_data()[f'q{self._side_char()}'] + + def get_i(self) -> float: + if self.equipment_type == ns.EquipmentType.SWITCH: + return math.nan + return self.get_data()[f'i{self._side_char()}'] + + def get_connected(self) -> bool: + if self.equipment_type == ns.EquipmentType.SWITCH: + return True + return self.get_data()[f'connected{self._side_char()}'] + + def get_data(self) -> pd.Series: + return self.network_structure.get_connection_data(self.equipment_id, self.side) diff --git a/yagat/networkstructure/impl/equipment_type.py b/yagat/networkstructure/impl/equipment_type.py new file mode 100644 index 0000000..8524fda --- /dev/null +++ b/yagat/networkstructure/impl/equipment_type.py @@ -0,0 +1,37 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +from enum import StrEnum + + +class EquipmentType(StrEnum): + LOAD = 'LOAD' + GENERATOR = 'GENERATOR' + LINE = 'LINE' + TWO_WINDINGS_TRANSFORMER = 'TWO_WINDINGS_TRANSFORMER' + THREE_WINDINGS_TRANSFORMER = 'THREE_WINDINGS_TRANSFORMER' + DANGLING_LINE = 'DANGLING_LINE' + STATIC_VAR_COMPENSATOR = 'STATIC_VAR_COMPENSATOR' + SHUNT_COMPENSATOR = 'SHUNT_COMPENSATOR' + LCC_CONVERTER_STATION = 'LCC_CONVERTER_STATION' + VSC_CONVERTER_STATION = 'VSC_CONVERTER_STATION' + SWITCH = 'SWITCH' + + @staticmethod + def branch_types(): + return [EquipmentType.LINE, EquipmentType.TWO_WINDINGS_TRANSFORMER] + + @staticmethod + def injection_types(): + return [EquipmentType.LOAD, EquipmentType.GENERATOR, EquipmentType.SHUNT_COMPENSATOR, + EquipmentType.DANGLING_LINE, EquipmentType.STATIC_VAR_COMPENSATOR, EquipmentType.LCC_CONVERTER_STATION, + EquipmentType.VSC_CONVERTER_STATION] + + +class ShuntCompensatorType(StrEnum): + CAPACITOR = 'CAPACITOR' + REACTOR = 'REACTOR' diff --git a/yagat/networkstructure/impl/network_structure.py b/yagat/networkstructure/impl/network_structure.py new file mode 100644 index 0000000..1b96382 --- /dev/null +++ b/yagat/networkstructure/impl/network_structure.py @@ -0,0 +1,261 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +import logging +from typing import Dict, List, Optional + +import pandas as pd +import pypowsybl.network as pn + +import yagat.networkstructure as ns + + +class NetworkStructure: + def __init__(self, network: pn.Network): + self._network: pn.Network = network + self._substations: Dict[str, ns.Substation] = {} + self._voltage_levels: Dict[str, ns.VoltageLevel] = {} + self._connections: Dict[(str, Optional[int]), ns.Connection] = {} + + self._substations_df: pd.DataFrame = pd.DataFrame() + self._voltage_levels_df: pd.DataFrame = pd.DataFrame() + + self._injections_df: Dict[ns.EquipmentType, pd.DataFrame] = {} + self._branches_df: Dict[ns.EquipmentType, pd.DataFrame] = {} + self._three_windings_transformers_df: pd.DataFrame = pd.DataFrame() + self._tie_lines_df: pd.DataFrame = pd.DataFrame() + self._buses_df: pd.DataFrame = pd.DataFrame() + self._switches_df: pd.DataFrame = pd.DataFrame() + self._hvdc_lines_df: pd.DataFrame = pd.DataFrame() + + self._linear_shunt_compensator_sections_df: pd.DataFrame = pd.DataFrame() + self._non_linear_shunt_compensator_sections_df: pd.DataFrame = pd.DataFrame() + + self.refresh() + + for substation_idx, substation_s in self._substations_df.iterrows(): + substation_id = str(substation_idx) + self._substations[substation_id] = ns.Substation(self, substation_id, str(substation_s['name'])) + + for voltage_level_idx, voltage_level_s in self._voltage_levels_df.iterrows(): + voltage_level_id = str(voltage_level_idx) + substation_id = voltage_level_s.substation_id + substation = None + if substation_id in self._substations: + substation = self._substations[substation_id] + voltage_level = ns.VoltageLevel(self, substation, voltage_level_id, str(voltage_level_s['name'])) + self._voltage_levels[voltage_level_id] = voltage_level + if substation: + substation.add_voltage_level(voltage_level) + + for typ in ns.EquipmentType.branch_types(): + self.__process_branches(self._branches_df[typ], typ) + + for typ in ns.EquipmentType.injection_types(): + self.__process_injection(self._injections_df[typ], typ) + + for three_windings_xf_idx, three_windings_xf_s in self._three_windings_transformers_df.iterrows(): + three_windings_xf_id = str(three_windings_xf_idx) + voltage_level1_id = three_windings_xf_s.voltage_level1_id + voltage_level2_id = three_windings_xf_s.voltage_level2_id + voltage_level3_id = three_windings_xf_s.voltage_level3_id + voltage_level1 = self._voltage_levels[voltage_level1_id] + voltage_level2 = self._voltage_levels[voltage_level2_id] + voltage_level3 = self._voltage_levels[voltage_level3_id] + c1 = ns.Connection(self, voltage_level1, three_windings_xf_id, ns.EquipmentType.THREE_WINDINGS_TRANSFORMER, + 1, + str(three_windings_xf_s['name'])) + c2 = ns.Connection(self, voltage_level2, three_windings_xf_id, ns.EquipmentType.THREE_WINDINGS_TRANSFORMER, + 2, + str(three_windings_xf_s['name'])) + c3 = ns.Connection(self, voltage_level3, three_windings_xf_id, ns.EquipmentType.THREE_WINDINGS_TRANSFORMER, + 3, + str(three_windings_xf_s['name'])) + voltage_level1.add_connection(c1) + voltage_level2.add_connection(c2) + voltage_level3.add_connection(c3) + self._connections[(c1.equipment_id, c1.side)] = c1 + self._connections[(c2.equipment_id, c2.side)] = c2 + self._connections[(c3.equipment_id, c3.side)] = c3 + + for switch_idx, switch_s in self._switches_df.iterrows(): + switch_id = str(switch_idx) + voltage_level_id = switch_s.voltage_level_id + voltage_level = self._voltage_levels[voltage_level_id] + c1 = ns.Connection(self, voltage_level, switch_id, ns.EquipmentType.SWITCH, + 1, + str(switch_s['name'])) + c2 = ns.Connection(self, voltage_level, switch_id, ns.EquipmentType.SWITCH, + 2, + str(switch_s['name'])) + voltage_level.add_connection(c1) + voltage_level.add_connection(c2) + self._connections[(c1.equipment_id, c1.side)] = c1 + self._connections[(c2.equipment_id, c2.side)] = c2 + + @property + def network(self) -> pn.Network: + return self._network + + def refresh(self): + logging.info('refresh start') + self._substations_df = self._network.get_substations(all_attributes=True) + self._voltage_levels_df = self._network.get_voltage_levels(all_attributes=True) + self._three_windings_transformers_df = self._network.get_3_windings_transformers(all_attributes=True) + self._tie_lines_df = self._network.get_tie_lines(all_attributes=True) + self._switches_df = self._network.get_switches(all_attributes=True) + self._injections_df[ns.EquipmentType.LOAD] = self._network.get_loads(all_attributes=True) + self._injections_df[ns.EquipmentType.GENERATOR] = self._network.get_generators(all_attributes=True) + self._injections_df[ns.EquipmentType.DANGLING_LINE] = self._network.get_dangling_lines(all_attributes=True) + self._injections_df[ns.EquipmentType.SHUNT_COMPENSATOR] = self._network.get_shunt_compensators( + all_attributes=True) + self._injections_df[ns.EquipmentType.STATIC_VAR_COMPENSATOR] = self._network.get_static_var_compensators( + all_attributes=True) + self._injections_df[ns.EquipmentType.LCC_CONVERTER_STATION] = self._network.get_lcc_converter_stations( + all_attributes=True) + self._injections_df[ns.EquipmentType.VSC_CONVERTER_STATION] = self._network.get_vsc_converter_stations( + all_attributes=True) + self._branches_df[ns.EquipmentType.LINE] = self._network.get_lines(all_attributes=True) + self._branches_df[ns.EquipmentType.TWO_WINDINGS_TRANSFORMER] = self._network.get_2_windings_transformers( + all_attributes=True) + self._buses_df = self._network.get_buses(all_attributes=True) + self._linear_shunt_compensator_sections_df = self._network.get_linear_shunt_compensator_sections( + all_attributes=True) + self._non_linear_shunt_compensator_sections_df = self._network.get_non_linear_shunt_compensator_sections( + all_attributes=True) + self._hvdc_lines_df = self._network.get_hvdc_lines(all_attributes=True) + logging.info('refresh end') + + def __process_injection(self, injections_df, injection_type: ns.EquipmentType) -> None: + for injection_idx, injection_s in injections_df.iterrows(): + injection_id = str(injection_idx) + voltage_level_id = injection_s.voltage_level_id + voltage_level = self._voltage_levels[voltage_level_id] + c1 = ns.Connection(self, voltage_level, injection_id, injection_type, None, injection_s['name']) + voltage_level.add_connection(c1) + self._connections[(c1.equipment_id, c1.side)] = c1 + + def __process_branches(self, branches_df, branch_type: ns.EquipmentType) -> None: + for branch_idx, branches_s in branches_df.iterrows(): + branch_id = str(branch_idx) + voltage_level1_id = branches_s.voltage_level1_id + voltage_level2_id = branches_s.voltage_level2_id + voltage_level1 = self._voltage_levels[voltage_level1_id] + voltage_level2 = self._voltage_levels[voltage_level2_id] + c1 = ns.Connection(self, voltage_level1, branch_id, branch_type, 1, branches_s['name']) + c2 = ns.Connection(self, voltage_level2, branch_id, branch_type, 2, branches_s['name']) + voltage_level1.add_connection(c1) + voltage_level2.add_connection(c2) + self._connections[(c1.equipment_id, c1.side)] = c1 + self._connections[(c2.equipment_id, c2.side)] = c2 + + @property + def substations(self) -> 'List[ns.Substation]': + return sorted(self._substations.values(), key=lambda s: s.name) + + @property + def voltage_levels(self) -> 'List[ns.VoltageLevel]': + return sorted(self._voltage_levels.values(), key=lambda vl: (-vl.get_data().nominal_v, vl.name)) + + def get_substation(self, substation_id: str) -> 'Optional[ns.Substation]': + if substation_id in self._substations: + return self._substations[substation_id] + return None + + def get_voltage_level(self, voltage_level_id: str) -> 'Optional[ns.VoltageLevel]': + if voltage_level_id in self._voltage_levels: + return self._voltage_levels[voltage_level_id] + return None + + def get_buses(self, voltage_level: 'ns.VoltageLevel') -> pd.DataFrame: + return self._buses_df.loc[self._buses_df['voltage_level_id'] == voltage_level.voltage_level_id] + + def get_substation_or_voltage_level(self, object_id: str) -> '[ns.Substation, ns.VoltageLevel]': + substation = self.get_substation(object_id) + if substation: + return substation + voltage_level = self.get_voltage_level(object_id) + if voltage_level: + return voltage_level + raise RuntimeError(f'{object_id} is not a known Substation or VoltageLevel') + + def get_connection(self, connection_id: str, side: Optional[int]) -> 'Optional[ns.Connection]': + if (connection_id, side) in self._connections: + return self._connections[(connection_id, side)] + return None + + def get_voltage_level_data(self, voltage_level: 'ns.VoltageLevel') -> pd.Series: + return self._voltage_levels_df.loc[voltage_level.voltage_level_id] + + def get_connection_data(self, connection_id: str, side: Optional[int]) -> pd.Series: + connection = self._connections[(connection_id, side)] + if not connection: + return pd.Series() + typ = connection.equipment_type + if typ in ns.EquipmentType.branch_types(): + df = self._branches_df[typ] + elif typ in ns.EquipmentType.injection_types(): + df = self._injections_df[typ] + elif typ == ns.EquipmentType.THREE_WINDINGS_TRANSFORMER: + df = self._three_windings_transformers_df + else: + raise RuntimeError(f'No dataframe for connection {connection_id} of type {typ}') + + return df.loc[connection.equipment_id] + + def get_shunt_compensator_type(self, connection: ns.Connection) -> 'ns.ShuntCompensatorType': + if connection.equipment_type != ns.EquipmentType.SHUNT_COMPENSATOR: + raise RuntimeError('Not a shunt compensator') + model_type = self._injections_df[ns.EquipmentType.SHUNT_COMPENSATOR].loc[connection.equipment_id]['model_type'] + if model_type == 'LINEAR': + b = self._linear_shunt_compensator_sections_df.loc[connection.equipment_id]['b_per_section'] + else: + # just take the first section. it is not supposed to be a different sign across sections. + b = self._non_linear_shunt_compensator_sections_df.loc[(connection.equipment_id, 0)]['b'] + if b > 0: + return ns.ShuntCompensatorType.CAPACITOR + return ns.ShuntCompensatorType.REACTOR + + def is_retained(self, connection: ns.Connection) -> bool: + if connection.equipment_type != ns.EquipmentType.SWITCH: + raise RuntimeError('Not a switch') + return self._switches_df.loc[connection.equipment_id]['retained'] + + def is_open(self, connection: ns.Connection) -> bool: + if connection.equipment_type != ns.EquipmentType.SWITCH: + raise RuntimeError('Not a switch') + return self._switches_df.loc[connection.equipment_id]['open'] + + def get_other_sides(self, connection: ns.Connection) -> List[ns.Connection]: + if connection.equipment_type in ns.EquipmentType.branch_types() or connection.equipment_type == ns.EquipmentType.SWITCH: + other_side_num = 1 if connection.side == 2 else 2 + return [self._connections[(connection.equipment_id, other_side_num)]] + elif connection.equipment_type == ns.EquipmentType.THREE_WINDINGS_TRANSFORMER: + other_sides = [] + for i in range(1, 4): + if i != connection.side and (connection.equipment_id, i) in self._connections: + other_sides.append(self._connections[(connection.equipment_id, i)]) + return other_sides + elif connection.equipment_type == ns.EquipmentType.DANGLING_LINE: + return self._get_other_side_from_df(connection, self._tie_lines_df, 'dangling_line1_id', + 'dangling_line2_id') + elif connection.equipment_type in [ns.EquipmentType.LCC_CONVERTER_STATION, + ns.EquipmentType.VSC_CONVERTER_STATION]: + return self._get_other_side_from_df(connection, self._hvdc_lines_df, 'converter_station1_id', + 'converter_station2_id') + return [] + + def _get_other_side_from_df(self, connection: ns.Connection, data_frame: pd.DataFrame, col1: str, col2: str) -> \ + List[ns.Connection]: + for _, series in data_frame.iterrows(): + eq1 = series[col1] + eq2 = series[col2] + if connection.equipment_id == eq1: + return [self._connections[(eq2, None)]] + elif connection.equipment_id == eq2: + return [self._connections[(eq1, None)]] + return [] diff --git a/yagat/networkstructure/impl/substation.py b/yagat/networkstructure/impl/substation.py new file mode 100644 index 0000000..3922443 --- /dev/null +++ b/yagat/networkstructure/impl/substation.py @@ -0,0 +1,44 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +from typing import Dict, List, Optional + +import yagat.networkstructure as ns + + +class Substation: + def __init__(self, network_structure: 'ns.NetworkStructure', substation_id: str, name: Optional[str] = None): + self._network_structure = network_structure + self._substation_id = substation_id + self._name = name + self._voltage_levels: Dict[str, 'ns.VoltageLevel'] = {} + + @property + def network_structure(self) -> 'ns.NetworkStructure': + return self._network_structure + + @property + def substation_id(self) -> str: + return self._substation_id + + @property + def name(self) -> str: + if self._name: + return self._name + return self._substation_id + + @property + def voltage_levels(self) -> List['ns.VoltageLevel']: + return sorted(self._voltage_levels.values(), key=lambda vl: vl.get_data().nominal_v, reverse=True) + + def get_voltage_level(self, voltage_level_id: str) -> Optional['ns.VoltageLevel']: + if voltage_level_id in self._voltage_levels: + return self._voltage_levels[voltage_level_id] + return None + + def add_voltage_level(self, voltage_level: 'ns.VoltageLevel') -> None: + self._voltage_levels[voltage_level.voltage_level_id] = voltage_level diff --git a/yagat/networkstructure/impl/voltage_level.py b/yagat/networkstructure/impl/voltage_level.py new file mode 100644 index 0000000..9f4dc16 --- /dev/null +++ b/yagat/networkstructure/impl/voltage_level.py @@ -0,0 +1,68 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +from typing import Dict, List, Optional + +import pandas as pd + +import yagat.networkstructure as ns + + +class VoltageLevel: + def __init__(self, network_structure: 'ns.NetworkStructure', substation: 'Optional[ns.Substation]', + voltage_level_id: str, name: Optional[str] = None): + self._network_structure = network_structure + self._substation = substation + self._voltage_level_id = voltage_level_id + self._name = name + self._connections: Dict[(str, Optional[int]), 'ns.Connection'] = {} + + @property + def voltage_level_id(self) -> str: + return self._voltage_level_id + + @property + def name(self) -> str: + if self._name: + return self._name + return self._voltage_level_id + + @property + def substation(self) -> 'Optional[ns.Substation]': + return self._substation + + @property + def network_structure(self) -> 'ns.NetworkStructure': + return self._network_structure + + def add_connection(self, connection: 'ns.Connection'): + self._connections[(connection.equipment_id, connection.side)] = connection + + @property + def connections(self) -> List['ns.Connection']: + return list(self._connections.values()) + + def get_buses(self, bus_view: 'ns.BusView') -> pd.DataFrame: + bus_branch_buses = self.network_structure.get_buses(self) + match bus_view: + case ns.BusView.BUS_BRANCH: + return bus_branch_buses + case ns.BusView.BUS_BREAKER: + return self.network_structure.network.get_bus_breaker_topology(self.voltage_level_id).buses.join(bus_branch_buses, on='bus_id', rsuffix='_ignore') + + + def get_bus_connections(self, bus_view: 'ns.BusView', bus_id: str) -> List['ns.Connection']: + bus_connections = [c for c in self._connections.values() if c.get_bus_id(bus_view) == bus_id] + return bus_connections + + def get_connection(self, connection_id: str, side: Optional[int] = None) -> Optional['ns.Connection']: + if (connection_id, side) in self._connections: + return self._connections[(connection_id, side)] + return None + + def get_data(self) -> pd.Series: + return self.network_structure.get_voltage_level_data(self) diff --git a/yagat/utils/__init__.py b/yagat/utils/__init__.py new file mode 100644 index 0000000..db36f61 --- /dev/null +++ b/yagat/utils/__init__.py @@ -0,0 +1,9 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +from .impl.screen_utils import get_centered_geometry +from .impl.formatting_utils import format_v_mag, format_v_angle, format_power diff --git a/yagat/utils/impl/__init__.py b/yagat/utils/impl/__init__.py new file mode 100644 index 0000000..65327b4 --- /dev/null +++ b/yagat/utils/impl/__init__.py @@ -0,0 +1,7 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# diff --git a/yagat/utils/impl/formatting_utils.py b/yagat/utils/impl/formatting_utils.py new file mode 100644 index 0000000..8cc7a09 --- /dev/null +++ b/yagat/utils/impl/formatting_utils.py @@ -0,0 +1,21 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +def format_v_mag(value: float): + return _repl_nan(f'{value:.2f}') + + +def format_v_angle(value: float): + return _repl_nan(f'{value:.3f}') + + +def format_power(value: float): + return _repl_nan(f'{value:.1f}') + + +def _repl_nan(value: str): + return value.replace('nan', '-') diff --git a/yagat/utils/impl/screen_utils.py b/yagat/utils/impl/screen_utils.py new file mode 100644 index 0000000..c130c44 --- /dev/null +++ b/yagat/utils/impl/screen_utils.py @@ -0,0 +1,30 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +import os +import tkinter as tk + + +def get_screen_infos(root: tk.Tk) -> tuple[int, int]: + screen_width = root.winfo_screenwidth() + screen_height = root.winfo_screenheight() + scale = 1.0 + + if os.name == 'nt': + # Fix Tk's winfo not accounting windows scaling + from ctypes import windll + + scale = windll.shcore.GetScaleFactorForDevice(0) / 100 + + return scale * screen_width, scale * screen_height + + +def get_centered_geometry(root: tk.Tk, width: int, height: int) -> str: + screen_width, screen_height = get_screen_infos(root) + x = int((screen_width / 2) - (width / 2)) + y = int((screen_height / 2) - (height / 2)) + return f'{width}x{height}+{x}+{y}' diff --git a/yagat/widgets/__init__.py b/yagat/widgets/__init__.py new file mode 100644 index 0000000..4a918e7 --- /dev/null +++ b/yagat/widgets/__init__.py @@ -0,0 +1,9 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +from .impl.label_value import LabelValue +from .impl.substation import Substation, VoltageLevel, Bus, Connection diff --git a/yagat/widgets/impl/__init__.py b/yagat/widgets/impl/__init__.py new file mode 100644 index 0000000..65327b4 --- /dev/null +++ b/yagat/widgets/impl/__init__.py @@ -0,0 +1,7 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# diff --git a/yagat/widgets/impl/label_value.py b/yagat/widgets/impl/label_value.py new file mode 100644 index 0000000..c89fbec --- /dev/null +++ b/yagat/widgets/impl/label_value.py @@ -0,0 +1,41 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +import tkinter as tk +from tkinter import font +from tkinter import ttk +from typing import Optional + + +class LabelValue(tk.Frame): + BOLD_FONT = None + + def __init__(self, parent, label: str, value: tk.StringVar, unit: Optional[str] = None, *args, **kwargs): + tk.Frame.__init__(self, parent, *args, **kwargs) + + if not LabelValue.BOLD_FONT: + font_copy = font.nametofont('TkDefaultFont').copy() + font_copy.config(weight='bold') + LabelValue.BOLD_FONT = font_copy + + self._label = ttk.Label(self, text=label) + self._label.pack(side=tk.LEFT) + + self._value_var = value + self._value = ttk.Label(self, textvariable=self._value_var, font=LabelValue.BOLD_FONT) + self._value.pack(side=tk.LEFT) + + if unit: + self._unit = ttk.Label(self, text=unit) + self._unit.pack(side=tk.LEFT) + + +if __name__ == "__main__": + root = tk.Tk() + LabelValue(root, 'label1:', tk.StringVar(value='value1')).pack(side=tk.LEFT) + LabelValue(root, 'label2:', tk.StringVar(value='value2'), 'kV').pack(side=tk.LEFT, padx=10) + root.mainloop() diff --git a/yagat/widgets/impl/substation.py b/yagat/widgets/impl/substation.py new file mode 100644 index 0000000..47427eb --- /dev/null +++ b/yagat/widgets/impl/substation.py @@ -0,0 +1,242 @@ +# +# Copyright (c) 2024, Damien Jeandemange (https://github.com/jeandemanged) +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# +import math +import tkinter as tk + +import pandas as pd +import pypowsybl.network as pn + +import yagat.networkstructure as ns +import yagat.widgets as pw +from yagat.utils import format_v_mag, format_v_angle, format_power + + +class Substation(tk.Frame): + def __init__(self, parent, substation: 'ns.Substation', *args, **kwargs): + tk.Frame.__init__(self, parent, *args, **kwargs) + + self._name_var = tk.StringVar(value=substation.name) + self._name_label = pw.LabelValue(self, 'Substation:', self._name_var) + self._name_label.pack(side=tk.LEFT) + + self._id_var = tk.StringVar(value=substation.substation_id) + self._id_label = pw.LabelValue(self, 'id:', self._id_var) + self._id_label.pack(side=tk.LEFT, padx=(10, 0)) + + +class VoltageLevel(tk.Frame): + def __init__(self, parent, voltage_level: 'ns.VoltageLevel', *args, **kwargs): + tk.Frame.__init__(self, parent, *args, **kwargs) + + self._name_var = tk.StringVar(value=voltage_level.name) + self._name_label = pw.LabelValue(self, 'VoltageLevel:', self._name_var) + self._name_label.pack(side=tk.LEFT) + + self._nominal_v_var = tk.StringVar(value=format_v_mag(voltage_level.get_data().nominal_v)) + self._nominal_v_label = pw.LabelValue(self, 'Nominal voltage:', self._nominal_v_var, 'kV') + self._nominal_v_label.pack(side=tk.LEFT, padx=(10, 0)) + + self._id_var = tk.StringVar(value=voltage_level.voltage_level_id) + self._id_label = pw.LabelValue(self, 'id:', self._id_var) + self._id_label.pack(side=tk.LEFT, padx=(10, 0)) + + +class Bus(tk.Frame): + def __init__(self, parent, bus_id: str, bus_data: pd.Series, *args, **kwargs): + tk.Frame.__init__(self, parent, *args, **kwargs) + + self._name_var = tk.StringVar(value=str(bus_data['name'])) + self._name_label = pw.LabelValue(self, 'Bus:', self._name_var) + self._name_label.pack(side=tk.LEFT) + + self._v_mag_var = tk.StringVar(value=format_v_mag(bus_data.v_mag)) + self._v_mag_label = pw.LabelValue(self, 'Vmag:', self._v_mag_var, 'kV') + self._v_mag_label.pack(side=tk.LEFT, padx=(10, 0)) + + self._v_angle_var = tk.StringVar(value=format_v_angle(bus_data.v_angle)) + self._v_angle_label = pw.LabelValue(self, 'Vangle:', self._v_angle_var, '°') + self._v_angle_label.pack(side=tk.LEFT, padx=(10, 0)) + + cc = self.clean_component(bus_data.connected_component) + sc = self.clean_component(bus_data.synchronous_component) + self._component_var = tk.StringVar(value=f'CC{cc} SC{sc}') + self._component_var = pw.LabelValue(self, 'Island:', self._component_var) + self._component_var.pack(side=tk.LEFT, padx=(10, 0)) + + self._id_var = tk.StringVar(value=bus_id) + self._id_label = pw.LabelValue(self, 'id:', self._id_var) + self._id_label.pack(side=tk.LEFT, padx=(10, 0)) + + @staticmethod + def clean_component(component_num): + if math.isnan(component_num): + component_num = '-' + else: + component_num = int(component_num) + return component_num + + +class Connection(tk.Frame): + def __init__(self, parent, connection: 'ns.Connection', navigate_command, *args, **kwargs): + tk.Frame.__init__(self, parent, *args, **kwargs) + + self.canvas = tk.Canvas(self, width=550, height=60, highlightthickness=0) + self.canvas.create_line(0, 0, 0, 60, fill="black", width=15) + self.canvas.create_line(0, 30, 30, 30, fill="black", width=2) + self.connection = connection + self.navigate_command = navigate_command + if connection.equipment_type == ns.EquipmentType.SWITCH: + self.canvas.create_line(30, 30, 50, 30, fill="black", width=2) + else: + if connection.get_connected(): + self.canvas.create_line(30, 30, 50, 30, fill="black", width=3) + else: + self.canvas.create_line(30, 40, 50, 30, fill="black", width=3) + + match connection.equipment_type: + case ns.EquipmentType.LOAD: + self.draw_load() + case ns.EquipmentType.GENERATOR: + self.draw_generator() + case ns.EquipmentType.SHUNT_COMPENSATOR | ns.EquipmentType.STATIC_VAR_COMPENSATOR: + self.draw_shunt() + case ns.EquipmentType.LINE | ns.EquipmentType.DANGLING_LINE: + self.canvas.create_line(50, 30, 500, 30, fill="black", width=2) + self.draw_other_side_button() + case ns.EquipmentType.SWITCH: + self.draw_switch() + self.draw_other_side_button() + case ns.EquipmentType.TWO_WINDINGS_TRANSFORMER: + self.draw_2wt() + self.draw_other_side_button() + case ns.EquipmentType.THREE_WINDINGS_TRANSFORMER: + self.draw_3wt() + other_sides = self.connection.network_structure.get_other_sides(self.connection) + other_side1 = other_sides[0] + other_side2 = other_sides[1] + btn1 = tk.Button(self.canvas, text='>>', command=lambda: navigate_command(other_side1)) + self.canvas.create_window(510, 15, width=30, height=20, anchor=tk.W, window=btn1) + btn2 = tk.Button(self.canvas, text='>>', command=lambda: navigate_command(other_side2)) + self.canvas.create_window(510, 40, width=30, height=20, anchor=tk.W, window=btn2) + case ns.EquipmentType.LCC_CONVERTER_STATION | ns.EquipmentType.VSC_CONVERTER_STATION: + self.draw_dc_converter() + self.draw_other_side_button() + case _: + self.canvas.create_line(50, 30, 500, 30, fill="black", width=2) + + self.canvas.pack(side=tk.LEFT, pady=(0, 0), ipady=0) + + self._name_var = tk.StringVar(value=connection.name) + self._name_label = pw.LabelValue(self, '', self._name_var) + self._name_label.place(x=30, y=5) + + if connection.equipment_type != ns.EquipmentType.SWITCH: + self._p = tk.StringVar(value=format_power(connection.get_p())) + self._p_label = pw.LabelValue(self, '', self._p, 'MW') + self._p_label.place(x=80, y=33) + + self._q = tk.StringVar(value=format_power(connection.get_q())) + self._q_label = pw.LabelValue(self, '', self._q, 'Mvar') + self._q_label.place(x=180, y=33) + + def draw_other_side_button(self): + other_sides = self.connection.network_structure.get_other_sides(self.connection) + if len(other_sides) == 1: + other_side = other_sides[0] + btn = tk.Button(self.canvas, text='>>', command=lambda: self.navigate_command(other_side)) + self.canvas.create_window(510, 30, width=30, height=20, anchor=tk.W, window=btn) + + def draw_dc_converter(self): + self.canvas.create_line(50, 30, 300, 30, fill="black", width=2) + self.canvas.create_rectangle(300, 15, 330, 45, width=2) + self.canvas.create_line(300, 45, 330, 15, fill="black", width=2) + # ac + self.canvas.create_arc(304, 20, 309, 25, start=0, extent=180, width=2, style=tk.ARC) + self.canvas.create_arc(309, 20, 314, 25, start=180, extent=180, width=2, style=tk.ARC) + # dc + self.canvas.create_line(318, 35, 325, 35, fill="black", width=2) + self.canvas.create_line(318, 38, 325, 38, fill="black", width=2) + self.canvas.create_line(330, 30, 500, 30, fill="black", width=2) + + def draw_3wt(self): + self.canvas.create_line(50, 30, 300, 30, fill="black", width=2) + self.canvas.create_oval(300, 20, 320, 40, width=2) + self.canvas.create_oval(310, 15, 330, 35, width=2) + self.canvas.create_oval(310, 25, 330, 45, width=2) + self.canvas.create_line(330, 23, 500, 23, fill="black", width=2) + self.canvas.create_line(330, 37, 500, 37, fill="black", width=2) + + def draw_2wt(self): + self.canvas.create_line(50, 30, 300, 30, fill="black", width=2) + self.canvas.create_oval(300, 20, 320, 40, width=2) + self.canvas.create_oval(310, 20, 330, 40, width=2) + self.canvas.create_line(330, 30, 500, 30, fill="black", width=2) + + def draw_switch(self): + self.canvas.create_line(50, 30, 305, 30, fill="black", width=2) + self.canvas.create_rectangle(305, 20, 325, 40, width=2) + if self.connection.network_structure.is_open(self.connection): + self.canvas.create_line(315, 25, 315, 35, fill="black", width=2) + else: + self.canvas.create_line(310, 30, 320, 30, fill="black", width=2) + self.canvas.create_line(325, 30, 500, 30, fill="black", width=2) + + def draw_shunt(self): + self.canvas.create_line(50, 30, 300, 30, fill="black", width=2) + if self.connection.equipment_type == ns.EquipmentType.SHUNT_COMPENSATOR: + sc_type = self.connection.network_structure.get_shunt_compensator_type(self.connection) + if sc_type == ns.ShuntCompensatorType.CAPACITOR: + self.canvas.create_line(300, 30, 310, 30, fill="black", width=2) + self.canvas.create_line(310, 20, 310, 40, fill="black", width=2) + self.canvas.create_line(320, 20, 320, 40, fill="black", width=2) + self.canvas.create_line(320, 30, 330, 30, fill="black", width=2) + else: + for i in range(3): + self.canvas.create_arc(300 + 10 * i, 20, 300 + 10 * (i + 1), 40, start=0, extent=180, + width=2, style=tk.ARC) + elif self.connection.equipment_type == ns.EquipmentType.STATIC_VAR_COMPENSATOR: + self.canvas.create_rectangle(300, 20, 330, 40, width=2) + self.canvas.create_text(315, 30, text='SVC') + self.canvas.create_line(330, 30, 350, 30, fill="black", width=2) + # ground + self.canvas.create_line(350, 20, 350, 40, fill="black", width=2) + for i in range(6): + self.canvas.create_line(350, 20 + i * 4, 350 + 6, 20 + i * 4 + 6, fill="black", width=1) + + def draw_generator(self): + self.canvas.create_line(50, 30, 300, 30, fill="black", width=2) + self.canvas.create_oval(300, 15, 330, 45, width=2) + self.canvas.create_arc(305, 25, 315, 35, start=0, extent=180, width=1, style=tk.ARC) + self.canvas.create_arc(315, 25, 325, 35, start=180, extent=180, width=1, style=tk.ARC) + + def draw_load(self): + self.canvas.create_line(50, 30, 300, 30, fill="black", width=2) + self.canvas.create_rectangle(300, 20, 330, 40, width=2) + self.canvas.create_line(300, 20, 330, 40, fill="black", width=1) + self.canvas.create_line(330, 20, 300, 40, fill="black", width=1) + + def highlight(self): + # self.canvas.configure(background='light blue') + self.canvas.configure(highlightthickness=2, highlightbackground='blue') + + +if __name__ == "__main__": + root = tk.Tk() + net = pn.create_micro_grid_be_network() + structure = ns.NetworkStructure(net) + sub = structure.get_substation('37e14a0f-5e34-4647-a062-8bfd9305fa9d') + vl = structure.get_voltage_level('469df5f7-058f-4451-a998-57a48e8a56fe') + bus = vl.get_buses(ns.BusView.BUS_BRANCH).loc['469df5f7-058f-4451-a998-57a48e8a56fe_0'] + Substation(root, sub).pack(anchor=tk.NW) + VoltageLevel(root, vl).pack(anchor=tk.NW, padx=20) + Bus(root, '469df5f7-058f-4451-a998-57a48e8a56fe_0', bus).pack(anchor=tk.NW, padx=40) + cn = vl.get_connection('78736387-5f60-4832-b3fe-d50daf81b0a6') + Connection(root, cn, lambda *args: None).pack(anchor=tk.NW, padx=60, pady=(0, 0), ipady=0) + Connection(root, cn, lambda *args: None).pack(anchor=tk.NW, padx=60, pady=(0, 0), ipady=0) + Connection(root, cn, lambda *args: None).pack(anchor=tk.NW, padx=60, pady=(0, 0), ipady=0) + root.mainloop()