diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ef9bbe0..137377a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -28,30 +28,30 @@ jobs: strategy: fail-fast: false matrix: - arch: - - amd64 - - arm - os: + target: - ubuntu-24.04 + - ubuntu-24.04-arm + - windows-latest + - macos-15-intel + - macos-15 python: - "3.11" - "3.12" + - "3.13" + - "3.14" nox-session: # To speed things up a bit we use the special ci_checks_max session # that uses the same venv to run multiple linting sessions - "ci_checks_max" - "pytest_min" - runs-on: ${{ matrix.os }}${{ matrix.arch != 'amd64' && format('-{0}', matrix.arch) || '' }} + runs-on: ${{ matrix.target }} steps: - name: Run nox - uses: frequenz-floss/gh-action-nox@v1.0.1 + uses: frequenz-floss/gh-action-nox@e1351cf45e05e85afc1c79ab883e06322892d34c # v1.1.0 with: python-version: ${{ matrix.python }} nox-session: ${{ matrix.nox-session }} - # TODO(cookiecutter): Uncomment this for projects with private dependencies - # git-username: ${{ secrets.GIT_USER }} - # git-password: ${{ secrets.GIT_PASS }} # This job runs if all the `nox` matrix jobs ran and succeeded. # It is only used to have a single job that we can require in branch @@ -72,18 +72,24 @@ jobs: build: name: Build distribution packages - # Since this is a pure Python package, we only need to build it once. If it - # had any architecture specific code, we would need to build it for each - # architecture. - runs-on: ubuntu-24.04 - + strategy: + fail-fast: false + matrix: + target: + - ubuntu-24.04 + - ubuntu-24.04-arm + - windows-latest + - macos-15-intel + - macos-15 + python: + - "3.11" + - "3.12" + - "3.13" + - "3.14" + runs-on: ${{ matrix.target }} steps: - name: Setup Git uses: frequenz-floss/gh-action-setup-git@v1.0.0 - # TODO(cookiecutter): Uncomment this for projects with private dependencies - # with: - # username: ${{ secrets.GIT_USER }} - # password: ${{ secrets.GIT_PASS }} - name: Fetch sources uses: actions/checkout@v5 @@ -93,7 +99,7 @@ jobs: - name: Setup Python uses: frequenz-floss/gh-action-setup-python-with-deps@v1.0.1 with: - python-version: ${{ env.DEFAULT_PYTHON_VERSION }} + python-version: ${{ matrix.python }} dependencies: build - name: Build the source and binary distribution @@ -102,7 +108,7 @@ jobs: - name: Upload distribution files uses: actions/upload-artifact@v5 with: - name: dist-packages + name: dist-packages-${{ matrix.target }}-${{ matrix.python }} path: dist/ if-no-files-found: error @@ -112,23 +118,22 @@ jobs: strategy: fail-fast: false matrix: - arch: - - amd64 - - arm - os: + target: - ubuntu-24.04 + - ubuntu-24.04-arm + - windows-latest + - macos-15-intel + - macos-15 python: - "3.11" - "3.12" - runs-on: ${{ matrix.os }}${{ matrix.arch != 'amd64' && format('-{0}', matrix.arch) || '' }} + - "3.13" + - "3.14" + runs-on: ${{ matrix.target }} steps: - name: Setup Git uses: frequenz-floss/gh-action-setup-git@v1.0.0 - # TODO(cookiecutter): Uncomment this for projects with private dependencies - # with: - # username: ${{ secrets.GIT_USER }} - # password: ${{ secrets.GIT_PASS }} - name: Print environment (debug) run: env @@ -136,11 +141,12 @@ jobs: - name: Download package uses: actions/download-artifact@v6 with: - name: dist-packages + name: dist-packages-${{ matrix.target }}-${{ matrix.python }} path: dist # This is necessary for the `pip` caching in the setup-python action to work - name: Fetch the pyproject.toml file for this action hash + shell: bash env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} @@ -186,10 +192,6 @@ jobs: steps: - name: Setup Git uses: frequenz-floss/gh-action-setup-git@v1.0.0 - # TODO(cookiecutter): Uncomment this for projects with private dependencies - # with: - # username: ${{ secrets.GIT_USER }} - # password: ${{ secrets.GIT_PASS }} - name: Fetch sources uses: actions/checkout@v5 @@ -226,10 +228,6 @@ jobs: steps: - name: Setup Git uses: frequenz-floss/gh-action-setup-git@v1.0.0 - # TODO(cookiecutter): Uncomment this for projects with private dependencies - # with: - # username: ${{ secrets.GIT_USER }} - # password: ${{ secrets.GIT_PASS }} - name: Fetch sources uses: actions/checkout@v5 @@ -300,7 +298,6 @@ jobs: - name: Download distribution files uses: actions/download-artifact@v6 with: - name: dist-packages path: dist - name: Download RELEASE_NOTES.md @@ -346,7 +343,6 @@ jobs: - name: Download distribution files uses: actions/download-artifact@v6 with: - name: dist-packages path: dist - name: Publish the Python distribution to PyPI diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..41973f1 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,258 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "frequenz-microgrid-component-graph" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e45a0a99c4c324e7845e4128fc4078168ad14810c0bd0e4a629fa027895bf911" +dependencies = [ + "petgraph", + "tracing", +] + +[[package]] +name = "frequenz-microgrid-component-graph-python-bindings" +version = "0.1.0" +dependencies = [ + "frequenz-microgrid-component-graph", + "pyo3", +] + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37a6df7eab65fc7bee654a421404947e10a0f7085b6951bf2ea395f4659fb0cf" +dependencies = [ + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f77d387774f6f6eec64a004eac0ed525aab7fa1966d94b42f743797b3e395afb" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dd13844a4242793e02df3e2ec093f540d948299a6a77ea9ce7afd8623f542be" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaf8f9f1108270b90d3676b8679586385430e5c0bb78bb5f043f95499c821a71" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a3b2274450ba5288bc9b8c1b69ff569d1d61189d4bff38f8d22e03d17f932b" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "syn" +version = "2.0.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..98ac1e2 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "frequenz-microgrid-component-graph-python-bindings" +version = "0.1.0" +edition = "2024" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +name = "frequenz_microgrid_component_graph_python_bindings" +crate-type = ["cdylib"] + +[dependencies] +pyo3 = "0.27.1" +frequenz-microgrid-component-graph = "0.1.2" diff --git a/README.md b/README.md index 386a724..1514585 100644 --- a/README.md +++ b/README.md @@ -6,16 +6,14 @@ ## Introduction -Python bindings for the Frequenz microgrid component graph rust library. - -TODO(cookiecutter): Improve the README file +Python bindings for the [Frequenz microgrid component graph](https://github.com/frequenz-floss/frequenz-microgrid-component-graph-rs) rust library. ## Supported Platforms The following platforms are officially supported (tested): -- **Python:** 3.11 -- **Operating System:** Ubuntu Linux 20.04 +- **Python:** 3.11, 3.12, 3.13, 3.14 +- **Operating System:** Ubuntu Linux 24.04, Microsoft Windows, Apple MacOS 15 - **Architectures:** amd64, arm64 ## Contributing diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 5c3581a..59e2c2d 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -2,16 +2,4 @@ ## Summary - - -## Upgrading - - - -## New Features - - - -## Bug Fixes - - +This is the initial release of the python bindings for the component graph. diff --git a/pyproject.toml b/pyproject.toml index 7e8d616..a4e170d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,12 +2,8 @@ # Copyright © 2025 Frequenz Energy-as-a-Service GmbH [build-system] -requires = [ - "setuptools == 75.8.0", - "setuptools_scm[toml] == 9.2.2", - "frequenz-repo-config[lib] == 0.13.6", -] -build-backend = "setuptools.build_meta" +requires = ["maturin>=1.9.6,<2.0"] +build-backend = "maturin" [project] name = "frequenz-microgrid-component-graph" @@ -15,7 +11,6 @@ description = "Python bindings for the Frequenz microgrid component graph rust l readme = "README.md" license = { text = "MIT" } keywords = ["frequenz", "python", "lib", "library", "microgrid-component-graph"] -# TODO(cookiecutter): Remove and add more classifiers if appropriate classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", @@ -26,7 +21,6 @@ classifiers = [ "Typing :: Typed", ] requires-python = ">= 3.11, < 4" -# TODO(cookiecutter): Remove and add more dependencies if appropriate dependencies = [ "typing-extensions >= 4.14.1, < 5", ] @@ -36,8 +30,13 @@ dynamic = ["version"] name = "Frequenz Energy-as-a-Service GmbH" email = "floss@frequenz.com" -# TODO(cookiecutter): Remove and add more optional dependencies if appropriate [project.optional-dependencies] +microgrid = [ + "frequenz-client-microgrid >= 0.18.0, < 0.19", +] +assets = [ + "frequenz-client-assets >= 0.1.0, < 0.2", +] dev-flake8 = [ "flake8 == 7.3.0", "flake8-docstrings == 1.7.0", @@ -74,6 +73,7 @@ dev-pylint = [ "frequenz-microgrid-component-graph[dev-mkdocs,dev-noxfile,dev-pytest]", ] dev-pytest = [ + "frequenz-microgrid-component-graph[microgrid]", "pytest == 8.4.2", "pylint == 4.0.2", # We need this to check for the examples "frequenz-repo-config[extra-lint-examples] == 0.13.6", @@ -92,6 +92,11 @@ Issues = "https://github.com/frequenz-floss/frequenz-microgrid-component-graph-p Repository = "https://github.com/frequenz-floss/frequenz-microgrid-component-graph-python" Support = "https://github.com/frequenz-floss/frequenz-microgrid-component-graph-python/discussions/categories/support" +[tool.maturin] +features = ["pyo3/extension-module"] +module-name = "frequenz.microgrid_component_graph._component_graph" +python-source = "python" + [tool.black] line-length = 88 target-version = ['py311'] @@ -100,7 +105,8 @@ include = '\.pyi?$' [tool.isort] profile = "black" line_length = 88 -src_paths = ["benchmarks", "examples", "src", "tests"] +src_paths = ["benchmarks", "examples", "python", "tests"] +skip_glob = ["*.pyi"] [tool.flake8] # We give some flexibility to go over 88, there are cases like long URLs or @@ -150,7 +156,7 @@ disable = [ [tool.pytest.ini_options] addopts = "-W=all -Werror -Wdefault::DeprecationWarning -Wdefault::PendingDeprecationWarning -vv" -testpaths = ["tests", "src"] +testpaths = ["tests", "python"] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" required_plugins = ["pytest-asyncio", "pytest-mock"] @@ -166,6 +172,7 @@ namespace_packages = true #no_incremental = true packages = ["frequenz.microgrid_component_graph"] strict = true +mypy_path = "python" [[tool.mypy.overrides]] module = ["mkdocs_macros.*", "sybil", "sybil.*"] diff --git a/python/frequenz/microgrid_component_graph/__init__.py b/python/frequenz/microgrid_component_graph/__init__.py new file mode 100644 index 0000000..d09315e --- /dev/null +++ b/python/frequenz/microgrid_component_graph/__init__.py @@ -0,0 +1,18 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Python bindings for the Frequenz microgrid component graph rust library.""" + +from ._component_graph import ( + ComponentGraph, + ComponentGraphConfig, + FormulaGenerationError, + InvalidGraphError, +) + +__all__ = [ + "ComponentGraph", + "ComponentGraphConfig", + "FormulaGenerationError", + "InvalidGraphError", +] diff --git a/python/frequenz/microgrid_component_graph/__init__.pyi b/python/frequenz/microgrid_component_graph/__init__.pyi new file mode 100644 index 0000000..ac2d05c --- /dev/null +++ b/python/frequenz/microgrid_component_graph/__init__.pyi @@ -0,0 +1,332 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +# Type hints for the component graph module. + +"""A graph representation of the electrical components in a microgrid.""" + +from collections.abc import Iterable, Set +from typing import Generic, Protocol, TypeVar + +class InvalidGraphError(Exception): + """Exception type that will be thrown if graph data is not valid.""" + +class FormulaGenerationError(Exception): + """Encountered during formula generation from the component graph.""" + +class ComponentGraphConfig: + """Configuration for the component graph.""" + + def __init__( + self, + allow_component_validation_failures: bool = False, + allow_unconnected_components: bool = False, + allow_unspecified_inverters: bool = False, + disable_fallback_components: bool = False, + ) -> None: + """Initialize this instance. + + Args: + allow_component_validation_failures: Whether to allow validation errors on + components. When this is `True`, the graph will be built even if there + are validation errors on the components. + allow_unconnected_components: Whether to allow unconnected components in the + graph, that are not reachable from the root. + allow_unspecified_inverters: Whether to allow untyped inverters in the + graph. When this is `True`, inverters that have + `InverterType::Unspecified` will be assumed to be Battery inverters. + disable_fallback_components: Whether to disable fallback components in + generated formulas. When this is `True`, the formulas will not include + fallback components. + """ + +class ComponentIdProtocol(Protocol): + def __int__(self) -> int: + """Get the integer representation of the Component ID.""" + +class ComponentProtocol(Protocol): + @property + def id(self) -> ComponentIdProtocol: + """The Component ID""" + +class ConnectionProtocol(Protocol): + @property + def source(self) -> ComponentIdProtocol: + """The ID of the component at the start of the connection.""" + + @property + def destination(self) -> ComponentIdProtocol: + """The ID of the component at the end of the connection.""" + +ComponentT = TypeVar("ComponentT", bound=ComponentProtocol) +ConnectionT = TypeVar("ConnectionT", bound=ConnectionProtocol) +ComponentIdT = TypeVar("ComponentIdT", bound=ComponentIdProtocol) + +class ComponentGraph(Generic[ComponentT, ConnectionT, ComponentIdT]): + """A graph representation of the electrical components in a microgrid.""" + + def __init__( + self, + components: Iterable[ComponentT], + connections: Iterable[ConnectionT], + config: ComponentGraphConfig = ComponentGraphConfig(), + ) -> None: + """Initialize this instance. + + Args: + components: The list of components to build the graph with. + connections: The list of connections between the components. + config: The configuration for the component graph. + + Raises: + InvalidGraphError: if a valid graph cannot be constructed from the given + components and connections, based on the given configs. + """ + + def component(self, component_id: ComponentIdT) -> ComponentT: + """Fetch the component with the specified `component_id`. + + Args: + component_id: The id of the component to look for. + + Returns: + The component with the given id. + + Raises: + ValueError: if no component exists with the given ID. + """ + + def components( + self, + matching_ids: Iterable[ComponentIdT] | ComponentIdT | None = None, + matching_types: Iterable[type[ComponentT]] | type[ComponentT] | None = None, + ) -> set[ComponentT]: + """Fetch all components in this graph. + + Returns: + A set of all components in this graph. + """ + + def connections(self) -> Set[ConnectionT]: + """Fetch all connections in this graph. + + Returns: + A set of all connections in this graph. + """ + + def predecessors(self, component_id: ComponentIdT) -> Set[ComponentT]: + """Fetch all predecessors of the specified component ID. + + Args: + component_id: ID of the component whose predecessors should be fetched. + + Returns: + A set of components that are predecessors of the given component ID. + + Raises: + ValueError: if no component exists with the given ID. + """ + + def successors(self, component_id: ComponentIdT) -> Set[ComponentT]: + """Fetch all successors of the specified component ID. + + Args: + component_id: ID of the component whose successors should be fetched. + + Returns: + A set of components that are successors of the given component ID. + + Raises: + ValueError: if no component exists with the given ID. + """ + + def is_pv_meter(self, component_id: ComponentIdT) -> bool: + """Check if the specified component is a PV meter. + + A meter is identified as a PV meter if: + - it has at least one successor, + - all its successors are PV inverters. + + Args: + component_id: ID of the component to check. + + Returns: + Whether the specified component is a PV meter. + + Raises: + ValueError: if no component exists with the given ID. + """ + + def is_battery_meter(self, component_id: ComponentIdT) -> bool: + """Check if the specified component is a battery meter. + + A meter is identified as a battery meter if: + - it has at least one successor, + - all its successors are battery inverters. + + Args: + component_id: ID of the component to check. + + Returns: + Whether the specified component is a battery meter. + + Raises: + ValueError: if no component exists with the given ID. + """ + + def is_ev_charger_meter(self, component_id: ComponentIdT) -> bool: + """Check if the specified component is an EV charger meter. + + A meter is identified as an EV charger meter if + - it has at least one successor, + - all its successors are EV chargers. + + Args: + component_id: ID of the component to check. + + Returns: + Whether the specified component is an EV charger meter. + + Raises: + ValueError: if no component exists with the given ID. + """ + + def is_chp_meter(self, component_id: ComponentIdT) -> bool: + """Check if the specified component is a CHP meter. + + A meter is identified as a CHP meter if + - it has at least one successor, + - all its successors are CHPs. + + Args: + component_id: ID of the component to check. + + Returns: + Whether the specified component is a CHP meter. + + Raises: + ValueError: if no component exists with the given ID. + """ + + def consumer_formula(self) -> str: + """Generate the consumer formula for this component graph. + + Returns: + The consumer formula as a string. + """ + + def producer_formula(self) -> str: + """Generate the producer formula for this component graph. + + Returns: + The producer formula as a string. + """ + + def grid_formula(self) -> str: + """Generate the grid formula for this component graph. + + Returns: + The grid formula as a string. + """ + + def pv_formula(self, pv_inverter_ids: Set[ComponentIdT] | None) -> str: + """Generate the PV formula for this component graph. + + Args: + pv_inverter_ids: The set of PV inverter component IDs to include in + the formula. If `None`, all PV inverters in the graph will be + included. + + Returns: + The PV formula as a string. + + Raises: + FormulaGenerationError: if the given component IDs don't exist or + are not PV inverters. + """ + + def battery_formula(self, battery_ids: Set[ComponentIdT] | None) -> str: + """Generate the battery formula for this component graph. + + Args: + battery_ids: The set of battery component IDs to include in the + formula. If `None`, all batteries in the graph will be + included. + + Returns: + The battery formula as a string. + + Raises: + FormulaGenerationError: if the given component IDs don't exist or + are not batteries. + """ + + def chp_formula(self, chp_ids: Set[ComponentIdT] | None) -> str: + """Generate the CHP formula for this component graph. + + Args: + chp_ids: The set of CHP component IDs to include in the formula. If + `None`, all CHPs in the graph will be included. + + Returns: + The CHP formula as a string. + + Raises: + FormulaGenerationError: if the given component IDs don't exist or + are not CHPs. + """ + + def ev_charger_formula(self, ev_charger_ids: Set[ComponentIdT] | None) -> str: + """Generate the EV charger formula for this component graph. + + Args: + ev_charger_ids: The set of EV charger component IDs to include in + the formula. If `None`, all EV chargers in the graph will be + included. + + Returns: + The EV charger formula as a string. + + Raises: + FormulaGenerationError: if the given component IDs don't exist or + are not EV chargers. + """ + + def grid_coalesce_formula(self) -> str: + """Generate the grid coalesce formula for this component graph. + + Returns: + The grid coalesced formula as a string. + """ + + def battery_coalesce_formula(self, battery_ids: Set[ComponentIdT] | None) -> str: + """Generate the battery coalesce formula for this component graph. + + Args: + battery_ids: The set of battery inverter component IDs to include in + the formula. If `None`, all battery inverters in the graph will + be included. + + Returns: + The battery coalesced formula as a string. + + Raises: + FormulaGenerationError: if the given component IDs don't exist or + are not batteries. + """ + + def pv_coalesce_formula(self, pv_inverter_ids: Set[ComponentIdT] | None) -> str: + """Generate the PV coalesce formula for this component graph. + + Args: + pv_inverter_ids: The set of PV inverter component IDs to include in + the formula. If `None`, all PV inverters in the graph will be + included. + + Returns: + The PV coalesced formula as a string. + + Raises: + FormulaGenerationError: if the given component IDs don't exist or + are not PV inverters. + """ diff --git a/src/frequenz/microgrid_component_graph/conftest.py b/python/frequenz/microgrid_component_graph/conftest.py similarity index 100% rename from src/frequenz/microgrid_component_graph/conftest.py rename to python/frequenz/microgrid_component_graph/conftest.py diff --git a/src/frequenz/microgrid_component_graph/py.typed b/python/frequenz/microgrid_component_graph/py.typed similarity index 100% rename from src/frequenz/microgrid_component_graph/py.typed rename to python/frequenz/microgrid_component_graph/py.typed diff --git a/src/category.rs b/src/category.rs new file mode 100644 index 0000000..68827f3 --- /dev/null +++ b/src/category.rs @@ -0,0 +1,122 @@ +// License: MIT +// Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +use frequenz_microgrid_component_graph as cg; +use pyo3::{exceptions, prelude::*}; + +struct ComponentClasses<'py> { + grid_connection_point: Bound<'py, PyAny>, + meter: Bound<'py, PyAny>, + battery: Bound<'py, PyAny>, + ev_charger: Bound<'py, PyAny>, + chp: Bound<'py, PyAny>, + battery_inverter: Bound<'py, PyAny>, + solar_inverter: Bound<'py, PyAny>, + hybrid_inverter: Bound<'py, PyAny>, + unspecified_component: Bound<'py, PyAny>, +} + +impl<'py> ComponentClasses<'py> { + fn try_new(py: Python<'py>) -> PyResult { + let candidates = vec![ + "frequenz.client.microgrid.component".to_string(), + "frequenz.client.assets.electrical_component".to_string(), + ]; + + let mut last_err: Option = None; + for path in &candidates { + match py.import(path) { + Ok(module) => { + return Ok(Self { + grid_connection_point: module.getattr("GridConnectionPoint")?, + meter: module.getattr("Meter")?, + battery: module.getattr("Battery")?, + ev_charger: module.getattr("EvCharger")?, + chp: module.getattr("Chp")?, + battery_inverter: module.getattr("BatteryInverter")?, + solar_inverter: module.getattr("SolarInverter")?, + hybrid_inverter: module.getattr("HybridInverter")?, + unspecified_component: module.getattr("UnspecifiedComponent")?, + }); + } + Err(e) => last_err = Some(e), + } + } + Err(pyo3::exceptions::PyImportError::new_err(format!( + "Could not import a component provider. Tried: {candidates:?}. \ + Install one: pip install frequenz-component-graph[microgrid] or [assets]. \ + Last error: {last_err:?}" + ))) + } +} + +pub(crate) fn category_from_python_component( + py: Python<'_>, + object: &Bound<'_, PyAny>, +) -> PyResult { + let comp_classes = ComponentClasses::try_new(py)?; + + if object.is_instance(&comp_classes.grid_connection_point)? + || object.is(&comp_classes.grid_connection_point) + { + Ok(cg::ComponentCategory::GridConnectionPoint) + } else if object.is_instance(&comp_classes.meter)? || object.is(&comp_classes.meter) { + Ok(cg::ComponentCategory::Meter) + } else if object.is_instance(&comp_classes.battery)? || object.is(&comp_classes.battery) { + Ok(cg::ComponentCategory::Battery(cg::BatteryType::Unspecified)) + } else if object.is_instance(&comp_classes.ev_charger)? || object.is(&comp_classes.ev_charger) { + Ok(cg::ComponentCategory::EvCharger( + cg::EvChargerType::Unspecified, + )) + } else if object.is_instance(&comp_classes.chp)? || object.is(&comp_classes.chp) { + Ok(cg::ComponentCategory::Chp) + } else if object.is_instance(&comp_classes.battery_inverter)? + || object.is(&comp_classes.battery_inverter) + { + Ok(cg::ComponentCategory::Inverter(cg::InverterType::Battery)) + } else if object.is_instance(&comp_classes.solar_inverter)? + || object.is(&comp_classes.solar_inverter) + { + Ok(cg::ComponentCategory::Inverter(cg::InverterType::Pv)) + } else if object.is_instance(&comp_classes.hybrid_inverter)? + || object.is(&comp_classes.hybrid_inverter) + { + Ok(cg::ComponentCategory::Inverter(cg::InverterType::Hybrid)) + } else if object.is_instance(&comp_classes.unspecified_component)? + || object.is(&comp_classes.unspecified_component) + { + Ok(cg::ComponentCategory::Unspecified) + } else { + Err(exceptions::PyValueError::new_err(format!( + "Unsupported component category: {:?}", + object + ))) + } +} + +pub(crate) fn match_category( + category_1: cg::ComponentCategory, + category_2: cg::ComponentCategory, +) -> bool { + match (category_1, category_2) { + (cg::ComponentCategory::Inverter(type_1), cg::ComponentCategory::Inverter(type_2)) => { + match (type_1, type_2) { + (cg::InverterType::Unspecified, _) | (_, cg::InverterType::Unspecified) => true, + _ => type_1 == type_2, + } + } + (cg::ComponentCategory::Battery(type_1), cg::ComponentCategory::Battery(type_2)) => { + match (type_1, type_2) { + (cg::BatteryType::Unspecified, _) | (_, cg::BatteryType::Unspecified) => true, + _ => type_1 == type_2, + } + } + (cg::ComponentCategory::EvCharger(type_1), cg::ComponentCategory::EvCharger(type_2)) => { + match (type_1, type_2) { + (cg::EvChargerType::Unspecified, _) | (_, cg::EvChargerType::Unspecified) => true, + _ => type_1 == type_2, + } + } + _ => category_1 == category_2, + } +} diff --git a/src/component.rs b/src/component.rs new file mode 100644 index 0000000..957f1dd --- /dev/null +++ b/src/component.rs @@ -0,0 +1,38 @@ +// License: MIT +// Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +use frequenz_microgrid_component_graph as cg; + +use pyo3::{prelude::*, types::PyAny}; + +use crate::{category::category_from_python_component, utils::extract_int}; + +/// A wrapper for the Python object representing a component. +pub(crate) struct Component { + pub(crate) component_id: u64, + pub(crate) category: cg::ComponentCategory, + pub(crate) object: Py, +} + +impl cg::Node for Component { + fn component_id(&self) -> u64 { + self.component_id + } + + fn category(&self) -> cg::ComponentCategory { + self.category + } +} + +impl Component { + pub(crate) fn try_new(py: Python<'_>, object: Bound<'_, PyAny>) -> PyResult { + let component_id = extract_int(py, object.getattr("id")?)?; + let category = category_from_python_component(py, &object)?; + + Ok(Component { + component_id, + category, + object: object.into(), + }) + } +} diff --git a/src/connection.rs b/src/connection.rs new file mode 100644 index 0000000..a38ef9c --- /dev/null +++ b/src/connection.rs @@ -0,0 +1,38 @@ +// License: MIT +// Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +use frequenz_microgrid_component_graph as cg; + +use pyo3::{prelude::*, types::PyAny}; + +use crate::utils::extract_int; + +/// A wrapper for the Python object representing a connection. +pub(crate) struct Connection { + pub(crate) start: u64, + pub(crate) end: u64, + pub(crate) object: Py, +} + +impl Connection { + pub(crate) fn try_new(py: Python<'_>, object: Bound<'_, PyAny>) -> PyResult { + let start = extract_int(py, object.getattr("source")?)?; + let end = extract_int(py, object.getattr("destination")?)?; + + Ok(Connection { + start, + end, + object: object.into(), + }) + } +} + +impl cg::Edge for Connection { + fn source(&self) -> u64 { + self.start + } + + fn destination(&self) -> u64 { + self.end + } +} diff --git a/src/frequenz/microgrid_component_graph/__init__.py b/src/frequenz/microgrid_component_graph/__init__.py deleted file mode 100644 index 46bb8e6..0000000 --- a/src/frequenz/microgrid_component_graph/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -# License: MIT -# Copyright © 2025 Frequenz Energy-as-a-Service GmbH - -"""Python bindings for the Frequenz microgrid component graph rust library.. - -TODO(cookiecutter): Add a more descriptive module description. -""" - - -# TODO(cookiecutter): Remove this function -def delete_me(*, blow_up: bool = False) -> bool: - """Do stuff for demonstration purposes. - - Args: - blow_up: If True, raise an exception. - - Returns: - True if no exception was raised. - - Raises: - RuntimeError: if blow_up is True. - """ - if blow_up: - raise RuntimeError("This function should be removed!") - return True diff --git a/src/graph.rs b/src/graph.rs new file mode 100644 index 0000000..2c074cf --- /dev/null +++ b/src/graph.rs @@ -0,0 +1,323 @@ +// License: MIT +// Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +use std::collections::BTreeSet; + +use crate::{ + FormulaGenerationError, InvalidGraphError, + category::{category_from_python_component, match_category}, + component::Component, + connection::Connection, + utils::extract_int, +}; +use frequenz_microgrid_component_graph::{self as cg}; +use pyo3::{ + prelude::*, + types::{PyAny, PySet, PyType}, +}; + +#[pyclass] +#[derive(Clone, Default, Debug)] +pub struct ComponentGraphConfig { + config: cg::ComponentGraphConfig, +} + +#[pymethods] +impl ComponentGraphConfig { + #[new] + #[pyo3(signature = ( + *, + allow_component_validation_failures = false, + allow_unconnected_components = false, + allow_unspecified_inverters = false, + disable_fallback_components = false + ))] + fn new( + allow_component_validation_failures: bool, + allow_unconnected_components: bool, + allow_unspecified_inverters: bool, + disable_fallback_components: bool, + ) -> Self { + ComponentGraphConfig { + config: cg::ComponentGraphConfig { + allow_component_validation_failures, + allow_unconnected_components, + allow_unspecified_inverters, + disable_fallback_components, + }, + } + } +} + +#[pyclass] +pub struct ComponentGraph { + graph: cg::ComponentGraph, +} + +#[pymethods] +impl ComponentGraph { + #[new] + #[pyo3( + signature = (components, connections, config=None), + text_signature = "(components, connections, config=ComponentGraphConfig())" + )] + fn new( + py: Python<'_>, + components: Bound<'_, PyAny>, + connections: Bound<'_, PyAny>, + config: Option>, + ) -> PyResult { + let mut wrapped_components = Vec::new(); + let mut wrapped_connections = Vec::new(); + for component in components.try_iter()? { + wrapped_components.push(Component::try_new(py, component?)?); + } + + for connection in connections.try_iter()? { + wrapped_connections.push(Connection::try_new(py, connection?)?); + } + + Ok(ComponentGraph { + graph: cg::ComponentGraph::try_new( + wrapped_components, + wrapped_connections, + match config { + Some(config) => config.extract::()?.config, + None => Default::default(), + }, + ) + .map_err(|e| PyErr::new::(e.to_string()))?, + }) + } + + fn component(&self, component_id: Bound<'_, PyAny>) -> PyResult> { + Python::attach(|py| { + Ok(self + .graph + .component(extract_int::(py, component_id)?) + .map_err(|e| PyErr::new::(e.to_string()))? + .object + .clone_ref(py)) + }) + } + + #[classmethod] + fn __class_getitem__(cls: Bound<'_, PyType>, _generics: Bound<'_, PyAny>) -> Py { + cls.into() + } + + #[pyo3(signature = (matching_ids=None, matching_types=None))] + fn components( + &self, + matching_ids: Option>, + matching_types: Option>, + ) -> PyResult> { + let iter = self.graph.components(); + + Python::attach(|py| { + let components: Vec<_> = if let Some(component_categories) = matching_types { + let categories: Vec = + if let Ok(cat_iter) = component_categories.try_iter() { + cat_iter + .map(|c| category_from_python_component(py, &c?)) + .collect::>()? + } else { + vec![category_from_python_component(py, &component_categories)?] + }; + + iter.filter(|c| categories.iter().any(|x| match_category(*x, c.category))) + .collect() + } else { + iter.collect() + }; + + let components: Vec<_> = if let Some(ids) = matching_ids { + let ids_set: BTreeSet = if let Ok(id_iter) = ids.try_iter() { + id_iter + .map(|id| extract_int::(py, id?)) + .collect::>()? + } else { + BTreeSet::from([extract_int::(py, ids)?]) + }; + + components + .into_iter() + .filter(|c| ids_set.contains(&c.component_id)) + .collect() + } else { + components + }; + + PySet::new(py, components.iter().map(|c| c.object.bind(py))).map(|s| s.into()) + }) + } + + fn connections(&self) -> PyResult> { + Python::attach(|py| { + PySet::new(py, self.graph.connections().map(|c| c.object.bind(py))).map(|s| s.into()) + }) + } + + fn predecessors(&self, component_id: Bound<'_, PyAny>) -> PyResult> { + Python::attach(|py| { + PySet::new( + py, + self.graph + .predecessors(extract_int::(py, component_id)?) + .map_err(|e| PyErr::new::(e.to_string()))? + .map(|c| c.object.bind(py)), + ) + .map(|s| s.into()) + }) + } + + fn successors(&self, component_id: Bound<'_, PyAny>) -> PyResult> { + Python::attach(|py| { + PySet::new( + py, + self.graph + .successors(extract_int::(py, component_id)?) + .map_err(|e| PyErr::new::(e.to_string()))? + .map(|c| c.object.bind(py)), + ) + .map(|s| s.into()) + }) + } + + fn is_pv_meter(&self, py: Python<'_>, component_id: Bound<'_, PyAny>) -> PyResult { + self.graph + .is_pv_meter(extract_int::(py, component_id)?) + .map_err(|e| PyErr::new::(e.to_string())) + } + + fn is_battery_meter(&self, py: Python<'_>, component_id: Bound<'_, PyAny>) -> PyResult { + self.graph + .is_battery_meter(extract_int::(py, component_id)?) + .map_err(|e| PyErr::new::(e.to_string())) + } + + fn is_ev_charger_meter( + &self, + py: Python<'_>, + component_id: Bound<'_, PyAny>, + ) -> PyResult { + self.graph + .is_ev_charger_meter(extract_int::(py, component_id)?) + .map_err(|e| PyErr::new::(e.to_string())) + } + + fn is_chp_meter(&self, py: Python<'_>, component_id: Bound<'_, PyAny>) -> PyResult { + self.graph + .is_chp_meter(extract_int::(py, component_id)?) + .map_err(|e| PyErr::new::(e.to_string())) + } + + // Formula generators + fn consumer_formula(&self) -> PyResult { + self.graph + .consumer_formula() + .map(|f| f.to_string()) + .map_err(|e| PyErr::new::(e.to_string())) + } + + fn producer_formula(&self) -> PyResult { + self.graph + .producer_formula() + .map(|f| f.to_string()) + .map_err(|e| PyErr::new::(e.to_string())) + } + + fn grid_formula(&self) -> PyResult { + self.graph + .grid_formula() + .map(|f| f.to_string()) + .map_err(|e| PyErr::new::(e.to_string())) + } + + #[pyo3(signature = (battery_ids=None))] + fn battery_formula( + &self, + py: Python<'_>, + battery_ids: Option>, + ) -> PyResult { + self.graph + .battery_formula(extract_ids(py, battery_ids)?) + .map(|f| f.to_string()) + .map_err(|e| PyErr::new::(e.to_string())) + } + + #[pyo3(signature = (chp_ids=None))] + fn chp_formula(&self, py: Python<'_>, chp_ids: Option>) -> PyResult { + self.graph + .chp_formula(extract_ids(py, chp_ids)?) + .map(|f| f.to_string()) + .map_err(|e| PyErr::new::(e.to_string())) + } + + #[pyo3(signature = (pv_inverter_ids=None))] + fn pv_formula( + &self, + py: Python<'_>, + pv_inverter_ids: Option>, + ) -> PyResult { + self.graph + .pv_formula(extract_ids(py, pv_inverter_ids)?) + .map(|f| f.to_string()) + .map_err(|e| PyErr::new::(e.to_string())) + } + + #[pyo3(signature = (ev_charger_ids=None))] + fn ev_charger_formula( + &self, + py: Python<'_>, + ev_charger_ids: Option>, + ) -> PyResult { + self.graph + .ev_charger_formula(extract_ids(py, ev_charger_ids)?) + .map(|f| f.to_string()) + .map_err(|e| PyErr::new::(e.to_string())) + } + + fn grid_coalesce_formula(&self) -> PyResult { + self.graph + .grid_coalesce_formula() + .map(|f| f.to_string()) + .map_err(|e| PyErr::new::(e.to_string())) + } + + #[pyo3(signature = (battery_ids=None))] + fn battery_ac_coalesce_formula( + &self, + py: Python<'_>, + battery_ids: Option>, + ) -> PyResult { + self.graph + .battery_ac_coalesce_formula(extract_ids(py, battery_ids)?) + .map(|f| f.to_string()) + .map_err(|e| PyErr::new::(e.to_string())) + } + + #[pyo3(signature = (pv_inverter_ids=None))] + fn pv_ac_coalesce_formula( + &self, + py: Python<'_>, + pv_inverter_ids: Option>, + ) -> PyResult { + self.graph + .pv_ac_coalesce_formula(extract_ids(py, pv_inverter_ids)?) + .map(|f| f.to_string()) + .map_err(|e| PyErr::new::(e.to_string())) + } +} + +fn extract_ids(py: Python<'_>, ids: Option>) -> PyResult>> { + if let Some(ids) = ids { + Ok(Some( + ids.try_iter()? + .map(|id| extract_int::(py, id?)) + .collect::>()?, + )) + } else { + Ok(None) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..116a3f7 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,37 @@ +// License: MIT +// Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +//! This module defines the Python bindings for the `component_graph` library. + +mod category; +mod component; +mod connection; +mod graph; +mod utils; + +use pyo3::prelude::*; + +pyo3::create_exception!( + _component_graph, + InvalidGraphError, + pyo3::exceptions::PyException +); +pyo3::create_exception!( + _component_graph, + FormulaGenerationError, + pyo3::exceptions::PyException +); + +/// A Python module implemented in Rust. +#[pymodule] +mod _component_graph { + #[pymodule_export] + use super::FormulaGenerationError; + #[pymodule_export] + use super::InvalidGraphError; + + #[pymodule_export] + use crate::graph::ComponentGraph; + #[pymodule_export] + use crate::graph::ComponentGraphConfig; +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..a41dfe5 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,11 @@ +// License: MIT +// Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +use pyo3::{Bound, FromPyObject, PyAny, Python, intern, types::PyAnyMethods}; + +pub(crate) fn extract_int FromPyObject<'a, 'a, Error = pyo3::PyErr>>( + py: Python<'_>, + object: Bound<'_, PyAny>, +) -> pyo3::PyResult { + object.call_method0(intern!(py, "__int__"))?.extract::() +} diff --git a/tests/test_microgrid_component_graph.py b/tests/test_microgrid_component_graph.py index a89f81a..4815bbf 100644 --- a/tests/test_microgrid_component_graph.py +++ b/tests/test_microgrid_component_graph.py @@ -2,17 +2,78 @@ # Copyright © 2025 Frequenz Energy-as-a-Service GmbH """Tests for the frequenz.microgrid_component_graph package.""" -import pytest -from frequenz.microgrid_component_graph import delete_me +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.components import ComponentId +from frequenz.client.microgrid.component import ( + Component, + ComponentConnection, + GridConnectionPoint, + Meter, + SolarInverter, +) +from frequenz import microgrid_component_graph -def test_microgrid_component_graph_succeeds() -> None: # TODO(cookiecutter): Remove - """Test that the delete_me function succeeds.""" - assert delete_me() is True - -def test_microgrid_component_graph_fails() -> None: # TODO(cookiecutter): Remove - """Test that the delete_me function fails.""" - with pytest.raises(RuntimeError, match="This function should be removed!"): - delete_me(blow_up=True) +def test_graph_creation() -> None: + """Test that the microgrid_component_graph module loads correctly.""" + graph: microgrid_component_graph.ComponentGraph[ + Component, ComponentConnection, ComponentId + ] = microgrid_component_graph.ComponentGraph( + components={ + GridConnectionPoint( + id=ComponentId(1), + microgrid_id=MicrogridId(1), + rated_fuse_current=100, + ), + Meter(id=ComponentId(2), microgrid_id=MicrogridId(1)), + Meter(id=ComponentId(3), microgrid_id=MicrogridId(1)), + SolarInverter(id=ComponentId(4), microgrid_id=MicrogridId(1)), + }, + connections={ + ComponentConnection(source=ComponentId(1), destination=ComponentId(2)), + ComponentConnection(source=ComponentId(1), destination=ComponentId(3)), + ComponentConnection(source=ComponentId(2), destination=ComponentId(4)), + }, + ) + assert graph.components() == { + GridConnectionPoint( + id=ComponentId(1), microgrid_id=MicrogridId(1), rated_fuse_current=100 + ), + Meter(id=ComponentId(2), microgrid_id=MicrogridId(1)), + Meter(id=ComponentId(3), microgrid_id=MicrogridId(1)), + SolarInverter(id=ComponentId(4), microgrid_id=MicrogridId(1)), + } + assert graph.connections() == { + ComponentConnection(source=ComponentId(1), destination=ComponentId(2)), + ComponentConnection(source=ComponentId(1), destination=ComponentId(3)), + ComponentConnection(source=ComponentId(2), destination=ComponentId(4)), + } + assert graph.components(matching_ids=[ComponentId(2), ComponentId(3)]) == { + Meter(id=ComponentId(2), microgrid_id=MicrogridId(1)), + Meter(id=ComponentId(3), microgrid_id=MicrogridId(1)), + } + assert graph.components(matching_ids=ComponentId(1)) == { + GridConnectionPoint( + id=ComponentId(1), microgrid_id=MicrogridId(1), rated_fuse_current=100 + ) + } + assert graph.components(matching_types=Meter) == { + Meter(id=ComponentId(2), microgrid_id=MicrogridId(1)), + Meter(id=ComponentId(3), microgrid_id=MicrogridId(1)), + } + assert graph.components(matching_types=[Meter, GridConnectionPoint]) == { + Meter(id=ComponentId(2), microgrid_id=MicrogridId(1)), + Meter(id=ComponentId(3), microgrid_id=MicrogridId(1)), + GridConnectionPoint( + id=ComponentId(1), microgrid_id=MicrogridId(1), rated_fuse_current=100 + ), + } + assert graph.components( + matching_types=[Meter, SolarInverter], + matching_ids=[ComponentId(1), ComponentId(3), ComponentId(4)], + ) == { + Meter(id=ComponentId(3), microgrid_id=MicrogridId(1)), + SolarInverter(id=ComponentId(4), microgrid_id=MicrogridId(1)), + }