diff --git a/.cookiecutterrc b/.cookiecutterrc deleted file mode 100644 index ab8a1f8..0000000 --- a/.cookiecutterrc +++ /dev/null @@ -1,69 +0,0 @@ -# This file exists so you can easily regenerate your project. -# -# `cookiepatcher` is a convenient shim around `cookiecutter` -# for regenerating projects (it will generate a .cookiecutterrc -# automatically for any template). To use it: -# -# pip install cookiepatcher -# cookiepatcher gh:ionelmc/cookiecutter-pylibrary project-path -# -# See: -# https://pypi.org/project/cookiepatcher -# -# Alternatively, you can run: -# -# cookiecutter --overwrite-if-exists --config-file=project-path/.cookiecutterrc gh:ionelmc/cookiecutter-pylibrary - -default_context: - - _extensions: ['jinja2_time.TimeExtension'] - _template: 'gh:ionelmc/cookiecutter-pylibrary' - allow_tests_inside_package: 'no' - appveyor: 'no' - c_extension_function: 'longest' - c_extension_module: '_oemof.network' - c_extension_optional: 'no' - c_extension_support: 'no' - c_extension_test_pypi: 'no' - c_extension_test_pypi_username: 'oemof' - codacy: 'yes' - codacy_projectid: 'CODACY_PROJECT_ID' - codeclimate: 'yes' - codecov: 'no' - command_line_interface: 'no' - command_line_interface_bin_name: 'oemof.network' - coveralls: 'yes' - coveralls_token: 'COVERALLSTOKEN' - distribution_name: 'oemof.network' - email: 'gnn.code@gmail.com' - full_name: 'Stephan Günther' - landscape: 'no' - license: 'MIT license' - linter: 'flake8' - package_name: 'oemof.network' - project_name: 'oemof.network' - project_short_description: 'The network/graph submodules of oemof.' - pypi_badge: 'yes' - pypi_disable_upload: 'no' - release_date: '2020-04-01' - repo_hosting: 'github.com' - repo_hosting_domain: 'github.com' - repo_name: 'oemof.network' - repo_username: 'oemof' - requiresio: 'yes' - scrutinizer: 'yes' - setup_py_uses_setuptools_scm: 'no' - setup_py_uses_test_runner: 'no' - sphinx_docs: 'yes' - sphinx_docs_hosting: 'https://oemof-network.readthedocs.io/' - sphinx_doctest: 'no' - sphinx_theme: 'sphinx-rtd-theme' - test_matrix_configurator: 'yes' - test_matrix_separate_coverage: 'yes' - test_runner: 'nose' - travis: 'yes' - travis_osx: 'no' - version: '0.4.0.dev0' - website: 'https://oemof.org/' - year_from: '2020' - year_to: '2020' diff --git a/.github/workflows/tox_pytests.yml b/.github/workflows/tox_pytests.yml index a86512f..59b1f56 100644 --- a/.github/workflows/tox_pytests.yml +++ b/.github/workflows/tox_pytests.yml @@ -6,9 +6,7 @@ on: - master - dev pull_request: - branches: - - master - - dev + workflow_dispatch: schedule: - cron: "0 5 * * 6" # 5:00 UTC every Saturday @@ -18,14 +16,14 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7, 3.8, 3.9] + python-version: [3.8, 3.9, "3.10"] steps: - uses: actions/checkout@v1 - name: Install xmllint run: sudo apt install coinor-cbc - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/.gitignore b/.gitignore index dfe5838..7544e48 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ htmlcov .idea *.iml *.komodoproject +.vscode # Complexity output/*.html diff --git a/.pep8speaks.yml b/.pep8speaks.yml new file mode 100644 index 0000000..5e4815b --- /dev/null +++ b/.pep8speaks.yml @@ -0,0 +1,11 @@ +# File : .pep8speaks.yml + +scanner: + diff_only: True + linter: pycodestyle # Other option is flake8 + +pycodestyle: # Same as scanner.linter value. + max-line-length: 79 # Default in PEP 8 + ignore: # Errors and warnings to ignore + - W503 # line break before binary operator + - E203 diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e6a2fa0..22e4307 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,3 +15,10 @@ Changelog * Improved testing * Explicitly defined API + +0.5.0 +----- + +* Improved code quality +* Add Entity.custom_properties +* Simplify node access (experimental: energy_system.node[label]) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 344d6cd..e407f83 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -49,7 +49,7 @@ To set up `oemof.network` for local development: Now you can make your changes locally. -4. When you're done making changes run all the checks and docs builder with `tox `_ one command:: +4. When you're done making changes run all the checks and docs builder with `tox `_ one command:: tox diff --git a/MANIFEST.in b/MANIFEST.in index 2953616..5ef1435 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -10,6 +10,7 @@ include .editorconfig include AUTHORS.rst include CHANGELOG.rst +include CITATION.cff include CONTRIBUTING.rst include LICENSE include README.rst @@ -18,6 +19,7 @@ recursive-include docs *.rst recursive-include tests *.py -include tox.ini .travis.yml .appveyor.yml .readthedocs.yml +include tox.ini +include *.yml global-exclude *.py[cod] __pycache__/* *.so *.dylib *.swp diff --git a/README.rst b/README.rst index be42121..340a933 100644 --- a/README.rst +++ b/README.rst @@ -29,16 +29,12 @@ Overview :target: https://readthedocs.org/projects/oemof-network :alt: Documentation Status -.. |travis| image:: https://api.travis-ci.org/oemof/oemof-network.svg?branch=master - :alt: Travis-CI Build Status - :target: https://travis-ci.org/oemof/oemof-network - .. |coveralls| image:: https://coveralls.io/repos/oemof/oemof-network/badge.svg?branch=dev&service=github :alt: Coverage Status :target: https://coveralls.io/r/oemof/oemof-network?branch=dev .. |codacy| image:: https://api.codacy.com/project/badge/Grade/39b648d0de3340da912c3dc48688a7b5 - :target: https://www.codacy.com/gh/oemof/oemof-network?utm_source=github.com&utm_medium=referral&utm_content=oemof/oemof.network&utm_campaign=Badge_Grade + :target: https://app.codacy.com/gh/oemof/oemof-network :alt: Codacy Code Quality Status .. |codeclimate| image:: https://codeclimate.com/github/oemof/oemof-network/badges/gpa.svg diff --git a/ci/templates/tox.ini b/ci/templates/tox.ini index 9cec832..d7f6100 100644 --- a/ci/templates/tox.ini +++ b/ci/templates/tox.ini @@ -40,7 +40,7 @@ deps = isort skip_install = true commands = - python setup.py check --strict --metadata --restructuredtext + python setup.py check --strict-markers --metadata --restructuredtext check-manifest {toxinidir} flake8 src tests setup.py isort --verbose --check-only --diff --recursive src tests setup.py diff --git a/docs/conf.py b/docs/conf.py index 00c01dd..a2334de 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -17,16 +17,16 @@ source_suffix = ".rst" master_doc = "index" project = "oemof.network" -year = "2020" -author = "Stephan Günther" +year = "2023" +author = "oemof developer group" copyright = "{0}, {1}".format(year, author) -version = release = "0.4.0" +version = release = "0.5.0" pygments_style = "trac" templates_path = ["."] extlinks = { - "issue": ("https://github.com/oemof/oemof.network/issues/%s", "#"), - "pr": ("https://github.com/oemof/oemof.network/pull/%s", "PR #"), + "issue": ("https://github.com/oemof/oemof-network/issues/%s", "#%s"), + "pr": ("https://github.com/oemof/oemof-network/pull/%s", "PR #%s"), } # on_rtd is whether we are on readthedocs.org on_rtd = os.environ.get("READTHEDOCS", None) == "True" diff --git a/setup.cfg b/setup.cfg index af8d80d..efed8b3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -46,7 +46,7 @@ python_files = addopts = -ra - --strict + --strict-markers --ignore=docs/conf.py --ignore=setup.py --ignore=ci diff --git a/setup.py b/setup.py index b79840e..6bcce02 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ def read(*names, **kwargs): setup( name="oemof.network", - version="0.4.0", + version="0.5.0", license="MIT", description="The network/graph submodules of oemof.", long_description=long_description, @@ -77,7 +77,7 @@ def read(*names, **kwargs): # eg: 'keyword1', 'keyword2', 'keyword3', ], python_requires=">=3.7", - install_requires=["pandas", "blinker", "dill", "networkx"], + install_requires=["pandas", "blinker", "dill", "networkx", "oemof.tools"], extras_require={ "dev": ["pytest"], # eg: diff --git a/src/oemof/network/__init__.py b/src/oemof/network/__init__.py index c0526bc..739b024 100644 --- a/src/oemof/network/__init__.py +++ b/src/oemof/network/__init__.py @@ -1,24 +1,28 @@ -__version__ = "0.4.0" +__version__ = "0.5.0" from . import energy_system from . import graph from . import groupings from . import network -from .network import ( - Bus, - Component, - Sink, - Source, - Transformer, -) +from .network import Bus +from .network import Component +from .network import Edge +from .network import Entity +from .network import Node +from .network import Sink +from .network import Source +from .network import Transformer __all__ = [ "Bus", "Component", + "Edge", + "Entity", "energy_system", "graph", "groupings", "network", + "Node", "Sink", "Source", "Transformer", diff --git a/src/oemof/network/energy_system.py b/src/oemof/network/energy_system.py index 1f159ca..3b91818 100644 --- a/src/oemof/network/energy_system.py +++ b/src/oemof/network/energy_system.py @@ -10,20 +10,23 @@ SPDX-FileCopyrightText: Uwe Krien SPDX-FileCopyrightText: Simon Hilpert <> SPDX-FileCopyrightText: Cord Kaldemeyer <> +SPDX-FileCopyrightText: Patrik Schönfeldt SPDX-License-Identifier: MIT """ import logging import os +import warnings from collections import deque import blinker import dill as pickle +from oemof.tools import debugging from oemof.network.groupings import DEFAULT as BY_UID +from oemof.network.groupings import Entities from oemof.network.groupings import Grouping -from oemof.network.groupings import Nodes class EnergySystem: @@ -60,12 +63,7 @@ class EnergySystem: ---------- entities : list of :class:`Entity ` A list containing the :class:`Entities ` - that comprise the energy system. If this :class:`EnergySystem` is - set as the :attr:`registry ` - attribute, which is done automatically on :class:`EnergySystem` - construction, newly created :class:`Entities - ` are automatically added to this list on - construction. + that comprise the energy system. groups : dict results : dictionary A dictionary holding the results produced by the energy system. @@ -89,9 +87,9 @@ class EnergySystem: ` will always be grouped by their :attr:`uid `: - >>> from oemof.network.network import Bus, Sink + >>> from oemof.network.network import Node >>> es = EnergySystem() - >>> bus = Bus(label='electricity') + >>> bus = Node(label='electricity') >>> es.add(bus) >>> bus is es.groups['electricity'] True @@ -103,22 +101,24 @@ class EnergySystem: >>> bus is es.groups['electricity'] False >>> es.groups['electricity'] - "" + "" For simple user defined groupings, you can just supply a function that computes a key from an :class:`entity ` and the resulting groups will be sets of :class:`entities - ` stored under the returned keys, like in this - example, where :class:`entities ` are grouped by + ` stored under the returned keys, like in this + example, where :class:`entities ` are grouped by their `type`: >>> es = EnergySystem(groupings=[type]) - >>> buses = set(Bus(label="Bus {}".format(i)) for i in range(9)) + >>> buses = set(Node(label="Node {}".format(i)) for i in range(9)) >>> es.add(*buses) + >>> class Sink(Node): + ... pass >>> components = set(Sink(label="Component {}".format(i)) ... for i in range(9)) >>> es.add(*components) - >>> buses == es.groups[Bus] + >>> buses == es.groups[Node] True >>> components == es.groups[Sink] True @@ -129,36 +129,54 @@ class EnergySystem: """A dictionary of blinker_ signals emitted by energy systems. Currently only one signal is supported. This signal is emitted whenever a - `Node ` is `add`ed to an energy system. The signal's - `sender` is set to the `node ` that got added to the - energy system so that `nodes ` have an easy way to only - receive signals for when they themselves get added to an energy system. + `node ` is `add`ed to an energy system. The + signal's `sender` is set to the `node ` that got + added to the energy system so that `node ` have an + easy way to only receive signals for when they themselves get added to an + energy system. - .. _blinker: https://pythonhosted.org/blinker/ + .. _blinker: https://blinker.readthedocs.io/en/stable/ """ - def __init__(self, **kwargs): + def __init__( + self, + *, + groupings=None, + results=None, + timeindex=None, + timeincrement=None, + temporal=None, + nodes=None, + entities=None, + ): + if groupings is None: + groupings = [] + if entities is not None: + warnings.warn( + "Parameter 'entities' is deprecated, use 'nodes'" + + " instead. Will overwrite nodes.", + FutureWarning, + ) + nodes = entities + if nodes is None: + nodes = [] + self._first_ungrouped_node_index_ = 0 self._groups = {} self._groupings = [BY_UID] + [ - g if isinstance(g, Grouping) else Nodes(g) - for g in kwargs.get("groupings", []) + g if isinstance(g, Grouping) else Entities(g) for g in groupings ] - self.entities = [] - - self.results = kwargs.get("results") - - self.timeindex = kwargs.get("timeindex") - - self.timeincrement = kwargs.get("timeincrement", None) - - self.temporal = kwargs.get("temporal") + self._nodes = {} - self.add(*kwargs.get("entities", ())) + self.results = results + self.timeindex = timeindex + self.timeincrement = timeincrement + self.temporal = temporal + self.add(*nodes) def add(self, *nodes): """Add :class:`nodes ` to this energy system.""" - self.nodes.extend(nodes) + self._nodes.update({node.label: node for node in nodes}) for n in nodes: self.signals[type(self).add].send(n, EnergySystem=self) @@ -171,7 +189,7 @@ def groups(self): ( g(n, gs) for g in self._groupings - for n in self.nodes[self._first_ungrouped_node_index_ :] + for n in list(self.nodes)[self._first_ungrouped_node_index_ :] ), maxlen=0, ) @@ -179,12 +197,17 @@ def groups(self): return self._groups @property - def nodes(self): - return self.entities + def node(self): + msg = ( + "The API to access nodes by label is experimental" + " and might change without prior notice." + ) + warnings.warn(msg, debugging.ExperimentalFeatureWarning) + return self._nodes - @nodes.setter - def nodes(self, value): - self.entities = value + @property + def nodes(self): + return self._nodes.values() def flows(self): return { diff --git a/src/oemof/network/graph.py b/src/oemof/network/graph.py index fca7c46..2b9295c 100644 --- a/src/oemof/network/graph.py +++ b/src/oemof/network/graph.py @@ -12,6 +12,8 @@ SPDX-License-Identifier: MIT """ +import warnings + import networkx as nx @@ -47,22 +49,21 @@ def create_nx_graph( -------- >>> import os >>> import pandas as pd - >>> from oemof.network.network import Bus, Sink, Transformer + >>> from oemof.network.network import Node >>> from oemof.network.energy_system import EnergySystem >>> import oemof.network.graph as grph >>> datetimeindex = pd.date_range('1/1/2017', periods=3, freq='H') >>> es = EnergySystem(timeindex=datetimeindex) - >>> b_gas = Bus(label='b_gas', balanced=False) - >>> bel1 = Bus(label='bel1') - >>> bel2 = Bus(label='bel2') - >>> demand_el = Sink(label='demand_el', inputs = [bel1]) - >>> pp_gas = Transformer(label=('pp', 'gas'), - ... inputs=[b_gas], - ... outputs=[bel1], - ... conversion_factors={bel1: 0.5}) - >>> line_to2 = Transformer(label='line_to2', inputs=[bel1], outputs=[bel2]) - >>> line_from2 = Transformer(label='line_from2', - ... inputs=[bel2], outputs=[bel1]) + >>> b_gas = Node(label='b_gas') + >>> bel1 = Node(label='bel1') + >>> bel2 = Node(label='bel2') + >>> demand_el = Node(label='demand_el', inputs = [bel1]) + >>> pp_gas = Node(label=('pp', 'gas'), + ... inputs=[b_gas], + ... outputs=[bel1]) + >>> line_to2 = Node(label='line_to2', inputs=[bel1], outputs=[bel2]) + >>> line_from2 = Node(label='line_from2', + ... inputs=[bel2], outputs=[bel1]) >>> es.add(b_gas, bel1, demand_el, pp_gas, bel2, line_to2, line_from2) >>> my_graph = grph.create_nx_graph(es) >>> # export graph as .graphml for programs like Yed where it can be @@ -94,44 +95,52 @@ def create_nx_graph( Needs graphviz and networkx (>= v.1.11) to work properly. Tested on Ubuntu 16.04 x64 and solydxk (debian 9). """ - # construct graph from nodes and flows - grph = nx.DiGraph() - - # add nodes - for n in energy_system.nodes: - grph.add_node(str(n.label), label=str(n.label)) - - # add labeled flows on directed edge if an optimization_model has been - # passed or undirected edge otherwise - for n in energy_system.nodes: - for i in n.inputs.keys(): - weight = getattr( - energy_system.flows()[(i, n)], "nominal_value", None - ) - if weight is None: - grph.add_edge(str(i.label), str(n.label)) - else: - grph.add_edge( - str(i.label), str(n.label), weigth=format(weight, ".2f") + with warnings.catch_warnings(): + # suppress ExperimentalFeatureWarnungs + warnings.simplefilter("ignore") + + # construct graph from nodes and flows + grph = nx.DiGraph() + + # add nodes + for label in energy_system.node.keys(): + grph.add_node(str(label), label=str(label)) + + # add labeled flows on directed edge if an optimization_model has been + # passed or undirected edge otherwise + for n in energy_system.nodes: + for i in n.inputs.keys(): + weight = getattr( + energy_system.flows()[(i, n)], "nominal_value", None ) - - # remove nodes and edges based on precise labels - if remove_nodes is not None: - grph.remove_nodes_from(remove_nodes) - if remove_edges is not None: - grph.remove_edges_from(remove_edges) - - # remove nodes based on substrings - if remove_nodes_with_substrings is not None: - for i in remove_nodes_with_substrings: - remove_nodes = [ - str(v.label) for v in energy_system.nodes if i in str(v.label) - ] + if weight is None: + grph.add_edge(str(i.label), str(n.label)) + else: + grph.add_edge( + str(i.label), + str(n.label), + weigth=format(weight, ".2f"), + ) + + # remove nodes and edges based on precise labels + if remove_nodes is not None: grph.remove_nodes_from(remove_nodes) - - if filename is not None: - if filename[-8:] != ".graphml": - filename = filename + ".graphml" - nx.write_graphml(grph, filename) - - return grph + if remove_edges is not None: + grph.remove_edges_from(remove_edges) + + # remove nodes based on substrings + if remove_nodes_with_substrings is not None: + for i in remove_nodes_with_substrings: + remove_nodes = [ + str(label) + for label in energy_system.node.keys() + if i in str(label) + ] + grph.remove_nodes_from(remove_nodes) + + if filename is not None: + if filename[-8:] != ".graphml": + filename = filename + ".graphml" + nx.write_graphml(grph, filename) + + return grph diff --git a/src/oemof/network/groupings.py b/src/oemof/network/groupings.py index cac095c..014d465 100644 --- a/src/oemof/network/groupings.py +++ b/src/oemof/network/groupings.py @@ -8,6 +8,7 @@ SPDX-FileCopyrightText: Stephan Günther <> SPDX-FileCopyrightText: Uwe Krien +SPDX-FileCopyrightText: Patrik Schönfeldt SPDX-License-Identifier: MIT """ @@ -15,11 +16,11 @@ from collections.abc import Hashable from collections.abc import Iterable from collections.abc import Mapping -from collections.abc import MutableMapping as MuMa +from collections.abc import MutableMapping from itertools import chain from itertools import filterfalse -from oemof.network.network import Edge +from oemof.network.network.edge import Edge # TODO: Update docstrings. # @@ -30,18 +31,18 @@ class Grouping: """ - Used to aggregate :class:`entities ` in an - :class:`energy system ` into - :attr:`groups `. + Used to aggregate :class:`entities ` in an + :class:`energy system ` into + :attr:`groups `. The way :class:`Groupings ` work is that each :class:`Grouping` :obj:`g` of an energy system is called whenever an :class:`entity - ` is added to the energy system (and for each - :class:`entity ` already present, if the energy + ` is added to the energy system (and for each + :class:`entity ` already present, if the energy system is created with existing enties). The call :obj:`g(e, groups)`, where :obj:`e` is an :class:`entity - ` and :attr:`groups - ` is a dictionary mapping + ` and :attr:`groups + ` is a dictionary mapping group keys to groups, then uses the three functions :meth:`key `, :meth:`value ` and :meth:`merge ` in the following way: @@ -60,7 +61,7 @@ class Grouping: `. Instead of trying to use this class directly, have a look at its - subclasses, like :class:`Nodes`, which should cater for most use cases. + subclasses, like :class:`Entities`, which should cater for most use cases. Notes ----- @@ -80,8 +81,8 @@ class Grouping: key: callable or hashable Specifies (if not callable) or extracts (if callable) a :meth:`key - ` for each :class:`entity ` of - the :class:`energy system `. + ` for each :class:`entity ` of + the :class:`energy system `. constant_key: hashable (optional) @@ -121,7 +122,7 @@ def __init__(self, key=None, constant_key=None, filter=None, **kwargs): + "one of `key` or `constant_key`." ) self.filter = filter - for kw in ["value", "merge", "filter"]: + for kw in ["value", "merge"]: if kw in kwargs: setattr(self, kw, kwargs[kw]) @@ -131,9 +132,10 @@ def key(self, node): You have to supply this method yourself using the :obj:`key` parameter when creating :class:`Grouping` instances. - Called for every :class:`node ` of the energy - system. Expected to return the key (i.e. a valid :class:`hashable`) - under which the group :meth:`value(node) ` will be + Called for every :class:`node ` of the + energy system. Expected to return the key (i.e. a valid + :class:`hashable`) under which the group + :meth:`value(node) ` will be stored. If it should be added to more than one group, return a :class:`list` (or any other non-:class:`hashable `, :class:`iterable`) containing the group keys. @@ -159,7 +161,7 @@ def value(self, e): `. Otherwise :meth:`merge(value(e), groups[key(e)]) ` is called. - The default returns the :class:`entity ` + The default returns the :class:`entity ` itself. """ return e @@ -214,7 +216,7 @@ def __call__(self, e, d): if k is None: return v = self.value(e) - if isinstance(v, MuMa): + if isinstance(v, MutableMapping): for k in list(filterfalse(self.filter, v)): v.pop(k) elif isinstance(v, Mapping): @@ -233,16 +235,17 @@ def __call__(self, e, d): d[group] = self.merge(v, d[group]) if group in d else v -class Nodes(Grouping): +class Entities(Grouping): """ - Specialises :class:`Grouping` to group :class:`nodes ` + Specialises :class:`Grouping` to group + :class:`entities ` into :class:`sets `. """ def value(self, e): """ Returns a :class:`set` containing only :obj:`e`, so groups are - :class:`sets ` of :class:`node `. + :class:`sets ` of :class:`entity `. """ return {e} @@ -254,10 +257,10 @@ def merge(self, new, old): return old.union(new) -class Flows(Nodes): +class Flows(Entities): """ Specialises :class:`Grouping` to group the flows connected to :class:`nodes - ` into :class:`sets `. + ` into :class:`sets `. Note that this specifically means that the :meth:`key `, and :meth:`value ` functions act on a set of flows. """ @@ -278,7 +281,7 @@ def __call__(self, n, d): super().__call__(flows, d) -class FlowsWithNodes(Nodes): +class FlowsWithNodes(Entities): """ Specialises :class:`Grouping` to act on the flows connected to :class:`nodes ` and create :class:`sets ` of @@ -309,7 +312,9 @@ def __call__(self, n, d): def _uid_or_str(node_or_entity): - """Helper function to support the transition from `Entitie`s to `Node`s.""" + """ + Helper function to support the transition from `Entitie`s to `Entity`s. + """ return ( node_or_entity.uid if hasattr(node_or_entity, "uid") @@ -321,10 +326,10 @@ def _uid_or_str(node_or_entity): """ The default :class:`Grouping`. This one is always present in an :class:`energy system -`. It stores every :class:`entity -` under its :attr:`uid -` and raises an error if another :class:`entity -` with the same :attr:`uid -` get's added to the :class:`energy system -`. +`. It stores every :class:`entity +` under its :attr:`uid +` and raises an error if another :class:`entity +` with the same :attr:`uid +` get's added to the :class:`energy system +`. """ diff --git a/src/oemof/network/network.py b/src/oemof/network/network.py deleted file mode 100644 index eb2daf4..0000000 --- a/src/oemof/network/network.py +++ /dev/null @@ -1,435 +0,0 @@ -# -*- coding: utf-8 -*- - -"""This package (along with its subpackages) contains the classes used to model -energy systems. An energy system is modelled as a graph/network of entities -with very specific constraints on which types of entities are allowed to be -connected. - -This file is part of project oemof (github.com/oemof/oemof). It's copyrighted -by the contributors recorded in the version control history of the file, -available from its original location oemof/oemof/network.py - -SPDX-FileCopyrightText: Stephan Günther <> -SPDX-FileCopyrightText: Uwe Krien -SPDX-FileCopyrightText: Simon Hilpert <> -SPDX-FileCopyrightText: Cord Kaldemeyer <> -SPDX-FileCopyrightText: Patrik Schönfeldt - -SPDX-License-Identifier: MIT -""" - -import warnings -from collections import UserDict as UD -from collections import namedtuple as NT -from collections.abc import Mapping -from collections.abc import MutableMapping as MM -from contextlib import contextmanager -from functools import total_ordering - -# TODO: -# -# * Only allow setting a Node's label if `_delay_registration_` is active -# and/or the node is not yet registered. -# * Only allow setting an Edge's input/output if it is None -# * Document the `register` method. Maybe also document the -# `_delay_registration_` attribute and make it official. This could also be -# a good chance to finally use `blinker` to put an event on -# `_delay_registration_` for deletion/assignment to trigger registration. -# I always had the hunch that using blinker could help to straighten out -# that delayed auto registration hack via partial functions. Maybe this -# could be a good starting point for this. -# * Finally get rid of `Entity`. -# - - -class Inputs(MM): - """A special helper to map `n1.inputs[n2]` to `n2.outputs[n1]`.""" - - def __init__(self, target): - self.target = target - - def __getitem__(self, key): - return key.outputs.__getitem__(self.target) - - def __delitem__(self, key): - return key.outputs.__delitem__(self.target) - - def __setitem__(self, key, value): - return key.outputs.__setitem__(self.target, value) - - def __iter__(self): - return iter(self.target._in_edges) - - def __len__(self): - return self.target._in_edges.__len__() - - def __repr__(self): - return repr( - "<{0.__module__}.{0.__name__}: {1!r}>".format( - type(self), dict(self) - ) - ) - - -class Outputs(UD): - """ - Helper that intercepts modifications to update `Inputs` symmetrically. - """ - - def __init__(self, source): - self.source = source - super().__init__() - - def __delitem__(self, key): - key._in_edges.remove(self.source) - return super().__delitem__(key) - - def __setitem__(self, key, value): - key._in_edges.add(self.source) - return super().__setitem__(key, value) - - -class Metaclass(type): - """The metaclass for objects in an oemof energy system.""" - - @property - def registry(cls): - warnings.warn(cls.registry_warning) - return cls._registry - - @registry.setter - def registry(cls, registry): - warnings.warn(cls.registry_warning) - cls._registry = registry - - -@total_ordering -class Node(metaclass=Metaclass): - """Represents a Node in an energy system graph. - - Abstract superclass of the two general types of nodes of an energy system - graph, collecting attributes and operations common to all types of nodes. - Users should neither instantiate nor subclass this, but use - :class:`Component`, :class:`Bus`, :class:`Edge` or one of their subclasses - instead. - - .. role:: python(code) - :language: python - - Parameters - ---------- - label: `hashable`, optional - Used as the string representation of this node. If this parameter is - not an instance of :class:`str` it will be converted to a string and - the result will be used as this node's :attr:`label`, which should be - unique with respect to the other nodes in the energy system graph this - node belongs to. If this parameter is not supplied, the string - representation of this node will instead be generated based on this - nodes `class` and `id`. - inputs: list or dict, optional - Either a list of this nodes' input nodes or a dictionary mapping input - nodes to corresponding inflows (i.e. input values). - outputs: list or dict, optional - Either a list of this nodes' output nodes or a dictionary mapping - output nodes to corresponding outflows (i.e. output values). - - Attributes - ---------- - __slots__: str or iterable of str - See the Python documentation on `__slots__ - `_ for more - information. - """ - - registry_warning = FutureWarning( - "\nAutomatic registration of `Node`s is deprecated in favour of\n" - "explicitly adding `Node`s to an `EnergySystem` via " - "`EnergySystem.add`.\n" - "This feature, i.e. the `Node.registry` attribute and functionality\n" - "pertaining to it, will be removed in future versions.\n" - ) - - _registry = None - __slots__ = ["_label", "_in_edges", "_inputs", "_outputs"] - - def __init__(self, *args, **kwargs): - args = list(args) - args.reverse - self._inputs = Inputs(self) - self._outputs = Outputs(self) - for optional in ["label"]: - if optional in kwargs: - if args: - raise ( - TypeError( - ( - "{}.__init__()\n" - " got multiple values for argument '{}'" - ).format(type(self), optional) - ) - ) - setattr(self, "_" + optional, kwargs[optional]) - else: - if args: - setattr(self, "_" + optional, args.pop()) - self._in_edges = set() - for i in kwargs.get("inputs", {}): - assert isinstance(i, Node), ( - "\n\nInput\n\n {!r}\n\nof\n\n {!r}\n\n" - "not an instance of Node, but of {}." - ).format(i, self, type(i)) - self._in_edges.add(i) - try: - flow = kwargs["inputs"].get(i) - except AttributeError: - flow = None - edge = globals()["Edge"].from_object(flow) - edge.input = i - edge.output = self - for o in kwargs.get("outputs", {}): - assert isinstance(o, Node), ( - "\n\nOutput\n\n {!r}\n\nof\n\n {!r}\n\n" - "not an instance of Node, but of {}." - ).format(o, self, type(o)) - try: - flow = kwargs["outputs"].get(o) - except AttributeError: - flow = None - edge = globals()["Edge"].from_object(flow) - edge.input = self - edge.output = o - - self.register() - """ - This could be slightly more efficient than the loops above, but doesn't - play well with the assertions: - - inputs = kwargs.get('inputs', {}) - self.in_edges = { - Edge(input=i, output=self, - flow=None if not isinstance(inputs, MM) else inputs[i]) - for i in inputs} - - outputs = kwargs.get('outputs', {}) - self.out_edges = { - Edge(input=self, output=o, - flow=None if not isinstance(outputs, MM) else outputs[o]) - for o in outputs} - self.edges = self.in_edges.union(self.out_edges) - """ - - def register(self): - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - registry = __class__.registry - - if registry is not None and not getattr( - self, "_delay_registration_", False - ): - __class__.registry.add(self) - - def __eq__(self, other): - return id(self) == id(other) - - def __lt__(self, other): - return str(self) < str(other) - - def __hash__(self): - return hash(self.label) - - def __str__(self): - return str(self.label) - - def __repr__(self): - return repr( - "<{0.__module__}.{0.__name__}: {1!r}>".format( - type(self), self.label - ) - ) - - @property - def label(self): - """ - If this node was given a `label` on construction, this - attribute holds the actual object passed as a parameter. Otherwise - :py:`node.label` is a synonym for :py:`str(node)`. - """ - return ( - self._label - if hasattr(self, "_label") - else "<{} #0x{:x}>".format(type(self).__name__, id(self)) - ) - - @label.setter - def label(self, label): - self._label = label - - @property - def inputs(self): - """dict: - Dictionary mapping input :class:`Nodes ` :obj:`n` to - :class:`Edge`s from :obj:`n` into :obj:`self`. - If :obj:`self` is an :class:`Edge`, returns a dict containing the - :class:`Edge`'s single input node as the key and the flow as the value. - """ - return self._inputs - - @property - def outputs(self): - """dict: - Dictionary mapping output :class:`Nodes ` :obj:`n` to - :class:`Edges` from :obj:`self` into :obj:`n`. - If :obj:`self` is an :class:`Edge`, returns a dict containing the - :class:`Edge`'s single output node as the key and the flow as the - value. - """ - return self._outputs - - -EdgeLabel = NT("EdgeLabel", ["input", "output"]) - - -class Edge(Node): - """ - :class:`Bus`es/:class:`Component`s are always connected by an - :class:`Edge`. - - :class:`Edge`s connect a single non-:class:`Edge` Node with another. They - are directed and have a (sequence of) value(s) attached to them so they can - be used to represent a flow from a source/an input to a target/an output. - - Parameters - ---------- - input, output: :class:`Bus` or :class:`Component`, optional - flow, values: object, optional - The (list of) object(s) representing the values flowing from this - edge's input into its output. Note that these two names are aliases of - each other, so `flow` and `values` are mutually exclusive. - - Note that all of these parameters are also set as attributes with the same - name. - """ - - Label = EdgeLabel - - def __init__( - self, input=None, output=None, flow=None, values=None, **kwargs - ): - if flow is not None and values is not None: - raise ValueError( - "\n\n`Edge`'s `flow` and `values` keyword arguments are " - "aliases of each other,\nso they're mutually exclusive.\n" - "You supplied:\n" - + " `flow` : {}\n".format(flow) - + " `values`: {}\n".format(values) - + "Choose one." - ) - if input is None or output is None: - self._delay_registration_ = True - super().__init__(label=Edge.Label(input, output)) - self.values = values if values is not None else flow - if input is not None and output is not None: - input.outputs[output] = self - - @classmethod - def from_object(cls, o): - """Creates an `Edge` instance from a single object. - - This method inspects its argument and does something different - depending on various cases: - - * If `o` is an instance of `Edge`, `o` is returned unchanged. - * If `o` is a `Mapping`, the instance is created by calling - `cls(**o)`, - * In all other cases, `o` will be used as the `values` keyword - argument to `Edge`s constructor. - """ - if isinstance(o, Edge): - return o - elif isinstance(o, Mapping): - return cls(**o) - else: - return Edge(values=o) - - @property - def flow(self): - return self.values - - @flow.setter - def flow(self, values): - self.values = values - - @property - def input(self): - return self.label.input - - @input.setter - def input(self, i): - old_input = self.input - self.label = Edge.Label(i, self.label.output) - if old_input is None and i is not None and self.output is not None: - del self._delay_registration_ - self.register() - i.outputs[self.output] = self - - @property - def output(self): - return self.label.output - - @output.setter - def output(self, o): - old_output = self.output - self.label = Edge.Label(self.label.input, o) - if old_output is None and o is not None and self.input is not None: - del self._delay_registration_ - self.register() - o.inputs[self.input] = self - - -class Bus(Node): - pass - - -class Component(Node): - pass - - -class Sink(Component): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - -class Source(Component): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - -class Transformer(Component): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - -@contextmanager -def registry_changed_to(r): - """ - Override registry during execution of a block and restore it afterwards. - """ - backup = Node.registry - Node.registry = r - yield - Node.registry = backup - - -def temporarily_modifies_registry(f): - """Decorator that disables `Node` registration during `f`'s execution. - - It does so by setting `Node.registry` to `None` while `f` is executing, so - `f` can freely set `Node.registry` to something else. The registration's - original value is restored afterwards. - """ - - def result(*xs, **ks): - with registry_changed_to(None): - return f(*xs, **ks) - - return result diff --git a/src/oemof/network/network/__init__.py b/src/oemof/network/network/__init__.py new file mode 100644 index 0000000..9d5fc72 --- /dev/null +++ b/src/oemof/network/network/__init__.py @@ -0,0 +1,19 @@ +from .edge import Edge +from .entity import Entity +from .nodes import Bus +from .nodes import Component +from .nodes import Node +from .nodes import Sink +from .nodes import Source +from .nodes import Transformer + +__all__ = [ + "Bus", + "Component", + "Edge", + "Entity", + "Node", + "Sink", + "Source", + "Transformer", +] diff --git a/src/oemof/network/network/edge.py b/src/oemof/network/network/edge.py new file mode 100644 index 0000000..37ece05 --- /dev/null +++ b/src/oemof/network/network/edge.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- + +"""This package contains the class Edge used to model +energy systems. + +SPDX-FileCopyrightText: Stephan Günther <> +SPDX-FileCopyrightText: Uwe Krien +SPDX-FileCopyrightText: Simon Hilpert <> +SPDX-FileCopyrightText: Cord Kaldemeyer <> +SPDX-FileCopyrightText: Patrik Schönfeldt + +SPDX-License-Identifier: MIT +""" + +from collections import namedtuple +from collections.abc import Mapping + +from .entity import Entity + + +class Edge(Entity): + """ + :class:`Bus`es/:class:`Component`s are always connected by an + :class:`Edge`. + + :class:`Edge`s connect a single :class:`Node` with another. They are + directed and have a (sequence of) value(s) attached to them, so they can + be used to represent a flow from a source/an input to a target/an output. + + Parameters + ---------- + input, output: :class:`Bus` or :class:`Component`, optional + flow, values: object, optional + The (list of) object(s) representing the values flowing from this + edge's input into its output. Note that these two names are aliases of + each other, so `flow` and `values` are mutually exclusive. + + Note that all of these parameters are also set as attributes with the same + name. + """ + + Label = namedtuple("EdgeLabel", ["input", "output"]) + + def __init__( + self, + input_node=None, + output_node=None, + flow=None, + values=None, + *, + custom_properties=None, + ): + if flow is not None and values is not None: + raise ValueError( + "\n\n`Edge`'s `flow` and `values` keyword arguments are " + "aliases of each other,\nso they're mutually exclusive.\n" + "You supplied:\n" + f" `flow` : {flow}\n" + f" `values`: {values}\n" + "Choose one." + ) + super().__init__( + label=Edge.Label(input_node, output_node), + custom_properties=custom_properties, + ) + self.values = values if values is not None else flow + if input_node is not None and output_node is not None: + input_node.outputs[output_node] = self + + @classmethod + def from_object(cls, o): + """Creates an `Edge` instance from a single object. + + This method inspects its argument and does something different + depending on various cases: + + * If `o` is an instance of `Edge`, `o` is returned unchanged. + * If `o` is a `Mapping`, the instance is created by calling + `cls(**o)`, + * In all other cases, `o` will be used as the `values` keyword + argument to `Edge`'s constructor. + """ + if isinstance(o, Edge): + return o + elif isinstance(o, Mapping): + return cls(**o) + else: + return Edge(values=o) + + @property + def flow(self): + return self.values + + @flow.setter + def flow(self, values): + self.values = values + + @property + def input(self): + return self.label.input + + @input.setter + def input(self, i): + old_input = self.input + self._label = Edge.Label(i, self.label.output) + if old_input is None and i is not None and self.output is not None: + i.outputs[self.output] = self + + @property + def output(self): + return self.label.output + + @output.setter + def output(self, o): + old_output = self.output + self._label = Edge.Label(self.label.input, o) + if old_output is None and o is not None and self.input is not None: + o.inputs[self.input] = self diff --git a/src/oemof/network/network/entity.py b/src/oemof/network/network/entity.py new file mode 100644 index 0000000..778300c --- /dev/null +++ b/src/oemof/network/network/entity.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- + +"""This package contains the abstract entity classes used to model +energy systems. + +SPDX-FileCopyrightText: Stephan Günther <> +SPDX-FileCopyrightText: Uwe Krien +SPDX-FileCopyrightText: Simon Hilpert <> +SPDX-FileCopyrightText: Cord Kaldemeyer <> +SPDX-FileCopyrightText: Patrik Schönfeldt + +SPDX-License-Identifier: MIT +""" + +from functools import total_ordering + + +@total_ordering +class Entity: + """Represents an Entity in an energy system graph. + + Abstract superclass of the general types of entities of an energy system + graph, collecting attributes and operations common to all types of nodes. + Users should neither instantiate nor subclass this, but use + :class:`Component`, :class:`Bus`, :class:`Edge` or one of their subclasses + instead. + + .. role:: python(code) + :language: python + + Parameters + ---------- + label: `hashable`, optional + Used as the string representation of this node. If this parameter is + not an instance of :class:`str` it will be converted to a string and + the result will be used as this node's :attr:`label`, which should be + unique with respect to the other nodes in the energy system graph this + node belongs to. If this parameter is not supplied, the string + representation of this node will instead be generated based on this + nodes `class` and `id`. + + custom_properties: `dict` + This dictionary that can be used to store information that can be used + to easily attach custom information to any Entity. + """ + + def __init__(self, label, *, custom_properties=None): + self._label = label + if custom_properties is None: + custom_properties = {} + self.custom_properties = custom_properties + + def __eq__(self, other): + return id(self) == id(other) + + def __lt__(self, other): + return str(self) < str(other) + + def __hash__(self): + return hash(self.label) + + def __str__(self): + return str(self.label) + + def __repr__(self): + return repr( + "<{0.__module__}.{0.__name__}: {1!r}>".format( + type(self), self.label + ) + ) + + @property + def label(self): + """ + If this node was given a `label` on construction, this + attribute holds the actual object passed as a parameter. Otherwise + `node.label` is a synonym for `str(node)`. + """ + try: + return self._label if self._label is not None else self._id_label + except AttributeError: # Workaround for problems with pickle/dill + return hash(self._id_label) + + @property + def _id_label(self): + return "<{} #0x{:x}>".format(type(self).__name__, id(self)) diff --git a/src/oemof/network/network/helpers.py b/src/oemof/network/network/helpers.py new file mode 100644 index 0000000..73512e2 --- /dev/null +++ b/src/oemof/network/network/helpers.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- + +"""This package contains helpers used by Entities of the energy systems. + +SPDX-FileCopyrightText: Stephan Günther <> +SPDX-FileCopyrightText: Uwe Krien +SPDX-FileCopyrightText: Simon Hilpert <> +SPDX-FileCopyrightText: Cord Kaldemeyer <> +SPDX-FileCopyrightText: Patrik Schönfeldt + +SPDX-License-Identifier: MIT +""" + +from collections import UserDict +from collections.abc import MutableMapping + + +class Inputs(MutableMapping): + """A special helper to map `n1.inputs[n2]` to `n2.outputs[n1]`.""" + + def __init__(self, target): + self.target = target + + def __getitem__(self, key): + return key.outputs.__getitem__(self.target) + + def __delitem__(self, key): + return key.outputs.__delitem__(self.target) + + def __setitem__(self, key, value): + return key.outputs.__setitem__(self.target, value) + + def __iter__(self): + return iter(self.target._in_edges) + + def __len__(self): + return self.target._in_edges.__len__() + + def __repr__(self): + return repr( + "<{0.__module__}.{0.__name__}: {1!r}>".format( + type(self), dict(self) + ) + ) + + +class Outputs(UserDict): + """ + Helper that intercepts modifications to update `Inputs` symmetrically. + """ + + def __init__(self, source): + self.source = source + super().__init__() + + def __delitem__(self, key): + key._in_edges.remove(self.source) + return super().__delitem__(key) + + def __setitem__(self, key, value): + key._in_edges.add(self.source) + return super().__setitem__(key, value) diff --git a/src/oemof/network/network/nodes.py b/src/oemof/network/network/nodes.py new file mode 100644 index 0000000..6984d73 --- /dev/null +++ b/src/oemof/network/network/nodes.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +"""This package contains the differnt types of Node for +modelling an energy system graph. + +SPDX-FileCopyrightText: Stephan Günther <> +SPDX-FileCopyrightText: Uwe Krien +SPDX-FileCopyrightText: Simon Hilpert <> +SPDX-FileCopyrightText: Cord Kaldemeyer <> +SPDX-FileCopyrightText: Patrik Schönfeldt + +SPDX-License-Identifier: MIT +""" + +import warnings + +from .edge import Edge +from .entity import Entity +from .helpers import Inputs +from .helpers import Outputs + + +class Node(Entity): + r"""A Node of an energy system graph. + + Parameters + ---------- + label : (See documentation of class `Entity`) + inputs: list or dict, optional + Either a list of this nodes' input nodes or a dictionary mapping input + nodes to corresponding inflows (i.e. input values). + List will be converted to dictionary with values set to None. + outputs: list or dict, optional + Either a list of this nodes' output nodes or a dictionary mapping + output nodes to corresponding outflows (i.e. output values). + List will be converted to dictionary with values set to None. + + Attributes + ---------- + inputs: dict + A dictionary mapping input nodes to corresponding inflows. + outputs: dict + A dictionary mapping output nodes to corresponding outflows. + """ + + def __init__( + self, + label, + *, + inputs=None, + outputs=None, + custom_properties=None, + ): + super().__init__(label=label, custom_properties=custom_properties) + + self._inputs = Inputs(self) + self._outputs = Outputs(self) + self._in_edges = set() + + if inputs is None: + inputs = {} + if outputs is None: + outputs = {} + + msg = "{} {!r} of {!r} not an instance of Node but of {}." + + for i in inputs: + if not isinstance(i, Node): + raise ValueError(msg.format("Input", i, self, type(i))) + self._in_edges.add(i) + try: + flow = inputs.get(i) + except AttributeError: + flow = None + edge = Edge.from_object(flow) + edge.input = i + edge.output = self + for o in outputs: + if not isinstance(o, Node): + raise ValueError(msg.format("Output", o, self, type(o))) + try: + flow = outputs.get(o) + except AttributeError: + flow = None + edge = Edge.from_object(flow) + edge.input = self + edge.output = o + + @property + def inputs(self): + """dict: + Dictionary mapping input :class:`Entities ` :obj:`n` to + :class:`Edge`s from :obj:`n` into :obj:`self`. + If :obj:`self` is an :class:`Edge`, returns a dict containing the + :class:`Edge`'s single input node as the key and the flow as the value. + """ + return self._inputs + + @property + def outputs(self): + """dict: + Dictionary mapping output :class:`Entities ` :obj:`n` to + :class:`Edges` from :obj:`self` into :obj:`n`. + If :obj:`self` is an :class:`Edge`, returns a dict containing the + :class:`Edge`'s single output node as the key and the flow as the + value. + """ + return self._outputs + + +_deprecation_warning = ( + "Usage of {} is deprecated. Use oemof.network.Node instead." +) + + +class Bus(Node): + def __init__(self, *args, **kwargs): + warnings.warn( + _deprecation_warning.format("oemof.network.Bus"), + FutureWarning, + ) + super().__init__(*args, **kwargs) + + +class Component(Node): + def __init__(self, *args, **kwargs): + warnings.warn( + _deprecation_warning.format("oemof.network.Component"), + FutureWarning, + ) + super().__init__(*args, **kwargs) + + +class Sink(Component): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + +class Source(Component): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + +class Transformer(Component): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) diff --git a/tests/basic_tests.py b/tests/basic_tests.py deleted file mode 100644 index 3a94fbf..0000000 --- a/tests/basic_tests.py +++ /dev/null @@ -1,241 +0,0 @@ -# -*- coding: utf-8 - - -"""Basic tests. - -This file is part of project oemof (github.com/oemof/oemof). It's copyrighted -by the contributors recorded in the version control history of the file, -available from its original location oemof/tests/basic_tests.py - -SPDX-FileCopyrightText: Stephan Günther <> -SPDX-FileCopyrightText: Uwe Krien -SPDX-FileCopyrightText: Simon Hilpert <> -SPDX-FileCopyrightText: Cord Kaldemeyer <> - -SPDX-License-Identifier: MIT -""" -from collections.abc import Iterable -from itertools import chain -from pprint import pformat - -from oemof.network import energy_system as es -from oemof.network.groupings import Flows -from oemof.network.groupings import FlowsWithNodes as FWNs -from oemof.network.groupings import Grouping -from oemof.network.groupings import Nodes -from oemof.network.network import Bus -from oemof.network.network import Node - - -class TestsEnergySystem: - def setup(self): - self.es = es.EnergySystem() - - def test_entity_grouping_on_construction(self): - bus = Bus(label="test bus") - ensys = es.EnergySystem(entities=[bus]) - assert ensys.groups[bus.label] is bus - - def test_that_nodes_is_a_proper_alias_for_entities(self): - b1, b2 = Bus(label="B1"), Bus(label="B2") - self.es.add(b1, b2) - assert self.es.nodes == [b1, b2] - empty = [] - self.es.nodes = empty - assert self.es.entities is empty - - def test_that_none_is_not_a_valid_group(self): - def by_uid(n): - if "Not in 'Group'" in n.uid: - return None - else: - return "Group" - - ensys = es.EnergySystem(groupings=[by_uid]) - - ungrouped = [ - Node(uid="Not in 'Group': {}".format(i)) for i in range(10) - ] - grouped = [Node(uid="In 'Group': {}".format(i)) for i in range(10)] - assert None not in ensys.groups - for g in ensys.groups.values(): - for e in ungrouped: - if isinstance(g, Iterable) and not isinstance(g, str): - assert e not in g - for e in grouped: - if isinstance(g, Iterable) and not isinstance(g, str): - assert e in g - - def test_defining_multiple_groupings_with_one_function(self): - def assign_to_multiple_groups_in_one_go(n): - g1 = n.label[-1] - g2 = n.label[0:3] - return [g1, g2] - - ensy = es.EnergySystem(groupings=[assign_to_multiple_groups_in_one_go]) - nodes = [ - Node( - label=("Foo: " if i % 2 == 0 else "Bar: ") - + "{}".format(i) - + ("A" if i < 5 else "B") - ) - for i in range(10) - ] - ensy.add(*nodes) - for group in ["Foo", "Bar", "A", "B"]: - assert len(ensy.groups[group]) == 5, ( - "\n Failed testing length of group '{}'." - + "\n Expected: 5" - + "\n Got : {}" - + "\n Group : {}" - ).format( - group, - len(ensy.groups[group]), - sorted([e.label for e in ensy.groups[group]]), - ) - - def test_grouping_filter_parameter(self): - g1 = Grouping( - key=lambda e: "The Special One", - filter=lambda e: "special" in str(e), - ) - g2 = Nodes( - key=lambda e: "A Subset", filter=lambda e: "subset" in str(e) - ) - ensys = es.EnergySystem(groupings=[g1, g2]) - special = Node(label="special") - subset = set(Node(label="subset: {}".format(i)) for i in range(10)) - others = set(Node(label="other: {}".format(i)) for i in range(10)) - ensys.add(special, *subset) - ensys.add(*others) - assert ensys.groups["The Special One"] == special - assert ensys.groups["A Subset"] == subset - - def test_proper_filtering(self): - """`Grouping.filter` should not be "all or nothing". - - There was a bug where, if `Grouping.filter` returned `False` only for - some elements of `Grouping.value(e)`, those elements where actually - retained. - This test makes sure that the bug doesn't resurface again. - """ - g = Nodes( - key="group", - value=lambda _: {1, 2, 3, 4}, - filter=lambda x: x % 2 == 0, - ) - ensys = es.EnergySystem(groupings=[g]) - special = Node(label="object") - ensys.add(special) - assert ensys.groups["group"] == {2, 4} - - def test_non_callable_group_keys(self): - collect_everything = Nodes(key="everything") - g1 = Grouping( - key="The Special One", filter=lambda e: "special" in e.label - ) - g2 = Nodes(key="A Subset", filter=lambda e: "subset" in e.label) - ensys = es.EnergySystem(groupings=[g1, g2, collect_everything]) - special = Node(label="special") - subset = set(Node(label="subset: {}".format(i)) for i in range(2)) - others = set(Node(label="other: {}".format(i)) for i in range(2)) - everything = subset.union(others) - everything.add(special) - ensys.add(*everything) - assert ensys.groups["The Special One"] == special - assert ensys.groups["A Subset"] == subset - assert ensys.groups["everything"] == everything - - def test_grouping_laziness(self): - """Energy system `groups` should be fully lazy. - - `Node`s added to an energy system should only be tested for and put - into their respective groups right before the `groups` property of an - energy system is accessed. - """ - group = "Group" - g = Nodes(key=group, filter=lambda n: getattr(n, "group", False)) - self.es = es.EnergySystem(groupings=[g]) - buses = [Bus("Grouped"), Bus("Ungrouped one"), Bus("Ungrouped two")] - self.es.add(buses[0]) - buses[0].group = True - self.es.add(*buses[1:]) - assert group in self.es.groups, ( - ( - "\nExpected to find\n\n `{!r}`\n\n" - "in `es.groups`.\nGot:\n\n `{}`" - ).format( - group, - "\n ".join(pformat(set(self.es.groups.keys())).split("\n")), - ), - ) - assert buses[0] in self.es.groups[group], ( - "\nExpected\n\n `{}`\n\nin `es.groups['{}']`:\n\n `{}`".format( - "\n ".join(pformat(buses[0]).split("\n")), - group, - "\n ".join(pformat(self.es.groups[group]).split("\n")), - ), - ) - - def test_constant_group_keys(self): - """Callable keys passed in as `constant_key` should not be called. - - The `constant_key` parameter can be used to specify callable group keys - without having to worry about `Grouping`s trying to call them. This - test makes sure that the parameter is handled correctly. - """ - - def everything(): - return "everything" - - collect_everything = Nodes(constant_key=everything) - ensys = es.EnergySystem(groupings=[collect_everything]) - node = Node(label="A Node") - ensys.add(node) - assert "everything" not in ensys.groups - assert everything in ensys.groups - assert ensys.groups[everything] == {node} - assert everything() == "everything" - - def test_flows(self): - key = object() - ensys = es.EnergySystem(groupings=[Flows(key)]) - bus = Bus(label="A Bus") - node = Node(label="A Node", inputs={bus: None}, outputs={bus: None}) - ensys.add(bus, node) - assert ensys.groups[key] == set( - chain(bus.inputs.values(), bus.outputs.values()) - ) - - def test_flows_with_nodes(self): - key = object() - ensys = es.EnergySystem(groupings=[FWNs(key)]) - bus = Bus(label="A Bus") - node = Node(label="A Node", inputs={bus: None}, outputs={bus: None}) - ensys.add(bus, node) - assert ensys.groups[key], { - (bus, node, bus.outputs[node]), - (node, bus, node.outputs[bus]), - } - - def test_that_node_additions_are_signalled(self): - """ - When a node gets `add`ed, a corresponding signal should be emitted. - """ - node = Node(label="Node") - - def subscriber(sender, **kwargs): - assert sender is node - assert kwargs["EnergySystem"] is self.es - subscriber.called = True - - subscriber.called = False - - es.EnergySystem.signals[es.EnergySystem.add].connect( - subscriber, sender=node - ) - self.es.add(node) - assert subscriber.called, ( - "\nExpected `subscriber.called` to be `True`.\n" - "Got {}.\n" - "Probable reason: `subscriber` didn't get called." - ).format(subscriber.called) diff --git a/tests/node_registration_tests.py b/tests/node_registration_tests.py deleted file mode 100644 index 7cfa9fe..0000000 --- a/tests/node_registration_tests.py +++ /dev/null @@ -1,86 +0,0 @@ -# -*- coding: utf-8 - - -""" Tests pertaining to :obj:`node {}` registration via -:attr:`Node.registry `. - -This test suite (eventually) collects all tests revolving around automatically -registering :obj:`nodes ` in an -:obj:`energy system `. Since this feature is -deprecated, having all tests pertaining to it in one file makes it easier to -remove them all at once, when the feature is romved. - -This file is part of project oemof (github.com/oemof/oemof). It's copyrighted -by the contributors recorded in the version control history of the file, -available from its original location oemof/tests/basic_tests.py - -SPDX-License-Identifier: MIT -""".format( - "" -) - -import warnings - -import pandas as pd -import pytest - -from oemof.network.energy_system import EnergySystem -from oemof.network.network import Bus -from oemof.network.network import Node -from oemof.network.network import Transformer - - -class NodeRegistrationTests: - - # TODO: Move all other registration tests into this test suite. - - @classmethod - def setup_class(cls): - cls.timeindex = pd.date_range("1/1/2012", periods=5, freq="H") - - def setup(self): - self.es = EnergySystem() - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - Node.registry = None - - def test_entity_registration(self): - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - Node.registry = self.es - bus = Bus(label="bus-uid", type="bus-type") - assert self.es.nodes[0] == bus - bus2 = Bus(label="bus-uid2", type="bus-type") - assert self.es.nodes[1] == bus2 - t1 = Transformer(label="pp_gas", inputs=[bus], outputs=[bus2]) - assert t1 in self.es.nodes - self.es.timeindex = self.timeindex - assert len(self.es.timeindex) == 5 - - def test_that_setting_a_node_registry_emits_a_warning(self): - with pytest.warns(FutureWarning): - Node.registry = 1 - - def test_that_accessing_the_node_registry_emits_a_warning(self): - with pytest.warns(FutureWarning): - Node.registry - - def test_that_node_creation_does_not_emit_a_warning(self): - with pytest.warns(None) as record: - Node() - - recorded = [w for w in record.list if w.category is FutureWarning] - if recorded: - pytest.fail( - "Creating a node emitted the following `FutureWarning`s\n" - "although no warning was expected:\n{}".format( - "\n---\n".join([str(w.message) for w in recorded]) - ) - ) - - def test_that_node_creation_emits_a_warning_if_registry_is_not_none(self): - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - Node.registry = EnergySystem() - - with pytest.warns(FutureWarning): - Node() diff --git a/tests/test_energy_system.py b/tests/test_energy_system.py new file mode 100644 index 0000000..9616ccf --- /dev/null +++ b/tests/test_energy_system.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 - + +"""Basic tests. + +This file is part of project oemof.network (github.com/oemof/oemof-network). + +SPDX-FileCopyrightText: Stephan Günther <> +SPDX-FileCopyrightText: Uwe Krien +SPDX-FileCopyrightText: Simon Hilpert <> +SPDX-FileCopyrightText: Cord Kaldemeyer <> +SPDX-FileCopyrightText: Patrik Schönfeldt + +SPDX-License-Identifier: MIT +""" + +import pytest + +from oemof.network.energy_system import EnergySystem +from oemof.network.network import Edge +from oemof.network.network.nodes import Node + + +def test_ensys_init(): + node = Node("label") + ensys = EnergySystem(nodes=[node]) + assert node in ensys.nodes + + with pytest.warns(FutureWarning): + ensys = EnergySystem(entities=[node]) + assert node in ensys.nodes + + +class TestsEnergySystem: + def setup_method(self): + self.es = EnergySystem() + + def test_add_nodes(self): + assert not self.es.nodes + + node1 = Node(label="node1") + self.es.add(node1) + assert self.es.nodes + assert node1 in self.es.nodes + assert not self.es.flows() + + # Note that node2 is not added, but the Flow is already + # registred. We do not assert the latter fact as this is not a + # guaranteed functionality. + node2 = Node(label="node2", inputs={node1: Edge()}) + assert node2 not in self.es.nodes + + # When both nodes are registred, also the Flow needs to be there. + self.es.add(node2) + assert node2 in self.es.nodes + assert (node1, node2) in self.es.flows().keys() + + def test_add_flow_assignment(self): + assert not self.es.nodes + + node0 = Node(label="node0") + node1 = Node(label="node1") + node2 = Node(label="node2", inputs={node0: Edge()}) + + self.es.add(node0, node1, node2) + + assert (node0, node2) in self.es.flows().keys() + assert (node1, node2) not in self.es.flows().keys() + assert (node2, node1) not in self.es.flows().keys() + + node2.inputs[node1] = Edge() + + assert (node0, node2) in self.es.flows().keys() + assert (node1, node2) in self.es.flows().keys() + assert (node2, node1) not in self.es.flows().keys() + + node2.outputs[node1] = Edge() + assert (node0, node2) in self.es.flows().keys() + assert (node1, node2) in self.es.flows().keys() + assert (node2, node1) in self.es.flows().keys() + + def test_that_node_additions_are_signalled(self): + """ + When a node gets `add`ed, a corresponding signal should be emitted. + """ + node = Node(label="Node") + + def subscriber(sender, **kwargs): + assert sender is node + assert kwargs["EnergySystem"] is self.es + subscriber.called = True + + subscriber.called = False + + EnergySystem.signals[EnergySystem.add].connect(subscriber, sender=node) + self.es.add(node) + assert subscriber.called, ( + "\nExpected `subscriber.called` to be `True`.\n" + "Got {}.\n" + "Probable reason: `subscriber` didn't get called." + ).format(subscriber.called) diff --git a/tests/test_groupings.py b/tests/test_groupings/test_groupings_advanced.py similarity index 65% rename from tests/test_groupings.py rename to tests/test_groupings/test_groupings_advanced.py index e3522e3..de12c27 100644 --- a/tests/test_groupings.py +++ b/tests/test_groupings/test_groupings_advanced.py @@ -12,8 +12,7 @@ from types import MappingProxyType as MaProTy -from nose.tools import assert_raises -from nose.tools import eq_ +import pytest from oemof.network.groupings import Grouping @@ -21,26 +20,28 @@ def test_initialization_argument_checks(): """`Grouping` constructor should raise `TypeError` on bad arguments.""" - message = "\n`Grouping` constructor did not check mandatory arguments." - with assert_raises(TypeError, msg=message): + message = "Grouping constructor missing required argument" + with pytest.raises(TypeError, match=message): Grouping() - message = "\n`Grouping` constructor did not check conflicting arguments." - with assert_raises(TypeError, msg=message): + message = ( + "Grouping arguments `key` and `constant_key` are mutually exclusive." + ) + with pytest.raises(TypeError, match=message): Grouping(key=lambda x: x, constant_key="key") def test_notimplementederrors(): """`Grouping` should raise an error when reaching unreachable code.""" - message = "\n`Grouping.key` not overriden, but no error raised." - with assert_raises(NotImplementedError, msg=message): + message = "There is no default implementation for `Groupings.key`." + with pytest.raises(NotImplementedError, match=message): g = Grouping(key="key") del g.key g.key("dummy argument") - message = "\n`Grouping.filter` not overriden, but no error raised." - with assert_raises(NotImplementedError, msg=message): + message = "`Groupings.filter` called without being overridden." + with pytest.raises(NotImplementedError, match=message): g = Grouping(key="key") del g.filter g.filter("dummy argument") @@ -54,10 +55,8 @@ def test_mutable_mapping_groups(): groups = {} expected = {3: {"o": 2, "f": 1}} g("foo", groups) - eq_( - groups, - expected, - "\n Expected: {} \n Got : {}".format(expected, groups), + assert groups == expected, "\n Expected: {} \n Got : {}".format( + expected, groups ) @@ -69,8 +68,6 @@ def test_immutable_mapping_groups(): groups = {} expected = {3: MaProTy({"o": 2, "f": 1})} g("foo", groups) - eq_( - groups, - expected, - "\n Expected: {} \n Got : {}".format(expected, groups), + assert groups == expected, "\n Expected: {} \n Got : {}".format( + expected, groups ) diff --git a/tests/test_groupings/test_groupings_basic.py b/tests/test_groupings/test_groupings_basic.py new file mode 100644 index 0000000..3132d1f --- /dev/null +++ b/tests/test_groupings/test_groupings_basic.py @@ -0,0 +1,183 @@ +# -*- coding: utf-8 - + +"""Basic tests. + +This file is part of project oemof.network (github.com/oemof/oemof-network). + +SPDX-FileCopyrightText: Stephan Günther <> +SPDX-FileCopyrightText: Uwe Krien +SPDX-FileCopyrightText: Simon Hilpert <> +SPDX-FileCopyrightText: Cord Kaldemeyer <> +SPDX-FileCopyrightText: Patrik Schönfeldt + +SPDX-License-Identifier: MIT +""" +from collections.abc import Iterable +from pprint import pformat + +from oemof.network import energy_system as es +from oemof.network.groupings import Entities +from oemof.network.groupings import Grouping +from oemof.network.network.nodes import Node + + +def test_entity_grouping_on_construction(): + bus = Node(label="test bus") + ensys = es.EnergySystem(nodes=[bus]) + assert ensys.groups[bus.label] is bus + + +def test_that_none_is_not_a_valid_group(): + def by_uid(n): + if "Not in 'Group'" in n.uid: + return None + else: + return "Group" + + ensys = es.EnergySystem(groupings=[by_uid]) + + ungrouped = [Node(label="Not in 'Group': {}".format(i)) for i in range(10)] + grouped = [Node(label="In 'Group': {}".format(i)) for i in range(10)] + assert None not in ensys.groups + for g in ensys.groups.values(): + for e in ungrouped: + if isinstance(g, Iterable) and not isinstance(g, str): + assert e not in g + for e in grouped: + if isinstance(g, Iterable) and not isinstance(g, str): + assert e in g + + +def test_defining_multiple_groupings_with_one_function(): + def assign_to_multiple_groups_in_one_go(n): + g1 = n.label[-1] + g2 = n.label[0:3] + return [g1, g2] + + ensy = es.EnergySystem(groupings=[assign_to_multiple_groups_in_one_go]) + nodes = [ + Node( + label=("Foo: " if i % 2 == 0 else "Bar: ") + + "{}".format(i) + + ("A" if i < 5 else "B") + ) + for i in range(10) + ] + ensy.add(*nodes) + for group in ["Foo", "Bar", "A", "B"]: + assert len(ensy.groups[group]) == 5, ( + "\n Failed testing length of group '{}'." + + "\n Expected: 5" + + "\n Got : {}" + + "\n Group : {}" + ).format( + group, + len(ensy.groups[group]), + sorted([e.label for e in ensy.groups[group]]), + ) + + +def test_grouping_filter_parameter(): + g1 = Grouping( + key=lambda e: "The Special One", + filter=lambda e: "special" in str(e), + ) + g2 = Entities( + key=lambda e: "A Subset", filter=lambda e: "subset" in str(e) + ) + ensys = es.EnergySystem(groupings=[g1, g2]) + special = Node(label="special") + subset = set(Node(label="subset: {}".format(i)) for i in range(10)) + others = set(Node(label="other: {}".format(i)) for i in range(10)) + ensys.add(special, *subset) + ensys.add(*others) + assert ensys.groups["The Special One"] == special + assert ensys.groups["A Subset"] == subset + + +def test_proper_filtering(): + """`Grouping.filter` should not be "all or nothing". + + There was a bug where, if `Grouping.filter` returned `False` only for + some elements of `Grouping.value(e)`, those elements where actually + retained. + This test makes sure that the bug doesn't resurface again. + """ + g = Entities( + key="group", + value=lambda _: {1, 2, 3, 4}, + filter=lambda x: x % 2 == 0, + ) + ensys = es.EnergySystem(groupings=[g]) + special = Node(label="object") + ensys.add(special) + assert ensys.groups["group"] == {2, 4} + + +def test_non_callable_group_keys(): + collect_everything = Entities(key="everything") + g1 = Grouping(key="The Special One", filter=lambda e: "special" in e.label) + g2 = Entities(key="A Subset", filter=lambda e: "subset" in e.label) + ensys = es.EnergySystem(groupings=[g1, g2, collect_everything]) + special = Node(label="special") + subset = set(Node(label="subset: {}".format(i)) for i in range(2)) + others = set(Node(label="other: {}".format(i)) for i in range(2)) + everything = subset.union(others) + everything.add(special) + ensys.add(*everything) + assert ensys.groups["The Special One"] == special + assert ensys.groups["A Subset"] == subset + assert ensys.groups["everything"] == everything + + +def test_grouping_laziness(): + """Energy system `groups` should be fully lazy. + + `Node`s added to an energy system should only be tested for and put + into their respective groups right before the `groups` property of an + energy system is accessed. + """ + group = "Group" + g = Entities(key=group, filter=lambda n: getattr(n, "group", False)) + ensys = es.EnergySystem(groupings=[g]) + buses = [Node("Grouped"), Node("Ungrouped one"), Node("Ungrouped two")] + ensys.add(buses[0]) + buses[0].group = True + ensys.add(*buses[1:]) + assert group in ensys.groups, ( + ( + "\nExpected to find\n\n `{!r}`\n\n" + "in `es.groups`.\nGot:\n\n `{}`" + ).format( + group, + "\n ".join(pformat(set(ensys.groups.keys())).split("\n")), + ), + ) + assert buses[0] in ensys.groups[group], ( + "\nExpected\n\n `{}`\n\nin `es.groups['{}']`:\n\n `{}`".format( + "\n ".join(pformat(buses[0]).split("\n")), + group, + "\n ".join(pformat(ensys.groups[group]).split("\n")), + ), + ) + + +def test_constant_group_keys(): + """Callable keys passed in as `constant_key` should not be called. + + The `constant_key` parameter can be used to specify callable group keys + without having to worry about `Grouping`s trying to call them. This + test makes sure that the parameter is handled correctly. + """ + + def everything(): + return "everything" + + collect_everything = Entities(constant_key=everything) + ensys = es.EnergySystem(groupings=[collect_everything]) + node = Node(label="A Node") + ensys.add(node) + assert "everything" not in ensys.groups + assert everything in ensys.groups + assert ensys.groups[everything] == {node} + assert everything() == "everything" diff --git a/tests/test_groupings/test_groupings_in_es.py b/tests/test_groupings/test_groupings_in_es.py new file mode 100644 index 0000000..69e2c53 --- /dev/null +++ b/tests/test_groupings/test_groupings_in_es.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 - + +"""Basic tests. + +This file is part of project oemof.network (github.com/oemof/oemof-network). + +SPDX-FileCopyrightText: Stephan Günther <> +SPDX-FileCopyrightText: Uwe Krien +SPDX-FileCopyrightText: Simon Hilpert <> +SPDX-FileCopyrightText: Cord Kaldemeyer <> +SPDX-FileCopyrightText: Patrik Schönfeldt + +SPDX-License-Identifier: MIT +""" +from itertools import chain + +from oemof.network import energy_system as es +from oemof.network.groupings import Flows +from oemof.network.groupings import FlowsWithNodes +from oemof.network.network.nodes import Node + + +def test_flows(): + key = object() + ensys = es.EnergySystem(groupings=[Flows(key)]) + bus = Node(label="A Bus") + node = Node(label="A Node", inputs={bus: None}, outputs={bus: None}) + ensys.add(bus, node) + assert ensys.groups[key] == set( + chain(bus.inputs.values(), bus.outputs.values()) + ) + + +def test_flows_with_nodes(): + key = object() + ensys = es.EnergySystem(groupings=[FlowsWithNodes(key)]) + bus = Node(label="A Bus") + node = Node(label="A Node", inputs={bus: None}, outputs={bus: None}) + ensys.add(bus, node) + assert ensys.groups[key], { + (bus, node, bus.outputs[node]), + (node, bus, node.outputs[bus]), + } diff --git a/tests/test_network_classes.py b/tests/test_network_classes.py index 9d625f4..81e7ab6 100644 --- a/tests/test_network_classes.py +++ b/tests/test_network_classes.py @@ -10,6 +10,7 @@ SPDX-FileCopyrightText: Uwe Krien SPDX-FileCopyrightText: Simon Hilpert <> SPDX-FileCopyrightText: Cord Kaldemeyer <> +SPDX-FileCopyrightText: Patrik Schönfeldt SPDX-License-Identifier: MIT """ @@ -18,24 +19,23 @@ import pytest -from oemof.network.energy_system import EnergySystem as EnSys +from oemof.network.energy_system import EnergySystem from oemof.network.network import Bus -from oemof.network.network import Edge -from oemof.network.network import Node +from oemof.network.network import Sink +from oemof.network.network import Source from oemof.network.network import Transformer -from oemof.network.network import registry_changed_to -from oemof.network.network import temporarily_modifies_registry +from oemof.network.network.edge import Edge +from oemof.network.network.entity import Entity +from oemof.network.network.nodes import Node class TestsNode: - def setup(self): - self.energysystem = EnSys() - Node.registry = self.energysystem + def setup_method(self): + self.energysystem = EnergySystem() - def test_that_attributes_cannot_be_added(self): - node = Node() - with pytest.raises(AttributeError): - node.foo = "bar" + def test_entity_initialisation(self): + entity = Entity(label="foo") + assert entity.label == "foo" def test_symmetric_input_output_assignment(self): n1 = Node(label="") @@ -60,7 +60,7 @@ def test_symmetric_input_output_assignment(self): ) def test_accessing_outputs_of_a_node_without_output_flows(self): - n = Node() + n = Node(label="node") exception = None outputs = None try: @@ -79,7 +79,7 @@ def test_accessing_outputs_of_a_node_without_output_flows(self): ) def test_accessing_inputs_of_a_node_without_input_flows(self): - n = Node() + n = Node(label="node") exception = None inputs = None try: @@ -98,7 +98,7 @@ def test_accessing_inputs_of_a_node_without_input_flows(self): ) def test_that_the_outputs_attribute_of_a_node_is_a_mapping(self): - n = Node() + n = Node(label="node") exception = None try: n.outputs.values() @@ -123,13 +123,9 @@ def test_that_nodes_do_not_get_undead_flows(self): This test ensures that new nodes only have those flows which are assigned to them on construction. """ - # We don't want a registry as we are re-using a label on purpose. - # Having a registry would just throw and error generated by the DEFAULT - # grouping in this case. - Node.registry = None flow = object() old = Node(label="A reused label") - bus = Bus(label="bus", inputs={old: flow}) + bus = Node(label="bus", inputs={old: flow}) assert bus.inputs[old].flow == flow, ( ("\n Expected: {0}" + "\n Got : {1} instead").format( flow, bus.inputs[old].flow @@ -177,7 +173,7 @@ def test_modifying_outputs_after_construction(self): def test_modifying_inputs_after_construction(self): """One should be able to add and delete inputs of a node.""" node = Node("N1") - bus = Bus("N2") + bus = Node("N2") flow = "flow" assert node.inputs == {}, ( @@ -245,31 +241,33 @@ def test_error_for_duplicate_label_argument(self): with pytest.raises(TypeError): Node("Positional Label", label="Keyword Label") - def test_node_input_output_type_assertions(self): + def test_entity_input_output_type_assertions(self): """ - `Node`s should only accept `Node` instances as input/output targets. + `'Node'` should only accept `Node` instances + as input/output targets. """ - with pytest.raises(AssertionError): - Node("A node with an output", outputs={"Not a Node": "A Flow"}) - Node("A node with an input", inputs={"Not a Node": "A Flow"}) + with pytest.raises(ValueError): + Node("An entity with an output", outputs={"Not a Node": "A Flow"}) + + with pytest.raises(ValueError): + Node("An entity with an input", inputs={"Not a Node": "A Flow"}) - def test_node_label_without_private_attribute(self): + def test_node_requires_label(self): """ - A `Node` with no explicit `label` doesn't have a `_label` attribute. + A `Node` without `label` cannot be constructed. """ - n = Node() - with pytest.raises(AttributeError): - n._label + with pytest.raises(TypeError): + _ = Node() def test_node_label_if_its_not_explicitly_specified(self): """If not explicitly given, a `Node`'s label is based on its `id`.""" - n = Node() + n = Node(label=None) assert "0x{:x}>".format(id(n)) in n.label class TestsEdge: - def setup(self): - Node.registry = None + def setup_method(self): + pass def test_edge_construction_side_effects(self): """Constructing an `Edge` should affect it's input/output `Node`s. @@ -279,7 +277,7 @@ def test_edge_construction_side_effects(self): """ source = Node(label="source") target = Node(label="target") - edge = Edge(input=source, output=target) + edge = Edge(input_node=source, output_node=target) assert target in source.outputs, ( "{} not in {} after constructing {}.".format( target, source.outputs, edge @@ -312,7 +310,7 @@ def test_alternative_edge_construction_from_mapping(self): i, o, f = (Node("input"), Node("output"), "flow") with pytest.raises(ValueError): Edge.from_object({"flow": i, "values": o}) - edge = Edge.from_object({"input": i, "output": o, "flow": f}) + edge = Edge.from_object({"input_node": i, "output_node": o, "flow": f}) assert edge.input == i assert edge.output == o assert edge.values == f @@ -327,37 +325,57 @@ def test_flow_setter(self): assert e.flow == "new values set via `e.flow`" assert e.values == "new values set via `e.flow`" - def test_delayed_registration_when_setting_input(self): - """`Edge` registration gets delayed until input and output are set.""" - i, o = (Node("input"), Node("output")) - with registry_changed_to(EnSys()): - e = Edge(output=o) - assert e not in Node.registry.groups.values() - e.input = i - assert e in Node.registry.groups.values() - class TestsEnergySystemNodesIntegration: - def setup(self): - self.es = EnSys() - Node.registry = self.es - - def test_node_registration(self): - assert Node.registry == self.es - b1 = Bus(label="") - assert self.es.entities[0] == b1 - b2 = Bus(label="") - assert self.es.entities[1] == b2 - t1 = Transformer(label="", inputs=[b1], outputs=[b2]) - assert t1 in self.es.entities - - def test_registry_modification_decorator(self): - Node("registered") - assert "registered" in self.es.groups - - @temporarily_modifies_registry - def create_a_node(): - Node("not registered") - - create_a_node() - assert "not registered" not in self.es.groups + def setup_method(self): + self.es = EnergySystem() + + def test_entity_registration(self): + with pytest.warns( + match="API to access nodes by label is experimental" + ): + n1 = Node(label="") + self.es.add(n1) + assert self.es.node[""] == n1 + n2 = Node(label="") + self.es.add(n2) + assert self.es.node[""] == n2 + n3 = Node(label="", inputs=[n1], outputs=[n2]) + self.es.add(n3) + assert self.es.node[""] == n3 + + +def test_deprecated_classes(): + with pytest.warns(FutureWarning): + Bus("bus") + with pytest.warns(FutureWarning): + Sink("sink") + with pytest.warns(FutureWarning): + Source("source") + with pytest.warns(FutureWarning): + Transformer("transformer") + + +def test_custom_properties(): + node0 = Node("n0") + + assert not node0.custom_properties + + node1 = Node( + "n1", + custom_properties={ + "foo": "bar", + 1: 2, + }, + ) + assert node1.custom_properties["foo"] == "bar" + assert node1.custom_properties[1] == 2 + + +def test_comparision(): + node0 = Node(label=0) + node1 = Node(label=2) + node2 = Node(label=-5) + + assert node0 < node1 + assert node0 > node2 diff --git a/tox.ini b/tox.ini index 46d4329..cab91cf 100644 --- a/tox.ini +++ b/tox.ini @@ -3,19 +3,17 @@ envlist = clean, check, docs, - py37-cover, - py37-nocov, - py38-cover, - py38-nocov, - py39-cover, - py39-nocov, + py38, + py39, + py310, + py3-nocov, report [gh-actions] python = - 3.7: py37-cover - 3.8: py38-cover - 3.9: py39-cover + 3.8: py38 + 3.9: py39 + 3.10: py310 [testenv] basepython = @@ -29,10 +27,11 @@ passenv = deps = nose pytest - pytest-travis-fold commands = {posargs:pytest -vv --ignore=src} +ignore_basepython_conflict = True + [testenv:bootstrap] deps = jinja2 @@ -55,7 +54,7 @@ commands = twine check dist/oemof* check-manifest {toxinidir} flake8 src tests setup.py - isort --verbose --check-only --diff src tests setup.py + isort --check-only --profile black --diff src tests setup.py [testenv:docs] @@ -63,7 +62,7 @@ usedevelop = true deps = -r{toxinidir}/docs/requirements.txt commands = - sphinx-build {posargs:-E} -b html docs dist/docs + sphinx-build {posargs:-E} -W -b html docs dist/docs sphinx-build -b linkcheck docs dist/docs [testenv:coveralls] @@ -73,7 +72,12 @@ skip_install = true commands = coveralls [] - +[testenv:codecov] +deps = + codecov +skip_install = true +commands = + codecov [] [testenv:report] deps = coverage @@ -87,8 +91,8 @@ commands = coverage erase skip_install = true deps = coverage -[testenv:py37-cover] -basepython = {env:TOXPYTHON:python3.7} +[testenv:py310] +basepython = {env:TOXPYTHON:python3.10} setenv = {[testenv]setenv} usedevelop = true @@ -98,10 +102,7 @@ deps = {[testenv]deps} pytest-cov -[testenv:py37-nocov] -basepython = {env:TOXPYTHON:python3.7} - -[testenv:py38-cover] +[testenv:py38] basepython = {env:TOXPYTHON:python3.8} setenv = {[testenv]setenv} @@ -112,10 +113,7 @@ deps = {[testenv]deps} pytest-cov -[testenv:py38-nocov] -basepython = {env:TOXPYTHON:python3.8} - -[testenv:py39-cover] +[testenv:py39] basepython = {env:TOXPYTHON:python3.9} setenv = {[testenv]setenv} @@ -126,5 +124,5 @@ deps = {[testenv]deps} pytest-cov -[testenv:py39-nocov] -basepython = {env:TOXPYTHON:python3.9} +[testenv:py3-nocov] +basepython = {env:TOXPYTHON:python3}