From 3e2412a87d45171ac8a6636e5f12b53764a03a62 Mon Sep 17 00:00:00 2001 From: Ali Hamdan Date: Mon, 30 Oct 2023 09:54:29 +0100 Subject: [PATCH] Implement networkx graphs --- conda/environment.yml | 1 + conda/meta.yaml | 1 + doc/Changelog.md | 8 +++ doc/conf.py | 2 + doc/usage/Extras.md | 33 +++++++++++ poetry.lock | 57 ++++++------------- pyproject.toml | 2 +- roseau/load_flow/models/buses.py | 25 +++++++- .../models/loads/flexible_parameters.py | 27 +++------ roseau/load_flow/models/tests/test_buses.py | 43 ++++++++++++++ roseau/load_flow/network.py | 49 +++++++++++++++- .../tests/test_electrical_network.py | 16 ++++++ roseau/load_flow/utils/_optional_deps.py | 42 ++++++++++++++ 13 files changed, 242 insertions(+), 64 deletions(-) create mode 100644 roseau/load_flow/utils/_optional_deps.py diff --git a/conda/environment.yml b/conda/environment.yml index 640acd57..1285b8e3 100644 --- a/conda/environment.yml +++ b/conda/environment.yml @@ -14,3 +14,4 @@ dependencies: - typing-extensions >=4.6.2 - rich >=13.5.1 - matplotlib >=3.7.2 + - networkx >=3.0.0 diff --git a/conda/meta.yaml b/conda/meta.yaml index 8cc7e50b..2a501fbe 100644 --- a/conda/meta.yaml +++ b/conda/meta.yaml @@ -36,6 +36,7 @@ requirements: - typing-extensions >=4.6.2 - rich >=13.5.1 - matplotlib >=3.7.2 + - networkx >=3.0.0 test: imports: diff --git a/doc/Changelog.md b/doc/Changelog.md index 845e0ce2..3d9e3092 100644 --- a/doc/Changelog.md +++ b/doc/Changelog.md @@ -4,6 +4,14 @@ **In development** +- {gh-pr}`141` {gh-issue}`137` Add `ElectricalNetwork.to_graph()` to get a `networkx.Graph` object + representing the electrical network for graph theory studies. Install with the `"graph"` extra to + get _networkx_. + `ElectricalNetwork` also gained a new `buses_clusters` property that returns a list of sets of + IDs of buses that are connected by a line or a switch. This can be useful to isolate parts of the + network for localized analysis. For example, to study a LV subnetwork of a MV feeder. Alternatively, + to get the cluster certain bus belongs to, you can use `Bus.find_neighbors()`. +- {gh-pr}`141` Add official support for Python 3.12. This is the last release to support Python 3.9. - {gh-pr}`138` Add network constraints for analysis of the results. - Buses can define minimum and maximum voltages. Use `bus.res_violated` to see if the bus has over- or under-voltage. diff --git a/doc/conf.py b/doc/conf.py index 42280718..df28633d 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -130,6 +130,8 @@ "pint": ("https://pint.readthedocs.io/en/stable/", None), "typing_extensions": ("https://typing-extensions.readthedocs.io/en/stable/", None), "rich": ("https://rich.readthedocs.io/en/stable/", None), + "matplotlib": ("https://matplotlib.org/stable/", None), + "networkx": ("https://networkx.org/documentation/stable/", None), } # -- Options for sphinx_copybutton ------------------------------------------- diff --git a/doc/usage/Extras.md b/doc/usage/Extras.md index f185bdbe..ab4b63aa 100644 --- a/doc/usage/Extras.md +++ b/doc/usage/Extras.md @@ -2,6 +2,39 @@ `roseau-load-flow` comes with some extra features that can be useful for some users. +## Graph theory + +{meth}`ElectricalNetwork.to_graph() ` can be used to +get a {class}`networkx.Graph` object from the electrical network. + +The graph contains the geometries of the buses in the nodes data and the geometries and branch +types in the edges data. + +```{note} +This method requires *networkx* which is not installed by default in pip managed installs. You can +install it with the `"graph"` extra if you are using pip: `pip install "roseau-load-flow[graph]"`. +``` + +In addition, you can use the property +{meth}`ElectricalNetwork.buses_clusters ` to +get a list of sets of IDs of buses connected by a line or a switch. For example, with a network +with a MV feeder, this property returns a list containing a set of MV buses IDs and all sets of +LV subnetworks buses IDs. If you want to get the cluster of only one bus, you can use +{meth}`Bus.find_neighbors ` + +If we take the example network from the [Getting Started page](gs-creating-network): + +```pycon +>>> set(source_bus.find_neighbors()) +{'sb', 'lb'} +>>> set(load_bus.find_neighbors()) +{'sb', 'lb'} +>>> en.buses_clusters +[{'sb', 'lb'}] +``` + +As there are no transformers between the two buses, they all belong to the same cluster. + ## Conversion to symmetrical components {mod}`roseau.load_flow.converters` contains helpers to convert between phasor and symmetrical diff --git a/poetry.lock b/poetry.lock index df6b3644..e5c3ebbb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -100,17 +100,6 @@ setuptools = {version = "*", markers = "python_version >= \"3.12\""} [package.extras] dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] -[[package]] -name = "backcall" -version = "0.2.0" -description = "Specifications for callback functions passed in to an API" -optional = false -python-versions = "*" -files = [ - {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"}, - {file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"}, -] - [[package]] name = "beautifulsoup4" version = "4.12.2" @@ -893,25 +882,23 @@ files = [ [[package]] name = "ipython" -version = "8.16.1" +version = "8.17.0" description = "IPython: Productive Interactive Computing" optional = false python-versions = ">=3.9" files = [ - {file = "ipython-8.16.1-py3-none-any.whl", hash = "sha256:0852469d4d579d9cd613c220af7bf0c9cc251813e12be647cb9d463939db9b1e"}, - {file = "ipython-8.16.1.tar.gz", hash = "sha256:ad52f58fca8f9f848e256c629eff888efc0528c12fe0f8ec14f33205f23ef938"}, + {file = "ipython-8.17.0-py3-none-any.whl", hash = "sha256:5feb75ac603a6663de233196e1beea81ec9e5042916d4e30eb42ac09adc718d8"}, + {file = "ipython-8.17.0.tar.gz", hash = "sha256:ec8023527c477910939841d4dc2348f8f843b310a504a49db7559bd6b7579953"}, ] [package.dependencies] appnope = {version = "*", markers = "sys_platform == \"darwin\""} -backcall = "*" colorama = {version = "*", markers = "sys_platform == \"win32\""} decorator = "*" exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} jedi = ">=0.16" matplotlib-inline = "*" pexpect = {version = ">4.3", markers = "sys_platform != \"win32\""} -pickleshare = "*" prompt-toolkit = ">=3.0.30,<3.0.37 || >3.0.37,<3.1.0" pygments = ">=2.4.0" stack-data = "*" @@ -919,17 +906,17 @@ traitlets = ">=5" typing-extensions = {version = "*", markers = "python_version < \"3.10\""} [package.extras] -all = ["black", "curio", "docrepr", "exceptiongroup", "ipykernel", "ipyparallel", "ipywidgets", "matplotlib", "matplotlib (!=3.2.0)", "nbconvert", "nbformat", "notebook", "numpy (>=1.21)", "pandas", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio", "qtconsole", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "trio", "typing-extensions"] +all = ["black", "curio", "docrepr", "exceptiongroup", "ipykernel", "ipyparallel", "ipywidgets", "matplotlib", "matplotlib (!=3.2.0)", "nbconvert", "nbformat", "notebook", "numpy (>=1.22)", "pandas", "pickleshare", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio", "qtconsole", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "trio", "typing-extensions"] black = ["black"] -doc = ["docrepr", "exceptiongroup", "ipykernel", "matplotlib", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "typing-extensions"] +doc = ["docrepr", "exceptiongroup", "ipykernel", "matplotlib", "pickleshare", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "typing-extensions"] kernel = ["ipykernel"] nbconvert = ["nbconvert"] nbformat = ["nbformat"] notebook = ["ipywidgets", "notebook"] parallel = ["ipyparallel"] qtconsole = ["qtconsole"] -test = ["pytest (<7.1)", "pytest-asyncio", "testpath"] -test-extra = ["curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.21)", "pandas", "pytest (<7.1)", "pytest-asyncio", "testpath", "trio"] +test = ["pickleshare", "pytest (<7.1)", "pytest-asyncio", "testpath"] +test-extra = ["curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.22)", "pandas", "pickleshare", "pytest (<7.1)", "pytest-asyncio", "testpath", "trio"] [[package]] name = "jedi" @@ -1592,17 +1579,6 @@ files = [ [package.dependencies] ptyprocess = ">=0.5" -[[package]] -name = "pickleshare" -version = "0.7.5" -description = "Tiny 'shelve'-like database with concurrency support" -optional = false -python-versions = "*" -files = [ - {file = "pickleshare-0.7.5-py2.py3-none-any.whl", hash = "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"}, - {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"}, -] - [[package]] name = "pillow" version = "10.1.0" @@ -2362,26 +2338,27 @@ test = ["cython (>=3.0)", "filelock", "html5lib", "pytest (>=4.6)", "setuptools [[package]] name = "sphinx-autoapi" -version = "2.1.1" +version = "3.0.0" description = "Sphinx API documentation generator" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "sphinx-autoapi-2.1.1.tar.gz", hash = "sha256:fbadb96e79020d6b0ec45d888517bf816d6b587a2d340fbe1ec31135e300a6c8"}, - {file = "sphinx_autoapi-2.1.1-py2.py3-none-any.whl", hash = "sha256:d8da890477bd18e3327cafdead9d5a44a7d798476c6fa32492100e288250a5a3"}, + {file = "sphinx-autoapi-3.0.0.tar.gz", hash = "sha256:09ebd674a32b44467222b0fb8a917b97c89523f20dbf05b52cb8a3f0e15714de"}, + {file = "sphinx_autoapi-3.0.0-py2.py3-none-any.whl", hash = "sha256:ea207793cba1feff7b2ded0e29364f2995a4d157303a98603cee0ce94cea2688"}, ] [package.dependencies] anyascii = "*" -astroid = ">=2.7" +astroid = [ + {version = ">=2.7", markers = "python_version < \"3.12\""}, + {version = ">=3.0.0a1", markers = "python_version >= \"3.12\""}, +] Jinja2 = "*" PyYAML = "*" -sphinx = ">=5.2.0" +sphinx = ">=6.1.0" [package.extras] docs = ["furo", "sphinx", "sphinx-design"] -dotnet = ["sphinxcontrib-dotnetdomain"] -go = ["sphinxcontrib-golangdomain"] [[package]] name = "sphinx-basic-ng" @@ -2734,4 +2711,4 @@ plot = ["matplotlib"] [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "6af6b3c11560d3007b5d7e6e00df2226a70acaf83b5a1e2205a72e83037389a8" +content-hash = "a6c19e6cea5d7254d7e5dcde757eacdde2d917590885dad05fa22a2f0a1709e3" diff --git a/pyproject.toml b/pyproject.toml index 5638f543..a4458a3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,7 +78,7 @@ ruff = "==0.1.3" # keep in sync with .pre-commit-config.yaml sphinx = "^7.0.1" myst-parser = ">=0.16.1" sphinx-math-dollar = "^1.2.1" -sphinx-autoapi = "^2.0.0" +sphinx-autoapi = "^3.0.0" sphinx-copybutton = ">=0.5.1" sphinx-inline-tabs = ">=2022.1.2b11" furo = ">=2022.9.29" diff --git a/roseau/load_flow/models/buses.py b/roseau/load_flow/models/buses.py index fd8254b4..76825443 100644 --- a/roseau/load_flow/models/buses.py +++ b/roseau/load_flow/models/buses.py @@ -1,5 +1,5 @@ import logging -from collections.abc import Sequence +from collections.abc import Iterator, Sequence from typing import TYPE_CHECKING, Any, Optional import numpy as np @@ -253,6 +253,29 @@ def propagate_limits(self, force: bool = False) -> None: bus._min_voltage = self._min_voltage bus._max_voltage = self._max_voltage + def find_neighbors(self) -> Iterator[Id]: + """Find the buses connected to this bus via a line or switch recursively.""" + from roseau.load_flow.models.lines import Line, Switch + + visited_buses = {self.id} + yield self.id + + visited: set[Element] = set() + remaining = set(self._connected_elements) + + while remaining: + branch = remaining.pop() + visited.add(branch) + if not isinstance(branch, (Line, Switch)): + continue + for element in branch._connected_elements: + if not isinstance(element, Bus) or element.id in visited_buses: + continue + visited_buses.add(element.id) + yield element.id + to_add = set(element._connected_elements).difference(visited) + remaining.update(to_add) + # # Json Mixin interface # diff --git a/roseau/load_flow/models/loads/flexible_parameters.py b/roseau/load_flow/models/loads/flexible_parameters.py index ee35f2dd..668886bc 100644 --- a/roseau/load_flow/models/loads/flexible_parameters.py +++ b/roseau/load_flow/models/loads/flexible_parameters.py @@ -8,7 +8,7 @@ from roseau.load_flow.exceptions import RoseauLoadFlowException, RoseauLoadFlowExceptionCode from roseau.load_flow.typing import Authentication, ControlType, JsonDict, ProjectionType from roseau.load_flow.units import Q_, ureg_wraps -from roseau.load_flow.utils import JsonMixin +from roseau.load_flow.utils import JsonMixin, _optional_deps logger = logging.getLogger(__name__) @@ -16,19 +16,6 @@ from matplotlib.axes import Axes -def _import_matplotlib_pyplot(): - try: - import matplotlib.pyplot - except ImportError as e: - msg = ( - 'matplotlib is required for plotting. Install it with the "plot" extra using ' - '`pip install -U "roseau-load-flow[plot]"`' - ) - logger.error(msg) - raise RoseauLoadFlowException(msg=msg, code=RoseauLoadFlowExceptionCode.IMPORT_ERROR) from e - return matplotlib.pyplot - - class Control(JsonMixin): """Control class for flexible loads. @@ -1065,12 +1052,12 @@ def plot_pq( The axis on which the plot has been drawn and the resulting flexible powers (the input if not `None` else the computed values). """ - plt = _import_matplotlib_pyplot() # this line first for better error handling + plt = _optional_deps.pyplot # this line first for better error handling from matplotlib import colormaps, patheffects # Get the axes if ax is None: - ax: "Axes" = plt.gca() + ax = plt.gca() # Initialise some variables if voltages_labels_mask is None: @@ -1173,11 +1160,11 @@ def plot_control_p( The axis on which the plot has been drawn and the resulting flexible powers (the input if not `None` else the computed values). """ - plt = _import_matplotlib_pyplot() + plt = _optional_deps.pyplot # Get the axes if ax is None: - ax: "Axes" = plt.gca() + ax = plt.gca() # Depending on the type of the control, several options x, y, x_ticks = self._theoretical_control_data( @@ -1239,11 +1226,11 @@ def plot_control_q( The axis on which the plot has been drawn and the resulting flexible powers (the input if not `None` else the computed values). """ - plt = _import_matplotlib_pyplot() + plt = _optional_deps.pyplot # Get the axes if ax is None: - ax: "Axes" = plt.gca() + ax = plt.gca() # Depending on the type of the control, several options x, y, x_ticks = self._theoretical_control_data( diff --git a/roseau/load_flow/models/tests/test_buses.py b/roseau/load_flow/models/tests/test_buses.py index fb05e427..af53c218 100644 --- a/roseau/load_flow/models/tests/test_buses.py +++ b/roseau/load_flow/models/tests/test_buses.py @@ -281,3 +281,46 @@ def test_propagate_limits(): # noqa: C901 for bus in (b1_lv, b2_lv): assert bus.min_voltage == Q_(217, "V") assert bus.max_voltage == Q_(253, "V") + + +def test_find_neighbors(): + b1_mv = Bus("b1_mv", phases="abc") + b2_mv = Bus("b2_mv", phases="abc") + b3_mv = Bus("b3_mv", phases="abc") + b4_mv = Bus("b4_mv", phases="abc") + b1_lv = Bus("b1_lv", phases="abcn") + b2_lv = Bus("b2_lv", phases="abcn") + b3_lv = Bus("b3_lv", phases="abcn") + + PotentialRef("pref_mv", element=b1_mv) + g = Ground("g") + PotentialRef("pref_lv", element=g) + + lp_mv = LineParameters("lp_mv", z_line=np.eye(3), y_shunt=0.1 * np.eye(3)) + lp_lv = LineParameters("lp_lv", z_line=np.eye(4)) + tp = TransformerParameters.from_catalogue(id="SE_Minera_A0Ak_100kVA", manufacturer="SE") + + Line("l1_mv", b1_mv, b2_mv, length=1.5, parameters=lp_mv, ground=g) + Line("l2_mv", b2_mv, b3_mv, length=2, parameters=lp_mv, ground=g) + Line("l3_mv", b2_mv, b4_mv, length=0.5, parameters=lp_mv, ground=g) # creates a loop + Switch("sw_mv", b3_mv, b4_mv) + Transformer("tr", b3_mv, b1_lv, parameters=tp) + Line("l1_lv", b1_lv, b2_lv, length=1, parameters=lp_lv) + Switch("sw_lv", b2_lv, b3_lv) + + voltages = 20_000 * np.exp([0, -2 / 3 * np.pi * 1j, 2 / 3 * np.pi * 1j]) + VoltageSource("s_mv", bus=b1_mv, voltages=voltages) + + PowerLoad("pl1_mv", bus=b2_mv, powers=[10e3, 10e3, 10e3]) + PowerLoad("pl2_mv", bus=b3_mv, powers=[10e3, 10e3, 10e3]) + PowerLoad("pl1_lv", bus=b1_lv, powers=[1e3, 1e3, 1e3]) + PowerLoad("pl2_lv", bus=b2_lv, powers=[1e3, 1e3, 1e3]) + + mv_buses = (b1_mv, b2_mv, b3_mv, b4_mv) + mv_bus_ids = sorted(b.id for b in mv_buses) + lv_buses = (b1_lv, b2_lv, b3_lv) + lv_bus_ids = sorted(b.id for b in lv_buses) + for mvb in mv_buses: + assert sorted(mvb.find_neighbors()) == mv_bus_ids + for lvb in lv_buses: + assert sorted(lvb.find_neighbors()) == lv_bus_ids diff --git a/roseau/load_flow/network.py b/roseau/load_flow/network.py index 8af70487..4df90e5e 100644 --- a/roseau/load_flow/network.py +++ b/roseau/load_flow/network.py @@ -10,7 +10,7 @@ from importlib import resources from itertools import cycle from pathlib import Path -from typing import NoReturn, Optional, TypeVar, Union +from typing import TYPE_CHECKING, NoReturn, Optional, TypeVar, Union from urllib.parse import urljoin import geopandas as gpd @@ -38,9 +38,12 @@ ) from roseau.load_flow.solvers import check_solver_params from roseau.load_flow.typing import Authentication, Id, JsonDict, Solver, StrPath -from roseau.load_flow.utils import CatalogueMixin, JsonMixin, console, palette +from roseau.load_flow.utils import CatalogueMixin, JsonMixin, _optional_deps, console, palette from roseau.load_flow.utils.types import _DTYPES, VoltagePhaseDtype +if TYPE_CHECKING: + from networkx import Graph + logger = logging.getLogger(__name__) _T = TypeVar("_T", bound=Element) @@ -441,6 +444,48 @@ def short_circuits_frame(self) -> pd.DataFrame: columns=["bus_id", "phases", "short_circuit", "ground"], ) + # + # Helpers to analyze the network + # + @property + def buses_clusters(self) -> list[set[Id]]: + """Sets of buses connected together by a line or a switch. + + This can be useful to isolate parts of the network for localized analysis. For example, to + study a LV subnetwork of a MV feeder. + + See Also: + :meth:`Bus.find_neighbors() `: Find the + buses in the same cluster as a certain bus. + """ + visited: set[Id] = set() + result: list[set[Id]] = [] + for bus in self.buses.values(): + if bus.id in visited: + continue + bus_cluster = set(bus.find_neighbors()) + visited |= bus_cluster + result.append(bus_cluster) + return result + + def to_graph(self) -> "Graph": + """Create a networkx graph from this electrical network. + + The graph contains the geometries of the buses in the nodes data and the geometries and + branch types in the edges data. + + Note: + This method requires *networkx* to be installed. You can install it with the ``"graph"`` + extra if you are using pip: ``pip install "roseau-load-flow[graph]"``. + """ + nx = _optional_deps.networkx + graph = nx.Graph() + for bus in self.buses.values(): + graph.add_node(bus.id, geom=bus.geometry) + for branch in self.branches.values(): + graph.add_edge(branch.bus1.id, branch.bus2.id, id=branch.id, type=branch.branch_type, geom=branch.geometry) + return graph + # # Method to solve a load flow # diff --git a/roseau/load_flow/tests/test_electrical_network.py b/roseau/load_flow/tests/test_electrical_network.py index 7271fd6c..0d8ef238 100644 --- a/roseau/load_flow/tests/test_electrical_network.py +++ b/roseau/load_flow/tests/test_electrical_network.py @@ -5,6 +5,7 @@ from urllib.parse import urljoin import geopandas as gpd +import networkx as nx import numpy as np import pandas as pd import pytest @@ -2093,3 +2094,18 @@ def test_print_catalogue(): with console.capture() as capture: ElectricalNetwork.print_catalogue(load_point_name=r"^winter[0-]") assert len(capture.get().split("\n")) == 2 + + +def test_to_graph(small_network: ElectricalNetwork): + g = small_network.to_graph() + assert isinstance(g, nx.Graph) + assert sorted(g.nodes) == sorted(small_network.buses) + assert sorted(g.edges) == sorted((b.bus1.id, b.bus2.id) for b in small_network.branches.values()) + + for bus in small_network.buses.values(): + node_data = g.nodes[bus.id] + assert node_data["geom"] == bus.geometry + + for branch in small_network.branches.values(): + edge_data = g.edges[branch.bus1.id, branch.bus2.id] + assert edge_data == {"id": branch.id, "type": branch.branch_type, "geom": branch.geometry} diff --git a/roseau/load_flow/utils/_optional_deps.py b/roseau/load_flow/utils/_optional_deps.py new file mode 100644 index 00000000..99be07f5 --- /dev/null +++ b/roseau/load_flow/utils/_optional_deps.py @@ -0,0 +1,42 @@ +import logging +from typing import TYPE_CHECKING, Any + +from roseau.load_flow.exceptions import RoseauLoadFlowException, RoseauLoadFlowExceptionCode + +if TYPE_CHECKING: + import networkx as networkx + from matplotlib import pyplot as pyplot + +logger = logging.getLogger(__name__) + +__all__ = [ + "pyplot", + "networkx", +] + + +def __getattr__(name: str) -> Any: + if name == "pyplot": + try: + import matplotlib.pyplot + except ImportError as e: + msg = ( + 'matplotlib is required for plotting. Install it with the "plot" extra using ' + '`pip install -U "roseau-load-flow[plot]"`' + ) + logger.error(msg) + raise RoseauLoadFlowException(msg=msg, code=RoseauLoadFlowExceptionCode.IMPORT_ERROR) from e + return matplotlib.pyplot + elif name == "networkx": + try: + import networkx + except ImportError as e: + msg = ( + 'networkx is not installed. Install it with the "graph" extra using ' + '`pip install -U "roseau-load-flow[graph]"`' + ) + logger.error(msg) + raise RoseauLoadFlowException(msg=msg, code=RoseauLoadFlowExceptionCode.IMPORT_ERROR) from e + return networkx + else: + raise AttributeError(f"module {__name__} has no attribute {name!r}")