diff --git a/.cookiecutter-replay.json b/.cookiecutter-replay.json index fae25f975..f7085ec67 100644 --- a/.cookiecutter-replay.json +++ b/.cookiecutter-replay.json @@ -1,5 +1,6 @@ { "cookiecutter": { + "Introduction": "]\n\nWelcome to repo-config Cookiecutter template!\n\nThis template will help you to create a new repository for your project. You will be asked to provide some information about your project.\n\nHere is an explanation of what each variable is for and will be used for:\n\n* `type`: The type of repository. It must be chosen from the list.\n\n* `name`: The name of the project. This will be used to build defaults for\n other inputs, such as `title`, `python_package`, etc. It should be one word,\n using only alphanumeric characters (and starting with a letter). It can\n include also `_` and `-` which will be handled differently when building\n other variables from it (replaced by spaces in titles for example).\n\n* `description`: A short description of the project. It will be used as the\n description in the `README.md`, `pyproject.toml`, `mkdocs.yml`, etc.\n\n* `title`: A human-readable name or title for the project. It will be used in\n the `README.md`, `CONTRIBUTING.md`, and other files to refer to the project,\n as well as the site title in `mkdocs.yml`.\n\n* `keywords`: A comma-separated list of keywords that will be used in the\n `pyproject.toml` file. If left untouched, it will use only some predefined\n keywords. If anything else is entered, it will be **added** to the default\n keywords.\n\n* `github_org`: The GitHub handle of the organization where the project will\n reside. This will be used to generate links to the project on GitHub.\n\n* `license`: Currently, only two options are provided: `MIT`, which should be\n used for open-source projects, and `Proprietary`, which should be used for\n closed-source projects. This will be added to file headers and used as the\n license in `pyproject.toml`.\n\n* `author_name`, `author_email`: The name and email address of the author of\n the project. They will be used in the copyright notice in file headers and\n as the author in `pyproject.toml`.\n\n* `python_package`: The Python package in which this project will reside. All\n files provided by this project should be located in this package. This needs\n to be a list of valid Python identifiers separated by dots. The source file\n structure will be derived from this. For example, `frequenz.actor.example`\n will generate files in `src/frequenz/actor/example`.\n\n* `pypi_package_name`: The name of the PyPI/wheel/distribution package. This\n should be consistent with the `python_package`, usually replacing `.` with\n `-`. For example, `frequenz-actor-example`.\n\n* `github_repo_name`: The handle of the GitHub repository where the project\n will reside. This will be used to generate links to the project on GitHub and\n as the top-level directory name.\n\n* `default_codeowners`: A space-separated list of GitHub teams (`@org/team`) or\n users (`@user`) that will be the default code owners for this project. This\n will be used to build the `CODEOWNERS` file. Please refer to the [code owners\n documentation] for more details on the valid syntax.\n\n[code owners documentation]: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners\n\n\n[Please press any key to continue", "type": "lib", "name": "sdk", "description": "A development kit to interact with the Frequenz development platform", @@ -15,14 +16,41 @@ "default_codeowners": "@frequenz-floss/python-sdk-team", "_extensions": [ "jinja2_time.TimeExtension", + "local_extensions.as_identifier", "local_extensions.default_codeowners", "local_extensions.github_repo_name", + "local_extensions.introduction", "local_extensions.keywords", "local_extensions.pypi_package_name", "local_extensions.python_package", "local_extensions.src_path", "local_extensions.title" ], - "_template": "gh:frequenz-floss/frequenz-repo-config-python" + "_template": "gh:frequenz-floss/frequenz-repo-config-python", + }, + "_cookiecutter": { + "Introduction": "{{cookiecutter | introduction}}", + "type": [ + "actor", + "api", + "app", + "lib", + "model" + ], + "name": null, + "description": null, + "title": "{{cookiecutter | title}}", + "keywords": "(comma separated: 'frequenz', and are included automatically)", + "github_org": "frequenz-floss", + "license": [ + "MIT", + "Proprietary" + ], + "author_name": "Frequenz Energy-as-a-Service GmbH", + "author_email": "floss@frequenz.com", + "python_package": "{{cookiecutter | python_package}}", + "pypi_package_name": "{{cookiecutter | pypi_package_name}}", + "github_repo_name": "{{cookiecutter | github_repo_name}}", + "default_codeowners": "(like @some-org/some-team; defaults to a team based on the repo type)" } -} +} \ No newline at end of file diff --git a/.github/containers/test-installation/Dockerfile b/.github/containers/test-installation/Dockerfile new file mode 100644 index 000000000..a25614157 --- /dev/null +++ b/.github/containers/test-installation/Dockerfile @@ -0,0 +1,10 @@ +# This dockerfile is used to test the installation of the python package in +# multiple platforms in the CI. It is not used to build the package itself. + +FROM --platform=${TARGETPLATFORM} python:3.11-slim + +RUN python -m pip install --upgrade --no-cache-dir pip + +COPY dist dist +RUN pip install dist/*.whl && \ + rm -rf dist diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ba8647c43..5d15160e3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -50,7 +50,7 @@ jobs: python -m pip install -e .[dev-noxfile] - name: Run nox - # To speed things up a bit we use the speciall ci_checks_max 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 run: nox -e ci_checks_max pytest_min timeout-minutes: 10 @@ -83,6 +83,31 @@ jobs: path: dist/ if-no-files-found: error + test-installation: + name: Test package installation in different architectures + needs: ["build"] + runs-on: ubuntu-20.04 + steps: + - name: Fetch sources + uses: actions/checkout@v3 + - name: Download package + uses: actions/download-artifact@v3 + with: + name: dist-packages + path: dist + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + - name: Set up docker-buildx + uses: docker/setup-buildx-action@v2 + - name: Test Installation + uses: docker/build-push-action@v4 + with: + context: . + file: .github/containers/test-installation/Dockerfile + platforms: linux/amd64,linux/arm64 + tags: localhost/test-installation + push: false + test-docs: name: Test documentation website generation if: github.event_name != 'push' @@ -121,7 +146,7 @@ jobs: publish-docs: name: Publish documentation website to GitHub pages - needs: ["nox", "build"] + needs: ["nox", "test-installation"] if: github.event_name == 'push' runs-on: ubuntu-20.04 permissions: diff --git a/.github/workflows/release_notes_check.yml b/.github/workflows/release_notes_check.yml new file mode 100644 index 000000000..e0b00ac93 --- /dev/null +++ b/.github/workflows/release_notes_check.yml @@ -0,0 +1,28 @@ +name: Release Notes Check + +on: + merge_group: + pull_request: + types: + # On by default if you specify no types. + - "opened" + - "reopened" + - "synchronize" + # For `skip-label` only. + - "labeled" + - "unlabeled" + + +jobs: + check-release-notes: + name: Check release notes are updated + runs-on: ubuntu-latest + steps: + - name: Check for a release notes update + if: github.event_name == 'pull_request' + uses: brettcannon/check-for-changed-files@4170644959a21843b31f1181f2a1761d65ef4791 # v1.2.0 + with: + file-pattern: "RELEASE_NOTES.md" + prereq-pattern: "src/**" + skip-label: "cmd:skip-release-notes" + failure-message: "Missing a release notes update. Please add one or apply the ${skip-label} label to the pull request" diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 43c161352..936bc6dae 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -2,22 +2,110 @@ ## Summary - +This release replaces the `@actor` decorator with a new `Actor` class. ## Upgrading - -- `Channels` has been upgraded to version 0.16.0, for information on how to upgrade visit https://github.com/frequenz-floss/frequenz-channels-python/releases/tag/v0.16.0 +- The `frequenz.sdk.power` package contained the power distribution algorithm, which is for internal use in the sdk, and is no longer part of the public API. + +- `PowerDistributingActor`'s result type `OutOfBound` has been renamed to `OutOfBounds`, and its member variable `bound` has been renamed to `bounds`. + +- The `@actor` decorator was replaced by the new `Actor` class. The main differences between the new class and the old decorator are: + + * It doesn't start automatically, `start()` needs to be called to start an actor (using the `frequenz.sdk.actor.run()` function is recommended). + * The method to implement the main logic was renamed from `run()` to `_run()`, as it is not intended to be run externally. + * Actors can have an optional `name` (useful for debugging/logging purposes). + * The actor will only be restarted if an unhandled `Exception` is raised by `_run()`. It will not be restarted if the `_run()` method finishes normally. If an unhandled `BaseException` is raised instead, it will be re-raised. For normal cancellation the `_run()` method should handle `asyncio.CancelledError` if the cancellation shouldn't be propagated (this is the same as with the decorator). + * The `_stop()` method is public (`stop()`) and will `cancel()` and `await` for the task to finish, catching the `asyncio.CancelledError`. + * The `join()` method is renamed to `wait()`, but they can also be awaited directly ( `await actor`). + * For deterministic cleanup, actors can now be used as `async` context managers. + + Most actors can be migrated following these steps: + + 1. Remove the decorator + 2. Add `Actor` as a base class + 3. Rename `run()` to `_run()` + 4. Forward the `name` argument (optional but recommended) + + For example, this old actor: + + ```python + from frequenz.sdk.actor import actor + + @actor + class TheActor: + def __init__(self, actor_args) -> None: + # init code + + def run(self) -> None: + # run code + ``` + + Can be migrated as: + + ```python + import asyncio + from frequenz.sdk.actor import Actor + + class TheActor(Actor): + def __init__(self, actor_args, + *, + name: str | None = None, + ) -> None: + super().__init__(name=name) + # init code + + def _run(self) -> None: + # run code + ``` + + Then you can instantiate all your actors first and then run them using: + + ```python + from frequenz.sdk.actor import run + # Init code + actor = TheActor() + other_actor = OtherActor() + # more setup + await run(actor, other_actor) # Start and await for all the actors + ``` + +- The `MovingWindow` is now a `BackgroundService`, so it needs to be started manually with `await window.start()`. It is recommended to use it as an `async` context manager if possible though: + + ```python + async with MovingWindow(...) as window: + # The moving windows is started here + use(window) + # The moving window is stopped here + ``` + +- The base actors (`ConfigManagingActor`, `ComponentMetricsResamplingActor`, `DataSourcingActor`, `PowerDistributingActor`) now inherit from the new `Actor` class, if you are using them directly, you need to start them manually with `await actor.start()` and you might need to do some other adjustments. ## New Features -- Add quantity class `Frequency` for frequency values. -- Add `abs()` support for quantities. +- Added `DFS` to the component graph + +- `BackgroundService`: This new abstract base class can be used to write other classes that runs one or more tasks in the background. It provides a consistent API to start and stop these services and also takes care of the handling of the background tasks. It can also work as an `async` context manager, giving the service a deterministic lifetime and guaranteed cleanup. + + All classes spawning tasks that are expected to run for an indeterminate amount of time are likely good candidates to use this as a base class. + +- `Actor`: This new class inherits from `BackgroundService` and it replaces the `@actor` decorator. ## Bug Fixes -- Fix formatting issue for `Quantity` objects with zero values. -- Fix formatting isuse for `Quantity` when the base value is float.inf or float.nan. +- Fixes a bug in the ring buffer updating the end timestamp of gaps when they are outdated. + +- Properly handles PV configurations with no or only some meters before the PV component. + + So far we only had configurations like this: `Meter -> Inverter -> PV`. However the scenario with `Inverter -> PV` is also possible and now handled correctly. + +- Fix `consumer_power()` not working certain configurations. + + In microgrids without consumers and no main meter, the formula would never return any values. + +- Fix `pv_power` not working in setups with 2 grid meters by using a new reliable function to search for components in the components graph + +- Fix `consumer_power` and `producer_power` similar to `pv_power` - +- Zero value requests received by the `PowerDistributingActor` will now always be accepted, even when there are non-zero exclusion bounds. diff --git a/benchmarks/power_distribution/power_distributor.py b/benchmarks/power_distribution/power_distributor.py index fd1e039f1..bd4a147d3 100644 --- a/benchmarks/power_distribution/power_distributor.py +++ b/benchmarks/power_distribution/power_distributor.py @@ -17,7 +17,7 @@ from frequenz.sdk.actor.power_distributing import ( BatteryStatus, Error, - OutOfBound, + OutOfBounds, PartialFailure, PowerDistributingActor, Request, @@ -75,7 +75,7 @@ def parse_result(result: List[List[Result]]) -> Dict[str, float]: Error: 0, Success: 0, PartialFailure: 0, - OutOfBound: 0, + OutOfBounds: 0, } for result_list in result: @@ -86,7 +86,7 @@ def parse_result(result: List[List[Result]]) -> Dict[str, float]: "success_num": result_counts[Success], "failed_num": result_counts[PartialFailure], "error_num": result_counts[Error], - "out_of_bound": result_counts[OutOfBound], + "out_of_bounds": result_counts[OutOfBounds], } @@ -108,19 +108,16 @@ async def run_test( # pylint: disable=too-many-locals power_request_channel = Broadcast[Request]("power-request") battery_status_channel = Broadcast[BatteryStatus]("battery-status") channel_registry = ChannelRegistry(name="power_distributor") - distributor = PowerDistributingActor( + async with PowerDistributingActor( channel_registry=channel_registry, requests_receiver=power_request_channel.new_receiver(), battery_status_sender=battery_status_channel.new_sender(), - ) - - tasks: List[Coroutine[Any, Any, List[Result]]] = [] - tasks.append(send_requests(batteries, num_requests)) - - result = await asyncio.gather(*tasks) - exec_time = timeit.default_timer() - start + ): + tasks: List[Coroutine[Any, Any, List[Result]]] = [] + tasks.append(send_requests(batteries, num_requests)) - await distributor._stop() # type: ignore # pylint: disable=no-member, protected-access + result = await asyncio.gather(*tasks) + exec_time = timeit.default_timer() - start summary = parse_result(result) summary["num_requests"] = num_requests diff --git a/benchmarks/timeseries/benchmark_datasourcing.py b/benchmarks/timeseries/benchmark_datasourcing.py index 9fffbf0b8..febcf731b 100644 --- a/benchmarks/timeseries/benchmark_datasourcing.py +++ b/benchmarks/timeseries/benchmark_datasourcing.py @@ -70,7 +70,7 @@ async def benchmark_data_sourcing( num_ev_chargers * len(COMPONENT_METRIC_IDS) * num_msgs_per_battery ) mock_grid = MockMicrogrid( - grid_side_meter=False, num_values=num_msgs_per_battery, sample_rate_s=0.0 + grid_meter=False, num_values=num_msgs_per_battery, sample_rate_s=0.0 ) mock_grid.add_ev_chargers(num_ev_chargers) @@ -113,23 +113,22 @@ async def consume(channel: Receiver[Any]) -> None: await request_sender.send(request) consume_tasks.append(asyncio.create_task(consume(recv_channel))) - DataSourcingActor(request_receiver, channel_registry) + async with DataSourcingActor(request_receiver, channel_registry): + await asyncio.gather(*consume_tasks) - await asyncio.gather(*consume_tasks) + time_taken = perf_counter() - start_time - time_taken = perf_counter() - start_time + await mock_grid.cleanup() - await mock_grid.cleanup() - - print(f"Samples Sent: {samples_sent}, time taken: {time_taken}") - print(f"Samples per second: {samples_sent / time_taken}") - print( - "Expected samples: " - f"{num_expected_messages}, missing: {num_expected_messages - samples_sent}" - ) - print( - f"Missing per EVC: {(num_expected_messages - samples_sent) / num_ev_chargers}" - ) + print(f"Samples Sent: {samples_sent}, time taken: {time_taken}") + print(f"Samples per second: {samples_sent / time_taken}") + print( + "Expected samples: " + f"{num_expected_messages}, missing: {num_expected_messages - samples_sent}" + ) + print( + f"Missing per EVC: {(num_expected_messages - samples_sent) / num_ev_chargers}" + ) def parse_args() -> Tuple[int, int, bool]: diff --git a/benchmarks/timeseries/periodic_feature_extractor.py b/benchmarks/timeseries/periodic_feature_extractor.py index 49bbdc180..9be937e23 100644 --- a/benchmarks/timeseries/periodic_feature_extractor.py +++ b/benchmarks/timeseries/periodic_feature_extractor.py @@ -12,6 +12,8 @@ from __future__ import annotations import asyncio +import collections.abc +import contextlib import logging from datetime import datetime, timedelta, timezone from functools import partial @@ -27,19 +29,23 @@ from frequenz.sdk.timeseries._quantities import Quantity -async def init_feature_extractor(period: int) -> PeriodicFeatureExtractor: +@contextlib.asynccontextmanager +async def init_feature_extractor( + period: int, +) -> collections.abc.AsyncIterator[PeriodicFeatureExtractor]: """Initialize the PeriodicFeatureExtractor class.""" # We only need the moving window to initialize the PeriodicFeatureExtractor class. lm_chan = Broadcast[Sample[Quantity]]("lm_net_power") - moving_window = MovingWindow( + async with MovingWindow( timedelta(seconds=1), lm_chan.new_receiver(), timedelta(seconds=1) - ) - - await lm_chan.new_sender().send(Sample(datetime.now(tz=timezone.utc), Quantity(0))) + ) as moving_window: + await lm_chan.new_sender().send( + Sample(datetime.now(tz=timezone.utc), Quantity(0)) + ) - # Initialize the PeriodicFeatureExtractor class with a period of period seconds. - # This works since the sampling period is set to 1 second. - return PeriodicFeatureExtractor(moving_window, timedelta(seconds=period)) + # Initialize the PeriodicFeatureExtractor class with a period of period seconds. + # This works since the sampling period is set to 1 second. + yield PeriodicFeatureExtractor(moving_window, timedelta(seconds=period)) def _calculate_avg_window( @@ -211,22 +217,22 @@ async def main() -> None: # create a random ndarray with 29 days -5 seconds of data days_29_s = 29 * DAY_S - feature_extractor = await init_feature_extractor(10) - data = rng.standard_normal(days_29_s) - run_benchmark(data, 4, feature_extractor) - - days_29_s = 29 * DAY_S + 3 - data = rng.standard_normal(days_29_s) - run_benchmark(data, 4, feature_extractor) - - # create a random ndarray with 29 days +5 seconds of data - data = rng.standard_normal(29 * DAY_S + 5) - - feature_extractor = await init_feature_extractor(7 * DAY_S) - # TEST one day window and 6 days distance. COPY (Case 3) - run_benchmark(data, DAY_S, feature_extractor) - # benchmark one day window and 6 days distance. NO COPY (Case 1) - run_benchmark(data[: 28 * DAY_S], DAY_S, feature_extractor) + async with init_feature_extractor(10) as feature_extractor: + data = rng.standard_normal(days_29_s) + run_benchmark(data, 4, feature_extractor) + + days_29_s = 29 * DAY_S + 3 + data = rng.standard_normal(days_29_s) + run_benchmark(data, 4, feature_extractor) + + # create a random ndarray with 29 days +5 seconds of data + data = rng.standard_normal(29 * DAY_S + 5) + + async with init_feature_extractor(7 * DAY_S) as feature_extractor: + # TEST one day window and 6 days distance. COPY (Case 3) + run_benchmark(data, DAY_S, feature_extractor) + # benchmark one day window and 6 days distance. NO COPY (Case 1) + run_benchmark(data[: 28 * DAY_S], DAY_S, feature_extractor) logging.basicConfig(level=logging.DEBUG) diff --git a/mkdocs.yml b/mkdocs.yml index 86701d07d..13fe0f1c9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -22,6 +22,8 @@ theme: repo: fontawesome/brands/github custom_dir: docs/overrides features: + - content.code.annotate + - content.code.copy - navigation.instant - navigation.tabs - navigation.top @@ -62,9 +64,11 @@ markdown_extensions: - admonition - attr_list - pymdownx.details - - pymdownx.superfences - - pymdownx.tasklist - - pymdownx.tabbed + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.keys - pymdownx.snippets: check_paths: true - pymdownx.superfences: @@ -72,6 +76,8 @@ markdown_extensions: - name: mermaid class: mermaid format: "!!python/name:pymdownx.superfences.fence_code_format" + - pymdownx.tabbed + - pymdownx.tasklist - toc: permalink: "¤" @@ -97,8 +103,11 @@ plugins: show_root_members_full_path: true show_source: true import: + # See https://mkdocstrings.github.io/python/usage/#import for details - https://docs.python.org/3/objects.inv + - https://frequenz-floss.github.io/frequenz-api-common/v0.3/objects.inv - https://frequenz-floss.github.io/frequenz-channels-python/v0.14/objects.inv + - https://frequenz-floss.github.io/frequenz-api-microgrid/v0.15/objects.inv - https://grpc.github.io/grpc/python/objects.inv - https://networkx.org/documentation/stable/objects.inv - https://numpy.org/doc/stable/objects.inv diff --git a/pyproject.toml b/pyproject.toml index 82a46f1c2..a5c8e87e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ requires = [ "setuptools == 67.7.2", "setuptools_scm[toml] == 7.1.0", - "frequenz-repo-config[lib] == 0.3.0", + "frequenz-repo-config[lib] == 0.4.0", ] build-backend = "setuptools.build_meta" @@ -14,7 +14,7 @@ name = "frequenz-sdk" description = "A development kit to interact with the Frequenz development platform" readme = "README.md" license = { text = "MIT" } -keywords = ["frequenz", "sdk", "microgrid", "actor"] +keywords = ["frequenz", "python", "lib", "library", "sdk", "microgrid"] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", @@ -26,7 +26,7 @@ classifiers = [ ] requires-python = ">= 3.11, < 4" dependencies = [ - "frequenz-api-microgrid >= 0.11.0, < 0.12.0", + "frequenz-api-microgrid >= 0.15.1, < 0.16.0", # Make sure to update the mkdocs.yml file when # changing the version # (plugins.mkdocstrings.handlers.python.import) @@ -54,27 +54,27 @@ dev-docstrings = [ "darglint == 1.8.1", "tomli == 2.0.1", # Needed by pydocstyle to read pyproject.toml ] -dev-examples = ["polars == 0.18.7"] +dev-examples = ["polars == 0.18.13"] dev-formatting = ["black == 23.7.0", "isort == 5.12.0"] dev-mkdocs = [ "mike == 1.1.2", "mkdocs-gen-files == 0.5.0", "mkdocs-literate-nav == 0.6.0", - "mkdocs-material == 9.1.18", + "mkdocs-material == 9.2.5", "mkdocs-section-index == 0.3.5", "mkdocstrings[python] == 0.22.0", - "frequenz-repo-config[lib] == 0.3.0", + "frequenz-repo-config[lib] == 0.4.0", ] dev-mypy = [ - "mypy == 1.4.1", + "mypy == 1.5.1", "grpc-stubs == 1.24.12", # This dependency introduces breaking changes in patch releases - "types-protobuf == 4.23.0.1", + "types-protobuf == 4.24.0.1", # For checking the noxfile, docs/ script, and tests "frequenz-sdk[dev-mkdocs,dev-noxfile,dev-pytest]", ] -dev-noxfile = ["nox == 2023.4.22", "frequenz-repo-config[lib] == 0.3.0"] +dev-noxfile = ["nox == 2023.4.22", "frequenz-repo-config[lib] == 0.4.0"] dev-pylint = [ - "pylint == 2.17.4", + "pylint == 2.17.5", # For checking the noxfile, docs/ script, and tests "frequenz-sdk[dev-mkdocs,dev-noxfile,dev-pytest]", ] @@ -82,11 +82,11 @@ dev-pytest = [ "pytest == 7.4.0", "pytest-mock == 3.11.1", "pytest-asyncio == 0.21.1", - "time-machine == 2.11.0", + "time-machine == 2.12.0", "async-solipsism == 0.5", # For checking docstring code examples "sybil == 5.0.3", - "pylint == 2.17.4", + "pylint == 2.17.5", "frequenz-sdk[dev-examples]", ] dev = [ @@ -107,7 +107,7 @@ include = '\.pyi?$' [tool.isort] profile = "black" line_length = 88 -src_paths = ["src", "examples", "tests"] +src_paths = ["benchmarks", "examples", "src", "tests"] [tool.pylint.similarities] ignore-comments = ['yes'] diff --git a/src/frequenz/sdk/actor/__init__.py b/src/frequenz/sdk/actor/__init__.py index b17162bef..a75aaf32c 100644 --- a/src/frequenz/sdk/actor/__init__.py +++ b/src/frequenz/sdk/actor/__init__.py @@ -4,20 +4,22 @@ """A base class for creating simple composable actors.""" from ..timeseries._resampling import ResamplerConfig +from ._actor import Actor +from ._background_service import BackgroundService from ._channel_registry import ChannelRegistry from ._config_managing import ConfigManagingActor from ._data_sourcing import ComponentMetricRequest, DataSourcingActor -from ._decorator import actor from ._resampling import ComponentMetricsResamplingActor from ._run_utils import run __all__ = [ + "Actor", + "BackgroundService", "ChannelRegistry", "ComponentMetricRequest", "ComponentMetricsResamplingActor", "ConfigManagingActor", "DataSourcingActor", "ResamplerConfig", - "actor", "run", ] diff --git a/src/frequenz/sdk/actor/_actor.py b/src/frequenz/sdk/actor/_actor.py new file mode 100644 index 000000000..b60a56a77 --- /dev/null +++ b/src/frequenz/sdk/actor/_actor.py @@ -0,0 +1,203 @@ +# License: MIT +# Copyright © 2023 Frequenz Energy-as-a-Service GmbH + +"""Actor model implementation.""" + +import abc +import asyncio +import logging + +from ._background_service import BackgroundService + +_logger = logging.getLogger(__name__) + + +class Actor(BackgroundService, abc.ABC): + """A primitive unit of computation that runs autonomously. + + From [Wikipedia](https://en.wikipedia.org/wiki/Actor_model), an actor is: + + > [...] the basic building block of concurrent computation. In response to + > a message it receives, an actor can: make local decisions, create more actors, + > send more messages, and determine how to respond to the next message received. + > Actors may modify their own private state, but can only affect each other + > indirectly through messaging (removing the need for lock-based synchronization). + + [Channels](https://github.com/frequenz-floss/frequenz-channels-python/) can be used + to implement communication between actors, as shown in the examples below. + + To implement an actor, subclasses must implement the `_run()` method, which should + run the actor's logic. The `_run()` method is called by the base class when the + actor is started, and is expected to run until the actor is stopped. + + If an unhandled exception is raised in the `_run()` method, the actor will be + restarted automatically. Unhandled [`BaseException`][]s will cause the actor to stop + immediately and will be re-raised. + + !!! warning + + As actors manage [`asyncio.Task`][] objects, a reference to them must be held + for as long as the actor is expected to be running, otherwise its tasks will be + cancelled and the actor will stop. For more information, please refer to the + [Python `asyncio` + documentation](https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task). + + Example: Example of an actor receiving from two receivers + + ```python + from frequenz.channels import Broadcast, Receiver, Sender + from frequenz.channels.util import select, selected_from + + class EchoActor(Actor): + def __init__( + self, + recv1: Receiver[bool], + recv2: Receiver[bool], + output: Sender[bool], + ) -> None: + super().__init__() + self._recv1 = recv1 + self._recv2 = recv2 + self._output = output + + async def _run(self) -> None: + async for selected in select(self._recv1, self._recv2): + if selected_from(selected, self._recv1): + await self._output.send(selected.value) + elif selected_from(selected, self._recv1): + await self._output.send(selected.value) + else: + assert False, "Unknown selected channel" + + + input_channel_1 = Broadcast[bool]("input_channel_1") + input_channel_2 = Broadcast[bool]("input_channel_2") + input_channel_2_sender = input_channel_2.new_sender() + + echo_channel = Broadcast[bool]("EchoChannel") + echo_receiver = echo_channel.new_receiver() + + async with EchoActor( + input_channel_1.new_receiver(), + input_channel_2.new_receiver(), + echo_channel.new_sender(), + ): + await input_channel_2_sender.send(True) + print(await echo_receiver.receive()) + ``` + + Example: Example of composing two actors + + ```python + from frequenz.channels import Broadcast, Receiver, Sender + + class Actor1(Actor): + def __init__( + self, + recv: Receiver[bool], + output: Sender[bool], + ) -> None: + super().__init__() + self._recv = recv + self._output = output + + async def _run(self) -> None: + async for msg in self._recv: + await self._output.send(msg) + + + class Actor2(Actor): + def __init__( + self, + recv: Receiver[bool], + output: Sender[bool], + ) -> None: + super().__init__() + self._recv = recv + self._output = output + + async def _run(self) -> None: + async for msg in self._recv: + await self._output.send(msg) + + input_channel: Broadcast[bool] = Broadcast("Input to Actor1") + middle_channel: Broadcast[bool] = Broadcast("Actor1 -> Actor2 stream") + output_channel: Broadcast[bool] = Broadcast("Actor2 output") + + input_sender = input_channel.new_sender() + output_receiver = output_channel.new_receiver() + + async with ( + Actor1(input_channel.new_receiver(), middle_channel.new_sender()), + Actor2(middle_channel.new_receiver(), output_channel.new_sender()), + ): + await input_sender.send(True) + print(await output_receiver.receive()) + ``` + """ + + _restart_limit: int | None = None + """The number of times actors can be restarted when they are stopped by unhandled exceptions. + + If this is bigger than 0 or `None`, the actor will be restarted when there is an + unhanded exception in the `_run()` method. + + If `None`, the actor will be restarted an unlimited number of times. + + !!! note + + This is mostly used for testing purposes and shouldn't be set in production. + """ + + async def start(self) -> None: + """Start this actor. + + If this actor is already running, this method does nothing. + """ + if self.is_running: + return + self._tasks.clear() + self._tasks.add(asyncio.create_task(self._run_loop())) + + @abc.abstractmethod + async def _run(self) -> None: + """Run this actor's logic.""" + + async def _run_loop(self) -> None: + """Run this actor's task in a loop until `_restart_limit` is reached. + + Raises: + asyncio.CancelledError: If this actor's `_run()` gets cancelled. + Exception: If this actor's `_run()` raises any other `Exception` and reached + the maximum number of restarts. + BaseException: If this actor's `_run()` raises any other `BaseException`. + """ + _logger.info("Actor %s: Starting...", self) + n_restarts = 0 + while True: + try: + await self._run() + _logger.info("Actor %s: _run() returned without error.", self) + except asyncio.CancelledError: + _logger.info("Actor %s: Cancelled.", self) + raise + except Exception: # pylint: disable=broad-except + _logger.exception("Actor %s: Raised an unhandled exception.", self) + limit_str = "∞" if self._restart_limit is None else self._restart_limit + limit_str = f"({n_restarts}/{limit_str})" + if self._restart_limit is None or n_restarts < self._restart_limit: + n_restarts += 1 + _logger.info("Actor %s: Restarting %s...", self._name, limit_str) + continue + _logger.info( + "Actor %s: Maximum restarts attempted %s, bailing out...", + self, + limit_str, + ) + raise + except BaseException: # pylint: disable=broad-except + _logger.exception("Actor %s: Raised a BaseException.", self) + raise + break + + _logger.info("Actor %s: Stopped.", self) diff --git a/src/frequenz/sdk/actor/_background_service.py b/src/frequenz/sdk/actor/_background_service.py new file mode 100644 index 000000000..cfbfea6b7 --- /dev/null +++ b/src/frequenz/sdk/actor/_background_service.py @@ -0,0 +1,253 @@ +# License: MIT +# Copyright © 2023 Frequenz Energy-as-a-Service GmbH + +"""Background service implementation.""" + +import abc +import asyncio +import collections.abc +from types import TracebackType +from typing import Any, Self + + +class BackgroundService(abc.ABC): + """A background service that can be started and stopped. + + A background service is a service that runs in the background spawning one or more + tasks. The service can be [started][frequenz.sdk.actor.BackgroundService.start] + and [stopped][frequenz.sdk.actor.BackgroundService.stop] and can work as an + async context manager to provide deterministic cleanup. + + To implement a background service, subclasses must implement the + [`start()`][frequenz.sdk.actor.BackgroundService.start] method, which should + start the background tasks needed by the service, and add them to the `_tasks` + protected attribute. + + If you need to collect results or handle exceptions of the tasks when stopping the + service, then you need to also override the + [`stop()`][frequenz.sdk.actor.BackgroundService.stop] method, as the base + implementation does not collect any results and re-raises all exceptions. + + !!! warning + + As background services manage [`asyncio.Task`][] objects, a reference to them + must be held for as long as the background service is expected to be running, + otherwise its tasks will be cancelled and the service will stop. For more + information, please refer to the [Python `asyncio` + documentation](https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task). + + Example: + ```python + import datetime + import asyncio + + class Clock(BackgroundService): + def __init__(self, resolution_s: float, *, name: str | None = None) -> None: + super().__init__(name=name) + self._resolution_s = resolution_s + + async def start(self) -> None: + self._tasks.add(asyncio.create_task(self._tick())) + + async def _tick(self) -> None: + while True: + await asyncio.sleep(self._resolution_s) + print(datetime.datetime.now()) + + async def main() -> None: + # As an async context manager + async with Clock(resolution_s=1): + await asyncio.sleep(5) + + # Manual start/stop (only use if necessary, as cleanup is more complicated) + clock = Clock(resolution_s=1) + await clock.start() + await asyncio.sleep(5) + await clock.stop() + + asyncio.run(main()) + ``` + """ + + def __init__(self, *, name: str | None = None) -> None: + """Initialize this BackgroundService. + + Args: + name: The name of this background service. If `None`, `str(id(self))` will + be used. This is used mostly for debugging purposes. + """ + self._name: str = str(id(self)) if name is None else name + self._tasks: set[asyncio.Task[Any]] = set() + + @abc.abstractmethod + async def start(self) -> None: + """Start this background service.""" + + @property + def name(self) -> str: + """The name of this background service. + + Returns: + The name of this background service. + """ + return self._name + + @property + def tasks(self) -> collections.abc.Set[asyncio.Task[Any]]: + """Return the set of running tasks spawned by this background service. + + Users typically should not modify the tasks in the returned set and only use + them for informational purposes. + + !!! danger + + Changing the returned tasks may lead to unexpected behavior, don't do it + unless the class explicitly documents it is safe to do so. + + Returns: + The set of running tasks spawned by this background service. + """ + return self._tasks + + @property + def is_running(self) -> bool: + """Return whether this background service is running. + + A service is considered running when at least one task is running. + + Returns: + Whether this background service is running. + """ + return any(not task.done() for task in self._tasks) + + def cancel(self, msg: str | None = None) -> None: + """Cancel all running tasks spawned by this background service. + + Args: + msg: The message to be passed to the tasks being cancelled. + """ + for task in self._tasks: + task.cancel(msg) + + async def stop(self, msg: str | None = None) -> None: + """Stop this background service. + + This method cancels all running tasks spawned by this service and waits for them + to finish. + + Args: + msg: The message to be passed to the tasks being cancelled. + + Raises: + BaseExceptionGroup: If any of the tasks spawned by this service raised an + exception. + + [//]: # (# noqa: DAR401 rest) + """ + if not self._tasks: + return + self.cancel(msg) + try: + await self.wait() + except BaseExceptionGroup as exc_group: + # We want to ignore CancelledError here as we explicitly cancelled all the + # tasks. + _, rest = exc_group.split(asyncio.CancelledError) + if rest is not None: + # We are filtering out from an exception group, we really don't want to + # add the exceptions we just filtered by adding a from clause here. + raise rest # pylint: disable=raise-missing-from + + async def __aenter__(self) -> Self: + """Enter an async context. + + Start this background service. + + Returns: + This background service. + """ + await self.start() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """Exit an async context. + + Stop this background service. + + Args: + exc_type: The type of the exception raised, if any. + exc_val: The exception raised, if any. + exc_tb: The traceback of the exception raised, if any. + """ + await self.stop() + + async def wait(self) -> None: + """Wait this background service to finish. + + Wait until all background service tasks are finished. + + Raises: + BaseExceptionGroup: If any of the tasks spawned by this service raised an + exception (`CancelError` is not considered an error and not returned in + the exception group). + """ + # We need to account for tasks that were created between when we started + # awaiting and we finished awaiting. + while self._tasks: + done, pending = await asyncio.wait(self._tasks) + assert not pending + + # We remove the done tasks, but there might be new ones created after we + # started waiting. + self._tasks = self._tasks - done + + exceptions: list[BaseException] = [] + for task in done: + try: + # This will raise a CancelledError if the task was cancelled or any + # other exception if the task raised one. + _ = task.result() + except BaseException as error: # pylint: disable=broad-except + exceptions.append(error) + if exceptions: + raise BaseExceptionGroup( + f"Error while stopping background service {self}", exceptions + ) + + def __await__(self) -> collections.abc.Generator[None, None, None]: + """Await this background service. + + An awaited background service will wait for all its tasks to finish. + + Returns: + An implementation-specific generator for the awaitable. + """ + return self.wait().__await__() + + def __del__(self) -> None: + """Destroy this instance. + + Cancel all running tasks spawned by this background service. + """ + self.cancel("{self!r} was deleted") + + def __repr__(self) -> str: + """Return a string representation of this instance. + + Returns: + A string representation of this instance. + """ + return f"{type(self).__name__}(name={self._name!r}, tasks={self._tasks!r})" + + def __str__(self) -> str: + """Return a string representation of this instance. + + Returns: + A string representation of this instance. + """ + return f"{type(self).__name__}[{self._name}]" diff --git a/src/frequenz/sdk/actor/_config_managing.py b/src/frequenz/sdk/actor/_config_managing.py index 1fee8d802..447728167 100644 --- a/src/frequenz/sdk/actor/_config_managing.py +++ b/src/frequenz/sdk/actor/_config_managing.py @@ -4,75 +4,87 @@ """Read and update config variables.""" import logging -import os +import pathlib import tomllib from collections import abc -from typing import Any, Dict +from typing import Any, assert_never from frequenz.channels import Sender from frequenz.channels.util import FileWatcher -from ..actor._decorator import actor +from ..actor._actor import Actor from ..config import Config _logger = logging.getLogger(__name__) -@actor -class ConfigManagingActor: - """ - Manages config variables. +class ConfigManagingActor(Actor): + """An actor that monitors a TOML configuration file for updates. + + When the file is updated, the new configuration is sent, as a [`dict`][], to the + `output` sender. - Config variables are read from file. - Only single file can be read. - If new file is read, then previous configs will be forgotten. + When the actor is started, if a configuration file already exists, then it will be + read and sent to the `output` sender before the actor starts monitoring the file + for updates. This way users can rely on the actor to do the initial configuration + reading too. """ def __init__( self, - conf_file: str, + config_path: pathlib.Path | str, output: Sender[Config], event_types: abc.Set[FileWatcher.EventType] = frozenset(FileWatcher.EventType), + *, + name: str | None = None, ) -> None: - """Read config variables from the file. + """Initialize this instance. Args: - conf_file: Path to file with config variables. - output: Channel to publish updates to. - event_types: Which types of events should update the config and - trigger a notification. + config_path: The path to the TOML file with the configuration. + output: The sender to send the config to. + event_types: The set of event types to monitor. + name: The name of the actor. If `None`, `str(id(self))` will + be used. This is used mostly for debugging purposes. """ - self._conf_file: str = conf_file - self._conf_dir: str = os.path.dirname(conf_file) - self._file_watcher = FileWatcher( - paths=[self._conf_dir], event_types=event_types + super().__init__(name=name) + self._config_path: pathlib.Path = ( + config_path + if isinstance(config_path, pathlib.Path) + else pathlib.Path(config_path) ) - self._output = output + # FileWatcher can't watch for non-existing files, so we need to watch for the + # parent directory instead just in case a configuration file doesn't exist yet + # or it is deleted and recreated again. + self._file_watcher: FileWatcher = FileWatcher( + paths=[self._config_path.parent], event_types=event_types + ) + self._output: Sender[Config] = output - def _read_config(self) -> Dict[str, Any]: - """Read the contents of the config file. - - Raises: - ValueError: if config file cannot be read. + def _read_config(self) -> dict[str, Any]: + """Read the contents of the configuration file. Returns: A dictionary containing configuration variables. + + Raises: + ValueError: If config file cannot be read. """ try: - with open(self._conf_file, "rb") as toml_file: + with self._config_path.open("rb") as toml_file: return tomllib.load(toml_file) except ValueError as err: - logging.error("Can't read config file, err: %s", err) + logging.error("%s: Can't read config file, err: %s", self, err) raise async def send_config(self) -> None: - """Send config file using a broadcast channel.""" + """Send the configuration to the output sender.""" conf_vars = self._read_config() config = Config(conf_vars) await self._output.send(config) - async def run(self) -> None: - """Watch config file and update when modified. + async def _run(self) -> None: + """Monitor for and send configuration file updates. At startup, the Config Manager sends the current config so that it can be cache in the Broadcast channel and served to receivers even if @@ -81,12 +93,31 @@ async def run(self) -> None: await self.send_config() async for event in self._file_watcher: - if event.type != FileWatcher.EventType.DELETE: - if str(event.path) == self._conf_file: + # Since we are watching the whole parent directory, we need to make sure + # we only react to events related to the configuration file. + if event.path != self._config_path: + continue + + match event.type: + case FileWatcher.EventType.CREATE: _logger.info( - "Update configs, because file %s was modified.", - self._conf_file, + "%s: The configuration file %s was created, sending new config...", + self, + self._config_path, ) await self.send_config() - - _logger.debug("ConfigManager stopped.") + case FileWatcher.EventType.MODIFY: + _logger.info( + "%s: The configuration file %s was modified, sending update...", + self, + self._config_path, + ) + await self.send_config() + case FileWatcher.EventType.DELETE: + _logger.info( + "%s: The configuration file %s was deleted, ignoring...", + self, + self._config_path, + ) + case _: + assert_never(event.type) diff --git a/src/frequenz/sdk/actor/_data_sourcing/data_sourcing.py b/src/frequenz/sdk/actor/_data_sourcing/data_sourcing.py index 0a721796d..d744e35cc 100644 --- a/src/frequenz/sdk/actor/_data_sourcing/data_sourcing.py +++ b/src/frequenz/sdk/actor/_data_sourcing/data_sourcing.py @@ -5,19 +5,20 @@ from frequenz.channels import Receiver +from .._actor import Actor from .._channel_registry import ChannelRegistry -from .._decorator import actor from .microgrid_api_source import ComponentMetricRequest, MicrogridApiSource -@actor -class DataSourcingActor: +class DataSourcingActor(Actor): """An actor that provides data streams of metrics as time series.""" def __init__( self, request_receiver: Receiver[ComponentMetricRequest], registry: ChannelRegistry, + *, + name: str | None = None, ) -> None: """Create a `DataSourcingActor` instance. @@ -25,11 +26,14 @@ def __init__( request_receiver: A channel receiver to accept metric requests from. registry: A channel registry. To be replaced by a singleton instance. + name: The name of the actor. If `None`, `str(id(self))` will be used. This + is used mostly for debugging purposes. """ + super().__init__(name=name) self._request_receiver = request_receiver self._microgrid_api_source = MicrogridApiSource(registry) - async def run(self) -> None: + async def _run(self) -> None: """Run the actor.""" async for request in self._request_receiver: await self._microgrid_api_source.add_metric(request) diff --git a/src/frequenz/sdk/actor/_data_sourcing/microgrid_api_source.py b/src/frequenz/sdk/actor/_data_sourcing/microgrid_api_source.py index b1aacc5f4..196c56a30 100644 --- a/src/frequenz/sdk/actor/_data_sourcing/microgrid_api_source.py +++ b/src/frequenz/sdk/actor/_data_sourcing/microgrid_api_source.py @@ -82,14 +82,35 @@ def get_channel_name(self) -> str: ComponentMetricId.SOC_LOWER_BOUND: lambda msg: msg.soc_lower_bound, ComponentMetricId.SOC_UPPER_BOUND: lambda msg: msg.soc_upper_bound, ComponentMetricId.CAPACITY: lambda msg: msg.capacity, - ComponentMetricId.POWER_LOWER_BOUND: lambda msg: msg.power_lower_bound, - ComponentMetricId.POWER_UPPER_BOUND: lambda msg: msg.power_upper_bound, + ComponentMetricId.POWER_INCLUSION_LOWER_BOUND: lambda msg: ( + msg.power_inclusion_lower_bound + ), + ComponentMetricId.POWER_EXCLUSION_LOWER_BOUND: lambda msg: ( + msg.power_exclusion_lower_bound + ), + ComponentMetricId.POWER_EXCLUSION_UPPER_BOUND: lambda msg: ( + msg.power_exclusion_upper_bound + ), + ComponentMetricId.POWER_INCLUSION_UPPER_BOUND: lambda msg: ( + msg.power_inclusion_upper_bound + ), + ComponentMetricId.TEMPERATURE: lambda msg: msg.temperature, } _InverterDataMethods: Dict[ComponentMetricId, Callable[[InverterData], float]] = { ComponentMetricId.ACTIVE_POWER: lambda msg: msg.active_power, - ComponentMetricId.ACTIVE_POWER_LOWER_BOUND: lambda msg: msg.active_power_lower_bound, - ComponentMetricId.ACTIVE_POWER_UPPER_BOUND: lambda msg: msg.active_power_upper_bound, + ComponentMetricId.ACTIVE_POWER_INCLUSION_LOWER_BOUND: lambda msg: ( + msg.active_power_inclusion_lower_bound + ), + ComponentMetricId.ACTIVE_POWER_EXCLUSION_LOWER_BOUND: lambda msg: ( + msg.active_power_exclusion_lower_bound + ), + ComponentMetricId.ACTIVE_POWER_EXCLUSION_UPPER_BOUND: lambda msg: ( + msg.active_power_exclusion_upper_bound + ), + ComponentMetricId.ACTIVE_POWER_INCLUSION_UPPER_BOUND: lambda msg: ( + msg.active_power_inclusion_upper_bound + ), } _EVChargerDataMethods: Dict[ComponentMetricId, Callable[[EVChargerData], float]] = { diff --git a/src/frequenz/sdk/actor/_decorator.py b/src/frequenz/sdk/actor/_decorator.py deleted file mode 100644 index a9dc44239..000000000 --- a/src/frequenz/sdk/actor/_decorator.py +++ /dev/null @@ -1,234 +0,0 @@ -# License: MIT -# Copyright © 2022 Frequenz Energy-as-a-Service GmbH - -"""A decorator for creating simple composable actors. - -Supports multiple input channels and a single output channel. - -Note that if your use-case needs multiple output channels, you may instead -consider using several actors. -""" - -import asyncio -import inspect -import logging -from typing import Any, Callable, Optional, Type, TypeVar - -from typing_extensions import ParamSpec - -from frequenz.sdk._internal._asyncio import cancel_and_await - -_logger = logging.getLogger(__name__) - - -def _check_run_method_exists(cls: Type[Any]) -> None: - """Check if a run method exists in the given class. - - Args: - cls: The actor class. - - Raises: - TypeError: when the class doesn't have a `run` method as per spec. - """ - run_method = None - for name, val in inspect.getmembers(cls): - if name == "run" and inspect.iscoroutinefunction(val): - run_method = val - break - if run_method is None: - raise TypeError( - "The `@actor` decorator can only be applied to a class " - "that has an async `run` method." - ) - run_params = inspect.signature(run_method).parameters - num_params = len(run_params) - if num_params != 1: - raise TypeError( - f"The `run` method in {cls.__name__} must have this signature: " - "`async def run(self)`" - ) - - -class BaseActor: - """Base class to provide common attributes for all actors.""" - - # None is unlimited, 0 is no restarts. After restarts are exhausted the - # exception will be re-raised. - restart_limit: Optional[int] = None - - -_P = ParamSpec("_P") -_R = TypeVar("_R") - - -def actor(cls: Callable[_P, _R]) -> Type[_R]: - """Decorate a class into a simple composable actor. - - A actor using the `actor` decorator should define an `async def run(self)` - method, that loops over incoming data, and sends results out. - - Channels can be used to implement communication between actors, as shown in - the examples below. - - Args: - cls: the class to decorate. - - Returns: - The decorated class. - - Raises: - TypeError: when the class doesn't have a `run` method as per spec. - - Example (one actor receiving from two receivers): - ```python - from frequenz.channels import Broadcast, Receiver, Sender - from frequenz.channels.util import select, selected_from - @actor - class EchoActor: - def __init__( - self, - name: str, - recv1: Receiver[bool], - recv2: Receiver[bool], - output: Sender[bool], - ) -> None: - self.name = name - - self._recv1 = recv1 - self._recv2 = recv2 - self._output = output - - async def run(self) -> None: - async for selected in select(self._recv1, self._recv2): - if selected_from(selected, self._recv1): - await self._output.send(selected.value) - - input_chan_1: Broadcast[bool] = Broadcast("input_chan_1") - input_chan_2: Broadcast[bool] = Broadcast("input_chan_2") - - echo_chan: Broadcast[bool] = Broadcast("EchoChannel") - - echo_actor = EchoActor( - "EchoActor", - recv1=input_chan_1.new_receiver(), - recv2=input_chan_2.new_receiver(), - output=echo_chan.new_sender(), - ) - echo_rx = echo_chan.new_receiver() - - await input_chan_2.new_sender().send(True) - received_msg = await echo_rx.receive() - ``` - - Example (two Actors composed): - ```python - from frequenz.channels import Broadcast, Receiver, Sender - @actor - class Actor1: - def __init__( - self, - name: str, - recv: Receiver[bool], - output: Sender[bool], - ) -> None: - self.name = name - self._recv = recv - self._output = output - - async def run(self) -> None: - async for msg in self._recv: - await self._output.send(msg) - - - @actor - class Actor2: - def __init__( - self, - name: str, - recv: Receiver[bool], - output: Sender[bool], - ) -> None: - self.name = name - self._recv = recv - self._output = output - - async def run(self) -> None: - async for msg in self._recv: - await self._output.send(msg) - - input_chan: Broadcast[bool] = Broadcast("Input to A1") - a1_chan: Broadcast[bool] = Broadcast("A1 stream") - a2_chan: Broadcast[bool] = Broadcast("A2 stream") - a_1 = Actor1( - name="ActorOne", - recv=input_chan.new_receiver(), - output=a1_chan.new_sender(), - ) - a_2 = Actor2( - name="ActorTwo", - recv=a1_chan.new_receiver(), - output=a2_chan.new_sender(), - ) - - a2_rx = a2_chan.new_receiver() - - await input_chan.new_sender().send(True) - received_msg = await a2_rx.receive() - ``` - - """ - if not inspect.isclass(cls): - raise TypeError("The `@actor` decorator can only be applied for classes.") - - _check_run_method_exists(cls) - - class ActorClass(cls, BaseActor): # type: ignore - """A wrapper class to make an actor.""" - - def __init__(self, *args: _P.args, **kwargs: _P.kwargs) -> None: - """Create an `ActorClass` instance. - - Also call __init__ on `cls`. - - Args: - *args: Any positional arguments to `cls.__init__`. - **kwargs: Any keyword arguments to `cls.__init__`. - """ - super().__init__(*args, **kwargs) - self._actor_task = asyncio.create_task(self._start_actor()) - - async def _start_actor(self) -> None: - """Run the main logic of the actor as a coroutine. - - Raises: - asyncio.CancelledError: when the actor's task gets cancelled. - """ - _logger.debug("Starting actor: %s", cls.__name__) - number_of_restarts = 0 - while ( - self.restart_limit is None or number_of_restarts <= self.restart_limit - ): - if number_of_restarts > 0: - _logger.info("Restarting actor: %s", cls.__name__) - - try: - await super().run() - except asyncio.CancelledError: - _logger.debug("Cancelling actor: %s", cls.__name__) - raise - except Exception: # pylint: disable=broad-except - _logger.exception("Actor (%s) crashed", cls.__name__) - finally: - number_of_restarts += 1 - - _logger.info("Shutting down actor: %s", cls.__name__) - - async def _stop(self) -> None: - """Stop an running actor.""" - await cancel_and_await(self._actor_task) - - async def join(self) -> None: - """Await the actor's task, and return when the task completes.""" - await self._actor_task - - return ActorClass diff --git a/src/frequenz/sdk/actor/_resampling.py b/src/frequenz/sdk/actor/_resampling.py index d5dae88f5..c669a0a6f 100644 --- a/src/frequenz/sdk/actor/_resampling.py +++ b/src/frequenz/sdk/actor/_resampling.py @@ -15,15 +15,14 @@ from ..timeseries import Sample from ..timeseries._quantities import Quantity from ..timeseries._resampling import Resampler, ResamplerConfig, ResamplingError +from ._actor import Actor from ._channel_registry import ChannelRegistry from ._data_sourcing import ComponentMetricRequest -from ._decorator import actor _logger = logging.getLogger(__name__) -@actor -class ComponentMetricsResamplingActor: +class ComponentMetricsResamplingActor(Actor): """An actor to resample microgrid component metrics.""" def __init__( # pylint: disable=too-many-arguments @@ -33,6 +32,7 @@ def __init__( # pylint: disable=too-many-arguments data_sourcing_request_sender: Sender[ComponentMetricRequest], resampling_request_receiver: Receiver[ComponentMetricRequest], config: ResamplerConfig, + name: str | None = None, ) -> None: """Initialize an instance. @@ -45,7 +45,10 @@ def __init__( # pylint: disable=too-many-arguments resampling_request_receiver: The receiver to use to receive new resampmling subscription requests. config: The configuration for the resampler. + name: The name of the actor. If `None`, `str(id(self))` will be used. This + is used mostly for debugging purposes. """ + super().__init__(name=name) self._channel_registry: ChannelRegistry = channel_registry self._data_sourcing_request_sender: Sender[ ComponentMetricRequest @@ -79,7 +82,7 @@ async def _subscribe(self, request: ComponentMetricRequest) -> None: # This is a temporary hack until the Sender implementation uses # exceptions to report errors. - sender = self._channel_registry.new_sender(request.get_channel_name()) + sender = self._channel_registry.new_sender(request_channel_name) async def sink_adapter(sample: Sample[Quantity]) -> None: await sender.send(sample) @@ -91,7 +94,7 @@ async def _process_resampling_requests(self) -> None: async for request in self._resampling_request_receiver: await self._subscribe(request) - async def run(self) -> None: + async def _run(self) -> None: """Resample known component metrics and process resampling requests. If there is a resampling error while resampling some component metric, @@ -99,11 +102,16 @@ async def run(self) -> None: other error will be propagated (most likely ending in the actor being restarted). + This method creates 2 main tasks: + + - One task to process incoming subscription requests to resample new metrics. + - One task to run the resampler. + Raises: RuntimeError: If there is some unexpected error while resampling or handling requests. - # noqa: DAR401 error + [//]: # (# noqa: DAR401 error) """ tasks_to_cancel: set[asyncio.Task[None]] = set() try: diff --git a/src/frequenz/sdk/actor/_run_utils.py b/src/frequenz/sdk/actor/_run_utils.py index 434eb4b1a..db5b0e449 100644 --- a/src/frequenz/sdk/actor/_run_utils.py +++ b/src/frequenz/sdk/actor/_run_utils.py @@ -6,32 +6,39 @@ import asyncio import logging -from typing import Any -from ._decorator import BaseActor +from ._actor import Actor _logger = logging.getLogger(__name__) -async def run(*actors: Any) -> None: +async def run(*actors: Actor) -> None: """Await the completion of all actors. Args: actors: the actors to be awaited. - - Raises: - AssertionError: if any of the actors is not an instance of BaseActor. """ - # Check that each actor is an instance of BaseActor at runtime, - # due to the indirection created by the actor decorator. - for actor in actors: - assert isinstance(actor, BaseActor), f"{actor} is not an instance of BaseActor" + _logger.info("Starting %s actor(s)...", len(actors)) + await _wait_tasks( + set(asyncio.create_task(a.start(), name=str(a)) for a in actors), + "starting", + "started", + ) + + # Wait until all actors are done + await _wait_tasks( + set(asyncio.create_task(a.wait(), name=str(a)) for a in actors), + "running", + "finished", + ) + + _logger.info("All %s actor(s) finished.", len(actors)) - pending_tasks = set() - for actor in actors: - pending_tasks.add(asyncio.create_task(actor.join(), name=str(actor))) - # Currently the actor decorator manages the life-cycle of the actor tasks +async def _wait_tasks( + tasks: set[asyncio.Task[None]], error_str: str, success_str: str +) -> None: + pending_tasks = tasks while pending_tasks: done_tasks, pending_tasks = await asyncio.wait( pending_tasks, return_when=asyncio.FIRST_COMPLETED @@ -42,12 +49,19 @@ async def run(*actors: Any) -> None: # Cancellation needs to be checked first, otherwise the other methods # could raise a CancelledError if task.cancelled(): - _logger.info("The actor %s was cancelled", task.get_name()) + _logger.info( + "Actor %s: Cancelled while %s.", + task.get_name(), + error_str, + ) elif exception := task.exception(): _logger.error( - "The actor %s was finished due to an uncaught exception", + "Actor %s: Raised an exception while %s.", task.get_name(), + error_str, exc_info=exception, ) else: - _logger.info("The actor %s finished normally", task.get_name()) + _logger.info( + "Actor %s: %s normally.", task.get_name(), success_str.capitalize() + ) diff --git a/src/frequenz/sdk/actor/power_distributing/__init__.py b/src/frequenz/sdk/actor/power_distributing/__init__.py index 9926ca1b7..a1ba4b08a 100644 --- a/src/frequenz/sdk/actor/power_distributing/__init__.py +++ b/src/frequenz/sdk/actor/power_distributing/__init__.py @@ -13,7 +13,7 @@ from ._battery_pool_status import BatteryStatus from .power_distributing import PowerDistributingActor from .request import Request -from .result import Error, OutOfBound, PartialFailure, Result, Success +from .result import Error, OutOfBounds, PartialFailure, Result, Success __all__ = [ "PowerDistributingActor", @@ -21,7 +21,7 @@ "Result", "Error", "Success", - "OutOfBound", + "OutOfBounds", "PartialFailure", "BatteryStatus", ] diff --git a/src/frequenz/sdk/actor/power_distributing/_battery_status.py b/src/frequenz/sdk/actor/power_distributing/_battery_status.py index c18b7cb1e..4b3ebd2e5 100644 --- a/src/frequenz/sdk/actor/power_distributing/_battery_status.py +++ b/src/frequenz/sdk/actor/power_distributing/_battery_status.py @@ -12,16 +12,19 @@ from enum import Enum from typing import Iterable, Optional, Set, TypeVar, Union +# pylint: disable=no-name-in-module from frequenz.api.microgrid.battery_pb2 import ComponentState as BatteryComponentState from frequenz.api.microgrid.battery_pb2 import RelayState as BatteryRelayState from frequenz.api.microgrid.common_pb2 import ErrorLevel from frequenz.api.microgrid.inverter_pb2 import ComponentState as InverterComponentState + +# pylint: enable=no-name-in-module from frequenz.channels import Receiver, Sender from frequenz.channels.util import Timer, select, selected_from -from frequenz.sdk._internal._asyncio import cancel_and_await -from frequenz.sdk.microgrid import connection_manager -from frequenz.sdk.microgrid.component import ( +from ..._internal._asyncio import cancel_and_await +from ...microgrid import connection_manager +from ...microgrid.component import ( BatteryData, ComponentCategory, ComponentData, @@ -81,6 +84,10 @@ class _BlockingStatus: max_duration_sec: float def __post_init__(self) -> None: + assert self.min_duration_sec <= self.max_duration_sec, ( + f"Minimum blocking duration ({self.min_duration_sec}) cannot be greater " + f"than maximum blocking duration ({self.max_duration_sec})" + ) self.last_blocking_duration_sec: float = self.min_duration_sec self.blocked_until: Optional[datetime] = None @@ -335,9 +342,25 @@ async def _run( self._handle_status_set_power_result(selected.value) elif selected_from(selected, battery_timer): + if ( + datetime.now(tz=timezone.utc) + - self._battery.last_msg_timestamp + ) < timedelta(seconds=self._max_data_age): + # This means that we have received data from the battery + # since the timer triggered, but the timer event arrived + # late, so we can ignore it. + continue self._handle_status_battery_timer() elif selected_from(selected, inverter_timer): + if ( + datetime.now(tz=timezone.utc) + - self._inverter.last_msg_timestamp + ) < timedelta(seconds=self._max_data_age): + # This means that we have received data from the inverter + # since the timer triggered, but the timer event arrived + # late, so we can ignore it. + continue self._handle_status_inverter_timer() else: diff --git a/src/frequenz/sdk/power/__init__.py b/src/frequenz/sdk/actor/power_distributing/_distribution_algorithm/__init__.py similarity index 100% rename from src/frequenz/sdk/power/__init__.py rename to src/frequenz/sdk/actor/power_distributing/_distribution_algorithm/__init__.py diff --git a/src/frequenz/sdk/power/_distribution_algorithm.py b/src/frequenz/sdk/actor/power_distributing/_distribution_algorithm/_distribution_algorithm.py similarity index 70% rename from src/frequenz/sdk/power/_distribution_algorithm.py rename to src/frequenz/sdk/actor/power_distributing/_distribution_algorithm/_distribution_algorithm.py index d90feea0e..01a16b786 100644 --- a/src/frequenz/sdk/power/_distribution_algorithm.py +++ b/src/frequenz/sdk/actor/power_distributing/_distribution_algorithm/_distribution_algorithm.py @@ -4,12 +4,12 @@ """Power distribution algorithm to distribute power between batteries.""" import logging +import math from dataclasses import dataclass from typing import Dict, List, NamedTuple, Tuple -from frequenz.sdk._internal._math import is_close_to_zero - -from ..microgrid.component import BatteryData, InverterData +from ...._internal._math import is_close_to_zero +from ....microgrid.component import BatteryData, InverterData _logger = logging.getLogger(__name__) @@ -146,39 +146,16 @@ def __init__(self, distributor_exponent: float = 1) -> None: * `Bat1.available_soc = 10`, `Bat2.available_soc = 30` * `Bat1.available_soc / Bat2.available_soc = 3` - We need to distribute 8000W. + A request power of 8000W will be distributed as follows, for different + values of `distribution_exponent`: - If `distribution_exponent` is: + | distribution_exponent | Bat1 | Bat2 | + |-----------------------|------|------| + | 0 | 4000 | 4000 | + | 1 | 2000 | 6000 | + | 2 | 800 | 7200 | + | 3 | 285 | 7715 | - * `0`: distribution for each battery will be the equal. - ```python - BAT1_DISTRIBUTION = 4000 - BAT2_DISTRIBUTION = 4000 - ``` - - * `1`: then `Bat2` will have 3x more power assigned then `Bat1`. - ```python - # 10 * x + 30 * x = 8000 - X = 200 - BAT1_DISTRIBUTION = 2000 - BAT2_DISTRIBUTION = 6000 - ``` - - * `2`: then `Bat2` will have 9x more power assigned then `Bat1`. - ```python - # 10^2 * x + 30^2 * x = 8000 - X = 80 - BAT1_DISTRIBUTION = 800 - BAT2_DISTRIBUTION = 7200 - ``` - - * `3`: then `Bat2` will have 27x more power assigned then `Bat1`. - ```python - # 10^3 * x + 30^3 * x = 8000 - X = 0.285714286 - BAT1_DISTRIBUTION = 285 - BAT2_DISTRIBUTION = 7715 - ``` # Example 2 @@ -188,39 +165,15 @@ def __init__(self, distributor_exponent: float = 1) -> None: * `Bat1.available_soc = 30`, `Bat2.available_soc = 60` * `Bat1.available_soc / Bat2.available_soc = 2` - We need to distribute 900W. - - If `distribution_exponent` is: + A request power of 900W will be distributed as follows, for different + values of `distribution_exponent`. - * `0`: distribution for each battery will be the same. - ```python - BAT1_DISTRIBUTION = 4500 - BAT2_DISTRIBUTION = 450 - ``` - - * `1`: then `Bat2` will have 2x more power assigned then `Bat1`. - ```python - # 30 * x + 60 * x = 900 - X = 100 - BAT1_DISTRIBUTION = 300 - BAT2_DISTRIBUTION = 600 - ``` - - * `2`: then `Bat2` will have 4x more power assigned then `Bat1`. - ```python - # 30^2 * x + 60^2 * x = 900 - X = 0.2 - BAT1_DISTRIBUTION = 180 - BAT2_DISTRIBUTION = 720 - ``` - - * `3`: then `Bat2` will have 8x more power assigned then `Bat1`. - ```python - # 30^3 * x + 60^3 * x = 900 - X = 0.003703704 - BAT1_DISTRIBUTION = 100 - BAT2_DISTRIBUTION = 800 - ``` + | distribution_exponent | Bat1 | Bat2 | + |-----------------------|------|------| + | 0 | 450 | 450 | + | 1 | 300 | 600 | + | 2 | 180 | 720 | + | 3 | 100 | 800 | # Example 3 @@ -229,26 +182,19 @@ def __init__(self, distributor_exponent: float = 1) -> None: * `Bat1.soc = 44` and `Bat2.soc = 64`. * `Bat1.available_soc = 36 (80 - 44)`, `Bat2.available_soc = 16 (80 - 64)` - We need to distribute 900W. + A request power of 900W will be distributed as follows, for these values of + `distribution_exponent`: If `distribution_exponent` is: - * `0`: distribution for each battery will be the equal. - ```python - BAT1_DISTRIBUTION = 450 - BAT2_DISTRIBUTION = 450 - ``` - - * `0.5`: then `Bat2` will have 6/4x more power assigned then `Bat1`. - ```python - # sqrt(36) * x + sqrt(16) * x = 900 - X = 100 - BAT1_DISTRIBUTION = 600 - BAT2_DISTRIBUTION = 400 - ``` + | distribution_exponent | Bat1 | Bat2 | + |-----------------------|------|------| + | 0 | 450 | 450 | + | 0.5 | 600 | 400 | Raises: ValueError: If distributor_exponent < 0 + """ super().__init__() @@ -277,8 +223,11 @@ def _total_capacity(self, components: List[InvBatPair]) -> float: return total_capacity def _compute_battery_availability_ratio( - self, components: List[InvBatPair], available_soc: Dict[int, float] - ) -> Tuple[List[Tuple[InvBatPair, float]], float]: + self, + components: List[InvBatPair], + available_soc: Dict[int, float], + excl_bounds: Dict[int, float], + ) -> Tuple[List[Tuple[InvBatPair, float, float]], float]: r"""Compute battery ratio and the total sum of all of them. battery_availability_ratio = capacity_ratio[i] * available_soc[i] @@ -291,6 +240,7 @@ def _compute_battery_availability_ratio( available_soc: How much SoC remained to reach * SoC upper bound - if need to distribute consumption power * SoC lower bound - if need to distribute supply power + excl_bounds: Exclusion bounds for each inverter Returns: Tuple where first argument is battery availability ratio for each @@ -299,32 +249,37 @@ def _compute_battery_availability_ratio( of all battery ratios in the list. """ total_capacity = self._total_capacity(components) - battery_availability_ratio: List[Tuple[InvBatPair, float]] = [] + battery_availability_ratio: List[Tuple[InvBatPair, float, float]] = [] total_battery_availability_ratio: float = 0.0 for pair in components: - battery = pair[0] + battery, inverter = pair capacity_ratio = battery.capacity / total_capacity soc_factor = pow( available_soc[battery.component_id], self._distributor_exponent ) ratio = capacity_ratio * soc_factor - battery_availability_ratio.append((pair, ratio)) + battery_availability_ratio.append( + (pair, excl_bounds[inverter.component_id], ratio) + ) total_battery_availability_ratio += ratio - battery_availability_ratio.sort(key=lambda item: item[1], reverse=True) + battery_availability_ratio.sort( + key=lambda item: (item[1], item[2]), reverse=True + ) return battery_availability_ratio, total_battery_availability_ratio - def _distribute_power( + def _distribute_power( # pylint: disable=too-many-arguments self, components: List[InvBatPair], power_w: float, available_soc: Dict[int, float], - upper_bounds: Dict[int, float], + incl_bounds: Dict[int, float], + excl_bounds: Dict[int, float], ) -> DistributionResult: - # pylint: disable=too-many-locals + # pylint: disable=too-many-locals,too-many-branches,too-many-statements """Distribute power between given components. After this method power should be distributed between batteries @@ -336,9 +291,8 @@ def _distribute_power( available_soc: how much SoC remained to reach: * SoC upper bound - if need to distribute consumption power * SoC lower bound - if need to distribute supply power - upper_bounds: Min between upper bound of each pair in the components list: - * supply upper bound - if need to distribute consumption power - * consumption lower bound - if need to distribute supply power + incl_bounds: Inclusion bounds for each inverter + excl_bounds: Exclusion bounds for each inverter Returns: Distribution result. @@ -346,20 +300,24 @@ def _distribute_power( ( battery_availability_ratio, sum_ratio, - ) = self._compute_battery_availability_ratio(components, available_soc) + ) = self._compute_battery_availability_ratio( + components, available_soc, excl_bounds + ) distribution: Dict[int, float] = {} - # sum_ratio == 0 means that all batteries are fully charged / discharged if is_close_to_zero(sum_ratio): distribution = {inverter.component_id: 0 for _, inverter in components} return DistributionResult(distribution, power_w) distributed_power: float = 0.0 + reserved_power: float = 0.0 power_to_distribute: float = power_w used_ratio: float = 0.0 ratio = sum_ratio - for pair, battery_ratio in battery_availability_ratio: + excess_reserved: dict[int, float] = {} + deficits: dict[int, float] = {} + for pair, excl_bound, battery_ratio in battery_availability_ratio: inverter = pair[1] # ratio = 0, means all remaining batteries reach max SoC lvl or have no # capacity @@ -367,26 +325,57 @@ def _distribute_power( distribution[inverter.component_id] = 0.0 continue - distribution[inverter.component_id] = ( - power_to_distribute * battery_ratio / ratio - ) - + power_to_distribute = power_w - reserved_power + calculated_power = power_to_distribute * battery_ratio / ratio + reserved_power += max(calculated_power, excl_bound) used_ratio += battery_ratio - + ratio = sum_ratio - used_ratio # If the power allocated for that inverter is out of bound, # then we need to distribute more power over all remaining batteries. - upper_bound = upper_bounds[inverter.component_id] - if distribution[inverter.component_id] > upper_bound: - distribution[inverter.component_id] = upper_bound - distributed_power += upper_bound - # Distribute only the remaining power. - power_to_distribute = power_w - distributed_power - # Distribute between remaining batteries - ratio = sum_ratio - used_ratio + incl_bound = incl_bounds[inverter.component_id] + if calculated_power > incl_bound: + excess_reserved[inverter.component_id] = incl_bound - excl_bound + # # Distribute between remaining batteries + elif calculated_power < excl_bound: + deficits[inverter.component_id] = calculated_power - excl_bound else: - distributed_power += distribution[inverter.component_id] + excess_reserved[inverter.component_id] = calculated_power - excl_bound + + distributed_power += excl_bound + distribution[inverter.component_id] = excl_bound + + for inverter_id, deficit in deficits.items(): + while not is_close_to_zero(deficit) and deficit < 0.0: + if not excess_reserved: + break + take_from = max(excess_reserved.items(), key=lambda item: item[1]) + if is_close_to_zero(take_from[1]) or take_from[1] < 0.0: + break + if take_from[1] >= -deficit or math.isclose(take_from[1], -deficit): + excess_reserved[take_from[0]] += deficit + deficits[inverter_id] = 0.0 + deficit = 0.0 + else: + deficit += excess_reserved[take_from[0]] + deficits[inverter_id] = deficit + excess_reserved[take_from[0]] = 0.0 + if deficit < -0.1: + left_over = power_w - distributed_power + if left_over > -deficit: + distributed_power += deficit + elif left_over > 0.0: + distributed_power += left_over + + for inverter_id, excess in excess_reserved.items(): + distribution[inverter_id] += excess + distributed_power += excess + + left_over = power_w - distributed_power + dist = DistributionResult(distribution, left_over) - return DistributionResult(distribution, power_w - distributed_power) + return self._greedy_distribute_remaining_power( + dist.distribution, incl_bounds, dist.remaining_power + ) def _greedy_distribute_remaining_power( self, @@ -487,19 +476,21 @@ def _distribute_consume_power( 0.0, battery.soc_upper_bound - battery.soc ) - bounds: Dict[int, float] = {} + incl_bounds: Dict[int, float] = {} + excl_bounds: Dict[int, float] = {} for battery, inverter in components: # We can supply/consume with int only - inverter_bound = inverter.active_power_upper_bound - battery_bound = battery.power_upper_bound - bounds[inverter.component_id] = min(inverter_bound, battery_bound) - - result: DistributionResult = self._distribute_power( - components, power_w, available_soc, bounds - ) + incl_bounds[inverter.component_id] = min( + inverter.active_power_inclusion_upper_bound, + battery.power_inclusion_upper_bound, + ) + excl_bounds[inverter.component_id] = max( + inverter.active_power_exclusion_upper_bound, + battery.power_exclusion_upper_bound, + ) - return self._greedy_distribute_remaining_power( - result.distribution, bounds, result.remaining_power + return self._distribute_power( + components, power_w, available_soc, incl_bounds, excl_bounds ) def _distribute_supply_power( @@ -525,19 +516,20 @@ def _distribute_supply_power( 0.0, battery.soc - battery.soc_lower_bound ) - bounds: Dict[int, float] = {} + incl_bounds: Dict[int, float] = {} + excl_bounds: Dict[int, float] = {} for battery, inverter in components: - # We can consume with int only - inverter_bound = inverter.active_power_lower_bound - battery_bound = battery.power_lower_bound - bounds[inverter.component_id] = -1 * max(inverter_bound, battery_bound) + incl_bounds[inverter.component_id] = -1 * max( + inverter.active_power_inclusion_lower_bound, + battery.power_inclusion_lower_bound, + ) + excl_bounds[inverter.component_id] = -1 * min( + inverter.active_power_exclusion_lower_bound, + battery.power_exclusion_lower_bound, + ) result: DistributionResult = self._distribute_power( - components, -1 * power_w, available_soc, bounds - ) - - result = self._greedy_distribute_remaining_power( - result.distribution, bounds, result.remaining_power + components, -1 * power_w, available_soc, incl_bounds, excl_bounds ) for inverter_id in result.distribution.keys(): diff --git a/src/frequenz/sdk/actor/power_distributing/power_distributing.py b/src/frequenz/sdk/actor/power_distributing/power_distributing.py index e4dad9e38..ebc36d885 100644 --- a/src/frequenz/sdk/actor/power_distributing/power_distributing.py +++ b/src/frequenz/sdk/actor/power_distributing/power_distributing.py @@ -26,8 +26,9 @@ import grpc from frequenz.channels import Peekable, Receiver, Sender +from ..._internal._math import is_close_to_zero from ...actor import ChannelRegistry -from ...actor._decorator import actor +from ...actor._actor import Actor from ...microgrid import ComponentGraph, connection_manager from ...microgrid.client import MicrogridApiClient from ...microgrid.component import ( @@ -36,10 +37,14 @@ ComponentCategory, InverterData, ) -from ...power import DistributionAlgorithm, DistributionResult, InvBatPair from ._battery_pool_status import BatteryPoolStatus, BatteryStatus +from ._distribution_algorithm import ( + DistributionAlgorithm, + DistributionResult, + InvBatPair, +) from .request import Request -from .result import Error, OutOfBound, PartialFailure, Result, Success +from .result import Error, OutOfBounds, PartialFailure, PowerBounds, Result, Success _logger = logging.getLogger(__name__) @@ -78,8 +83,7 @@ def has_expired(self) -> bool: return time.monotonic_ns() >= self.expiry_time -@actor -class PowerDistributingActor: +class PowerDistributingActor(Actor): # pylint: disable=too-many-instance-attributes """Actor to distribute the power between batteries in a microgrid. @@ -103,76 +107,6 @@ class PowerDistributingActor: overlap (they have at least one common battery), then then both batteries will be processed. However it is not expected so the proper error log will be printed. - - Example: - ```python - from frequenz.sdk import microgrid - from frequenz.sdk.microgrid.component import ComponentCategory - from frequenz.sdk.actor import ResamplerConfig - from frequenz.sdk.actor.power_distributing import ( - PowerDistributingActor, - Request, - Result, - Success, - Error, - PartialFailure, - Ignored, - ) - from frequenz.channels import Broadcast, Receiver, Sender - from datetime import timedelta - from frequenz.sdk import actor - - HOST = "localhost" - PORT = 50051 - - await microgrid.initialize( - HOST, - PORT, - ResamplerConfig(resampling_period=timedelta(seconds=1)) - ) - - graph = microgrid.connection_manager.get().component_graph - - batteries = graph.components(component_category={ComponentCategory.BATTERY}) - batteries_ids = {c.component_id for c in batteries} - - battery_status_channel = Broadcast[BatteryStatus]("battery-status") - - channel = Broadcast[Request]("power_distributor") - channel_registry = ChannelRegistry(name="power_distributor") - power_distributor = PowerDistributingActor( - requests_receiver=channel.new_receiver(), - channel_registry=channel_registry, - battery_status_sender=battery_status_channel.new_sender(), - ) - - sender = channel.new_sender() - namespace: str = "namespace" - # Set power 1200W to given batteries. - request = Request( - namespace=namespace, - power=1200.0, - batteries=batteries_ids, - request_timeout_sec=10.0 - ) - await sender.send(request) - result_rx = channel_registry.new_receiver(namespace) - - # It is recommended to use timeout when waiting for the response! - result: Result = await asyncio.wait_for(result_rx.receive(), timeout=10) - - if isinstance(result, Success): - print("Command succeed") - elif isinstance(result, PartialFailure): - print( - f"Batteries {result.failed_batteries} failed, total failed power" \ - f"{result.failed_power}" - ) - elif isinstance(result, Ignored): - print("Request was ignored, because of newer request") - elif isinstance(result, Error): - print(f"Request failed with error: {result.msg}") - ``` """ def __init__( @@ -181,6 +115,8 @@ def __init__( channel_registry: ChannelRegistry, battery_status_sender: Sender[BatteryStatus], wait_for_data_sec: float = 2, + *, + name: str | None = None, ) -> None: """Create class instance. @@ -192,7 +128,10 @@ def __init__( working. wait_for_data_sec: How long actor should wait before processing first request. It is a time needed to collect first components data. + name: The name of the actor. If `None`, `str(id(self))` will be used. This + is used mostly for debugging purposes. """ + super().__init__(name=name) self._requests_receiver = requests_receiver self._channel_registry = channel_registry self._wait_for_data_sec = wait_for_data_sec @@ -226,48 +165,47 @@ def __init__( bat_id: None for bat_id, _ in self._bat_inv_map.items() } - def _get_upper_bound(self, batteries: abc.Set[int], include_broken: bool) -> float: - """Get total upper bound of power to be set for given batteries. - - Note, output of that function doesn't guarantee that this bound will be - the same when the request is processed. - - Args: - batteries: List of batteries - include_broken: whether all batteries in the batteries set in the - request must be used regardless the status. - - Returns: - Upper bound for `set_power` operation. - """ - pairs_data: List[InvBatPair] = self._get_components_data( - batteries, include_broken - ) - return sum( - min(battery.power_upper_bound, inverter.active_power_upper_bound) - for battery, inverter in pairs_data - ) - - def _get_lower_bound(self, batteries: abc.Set[int], include_broken: bool) -> float: - """Get total lower bound of power to be set for given batteries. - - Note, output of that function doesn't guarantee that this bound will be - the same when the request is processed. + def _get_bounds( + self, + pairs_data: list[InvBatPair], + ) -> PowerBounds: + """Get power bounds for given batteries. Args: - batteries: List of batteries - include_broken: whether all batteries in the batteries set in the - request must be used regardless the status. + pairs_data: list of battery and adjacent inverter data pairs. Returns: - Lower bound for `set_power` operation. + Power bounds for given batteries. """ - pairs_data: List[InvBatPair] = self._get_components_data( - batteries, include_broken - ) - return sum( - max(battery.power_lower_bound, inverter.active_power_lower_bound) - for battery, inverter in pairs_data + return PowerBounds( + inclusion_lower=sum( + max( + battery.power_inclusion_lower_bound, + inverter.active_power_inclusion_lower_bound, + ) + for battery, inverter in pairs_data + ), + inclusion_upper=sum( + min( + battery.power_inclusion_upper_bound, + inverter.active_power_inclusion_upper_bound, + ) + for battery, inverter in pairs_data + ), + exclusion_lower=sum( + min( + battery.power_exclusion_lower_bound, + inverter.active_power_exclusion_lower_bound, + ) + for battery, inverter in pairs_data + ), + exclusion_upper=sum( + max( + battery.power_exclusion_upper_bound, + inverter.active_power_exclusion_upper_bound, + ) + for battery, inverter in pairs_data + ), ) async def _send_result(self, namespace: str, result: Result) -> None: @@ -284,7 +222,7 @@ async def _send_result(self, namespace: str, result: Result) -> None: await self._result_senders[namespace].send(result) - async def run(self) -> None: + async def _run(self) -> None: """Run actor main function. It waits for new requests in task_queue and process it, and send @@ -301,11 +239,6 @@ async def run(self) -> None: await asyncio.sleep(self._wait_for_data_sec) async for request in self._requests_receiver: - error = self._check_request(request) - if error: - await self._send_result(request.namespace, error) - continue - try: pairs_data: List[InvBatPair] = self._get_components_data( request.batteries, request.include_broken_batteries @@ -323,9 +256,15 @@ async def run(self) -> None: ) continue + error = self._check_request(request, pairs_data) + if error: + await self._send_result(request.namespace, error) + continue + try: distribution = self._get_power_distribution(request, pairs_data) except ValueError as err: + _logger.exception("Couldn't distribute power") error_msg = f"Couldn't distribute power, error: {str(err)}" await self._send_result( request.namespace, Error(request=request, msg=str(error_msg)) @@ -446,11 +385,16 @@ def _get_power_distribution( return result - def _check_request(self, request: Request) -> Optional[Result]: + def _check_request( + self, + request: Request, + pairs_data: List[InvBatPair], + ) -> Optional[Result]: """Check whether the given request if correct. Args: request: request to check + pairs_data: list of battery and adjacent inverter data pairs. Returns: Result for the user if the request is wrong, None otherwise. @@ -466,19 +410,30 @@ def _check_request(self, request: Request) -> Optional[Result]: ) return Error(request=request, msg=msg) - if not request.adjust_power: - if request.power < 0: - bound = self._get_lower_bound( - request.batteries, request.include_broken_batteries - ) - if request.power < bound: - return OutOfBound(request=request, bound=bound) - else: - bound = self._get_upper_bound( - request.batteries, request.include_broken_batteries - ) - if request.power > bound: - return OutOfBound(request=request, bound=bound) + bounds = self._get_bounds(pairs_data) + + # Zero power requests are always forwarded to the microgrid API, even if they + # are outside the exclusion bounds. + if is_close_to_zero(request.power): + return None + + if request.adjust_power: + # Automatic power adjustments can only bring down the requested power down + # to the inclusion bounds. + # + # If the requested power is in the exclusion bounds, it is NOT possible to + # increase it so that it is outside the exclusion bounds. + if bounds.exclusion_lower < request.power < bounds.exclusion_upper: + return OutOfBounds(request=request, bounds=bounds) + else: + in_lower_range = ( + bounds.inclusion_lower <= request.power <= bounds.exclusion_lower + ) + in_upper_range = ( + bounds.exclusion_upper <= request.power <= bounds.inclusion_upper + ) + if not (in_lower_range or in_upper_range): + return OutOfBounds(request=request, bounds=bounds) return None @@ -622,10 +577,10 @@ def _get_battery_inverter_data( return None replaceable_metrics = [ - battery_data.power_lower_bound, - battery_data.power_upper_bound, - inverter_data.active_power_lower_bound, - inverter_data.active_power_upper_bound, + battery_data.power_inclusion_lower_bound, + battery_data.power_inclusion_upper_bound, + inverter_data.active_power_inclusion_lower_bound, + inverter_data.active_power_inclusion_upper_bound, ] # If all values are ok then return them. @@ -637,8 +592,8 @@ def _get_battery_inverter_data( # Replace NaN with the corresponding value in the adjacent component. # If both metrics are None, return None to ignore this battery. replaceable_pairs = [ - ("power_lower_bound", "active_power_lower_bound"), - ("power_upper_bound", "active_power_upper_bound"), + ("power_inclusion_lower_bound", "active_power_inclusion_lower_bound"), + ("power_inclusion_upper_bound", "active_power_inclusion_upper_bound"), ] battery_new_metrics = {} @@ -737,7 +692,11 @@ async def _cancel_tasks(self, tasks: Iterable[asyncio.Task[Any]]) -> None: await asyncio.gather(*tasks, return_exceptions=True) - async def _stop_actor(self) -> None: - """Stop all running async tasks.""" + async def stop(self, msg: str | None = None) -> None: + """Stop this actor. + + Args: + msg: The message to be passed to the tasks being cancelled. + """ await self._all_battery_status.stop() - await self._stop() # type: ignore # pylint: disable=no-member + await super().stop(msg) diff --git a/src/frequenz/sdk/actor/power_distributing/result.py b/src/frequenz/sdk/actor/power_distributing/result.py index ebc0b641d..21f22594f 100644 --- a/src/frequenz/sdk/actor/power_distributing/result.py +++ b/src/frequenz/sdk/actor/power_distributing/result.py @@ -78,15 +78,25 @@ class Error(Result): @dataclasses.dataclass -class OutOfBound(Result): +class PowerBounds: + """Inclusion and exclusion power bounds for requested batteries.""" + + inclusion_lower: float + exclusion_lower: float + exclusion_upper: float + inclusion_upper: float + + +@dataclasses.dataclass +class OutOfBounds(Result): """Result returned when the power was not set because it was out of bounds. This result happens when the originating request was done with `adjust_power = False` and the requested power is not within the batteries bounds. """ - bound: float - """The total power bound for the requested batteries. + bounds: PowerBounds + """The power bounds for the requested batteries. If the requested power negative, then this value is the lower bound. Otherwise it is upper bound. diff --git a/src/frequenz/sdk/microgrid/__init__.py b/src/frequenz/sdk/microgrid/__init__.py index 0d22eabbb..ad303f0d3 100644 --- a/src/frequenz/sdk/microgrid/__init__.py +++ b/src/frequenz/sdk/microgrid/__init__.py @@ -22,7 +22,7 @@ async def initialize(host: str, port: int, resampler_config: ResamplerConfig) -> resampler_config: Configuration for the resampling actor. """ await connection_manager.initialize(host, port) - _data_pipeline.initialize(resampler_config) + await _data_pipeline.initialize(resampler_config) __all__ = [ diff --git a/src/frequenz/sdk/microgrid/_data_pipeline.py b/src/frequenz/sdk/microgrid/_data_pipeline.py index d1825b7b0..f9fe2e53f 100644 --- a/src/frequenz/sdk/microgrid/_data_pipeline.py +++ b/src/frequenz/sdk/microgrid/_data_pipeline.py @@ -50,12 +50,14 @@ requests and will be able to keep up with higher request rates in larger installations. """ +_T = typing.TypeVar("_T") + @dataclass -class _ActorInfo: +class _ActorInfo(typing.Generic[_T]): """Holds instances of core data pipeline actors and their request channels.""" - actor: "DataSourcingActor | ComponentMetricsResamplingActor" + actor: _T channel: Broadcast["ComponentMetricRequest"] @@ -82,8 +84,10 @@ def __init__( self._channel_registry = ChannelRegistry(name="Data Pipeline Registry") - self._data_sourcing_actor: _ActorInfo | None = None - self._resampling_actor: _ActorInfo | None = None + self._data_sourcing_actor: _ActorInfo[DataSourcingActor] | None = None + self._resampling_actor: _ActorInfo[ + ComponentMetricsResamplingActor + ] | None = None self._battery_status_channel = Broadcast["BatteryStatus"]( "battery-status", resend_latest=True @@ -260,20 +264,29 @@ def _resampling_request_sender(self) -> Sender[ComponentMetricRequest]: self._resampling_actor = _ActorInfo(actor, channel) return self._resampling_actor.channel.new_sender() + async def _start(self) -> None: + """Start the data pipeline actors.""" + if self._data_sourcing_actor: + await self._data_sourcing_actor.actor.start() + if self._resampling_actor: + await self._resampling_actor.actor.start() + # The power distributing actor is started lazily when the first battery pool is + # created. + async def _stop(self) -> None: """Stop the data pipeline actors.""" - # pylint: disable=protected-access if self._data_sourcing_actor: - await self._data_sourcing_actor.actor._stop() # type: ignore + await self._data_sourcing_actor.actor.stop() if self._resampling_actor: - await self._resampling_actor.actor._stop() # type: ignore - # pylint: enable=protected-access + await self._resampling_actor.actor.stop() + if self._power_distributing_actor: + await self._power_distributing_actor.stop() _DATA_PIPELINE: _DataPipeline | None = None -def initialize(resampler_config: ResamplerConfig) -> None: +async def initialize(resampler_config: ResamplerConfig) -> None: """Initialize a `DataPipeline` instance. Args: @@ -287,6 +300,7 @@ def initialize(resampler_config: ResamplerConfig) -> None: if _DATA_PIPELINE is not None: raise RuntimeError("DataPipeline is already initialized.") _DATA_PIPELINE = _DataPipeline(resampler_config) + await _DATA_PIPELINE._start() # pylint: disable=protected-access def logical_meter() -> LogicalMeter: diff --git a/src/frequenz/sdk/microgrid/_graph.py b/src/frequenz/sdk/microgrid/_graph.py index ea4c197ed..e433fa566 100644 --- a/src/frequenz/sdk/microgrid/_graph.py +++ b/src/frequenz/sdk/microgrid/_graph.py @@ -266,6 +266,29 @@ def is_chp_chain(self, component: Component) -> bool: Whether the specified component is part of a CHP chain. """ + @abstractmethod + def dfs( + self, + current_node: Component, + visited: Set[Component], + condition: Callable[[Component], bool], + ) -> Set[Component]: + """ + Search for components that fulfill the condition in the Graph. + + DFS is used for searching the graph. The graph traversal is stopped + once a component fulfills the condition. + + Args: + current_node: The current node to search from. + visited: The set of visited nodes. + condition: The condition function to check for. + + Returns: + A set of component ids where the corresponding components fulfill + the condition function. + """ + class _MicrogridComponentGraph(ComponentGraph): """ComponentGraph implementation designed to work with the microgrid API. @@ -688,6 +711,42 @@ def is_chp_chain(self, component: Component) -> bool: """ return self.is_chp(component) or self.is_chp_meter(component) + def dfs( + self, + current_node: Component, + visited: Set[Component], + condition: Callable[[Component], bool], + ) -> Set[Component]: + """ + Search for components that fulfill the condition in the Graph. + + DFS is used for searching the graph. The graph travarsal is stopped + once a component fulfills the condition. + + Args: + current_node: The current node to search from. + visited: The set of visited nodes. + condition: The condition function to check for. + + Returns: + A set of component ids where the coresponding components fulfill + the condition function. + """ + if current_node in visited: + return set() + + visited.add(current_node) + + if condition(current_node): + return {current_node} + + component: Set[Component] = set() + + for successor in self.successors(current_node.component_id): + component.update(self.dfs(successor, visited, condition)) + + return component + def _validate_graph(self) -> None: """Check that the underlying graph data is valid. diff --git a/src/frequenz/sdk/microgrid/client/_client.py b/src/frequenz/sdk/microgrid/client/_client.py index 50b69e0ab..e581781b4 100644 --- a/src/frequenz/sdk/microgrid/client/_client.py +++ b/src/frequenz/sdk/microgrid/client/_client.py @@ -5,7 +5,6 @@ import asyncio import logging -import math from abc import ABC, abstractmethod from typing import ( Any, @@ -20,11 +19,11 @@ ) import grpc -from frequenz.api.microgrid import common_pb2 as common_pb +from frequenz.api.common import components_pb2 as components_pb +from frequenz.api.common import metrics_pb2 as metrics_pb from frequenz.api.microgrid import microgrid_pb2 as microgrid_pb from frequenz.api.microgrid.microgrid_pb2_grpc import MicrogridStub from frequenz.channels import Broadcast, Receiver, Sender -from google.protobuf.empty_pb2 import Empty # pylint: disable=no-name-in-module from ..._internal._constants import RECEIVER_MAX_SIZE from ..component import ( @@ -193,6 +192,9 @@ async def set_bounds(self, component_id: int, lower: float, upper: float) -> Non """ +# pylint: disable=no-member + + class MicrogridGrpcClient(MicrogridApiClient): """Microgrid API client implementation using gRPC as the underlying protocol.""" @@ -244,7 +246,7 @@ async def components(self) -> Iterable[Component]: ) components_only = filter( lambda c: c.category - is not microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_SENSOR, + is not components_pb.ComponentCategory.COMPONENT_CATEGORY_SENSOR, component_list.components, ) result: Iterable[Component] = map( @@ -340,7 +342,7 @@ async def _component_data_task( "Making call to `GetComponentData`, for component_id=%d", component_id ) try: - call = self.api.GetComponentData( + call = self.api.StreamComponentData( microgrid_pb.ComponentIdParam(id=component_id), ) # grpc.aio is missing types and mypy thinks this is not @@ -578,25 +580,12 @@ async def set_power(self, component_id: int, power_w: float) -> None: when the api call exceeded timeout """ try: - if power_w >= 0: - # grpc.aio is missing types and mypy thinks this is not - # async iterable, but it is - await self.api.Charge( - microgrid_pb.PowerLevelParam( - component_id=component_id, power_w=math.floor(power_w) - ), - timeout=DEFAULT_GRPC_CALL_TIMEOUT, # type: ignore[arg-type] - ) # type: ignore[misc] - else: - # grpc.aio is missing types and mypy thinks this is not - # async iterable, but it is - power_w *= -1 - await self.api.Discharge( - microgrid_pb.PowerLevelParam( - component_id=component_id, power_w=math.floor(power_w) - ), - timeout=DEFAULT_GRPC_CALL_TIMEOUT, # type: ignore[arg-type] - ) # type: ignore[misc] + await self.api.SetPowerActive( + microgrid_pb.SetPowerActiveParam( + component_id=component_id, power=power_w + ), + timeout=DEFAULT_GRPC_CALL_TIMEOUT, # type: ignore[arg-type] + ) # type: ignore[misc] except grpc.aio.AioRpcError as err: msg = f"Failed to set power. Microgrid API: {self.target}. Err: {err.details()}" raise grpc.aio.AioRpcError( @@ -632,20 +621,13 @@ async def set_bounds( if lower > 0: raise ValueError(f"Lower bound {upper} must be less than or equal to 0.") - # grpc.aio is missing types and mypy thinks request_iterator is - # a required argument, but it is not - set_bounds_call = self.api.SetBounds( - timeout=DEFAULT_GRPC_CALL_TIMEOUT, - ) # type: ignore[call-arg] try: - # grpc.aio is missing types and mypy thinks set_bounds_call can be Empty - assert not isinstance(set_bounds_call, Empty) - await set_bounds_call.write( + self.api.AddInclusionBounds( microgrid_pb.SetBoundsParam( component_id=component_id, # pylint: disable=no-member,line-too-long target_metric=microgrid_pb.SetBoundsParam.TargetMetric.TARGET_METRIC_POWER_ACTIVE, - bounds=common_pb.Bounds(lower=lower, upper=upper), + bounds=metrics_pb.Bounds(lower=lower, upper=upper), ), ) except grpc.aio.AioRpcError as err: diff --git a/src/frequenz/sdk/microgrid/component/_component.py b/src/frequenz/sdk/microgrid/component/_component.py index 131f25010..3a3c24673 100644 --- a/src/frequenz/sdk/microgrid/component/_component.py +++ b/src/frequenz/sdk/microgrid/component/_component.py @@ -9,14 +9,17 @@ from enum import Enum from typing import Optional +import frequenz.api.common.components_pb2 as components_pb import frequenz.api.microgrid.inverter_pb2 as inverter_pb -import frequenz.api.microgrid.microgrid_pb2 as microgrid_pb class ComponentType(Enum): """A base class from which individual component types are derived.""" +# pylint: disable=no-member + + class InverterType(ComponentType): """Enum representing inverter types.""" @@ -27,8 +30,8 @@ class InverterType(ComponentType): def _component_type_from_protobuf( - component_category: microgrid_pb.ComponentCategory.ValueType, - component_type: inverter_pb.Type.ValueType, + component_category: components_pb.ComponentCategory.ValueType, + component_metadata: inverter_pb.Metadata, ) -> Optional[ComponentType]: """Convert a protobuf InverterType message to Component enum. @@ -36,7 +39,7 @@ def _component_type_from_protobuf( Args: component_category: category the type belongs to. - component_type: protobuf enum to convert. + component_metadata: protobuf metadata to fetch type from. Returns: Enum value corresponding to the protobuf message. @@ -44,11 +47,18 @@ def _component_type_from_protobuf( # ComponentType values in the protobuf definition are not unique across categories # as of v0.11.0, so we need to check the component category first, before doing any # component type checks. - if component_category == microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_INVERTER: - if not any(t.value == component_type for t in InverterType): + if ( + component_category + == components_pb.ComponentCategory.COMPONENT_CATEGORY_INVERTER + ): + # mypy 1.4.1 crashes at this line, maybe it doesn't like the name of the "type" + # attribute in this context. Hence the "# type: ignore". + if not any( + t.value == component_metadata.type for t in InverterType # type: ignore + ): return None - return InverterType(component_type) + return InverterType(component_metadata.type) return None @@ -56,13 +66,13 @@ def _component_type_from_protobuf( class ComponentCategory(Enum): """Possible types of microgrid component.""" - NONE = microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_UNSPECIFIED - GRID = microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_GRID - METER = microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_METER - INVERTER = microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_INVERTER - BATTERY = microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_BATTERY - EV_CHARGER = microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_EV_CHARGER - CHP = microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_CHP + NONE = components_pb.ComponentCategory.COMPONENT_CATEGORY_UNSPECIFIED + GRID = components_pb.ComponentCategory.COMPONENT_CATEGORY_GRID + METER = components_pb.ComponentCategory.COMPONENT_CATEGORY_METER + INVERTER = components_pb.ComponentCategory.COMPONENT_CATEGORY_INVERTER + BATTERY = components_pb.ComponentCategory.COMPONENT_CATEGORY_BATTERY + EV_CHARGER = components_pb.ComponentCategory.COMPONENT_CATEGORY_EV_CHARGER + CHP = components_pb.ComponentCategory.COMPONENT_CATEGORY_CHP # types not yet supported by the API but which can be inferred # from available graph info @@ -70,7 +80,7 @@ class ComponentCategory(Enum): def _component_category_from_protobuf( - component_category: microgrid_pb.ComponentCategory.ValueType, + component_category: components_pb.ComponentCategory.ValueType, ) -> ComponentCategory: """Convert a protobuf ComponentCategory message to ComponentCategory enum. @@ -87,7 +97,7 @@ def _component_category_from_protobuf( a valid component category as it does not form part of the microgrid itself) """ - if component_category == microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_SENSOR: + if component_category == components_pb.ComponentCategory.COMPONENT_CATEGORY_SENSOR: raise ValueError("Cannot create a component from a sensor!") if not any(t.value == component_category for t in ComponentCategory): @@ -134,8 +144,14 @@ class ComponentMetricId(Enum): SOC_UPPER_BOUND = "soc_upper_bound" CAPACITY = "capacity" - POWER_LOWER_BOUND = "power_lower_bound" - POWER_UPPER_BOUND = "power_upper_bound" + POWER_INCLUSION_LOWER_BOUND = "power_inclusion_lower_bound" + POWER_EXCLUSION_LOWER_BOUND = "power_exclusion_lower_bound" + POWER_EXCLUSION_UPPER_BOUND = "power_exclusion_upper_bound" + POWER_INCLUSION_UPPER_BOUND = "power_inclusion_upper_bound" + + ACTIVE_POWER_INCLUSION_LOWER_BOUND = "active_power_inclusion_lower_bound" + ACTIVE_POWER_EXCLUSION_LOWER_BOUND = "active_power_exclusion_lower_bound" + ACTIVE_POWER_EXCLUSION_UPPER_BOUND = "active_power_exclusion_upper_bound" + ACTIVE_POWER_INCLUSION_UPPER_BOUND = "active_power_inclusion_upper_bound" - ACTIVE_POWER_LOWER_BOUND = "active_power_lower_bound" - ACTIVE_POWER_UPPER_BOUND = "active_power_upper_bound" + TEMPERATURE = "temperature" diff --git a/src/frequenz/sdk/microgrid/component/_component_data.py b/src/frequenz/sdk/microgrid/component/_component_data.py index cbeb73ab3..a3e29c3dc 100644 --- a/src/frequenz/sdk/microgrid/component/_component_data.py +++ b/src/frequenz/sdk/microgrid/component/_component_data.py @@ -130,19 +130,54 @@ class BatteryData(ComponentData): capacity: float """The capacity of the battery in Wh (Watt-hour).""" - power_lower_bound: float - """The maximum discharge power, in watts, represented in the passive sign - convention. this will be a negative number, or zero if no discharging is - possible. + # pylint: disable=line-too-long + power_inclusion_lower_bound: float + """Lower inclusion bound for battery power in watts. + + This is the lower limit of the range within which power requests are allowed for the + battery. + + See [`frequenz.api.common.metrics_pb2.Metric.system_inclusion_bounds`][] and + [`frequenz.api.common.metrics_pb2.Metric.system_exclusion_bounds`][] for more + details. + """ + + power_exclusion_lower_bound: float + """Lower exclusion bound for battery power in watts. + + This is the lower limit of the range within which power requests are not allowed for + the battery. + + See [`frequenz.api.common.metrics_pb2.Metric.system_inclusion_bounds`][] and + [`frequenz.api.common.metrics_pb2.Metric.system_exclusion_bounds`][] for more + details. """ - power_upper_bound: float - """The maximum charge power, in Watts, represented in the passive sign convention. - This will be a positive number, or zero if no charging is possible. + power_inclusion_upper_bound: float + """Upper inclusion bound for battery power in watts. + + This is the upper limit of the range within which power requests are allowed for the + battery. + + See [`frequenz.api.common.metrics_pb2.Metric.system_inclusion_bounds`][] and + [`frequenz.api.common.metrics_pb2.Metric.system_exclusion_bounds`][] for more + details. + """ + + power_exclusion_upper_bound: float + """Upper exclusion bound for battery power in watts. + + This is the upper limit of the range within which power requests are not allowed for + the battery. + + See [`frequenz.api.common.metrics_pb2.Metric.system_inclusion_bounds`][] and + [`frequenz.api.common.metrics_pb2.Metric.system_exclusion_bounds`][] for more + details. """ + # pylint: enable=line-too-long - temperature_max: float - """The maximum temperature of all the blocks in a battery, in Celcius (°C).""" + temperature: float + """The (average) temperature reported by the battery, in Celcius (°C).""" _relay_state: battery_pb.RelayState.ValueType """State of the battery relay.""" @@ -163,16 +198,19 @@ def from_proto(cls, raw: microgrid_pb.ComponentData) -> BatteryData: Returns: Instance of BatteryData created from the protobuf message. """ + raw_power = raw.battery.data.dc.power battery_data = cls( component_id=raw.id, timestamp=raw.ts.ToDatetime(tzinfo=timezone.utc), soc=raw.battery.data.soc.avg, - soc_lower_bound=raw.battery.data.soc.system_bounds.lower, - soc_upper_bound=raw.battery.data.soc.system_bounds.upper, + soc_lower_bound=raw.battery.data.soc.system_inclusion_bounds.lower, + soc_upper_bound=raw.battery.data.soc.system_inclusion_bounds.upper, capacity=raw.battery.properties.capacity, - power_lower_bound=raw.battery.data.dc.power.system_bounds.lower, - power_upper_bound=raw.battery.data.dc.power.system_bounds.upper, - temperature_max=raw.battery.data.temperature.max, + power_inclusion_lower_bound=raw_power.system_inclusion_bounds.lower, + power_exclusion_lower_bound=raw_power.system_exclusion_bounds.lower, + power_inclusion_upper_bound=raw_power.system_inclusion_bounds.upper, + power_exclusion_upper_bound=raw_power.system_exclusion_bounds.upper, + temperature=raw.battery.data.temperature.avg, _relay_state=raw.battery.state.relay_state, _component_state=raw.battery.state.component_state, _errors=list(raw.battery.errors), @@ -191,16 +229,51 @@ class InverterData(ComponentData): -ve current means supply into the grid. """ - active_power_lower_bound: float - """The maximum discharge power, in Watts, represented in the passive sign - convention. This will be a negative number, or zero if no discharging is - possible. + # pylint: disable=line-too-long + active_power_inclusion_lower_bound: float + """Lower inclusion bound for inverter power in watts. + + This is the lower limit of the range within which power requests are allowed for the + inverter. + + See [`frequenz.api.common.metrics_pb2.Metric.system_inclusion_bounds`][] and + [`frequenz.api.common.metrics_pb2.Metric.system_exclusion_bounds`][] for more + details. + """ + + active_power_exclusion_lower_bound: float + """Lower exclusion bound for inverter power in watts. + + This is the lower limit of the range within which power requests are not allowed for + the inverter. + + See [`frequenz.api.common.metrics_pb2.Metric.system_inclusion_bounds`][] and + [`frequenz.api.common.metrics_pb2.Metric.system_exclusion_bounds`][] for more + details. """ - active_power_upper_bound: float - """The maximum charge power, in Watts, represented in the passive sign convention. - This will be a positive number, or zero if no charging is possible. + active_power_inclusion_upper_bound: float + """Upper inclusion bound for inverter power in watts. + + This is the upper limit of the range within which power requests are allowed for the + inverter. + + See [`frequenz.api.common.metrics_pb2.Metric.system_inclusion_bounds`][] and + [`frequenz.api.common.metrics_pb2.Metric.system_exclusion_bounds`][] for more + details. + """ + + active_power_exclusion_upper_bound: float + """Upper exclusion bound for inverter power in watts. + + This is the upper limit of the range within which power requests are not allowed for + the inverter. + + See [`frequenz.api.common.metrics_pb2.Metric.system_inclusion_bounds`][] and + [`frequenz.api.common.metrics_pb2.Metric.system_exclusion_bounds`][] for more + details. """ + # pylint: enable=line-too-long _component_state: inverter_pb.ComponentState.ValueType """State of the inverter.""" @@ -218,12 +291,15 @@ def from_proto(cls, raw: microgrid_pb.ComponentData) -> InverterData: Returns: Instance of InverterData created from the protobuf message. """ + raw_power = raw.inverter.data.ac.power_active inverter_data = cls( component_id=raw.id, timestamp=raw.ts.ToDatetime(tzinfo=timezone.utc), active_power=raw.inverter.data.ac.power_active.value, - active_power_lower_bound=raw.inverter.data.ac.power_active.system_bounds.lower, - active_power_upper_bound=raw.inverter.data.ac.power_active.system_bounds.upper, + active_power_inclusion_lower_bound=raw_power.system_inclusion_bounds.lower, + active_power_exclusion_lower_bound=raw_power.system_exclusion_bounds.lower, + active_power_inclusion_upper_bound=raw_power.system_inclusion_bounds.upper, + active_power_exclusion_upper_bound=raw_power.system_exclusion_bounds.upper, _component_state=raw.inverter.state.component_state, _errors=list(raw.inverter.errors), ) diff --git a/src/frequenz/sdk/microgrid/component/_component_states.py b/src/frequenz/sdk/microgrid/component/_component_states.py index 3aa9d82e0..08f8cbb77 100644 --- a/src/frequenz/sdk/microgrid/component/_component_states.py +++ b/src/frequenz/sdk/microgrid/component/_component_states.py @@ -8,6 +8,8 @@ from frequenz.api.microgrid import ev_charger_pb2 as ev_charger_pb +# pylint: disable=no-member + class EVChargerCableState(Enum): """Cable states of an EV Charger.""" diff --git a/src/frequenz/sdk/timeseries/__init__.py b/src/frequenz/sdk/timeseries/__init__.py index edab3e12e..48e0000d1 100644 --- a/src/frequenz/sdk/timeseries/__init__.py +++ b/src/frequenz/sdk/timeseries/__init__.py @@ -45,6 +45,7 @@ Percentage, Power, Quantity, + Temperature, Voltage, ) from ._resampling import ResamplerConfig @@ -63,6 +64,7 @@ "Current", "Energy", "Power", + "Temperature", "Voltage", "Frequency", "Percentage", diff --git a/src/frequenz/sdk/timeseries/_formula_engine/_formula_engine.py b/src/frequenz/sdk/timeseries/_formula_engine/_formula_engine.py index bc46c3726..6dd989200 100644 --- a/src/frequenz/sdk/timeseries/_formula_engine/_formula_engine.py +++ b/src/frequenz/sdk/timeseries/_formula_engine/_formula_engine.py @@ -9,15 +9,12 @@ import logging from abc import ABC from collections import deque -from datetime import datetime -from math import isinf, isnan from typing import ( Callable, Dict, Generic, List, Optional, - Set, Tuple, Type, TypeVar, @@ -29,7 +26,8 @@ from ..._internal._asyncio import cancel_and_await from .. import Sample, Sample3Phase -from .._quantities import QuantityT +from .._quantities import Quantity, QuantityT +from ._formula_evaluator import FormulaEvaluator from ._formula_steps import ( Adder, Averager, @@ -56,126 +54,6 @@ } -class FormulaEvaluator(Generic[QuantityT]): - """A post-fix formula evaluator that operates on `Sample` receivers.""" - - def __init__( - self, - name: str, - steps: List[FormulaStep], - metric_fetchers: Dict[str, MetricFetcher[QuantityT]], - create_method: Callable[[float], QuantityT], - ) -> None: - """Create a `FormulaEngine` instance. - - Args: - name: A name for the formula. - steps: Steps for the engine to execute, in post-fix order. - metric_fetchers: Fetchers for each metric stream the formula depends on. - create_method: A method to generate the output `Sample` value with. If the - formula is for generating power values, this would be - `Power.from_watts`, for example. - """ - self._name = name - self._steps = steps - self._metric_fetchers: Dict[str, MetricFetcher[QuantityT]] = metric_fetchers - self._first_run = True - self._create_method: Callable[[float], QuantityT] = create_method - - async def _synchronize_metric_timestamps( - self, metrics: Set[asyncio.Task[Optional[Sample[QuantityT]]]] - ) -> datetime: - """Synchronize the metric streams. - - For synchronised streams like data from the `ComponentMetricsResamplingActor`, - this a call to this function is required only once, before the first set of - inputs are fetched. - - Args: - metrics: The finished tasks from the first `fetch_next` calls to all the - `MetricFetcher`s. - - Returns: - The timestamp of the latest metric value. - - Raises: - RuntimeError: when some streams have no value, or when the synchronization - of timestamps fails. - """ - metrics_by_ts: Dict[datetime, list[str]] = {} - for metric in metrics: - result = metric.result() - name = metric.get_name() - if result is None: - raise RuntimeError(f"Stream closed for component: {name}") - metrics_by_ts.setdefault(result.timestamp, []).append(name) - latest_ts = max(metrics_by_ts) - - # fetch the metrics with non-latest timestamps again until we have the values - # for the same ts for all metrics. - for metric_ts, names in metrics_by_ts.items(): - if metric_ts == latest_ts: - continue - while metric_ts < latest_ts: - for name in names: - fetcher = self._metric_fetchers[name] - next_val = await fetcher.fetch_next() - assert next_val is not None - metric_ts = next_val.timestamp - if metric_ts > latest_ts: - raise RuntimeError( - "Unable to synchronize resampled metric timestamps, " - f"for formula: {self._name}" - ) - self._first_run = False - return latest_ts - - async def apply(self) -> Sample[QuantityT]: - """Fetch the latest metrics, apply the formula once and return the result. - - Returns: - The result of the formula. - - Raises: - RuntimeError: if some samples didn't arrive, or if formula application - failed. - """ - eval_stack: List[float] = [] - ready_metrics, pending = await asyncio.wait( - [ - asyncio.create_task(fetcher.fetch_next(), name=name) - for name, fetcher in self._metric_fetchers.items() - ], - return_when=asyncio.ALL_COMPLETED, - ) - - if pending or any(res.result() is None for res in iter(ready_metrics)): - raise RuntimeError( - f"Some resampled metrics didn't arrive, for formula: {self._name}" - ) - - if self._first_run: - metric_ts = await self._synchronize_metric_timestamps(ready_metrics) - else: - sample = next(iter(ready_metrics)).result() - assert sample is not None - metric_ts = sample.timestamp - - for step in self._steps: - step.apply(eval_stack) - - # if all steps were applied and the formula was correct, there should only be a - # single value in the evaluation stack, and that would be the formula result. - if len(eval_stack) != 1: - raise RuntimeError(f"Formula application failed: {self._name}") - - res = eval_stack.pop() - if isnan(res) or isinf(res): - return Sample(metric_ts, None) - - return Sample(metric_ts, self._create_method(res)) - - _CompositionType = Union[ "FormulaEngine", "HigherOrderFormulaBuilder", @@ -231,7 +109,7 @@ async def _stop(self) -> None: def __add__( self, - other: _GenericEngine | _GenericHigherOrderBuilder, + other: _GenericEngine | _GenericHigherOrderBuilder | QuantityT, ) -> _GenericHigherOrderBuilder: """Return a formula builder that adds (data in) `other` to `self`. @@ -246,7 +124,7 @@ def __add__( return self._higher_order_builder(self, self._create_method) + other # type: ignore def __sub__( - self, other: _GenericEngine | _GenericHigherOrderBuilder + self, other: _GenericEngine | _GenericHigherOrderBuilder | QuantityT ) -> _GenericHigherOrderBuilder: """Return a formula builder that subtracts (data in) `other` from `self`. @@ -261,7 +139,7 @@ def __sub__( return self._higher_order_builder(self, self._create_method) - other # type: ignore def __mul__( - self, other: _GenericEngine | _GenericHigherOrderBuilder + self, other: _GenericEngine | _GenericHigherOrderBuilder | float ) -> _GenericHigherOrderBuilder: """Return a formula builder that multiplies (data in) `self` with `other`. @@ -276,7 +154,7 @@ def __mul__( return self._higher_order_builder(self, self._create_method) * other # type: ignore def __truediv__( - self, other: _GenericEngine | _GenericHigherOrderBuilder + self, other: _GenericEngine | _GenericHigherOrderBuilder | float ) -> _GenericHigherOrderBuilder: """Return a formula builder that divides (data in) `self` by `other`. @@ -740,7 +618,11 @@ def __init__( self._steps: deque[ tuple[ TokenType, - FormulaEngine[QuantityT] | FormulaEngine3Phase[QuantityT] | str, + FormulaEngine[QuantityT] + | FormulaEngine3Phase[QuantityT] + | QuantityT + | float + | str, ] ] = deque() self._steps.append((TokenType.COMPONENT_METRIC, engine)) @@ -754,12 +636,12 @@ def _push( @overload def _push( - self, oper: str, other: _CompositionType3Phase + self, oper: str, other: _CompositionType3Phase | QuantityT | float ) -> HigherOrderFormulaBuilder3Phase[QuantityT]: ... def _push( - self, oper: str, other: _CompositionType + self, oper: str, other: _CompositionType | QuantityT | float ) -> ( HigherOrderFormulaBuilder[QuantityT] | HigherOrderFormulaBuilder3Phase[QuantityT] @@ -771,6 +653,19 @@ def _push( # pylint: disable=protected-access if isinstance(other, (FormulaEngine, FormulaEngine3Phase)): self._steps.append((TokenType.COMPONENT_METRIC, other)) + elif isinstance(other, (Quantity, float)): + match oper: + case "+" | "-": + if not isinstance(other, Quantity): + raise RuntimeError( + f"A Quantity must be provided for addition or subtraction to {other}" + ) + case "*" | "/": + if not isinstance(other, (float, int)): + raise RuntimeError( + f"A float must be provided for scalar multiplication to {other}" + ) + self._steps.append((TokenType.CONSTANT, other)) elif isinstance(other, _BaseHOFormulaBuilder): self._steps.append((TokenType.OPER, "(")) self._steps.extend(other._steps) @@ -791,12 +686,12 @@ def __add__( @overload def __add__( - self, other: _CompositionType3Phase + self, other: _CompositionType3Phase | QuantityT ) -> HigherOrderFormulaBuilder3Phase[QuantityT]: ... def __add__( - self, other: _CompositionType + self, other: _CompositionType | QuantityT ) -> ( HigherOrderFormulaBuilder[QuantityT] | HigherOrderFormulaBuilder3Phase[QuantityT] @@ -821,13 +716,13 @@ def __sub__( @overload def __sub__( - self, other: _CompositionType3Phase + self, other: _CompositionType3Phase | QuantityT ) -> HigherOrderFormulaBuilder3Phase[QuantityT]: ... def __sub__( self, - other: _CompositionType, + other: _CompositionType | QuantityT, ) -> ( HigherOrderFormulaBuilder[QuantityT] | HigherOrderFormulaBuilder3Phase[QuantityT] @@ -852,13 +747,13 @@ def __mul__( @overload def __mul__( - self, other: _CompositionType3Phase + self, other: _CompositionType3Phase | float ) -> HigherOrderFormulaBuilder3Phase[QuantityT]: ... def __mul__( self, - other: _CompositionType, + other: _CompositionType | float, ) -> ( HigherOrderFormulaBuilder[QuantityT] | HigherOrderFormulaBuilder3Phase[QuantityT] @@ -883,13 +778,13 @@ def __truediv__( @overload def __truediv__( - self, other: _CompositionType3Phase + self, other: _CompositionType3Phase | float ) -> HigherOrderFormulaBuilder3Phase[QuantityT]: ... def __truediv__( self, - other: _CompositionType, + other: _CompositionType | float, ) -> ( HigherOrderFormulaBuilder[QuantityT] | HigherOrderFormulaBuilder3Phase[QuantityT] @@ -935,6 +830,11 @@ def build( elif typ == TokenType.OPER: assert isinstance(value, str) builder.push_oper(value) + elif typ == TokenType.CONSTANT: + assert isinstance(value, (Quantity, float)) + builder.push_constant( + value.base_value if isinstance(value, Quantity) else value + ) return builder.build() diff --git a/src/frequenz/sdk/timeseries/_formula_engine/_formula_evaluator.py b/src/frequenz/sdk/timeseries/_formula_engine/_formula_evaluator.py new file mode 100644 index 000000000..6f2313858 --- /dev/null +++ b/src/frequenz/sdk/timeseries/_formula_engine/_formula_evaluator.py @@ -0,0 +1,133 @@ +# License: MIT +# Copyright © 2022 Frequenz Energy-as-a-Service GmbH + +"""A post-fix formula evaluator that operates on `Sample` receivers.""" + +import asyncio +from datetime import datetime +from math import isinf, isnan +from typing import Callable, Dict, Generic, List, Optional, Set + +from .. import Sample +from .._quantities import QuantityT +from ._formula_steps import FormulaStep, MetricFetcher + + +class FormulaEvaluator(Generic[QuantityT]): + """A post-fix formula evaluator that operates on `Sample` receivers.""" + + def __init__( + self, + name: str, + steps: List[FormulaStep], + metric_fetchers: Dict[str, MetricFetcher[QuantityT]], + create_method: Callable[[float], QuantityT], + ) -> None: + """Create a `FormulaEngine` instance. + + Args: + name: A name for the formula. + steps: Steps for the engine to execute, in post-fix order. + metric_fetchers: Fetchers for each metric stream the formula depends on. + create_method: A method to generate the output `Sample` value with. If the + formula is for generating power values, this would be + `Power.from_watts`, for example. + """ + self._name = name + self._steps = steps + self._metric_fetchers: Dict[str, MetricFetcher[QuantityT]] = metric_fetchers + self._first_run = True + self._create_method: Callable[[float], QuantityT] = create_method + + async def _synchronize_metric_timestamps( + self, metrics: Set[asyncio.Task[Optional[Sample[QuantityT]]]] + ) -> datetime: + """Synchronize the metric streams. + + For synchronised streams like data from the `ComponentMetricsResamplingActor`, + this a call to this function is required only once, before the first set of + inputs are fetched. + + Args: + metrics: The finished tasks from the first `fetch_next` calls to all the + `MetricFetcher`s. + + Returns: + The timestamp of the latest metric value. + + Raises: + RuntimeError: when some streams have no value, or when the synchronization + of timestamps fails. + """ + metrics_by_ts: Dict[datetime, list[str]] = {} + for metric in metrics: + result = metric.result() + name = metric.get_name() + if result is None: + raise RuntimeError(f"Stream closed for component: {name}") + metrics_by_ts.setdefault(result.timestamp, []).append(name) + latest_ts = max(metrics_by_ts) + + # fetch the metrics with non-latest timestamps again until we have the values + # for the same ts for all metrics. + for metric_ts, names in metrics_by_ts.items(): + if metric_ts == latest_ts: + continue + while metric_ts < latest_ts: + for name in names: + fetcher = self._metric_fetchers[name] + next_val = await fetcher.fetch_next() + assert next_val is not None + metric_ts = next_val.timestamp + if metric_ts > latest_ts: + raise RuntimeError( + "Unable to synchronize resampled metric timestamps, " + f"for formula: {self._name}" + ) + self._first_run = False + return latest_ts + + async def apply(self) -> Sample[QuantityT]: + """Fetch the latest metrics, apply the formula once and return the result. + + Returns: + The result of the formula. + + Raises: + RuntimeError: if some samples didn't arrive, or if formula application + failed. + """ + eval_stack: List[float] = [] + ready_metrics, pending = await asyncio.wait( + [ + asyncio.create_task(fetcher.fetch_next(), name=name) + for name, fetcher in self._metric_fetchers.items() + ], + return_when=asyncio.ALL_COMPLETED, + ) + + if pending or any(res.result() is None for res in iter(ready_metrics)): + raise RuntimeError( + f"Some resampled metrics didn't arrive, for formula: {self._name}" + ) + + if self._first_run: + metric_ts = await self._synchronize_metric_timestamps(ready_metrics) + else: + sample = next(iter(ready_metrics)).result() + assert sample is not None + metric_ts = sample.timestamp + + for step in self._steps: + step.apply(eval_stack) + + # if all steps were applied and the formula was correct, there should only be a + # single value in the evaluation stack, and that would be the formula result. + if len(eval_stack) != 1: + raise RuntimeError(f"Formula application failed: {self._name}") + + res = eval_stack.pop() + if isnan(res) or isinf(res): + return Sample(metric_ts, None) + + return Sample(metric_ts, self._create_method(res)) diff --git a/src/frequenz/sdk/timeseries/_formula_engine/_formula_generators/_consumer_power_formula.py b/src/frequenz/sdk/timeseries/_formula_engine/_formula_generators/_consumer_power_formula.py index 7d33c5a2a..2dc18f3fc 100644 --- a/src/frequenz/sdk/timeseries/_formula_engine/_formula_generators/_consumer_power_formula.py +++ b/src/frequenz/sdk/timeseries/_formula_engine/_formula_generators/_consumer_power_formula.py @@ -3,16 +3,20 @@ """Formula generator from component graph for Consumer Power.""" -from __future__ import annotations - -from collections import abc +import logging from ....microgrid import connection_manager from ....microgrid.component import Component, ComponentCategory, ComponentMetricId from ..._quantities import Power from .._formula_engine import FormulaEngine from .._resampled_formula_builder import ResampledFormulaBuilder -from ._formula_generator import ComponentNotFound, FormulaGenerator +from ._formula_generator import ( + NON_EXISTING_COMPONENT_ID, + ComponentNotFound, + FormulaGenerator, +) + +_logger = logging.getLogger(__name__) class ConsumerPowerFormula(FormulaGenerator[Power]): @@ -37,99 +41,146 @@ def generate(self) -> FormulaEngine[Power]: builder = self._get_builder( "consumer-power", ComponentMetricId.ACTIVE_POWER, Power.from_watts ) - component_graph = connection_manager.get().component_graph - grid_component = next( - ( - comp - for comp in component_graph.components() - if comp.category == ComponentCategory.GRID - ), - None, - ) - if grid_component is None: - raise ComponentNotFound("Grid component not found in the component graph.") + grid_successors = self._get_grid_component_successors() - grid_successors = component_graph.successors(grid_component.component_id) if not grid_successors: raise ComponentNotFound("No components found in the component graph.") - if len(grid_successors) == 1: - grid_meter = next(iter(grid_successors)) - if grid_meter.category != ComponentCategory.METER: - raise RuntimeError( - "Only grid successor in the component graph is not a meter." - ) - return self._gen_with_grid_meter(builder, grid_meter) - return self._gen_without_grid_meter(builder, grid_successors) + component_graph = connection_manager.get().component_graph + if all( + successor.category == ComponentCategory.METER + and not component_graph.is_battery_chain(successor) + and not component_graph.is_chp_chain(successor) + and not component_graph.is_pv_chain(successor) + and not component_graph.is_ev_charger_chain(successor) + for successor in grid_successors + ): + return self._gen_with_grid_meter(builder, grid_successors) + + return self._gen_without_grid_meter(builder, self._get_grid_component()) def _gen_with_grid_meter( self, builder: ResampledFormulaBuilder[Power], - grid_meter: Component, + grid_meters: set[Component], ) -> FormulaEngine[Power]: """Generate formula for calculating consumer power with grid meter. Args: builder: The formula engine builder. - grid_meter: The grid meter component. + grid_meters: The grid meter component. Returns: A formula engine that will calculate the consumer power. """ + assert grid_meters component_graph = connection_manager.get().component_graph - successors = component_graph.successors(grid_meter.component_id) - builder.push_component_metric(grid_meter.component_id, nones_are_zeros=False) + def non_consumer_component(component: Component) -> bool: + """ + Check if a component is not a consumer component. + + Args: + component: The component to check. - for successor in successors: + Returns: + True if the component is not a consumer component, False otherwise. + """ # If the component graph supports additional types of grid successors in the # future, additional checks need to be added here. - if ( - component_graph.is_battery_chain(successor) - or component_graph.is_chp_chain(successor) - or component_graph.is_pv_chain(successor) - or component_graph.is_ev_charger_chain(successor) - ): + return ( + component_graph.is_battery_chain(component) + or component_graph.is_chp_chain(component) + or component_graph.is_pv_chain(component) + or component_graph.is_ev_charger_chain(component) + ) + + # join all non consumer components reachable from the different grid meters + non_consumer_components: set[Component] = set() + for grid_meter in grid_meters: + non_consumer_components = non_consumer_components.union( + component_graph.dfs(grid_meter, set(), non_consumer_component) + ) + + # push all grid meters + for idx, grid_meter in enumerate(grid_meters): + if idx > 0: builder.push_oper("-") - nones_are_zeros = True - if successor.category == ComponentCategory.METER: - nones_are_zeros = False - builder.push_component_metric( - successor.component_id, nones_are_zeros=nones_are_zeros - ) + builder.push_component_metric( + grid_meter.component_id, nones_are_zeros=False + ) + + # push all non consumer components and subtract them from the grid meters + for component in non_consumer_components: + builder.push_oper("-") + builder.push_component_metric( + component.component_id, + nones_are_zeros=component.category != ComponentCategory.METER, + ) return builder.build() def _gen_without_grid_meter( self, builder: ResampledFormulaBuilder[Power], - grid_successors: abc.Iterable[Component], + grid: Component, ) -> FormulaEngine[Power]: """Generate formula for calculating consumer power without a grid meter. Args: builder: The formula engine builder. - grid_successors: The grid successors. + grid: The grid component. Returns: A formula engine that will calculate the consumer power. """ - component_graph = connection_manager.get().component_graph - is_first = True - for successor in grid_successors: + + def consumer_component(component: Component) -> bool: + """ + Check if a component is a consumer component. + + Args: + component: The component to check. + + Returns: + True if the component is a consumer component, False otherwise. + """ # If the component graph supports additional types of grid successors in the # future, additional checks need to be added here. - if ( - component_graph.is_battery_chain(successor) - or component_graph.is_chp_chain(successor) - or component_graph.is_pv_chain(successor) - or component_graph.is_ev_charger_chain(successor) - ): - continue - if not is_first: + return ( + component.category + in {ComponentCategory.METER, ComponentCategory.INVERTER} + and not component_graph.is_battery_chain(component) + and not component_graph.is_chp_chain(component) + and not component_graph.is_pv_chain(component) + and not component_graph.is_ev_charger_chain(component) + ) + + component_graph = connection_manager.get().component_graph + consumer_components = component_graph.dfs(grid, set(), consumer_component) + + if not consumer_components: + _logger.warning( + "Unable to find any consumers in the component graph. " + "Subscribing to the resampling actor with a non-existing " + "component id, so that `0` values are sent from the formula." + ) + # If there are no consumer components, we have to send 0 values at the same + # frequency as the other streams. So we subscribe with a non-existing + # component id, just to get a `None` message at the resampling interval. + builder.push_component_metric( + NON_EXISTING_COMPONENT_ID, nones_are_zeros=True + ) + return builder.build() + + for idx, component in enumerate(consumer_components): + if idx > 0: builder.push_oper("+") - is_first = False - builder.push_component_metric(successor.component_id, nones_are_zeros=False) + + builder.push_component_metric( + component.component_id, + nones_are_zeros=component.category != ComponentCategory.METER, + ) return builder.build() diff --git a/src/frequenz/sdk/timeseries/_formula_engine/_formula_generators/_formula_generator.py b/src/frequenz/sdk/timeseries/_formula_engine/_formula_generators/_formula_generator.py index 58bb5935e..2b88d4a1f 100644 --- a/src/frequenz/sdk/timeseries/_formula_engine/_formula_generators/_formula_generator.py +++ b/src/frequenz/sdk/timeseries/_formula_engine/_formula_generators/_formula_generator.py @@ -15,6 +15,7 @@ from frequenz.channels import Sender from ....actor import ChannelRegistry, ComponentMetricRequest +from ....microgrid import component, connection_manager from ....microgrid.component import ComponentMetricId from ..._quantities import QuantityT from .._formula_engine import FormulaEngine, FormulaEngine3Phase @@ -100,6 +101,48 @@ def _get_builder( ) return builder + def _get_grid_component(self) -> component.Component: + """ + Get the grid component in the component graph. + + Returns: + The first grid component found in the graph. + + Raises: + ComponentNotFound: If the grid component is not found in the component graph. + """ + component_graph = connection_manager.get().component_graph + grid_component = next( + iter( + component_graph.components( + component_category={component.ComponentCategory.GRID} + ) + ), + None, + ) + if grid_component is None: + raise ComponentNotFound("Grid component not found in the component graph.") + + return grid_component + + def _get_grid_component_successors(self) -> set[component.Component]: + """Get the set of grid component successors in the component graph. + + Returns: + A set of grid component successors. + + Raises: + ComponentNotFound: If no successor components are found in the component graph. + """ + grid_component = self._get_grid_component() + component_graph = connection_manager.get().component_graph + grid_successors = component_graph.successors(grid_component.component_id) + + if not grid_successors: + raise ComponentNotFound("No components found in the component graph.") + + return grid_successors + @abstractmethod def generate( self, diff --git a/src/frequenz/sdk/timeseries/_formula_engine/_formula_generators/_grid_current_formula.py b/src/frequenz/sdk/timeseries/_formula_engine/_formula_generators/_grid_current_formula.py index 31adb1d65..63e2ba98d 100644 --- a/src/frequenz/sdk/timeseries/_formula_engine/_formula_generators/_grid_current_formula.py +++ b/src/frequenz/sdk/timeseries/_formula_engine/_formula_generators/_grid_current_formula.py @@ -5,11 +5,10 @@ from typing import Set -from ....microgrid import connection_manager from ....microgrid.component import Component, ComponentCategory, ComponentMetricId from ..._quantities import Current from .._formula_engine import FormulaEngine, FormulaEngine3Phase -from ._formula_generator import ComponentNotFound, FormulaGenerator +from ._formula_generator import FormulaGenerator class GridCurrentFormula(FormulaGenerator[Current]): @@ -24,22 +23,7 @@ def generate(self) -> FormulaEngine3Phase[Current]: Raises: ComponentNotFound: when the component graph doesn't have a `GRID` component. """ - component_graph = connection_manager.get().component_graph - grid_component = next( - ( - comp - for comp in component_graph.components() - if comp.category == ComponentCategory.GRID - ), - None, - ) - - if grid_component is None: - raise ComponentNotFound( - "Unable to find a GRID component from the component graph." - ) - - grid_successors = component_graph.successors(grid_component.component_id) + grid_successors = self._get_grid_component_successors() return FormulaEngine3Phase( "grid-current", diff --git a/src/frequenz/sdk/timeseries/_formula_engine/_formula_generators/_grid_power_formula.py b/src/frequenz/sdk/timeseries/_formula_engine/_formula_generators/_grid_power_formula.py index 4ba224229..489bf8bdb 100644 --- a/src/frequenz/sdk/timeseries/_formula_engine/_formula_generators/_grid_power_formula.py +++ b/src/frequenz/sdk/timeseries/_formula_engine/_formula_generators/_grid_power_formula.py @@ -3,11 +3,10 @@ """Formula generator from component graph for Grid Power.""" -from ....microgrid import connection_manager from ....microgrid.component import ComponentCategory, ComponentMetricId from ..._quantities import Power from .._formula_engine import FormulaEngine -from ._formula_generator import ComponentNotFound, FormulaGenerator, FormulaType +from ._formula_generator import FormulaGenerator, FormulaType class GridPowerFormula(FormulaGenerator[Power]): @@ -27,22 +26,7 @@ def generate( builder = self._get_builder( "grid-power", ComponentMetricId.ACTIVE_POWER, Power.from_watts ) - component_graph = connection_manager.get().component_graph - grid_component = next( - ( - comp - for comp in component_graph.components() - if comp.category == ComponentCategory.GRID - ), - None, - ) - - if grid_component is None: - raise ComponentNotFound( - "Unable to find a GRID component from the component graph." - ) - - grid_successors = component_graph.successors(grid_component.component_id) + grid_successors = self._get_grid_component_successors() # generate a formula that just adds values from all commponents that are # directly connected to the grid. If the requested formula type is diff --git a/src/frequenz/sdk/timeseries/_formula_engine/_formula_generators/_producer_power_formula.py b/src/frequenz/sdk/timeseries/_formula_engine/_formula_generators/_producer_power_formula.py index 76e111e9b..bd51d969d 100644 --- a/src/frequenz/sdk/timeseries/_formula_engine/_formula_generators/_producer_power_formula.py +++ b/src/frequenz/sdk/timeseries/_formula_engine/_formula_generators/_producer_power_formula.py @@ -3,17 +3,15 @@ """Formula generator from component graph for Producer Power.""" -from __future__ import annotations +import logging from ....microgrid import connection_manager from ....microgrid.component import ComponentCategory, ComponentMetricId from ..._quantities import Power from .._formula_engine import FormulaEngine -from ._formula_generator import ( - NON_EXISTING_COMPONENT_ID, - ComponentNotFound, - FormulaGenerator, -) +from ._formula_generator import NON_EXISTING_COMPONENT_ID, FormulaGenerator + +_logger = logging.getLogger(__name__) class ProducerPowerFormula(FormulaGenerator[Power]): @@ -38,48 +36,37 @@ def generate(self) -> FormulaEngine[Power]: builder = self._get_builder( "producer_power", ComponentMetricId.ACTIVE_POWER, Power.from_watts ) + component_graph = connection_manager.get().component_graph - grid_component = next( - iter( - component_graph.components(component_category={ComponentCategory.GRID}) - ), - None, + # if in the future we support additional producers, we need to add them to the lambda + producer_components = component_graph.dfs( + self._get_grid_component(), + set(), + lambda component: component_graph.is_pv_chain(component) + or component_graph.is_chp_chain(component), ) - if grid_component is None: - raise ComponentNotFound("Grid component not found in the component graph.") - - grid_successors = component_graph.successors(grid_component.component_id) - if not grid_successors: - raise ComponentNotFound("No components found in the component graph.") - - if len(grid_successors) == 1: - grid_meter = next(iter(grid_successors)) - if grid_meter.category != ComponentCategory.METER: - raise RuntimeError( - "Only grid successor in the component graph is not a meter." - ) - grid_successors = component_graph.successors(grid_meter.component_id) - - first_iteration = True - for successor in iter(grid_successors): - # if in the future we support additional producers, we need to add them here - if component_graph.is_chp_chain(successor) or component_graph.is_pv_chain( - successor - ): - if not first_iteration: - builder.push_oper("+") - - first_iteration = False + if not producer_components: + _logger.warning( + "Unable to find any producer components in the component graph. " + "Subscribing to the resampling actor with a non-existing " + "component id, so that `0` values are sent from the formula." + ) + # If there are no producer components, we have to send 0 values at the same + # frequency as the other streams. So we subscribe with a non-existing + # component id, just to get a `None` message at the resampling interval. + builder.push_component_metric( + NON_EXISTING_COMPONENT_ID, nones_are_zeros=True + ) + return builder.build() - builder.push_component_metric( - successor.component_id, - nones_are_zeros=successor.category != ComponentCategory.METER, - ) + for idx, component in enumerate(producer_components): + if idx > 0: + builder.push_oper("+") - if first_iteration: builder.push_component_metric( - NON_EXISTING_COMPONENT_ID, nones_are_zeros=True + component.component_id, + nones_are_zeros=component.category != ComponentCategory.METER, ) return builder.build() diff --git a/src/frequenz/sdk/timeseries/_formula_engine/_formula_generators/_pv_power_formula.py b/src/frequenz/sdk/timeseries/_formula_engine/_formula_generators/_pv_power_formula.py index 99fdce570..cce41f00d 100644 --- a/src/frequenz/sdk/timeseries/_formula_engine/_formula_generators/_pv_power_formula.py +++ b/src/frequenz/sdk/timeseries/_formula_engine/_formula_generators/_pv_power_formula.py @@ -3,21 +3,13 @@ """Formula generator for PV Power, from the component graph.""" -from __future__ import annotations - import logging -from collections import abc from ....microgrid import connection_manager -from ....microgrid.component import ComponentCategory, ComponentMetricId, InverterType +from ....microgrid.component import ComponentCategory, ComponentMetricId from ..._quantities import Power from .._formula_engine import FormulaEngine -from ._formula_generator import ( - NON_EXISTING_COMPONENT_ID, - FormulaGenerationError, - FormulaGenerator, - FormulaType, -) +from ._formula_generator import NON_EXISTING_COMPONENT_ID, FormulaGenerator, FormulaType _logger = logging.getLogger(__name__) @@ -38,14 +30,20 @@ def generate(self) -> FormulaEngine[Power]: "pv-power", ComponentMetricId.ACTIVE_POWER, Power.from_watts ) - pv_meters = self._get_pv_meters() - if not pv_meters: + component_graph = connection_manager.get().component_graph + pv_components = component_graph.dfs( + self._get_grid_component(), + set(), + component_graph.is_pv_chain, + ) + + if not pv_components: _logger.warning( - "Unable to find any PV inverters in the component graph. " + "Unable to find any PV components in the component graph. " "Subscribing to the resampling actor with a non-existing " "component id, so that `0` values are sent from the formula." ) - # If there are no PV inverters, we have to send 0 values as the same + # If there are no PV components, we have to send 0 values at the same # frequency as the other streams. So we subscribe with a non-existing # component id, just to get a `None` message at the resampling interval. builder.push_component_metric( @@ -55,11 +53,15 @@ def generate(self) -> FormulaEngine[Power]: builder.push_oper("(") builder.push_oper("(") - for idx, comp_id in enumerate(pv_meters): + for idx, component in enumerate(pv_components): if idx > 0: builder.push_oper("+") - builder.push_component_metric(comp_id, nones_are_zeros=True) + # should only be the case if the component is not a meter + builder.push_component_metric( + component.component_id, + nones_are_zeros=component.category != ComponentCategory.METER, + ) builder.push_oper(")") if self._config.formula_type == FormulaType.PRODUCTION: builder.push_oper("*") @@ -70,40 +72,3 @@ def generate(self) -> FormulaEngine[Power]: builder.push_clipper(0.0, None) return builder.build() - - def _get_pv_meters(self) -> abc.Set[int]: - component_graph = connection_manager.get().component_graph - - pv_inverters = list( - comp - for comp in component_graph.components() - if comp.category == ComponentCategory.INVERTER - and comp.type == InverterType.SOLAR - ) - pv_meters: set[int] = set() - - if not pv_inverters: - return pv_meters - - for pv_inverter in pv_inverters: - predecessors = component_graph.predecessors(pv_inverter.component_id) - if len(predecessors) != 1: - raise FormulaGenerationError( - "Expected exactly one predecessor for PV inverter " - f"{pv_inverter.component_id}, but found {len(predecessors)}." - ) - meter = next(iter(predecessors)) - if meter.category != ComponentCategory.METER: - raise FormulaGenerationError( - f"Expected predecessor of PV inverter {pv_inverter.component_id} " - f"to be a meter, but found {meter.category}." - ) - meter_successors = component_graph.successors(meter.component_id) - if len(meter_successors) != 1: - raise FormulaGenerationError( - f"Expected exactly one successor for meter {meter.component_id}" - f", connected to PV inverter {pv_inverter.component_id}" - f", but found {len(meter_successors)}." - ) - pv_meters.add(meter.component_id) - return pv_meters diff --git a/src/frequenz/sdk/timeseries/_formula_engine/_tokenizer.py b/src/frequenz/sdk/timeseries/_formula_engine/_tokenizer.py index ce2be427a..956bf67b5 100644 --- a/src/frequenz/sdk/timeseries/_formula_engine/_tokenizer.py +++ b/src/frequenz/sdk/timeseries/_formula_engine/_tokenizer.py @@ -79,7 +79,8 @@ class TokenType(Enum): """Represents the types of tokens the Tokenizer can return.""" COMPONENT_METRIC = 0 - OPER = 1 + CONSTANT = 1 + OPER = 2 @dataclass diff --git a/src/frequenz/sdk/timeseries/_moving_window.py b/src/frequenz/sdk/timeseries/_moving_window.py index b2d254ee3..41e24d097 100644 --- a/src/frequenz/sdk/timeseries/_moving_window.py +++ b/src/frequenz/sdk/timeseries/_moving_window.py @@ -16,7 +16,7 @@ from frequenz.channels import Broadcast, Receiver, Sender from numpy.typing import ArrayLike -from .._internal._asyncio import cancel_and_await +from ..actor._background_service import BackgroundService from ._base_types import UNIX_EPOCH, Sample from ._quantities import Quantity from ._resampling import Resampler, ResamplerConfig @@ -25,7 +25,7 @@ _logger = logging.getLogger(__name__) -class MovingWindow: +class MovingWindow(BackgroundService): """ A data window that moves with the latest datapoints of a data stream. @@ -72,22 +72,21 @@ async def run() -> None: send_task = asyncio.create_task(send_mock_data(resampled_data_sender)) - window = MovingWindow( + async with MovingWindow( size=timedelta(seconds=5), resampled_data_recv=resampled_data_receiver, input_sampling_period=timedelta(seconds=1), - ) + ) as window: + time_start = datetime.now(tz=timezone.utc) + time_end = time_start + timedelta(seconds=5) - time_start = datetime.now(tz=timezone.utc) - time_end = time_start + timedelta(seconds=5) + # ... wait for 5 seconds until the buffer is filled + await asyncio.sleep(5) - # ... wait for 5 seconds until the buffer is filled - await asyncio.sleep(5) - - # return an numpy array from the window - array = window[time_start:time_end] - # and use it to for example calculate the mean - mean = array.mean() + # return an numpy array from the window + array = window[time_start:time_end] + # and use it to for example calculate the mean + mean = array.mean() asyncio.run(run()) ``` @@ -112,19 +111,18 @@ async def run() -> None: # create a window that stores two days of data # starting at 1.1.23 with samplerate=1 - window = MovingWindow( + async with MovingWindow( size=timedelta(days=2), resampled_data_recv=resampled_data_receiver, input_sampling_period=timedelta(seconds=1), - ) - - # wait for one full day until the buffer is filled - await asyncio.sleep(60*60*24) + ) as window: + # wait for one full day until the buffer is filled + await asyncio.sleep(60*60*24) - # create a polars series with one full day of data - time_start = datetime(2023, 1, 1, tzinfo=timezone.utc) - time_end = datetime(2023, 1, 2, tzinfo=timezone.utc) - series = pl.Series("Jan_1", window[time_start:time_end]) + # create a polars series with one full day of data + time_start = datetime(2023, 1, 1, tzinfo=timezone.utc) + time_end = datetime(2023, 1, 2, tzinfo=timezone.utc) + series = pl.Series("Jan_1", window[time_start:time_end]) asyncio.run(run()) ``` @@ -137,6 +135,8 @@ def __init__( # pylint: disable=too-many-arguments input_sampling_period: timedelta, resampler_config: ResamplerConfig | None = None, align_to: datetime = UNIX_EPOCH, + *, + name: str | None = None, ) -> None: """ Initialize the MovingWindow. @@ -154,9 +154,8 @@ def __init__( # pylint: disable=too-many-arguments align_to: A datetime object that defines a point in time to which the window is aligned to modulo window size. For further information, consult the class level documentation. - - Raises: - asyncio.CancelledError: when the task gets cancelled. + name: The name of this moving window. If `None`, `str(id(self))` will be + used. This is used mostly for debugging purposes. """ assert ( input_sampling_period.total_seconds() > 0 @@ -164,12 +163,12 @@ def __init__( # pylint: disable=too-many-arguments assert ( input_sampling_period <= size ), "The input sampling period should be equal to or lower than the window size." + super().__init__(name=name) self._sampling_period = input_sampling_period self._resampler: Resampler | None = None self._resampler_sender: Sender[Sample[Quantity]] | None = None - self._resampler_task: asyncio.Task[None] | None = None if resampler_config: assert ( @@ -191,12 +190,14 @@ def __init__( # pylint: disable=too-many-arguments align_to=align_to, ) + async def start(self) -> None: + """Start the MovingWindow. + + This method starts the MovingWindow tasks. + """ if self._resampler: self._configure_resampler() - - self._update_window_task: asyncio.Task[None] = asyncio.create_task( - self._run_impl() - ) + self._tasks.add(asyncio.create_task(self._run_impl(), name="update-window")) @property def sampling_period(self) -> timedelta: @@ -228,12 +229,6 @@ async def _run_impl(self) -> None: _logger.error("Channel has been closed") - async def stop(self) -> None: - """Cancel the running tasks and stop the MovingWindow.""" - await cancel_and_await(self._update_window_task) - if self._resampler_task: - await cancel_and_await(self._resampler_task) - def _configure_resampler(self) -> None: """Configure the components needed to run the resampler.""" assert self._resampler is not None @@ -247,7 +242,9 @@ async def sink_buffer(sample: Sample[Quantity]) -> None: self._resampler.add_timeseries( "avg", resampler_channel.new_receiver(), sink_buffer ) - self._resampler_task = asyncio.create_task(self._resampler.resample()) + self._tasks.add( + asyncio.create_task(self._resampler.resample(), name="resample") + ) def __len__(self) -> int: """ @@ -262,21 +259,21 @@ def __len__(self) -> int: def __getitem__(self, key: SupportsIndex) -> float: """See the main __getitem__ method. - # noqa: DAR101 key + [//]: # (# noqa: DAR101 key) """ @overload def __getitem__(self, key: datetime) -> float: """See the main __getitem__ method. - # noqa: DAR101 key + [//]: # (# noqa: DAR101 key) """ @overload def __getitem__(self, key: slice) -> ArrayLike: """See the main __getitem__ method. - # noqa: DAR101 key + [//]: # (# noqa: DAR101 key) """ def __getitem__(self, key: SupportsIndex | datetime | slice) -> float | ArrayLike: @@ -305,6 +302,8 @@ def __getitem__(self, key: SupportsIndex | datetime | slice) -> float | ArrayLik A float if the key is a number or a timestamp. an numpy array if the key is a slice. """ + if len(self._buffer) == 0: + raise IndexError("The buffer is empty.") if isinstance(key, slice): if isinstance(key.start, int) or isinstance(key.stop, int): if key.start is None or key.stop is None: @@ -327,6 +326,7 @@ def __getitem__(self, key: SupportsIndex | datetime | slice) -> float | ArrayLik _logger.debug("Returning value at time %s ", key) return self._buffer[self._buffer.datetime_to_index(key)] elif isinstance(key, SupportsIndex): + _logger.debug("Returning value at index %s ", key) return self._buffer[key] raise TypeError( diff --git a/src/frequenz/sdk/timeseries/_periodic_feature_extractor.py b/src/frequenz/sdk/timeseries/_periodic_feature_extractor.py index baa4a31fa..66a954f57 100644 --- a/src/frequenz/sdk/timeseries/_periodic_feature_extractor.py +++ b/src/frequenz/sdk/timeseries/_periodic_feature_extractor.py @@ -82,28 +82,27 @@ class PeriodicFeatureExtractor: from frequenz.sdk import microgrid from datetime import datetime, timedelta, timezone - moving_window = MovingWindow( + async with MovingWindow( size=timedelta(days=35), resampled_data_recv=microgrid.logical_meter().grid_power.new_receiver(), input_sampling_period=timedelta(seconds=1), - ) - - feature_extractor = PeriodicFeatureExtractor( - moving_window = moving_window, - period=timedelta(days=7), - ) + ) as moving_window: + feature_extractor = PeriodicFeatureExtractor( + moving_window=moving_window, + period=timedelta(days=7), + ) - now = datetime.now(timezone.utc) + now = datetime.now(timezone.utc) - # create a daily weighted average for the next 24h - avg_24h = feature_extractor.avg( - now, - now + timedelta(hours=24), - weights=[0.1, 0.2, 0.3, 0.4] - ) + # create a daily weighted average for the next 24h + avg_24h = feature_extractor.avg( + now, + now + timedelta(hours=24), + weights=[0.1, 0.2, 0.3, 0.4] + ) - # create a daily average for Thursday March 23 2023 - th_avg_24h = feature_extractor.avg(datetime(2023, 3, 23), datetime(2023, 3, 24)) + # create a daily average for Thursday March 23 2023 + th_avg_24h = feature_extractor.avg(datetime(2023, 3, 23), datetime(2023, 3, 24)) ``` """ diff --git a/src/frequenz/sdk/timeseries/_quantities.py b/src/frequenz/sdk/timeseries/_quantities.py index 1e896a050..d5dd0147b 100644 --- a/src/frequenz/sdk/timeseries/_quantities.py +++ b/src/frequenz/sdk/timeseries/_quantities.py @@ -3,6 +3,8 @@ """Types for holding quantities with units.""" +# pylint: disable=too-many-lines + from __future__ import annotations import math @@ -18,11 +20,15 @@ "Energy", "Frequency", "Percentage", + "Temperature", ) class Quantity: - """A quantity with a unit.""" + """A quantity with a unit. + + Quantities try to behave like float and are also immutable. + """ _base_value: float """The value of this quantity in the base unit.""" @@ -60,6 +66,30 @@ def __init_subclass__(cls, exponent_unit_map: dict[int, str]) -> None: cls._exponent_unit_map = exponent_unit_map super().__init_subclass__() + _zero_cache: dict[type, Quantity] = {} + """Cache for zero singletons. + + This is a workaround for mypy getting confused when using @functools.cache and + @classmethod combined with returning Self. It believes the resulting type of this + method is Self and complains that members of the actual class don't exist in Self, + so we need to implement the cache ourselves. + """ + + @classmethod + def zero(cls) -> Self: + """Return a quantity with value 0. + + Returns: + A quantity with value 0. + """ + _zero = cls._zero_cache.get(cls, None) + if _zero is None: + _zero = cls.__new__(cls) + _zero._base_value = 0 + cls._zero_cache[cls] = _zero + assert isinstance(_zero, cls) + return _zero + @property def base_value(self) -> float: """Return the value of this quantity in the base unit. @@ -98,13 +128,23 @@ def isinf(self) -> bool: """ return math.isinf(self._base_value) - def __hash__(self) -> int: - """Return a hash of this object. + def isclose(self, other: Self, rel_tol: float = 1e-9, abs_tol: float = 0.0) -> bool: + """Return whether this quantity is close to another. + + Args: + other: The quantity to compare to. + rel_tol: The relative tolerance. + abs_tol: The absolute tolerance. Returns: - A hash of this object. + Whether this quantity is close to another. """ - return hash((type(self), self._base_value)) + return math.isclose( + self._base_value, + other._base_value, # pylint: disable=protected-access + rel_tol=rel_tol, + abs_tol=abs_tol, + ) def __repr__(self) -> str: """Return a representation of this quantity. @@ -221,6 +261,22 @@ def __sub__(self, other: Self) -> Self: difference._base_value = self._base_value - other._base_value return difference + def __mul__(self, percent: Percentage) -> Self: + """Return the product of this quantity and a percentage. + + Args: + percent: The percentage. + + Returns: + The product of this quantity and a percentage. + """ + if not isinstance(percent, Percentage): + return NotImplemented + + product = type(self).__new__(type(self)) + product._base_value = self._base_value * percent.as_fraction() + return product + def __gt__(self, other: Self) -> bool: """Return whether this quantity is greater than another. @@ -331,6 +387,38 @@ def __call__(cls, *_args: Any, **_kwargs: Any) -> NoReturn: ) +class Temperature( + Quantity, + metaclass=_NoDefaultConstructible, + exponent_unit_map={ + 0: "°C", + }, +): + """A temperature quantity (in degrees Celsius).""" + + @classmethod + def from_celsius(cls, value: float) -> Self: + """Initialize a new temperature quantity. + + Args: + value: The temperature in degrees Celsius. + + Returns: + A new temperature quantity. + """ + power = cls.__new__(cls) + power._base_value = value + return power + + def as_celsius(self) -> float: + """Return the temperature in degrees Celsius. + + Returns: + The temperature in degrees Celsius. + """ + return self._base_value + + class Power( Quantity, metaclass=_NoDefaultConstructible, @@ -341,7 +429,16 @@ class Power( 6: "MW", }, ): - """A power quantity.""" + """A power quantity. + + Objects of this type are wrappers around `float` values and are immutable. + + The constructors accept a single `float` value, the `as_*()` methods return a + `float` value, and each of the arithmetic operators supported by this type are + actually implemented using floating-point arithmetic. + + So all considerations about floating-point arithmetic apply to this type as well. + """ @classmethod def from_watts(cls, watts: float) -> Self: @@ -423,18 +520,42 @@ def as_megawatts(self) -> float: """ return self._base_value / 1e6 - def __mul__(self, duration: timedelta) -> Energy: + @overload # type: ignore + def __mul__(self, other: Percentage) -> Self: + """Return a power from multiplying this power by the given percentage. + + Args: + other: The percentage to multiply by. + """ + + @overload + def __mul__(self, other: timedelta) -> Energy: """Return an energy from multiplying this power by the given duration. Args: - duration: The duration to multiply by. + other: The duration to multiply by. + """ + + def __mul__(self, other: Percentage | timedelta) -> Self | Energy: + """Return a power or energy from multiplying this power by the given value. + + Args: + other: The percentage or duration to multiply by. Returns: - An energy from multiplying this power by the given duration. + A power or energy. + + Raises: + TypeError: If the given value is not a percentage or duration. """ - return Energy.from_watt_hours( - self._base_value * duration.total_seconds() / 3600.0 - ) + if isinstance(other, Percentage): + return super().__mul__(other) + if isinstance(other, timedelta): + return Energy.from_watt_hours( + self._base_value * other.total_seconds() / 3600.0 + ) + + return NotImplemented @overload def __truediv__(self, other: Current) -> Voltage: @@ -481,7 +602,16 @@ class Current( 0: "A", }, ): - """A current quantity.""" + """A current quantity. + + Objects of this type are wrappers around `float` values and are immutable. + + The constructors accept a single `float` value, the `as_*()` methods return a + `float` value, and each of the arithmetic operators supported by this type are + actually implemented using floating-point arithmetic. + + So all considerations about floating-point arithmetic apply to this type as well. + """ @classmethod def from_amperes(cls, amperes: float) -> Self: @@ -527,16 +657,40 @@ def as_milliamperes(self) -> float: """ return self._base_value * 1e3 - def __mul__(self, voltage: Voltage) -> Power: + @overload # type: ignore + def __mul__(self, other: Percentage) -> Self: + """Return a power from multiplying this power by the given percentage. + + Args: + other: The percentage to multiply by. + """ + + @overload + def __mul__(self, other: Voltage) -> Power: """Multiply the current by a voltage to get a power. Args: - voltage: The voltage. + other: The voltage. + """ + + def __mul__(self, other: Percentage | Voltage) -> Self | Power: + """Return a current or power from multiplying this current by the given value. + + Args: + other: The percentage or voltage to multiply by. Returns: - The power. + A current or power. + + Raises: + TypeError: If the given value is not a percentage or voltage. """ - return Power.from_watts(self._base_value * voltage._base_value) + if isinstance(other, Percentage): + return super().__mul__(other) + if isinstance(other, Voltage): + return Power.from_watts(self._base_value * other._base_value) + + return NotImplemented class Voltage( @@ -544,7 +698,16 @@ class Voltage( metaclass=_NoDefaultConstructible, exponent_unit_map={0: "V", -3: "mV", 3: "kV"}, ): - """A voltage quantity.""" + """A voltage quantity. + + Objects of this type are wrappers around `float` values and are immutable. + + The constructors accept a single `float` value, the `as_*()` methods return a + `float` value, and each of the arithmetic operators supported by this type are + actually implemented using floating-point arithmetic. + + So all considerations about floating-point arithmetic apply to this type as well. + """ @classmethod def from_volts(cls, volts: float) -> Self: @@ -612,16 +775,40 @@ def as_kilovolts(self) -> float: """ return self._base_value / 1e3 - def __mul__(self, current: Current) -> Power: + @overload # type: ignore + def __mul__(self, other: Percentage) -> Self: + """Return a power from multiplying this power by the given percentage. + + Args: + other: The percentage to multiply by. + """ + + @overload + def __mul__(self, other: Current) -> Power: """Multiply the voltage by the current to get the power. Args: - current: The current to multiply the voltage with. + other: The current to multiply the voltage with. + """ + + def __mul__(self, other: Percentage | Current) -> Self | Power: + """Return a voltage or power from multiplying this voltage by the given value. + + Args: + other: The percentage or current to multiply by. Returns: - The calculated power. + The calculated voltage or power. + + Raises: + TypeError: If the given value is not a percentage or current. """ - return Power.from_watts(self._base_value * current._base_value) + if isinstance(other, Percentage): + return super().__mul__(other) + if isinstance(other, Current): + return Power.from_watts(self._base_value * other._base_value) + + return NotImplemented class Energy( @@ -633,7 +820,16 @@ class Energy( 6: "MWh", }, ): - """An energy quantity.""" + """An energy quantity. + + Objects of this type are wrappers around `float` values and are immutable. + + The constructors accept a single `float` value, the `as_*()` methods return a + `float` value, and each of the arithmetic operators supported by this type are + actually implemented using floating-point arithmetic. + + So all considerations about floating-point arithmetic apply to this type as well. + """ @classmethod def from_watt_hours(cls, watt_hours: float) -> Self: @@ -743,7 +939,16 @@ class Frequency( metaclass=_NoDefaultConstructible, exponent_unit_map={0: "Hz", 3: "kHz", 6: "MHz", 9: "GHz"}, ): - """A frequency quantity.""" + """A frequency quantity. + + Objects of this type are wrappers around `float` values and are immutable. + + The constructors accept a single `float` value, the `as_*()` methods return a + `float` value, and each of the arithmetic operators supported by this type are + actually implemented using floating-point arithmetic. + + So all considerations about floating-point arithmetic apply to this type as well. + """ @classmethod def from_hertz(cls, hertz: float) -> Self: @@ -847,7 +1052,16 @@ class Percentage( metaclass=_NoDefaultConstructible, exponent_unit_map={0: "%"}, ): - """A percentage quantity.""" + """A percentage quantity. + + Objects of this type are wrappers around `float` values and are immutable. + + The constructors accept a single `float` value, the `as_*()` methods return a + `float` value, and each of the arithmetic operators supported by this type are + actually implemented using floating-point arithmetic. + + So all considerations about floating-point arithmetic apply to this type as well. + """ @classmethod def from_percent(cls, percent: float) -> Self: diff --git a/src/frequenz/sdk/timeseries/_resampling.py b/src/frequenz/sdk/timeseries/_resampling.py index 2d16abb9b..587e7118e 100644 --- a/src/frequenz/sdk/timeseries/_resampling.py +++ b/src/frequenz/sdk/timeseries/_resampling.py @@ -15,6 +15,9 @@ from datetime import datetime, timedelta, timezone from typing import AsyncIterator, Callable, Coroutine, Optional, Sequence +from frequenz.channels.util import Timer +from frequenz.channels.util._timer import _to_microseconds + from .._internal._asyncio import cancel_and_await from ._base_types import UNIX_EPOCH, Sample from ._quantities import Quantity, QuantityT @@ -367,7 +370,8 @@ def __init__(self, config: ResamplerConfig) -> None: self._resamplers: dict[Source, _StreamingHelper] = {} """A mapping between sources and the streaming helper handling that source.""" - self._window_end: datetime = self._calculate_window_end() + window_end, start_delay_time = self._calculate_window_end() + self._window_end: datetime = window_end """The time in which the current window ends. This is used to make sure every resampling window is generated at @@ -380,6 +384,16 @@ def __init__(self, config: ResamplerConfig) -> None: the window end is deterministic. """ + self._timer: Timer = Timer.periodic(config.resampling_period) + """The timer used to trigger the resampling windows.""" + + # Hack to align the timer, this should be implemented in the Timer class + self._timer._next_tick_time = _to_microseconds( + timedelta(seconds=asyncio.get_running_loop().time()) + + config.resampling_period + + start_delay_time + ) # pylint: disable=protected-access + @property def config(self) -> ResamplerConfig: """Get the resampler configuration. @@ -461,15 +475,32 @@ async def resample(self, *, one_shot: bool = False) -> None: timeseries from the resampler before calling this method again). """ - while True: - await self._wait_for_next_resampling_period() + # We use a tolerance of 10% of the resampling period + tolerance = timedelta( + seconds=self._config.resampling_period.total_seconds() / 10.0 + ) + + async for drift in self._timer: + now = datetime.now(tz=timezone.utc) + + if drift > tolerance: + _logger.warning( + "The resampling task woke up too late. Resampling should have " + "started at %s, but it started at %s (tolerance: %s, " + "difference: %s; resampling period: %s)", + self._window_end, + now, + tolerance, + drift, + self._config.resampling_period, + ) results = await asyncio.gather( *[r.resample(self._window_end) for r in self._resamplers.values()], return_exceptions=True, ) - self._window_end = self._window_end + self._config.resampling_period + self._window_end += self._config.resampling_period exceptions = { source: results[i] for i, source in enumerate(self._resamplers) @@ -482,36 +513,7 @@ async def resample(self, *, one_shot: bool = False) -> None: if one_shot: break - async def _wait_for_next_resampling_period(self) -> None: - """Wait for next resampling period. - - If resampling period already started, then return without sleeping. - That would allow us to catch up with resampling. - Print warning if function woke up to late. - """ - now = datetime.now(tz=timezone.utc) - if self._window_end > now: - sleep_for = self._window_end - now - await asyncio.sleep(sleep_for.total_seconds()) - - timer_error = now - self._window_end - # We use a tolerance of 10% of the resampling period - tolerance = timedelta( - seconds=self._config.resampling_period.total_seconds() / 10.0 - ) - if timer_error > tolerance: - _logger.warning( - "The resampling task woke up too late. Resampling should have " - "started at %s, but it started at %s (tolerance: %s, " - "difference: %s; resampling period: %s)", - self._window_end, - now, - tolerance, - timer_error, - self._config.resampling_period, - ) - - def _calculate_window_end(self) -> datetime: + def _calculate_window_end(self) -> tuple[datetime, timedelta]: """Calculate the end of the current resampling window. The calculated resampling window end is a multiple of @@ -519,20 +521,35 @@ def _calculate_window_end(self) -> datetime: if `self._config.align_to` is `None`, the current time is used. + If the current time is not aligned to `self._config.resampling_period`, then + the end of the current resampling window will be more than one period away, to + make sure to have some time to collect samples if the misalignment is too big. + Returns: - The end of the current resampling window aligned to - `self._config.align_to`. + A tuple with the end of the current resampling window aligned to + `self._config.align_to` as the first item and the time we need to + delay the timer start to make sure it is also aligned. """ now = datetime.now(timezone.utc) period = self._config.resampling_period align_to = self._config.align_to if align_to is None: - return now + period + return (now + period, timedelta(0)) elapsed = (now - align_to) % period - return now + period - elapsed + # If we are already in sync, we don't need to add an extra period + if not elapsed: + return (now + period, timedelta(0)) + + return ( + # We add an extra period when it is not aligned to make sure we collected + # enough samples before the first resampling, otherwise the initial window + # to collect samples could be too small. + now + period * 2 - elapsed, + period - elapsed if elapsed else timedelta(0), + ) class _ResamplingHelper: @@ -798,7 +815,7 @@ async def resample(self, timestamp: datetime) -> None: If the error is in the sink, the receiving part will continue working while this helper is alive. - # noqa: DAR401 recv_exception + [//]: # (# noqa: DAR401 recv_exception) """ if self._receiving_task.done(): if recv_exception := self._receiving_task.exception(): diff --git a/src/frequenz/sdk/timeseries/_ringbuffer/buffer.py b/src/frequenz/sdk/timeseries/_ringbuffer/buffer.py index 5a794d309..a1616d616 100644 --- a/src/frequenz/sdk/timeseries/_ringbuffer/buffer.py +++ b/src/frequenz/sdk/timeseries/_ringbuffer/buffer.py @@ -328,17 +328,24 @@ def _cleanup_gaps(self) -> None: self._gaps = sorted(self._gaps, key=lambda x: x.start.timestamp()) i = 0 - while i < len(self._gaps) - 1: + while i < len(self._gaps): w_1 = self._gaps[i] - w_2 = self._gaps[i + 1] + if i < len(self._gaps) - 1: + w_2 = self._gaps[i + 1] + else: + w_2 = None + # Delete out-of-date gaps if w_1.end <= self._datetime_oldest: del self._gaps[i] + # Update start of gap if it's rolled out of the buffer + elif w_1.start < self._datetime_oldest: + self._gaps[i].start = self._datetime_oldest # If w2 is a subset of w1 we can delete it - elif w_1.start <= w_2.start and w_1.end >= w_2.end: + elif w_2 and w_1.start <= w_2.start and w_1.end >= w_2.end: del self._gaps[i + 1] # If the gaps are direct neighbors, merge them - elif w_1.end >= w_2.start: + elif w_2 and w_1.end >= w_2.start: w_1.end = w_2.end del self._gaps[i + 1] else: diff --git a/src/frequenz/sdk/timeseries/battery_pool/__init__.py b/src/frequenz/sdk/timeseries/battery_pool/__init__.py index 56f07eda2..1550595b4 100644 --- a/src/frequenz/sdk/timeseries/battery_pool/__init__.py +++ b/src/frequenz/sdk/timeseries/battery_pool/__init__.py @@ -3,11 +3,11 @@ """Manage a pool of batteries.""" -from ._result_types import Bound, PowerMetrics +from ._result_types import Bounds, PowerMetrics from .battery_pool import BatteryPool __all__ = [ "BatteryPool", "PowerMetrics", - "Bound", + "Bounds", ] diff --git a/src/frequenz/sdk/timeseries/battery_pool/_metric_calculator.py b/src/frequenz/sdk/timeseries/battery_pool/_metric_calculator.py index f0da2c95e..ceb730e0d 100644 --- a/src/frequenz/sdk/timeseries/battery_pool/_metric_calculator.py +++ b/src/frequenz/sdk/timeseries/battery_pool/_metric_calculator.py @@ -12,9 +12,10 @@ from ...microgrid import connection_manager from ...microgrid.component import ComponentCategory, ComponentMetricId, InverterType -from ...timeseries import Energy, Percentage, Sample +from ...timeseries import Sample +from .._quantities import Energy, Percentage, Power, Temperature from ._component_metrics import ComponentMetricsData -from ._result_types import Bound, PowerMetrics +from ._result_types import Bounds, PowerMetrics _logger = logging.getLogger(__name__) _MIN_TIMESTAMP = datetime.min.replace(tzinfo=timezone.utc) @@ -59,7 +60,7 @@ def battery_inverter_mapping(batteries: Iterable[int]) -> dict[int, int]: # Formula output types class have no common interface # Print all possible types here. -T = TypeVar("T", Sample[Percentage], Sample[Energy], PowerMetrics) +T = TypeVar("T", Sample[Percentage], Sample[Energy], PowerMetrics, Sample[Temperature]) class MetricCalculator(ABC, Generic[T]): @@ -234,6 +235,93 @@ def calculate( ) +class TemperatureCalculator(MetricCalculator[Sample[Temperature]]): + """Define how to calculate temperature metrics.""" + + def __init__(self, batteries: Set[int]) -> None: + """Create class instance. + + Args: + batteries: What batteries should be used for calculation. + """ + super().__init__(batteries) + + self._metrics = [ + ComponentMetricId.TEMPERATURE, + ] + + @classmethod + def name(cls) -> str: + """Return name of the calculator. + + Returns: + Name of the calculator + """ + return "temperature" + + @property + def battery_metrics(self) -> Mapping[int, list[ComponentMetricId]]: + """Return what metrics are needed for each battery. + + Returns: + Map between battery id and set of required metrics id. + """ + return {bid: self._metrics for bid in self._batteries} + + @property + def inverter_metrics(self) -> Mapping[int, list[ComponentMetricId]]: + """Return what metrics are needed for each inverter. + + Returns: + Map between inverter id and set of required metrics id. + """ + return {} + + def calculate( + self, + metrics_data: dict[int, ComponentMetricsData], + working_batteries: set[int], + ) -> Sample[Temperature] | None: + """Aggregate the metrics_data and calculate high level metric for temperature. + + Missing components will be ignored. Formula will be calculated for all + working batteries that are in metrics_data. + + Args: + metrics_data: Components metrics data, that should be used to calculate the + result. + working_batteries: working batteries. These batteries will be used + to calculate the result. It should be subset of the batteries given in a + constructor. + + Returns: + High level metric calculated from the given metrics. + Return None if there are no component metrics. + """ + timestamp = _MIN_TIMESTAMP + temperature_sum: float = 0.0 + temperature_count: int = 0 + for battery_id in working_batteries: + if battery_id not in metrics_data: + continue + metrics = metrics_data[battery_id] + temperature = metrics.get(ComponentMetricId.TEMPERATURE) + if temperature is None: + continue + timestamp = max(timestamp, metrics.timestamp) + temperature_sum += temperature + temperature_count += 1 + if timestamp == _MIN_TIMESTAMP: + return None + + temperature_avg = temperature_sum / temperature_count + + return Sample[Temperature]( + timestamp=timestamp, + value=Temperature.from_celsius(value=temperature_avg), + ) + + class SoCCalculator(MetricCalculator[Sample[Percentage]]): """Define how to calculate SoC metrics.""" @@ -337,7 +425,8 @@ def calculate( soc_scaled = ( (soc - soc_lower_bound) / (soc_upper_bound - soc_lower_bound) * 100 ) - soc_scaled = max(soc_scaled, 0) + # we are clamping here because the SoC might be out of bounds + soc_scaled = min(max(soc_scaled, 0), 100) timestamp = max(timestamp, metrics.timestamp) used_capacity_x100 += usable_capacity_x100 * soc_scaled total_capacity_x100 += usable_capacity_x100 @@ -390,13 +479,17 @@ def __init__( super().__init__(used_batteries) self._battery_metrics = [ - ComponentMetricId.POWER_LOWER_BOUND, - ComponentMetricId.POWER_UPPER_BOUND, + ComponentMetricId.POWER_INCLUSION_LOWER_BOUND, + ComponentMetricId.POWER_EXCLUSION_LOWER_BOUND, + ComponentMetricId.POWER_EXCLUSION_UPPER_BOUND, + ComponentMetricId.POWER_INCLUSION_UPPER_BOUND, ] self._inverter_metrics = [ - ComponentMetricId.ACTIVE_POWER_LOWER_BOUND, - ComponentMetricId.ACTIVE_POWER_UPPER_BOUND, + ComponentMetricId.ACTIVE_POWER_INCLUSION_LOWER_BOUND, + ComponentMetricId.ACTIVE_POWER_EXCLUSION_LOWER_BOUND, + ComponentMetricId.ACTIVE_POWER_EXCLUSION_UPPER_BOUND, + ComponentMetricId.ACTIVE_POWER_INCLUSION_UPPER_BOUND, ] @classmethod @@ -426,6 +519,84 @@ def inverter_metrics(self) -> Mapping[int, list[ComponentMetricId]]: """ return {cid: self._inverter_metrics for cid in set(self._bat_inv_map.values())} + def _fetch_inclusion_bounds( + self, + battery_id: int, + inverter_id: int, + metrics_data: dict[int, ComponentMetricsData], + ) -> tuple[datetime, list[float], list[float]]: + timestamp = _MIN_TIMESTAMP + inclusion_lower_bounds: list[float] = [] + inclusion_upper_bounds: list[float] = [] + + # Inclusion upper and lower bounds are not related. + # If one is missing, then we can still use the other. + if battery_id in metrics_data: + data = metrics_data[battery_id] + value = data.get(ComponentMetricId.POWER_INCLUSION_UPPER_BOUND) + if value is not None: + timestamp = max(timestamp, data.timestamp) + inclusion_upper_bounds.append(value) + + value = data.get(ComponentMetricId.POWER_INCLUSION_LOWER_BOUND) + if value is not None: + timestamp = max(timestamp, data.timestamp) + inclusion_lower_bounds.append(value) + + if inverter_id in metrics_data: + data = metrics_data[inverter_id] + + value = data.get(ComponentMetricId.ACTIVE_POWER_INCLUSION_UPPER_BOUND) + if value is not None: + timestamp = max(data.timestamp, timestamp) + inclusion_upper_bounds.append(value) + + value = data.get(ComponentMetricId.ACTIVE_POWER_INCLUSION_LOWER_BOUND) + if value is not None: + timestamp = max(data.timestamp, timestamp) + inclusion_lower_bounds.append(value) + + return (timestamp, inclusion_lower_bounds, inclusion_upper_bounds) + + def _fetch_exclusion_bounds( + self, + battery_id: int, + inverter_id: int, + metrics_data: dict[int, ComponentMetricsData], + ) -> tuple[datetime, list[float], list[float]]: + timestamp = _MIN_TIMESTAMP + exclusion_lower_bounds: list[float] = [] + exclusion_upper_bounds: list[float] = [] + + # Exclusion upper and lower bounds are not related. + # If one is missing, then we can still use the other. + if battery_id in metrics_data: + data = metrics_data[battery_id] + value = data.get(ComponentMetricId.POWER_EXCLUSION_UPPER_BOUND) + if value is not None: + timestamp = max(timestamp, data.timestamp) + exclusion_upper_bounds.append(value) + + value = data.get(ComponentMetricId.POWER_EXCLUSION_LOWER_BOUND) + if value is not None: + timestamp = max(timestamp, data.timestamp) + exclusion_lower_bounds.append(value) + + if inverter_id in metrics_data: + data = metrics_data[inverter_id] + + value = data.get(ComponentMetricId.ACTIVE_POWER_EXCLUSION_UPPER_BOUND) + if value is not None: + timestamp = max(data.timestamp, timestamp) + exclusion_upper_bounds.append(value) + + value = data.get(ComponentMetricId.ACTIVE_POWER_EXCLUSION_LOWER_BOUND) + if value is not None: + timestamp = max(data.timestamp, timestamp) + exclusion_lower_bounds.append(value) + + return (timestamp, exclusion_lower_bounds, exclusion_upper_bounds) + def calculate( self, metrics_data: dict[int, ComponentMetricsData], @@ -445,53 +616,45 @@ def calculate( High level metric calculated from the given metrics. Return None if there are no component metrics. """ - # In the future we will have lower bound, too. - - result = PowerMetrics( - timestamp=_MIN_TIMESTAMP, - supply_bound=Bound(0, 0), - consume_bound=Bound(0, 0), - ) + timestamp = _MIN_TIMESTAMP + inclusion_bounds_lower = 0.0 + inclusion_bounds_upper = 0.0 + exclusion_bounds_lower = 0.0 + exclusion_bounds_upper = 0.0 for battery_id in working_batteries: - supply_upper_bounds: list[float] = [] - consume_upper_bounds: list[float] = [] - - if battery_id in metrics_data: - data = metrics_data[battery_id] - - # Consume and supply bounds are not related. - # If one is missing, then we can still use the other. - value = data.get(ComponentMetricId.POWER_UPPER_BOUND) - if value is not None: - result.timestamp = max(result.timestamp, data.timestamp) - consume_upper_bounds.append(value) - - value = data.get(ComponentMetricId.POWER_LOWER_BOUND) - if value is not None: - result.timestamp = max(result.timestamp, data.timestamp) - supply_upper_bounds.append(value) - inverter_id = self._bat_inv_map[battery_id] - if inverter_id in metrics_data: - data = metrics_data[inverter_id] - - value = data.get(ComponentMetricId.ACTIVE_POWER_UPPER_BOUND) - if value is not None: - result.timestamp = max(data.timestamp, result.timestamp) - consume_upper_bounds.append(value) - - value = data.get(ComponentMetricId.ACTIVE_POWER_LOWER_BOUND) - if value is not None: - result.timestamp = max(data.timestamp, result.timestamp) - supply_upper_bounds.append(value) - - if len(consume_upper_bounds) > 0: - result.consume_bound.upper += min(consume_upper_bounds) - if len(supply_upper_bounds) > 0: - result.supply_bound.lower += max(supply_upper_bounds) + ( + _ts, + inclusion_lower_bounds, + inclusion_upper_bounds, + ) = self._fetch_inclusion_bounds(battery_id, inverter_id, metrics_data) + timestamp = max(timestamp, _ts) + ( + _ts, + exclusion_lower_bounds, + exclusion_upper_bounds, + ) = self._fetch_exclusion_bounds(battery_id, inverter_id, metrics_data) + if len(inclusion_upper_bounds) > 0: + inclusion_bounds_upper += min(inclusion_upper_bounds) + if len(inclusion_lower_bounds) > 0: + inclusion_bounds_lower += max(inclusion_lower_bounds) + if len(exclusion_upper_bounds) > 0: + exclusion_bounds_upper += max(exclusion_upper_bounds) + if len(exclusion_lower_bounds) > 0: + exclusion_bounds_lower += min(exclusion_lower_bounds) - if result.timestamp == _MIN_TIMESTAMP: + if timestamp == _MIN_TIMESTAMP: return None - return result + return PowerMetrics( + timestamp=timestamp, + inclusion_bounds=Bounds( + Power.from_watts(inclusion_bounds_lower), + Power.from_watts(inclusion_bounds_upper), + ), + exclusion_bounds=Bounds( + Power.from_watts(exclusion_bounds_lower), + Power.from_watts(exclusion_bounds_upper), + ), + ) diff --git a/src/frequenz/sdk/timeseries/battery_pool/_result_types.py b/src/frequenz/sdk/timeseries/battery_pool/_result_types.py index 728d68a5e..8b8ea6cd0 100644 --- a/src/frequenz/sdk/timeseries/battery_pool/_result_types.py +++ b/src/frequenz/sdk/timeseries/battery_pool/_result_types.py @@ -6,15 +6,17 @@ from dataclasses import dataclass, field from datetime import datetime +from .._quantities import Power + @dataclass -class Bound: +class Bounds: """Lower and upper bound values.""" - lower: float + lower: Power """Lower bound.""" - upper: float + upper: Power """Upper bound.""" @@ -26,38 +28,28 @@ class PowerMetrics: timestamp: datetime = field(compare=False) """Timestamp of the metrics.""" - supply_bound: Bound - """Supply power bounds. - - Upper bound is always 0 and will be supported later. - Lower bound is negative number calculated with with the formula: - ```python - working_pairs: Set[BatteryData, InverterData] # working batteries from the battery - pool and adjacent inverters - - supply_bound.lower = sum( - max( - battery.power_lower_bound, inverter.active_power_lower_bound) - for each working battery in battery pool - ) - ) - ``` + # pylint: disable=line-too-long + inclusion_bounds: Bounds + """Inclusion power bounds for all batteries in the battery pool instance. + + This is the range within which power requests are allowed by the battery pool. + + When exclusion bounds are present, they will exclude a subset of the inclusion + bounds. + + See [`frequenz.api.common.metrics_pb2.Metric.system_inclusion_bounds`][] and + [`frequenz.api.common.metrics_pb2.Metric.system_exclusion_bounds`][] for more + details. """ - consume_bound: Bound - """Consume power bounds. - - Lower bound is always 0 and will be supported later. - Upper bound is positive number calculated with with the formula: - ```python - working_pairs: Set[BatteryData, InverterData] # working batteries from the battery - pool and adjacent inverters - - consume_bound.upper = sum( - min( - battery.power_upper_bound, inverter.active_power_upper_bound) - for each working battery in battery pool - ) - ) - ``` + exclusion_bounds: Bounds + """Exclusion power bounds for all batteries in the battery pool instance. + + This is the range within which power requests are NOT allowed by the battery pool. + If present, they will be a subset of the inclusion bounds. + + See [`frequenz.api.common.metrics_pb2.Metric.system_inclusion_bounds`][] and + [`frequenz.api.common.metrics_pb2.Metric.system_exclusion_bounds`][] for more + details. """ + # pylint: enable=line-too-long diff --git a/src/frequenz/sdk/timeseries/battery_pool/battery_pool.py b/src/frequenz/sdk/timeseries/battery_pool/battery_pool.py index fa23bd5de..6fc3ae54e 100644 --- a/src/frequenz/sdk/timeseries/battery_pool/battery_pool.py +++ b/src/frequenz/sdk/timeseries/battery_pool/battery_pool.py @@ -27,9 +27,14 @@ FormulaGeneratorConfig, FormulaType, ) -from .._quantities import Energy, Percentage, Power +from .._quantities import Energy, Percentage, Power, Temperature from ._methods import MetricAggregator, SendOnUpdate -from ._metric_calculator import CapacityCalculator, PowerBoundsCalculator, SoCCalculator +from ._metric_calculator import ( + CapacityCalculator, + PowerBoundsCalculator, + SoCCalculator, + TemperatureCalculator, +) from ._result_types import PowerMetrics @@ -342,16 +347,17 @@ def consumption_power(self) -> FormulaEngine[Power]: def soc(self) -> MetricAggregator[Sample[Percentage]]: """Fetch the normalized average weighted-by-capacity SoC values for the pool. - The values are normalized to the 0-100% range. + The values are normalized to the 0-100% range and clamped if the SoC is out of + bounds. Average soc is calculated with the formula: ``` working_batteries: Set[BatteryData] # working batteries from the battery pool - soc_scaled = max( + soc_scaled = min(max( 0, (soc - soc_lower_bound) / (soc_upper_bound - soc_lower_bound) * 100, - ) + ), 100) used_capacity = sum( battery.usable_capacity * battery.soc_scaled for battery in working_batteries @@ -382,6 +388,24 @@ def soc(self) -> MetricAggregator[Sample[Percentage]]: return self._active_methods[method_name] + @property + def temperature(self) -> MetricAggregator[Sample[Temperature]]: + """Fetch the average temperature of the batteries in the pool. + + Returns: + A MetricAggregator that will calculate and stream the average temperature + of all batteries in the pool. + """ + method_name = SendOnUpdate.name() + "_" + TemperatureCalculator.name() + if method_name not in self._active_methods: + calculator = TemperatureCalculator(self._batteries) + self._active_methods[method_name] = SendOnUpdate( + metric_calculator=calculator, + working_batteries=self._working_batteries, + min_update_interval=self._min_update_interval, + ) + return self._active_methods[method_name] + @property def capacity(self) -> MetricAggregator[Sample[Energy]]: """Get receiver to receive new capacity metrics when they change. diff --git a/src/frequenz/sdk/timeseries/ev_charger_pool/_state_tracker.py b/src/frequenz/sdk/timeseries/ev_charger_pool/_state_tracker.py index 76d31f49e..c10004129 100644 --- a/src/frequenz/sdk/timeseries/ev_charger_pool/_state_tracker.py +++ b/src/frequenz/sdk/timeseries/ev_charger_pool/_state_tracker.py @@ -12,9 +12,8 @@ from frequenz.channels import Receiver from frequenz.channels.util import Merge -from frequenz.sdk import microgrid -from frequenz.sdk._internal._asyncio import cancel_and_await - +from ... import microgrid +from ..._internal._asyncio import cancel_and_await from ...microgrid.component import ( EVChargerCableState, EVChargerComponentState, diff --git a/src/frequenz/sdk/timeseries/logical_meter/_logical_meter.py b/src/frequenz/sdk/timeseries/logical_meter/_logical_meter.py index 78c736ec1..a91da6c13 100644 --- a/src/frequenz/sdk/timeseries/logical_meter/_logical_meter.py +++ b/src/frequenz/sdk/timeseries/logical_meter/_logical_meter.py @@ -57,41 +57,40 @@ class LogicalMeter: ) # Instantiate a resampling actor - _resampling_actor = ComponentMetricsResamplingActor( + async with ComponentMetricsResamplingActor( channel_registry=channel_registry, data_sourcing_request_sender=data_source_request_sender, resampling_request_receiver=resampling_request_receiver, config=ResamplerConfig(resampling_period=timedelta(seconds=1)), - ) - - await initialize( - "127.0.0.1", - 50051, - ResamplerConfig(resampling_period=timedelta(seconds=1)) - ) - - # Create a logical meter instance - logical_meter = LogicalMeter( - channel_registry, - resampling_request_sender, - ) + ): + await initialize( + "127.0.0.1", + 50051, + ResamplerConfig(resampling_period=timedelta(seconds=1)) + ) - # Get a receiver for a builtin formula - grid_power_recv = logical_meter.grid_power.new_receiver() - for grid_power_sample in grid_power_recv: - print(grid_power_sample) + # Create a logical meter instance + logical_meter = LogicalMeter( + channel_registry, + resampling_request_sender, + ) - # or compose formula receivers to create a new formula - net_power_recv = ( - ( - logical_meter.grid_power - - logical_meter.pv_power + # Get a receiver for a builtin formula + grid_power_recv = logical_meter.grid_power.new_receiver() + for grid_power_sample in grid_power_recv: + print(grid_power_sample) + + # or compose formula receivers to create a new formula + net_power_recv = ( + ( + logical_meter.grid_power + - logical_meter.pv_power + ) + .build("net_power") + .new_receiver() ) - .build("net_power") - .new_receiver() - ) - for net_power_sample in net_power_recv: - print(net_power_sample) + for net_power_sample in net_power_recv: + print(net_power_sample) ``` """ diff --git a/tests/power/__init__.py b/tests/actor/power_distributing/__init__.py similarity index 53% rename from tests/power/__init__.py rename to tests/actor/power_distributing/__init__.py index eb6dafdb1..cf08480b6 100644 --- a/tests/power/__init__.py +++ b/tests/actor/power_distributing/__init__.py @@ -1,4 +1,4 @@ # License: MIT # Copyright © 2022 Frequenz Energy-as-a-Service GmbH -"""Test power distribution module.""" +"""Tests for the power distributing actor and algorithm.""" diff --git a/tests/power/test_distribution_algorithm.py b/tests/actor/power_distributing/test_distribution_algorithm.py similarity index 57% rename from tests/power/test_distribution_algorithm.py rename to tests/actor/power_distributing/test_distribution_algorithm.py index 0dae16bf3..87c2badf8 100644 --- a/tests/power/test_distribution_algorithm.py +++ b/tests/actor/power_distributing/test_distribution_algorithm.py @@ -8,13 +8,17 @@ from datetime import datetime, timezone from typing import Dict, List, Optional -from frequenz.api.microgrid.common_pb2 import Bounds from pytest import approx, raises +from frequenz.sdk.actor.power_distributing._distribution_algorithm import ( + DistributionAlgorithm, + DistributionResult, + InvBatPair, +) +from frequenz.sdk.actor.power_distributing.result import PowerBounds from frequenz.sdk.microgrid.component import BatteryData, InverterData -from frequenz.sdk.power import DistributionAlgorithm, InvBatPair -from ..utils.component_data_wrapper import BatteryDataWrapper, InverterDataWrapper +from ...utils.component_data_wrapper import BatteryDataWrapper, InverterDataWrapper @dataclass @@ -24,14 +28,6 @@ class Bound: lower: float upper: float - def to_protobuf(self) -> Bounds: - """Create protobuf Bounds message from that instance. - - Returns: - Protobuf Bounds message. - """ - return Bounds(lower=self.lower, upper=self.upper) - @dataclass class Metric: @@ -45,7 +41,7 @@ def battery_msg( # pylint: disable=too-many-arguments component_id: int, capacity: Metric, soc: Metric, - power: Bound, + power: PowerBounds, timestamp: datetime = datetime.now(timezone.utc), ) -> BatteryData: """Create protobuf battery components with given arguments. @@ -67,15 +63,17 @@ def battery_msg( # pylint: disable=too-many-arguments soc=soc.now if soc.now is not None else math.nan, soc_lower_bound=soc.bound.lower if soc.bound is not None else math.nan, soc_upper_bound=soc.bound.upper if soc.bound is not None else math.nan, - power_lower_bound=power.lower, - power_upper_bound=power.upper, + power_inclusion_lower_bound=power.inclusion_lower, + power_exclusion_lower_bound=power.exclusion_lower, + power_exclusion_upper_bound=power.exclusion_upper, + power_inclusion_upper_bound=power.inclusion_upper, timestamp=timestamp, ) def inverter_msg( component_id: int, - power: Bound, + power: PowerBounds, timestamp: datetime = datetime.now(timezone.utc), ) -> InverterData: """Create protobuf inverter components with given arguments. @@ -92,11 +90,40 @@ def inverter_msg( return InverterDataWrapper( component_id=component_id, timestamp=timestamp, - active_power_lower_bound=power.lower, - active_power_upper_bound=power.upper, + active_power_inclusion_lower_bound=power.inclusion_lower, + active_power_exclusion_lower_bound=power.exclusion_lower, + active_power_exclusion_upper_bound=power.exclusion_upper, + active_power_inclusion_upper_bound=power.inclusion_upper, ) +def create_components( + num: int, + capacity: List[Metric], + soc: List[Metric], + power: List[PowerBounds], +) -> List[InvBatPair]: + """Create components with given arguments. + + Args: + num: Number of components + capacity: Capacity for each battery + soc: SoC for each battery + soc_bounds: SoC bounds for each battery + supply_bounds: Supply bounds for each battery and inverter + consumption_bounds: Consumption bounds for each battery and inverter + + Returns: + List of the components + """ + components: List[InvBatPair] = [] + for i in range(0, num): + battery = battery_msg(2 * i, capacity[i], soc[i], power[2 * i]) + inverter = inverter_msg(2 * i + 1, power[2 * i + 1]) + components.append(InvBatPair(battery, inverter)) + return components + + class TestDistributionAlgorithm: # pylint: disable=too-many-public-methods """Test whether the algorithm works as expected.""" @@ -144,11 +171,12 @@ def test_distribute_power_one_battery(self) -> None: components = self.create_components_with_capacity(1, capacity) available_soc: Dict[int, float] = {0: 40} - upper_bounds: Dict[int, float] = {1: 500} + incl_bounds: Dict[int, float] = {1: 500} + excl_bounds: Dict[int, float] = {1: 0} algorithm = DistributionAlgorithm(distributor_exponent=1) result = algorithm._distribute_power( # pylint: disable=protected-access - components, 650, available_soc, upper_bounds + components, 650, available_soc, incl_bounds, excl_bounds ) assert result.distribution == approx({1: 500}) @@ -164,11 +192,12 @@ def test_distribute_power_two_batteries_1(self) -> None: components = self.create_components_with_capacity(2, capacity) available_soc: Dict[int, float] = {0: 40, 2: 20} - upper_bounds: Dict[int, float] = {1: 500, 3: 500} + incl_bounds: Dict[int, float] = {1: 500, 3: 500} + excl_bounds: Dict[int, float] = {1: 0, 3: 0} algorithm = DistributionAlgorithm(distributor_exponent=1) result = algorithm._distribute_power( # pylint: disable=protected-access - components, 600, available_soc, upper_bounds + components, 600, available_soc, incl_bounds, excl_bounds ) assert result.distribution == approx({1: 400, 3: 200}) @@ -184,11 +213,12 @@ def test_distribute_power_two_batteries_2(self) -> None: components = self.create_components_with_capacity(2, capacity) available_soc: Dict[int, float] = {0: 20, 2: 20} - upper_bounds: Dict[int, float] = {1: 500, 3: 500} + incl_bounds: Dict[int, float] = {1: 500, 3: 500} + excl_bounds: Dict[int, float] = {1: 0, 3: 0} algorithm = DistributionAlgorithm(distributor_exponent=1) result = algorithm._distribute_power( # pylint: disable=protected-access - components, 600, available_soc, upper_bounds + components, 600, available_soc, incl_bounds, excl_bounds ) assert result.distribution == approx({1: 200, 3: 400}) @@ -205,11 +235,12 @@ def test_distribute_power_two_batteries_bounds(self) -> None: components = self.create_components_with_capacity(2, capacity) available_soc: Dict[int, float] = {0: 40, 2: 20} - upper_bounds: Dict[int, float] = {1: 250, 3: 330} + incl_bounds: Dict[int, float] = {1: 250, 3: 330} + excl_bounds: Dict[int, float] = {1: 0, 3: 0} algorithm = DistributionAlgorithm(distributor_exponent=1) result = algorithm._distribute_power( # pylint: disable=protected-access - components, 600, available_soc, upper_bounds + components, 600, available_soc, incl_bounds, excl_bounds ) assert result.distribution == approx({1: 250, 3: 330}) @@ -221,11 +252,12 @@ def test_distribute_power_three_batteries(self) -> None: components = self.create_components_with_capacity(3, capacity) available_soc: Dict[int, float] = {0: 40, 2: 20, 4: 20} - upper_bounds: Dict[int, float] = {1: 1000, 3: 3400, 5: 3550} + incl_bounds: Dict[int, float] = {1: 1000, 3: 3400, 5: 3550} + excl_bounds: Dict[int, float] = {1: 0, 3: 0, 5: 0} algorithm = DistributionAlgorithm(distributor_exponent=1) result = algorithm._distribute_power( # pylint: disable=protected-access - components, 1000, available_soc, upper_bounds + components, 1000, available_soc, incl_bounds, excl_bounds ) assert result.distribution == approx({1: 400, 3: 400, 5: 200}) @@ -237,11 +269,12 @@ def test_distribute_power_three_batteries_2(self) -> None: components = self.create_components_with_capacity(3, capacity) available_soc: Dict[int, float] = {0: 80, 2: 10, 4: 20} - upper_bounds: Dict[int, float] = {1: 400, 3: 3400, 5: 300} + incl_bounds: Dict[int, float] = {1: 400, 3: 3400, 5: 300} + excl_bounds: Dict[int, float] = {1: 0, 3: 0, 5: 0} algorithm = DistributionAlgorithm(distributor_exponent=1) result = algorithm._distribute_power( # pylint: disable=protected-access - components, 1000, available_soc, upper_bounds + components, 1000, available_soc, incl_bounds, excl_bounds ) assert result.distribution == approx({1: 400, 3: 300, 5: 300}) @@ -253,44 +286,17 @@ def test_distribute_power_three_batteries_3(self) -> None: components = self.create_components_with_capacity(3, capacity) available_soc: Dict[int, float] = {0: 80, 2: 10, 4: 20} - upper_bounds: Dict[int, float] = {1: 500, 3: 300, 5: 300} + incl_bounds: Dict[int, float] = {1: 500, 3: 300, 5: 300} + excl_bounds: Dict[int, float] = {1: 0, 3: 0, 5: 0} algorithm = DistributionAlgorithm(distributor_exponent=1) result = algorithm._distribute_power( # pylint: disable=protected-access - components, 1000, available_soc, upper_bounds + components, 1000, available_soc, incl_bounds, excl_bounds ) assert result.distribution == approx({1: 0, 3: 300, 5: 0}) assert result.remaining_power == approx(700.0) - def create_components( # pylint: disable=too-many-arguments - self, - num: int, - capacity: List[Metric], - soc: List[Metric], - power: List[Bound], - ) -> List[InvBatPair]: - """Create components with given arguments. - - Args: - num: Number of components - capacity: Capacity for each battery - soc: SoC for each battery - soc_bounds: SoC bounds for each battery - supply_bounds: Supply bounds for each battery and inverter - consumption_bounds: Consumption bounds for each battery and inverter - - Returns: - List of the components - """ - - components: List[InvBatPair] = [] - for i in range(0, num): - battery = battery_msg(2 * i, capacity[i], soc[i], power[2 * i]) - inverter = inverter_msg(2 * i + 1, power[2 * i + 1]) - components.append(InvBatPair(battery, inverter)) - return components - # Test distribute supply power def test_supply_three_batteries_1(self) -> None: """Test distribute supply power for batteries with different SoC.""" @@ -304,14 +310,14 @@ def test_supply_three_batteries_1(self) -> None: # consume bounds == 0 makes sure they are not used in supply algorithm bounds = [ - Bound(-900, 0), - Bound(-1000, 0), - Bound(-800, 0), - Bound(-700, 0), - Bound(-900, 0), - Bound(-900, 0), + PowerBounds(-900, 0, 0, 0), + PowerBounds(-1000, 0, 0, 0), + PowerBounds(-800, 0, 0, 0), + PowerBounds(-700, 0, 0, 0), + PowerBounds(-900, 0, 0, 0), + PowerBounds(-900, 0, 0, 0), ] - components = self.create_components(3, capacity, soc, bounds) + components = create_components(3, capacity, soc, bounds) algorithm = DistributionAlgorithm(distributor_exponent=1) result = algorithm.distribute_power(-1200, components) @@ -329,14 +335,14 @@ def test_supply_three_batteries_2(self) -> None: ] # consume bounds == 0 makes sure they are not used in supply algorithm bounds = [ - Bound(-900, 0), - Bound(-1000, 0), - Bound(-800, 0), - Bound(-700, 0), - Bound(-900, 0), - Bound(-900, 0), + PowerBounds(-900, 0, 0, 0), + PowerBounds(-1000, 0, 0, 0), + PowerBounds(-800, 0, 0, 0), + PowerBounds(-700, 0, 0, 0), + PowerBounds(-900, 0, 0, 0), + PowerBounds(-900, 0, 0, 0), ] - components = self.create_components(3, capacity, soc, bounds) + components = create_components(3, capacity, soc, bounds) algorithm = DistributionAlgorithm(distributor_exponent=1) result = algorithm.distribute_power(-1400, components) @@ -354,14 +360,14 @@ def test_supply_three_batteries_3(self) -> None: ] # consume bounds == 0 makes sure they are not used in supply algorithm supply_bounds = [ - Bound(-600, 0), - Bound(-1000, 0), - Bound(-600, 0), - Bound(-100, 0), - Bound(-800, 0), - Bound(-900, 0), + PowerBounds(-600, 0, 0, 0), + PowerBounds(-1000, 0, 0, 0), + PowerBounds(-600, 0, 0, 0), + PowerBounds(-100, 0, 0, 0), + PowerBounds(-800, 0, 0, 0), + PowerBounds(-900, 0, 0, 0), ] - components = self.create_components(3, capacity, soc, supply_bounds) + components = create_components(3, capacity, soc, supply_bounds) algorithm = DistributionAlgorithm(distributor_exponent=1) result = algorithm.distribute_power(-1400, components) @@ -379,14 +385,14 @@ def test_supply_three_batteries_4(self) -> None: ] # consume bounds == 0 makes sure they are not used in supply algorithm bounds = [ - Bound(-600, 0), - Bound(-1000, 0), - Bound(-600, 0), - Bound(-100, 0), - Bound(-800, 0), - Bound(-900, 0), + PowerBounds(-600, 0, 0, 0), + PowerBounds(-1000, 0, 0, 0), + PowerBounds(-600, 0, 0, 0), + PowerBounds(-100, 0, 0, 0), + PowerBounds(-800, 0, 0, 0), + PowerBounds(-900, 0, 0, 0), ] - components = self.create_components(3, capacity, soc, bounds) + components = create_components(3, capacity, soc, bounds) algorithm = DistributionAlgorithm(distributor_exponent=1) result = algorithm.distribute_power(-1700, components) @@ -404,14 +410,14 @@ def test_supply_three_batteries_5(self) -> None: ] # consume bounds == 0 makes sure they are not used in supply algorithm supply_bounds = [ - Bound(-600, 0), - Bound(-1000, 0), - Bound(-600, 0), - Bound(-100, 0), - Bound(-800, 0), - Bound(-900, 0), + PowerBounds(-600, 0, 0, 0), + PowerBounds(-1000, 0, 0, 0), + PowerBounds(-600, 0, 0, 0), + PowerBounds(-100, 0, 0, 0), + PowerBounds(-800, 0, 0, 0), + PowerBounds(-900, 0, 0, 0), ] - components = self.create_components(3, capacity, soc, supply_bounds) + components = create_components(3, capacity, soc, supply_bounds) algorithm = DistributionAlgorithm(distributor_exponent=1) result = algorithm.distribute_power(-1700, components) @@ -429,12 +435,12 @@ def test_supply_two_batteries_1(self) -> None: # consume bounds == 0 makes sure they are not used in supply algorithm supply_bounds = [ - Bound(-600, 0), - Bound(-1000, 0), - Bound(-600, 0), - Bound(-1000, 0), + PowerBounds(-600, 0, 0, 0), + PowerBounds(-1000, 0, 0, 0), + PowerBounds(-600, 0, 0, 0), + PowerBounds(-1000, 0, 0, 0), ] - components = self.create_components(2, capacity, soc, supply_bounds) + components = create_components(2, capacity, soc, supply_bounds) algorithm = DistributionAlgorithm(distributor_exponent=1) result = algorithm.distribute_power(-600, components) @@ -451,12 +457,12 @@ def test_supply_two_batteries_2(self) -> None: ] # consume bounds == 0 makes sure they are not used in supply algorithm supply_bounds = [ - Bound(-600, 0), - Bound(-1000, 0), - Bound(-600, 0), - Bound(-1000, 0), + PowerBounds(-600, 0, 0, 0), + PowerBounds(-1000, 0, 0, 0), + PowerBounds(-600, 0, 0, 0), + PowerBounds(-1000, 0, 0, 0), ] - components = self.create_components(2, capacity, soc, supply_bounds) + components = create_components(2, capacity, soc, supply_bounds) algorithm = DistributionAlgorithm(distributor_exponent=1) result = algorithm.distribute_power(-600, components) @@ -475,14 +481,14 @@ def test_consumption_three_batteries_1(self) -> None: ] # consume bounds == 0 makes sure they are not used in supply algorithm bounds = [ - Bound(0, 900), - Bound(0, 1000), - Bound(0, 800), - Bound(0, 700), - Bound(0, 900), - Bound(0, 900), + PowerBounds(0, 0, 0, 900), + PowerBounds(0, 0, 0, 1000), + PowerBounds(0, 0, 0, 800), + PowerBounds(0, 0, 0, 700), + PowerBounds(0, 0, 0, 900), + PowerBounds(0, 0, 0, 900), ] - components = self.create_components(3, capacity, soc, bounds) + components = create_components(3, capacity, soc, bounds) algorithm = DistributionAlgorithm(distributor_exponent=1) result = algorithm.distribute_power(1200, components) @@ -500,14 +506,14 @@ def test_consumption_three_batteries_2(self) -> None: ] # consume bounds == 0 makes sure they are not used in supply algorithm bounds = [ - Bound(0, 900), - Bound(0, 1000), - Bound(0, 800), - Bound(0, 700), - Bound(0, 900), - Bound(0, 900), + PowerBounds(0, 0, 0, 900), + PowerBounds(0, 0, 0, 1000), + PowerBounds(0, 0, 0, 800), + PowerBounds(0, 0, 0, 700), + PowerBounds(0, 0, 0, 900), + PowerBounds(0, 0, 0, 900), ] - components = self.create_components(3, capacity, soc, bounds) + components = create_components(3, capacity, soc, bounds) algorithm = DistributionAlgorithm(distributor_exponent=1) result = algorithm.distribute_power(1400, components) @@ -525,14 +531,14 @@ def test_consumption_three_batteries_3(self) -> None: ] # consume bounds == 0 makes sure they are not used in supply algorithm bounds = [ - Bound(0, 600), - Bound(0, 1000), - Bound(0, 600), - Bound(0, 100), - Bound(0, 800), - Bound(0, 900), + PowerBounds(0, 0, 0, 600), + PowerBounds(0, 0, 0, 1000), + PowerBounds(0, 0, 0, 600), + PowerBounds(0, 0, 0, 100), + PowerBounds(0, 0, 0, 800), + PowerBounds(0, 0, 0, 900), ] - components = self.create_components(3, capacity, soc, bounds) + components = create_components(3, capacity, soc, bounds) algorithm = DistributionAlgorithm(distributor_exponent=1) result = algorithm.distribute_power(1400, components) @@ -550,14 +556,14 @@ def test_consumption_three_batteries_4(self) -> None: ] # consume bounds == 0 makes sure they are not used in supply algorithm bounds = [ - Bound(0, 600), - Bound(0, 1000), - Bound(0, 600), - Bound(0, 100), - Bound(0, 800), - Bound(0, 900), + PowerBounds(0, 0, 0, 600), + PowerBounds(0, 0, 0, 1000), + PowerBounds(0, 0, 0, 600), + PowerBounds(0, 0, 0, 100), + PowerBounds(0, 0, 0, 800), + PowerBounds(0, 0, 0, 900), ] - components = self.create_components(3, capacity, soc, bounds) + components = create_components(3, capacity, soc, bounds) algorithm = DistributionAlgorithm(distributor_exponent=1) result = algorithm.distribute_power(1700, components) @@ -575,14 +581,14 @@ def test_consumption_three_batteries_5(self) -> None: ] # consume bounds == 0 makes sure they are not used in supply algorithm bounds = [ - Bound(0, 600), - Bound(0, 1000), - Bound(0, 600), - Bound(0, 100), - Bound(0, 800), - Bound(0, 900), + PowerBounds(0, 0, 0, 600), + PowerBounds(0, 0, 0, 1000), + PowerBounds(0, 0, 0, 600), + PowerBounds(0, 0, 0, 100), + PowerBounds(0, 0, 0, 800), + PowerBounds(0, 0, 0, 900), ] - components = self.create_components(3, capacity, soc, bounds) + components = create_components(3, capacity, soc, bounds) algorithm = DistributionAlgorithm(distributor_exponent=1) result = algorithm.distribute_power(1700, components) @@ -600,14 +606,14 @@ def test_consumption_three_batteries_6(self) -> None: ] # consume bounds == 0 makes sure they are not used in supply algorithm bounds = [ - Bound(0, 600), - Bound(0, 1000), - Bound(0, 600), - Bound(0, 100), - Bound(0, 800), - Bound(0, 900), + PowerBounds(0, 0, 0, 600), + PowerBounds(0, 0, 0, 1000), + PowerBounds(0, 0, 0, 600), + PowerBounds(0, 0, 0, 100), + PowerBounds(0, 0, 0, 800), + PowerBounds(0, 0, 0, 900), ] - components = self.create_components(3, capacity, soc, bounds) + components = create_components(3, capacity, soc, bounds) algorithm = DistributionAlgorithm(distributor_exponent=1) result = algorithm.distribute_power(1700, components) @@ -625,14 +631,14 @@ def test_consumption_three_batteries_7(self) -> None: ] # consume bounds == 0 makes sure they are not used in supply algorithm bounds = [ - Bound(0, 500), - Bound(0, 1000), - Bound(0, 600), - Bound(0, 700), - Bound(0, 800), - Bound(0, 900), + PowerBounds(0, 0, 0, 500), + PowerBounds(0, 0, 0, 1000), + PowerBounds(0, 0, 0, 600), + PowerBounds(0, 0, 0, 700), + PowerBounds(0, 0, 0, 800), + PowerBounds(0, 0, 0, 900), ] - components = self.create_components(3, capacity, soc, bounds) + components = create_components(3, capacity, soc, bounds) algorithm = DistributionAlgorithm(distributor_exponent=1) result = algorithm.distribute_power(500, components) @@ -649,12 +655,12 @@ def test_consumption_two_batteries_1(self) -> None: ] # consume bounds == 0 makes sure they are not used in supply algorithm bounds = [ - Bound(0, 600), - Bound(0, 1000), - Bound(0, 600), - Bound(0, 1000), + PowerBounds(0, 0, 0, 600), + PowerBounds(0, 0, 0, 1000), + PowerBounds(0, 0, 0, 600), + PowerBounds(0, 0, 0, 1000), ] - components = self.create_components(2, capacity, soc, bounds) + components = create_components(2, capacity, soc, bounds) algorithm = DistributionAlgorithm(distributor_exponent=1) result = algorithm.distribute_power(600, components) @@ -671,12 +677,12 @@ def test_consumption_two_batteries_distribution_exponent(self) -> None: ] # consume bounds == 0 makes sure they are not used in supply algorithm bounds = [ - Bound(0, 9000), - Bound(0, 9000), - Bound(0, 9000), - Bound(0, 9000), + PowerBounds(0, 0, 0, 9000), + PowerBounds(0, 0, 0, 9000), + PowerBounds(0, 0, 0, 9000), + PowerBounds(0, 0, 0, 9000), ] - components = self.create_components(2, capacity, soc, bounds) + components = create_components(2, capacity, soc, bounds) algorithm = DistributionAlgorithm(distributor_exponent=1) result = algorithm.distribute_power(8000, components) @@ -705,12 +711,12 @@ def test_consumption_two_batteries_distribution_exponent_1(self) -> None: ] # consume bounds == 0 makes sure they are not used in supply algorithm bounds = [ - Bound(0, 9000), - Bound(0, 9000), - Bound(0, 9000), - Bound(0, 9000), + PowerBounds(0, 0, 0, 9000), + PowerBounds(0, 0, 0, 9000), + PowerBounds(0, 0, 0, 9000), + PowerBounds(0, 0, 0, 9000), ] - components = self.create_components(2, capacity, soc, bounds) + components = create_components(2, capacity, soc, bounds) algorithm = DistributionAlgorithm(distributor_exponent=1) result = algorithm.distribute_power(900, components) @@ -757,12 +763,12 @@ def test_supply_two_batteries_distribution_exponent(self) -> None: ] # consume bounds == 0 makes sure they are not used in supply algorithm bounds = [ - Bound(-9000, 0), - Bound(-9000, 0), - Bound(-9000, 0), - Bound(-9000, 0), + PowerBounds(-9000, 0, 0, 0), + PowerBounds(-9000, 0, 0, 0), + PowerBounds(-9000, 0, 0, 0), + PowerBounds(-9000, 0, 0, 0), ] - components = self.create_components(2, capacity, soc, bounds) + components = create_components(2, capacity, soc, bounds) algorithm = DistributionAlgorithm(distributor_exponent=1) result = algorithm.distribute_power(-8000, components) @@ -791,12 +797,12 @@ def test_supply_two_batteries_distribution_exponent_1(self) -> None: ] # consume bounds == 0 makes sure they are not used in supply algorithm supply_bounds = [ - Bound(-9000, 0), - Bound(-9000, 0), - Bound(-9000, 0), - Bound(-9000, 0), + PowerBounds(-9000, 0, 0, 0), + PowerBounds(-9000, 0, 0, 0), + PowerBounds(-9000, 0, 0, 0), + PowerBounds(-9000, 0, 0, 0), ] - components = self.create_components(2, capacity, soc, supply_bounds) + components = create_components(2, capacity, soc, supply_bounds) algorithm = DistributionAlgorithm(distributor_exponent=1) result = algorithm.distribute_power(-8000, components) @@ -826,14 +832,14 @@ def test_supply_three_batteries_distribution_exponent_2(self) -> None: ] # consume bounds == 0 makes sure they are not used in supply algorithm bounds = [ - Bound(-9000, 0), - Bound(-9000, 0), - Bound(-9000, 0), - Bound(-9000, 0), - Bound(-9000, 0), - Bound(-9000, 0), + PowerBounds(-9000, 0, 0, 0), + PowerBounds(-9000, 0, 0, 0), + PowerBounds(-9000, 0, 0, 0), + PowerBounds(-9000, 0, 0, 0), + PowerBounds(-9000, 0, 0, 0), + PowerBounds(-9000, 0, 0, 0), ] - components = self.create_components(3, capacity, soc, bounds) + components = create_components(3, capacity, soc, bounds) algorithm = DistributionAlgorithm(distributor_exponent=1) result = algorithm.distribute_power(-8000, components) @@ -869,14 +875,14 @@ def test_supply_three_batteries_distribution_exponent_3(self) -> None: ] # consume bounds == 0 makes sure they are not used in supply algorithm supply_bounds = [ - Bound(-9000, 0), - Bound(-9000, 0), - Bound(-9000, 0), - Bound(-9000, 0), - Bound(-9000, 0), - Bound(-9000, 0), + PowerBounds(-9000, 0, 0, 0), + PowerBounds(-9000, 0, 0, 0), + PowerBounds(-9000, 0, 0, 0), + PowerBounds(-9000, 0, 0, 0), + PowerBounds(-9000, 0, 0, 0), + PowerBounds(-9000, 0, 0, 0), ] - components = self.create_components(3, capacity, soc, supply_bounds) + components = create_components(3, capacity, soc, supply_bounds) algorithm = DistributionAlgorithm(distributor_exponent=0.5) result = algorithm.distribute_power(-1300, components) @@ -899,12 +905,12 @@ def test_supply_two_batteries_distribution_exponent_less_then_1(self) -> None: ] # consume bounds == 0 makes sure they are not used in supply algorithm bounds = [ - Bound(0, 9000), - Bound(0, 9000), - Bound(0, 9000), - Bound(0, 9000), + PowerBounds(0, 0, 0, 9000), + PowerBounds(0, 0, 0, 9000), + PowerBounds(0, 0, 0, 9000), + PowerBounds(0, 0, 0, 9000), ] - components = self.create_components(2, capacity, soc, bounds) + components = create_components(2, capacity, soc, bounds) algorithm = DistributionAlgorithm(distributor_exponent=0.5) result = algorithm.distribute_power(1000, components) @@ -917,3 +923,290 @@ def test_supply_two_batteries_distribution_exponent_less_then_1(self) -> None: assert result.distribution == approx({1: 500, 3: 500}) assert result.remaining_power == approx(0.0) + + +class TestDistWithExclBounds: + """Test the distribution algorithm with exclusive bounds.""" + + @staticmethod + def assert_result(result: DistributionResult, expected: DistributionResult) -> None: + """Assert the result is as expected.""" + assert result.distribution == approx(expected.distribution, abs=0.01) + assert result.remaining_power == approx(expected.remaining_power, abs=0.01) + + def test_scenario_1(self) -> None: + """Test scenario 1. + + Set params for 3 batteries: + capacities: 10000, 10000, 10000 + socs: 30, 50, 70 + + individual soc bounds: 10-90 + individual bounds: -1000, -100, 100, 1000 + + battery pool bounds: -3000, -300, 300, 1000 + + Expected result: + + | request | | excess | + | power | distribution | remaining | + |---------+------------------------+-----------| + | -300 | -100, -100, -100 | 0 | + | 300 | 100, 100, 100 | 0 | + | -600 | -100, -200, -300 | 0 | + | 900 | 466.66, 300, 133.33 | 0 | + | -900 | -133.33, -300, -466.66 | 0 | + | 2200 | 1000, 850, 350 | 0 | + | -2200 | -350, -850, -1000 | 0 | + | 2800 | 1000, 1000, 800 | 0 | + | -2800 | -800, -1000, -1000 | 0 | + | 3800 | 1000, 1000, 1000 | 800 | + | -3200 | -1000, -1000, -1000 | -200 | + + """ + capacities: List[Metric] = [Metric(10000), Metric(10000), Metric(10000)] + soc: List[Metric] = [ + Metric(30.0, Bound(10, 90)), + Metric(50.0, Bound(10, 90)), + Metric(70.0, Bound(10, 90)), + ] + bounds = [ + PowerBounds(-1000, -100, 20, 1000), + PowerBounds(-1000, -90, 100, 1000), + PowerBounds(-1000, -50, 100, 1000), + PowerBounds(-1000, -100, 90, 1000), + PowerBounds(-1000, -20, 100, 1000), + PowerBounds(-1000, -100, 80, 1000), + ] + components = create_components(3, capacities, soc, bounds) + + algorithm = DistributionAlgorithm() + + self.assert_result( + algorithm.distribute_power(-300, components), + DistributionResult({1: -100, 3: -100, 5: -100}, remaining_power=0.0), + ) + self.assert_result( + algorithm.distribute_power(300, components), + DistributionResult({1: 100, 3: 100, 5: 100}, remaining_power=0.0), + ) + self.assert_result( + algorithm.distribute_power(-600, components), + DistributionResult({1: -100, 3: -200, 5: -300}, remaining_power=0.0), + ) + self.assert_result( + algorithm.distribute_power(900, components), + DistributionResult({1: 450, 3: 300, 5: 150}, remaining_power=0.0), + ) + self.assert_result( + algorithm.distribute_power(-900, components), + DistributionResult({1: -150, 3: -300, 5: -450}, remaining_power=0.0), + ) + self.assert_result( + algorithm.distribute_power(2200, components), + DistributionResult({1: 1000, 3: 833.33, 5: 366.66}, remaining_power=0.0), + ) + self.assert_result( + algorithm.distribute_power(-2200, components), + DistributionResult({1: -366.66, 3: -833.33, 5: -1000}, remaining_power=0.0), + ) + self.assert_result( + algorithm.distribute_power(2800, components), + DistributionResult({1: 1000, 3: 1000, 5: 800}, remaining_power=0.0), + ) + self.assert_result( + algorithm.distribute_power(-2800, components), + DistributionResult({1: -800, 3: -1000, 5: -1000}, remaining_power=0.0), + ) + self.assert_result( + algorithm.distribute_power(3800, components), + DistributionResult({1: 1000, 3: 1000, 5: 1000}, remaining_power=800.0), + ) + self.assert_result( + algorithm.distribute_power(-3200, components), + DistributionResult({1: -1000, 3: -1000, 5: -1000}, remaining_power=-200.0), + ) + + def test_scenario_2(self) -> None: + """Test scenario 2. + + Set params for 3 batteries: + capacities: 10000, 10000, 10000 + socs: 50, 50, 70 + + individual soc bounds: 10-90 + individual bounds: -1000, -100, 100, 1000 + + battery pool bounds: -3000, -300, 300, 1000 + + Expected result: + + | request | | excess | + | power | distribution | remaining | + |---------+---------------------------+-----------| + | -300 | -100, -100, -100 | 0 | + | 300 | 100, 100, 100 | 0 | + | -530 | -151.42, -151.42, -227.14 | 0 | + | 530 | 212, 212, 106 | 0 | + | 2000 | 800, 800, 400 | 0 | + | -2000 | -571.42, -571.42, -857.14 | 0 | + | 2500 | 1000, 1000, 500 | 0 | + | -2500 | -785.71, -714.28, -1000.0 | 0 | + | 3000 | 1000, 1000, 1000 | 0 | + | -3000 | -1000, -1000, -1000 | 0 | + | 3500 | 1000, 1000, 1000 | 500 | + | -3500 | -1000, -1000, -1000 | -500 | + """ + capacities: List[Metric] = [Metric(10000), Metric(10000), Metric(10000)] + soc: List[Metric] = [ + Metric(50.0, Bound(10, 90)), + Metric(50.0, Bound(10, 90)), + Metric(70.0, Bound(10, 90)), + ] + bounds = [ + PowerBounds(-1000, -100, 20, 1000), + PowerBounds(-1000, -90, 100, 1000), + PowerBounds(-1000, -50, 100, 1000), + PowerBounds(-1000, -100, 90, 1000), + PowerBounds(-1000, -20, 100, 1000), + PowerBounds(-1000, -100, 80, 1000), + ] + components = create_components(3, capacities, soc, bounds) + + algorithm = DistributionAlgorithm() + + self.assert_result( + algorithm.distribute_power(-300, components), + DistributionResult({1: -100, 3: -100, 5: -100}, remaining_power=0.0), + ) + self.assert_result( + algorithm.distribute_power(300, components), + DistributionResult({1: 100, 3: 100, 5: 100}, remaining_power=0.0), + ) + self.assert_result( + algorithm.distribute_power(-530, components), + DistributionResult( + {1: -151.42, 3: -151.42, 5: -227.14}, remaining_power=0.0 + ), + ) + self.assert_result( + algorithm.distribute_power(530, components), + DistributionResult({1: 212, 3: 212, 5: 106}, remaining_power=0.0), + ) + self.assert_result( + algorithm.distribute_power(2000, components), + DistributionResult({1: 800, 3: 800, 5: 400}, remaining_power=0.0), + ) + self.assert_result( + algorithm.distribute_power(-2000, components), + DistributionResult( + {1: -571.42, 3: -571.42, 5: -857.14}, remaining_power=0.0 + ), + ) + self.assert_result( + algorithm.distribute_power(2500, components), + DistributionResult({1: 1000, 3: 1000, 5: 500}, remaining_power=0.0), + ) + self.assert_result( + algorithm.distribute_power(-2500, components), + DistributionResult( + {1: -785.71, 3: -714.28, 5: -1000.0}, remaining_power=0.0 + ), + ) + self.assert_result( + algorithm.distribute_power(3000, components), + DistributionResult({1: 1000, 3: 1000, 5: 1000}, remaining_power=0.0), + ) + self.assert_result( + algorithm.distribute_power(-3000, components), + DistributionResult({1: -1000, 3: -1000, 5: -1000}, remaining_power=0.0), + ) + self.assert_result( + algorithm.distribute_power(3500, components), + DistributionResult({1: 1000, 3: 1000, 5: 1000}, remaining_power=500.0), + ) + self.assert_result( + algorithm.distribute_power(-3500, components), + DistributionResult({1: -1000, 3: -1000, 5: -1000}, remaining_power=-500.0), + ) + + def test_scenario_3(self) -> None: + """Test scenario 3. + + Set params for 3 batteries: + capacities: 10000, 10000, 10000 + socs: 50, 50, 70 + + individual soc bounds: 10-90 + individual bounds 1: -1000, 0, 0, 1000 + individual bounds 2: -1000, -100, 100, 1000 + individual bounds 3: -1000, 0, 0, 1000 + + battery pool bounds: -3000, -100, 100, 1000 + + Expected result: + + | request | | excess | + | power | distribution | remaining | + |---------+---------------------------+-----------| + | -300 | -88, -108.57, -123.43 | 0 | + | 300 | 128, 128, 64 | 0 | + | -1800 | -514.28, -514.28, -771.42 | 0 | + | 1800 | 720, 720, 360 | 0 | + | -2800 | -800, -1000, -1000 | 0 | + | 2800 | 1000, 1000, 800 | 0 | + | -3500 | -1000, -1000, -1000 | -500 | + | 3500 | 1000, 1000, 1000 | 500 | + """ + capacities: List[Metric] = [Metric(10000), Metric(10000), Metric(10000)] + soc: List[Metric] = [ + Metric(50.0, Bound(10, 90)), + Metric(50.0, Bound(10, 90)), + Metric(70.0, Bound(10, 90)), + ] + bounds = [ + PowerBounds(-1000, 0, 0, 1000), + PowerBounds(-1000, 0, 0, 1000), + PowerBounds(-1000, -100, 100, 1000), + PowerBounds(-1000, -100, 100, 1000), + PowerBounds(-1000, 0, 0, 1000), + PowerBounds(-1000, 0, 0, 1000), + ] + components = create_components(3, capacities, soc, bounds) + + algorithm = DistributionAlgorithm() + + self.assert_result( + algorithm.distribute_power(-320, components), + DistributionResult({1: -88, 3: -108.57, 5: -123.43}, remaining_power=0.0), + ) + self.assert_result( + algorithm.distribute_power(320, components), + DistributionResult({1: 128, 3: 128, 5: 64}, remaining_power=0.0), + ) + self.assert_result( + algorithm.distribute_power(-1800, components), + DistributionResult( + {1: -514.28, 3: -514.28, 5: -771.42}, remaining_power=0.0 + ), + ) + self.assert_result( + algorithm.distribute_power(1800, components), + DistributionResult({1: 720, 3: 720, 5: 360}, remaining_power=0.0), + ) + self.assert_result( + algorithm.distribute_power(-2800, components), + DistributionResult({1: -800, 3: -1000, 5: -1000}, remaining_power=0.0), + ) + self.assert_result( + algorithm.distribute_power(2800, components), + DistributionResult({1: 1000, 3: 1000, 5: 800}, remaining_power=0.0), + ) + self.assert_result( + algorithm.distribute_power(-3500, components), + DistributionResult({1: -1000, 3: -1000, 5: -1000}, remaining_power=-500.0), + ) + self.assert_result( + algorithm.distribute_power(3500, components), + DistributionResult({1: 1000, 3: 1000, 5: 1000}, remaining_power=500.0), + ) diff --git a/tests/actor/test_power_distributing.py b/tests/actor/power_distributing/test_power_distributing.py similarity index 50% rename from tests/actor/test_power_distributing.py rename to tests/actor/power_distributing/test_power_distributing.py index 6f652f2ed..6e92d6d19 100644 --- a/tests/actor/test_power_distributing.py +++ b/tests/actor/power_distributing/test_power_distributing.py @@ -15,6 +15,7 @@ from pytest import approx from pytest_mock import MockerFixture +from frequenz.sdk import microgrid from frequenz.sdk.actor import ChannelRegistry from frequenz.sdk.actor.power_distributing import ( BatteryStatus, @@ -24,16 +25,16 @@ from frequenz.sdk.actor.power_distributing._battery_pool_status import BatteryPoolStatus from frequenz.sdk.actor.power_distributing.result import ( Error, - OutOfBound, + OutOfBounds, + PowerBounds, Result, Success, ) -from frequenz.sdk.microgrid.client import Connection -from frequenz.sdk.microgrid.component import Component, ComponentCategory +from frequenz.sdk.microgrid.component import ComponentCategory +from tests.timeseries.mock_microgrid import MockMicrogrid -from ..conftest import SAFETY_TIMEOUT -from ..power.test_distribution_algorithm import Bound, Metric, battery_msg, inverter_msg -from ..utils.mock_microgrid_client import MockMicrogridClient +from ...conftest import SAFETY_TIMEOUT +from .test_distribution_algorithm import Bound, Metric, battery_msg, inverter_msg T = TypeVar("T") # Declare type variable @@ -44,102 +45,67 @@ class TestPowerDistributingActor: _namespace = "power_distributor" - def component_graph(self) -> tuple[set[Component], set[Connection]]: - """Create graph components - - Returns: - Tuple where first element is set of components and second element is - set of connections. - """ - components = { - Component(1, ComponentCategory.GRID), - Component(2, ComponentCategory.METER), - Component(104, ComponentCategory.METER), - Component(105, ComponentCategory.INVERTER), - Component(106, ComponentCategory.BATTERY), - Component(204, ComponentCategory.METER), - Component(205, ComponentCategory.INVERTER), - Component(206, ComponentCategory.BATTERY), - Component(304, ComponentCategory.METER), - Component(305, ComponentCategory.INVERTER), - Component(306, ComponentCategory.BATTERY), - } - - connections = { - Connection(1, 2), - Connection(2, 104), - Connection(104, 105), - Connection(105, 106), - Connection(2, 204), - Connection(204, 205), - Connection(205, 206), - Connection(2, 304), - Connection(304, 305), - Connection(305, 306), - } - return components, connections - async def test_constructor(self, mocker: MockerFixture) -> None: """Test if gets all necessary data.""" - components, connections = self.component_graph() - mock_microgrid = MockMicrogridClient(components, connections) - mock_microgrid.initialize(mocker) - - attrs = {"get_working_batteries.return_value": {306}} - mocker.patch( - "frequenz.sdk.actor.power_distributing.power_distributing.BatteryPoolStatus", - return_value=MagicMock(spec=BatteryPoolStatus, **attrs), - ) + mockgrid = MockMicrogrid(grid_meter=True) + mockgrid.add_batteries(2) + mockgrid.add_batteries(1, no_meter=True) + await mockgrid.start(mocker) channel = Broadcast[Request]("power_distributor") channel_registry = ChannelRegistry(name="power_distributor") battery_status_channel = Broadcast[BatteryStatus]("battery_status") - distributor = PowerDistributingActor( + async with PowerDistributingActor( requests_receiver=channel.new_receiver(), channel_registry=channel_registry, battery_status_sender=battery_status_channel.new_sender(), - ) - - assert distributor._bat_inv_map == {106: 105, 206: 205, 306: 305} - assert distributor._inv_bat_map == {105: 106, 205: 206, 305: 306} - await distributor._stop_actor() - - async def init_mock_microgrid(self, mocker: MockerFixture) -> MockMicrogridClient: - """Create mock microgrid and send initial data from the components. - - Returns: - Mock microgrid instance. - """ - components, connections = self.component_graph() - microgrid = MockMicrogridClient(components, connections) - microgrid.initialize(mocker) - - graph = microgrid.component_graph + ) as distributor: + assert distributor._bat_inv_map == {9: 8, 19: 18, 29: 28} + assert distributor._inv_bat_map == {8: 9, 18: 19, 28: 29} + await mockgrid.cleanup() + + # Test if it works without grid side meter + mockgrid = MockMicrogrid(grid_meter=False) + mockgrid.add_batteries(1) + mockgrid.add_batteries(2, no_meter=True) + await mockgrid.start(mocker) + async with PowerDistributingActor( + requests_receiver=channel.new_receiver(), + channel_registry=channel_registry, + battery_status_sender=battery_status_channel.new_sender(), + ) as distributor: + assert distributor._bat_inv_map == {9: 8, 19: 18, 29: 28} + assert distributor._inv_bat_map == {8: 9, 18: 19, 28: 29} + await mockgrid.cleanup() + + async def init_component_data(self, mockgrid: MockMicrogrid) -> None: + """Send initial component data, for power distributor to start.""" + graph = microgrid.connection_manager.get().component_graph for battery in graph.components(component_category={ComponentCategory.BATTERY}): - await microgrid.send( + await mockgrid.mock_client.send( battery_msg( battery.component_id, capacity=Metric(98000), soc=Metric(40, Bound(20, 80)), - power=Bound(-1000, 1000), + power=PowerBounds(-1000, 0, 0, 1000), ) ) inverters = graph.components(component_category={ComponentCategory.INVERTER}) for inverter in inverters: - await microgrid.send( + await mockgrid.mock_client.send( inverter_msg( inverter.component_id, - power=Bound(-500, 500), + power=PowerBounds(-500, 0, 0, 500), ) ) - return microgrid - async def test_power_distributor_one_user(self, mocker: MockerFixture) -> None: - # pylint: disable=too-many-locals """Test if power distribution works with single user works.""" - await self.init_mock_microgrid(mocker) + mockgrid = MockMicrogrid(grid_meter=False) + mockgrid.add_batteries(3) + await mockgrid.start(mocker) + await self.init_component_data(mockgrid) channel = Broadcast[Request]("power_distributor") channel_registry = ChannelRegistry(name="power_distributor") @@ -147,7 +113,7 @@ async def test_power_distributor_one_user(self, mocker: MockerFixture) -> None: request = Request( namespace=self._namespace, power=1200.0, - batteries={106, 206}, + batteries={9, 19}, request_timeout_sec=SAFETY_TIMEOUT, ) @@ -159,20 +125,18 @@ async def test_power_distributor_one_user(self, mocker: MockerFixture) -> None: mocker.patch("asyncio.sleep", new_callable=AsyncMock) battery_status_channel = Broadcast[BatteryStatus]("battery_status") - distributor = PowerDistributingActor( + async with PowerDistributingActor( requests_receiver=channel.new_receiver(), channel_registry=channel_registry, battery_status_sender=battery_status_channel.new_sender(), - ) - - await channel.new_sender().send(request) - result_rx = channel_registry.new_receiver(self._namespace) + ): + await channel.new_sender().send(request) + result_rx = channel_registry.new_receiver(self._namespace) - done, pending = await asyncio.wait( - [asyncio.create_task(result_rx.receive())], - timeout=SAFETY_TIMEOUT, - ) - await distributor._stop_actor() + done, pending = await asyncio.wait( + [asyncio.create_task(result_rx.receive())], + timeout=SAFETY_TIMEOUT, + ) assert len(pending) == 0 assert len(done) == 1 @@ -183,17 +147,113 @@ async def test_power_distributor_one_user(self, mocker: MockerFixture) -> None: assert result.excess_power == approx(200.0) assert result.request == request + async def test_power_distributor_exclusion_bounds( + self, mocker: MockerFixture + ) -> None: + """Test if power distributing actor rejects non-zero requests in exclusion bounds.""" + mockgrid = MockMicrogrid(grid_meter=False) + mockgrid.add_batteries(2) + await mockgrid.start(mocker) + await self.init_component_data(mockgrid) + + await mockgrid.mock_client.send( + battery_msg( + 9, + soc=Metric(60, Bound(20, 80)), + capacity=Metric(98000), + power=PowerBounds(-1000, -300, 300, 1000), + ) + ) + await mockgrid.mock_client.send( + battery_msg( + 19, + soc=Metric(60, Bound(20, 80)), + capacity=Metric(98000), + power=PowerBounds(-1000, -300, 300, 1000), + ) + ) + + channel = Broadcast[Request]("power_distributor") + channel_registry = ChannelRegistry(name="power_distributor") + + attrs = { + "get_working_batteries.return_value": microgrid.battery_pool().battery_ids + } + mocker.patch( + "frequenz.sdk.actor.power_distributing.power_distributing.BatteryPoolStatus", + return_value=MagicMock(spec=BatteryPoolStatus, **attrs), + ) + + mocker.patch("asyncio.sleep", new_callable=AsyncMock) + battery_status_channel = Broadcast[BatteryStatus]("battery_status") + async with PowerDistributingActor( + requests_receiver=channel.new_receiver(), + channel_registry=channel_registry, + battery_status_sender=battery_status_channel.new_sender(), + ): + ## zero power requests should pass through despite the exclusion bounds. + request = Request( + namespace=self._namespace, + power=0.0, + batteries={9, 19}, + request_timeout_sec=SAFETY_TIMEOUT, + ) + + await channel.new_sender().send(request) + result_rx = channel_registry.new_receiver(self._namespace) + + done, pending = await asyncio.wait( + [asyncio.create_task(result_rx.receive())], + timeout=SAFETY_TIMEOUT, + ) + + assert len(pending) == 0 + assert len(done) == 1 + + result: Result = done.pop().result() + assert isinstance(result, Success) + assert result.succeeded_power == approx(0.0) + assert result.excess_power == approx(0.0) + assert result.request == request + + ## non-zero power requests that fall within the exclusion bounds should be + ## rejected. + request = Request( + namespace=self._namespace, + power=300.0, + batteries={9, 19}, + request_timeout_sec=SAFETY_TIMEOUT, + ) + + await channel.new_sender().send(request) + result_rx = channel_registry.new_receiver(self._namespace) + + done, pending = await asyncio.wait( + [asyncio.create_task(result_rx.receive())], + timeout=SAFETY_TIMEOUT, + ) + + assert len(pending) == 0 + assert len(done) == 1 + + result = done.pop().result() + assert isinstance(result, OutOfBounds) + assert result.bounds == PowerBounds(-1000, -600, 600, 1000) + assert result.request == request + async def test_battery_soc_nan(self, mocker: MockerFixture) -> None: - # pylint: disable=too-many-locals """Test if battery with SoC==NaN is not used.""" - mock_microgrid = await self.init_mock_microgrid(mocker) + mockgrid = MockMicrogrid(grid_meter=False) + mockgrid.add_batteries(3) + await mockgrid.start(mocker) + await self.init_component_data(mockgrid) - await mock_microgrid.send( + await mockgrid.mock_client.send( battery_msg( - 106, + 9, soc=Metric(math.nan, Bound(20, 80)), capacity=Metric(98000), - power=Bound(-1000, 1000), + power=PowerBounds(-1000, 0, 0, 1000), ) ) @@ -203,7 +263,7 @@ async def test_battery_soc_nan(self, mocker: MockerFixture) -> None: request = Request( namespace=self._namespace, power=1200.0, - batteries={106, 206}, + batteries={9, 19}, request_timeout_sec=SAFETY_TIMEOUT, ) @@ -215,48 +275,48 @@ async def test_battery_soc_nan(self, mocker: MockerFixture) -> None: mocker.patch("asyncio.sleep", new_callable=AsyncMock) battery_status_channel = Broadcast[BatteryStatus]("battery_status") - distributor = PowerDistributingActor( + async with PowerDistributingActor( requests_receiver=channel.new_receiver(), channel_registry=channel_registry, battery_status_sender=battery_status_channel.new_sender(), - ) - - attrs = {"get_working_batteries.return_value": request.batteries} - mocker.patch( - "frequenz.sdk.actor.power_distributing.power_distributing.BatteryPoolStatus", - return_value=MagicMock(spec=BatteryPoolStatus, **attrs), - ) + ): + attrs = {"get_working_batteries.return_value": request.batteries} + mocker.patch( + "frequenz.sdk.actor.power_distributing.power_distributing.BatteryPoolStatus", + return_value=MagicMock(spec=BatteryPoolStatus, **attrs), + ) - await channel.new_sender().send(request) - result_rx = channel_registry.new_receiver(self._namespace) + await channel.new_sender().send(request) + result_rx = channel_registry.new_receiver(self._namespace) - done, pending = await asyncio.wait( - [asyncio.create_task(result_rx.receive())], - timeout=SAFETY_TIMEOUT, - ) - await distributor._stop_actor() + done, pending = await asyncio.wait( + [asyncio.create_task(result_rx.receive())], + timeout=SAFETY_TIMEOUT, + ) assert len(pending) == 0 assert len(done) == 1 result: Result = done.pop().result() assert isinstance(result, Success) - assert result.succeeded_batteries == {206} + assert result.succeeded_batteries == {19} assert result.succeeded_power == approx(500.0) assert result.excess_power == approx(700.0) assert result.request == request async def test_battery_capacity_nan(self, mocker: MockerFixture) -> None: - # pylint: disable=too-many-locals """Test battery with capacity set to NaN is not used.""" - mock_microgrid = await self.init_mock_microgrid(mocker) + mockgrid = MockMicrogrid(grid_meter=False) + mockgrid.add_batteries(3) + await mockgrid.start(mocker) + await self.init_component_data(mockgrid) - await mock_microgrid.send( + await mockgrid.mock_client.send( battery_msg( - 106, + 9, soc=Metric(40, Bound(20, 80)), capacity=Metric(math.nan), - power=Bound(-1000, 1000), + power=PowerBounds(-1000, 0, 0, 1000), ) ) @@ -266,7 +326,7 @@ async def test_battery_capacity_nan(self, mocker: MockerFixture) -> None: request = Request( namespace=self._namespace, power=1200.0, - batteries={106, 206}, + batteries={9, 19}, request_timeout_sec=SAFETY_TIMEOUT, ) attrs = {"get_working_batteries.return_value": request.batteries} @@ -277,58 +337,58 @@ async def test_battery_capacity_nan(self, mocker: MockerFixture) -> None: mocker.patch("asyncio.sleep", new_callable=AsyncMock) battery_status_channel = Broadcast[BatteryStatus]("battery_status") - distributor = PowerDistributingActor( + async with PowerDistributingActor( requests_receiver=channel.new_receiver(), channel_registry=channel_registry, battery_status_sender=battery_status_channel.new_sender(), - ) - - await channel.new_sender().send(request) - result_rx = channel_registry.new_receiver(self._namespace) + ): + await channel.new_sender().send(request) + result_rx = channel_registry.new_receiver(self._namespace) - done, pending = await asyncio.wait( - [asyncio.create_task(result_rx.receive())], - timeout=SAFETY_TIMEOUT, - ) - await distributor._stop_actor() + done, pending = await asyncio.wait( + [asyncio.create_task(result_rx.receive())], + timeout=SAFETY_TIMEOUT, + ) assert len(pending) == 0 assert len(done) == 1 result: Result = done.pop().result() assert isinstance(result, Success) - assert result.succeeded_batteries == {206} + assert result.succeeded_batteries == {19} assert result.succeeded_power == approx(500.0) assert result.excess_power == approx(700.0) assert result.request == request async def test_battery_power_bounds_nan(self, mocker: MockerFixture) -> None: - # pylint: disable=too-many-locals """Test battery with power bounds set to NaN is not used.""" - mock_microgrid = await self.init_mock_microgrid(mocker) + mockgrid = MockMicrogrid(grid_meter=False) + mockgrid.add_batteries(3) + await mockgrid.start(mocker) + await self.init_component_data(mockgrid) - # Battery 206 should work even if his inverter sends NaN - await mock_microgrid.send( + # Battery 19 should work even if his inverter sends NaN + await mockgrid.mock_client.send( inverter_msg( - 205, - power=Bound(math.nan, math.nan), + 18, + power=PowerBounds(math.nan, 0, 0, math.nan), ) ) # Battery 106 should not work because both battery and inverter sends NaN - await mock_microgrid.send( + await mockgrid.mock_client.send( inverter_msg( - 105, - power=Bound(-1000, math.nan), + 8, + power=PowerBounds(-1000, 0, 0, math.nan), ) ) - await mock_microgrid.send( + await mockgrid.mock_client.send( battery_msg( - 106, + 9, soc=Metric(40, Bound(20, 80)), capacity=Metric(float(98000)), - power=Bound(math.nan, math.nan), + power=PowerBounds(math.nan, 0, 0, math.nan), ) ) @@ -338,7 +398,7 @@ async def test_battery_power_bounds_nan(self, mocker: MockerFixture) -> None: request = Request( namespace=self._namespace, power=1200.0, - batteries={106, 206}, + batteries={9, 19}, request_timeout_sec=SAFETY_TIMEOUT, ) attrs = {"get_working_batteries.return_value": request.batteries} @@ -349,27 +409,25 @@ async def test_battery_power_bounds_nan(self, mocker: MockerFixture) -> None: mocker.patch("asyncio.sleep", new_callable=AsyncMock) battery_status_channel = Broadcast[BatteryStatus]("battery_status") - distributor = PowerDistributingActor( + async with PowerDistributingActor( requests_receiver=channel.new_receiver(), channel_registry=channel_registry, battery_status_sender=battery_status_channel.new_sender(), - ) + ): + await channel.new_sender().send(request) + result_rx = channel_registry.new_receiver(self._namespace) - await channel.new_sender().send(request) - result_rx = channel_registry.new_receiver(self._namespace) - - done, pending = await asyncio.wait( - [asyncio.create_task(result_rx.receive())], - timeout=SAFETY_TIMEOUT, - ) - await distributor._stop_actor() + done, pending = await asyncio.wait( + [asyncio.create_task(result_rx.receive())], + timeout=SAFETY_TIMEOUT, + ) assert len(pending) == 0 assert len(done) == 1 result: Result = done.pop().result() assert isinstance(result, Success) - assert result.succeeded_batteries == {206} + assert result.succeeded_batteries == {19} assert result.succeeded_power == approx(1000.0) assert result.excess_power == approx(200.0) assert result.request == request @@ -377,16 +435,18 @@ async def test_battery_power_bounds_nan(self, mocker: MockerFixture) -> None: async def test_power_distributor_invalid_battery_id( self, mocker: MockerFixture ) -> None: - # pylint: disable=too-many-locals """Test if power distribution raises error if any battery id is invalid.""" - await self.init_mock_microgrid(mocker) + mockgrid = MockMicrogrid(grid_meter=False) + mockgrid.add_batteries(3) + await mockgrid.start(mocker) + await self.init_component_data(mockgrid) channel = Broadcast[Request]("power_distributor") channel_registry = ChannelRegistry(name="power_distributor") request = Request( namespace=self._namespace, power=1200.0, - batteries={106, 208}, + batteries={9, 100}, request_timeout_sec=SAFETY_TIMEOUT, ) @@ -398,34 +458,34 @@ async def test_power_distributor_invalid_battery_id( mocker.patch("asyncio.sleep", new_callable=AsyncMock) battery_status_channel = Broadcast[BatteryStatus]("battery_status") - distributor = PowerDistributingActor( + async with PowerDistributingActor( requests_receiver=channel.new_receiver(), channel_registry=channel_registry, battery_status_sender=battery_status_channel.new_sender(), - ) - - await channel.new_sender().send(request) - result_rx = channel_registry.new_receiver(self._namespace) + ): + await channel.new_sender().send(request) + result_rx = channel_registry.new_receiver(self._namespace) - done, _ = await asyncio.wait( - [asyncio.create_task(result_rx.receive())], - timeout=SAFETY_TIMEOUT, - ) - await distributor._stop_actor() + done, _ = await asyncio.wait( + [asyncio.create_task(result_rx.receive())], + timeout=SAFETY_TIMEOUT, + ) assert len(done) == 1 result: Result = done.pop().result() assert isinstance(result, Error) assert result.request == request - err_msg = re.search(r"^No battery 208, available batteries:", result.msg) + err_msg = re.search(r"No battery 100, available batteries:", result.msg) assert err_msg is not None async def test_power_distributor_one_user_adjust_power_consume( self, mocker: MockerFixture ) -> None: - # pylint: disable=too-many-locals """Test if power distribution works with single user works.""" - await self.init_mock_microgrid(mocker) + mockgrid = MockMicrogrid(grid_meter=False) + mockgrid.add_batteries(3) + await mockgrid.start(mocker) + await self.init_component_data(mockgrid) channel = Broadcast[Request]("power_distributor") channel_registry = ChannelRegistry(name="power_distributor") @@ -433,7 +493,7 @@ async def test_power_distributor_one_user_adjust_power_consume( request = Request( namespace=self._namespace, power=1200.0, - batteries={106, 206}, + batteries={9, 19}, request_timeout_sec=SAFETY_TIMEOUT, adjust_power=False, ) @@ -447,36 +507,36 @@ async def test_power_distributor_one_user_adjust_power_consume( mocker.patch("asyncio.sleep", new_callable=AsyncMock) battery_status_channel = Broadcast[BatteryStatus]("battery_status") - distributor = PowerDistributingActor( + async with PowerDistributingActor( requests_receiver=channel.new_receiver(), channel_registry=channel_registry, battery_status_sender=battery_status_channel.new_sender(), - ) - - await channel.new_sender().send(request) - result_rx = channel_registry.new_receiver(self._namespace) + ): + await channel.new_sender().send(request) + result_rx = channel_registry.new_receiver(self._namespace) - done, pending = await asyncio.wait( - [asyncio.create_task(result_rx.receive())], - timeout=SAFETY_TIMEOUT, - ) - await distributor._stop_actor() + done, pending = await asyncio.wait( + [asyncio.create_task(result_rx.receive())], + timeout=SAFETY_TIMEOUT, + ) assert len(pending) == 0 assert len(done) == 1 result = done.pop().result() - assert isinstance(result, OutOfBound) + assert isinstance(result, OutOfBounds) assert result is not None assert result.request == request - assert result.bound == 1000 + assert result.bounds.inclusion_upper == 1000 async def test_power_distributor_one_user_adjust_power_supply( self, mocker: MockerFixture ) -> None: - # pylint: disable=too-many-locals """Test if power distribution works with single user works.""" - await self.init_mock_microgrid(mocker) + mockgrid = MockMicrogrid(grid_meter=False) + mockgrid.add_batteries(3) + await mockgrid.start(mocker) + await self.init_component_data(mockgrid) channel = Broadcast[Request]("power_distributor") channel_registry = ChannelRegistry(name="power_distributor") @@ -484,7 +544,7 @@ async def test_power_distributor_one_user_adjust_power_supply( request = Request( namespace=self._namespace, power=-1200.0, - batteries={106, 206}, + batteries={9, 19}, request_timeout_sec=SAFETY_TIMEOUT, adjust_power=False, ) @@ -498,36 +558,36 @@ async def test_power_distributor_one_user_adjust_power_supply( mocker.patch("asyncio.sleep", new_callable=AsyncMock) battery_status_channel = Broadcast[BatteryStatus]("battery_status") - distributor = PowerDistributingActor( + async with PowerDistributingActor( requests_receiver=channel.new_receiver(), channel_registry=channel_registry, battery_status_sender=battery_status_channel.new_sender(), - ) + ): + await channel.new_sender().send(request) + result_rx = channel_registry.new_receiver(self._namespace) - await channel.new_sender().send(request) - result_rx = channel_registry.new_receiver(self._namespace) - - done, pending = await asyncio.wait( - [asyncio.create_task(result_rx.receive())], - timeout=SAFETY_TIMEOUT, - ) - await distributor._stop_actor() + done, pending = await asyncio.wait( + [asyncio.create_task(result_rx.receive())], + timeout=SAFETY_TIMEOUT, + ) assert len(pending) == 0 assert len(done) == 1 result = done.pop().result() - assert isinstance(result, OutOfBound) + assert isinstance(result, OutOfBounds) assert result is not None assert result.request == request - assert result.bound == -1000 + assert result.bounds.inclusion_lower == -1000 async def test_power_distributor_one_user_adjust_power_success( self, mocker: MockerFixture ) -> None: - # pylint: disable=too-many-locals """Test if power distribution works with single user works.""" - await self.init_mock_microgrid(mocker) + mockgrid = MockMicrogrid(grid_meter=False) + mockgrid.add_batteries(3) + await mockgrid.start(mocker) + await self.init_component_data(mockgrid) channel = Broadcast[Request]("power_distributor") channel_registry = ChannelRegistry(name="power_distributor") @@ -535,7 +595,7 @@ async def test_power_distributor_one_user_adjust_power_success( request = Request( namespace=self._namespace, power=1000.0, - batteries={106, 206}, + batteries={9, 19}, request_timeout_sec=SAFETY_TIMEOUT, adjust_power=False, ) @@ -549,20 +609,18 @@ async def test_power_distributor_one_user_adjust_power_success( mocker.patch("asyncio.sleep", new_callable=AsyncMock) battery_status_channel = Broadcast[BatteryStatus]("battery_status") - distributor = PowerDistributingActor( + async with PowerDistributingActor( requests_receiver=channel.new_receiver(), channel_registry=channel_registry, battery_status_sender=battery_status_channel.new_sender(), - ) - - await channel.new_sender().send(request) - result_rx = channel_registry.new_receiver(self._namespace) + ): + await channel.new_sender().send(request) + result_rx = channel_registry.new_receiver(self._namespace) - done, pending = await asyncio.wait( - [asyncio.create_task(result_rx.receive())], - timeout=SAFETY_TIMEOUT, - ) - await distributor._stop_actor() + done, pending = await asyncio.wait( + [asyncio.create_task(result_rx.receive())], + timeout=SAFETY_TIMEOUT, + ) assert len(pending) == 0 assert len(done) == 1 @@ -575,13 +633,16 @@ async def test_power_distributor_one_user_adjust_power_success( async def test_not_all_batteries_are_working(self, mocker: MockerFixture) -> None: """Test if power distribution works if not all batteries are working.""" - await self.init_mock_microgrid(mocker) + mockgrid = MockMicrogrid(grid_meter=False) + mockgrid.add_batteries(3) + await mockgrid.start(mocker) + await self.init_component_data(mockgrid) mocker.patch("asyncio.sleep", new_callable=AsyncMock) - batteries = {106, 206} + batteries = {9, 19} - attrs = {"get_working_batteries.return_value": batteries - {106}} + attrs = {"get_working_batteries.return_value": batteries - {9}} mocker.patch( "frequenz.sdk.actor.power_distributing.power_distributing.BatteryPoolStatus", return_value=MagicMock(spec=BatteryPoolStatus, **attrs), @@ -590,43 +651,43 @@ async def test_not_all_batteries_are_working(self, mocker: MockerFixture) -> Non channel = Broadcast[Request]("power_distributor") channel_registry = ChannelRegistry(name="power_distributor") battery_status_channel = Broadcast[BatteryStatus]("battery_status") - distributor = PowerDistributingActor( + async with PowerDistributingActor( requests_receiver=channel.new_receiver(), channel_registry=channel_registry, battery_status_sender=battery_status_channel.new_sender(), - ) - - request = Request( - namespace=self._namespace, - power=1200.0, - batteries=batteries, - request_timeout_sec=SAFETY_TIMEOUT, - ) + ): + request = Request( + namespace=self._namespace, + power=1200.0, + batteries=batteries, + request_timeout_sec=SAFETY_TIMEOUT, + ) - await channel.new_sender().send(request) - result_rx = channel_registry.new_receiver(self._namespace) + await channel.new_sender().send(request) + result_rx = channel_registry.new_receiver(self._namespace) - done, pending = await asyncio.wait( - [asyncio.create_task(result_rx.receive())], - timeout=SAFETY_TIMEOUT, - ) - - assert len(pending) == 0 - assert len(done) == 1 - result = done.pop().result() - assert isinstance(result, Success) - assert result.succeeded_batteries == {206} - assert result.excess_power == approx(700.0) - assert result.succeeded_power == approx(500.0) - assert result.request == request + done, pending = await asyncio.wait( + [asyncio.create_task(result_rx.receive())], + timeout=SAFETY_TIMEOUT, + ) - await distributor._stop_actor() + assert len(pending) == 0 + assert len(done) == 1 + result = done.pop().result() + assert isinstance(result, Success) + assert result.succeeded_batteries == {19} + assert result.excess_power == approx(700.0) + assert result.succeeded_power == approx(500.0) + assert result.request == request async def test_use_all_batteries_none_is_working( self, mocker: MockerFixture ) -> None: """Test all batteries are used if none of them works.""" - await self.init_mock_microgrid(mocker) + mockgrid = MockMicrogrid(grid_meter=False) + mockgrid.add_batteries(3) + await mockgrid.start(mocker) + await self.init_component_data(mockgrid) mocker.patch("asyncio.sleep", new_callable=AsyncMock) @@ -639,50 +700,50 @@ async def test_use_all_batteries_none_is_working( channel = Broadcast[Request]("power_distributor") channel_registry = ChannelRegistry(name="power_distributor") battery_status_channel = Broadcast[BatteryStatus]("battery_status") - distributor = PowerDistributingActor( + async with PowerDistributingActor( requests_receiver=channel.new_receiver(), channel_registry=channel_registry, battery_status_sender=battery_status_channel.new_sender(), - ) - - request = Request( - namespace=self._namespace, - power=1200.0, - batteries={106, 206}, - request_timeout_sec=SAFETY_TIMEOUT, - include_broken_batteries=True, - ) - - await channel.new_sender().send(request) - result_rx = channel_registry.new_receiver(self._namespace) + ): + request = Request( + namespace=self._namespace, + power=1200.0, + batteries={9, 19}, + request_timeout_sec=SAFETY_TIMEOUT, + include_broken_batteries=True, + ) - done, pending = await asyncio.wait( - [asyncio.create_task(result_rx.receive())], - timeout=SAFETY_TIMEOUT, - ) + await channel.new_sender().send(request) + result_rx = channel_registry.new_receiver(self._namespace) - assert len(pending) == 0 - assert len(done) == 1 - result = done.pop().result() - assert isinstance(result, Success) - assert result.succeeded_batteries == {106, 206} - assert result.excess_power == approx(200.0) - assert result.succeeded_power == approx(1000.0) - assert result.request == request + done, pending = await asyncio.wait( + [asyncio.create_task(result_rx.receive())], + timeout=SAFETY_TIMEOUT, + ) - await distributor._stop_actor() + assert len(pending) == 0 + assert len(done) == 1 + result = done.pop().result() + assert isinstance(result, Success) + assert result.succeeded_batteries == {9, 19} + assert result.excess_power == approx(200.0) + assert result.succeeded_power == approx(1000.0) + assert result.request == request async def test_force_request_a_battery_is_not_working( self, mocker: MockerFixture ) -> None: """Test force request when a battery is not working.""" - await self.init_mock_microgrid(mocker) + mockgrid = MockMicrogrid(grid_meter=False) + mockgrid.add_batteries(3) + await mockgrid.start(mocker) + await self.init_component_data(mockgrid) mocker.patch("asyncio.sleep", new_callable=AsyncMock) - batteries = {106, 206} + batteries = {9, 19} - attrs = {"get_working_batteries.return_value": batteries - {106}} + attrs = {"get_working_batteries.return_value": batteries - {9}} mocker.patch( "frequenz.sdk.actor.power_distributing.power_distributing.BatteryPoolStatus", return_value=MagicMock(spec=BatteryPoolStatus, **attrs), @@ -691,49 +752,49 @@ async def test_force_request_a_battery_is_not_working( channel = Broadcast[Request]("power_distributor") channel_registry = ChannelRegistry(name="power_distributor") battery_status_channel = Broadcast[BatteryStatus]("battery_status") - distributor = PowerDistributingActor( + async with PowerDistributingActor( requests_receiver=channel.new_receiver(), channel_registry=channel_registry, battery_status_sender=battery_status_channel.new_sender(), - ) - - request = Request( - namespace=self._namespace, - power=1200.0, - batteries=batteries, - request_timeout_sec=SAFETY_TIMEOUT, - include_broken_batteries=True, - ) - - await channel.new_sender().send(request) - result_rx = channel_registry.new_receiver(self._namespace) + ): + request = Request( + namespace=self._namespace, + power=1200.0, + batteries=batteries, + request_timeout_sec=SAFETY_TIMEOUT, + include_broken_batteries=True, + ) - done, pending = await asyncio.wait( - [asyncio.create_task(result_rx.receive())], - timeout=SAFETY_TIMEOUT, - ) + await channel.new_sender().send(request) + result_rx = channel_registry.new_receiver(self._namespace) - assert len(pending) == 0 - assert len(done) == 1 - result = done.pop().result() - assert isinstance(result, Success) - assert result.succeeded_batteries == {106, 206} - assert result.excess_power == approx(200.0) - assert result.succeeded_power == approx(1000.0) - assert result.request == request + done, pending = await asyncio.wait( + [asyncio.create_task(result_rx.receive())], + timeout=SAFETY_TIMEOUT, + ) - await distributor._stop_actor() + assert len(pending) == 0 + assert len(done) == 1 + result = done.pop().result() + assert isinstance(result, Success) + assert result.succeeded_batteries == {9, 19} + assert result.excess_power == approx(200.0) + assert result.succeeded_power == approx(1000.0) + assert result.request == request - # pylint: disable=too-many-locals async def test_force_request_battery_nan_value_non_cached( self, mocker: MockerFixture ) -> None: """Test battery with NaN in SoC, capacity or power is used if request is forced.""" - mock_microgrid = await self.init_mock_microgrid(mocker) + # pylint: disable=too-many-locals + mockgrid = MockMicrogrid(grid_meter=False) + mockgrid.add_batteries(3) + await mockgrid.start(mocker) + await self.init_component_data(mockgrid) mocker.patch("asyncio.sleep", new_callable=AsyncMock) - batteries = {106, 206} + batteries = {9, 19} attrs = {"get_working_batteries.return_value": batteries} mocker.patch( @@ -744,66 +805,66 @@ async def test_force_request_battery_nan_value_non_cached( channel = Broadcast[Request]("power_distributor") channel_registry = ChannelRegistry(name="power_distributor") battery_status_channel = Broadcast[BatteryStatus]("battery_status") - distributor = PowerDistributingActor( + async with PowerDistributingActor( requests_receiver=channel.new_receiver(), channel_registry=channel_registry, battery_status_sender=battery_status_channel.new_sender(), - ) - - request = Request( - namespace=self._namespace, - power=1200.0, - batteries=batteries, - request_timeout_sec=SAFETY_TIMEOUT, - include_broken_batteries=True, - ) - - batteries_data = ( - battery_msg( - 106, - soc=Metric(math.nan, Bound(20, 80)), - capacity=Metric(math.nan), - power=Bound(-1000, 1000), - ), - battery_msg( - 206, - soc=Metric(40, Bound(20, 80)), - capacity=Metric(math.nan), - power=Bound(-1000, 1000), - ), - ) + ): + request = Request( + namespace=self._namespace, + power=1200.0, + batteries=batteries, + request_timeout_sec=SAFETY_TIMEOUT, + include_broken_batteries=True, + ) - for battery in batteries_data: - await mock_microgrid.send(battery) + batteries_data = ( + battery_msg( + 9, + soc=Metric(math.nan, Bound(20, 80)), + capacity=Metric(math.nan), + power=PowerBounds(-1000, 0, 0, 1000), + ), + battery_msg( + 19, + soc=Metric(40, Bound(20, 80)), + capacity=Metric(math.nan), + power=PowerBounds(-1000, 0, 0, 1000), + ), + ) - await channel.new_sender().send(request) - result_rx = channel_registry.new_receiver(self._namespace) + for battery in batteries_data: + await mockgrid.mock_client.send(battery) - done, pending = await asyncio.wait( - [asyncio.create_task(result_rx.receive())], - timeout=SAFETY_TIMEOUT, - ) + await channel.new_sender().send(request) + result_rx = channel_registry.new_receiver(self._namespace) - assert len(pending) == 0 - assert len(done) == 1 - result: Result = done.pop().result() - assert isinstance(result, Success) - assert result.succeeded_batteries == batteries - assert result.succeeded_power == approx(1199.9999) - assert result.excess_power == approx(0.0) - assert result.request == request + done, pending = await asyncio.wait( + [asyncio.create_task(result_rx.receive())], + timeout=SAFETY_TIMEOUT, + ) - await distributor._stop_actor() + assert len(pending) == 0 + assert len(done) == 1 + result: Result = done.pop().result() + assert isinstance(result, Success) + assert result.succeeded_batteries == batteries + assert result.succeeded_power == approx(1199.9999) + assert result.excess_power == approx(0.0) + assert result.request == request async def test_force_request_batteries_nan_values_cached( self, mocker: MockerFixture ) -> None: """Test battery with NaN in SoC, capacity or power is used if request is forced.""" - mock_microgrid = await self.init_mock_microgrid(mocker) + mockgrid = MockMicrogrid(grid_meter=False) + mockgrid.add_batteries(3) + await mockgrid.start(mocker) + await self.init_component_data(mockgrid) mocker.patch("asyncio.sleep", new_callable=AsyncMock) - batteries = {106, 206, 306} + batteries = {9, 19, 29} attrs = {"get_working_batteries.return_value": batteries} mocker.patch( @@ -814,67 +875,64 @@ async def test_force_request_batteries_nan_values_cached( channel = Broadcast[Request]("power_distributor") channel_registry = ChannelRegistry(name="power_distributor") battery_status_channel = Broadcast[BatteryStatus]("battery_status") - distributor = PowerDistributingActor( + async with PowerDistributingActor( requests_receiver=channel.new_receiver(), channel_registry=channel_registry, battery_status_sender=battery_status_channel.new_sender(), - ) - - request = Request( - namespace=self._namespace, - power=1200.0, - batteries=batteries, - request_timeout_sec=SAFETY_TIMEOUT, - include_broken_batteries=True, - ) - - result_rx = channel_registry.new_receiver(self._namespace) - - async def test_result() -> None: - done, pending = await asyncio.wait( - [asyncio.create_task(result_rx.receive())], - timeout=SAFETY_TIMEOUT, + ): + request = Request( + namespace=self._namespace, + power=1200.0, + batteries=batteries, + request_timeout_sec=SAFETY_TIMEOUT, + include_broken_batteries=True, ) - assert len(pending) == 0 - assert len(done) == 1 - result: Result = done.pop().result() - assert isinstance(result, Success) - assert result.succeeded_batteries == batteries - assert result.succeeded_power == approx(1199.9999) - assert result.excess_power == approx(0.0) - assert result.request == request - batteries_data = ( - battery_msg( - 106, - soc=Metric(math.nan, Bound(20, 80)), - capacity=Metric(98000), - power=Bound(-1000, 1000), - ), - battery_msg( - 206, - soc=Metric(40, Bound(20, 80)), - capacity=Metric(math.nan), - power=Bound(-1000, 1000), - ), - battery_msg( - 306, - soc=Metric(40, Bound(20, 80)), - capacity=Metric(float(98000)), - power=Bound(math.nan, math.nan), - ), - ) + result_rx = channel_registry.new_receiver(self._namespace) - # This request is needed to set the battery metrics cache to have valid - # metrics so that the distribution algorithm can be used in the next - # request where the batteries report NaN in the metrics. - await channel.new_sender().send(request) - await test_result() + async def test_result() -> None: + done, pending = await asyncio.wait( + [asyncio.create_task(result_rx.receive())], + timeout=SAFETY_TIMEOUT, + ) + assert len(pending) == 0 + assert len(done) == 1 + result: Result = done.pop().result() + assert isinstance(result, Success) + assert result.succeeded_batteries == batteries + assert result.succeeded_power == approx(1199.9999) + assert result.excess_power == approx(0.0) + assert result.request == request + + batteries_data = ( + battery_msg( + 9, + soc=Metric(math.nan, Bound(20, 80)), + capacity=Metric(98000), + power=PowerBounds(-1000, 0, 0, 1000), + ), + battery_msg( + 19, + soc=Metric(40, Bound(20, 80)), + capacity=Metric(math.nan), + power=PowerBounds(-1000, 0, 0, 1000), + ), + battery_msg( + 29, + soc=Metric(40, Bound(20, 80)), + capacity=Metric(float(98000)), + power=PowerBounds(math.nan, 0, 0, math.nan), + ), + ) - for battery in batteries_data: - await mock_microgrid.send(battery) + # This request is needed to set the battery metrics cache to have valid + # metrics so that the distribution algorithm can be used in the next + # request where the batteries report NaN in the metrics. + await channel.new_sender().send(request) + await test_result() - await channel.new_sender().send(request) - await test_result() + for battery in batteries_data: + await mockgrid.mock_client.send(battery) - await distributor._stop_actor() + await channel.new_sender().send(request) + await test_result() diff --git a/tests/actor/test_actor.py b/tests/actor/test_actor.py new file mode 100644 index 000000000..0cc64bb9a --- /dev/null +++ b/tests/actor/test_actor.py @@ -0,0 +1,381 @@ +# License: MIT +# Copyright © 2022 Frequenz Energy-as-a-Service GmbH + +"""Simple test for the BaseActor.""" + +import asyncio + +import pytest +from frequenz.channels import Broadcast, Receiver, Sender +from frequenz.channels.util import select, selected_from + +from frequenz.sdk.actor import Actor, run + +from ..conftest import actor_restart_limit + + +class MyBaseException(BaseException): + """A base exception for testing purposes.""" + + +class BaseTestActor(Actor): + """A base actor for testing purposes.""" + + restart_count: int = -1 + + def inc_restart_count(self) -> None: + """Increment the restart count.""" + BaseTestActor.restart_count += 1 + + @classmethod + def reset_restart_count(cls) -> None: + """Reset the restart count.""" + cls.restart_count = -1 + + +@pytest.fixture(autouse=True) +def reset_restart_count() -> None: + """Reset the restart count before each test.""" + BaseTestActor.reset_restart_count() + + +class NopActor(BaseTestActor): + """An actor that does nothing.""" + + def __init__(self) -> None: + """Create an instance.""" + super().__init__(name="test") + + async def _run(self) -> None: + """Start the actor and crash upon receiving a message""" + print(f"{self} started") + self.inc_restart_count() + print(f"{self} done") + + +class RaiseExceptionActor(BaseTestActor): + """A faulty actor that raises an Exception as soon as it receives a message.""" + + def __init__( + self, + recv: Receiver[int], + ) -> None: + """Create an instance. + + Args: + recv: A channel receiver for int data. + """ + super().__init__(name="test") + self._recv = recv + + async def _run(self) -> None: + """Start the actor and crash upon receiving a message""" + print(f"{self} started") + self.inc_restart_count() + async for msg in self._recv: + print(f"{self} is about to crash") + _ = msg / 0 + print(f"{self} done (should not happen)") + + +class RaiseBaseExceptionActor(BaseTestActor): + """A faulty actor that raises a BaseException as soon as it receives a message.""" + + def __init__( + self, + recv: Receiver[int], + ) -> None: + """Create an instance. + + Args: + recv: A channel receiver for int data. + """ + super().__init__(name="test") + self._recv = recv + + async def _run(self) -> None: + """Start the actor and crash upon receiving a message""" + print(f"{self} started") + self.inc_restart_count() + async for _ in self._recv: + print(f"{self} is about to crash") + raise MyBaseException("This is a test") + print(f"{self} done (should not happen)") + + +ACTOR_INFO = ("frequenz.sdk.actor._actor", 20) +ACTOR_ERROR = ("frequenz.sdk.actor._actor", 40) +RUN_INFO = ("frequenz.sdk.actor._run_utils", 20) +RUN_ERROR = ("frequenz.sdk.actor._run_utils", 40) + + +class EchoActor(BaseTestActor): + """An echo actor that whatever it receives into the output channel.""" + + def __init__( + self, + name: str, + recv1: Receiver[bool], + recv2: Receiver[bool], + output: Sender[bool], + ) -> None: + """Create an `EchoActor` instance. + + Args: + name (str): Name of the actor. + recv1 (Receiver[bool]): A channel receiver for test boolean data. + recv2 (Receiver[bool]): A channel receiver for test boolean data. + """ + super().__init__(name=name) + self._recv1 = recv1 + self._recv2 = recv2 + self._output = output + + async def _run(self) -> None: + """Do computations depending on the selected input message. + + Args: + output (Sender[OT]): A channel sender, to send actor's results to. + """ + print(f"{self} started") + self.inc_restart_count() + + channel_1 = self._recv1 + channel_2 = self._recv2 + + async for selected in select(channel_1, channel_2): + print(f"{self} received message {selected.value!r}") + if selected_from(selected, channel_1): + print(f"{self} sending message received from channel_1") + await self._output.send(selected.value) + elif selected_from(selected, channel_2): + print(f"{self} sending message received from channel_2") + await self._output.send(selected.value) + + print(f"{self} done (should not happen)") + + +async def test_basic_actor(caplog: pytest.LogCaptureFixture) -> None: + """Initialize the TestActor send a message and wait for the response.""" + caplog.set_level("DEBUG", logger="frequenz.sdk.actor._actor") + + input_chan_1: Broadcast[bool] = Broadcast("TestChannel1") + input_chan_2: Broadcast[bool] = Broadcast("TestChannel2") + + echo_chan: Broadcast[bool] = Broadcast("echo output") + echo_rx = echo_chan.new_receiver() + + async with EchoActor( + "EchoActor", + input_chan_1.new_receiver(), + input_chan_2.new_receiver(), + echo_chan.new_sender(), + ) as actor: + assert actor.is_running is True + original_tasks = set(actor.tasks) + + # Start is a no-op if already started + await actor.start() + assert actor.is_running is True + assert original_tasks == set(actor.tasks) + + await input_chan_1.new_sender().send(True) + msg = await echo_rx.receive() + assert msg is True + + await input_chan_2.new_sender().send(False) + msg = await echo_rx.receive() + assert msg is False + + assert actor.is_running is True + + assert actor.is_running is False + assert BaseTestActor.restart_count == 0 + assert caplog.record_tuples == [ + (*ACTOR_INFO, "Actor EchoActor[EchoActor]: Starting..."), + (*ACTOR_INFO, "Actor EchoActor[EchoActor]: Cancelled."), + ] + + +@pytest.mark.parametrize("restart_limit", [0, 1, 2, 10]) +async def test_restart_on_unhandled_exception( + restart_limit: int, caplog: pytest.LogCaptureFixture +) -> None: + """Create a faulty actor and expect it to restart because it raises an exception. + + Also test this works with different restart limits. + + Args: + restart_limit: The restart limit to use. + """ + caplog.set_level("DEBUG", logger="frequenz.sdk.actor._actor") + caplog.set_level("DEBUG", logger="frequenz.sdk.actor._run_utils") + + channel: Broadcast[int] = Broadcast("channel") + + async with asyncio.timeout(2.0): + with actor_restart_limit(restart_limit): + actor = RaiseExceptionActor( + channel.new_receiver(), + ) + for i in range(restart_limit + 1): + await channel.new_sender().send(i) + + await run(actor) + + assert actor.is_running is False + assert BaseTestActor.restart_count == restart_limit + expected_log = [ + (*RUN_INFO, "Starting 1 actor(s)..."), + (*ACTOR_INFO, "Actor RaiseExceptionActor[test]: Starting..."), + ] + for i in range(restart_limit): + expected_log.extend( + [ + ( + *ACTOR_ERROR, + "Actor RaiseExceptionActor[test]: Raised an unhandled exception.", + ), + ( + *ACTOR_INFO, + f"Actor test: Restarting ({i}/{restart_limit})...", + ), + ] + ) + expected_log.extend( + [ + ( + *ACTOR_ERROR, + "Actor RaiseExceptionActor[test]: Raised an unhandled exception.", + ), + ( + *ACTOR_INFO, + "Actor RaiseExceptionActor[test]: Maximum restarts attempted " + f"({restart_limit}/{restart_limit}), bailing out...", + ), + ( + *RUN_INFO, + "Actor RaiseExceptionActor[test]: Started normally.", + ), + ( + *RUN_ERROR, + "Actor RaiseExceptionActor[test]: Raised an exception while running.", + ), + (*RUN_INFO, "All 1 actor(s) finished."), + ] + ) + assert caplog.record_tuples == expected_log + + +async def test_does_not_restart_on_normal_exit( + actor_auto_restart_once: None, # pylint: disable=unused-argument + caplog: pytest.LogCaptureFixture, +) -> None: + """Create an actor that exists normally and expect it to not be restarted.""" + caplog.set_level("DEBUG", logger="frequenz.sdk.actor._actor") + caplog.set_level("DEBUG", logger="frequenz.sdk.actor._run_utils") + + channel: Broadcast[int] = Broadcast("channel") + + actor = NopActor() + + async with asyncio.timeout(1.0): + await channel.new_sender().send(1) + await run(actor) + + assert BaseTestActor.restart_count == 0 + assert caplog.record_tuples == [ + (*RUN_INFO, "Starting 1 actor(s)..."), + (*ACTOR_INFO, "Actor NopActor[test]: Starting..."), + (*ACTOR_INFO, "Actor NopActor[test]: _run() returned without error."), + (*ACTOR_INFO, "Actor NopActor[test]: Stopped."), + (*RUN_INFO, "Actor NopActor[test]: Started normally."), + (*RUN_INFO, "Actor NopActor[test]: Finished normally."), + (*RUN_INFO, "All 1 actor(s) finished."), + ] + + +async def test_does_not_restart_on_base_exception( + actor_auto_restart_once: None, # pylint: disable=unused-argument + caplog: pytest.LogCaptureFixture, +) -> None: + """Create a faulty actor and expect it not to restart because it raises a base exception.""" + caplog.set_level("DEBUG", logger="frequenz.sdk.actor._actor") + caplog.set_level("DEBUG", logger="frequenz.sdk.actor._run_utils") + + channel: Broadcast[int] = Broadcast("channel") + + actor = RaiseBaseExceptionActor(channel.new_receiver()) + + async with asyncio.timeout(1.0): + await channel.new_sender().send(1) + # We can't use pytest.raises() here because known BaseExceptions are handled + # specially by pytest. + try: + await run(actor) + except MyBaseException as error: + assert str(error) == "This is a test" + + assert BaseTestActor.restart_count == 0 + assert caplog.record_tuples == [ + (*RUN_INFO, "Starting 1 actor(s)..."), + (*ACTOR_INFO, "Actor RaiseBaseExceptionActor[test]: Starting..."), + (*ACTOR_ERROR, "Actor RaiseBaseExceptionActor[test]: Raised a BaseException."), + (*RUN_INFO, "Actor RaiseBaseExceptionActor[test]: Started normally."), + ( + *RUN_ERROR, + "Actor RaiseBaseExceptionActor[test]: Raised an exception while running.", + ), + (*RUN_INFO, "All 1 actor(s) finished."), + ] + + +async def test_does_not_restart_if_cancelled( + actor_auto_restart_once: None, # pylint: disable=unused-argument + caplog: pytest.LogCaptureFixture, +) -> None: + """Create a faulty actor and expect it not to restart when cancelled.""" + caplog.set_level("DEBUG", logger="frequenz.sdk.actor._actor") + caplog.set_level("DEBUG", logger="frequenz.sdk.actor._run_utils") + + input_chan_1: Broadcast[bool] = Broadcast("TestChannel1") + input_chan_2: Broadcast[bool] = Broadcast("TestChannel2") + + echo_chan: Broadcast[bool] = Broadcast("echo output") + echo_rx = echo_chan.new_receiver() + + actor = EchoActor( + "EchoActor", + input_chan_1.new_receiver(), + input_chan_2.new_receiver(), + echo_chan.new_sender(), + ) + + async def cancel_actor() -> None: + """Cancel the actor after a short delay.""" + await input_chan_1.new_sender().send(True) + msg = await echo_rx.receive() + assert msg is True + assert actor.is_running is True + + await input_chan_2.new_sender().send(False) + msg = await echo_rx.receive() + assert msg is False + + actor.cancel() + + async with asyncio.timeout(1.0): + async with asyncio.TaskGroup() as group: + group.create_task(cancel_actor(), name="cancel") + await run(actor) + + assert actor.is_running is False + assert BaseTestActor.restart_count == 0 + assert caplog.record_tuples == [ + (*RUN_INFO, "Starting 1 actor(s)..."), + (*ACTOR_INFO, "Actor EchoActor[EchoActor]: Starting..."), + (*RUN_INFO, "Actor EchoActor[EchoActor]: Started normally."), + (*ACTOR_INFO, "Actor EchoActor[EchoActor]: Cancelled."), + (*RUN_ERROR, "Actor EchoActor[EchoActor]: Raised an exception while running."), + (*RUN_INFO, "All 1 actor(s) finished."), + ] diff --git a/tests/actor/test_background_service.py b/tests/actor/test_background_service.py new file mode 100644 index 000000000..c36dd0567 --- /dev/null +++ b/tests/actor/test_background_service.py @@ -0,0 +1,152 @@ +# License: MIT +# Copyright © 2022 Frequenz Energy-as-a-Service GmbH + +"""Simple test for the BaseActor.""" +import asyncio +from collections.abc import Iterator +from typing import Literal, assert_never + +import async_solipsism +import pytest + +from frequenz.sdk.actor import BackgroundService + + +# Setting 'autouse' has no effect as this method replaces the event loop for all tests in the file. +@pytest.fixture() +def event_loop() -> Iterator[async_solipsism.EventLoop]: + """Replace the loop with one that doesn't interact with the outside world.""" + loop = async_solipsism.EventLoop() + yield loop + loop.close() + + +class FakeService(BackgroundService): + """A background service that does nothing.""" + + def __init__( + self, + *, + name: str | None = None, + sleep: float | None = None, + exc: BaseException | None = None, + ) -> None: + """Initialize a new FakeService.""" + super().__init__(name=name) + self._sleep = sleep + self._exc = exc + + async def start(self) -> None: + """Start this service.""" + + async def nop() -> None: + if self._sleep is not None: + await asyncio.sleep(self._sleep) + if self._exc is not None: + raise self._exc + + self._tasks.add(asyncio.create_task(nop(), name="nop")) + + +async def test_construction_defaults() -> None: + """Test the construction of a background service with default arguments.""" + fake_service = FakeService() + assert fake_service.name == str(id(fake_service)) + assert fake_service.tasks == set() + assert fake_service.is_running is False + assert str(fake_service) == f"FakeService[{fake_service.name}]" + assert repr(fake_service) == f"FakeService(name={fake_service.name!r}, tasks=set())" + + +async def test_construction_custom() -> None: + """Test the construction of a background service with a custom name.""" + fake_service = FakeService(name="test") + assert fake_service.name == "test" + assert fake_service.tasks == set() + assert fake_service.is_running is False + + +async def test_start_await() -> None: + """Test a background service starts and can be awaited.""" + fake_service = FakeService(name="test") + assert fake_service.name == "test" + assert fake_service.is_running is False + + # Is a no-op if the service is not running + await fake_service.stop() + assert fake_service.is_running is False + + await fake_service.start() + assert fake_service.is_running is True + + # Should stop immediately + async with asyncio.timeout(1.0): + await fake_service + + assert fake_service.is_running is False + + +async def test_start_stop() -> None: + """Test a background service starts and stops correctly.""" + fake_service = FakeService(name="test", sleep=2.0) + assert fake_service.name == "test" + assert fake_service.is_running is False + + # Is a no-op if the service is not running + await fake_service.stop() + assert fake_service.is_running is False + + await fake_service.start() + assert fake_service.is_running is True + + await asyncio.sleep(1.0) + assert fake_service.is_running is True + + await fake_service.stop() + assert fake_service.is_running is False + + await fake_service.stop() + assert fake_service.is_running is False + + +@pytest.mark.parametrize("method", ["await", "wait", "stop"]) +async def test_start_and_crash( + method: Literal["await"] | Literal["wait"] | Literal["stop"], +) -> None: + """Test a background service reports when crashing.""" + exc = RuntimeError("error") + fake_service = FakeService(name="test", exc=exc) + assert fake_service.name == "test" + assert fake_service.is_running is False + + await fake_service.start() + with pytest.raises(BaseExceptionGroup) as exc_info: + match method: + case "await": + await fake_service + case "wait": + await fake_service.wait() + case "stop": + # Give the service some time to run and crash, otherwise stop() will + # cancel it before it has a chance to crash + await asyncio.sleep(1.0) + await fake_service.stop() + case _: + assert_never(method) + + rt_errors, rest_errors = exc_info.value.split(RuntimeError) + assert rt_errors is not None + assert rest_errors is None + assert len(rt_errors.exceptions) == 1 + assert rt_errors.exceptions[0] is exc + + +async def test_async_context_manager() -> None: + """Test a background service works as an async context manager.""" + async with FakeService(name="test", sleep=1.0) as fake_service: + assert fake_service.is_running is True + # Is a no-op if the service is running + await fake_service.start() + assert fake_service.is_running is True + + assert fake_service.is_running is False diff --git a/tests/actor/test_battery_pool_status.py b/tests/actor/test_battery_pool_status.py index f92b47f15..4e0f40475 100644 --- a/tests/actor/test_battery_pool_status.py +++ b/tests/actor/test_battery_pool_status.py @@ -5,7 +5,6 @@ import asyncio from typing import Set -import pytest from frequenz.channels import Broadcast from pytest_mock import MockerFixture @@ -14,31 +13,16 @@ BatteryStatus, ) from frequenz.sdk.microgrid.component import ComponentCategory +from tests.timeseries.mock_microgrid import MockMicrogrid -from ..utils.mock_microgrid_client import MockMicrogridClient -from .test_battery_status import battery_data, component_graph, inverter_data +from .test_battery_status import battery_data, inverter_data # pylint: disable=protected-access class TestBatteryPoolStatus: """Tests for BatteryPoolStatus""" - @pytest.fixture - async def mock_microgrid(self, mocker: MockerFixture) -> MockMicrogridClient: - """Create and initialize mock microgrid - - Args: - mocker: pytest mocker - - Returns: - MockMicrogridClient - """ - components, connections = component_graph() - microgrid = MockMicrogridClient(components, connections) - microgrid.initialize(mocker) - return microgrid - - async def test_batteries_status(self, mock_microgrid: MockMicrogridClient) -> None: + async def test_batteries_status(self, mocker: MockerFixture) -> None: """Basic tests for BatteryPoolStatus. BatteryStatusTracker is more tested in its own unit tests. @@ -46,9 +30,13 @@ async def test_batteries_status(self, mock_microgrid: MockMicrogridClient) -> No Args: mock_microgrid: mock microgrid client """ + mock_microgrid = MockMicrogrid(grid_meter=True) + mock_microgrid.add_batteries(3) + await mock_microgrid.start(mocker) + batteries = { battery.component_id - for battery in mock_microgrid.component_graph.components( + for battery in mock_microgrid.mock_client.component_graph.components( component_category={ComponentCategory.BATTERY} ) } @@ -67,22 +55,34 @@ async def test_batteries_status(self, mock_microgrid: MockMicrogridClient) -> No batteries_list = list(batteries) - await mock_microgrid.send(battery_data(component_id=batteries_list[0])) + await mock_microgrid.mock_client.send( + battery_data(component_id=batteries_list[0]) + ) await asyncio.sleep(0.1) assert batteries_status.get_working_batteries(batteries) == expected_working expected_working.add(batteries_list[0]) - await mock_microgrid.send(inverter_data(component_id=batteries_list[0] - 1)) + await mock_microgrid.mock_client.send( + inverter_data(component_id=batteries_list[0] - 1) + ) await asyncio.sleep(0.1) assert batteries_status.get_working_batteries(batteries) == expected_working msg = await asyncio.wait_for(battery_status_recv.receive(), timeout=0.2) assert msg == batteries_status._current_status - await mock_microgrid.send(inverter_data(component_id=batteries_list[1] - 1)) - await mock_microgrid.send(battery_data(component_id=batteries_list[1])) + await mock_microgrid.mock_client.send( + inverter_data(component_id=batteries_list[1] - 1) + ) + await mock_microgrid.mock_client.send( + battery_data(component_id=batteries_list[1]) + ) - await mock_microgrid.send(inverter_data(component_id=batteries_list[2] - 1)) - await mock_microgrid.send(battery_data(component_id=batteries_list[2])) + await mock_microgrid.mock_client.send( + inverter_data(component_id=batteries_list[2] - 1) + ) + await mock_microgrid.mock_client.send( + battery_data(component_id=batteries_list[2]) + ) expected_working = set(batteries_list) await asyncio.sleep(0.1) @@ -91,15 +91,15 @@ async def test_batteries_status(self, mock_microgrid: MockMicrogridClient) -> No assert msg == batteries_status._current_status await batteries_status.update_status( - succeed_batteries={106}, failed_batteries={206, 306} + succeed_batteries={9}, failed_batteries={19, 29} ) await asyncio.sleep(0.1) - assert batteries_status.get_working_batteries(batteries) == {106} + assert batteries_status.get_working_batteries(batteries) == {9} await batteries_status.update_status( - succeed_batteries={106, 206}, failed_batteries=set() + succeed_batteries={9, 19}, failed_batteries=set() ) await asyncio.sleep(0.1) - assert batteries_status.get_working_batteries(batteries) == {106, 206} + assert batteries_status.get_working_batteries(batteries) == {9, 19} await batteries_status.stop() diff --git a/tests/actor/test_battery_status.py b/tests/actor/test_battery_status.py index b0e7c46da..375ebf64b 100644 --- a/tests/actor/test_battery_status.py +++ b/tests/actor/test_battery_status.py @@ -6,10 +6,12 @@ import math from dataclasses import dataclass from datetime import datetime, timedelta, timezone -from typing import Generic, Iterable, List, Optional, Set, Tuple, TypeVar +from typing import AsyncIterator, Generic, Iterable, List, Optional, TypeVar import pytest import time_machine + +# pylint: disable=no-name-in-module from frequenz.api.microgrid.battery_pb2 import ComponentState as BatteryState from frequenz.api.microgrid.battery_pb2 import Error as BatteryError from frequenz.api.microgrid.battery_pb2 import ErrorCode as BatteryErrorCode @@ -18,7 +20,9 @@ from frequenz.api.microgrid.inverter_pb2 import ComponentState as InverterState from frequenz.api.microgrid.inverter_pb2 import Error as InverterError from frequenz.api.microgrid.inverter_pb2 import ErrorCode as InverterErrorCode -from frequenz.channels import Broadcast + +# pylint: enable=no-name-in-module +from frequenz.channels import Broadcast, Receiver from pytest_mock import MockerFixture from frequenz.sdk.actor.power_distributing._battery_status import ( @@ -26,16 +30,10 @@ SetPowerResult, Status, ) -from frequenz.sdk.microgrid.client import Connection -from frequenz.sdk.microgrid.component import ( - BatteryData, - Component, - ComponentCategory, - InverterData, -) +from frequenz.sdk.microgrid.component import BatteryData, InverterData +from tests.timeseries.mock_microgrid import MockMicrogrid from ..utils.component_data_wrapper import BatteryDataWrapper, InverterDataWrapper -from ..utils.mock_microgrid_client import MockMicrogridClient def battery_data( # pylint: disable=too-many-arguments @@ -108,42 +106,6 @@ def inverter_data( ) -def component_graph() -> Tuple[Set[Component], Set[Connection]]: - """Creates components and connections for the microgrid component graph. - - Returns: - Tuple with set of components and set of connections. - """ - components = { - Component(1, ComponentCategory.GRID), - Component(2, ComponentCategory.METER), - Component(104, ComponentCategory.METER), - Component(105, ComponentCategory.INVERTER), - Component(106, ComponentCategory.BATTERY), - Component(204, ComponentCategory.METER), - Component(205, ComponentCategory.INVERTER), - Component(206, ComponentCategory.BATTERY), - Component(304, ComponentCategory.METER), - Component(305, ComponentCategory.INVERTER), - Component(306, ComponentCategory.BATTERY), - } - - connections = { - Connection(1, 2), - Connection(2, 104), - Connection(104, 105), - Connection(105, 106), - Connection(2, 204), - Connection(204, 205), - Connection(205, 206), - Connection(2, 304), - Connection(304, 305), - Connection(305, 306), - } - - return components, connections - - T = TypeVar("T") @@ -154,32 +116,37 @@ class Message(Generic[T]): inner: T -BATTERY_ID = 106 -INVERTER_ID = 105 +BATTERY_ID = 9 +INVERTER_ID = 8 -# pylint: disable=protected-access, unused-argument -class TestBatteryStatus: - """Tests BatteryStatusTracker.""" +class _Timeout: + """Sentinel for timeout.""" - @pytest.fixture - async def mock_microgrid(self, mocker: MockerFixture) -> MockMicrogridClient: - """Create and initialize mock microgrid - Args: - mocker: pytest mocker +async def recv_timeout(recv: Receiver[T], timeout: float = 0.1) -> T | type[_Timeout]: + """Receive message from receiver with timeout. + + Args: + recv: Receiver to receive message from. + timeout: Timeout in seconds. + + Returns: + Received message or _Timeout if timeout is reached. + """ + try: + return await asyncio.wait_for(recv.receive(), timeout=timeout) + except asyncio.TimeoutError: + return _Timeout - Returns: - MockMicrogridClient - """ - components, connections = component_graph() - microgrid = MockMicrogridClient(components, connections) - microgrid.initialize(mocker) - return microgrid + +# pylint: disable=protected-access, unused-argument +class TestBatteryStatus: + """Tests BatteryStatusTracker.""" @time_machine.travel("2022-01-01 00:00 UTC", tick=False) async def test_sync_update_status_with_messages( - self, mock_microgrid: MockMicrogridClient + self, mocker: MockerFixture ) -> None: """Test if messages changes battery status/ @@ -189,6 +156,10 @@ async def test_sync_update_status_with_messages( Args: mock_microgrid: mock_microgrid fixture """ + mock_microgrid = MockMicrogrid(grid_meter=True) + mock_microgrid.add_batteries(3) + await mock_microgrid.start(mocker) + status_channel = Broadcast[Status]("battery_status") set_power_result_channel = Broadcast[SetPowerResult]("set_power_result") @@ -336,10 +307,9 @@ async def test_sync_update_status_with_messages( assert tracker._get_new_status_if_changed() is Status.NOT_WORKING await tracker.stop() + await mock_microgrid.cleanup() - async def test_sync_blocking_feature( - self, mock_microgrid: MockMicrogridClient - ) -> None: + async def test_sync_blocking_feature(self, mocker: MockerFixture) -> None: """Test if status changes when SetPowerResult message is received. Tests uses FakeSelect to test status in sync way. @@ -348,6 +318,9 @@ async def test_sync_blocking_feature( Args: mock_microgrid: mock_microgrid fixture """ + mock_microgrid = MockMicrogrid(grid_meter=True) + mock_microgrid.add_batteries(3) + await mock_microgrid.start(mocker) status_channel = Broadcast[Status]("battery_status") set_power_result_channel = Broadcast[SetPowerResult]("set_power_result") @@ -378,7 +351,7 @@ async def test_sync_blocking_feature( # message is not correct, component should not block. tracker._handle_status_set_power_result( - SetPowerResult(succeed={1}, failed={106}) + SetPowerResult(succeed={1}, failed={BATTERY_ID}) ) assert tracker._get_new_status_if_changed() is None @@ -392,7 +365,7 @@ async def test_sync_blocking_feature( for timeout in expected_blocking_timeout: # message is not correct, component should not block. tracker._handle_status_set_power_result( - SetPowerResult(succeed={1}, failed={106}) + SetPowerResult(succeed={1}, failed={BATTERY_ID}) ) assert tracker._get_new_status_if_changed() is Status.UNCERTAIN @@ -400,7 +373,7 @@ async def test_sync_blocking_feature( # Battery should be still blocked, nothing should happen time.shift(timeout - 1) tracker._handle_status_set_power_result( - SetPowerResult(succeed={1}, failed={106}) + SetPowerResult(succeed={1}, failed={BATTERY_ID}) ) assert tracker._get_new_status_if_changed() is None @@ -416,7 +389,7 @@ async def test_sync_blocking_feature( # should block for 30 sec tracker._handle_status_set_power_result( - SetPowerResult(succeed={1}, failed={106}) + SetPowerResult(succeed={1}, failed={BATTERY_ID}) ) assert tracker._get_new_status_if_changed() is Status.UNCERTAIN @@ -437,21 +410,22 @@ async def test_sync_blocking_feature( # should block for 30 sec tracker._handle_status_set_power_result( - SetPowerResult(succeed={1}, failed={106}) + SetPowerResult(succeed={1}, failed={BATTERY_ID}) ) assert tracker._get_new_status_if_changed() is Status.UNCERTAIN time.shift(28) # If battery succeed, then it should unblock. tracker._handle_status_set_power_result( - SetPowerResult(succeed={106}, failed={206}) + SetPowerResult(succeed={BATTERY_ID}, failed={19}) ) assert tracker._get_new_status_if_changed() is Status.WORKING await tracker.stop() + await mock_microgrid.cleanup() async def test_sync_blocking_interrupted_with_with_max_data( - self, mock_microgrid: MockMicrogridClient + self, mocker: MockerFixture ) -> None: """Test if status changes when SetPowerResult message is received. @@ -461,6 +435,9 @@ async def test_sync_blocking_interrupted_with_with_max_data( Args: mock_microgrid: mock_microgrid fixture """ + mock_microgrid = MockMicrogrid(grid_meter=True) + mock_microgrid.add_batteries(3) + await mock_microgrid.start(mocker) status_channel = Broadcast[Status]("battery_status") set_power_result_channel = Broadcast[SetPowerResult]("set_power_result") @@ -481,7 +458,7 @@ async def test_sync_blocking_interrupted_with_with_max_data( assert tracker._get_new_status_if_changed() is Status.WORKING tracker._handle_status_set_power_result( - SetPowerResult(succeed={1}, failed={106}) + SetPowerResult(succeed={1}, failed={BATTERY_ID}) ) assert tracker._get_new_status_if_changed() is Status.UNCERTAIN @@ -489,16 +466,17 @@ async def test_sync_blocking_interrupted_with_with_max_data( for timeout in expected_blocking_timeout: # message is not correct, component should not block. tracker._handle_status_set_power_result( - SetPowerResult(succeed={1}, failed={106}) + SetPowerResult(succeed={1}, failed={BATTERY_ID}) ) assert tracker._get_new_status_if_changed() is None time.shift(timeout) - await tracker.stop() + await tracker.stop() + await mock_microgrid.cleanup() @time_machine.travel("2022-01-01 00:00 UTC", tick=False) async def test_sync_blocking_interrupted_with_invalid_message( - self, mock_microgrid: MockMicrogridClient + self, mocker: MockerFixture ) -> None: """Test if status changes when SetPowerResult message is received. @@ -508,6 +486,9 @@ async def test_sync_blocking_interrupted_with_invalid_message( Args: mock_microgrid: mock_microgrid fixture """ + mock_microgrid = MockMicrogrid(grid_meter=True) + mock_microgrid.add_batteries(3) + await mock_microgrid.start(mocker) status_channel = Broadcast[Status]("battery_status") set_power_result_channel = Broadcast[SetPowerResult]("set_power_result") @@ -527,7 +508,7 @@ async def test_sync_blocking_interrupted_with_invalid_message( assert tracker._get_new_status_if_changed() is Status.WORKING tracker._handle_status_set_power_result( - SetPowerResult(succeed={1}, failed={106}) + SetPowerResult(succeed={1}, failed={BATTERY_ID}) ) assert tracker._get_new_status_if_changed() is Status.UNCERTAIN @@ -540,12 +521,12 @@ async def test_sync_blocking_interrupted_with_invalid_message( assert tracker._get_new_status_if_changed() is Status.NOT_WORKING tracker._handle_status_set_power_result( - SetPowerResult(succeed={1}, failed={106}) + SetPowerResult(succeed={1}, failed={BATTERY_ID}) ) assert tracker._get_new_status_if_changed() is None tracker._handle_status_set_power_result( - SetPowerResult(succeed={106}, failed={}) + SetPowerResult(succeed={BATTERY_ID}, failed={}) ) assert tracker._get_new_status_if_changed() is None @@ -553,11 +534,10 @@ async def test_sync_blocking_interrupted_with_invalid_message( assert tracker._get_new_status_if_changed() is Status.WORKING await tracker.stop() + await mock_microgrid.cleanup() @time_machine.travel("2022-01-01 00:00 UTC", tick=False) - async def test_timers( - self, mock_microgrid: MockMicrogridClient, mocker: MockerFixture - ) -> None: + async def test_timers(self, mocker: MockerFixture) -> None: """Test if messages changes battery status/ Tests uses FakeSelect to test status in sync way. @@ -567,6 +547,10 @@ async def test_timers( mock_microgrid: mock_microgrid fixture mocker: pytest mocker instance """ + mock_microgrid = MockMicrogrid(grid_meter=True) + mock_microgrid.add_batteries(3) + await mock_microgrid.start(mocker) + status_channel = Broadcast[Status]("battery_status") set_power_result_channel = Broadcast[SetPowerResult]("set_power_result") @@ -616,16 +600,18 @@ async def test_timers( assert inverter_timer_spy.call_count == 2 await tracker.stop() + await mock_microgrid.cleanup() @time_machine.travel("2022-01-01 00:00 UTC", tick=False) - async def test_async_battery_status( - self, mock_microgrid: MockMicrogridClient - ) -> None: + async def test_async_battery_status(self, mocker: MockerFixture) -> None: """Test if status changes. Args: mock_microgrid: mock_microgrid fixture """ + mock_microgrid = MockMicrogrid(grid_meter=True) + mock_microgrid.add_batteries(3) + await mock_microgrid.start(mocker) status_channel = Broadcast[Status]("battery_status") set_power_result_channel = Broadcast[SetPowerResult]("set_power_result") @@ -643,8 +629,10 @@ async def test_async_battery_status( await asyncio.sleep(0.01) with time_machine.travel("2022-01-01 00:00 UTC", tick=False) as time: - await mock_microgrid.send(inverter_data(component_id=INVERTER_ID)) - await mock_microgrid.send(battery_data(component_id=BATTERY_ID)) + await mock_microgrid.mock_client.send( + inverter_data(component_id=INVERTER_ID) + ) + await mock_microgrid.mock_client.send(battery_data(component_id=BATTERY_ID)) status = await asyncio.wait_for(status_receiver.receive(), timeout=0.1) assert status is Status.WORKING @@ -656,11 +644,11 @@ async def test_async_battery_status( time.shift(2) - await mock_microgrid.send(battery_data(component_id=BATTERY_ID)) + await mock_microgrid.mock_client.send(battery_data(component_id=BATTERY_ID)) status = await asyncio.wait_for(status_receiver.receive(), timeout=0.1) assert status is Status.WORKING - await mock_microgrid.send( + await mock_microgrid.mock_client.send( inverter_data( component_id=INVERTER_ID, timestamp=datetime.now(tz=timezone.utc) - timedelta(seconds=7), @@ -675,8 +663,325 @@ async def test_async_battery_status( await asyncio.sleep(0.3) assert len(status_receiver) == 0 - await mock_microgrid.send(inverter_data(component_id=INVERTER_ID)) + await mock_microgrid.mock_client.send( + inverter_data(component_id=INVERTER_ID) + ) status = await asyncio.wait_for(status_receiver.receive(), timeout=0.1) assert status is Status.WORKING await tracker.stop() + await mock_microgrid.cleanup() + + +class TestBatteryStatusRecovery: + """Test battery status recovery. + + The following cases are tested: + + - battery/inverter data missing + - battery/inverter bad state + - battery/inverter warning/critical error + - battery capacity missing + - received stale battery/inverter data + """ + + @pytest.fixture + async def setup_tracker( + self, mocker: MockerFixture + ) -> AsyncIterator[tuple[MockMicrogrid, Receiver[Status]]]: + """Setup a BatteryStatusTracker instance to run tests with.""" + mock_microgrid = MockMicrogrid(grid_meter=True) + mock_microgrid.add_batteries(1) + await mock_microgrid.start(mocker) + + status_channel = Broadcast[Status]("battery_status") + set_power_result_channel = Broadcast[SetPowerResult]("set_power_result") + + status_receiver = status_channel.new_receiver() + + _tracker = BatteryStatusTracker( + BATTERY_ID, + max_data_age_sec=0.1, + max_blocking_duration_sec=1, + status_sender=status_channel.new_sender(), + set_power_result_receiver=set_power_result_channel.new_receiver(), + ) + + await asyncio.sleep(0.05) + + yield (mock_microgrid, status_receiver) + + await _tracker.stop() + await mock_microgrid.cleanup() + + async def _send_healthy_battery( + self, mock_microgrid: MockMicrogrid, timestamp: datetime | None = None + ) -> None: + await mock_microgrid.mock_client.send( + battery_data( + timestamp=timestamp, + component_id=BATTERY_ID, + component_state=BatteryState.COMPONENT_STATE_IDLE, + relay_state=BatteryRelayState.RELAY_STATE_CLOSED, + ) + ) + + async def _send_battery_missing_capacity( + self, mock_microgrid: MockMicrogrid + ) -> None: + await mock_microgrid.mock_client.send( + battery_data( + component_id=BATTERY_ID, + component_state=BatteryState.COMPONENT_STATE_IDLE, + relay_state=BatteryRelayState.RELAY_STATE_CLOSED, + capacity=math.nan, + ) + ) + + async def _send_healthy_inverter( + self, mock_microgrid: MockMicrogrid, timestamp: datetime | None = None + ) -> None: + await mock_microgrid.mock_client.send( + inverter_data( + timestamp=timestamp, + component_id=INVERTER_ID, + component_state=InverterState.COMPONENT_STATE_IDLE, + ) + ) + + async def _send_bad_state_battery(self, mock_microgrid: MockMicrogrid) -> None: + await mock_microgrid.mock_client.send( + battery_data( + component_id=BATTERY_ID, + component_state=BatteryState.COMPONENT_STATE_ERROR, + relay_state=BatteryRelayState.RELAY_STATE_CLOSED, + ) + ) + + async def _send_bad_state_inverter(self, mock_microgrid: MockMicrogrid) -> None: + await mock_microgrid.mock_client.send( + inverter_data( + component_id=INVERTER_ID, + component_state=InverterState.COMPONENT_STATE_ERROR, + ) + ) + + async def _send_critical_error_battery(self, mock_microgrid: MockMicrogrid) -> None: + battery_critical_error = BatteryError( + code=BatteryErrorCode.ERROR_CODE_BLOCK_ERROR, + level=ErrorLevel.ERROR_LEVEL_CRITICAL, + msg="", + ) + await mock_microgrid.mock_client.send( + battery_data( + component_id=BATTERY_ID, + component_state=BatteryState.COMPONENT_STATE_IDLE, + relay_state=BatteryRelayState.RELAY_STATE_CLOSED, + errors=[battery_critical_error], + ) + ) + + async def _send_warning_error_battery(self, mock_microgrid: MockMicrogrid) -> None: + battery_warning_error = BatteryError( + code=BatteryErrorCode.ERROR_CODE_HIGH_HUMIDITY, + level=ErrorLevel.ERROR_LEVEL_WARN, + msg="", + ) + await mock_microgrid.mock_client.send( + battery_data( + component_id=BATTERY_ID, + component_state=BatteryState.COMPONENT_STATE_IDLE, + relay_state=BatteryRelayState.RELAY_STATE_CLOSED, + errors=[battery_warning_error], + ) + ) + + async def _send_critical_error_inverter( + self, mock_microgrid: MockMicrogrid + ) -> None: + inverter_critical_error = InverterError( + code=InverterErrorCode.ERROR_CODE_UNSPECIFIED, + level=ErrorLevel.ERROR_LEVEL_CRITICAL, + msg="", + ) + await mock_microgrid.mock_client.send( + inverter_data( + component_id=INVERTER_ID, + component_state=InverterState.COMPONENT_STATE_IDLE, + errors=[inverter_critical_error], + ) + ) + + async def _send_warning_error_inverter(self, mock_microgrid: MockMicrogrid) -> None: + inverter_warning_error = InverterError( + code=InverterErrorCode.ERROR_CODE_UNSPECIFIED, + level=ErrorLevel.ERROR_LEVEL_WARN, + msg="", + ) + await mock_microgrid.mock_client.send( + inverter_data( + component_id=INVERTER_ID, + component_state=InverterState.COMPONENT_STATE_IDLE, + errors=[inverter_warning_error], + ) + ) + + async def test_missing_data( + self, + setup_tracker: tuple[MockMicrogrid, Receiver[Status]], + ) -> None: + """Test recovery after missing data.""" + mock_microgrid, status_receiver = setup_tracker + + await self._send_healthy_battery(mock_microgrid) + await self._send_healthy_inverter(mock_microgrid) + assert await status_receiver.receive() is Status.WORKING + + # --- missing battery data --- + await self._send_healthy_inverter(mock_microgrid) + assert await status_receiver.receive() is Status.NOT_WORKING + + await self._send_healthy_battery(mock_microgrid) + await self._send_healthy_inverter(mock_microgrid) + assert await status_receiver.receive() is Status.WORKING + + # --- missing inverter data --- + await self._send_healthy_battery(mock_microgrid) + assert await status_receiver.receive() is Status.NOT_WORKING + + await self._send_healthy_battery(mock_microgrid) + await self._send_healthy_inverter(mock_microgrid) + assert await status_receiver.receive() is Status.WORKING + + async def test_bad_state( + self, + setup_tracker: tuple[MockMicrogrid, Receiver[Status]], + ) -> None: + """Test recovery after bad component state.""" + mock_microgrid, status_receiver = setup_tracker + + await self._send_healthy_battery(mock_microgrid) + await self._send_healthy_inverter(mock_microgrid) + assert await status_receiver.receive() is Status.WORKING + + # --- bad battery state --- + await self._send_healthy_inverter(mock_microgrid) + await self._send_bad_state_battery(mock_microgrid) + assert await status_receiver.receive() is Status.NOT_WORKING + + await self._send_healthy_battery(mock_microgrid) + await self._send_healthy_inverter(mock_microgrid) + assert await status_receiver.receive() is Status.WORKING + + # --- bad inverter state --- + await self._send_bad_state_inverter(mock_microgrid) + await self._send_healthy_battery(mock_microgrid) + assert await status_receiver.receive() is Status.NOT_WORKING + + await self._send_healthy_battery(mock_microgrid) + await self._send_healthy_inverter(mock_microgrid) + assert await status_receiver.receive() is Status.WORKING + + async def test_critical_error( + self, + setup_tracker: tuple[MockMicrogrid, Receiver[Status]], + ) -> None: + """Test recovery after critical error.""" + + mock_microgrid, status_receiver = setup_tracker + + await self._send_healthy_inverter(mock_microgrid) + await self._send_healthy_battery(mock_microgrid) + assert await status_receiver.receive() is Status.WORKING + + # --- battery warning error (keeps working) --- + await self._send_healthy_inverter(mock_microgrid) + await self._send_warning_error_battery(mock_microgrid) + assert await recv_timeout(status_receiver, timeout=0.1) is _Timeout + + await self._send_healthy_battery(mock_microgrid) + await self._send_healthy_inverter(mock_microgrid) + + # --- battery critical error --- + await self._send_healthy_inverter(mock_microgrid) + await self._send_critical_error_battery(mock_microgrid) + assert await status_receiver.receive() is Status.NOT_WORKING + + await self._send_healthy_battery(mock_microgrid) + await self._send_healthy_inverter(mock_microgrid) + assert await status_receiver.receive() is Status.WORKING + + # --- inverter warning error (keeps working) --- + await self._send_healthy_battery(mock_microgrid) + await self._send_warning_error_inverter(mock_microgrid) + assert await recv_timeout(status_receiver, timeout=0.1) is _Timeout + + await self._send_healthy_battery(mock_microgrid) + await self._send_healthy_inverter(mock_microgrid) + + # --- inverter critical error --- + await self._send_healthy_battery(mock_microgrid) + await self._send_critical_error_inverter(mock_microgrid) + assert await status_receiver.receive() is Status.NOT_WORKING + + await self._send_healthy_battery(mock_microgrid) + await self._send_healthy_inverter(mock_microgrid) + assert await status_receiver.receive() is Status.WORKING + + async def test_missing_capacity( + self, + setup_tracker: tuple[MockMicrogrid, Receiver[Status]], + ) -> None: + """Test recovery after missing capacity.""" + mock_microgrid, status_receiver = setup_tracker + + await self._send_healthy_battery(mock_microgrid) + await self._send_healthy_inverter(mock_microgrid) + assert await status_receiver.receive() is Status.WORKING + + await self._send_healthy_inverter(mock_microgrid) + await self._send_battery_missing_capacity(mock_microgrid) + assert await status_receiver.receive() is Status.NOT_WORKING + + await self._send_healthy_battery(mock_microgrid) + await self._send_healthy_inverter(mock_microgrid) + assert await status_receiver.receive() is Status.WORKING + + async def test_stale_data( + self, + setup_tracker: tuple[MockMicrogrid, Receiver[Status]], + ) -> None: + """Test recovery after stale data.""" + mock_microgrid, status_receiver = setup_tracker + + timestamp = datetime.now(timezone.utc) + await self._send_healthy_battery(mock_microgrid, timestamp) + await self._send_healthy_inverter(mock_microgrid) + assert await status_receiver.receive() is Status.WORKING + + # --- stale battery data --- + await self._send_healthy_inverter(mock_microgrid) + await self._send_healthy_battery(mock_microgrid, timestamp) + assert await recv_timeout(status_receiver) is _Timeout + + await self._send_healthy_inverter(mock_microgrid) + await self._send_healthy_battery(mock_microgrid, timestamp) + assert await recv_timeout(status_receiver) is Status.NOT_WORKING + + timestamp = datetime.now(timezone.utc) + await self._send_healthy_battery(mock_microgrid, timestamp) + await self._send_healthy_inverter(mock_microgrid, timestamp) + assert await status_receiver.receive() is Status.WORKING + + # --- stale inverter data --- + await self._send_healthy_battery(mock_microgrid) + await self._send_healthy_inverter(mock_microgrid, timestamp) + assert await recv_timeout(status_receiver) is _Timeout + + await self._send_healthy_battery(mock_microgrid) + await self._send_healthy_inverter(mock_microgrid, timestamp) + assert await recv_timeout(status_receiver) is Status.NOT_WORKING + + await self._send_healthy_battery(mock_microgrid) + await self._send_healthy_inverter(mock_microgrid) + assert await status_receiver.receive() is Status.WORKING diff --git a/tests/actor/test_config_manager.py b/tests/actor/test_config_manager.py index 8c81060f8..3e70c4f09 100644 --- a/tests/actor/test_config_manager.py +++ b/tests/actor/test_config_manager.py @@ -80,29 +80,23 @@ async def test_update(self, config_file: pathlib.Path) -> None: config_channel: Broadcast[Config] = Broadcast( "Config Channel", resend_latest=True ) - _config_manager = ConfigManagingActor( - conf_file=str(config_file), output=config_channel.new_sender() - ) - config_receiver = config_channel.new_receiver() - config = await config_receiver.receive() - assert config is not None - assert config.get("logging_lvl") == "DEBUG" - assert config.get("var1") == "1" - assert config.get("var2") is None - assert config.get("var3") is None - - number = 5 - config_file.write_text(create_content(number=number)) - - config = await config_receiver.receive() - assert config is not None - assert config.get("logging_lvl") == "ERROR" - assert config.get("var1") == "0" - assert config.get("var2") == str(number) - assert config.get("var3") is None - assert config_file.read_text() == create_content(number=number) - - # pylint: disable=protected-access,no-member - await _config_manager._stop() # type: ignore + async with ConfigManagingActor(config_file, config_channel.new_sender()): + config = await config_receiver.receive() + assert config is not None + assert config.get("logging_lvl") == "DEBUG" + assert config.get("var1") == "1" + assert config.get("var2") is None + assert config.get("var3") is None + + number = 5 + config_file.write_text(create_content(number=number)) + + config = await config_receiver.receive() + assert config is not None + assert config.get("logging_lvl") == "ERROR" + assert config.get("var1") == "0" + assert config.get("var2") == str(number) + assert config.get("var3") is None + assert config_file.read_text() == create_content(number=number) diff --git a/tests/actor/test_data_sourcing.py b/tests/actor/test_data_sourcing.py index 5c8815210..4d70fe5cb 100644 --- a/tests/actor/test_data_sourcing.py +++ b/tests/actor/test_data_sourcing.py @@ -5,7 +5,7 @@ Tests for the DataSourcingActor. """ -from frequenz.api.microgrid import microgrid_pb2 +from frequenz.api.common import components_pb2 as components_pb from frequenz.channels import Broadcast from frequenz.sdk.actor import ( @@ -17,6 +17,8 @@ from frequenz.sdk.microgrid.component import ComponentMetricId from tests.microgrid import mock_api +# pylint: disable=no-member + class TestDataSourcingActor: """Tests for the DataSourcingActor.""" @@ -28,19 +30,19 @@ async def test_data_sourcing_actor(self) -> None: await server.start() servicer.add_component( - 1, microgrid_pb2.ComponentCategory.COMPONENT_CATEGORY_GRID + 1, components_pb.ComponentCategory.COMPONENT_CATEGORY_GRID ) servicer.add_component( - 4, microgrid_pb2.ComponentCategory.COMPONENT_CATEGORY_METER + 4, components_pb.ComponentCategory.COMPONENT_CATEGORY_METER ) servicer.add_component( - 7, microgrid_pb2.ComponentCategory.COMPONENT_CATEGORY_METER + 7, components_pb.ComponentCategory.COMPONENT_CATEGORY_METER ) servicer.add_component( - 8, microgrid_pb2.ComponentCategory.COMPONENT_CATEGORY_INVERTER + 8, components_pb.ComponentCategory.COMPONENT_CATEGORY_INVERTER ) servicer.add_component( - 9, microgrid_pb2.ComponentCategory.COMPONENT_CATEGORY_BATTERY + 9, components_pb.ComponentCategory.COMPONENT_CATEGORY_BATTERY ) servicer.add_connection(1, 4) @@ -55,41 +57,41 @@ async def test_data_sourcing_actor(self) -> None: registry = ChannelRegistry(name="test-registry") - DataSourcingActor(req_chan.new_receiver(), registry) - active_power_request = ComponentMetricRequest( - "test-namespace", 4, ComponentMetricId.ACTIVE_POWER, None - ) - active_power_recv = registry.new_receiver( - active_power_request.get_channel_name() - ) - await req_sender.send(active_power_request) - - soc_request = ComponentMetricRequest( - "test-namespace", 9, ComponentMetricId.SOC, None - ) - soc_recv = registry.new_receiver(soc_request.get_channel_name()) - await req_sender.send(soc_request) - - soc2_request = ComponentMetricRequest( - "test-namespace", 9, ComponentMetricId.SOC, None - ) - soc2_recv = registry.new_receiver(soc2_request.get_channel_name()) - await req_sender.send(soc2_request) - - for _ in range(3): - sample = await soc_recv.receive() - assert sample is not None - assert 9.0 == sample.value.base_value - - sample = await soc2_recv.receive() - assert sample is not None - assert 9.0 == sample.value.base_value - - sample = await active_power_recv.receive() - assert sample is not None - assert 100.0 == sample.value.base_value - - assert await server.graceful_shutdown() - connection_manager._CONNECTION_MANAGER = ( # pylint: disable=protected-access - None - ) + async with DataSourcingActor(req_chan.new_receiver(), registry): + active_power_request = ComponentMetricRequest( + "test-namespace", 4, ComponentMetricId.ACTIVE_POWER, None + ) + active_power_recv = registry.new_receiver( + active_power_request.get_channel_name() + ) + await req_sender.send(active_power_request) + + soc_request = ComponentMetricRequest( + "test-namespace", 9, ComponentMetricId.SOC, None + ) + soc_recv = registry.new_receiver(soc_request.get_channel_name()) + await req_sender.send(soc_request) + + soc2_request = ComponentMetricRequest( + "test-namespace", 9, ComponentMetricId.SOC, None + ) + soc2_recv = registry.new_receiver(soc2_request.get_channel_name()) + await req_sender.send(soc2_request) + + for _ in range(3): + sample = await soc_recv.receive() + assert sample is not None + assert 9.0 == sample.value.base_value + + sample = await soc2_recv.receive() + assert sample is not None + assert 9.0 == sample.value.base_value + + sample = await active_power_recv.receive() + assert sample is not None + assert 100.0 == sample.value.base_value + + assert await server.graceful_shutdown() + connection_manager._CONNECTION_MANAGER = ( # pylint: disable=protected-access + None + ) diff --git a/tests/actor/test_decorator.py b/tests/actor/test_decorator.py deleted file mode 100644 index a5e7a8170..000000000 --- a/tests/actor/test_decorator.py +++ /dev/null @@ -1,117 +0,0 @@ -# License: MIT -# Copyright © 2022 Frequenz Energy-as-a-Service GmbH - -"""Simple test for the BaseActor.""" -from frequenz.channels import Broadcast, Receiver, Sender -from frequenz.channels.util import select, selected_from - -from frequenz.sdk.actor import actor, run - - -@actor -class FaultyActor: - """A faulty actor that crashes as soon as it receives a message.""" - - def __init__( - self, - name: str, - recv: Receiver[int], - ) -> None: - """Create an instance of `FaultyActor`. - - Args: - name: Name of the actor. - recv: A channel receiver for int data. - """ - self.name = name - self._recv = recv - - async def run(self) -> None: - """Start the actor and crash upon receiving a message""" - async for msg in self._recv: - _ = msg / 0 - - -@actor -class EchoActor: - """An echo actor that whatever it receives into the output channel.""" - - def __init__( - self, - name: str, - recv1: Receiver[bool], - recv2: Receiver[bool], - output: Sender[bool], - ) -> None: - """Create an `EchoActor` instance. - - Args: - name (str): Name of the actor. - recv1 (Receiver[bool]): A channel receiver for test boolean data. - recv2 (Receiver[bool]): A channel receiver for test boolean data. - """ - self.name = name - - self._recv1 = recv1 - self._recv2 = recv2 - self._output = output - - async def run(self) -> None: - """Do computations depending on the selected input message. - - Args: - output (Sender[OT]): A channel sender, to send actor's results to. - """ - - channel_1 = self._recv1 - channel_2 = self._recv2 - - async for selected in select(channel_1, channel_2): - if selected_from(selected, channel_1): - await self._output.send(selected.value) - elif selected_from(selected, channel_2): - await self._output.send(selected.value) - - -async def test_basic_actor() -> None: - """Initialize the TestActor send a message and wait for the response.""" - - input_chan_1: Broadcast[bool] = Broadcast("TestChannel1") - input_chan_2: Broadcast[bool] = Broadcast("TestChannel2") - - echo_chan: Broadcast[bool] = Broadcast("echo output") - - _echo_actor = EchoActor( - "EchoActor", - input_chan_1.new_receiver(), - input_chan_2.new_receiver(), - echo_chan.new_sender(), - ) - - echo_rx = echo_chan.new_receiver() - - await input_chan_1.new_sender().send(True) - - msg = await echo_rx.receive() - assert msg is True - - await input_chan_2.new_sender().send(False) - - msg = await echo_rx.receive() - assert msg is False - # pylint: disable=protected-access,no-member - await _echo_actor._stop() # type: ignore[attr-defined] - - -async def test_actor_does_not_restart() -> None: - """Create a faulty actor and expect it to crash and stop running""" - - channel: Broadcast[int] = Broadcast("channel") - - _faulty_actor = FaultyActor( - "FaultyActor", - channel.new_receiver(), - ) - - await channel.new_sender().send(1) - await run(_faulty_actor) diff --git a/tests/actor/test_resampling.py b/tests/actor/test_resampling.py index b0daad47c..752ce479b 100644 --- a/tests/actor/test_resampling.py +++ b/tests/actor/test_resampling.py @@ -122,7 +122,7 @@ async def test_single_request( resampling_req_chan = Broadcast[ComponentMetricRequest]("resample-req") resampling_req_sender = resampling_req_chan.new_sender() - resampling_actor = ComponentMetricsResamplingActor( + async with ComponentMetricsResamplingActor( channel_registry=channel_registry, data_sourcing_request_sender=data_source_req_chan.new_sender(), resampling_request_receiver=resampling_req_chan.new_receiver(), @@ -130,31 +130,29 @@ async def test_single_request( resampling_period=timedelta(seconds=0.2), max_data_age_in_periods=2, ), - ) - - subs_req = ComponentMetricRequest( - namespace="Resampling", - component_id=9, - metric_id=ComponentMetricId.SOC, - start_time=None, - ) - - await resampling_req_sender.send(subs_req) - data_source_req = await data_source_req_recv.receive() - assert data_source_req is not None - assert data_source_req == dataclasses.replace( - subs_req, namespace="Resampling:Source" - ) - - await _assert_resampling_works( - channel_registry, - fake_time, - resampling_chan_name=subs_req.get_channel_name(), - data_source_chan_name=data_source_req.get_channel_name(), - ) - - await resampling_actor._stop() # type: ignore # pylint: disable=no-member,protected-access - await resampling_actor._resampler.stop() # pylint: disable=protected-access + ) as resampling_actor: + subs_req = ComponentMetricRequest( + namespace="Resampling", + component_id=9, + metric_id=ComponentMetricId.SOC, + start_time=None, + ) + + await resampling_req_sender.send(subs_req) + data_source_req = await data_source_req_recv.receive() + assert data_source_req is not None + assert data_source_req == dataclasses.replace( + subs_req, namespace="Resampling:Source" + ) + + await _assert_resampling_works( + channel_registry, + fake_time, + resampling_chan_name=subs_req.get_channel_name(), + data_source_chan_name=data_source_req.get_channel_name(), + ) + + await resampling_actor._resampler.stop() # pylint: disable=protected-access async def test_duplicate_request( @@ -168,7 +166,7 @@ async def test_duplicate_request( resampling_req_chan = Broadcast[ComponentMetricRequest]("resample-req") resampling_req_sender = resampling_req_chan.new_sender() - resampling_actor = ComponentMetricsResamplingActor( + async with ComponentMetricsResamplingActor( channel_registry=channel_registry, data_sourcing_request_sender=data_source_req_chan.new_sender(), resampling_request_receiver=resampling_req_chan.new_receiver(), @@ -176,29 +174,27 @@ async def test_duplicate_request( resampling_period=timedelta(seconds=0.2), max_data_age_in_periods=2, ), - ) - - subs_req = ComponentMetricRequest( - namespace="Resampling", - component_id=9, - metric_id=ComponentMetricId.SOC, - start_time=None, - ) - - await resampling_req_sender.send(subs_req) - data_source_req = await data_source_req_recv.receive() - - # Send duplicate request - await resampling_req_sender.send(subs_req) - with pytest.raises(asyncio.TimeoutError): - await asyncio.wait_for(data_source_req_recv.receive(), timeout=0.1) - - await _assert_resampling_works( - channel_registry, - fake_time, - resampling_chan_name=subs_req.get_channel_name(), - data_source_chan_name=data_source_req.get_channel_name(), - ) - - await resampling_actor._stop() # type: ignore # pylint: disable=no-member,protected-access - await resampling_actor._resampler.stop() # pylint: disable=protected-access + ) as resampling_actor: + subs_req = ComponentMetricRequest( + namespace="Resampling", + component_id=9, + metric_id=ComponentMetricId.SOC, + start_time=None, + ) + + await resampling_req_sender.send(subs_req) + data_source_req = await data_source_req_recv.receive() + + # Send duplicate request + await resampling_req_sender.send(subs_req) + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(data_source_req_recv.receive(), timeout=0.1) + + await _assert_resampling_works( + channel_registry, + fake_time, + resampling_chan_name=subs_req.get_channel_name(), + data_source_chan_name=data_source_req.get_channel_name(), + ) + + await resampling_actor._resampler.stop() # pylint: disable=protected-access diff --git a/tests/actor/test_run_utils.py b/tests/actor/test_run_utils.py index 90f40ff6e..3d93e2429 100644 --- a/tests/actor/test_run_utils.py +++ b/tests/actor/test_run_utils.py @@ -11,7 +11,7 @@ import pytest import time_machine -from frequenz.sdk.actor import actor, run +from frequenz.sdk.actor import Actor, run # Setting 'autouse' has no effect as this method replaces the event loop for all tests in the file. @@ -30,8 +30,7 @@ def fake_time() -> Iterator[time_machine.Coordinates]: yield traveller -@actor -class FaultyActor: +class FaultyActor(Actor): """A test faulty actor.""" def __init__(self, name: str) -> None: @@ -40,10 +39,10 @@ def __init__(self, name: str) -> None: Args: name: the name of the faulty actor. """ - self.name = name + super().__init__(name=name) self.is_cancelled = False - async def run(self) -> None: + async def _run(self) -> None: """Run the faulty actor. Raises: @@ -53,8 +52,7 @@ async def run(self) -> None: raise asyncio.CancelledError(f"Faulty Actor {self.name} failed") -@actor -class SleepyActor: +class SleepyActor(Actor): """A test actor that sleeps a short time.""" def __init__(self, name: str, sleep_duration: float) -> None: @@ -64,11 +62,11 @@ def __init__(self, name: str, sleep_duration: float) -> None: name: the name of the sleepy actor. sleep_duration: the virtual duration to sleep while running. """ - self.name = name + super().__init__(name=name) self.sleep_duration = sleep_duration self.is_joined = False - async def run(self) -> None: + async def _run(self) -> None: """Run the sleepy actor.""" while time.time() < self.sleep_duration: await asyncio.sleep(0.1) diff --git a/tests/conftest.py b/tests/conftest.py index d63e269ed..551c30008 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,25 +2,52 @@ # Copyright © 2022 Frequenz Energy-as-a-Service GmbH """Setup for all the tests.""" +import collections.abc +import contextlib + import pytest -from frequenz.sdk.actor import _decorator +from frequenz.sdk.actor import _actor # Used to impose a hard time limit for some async tasks in tests so that tests don't # run forever in case of a bug SAFETY_TIMEOUT = 10.0 -@pytest.fixture(scope="session", autouse=True) -def disable_actor_auto_restart(): # type: ignore - """Disable auto-restart of actors while running tests. +@contextlib.contextmanager +def actor_restart_limit(limit: int) -> collections.abc.Iterator[None]: + """Temporarily set the actor restart limit to a given value. - At some point we had a version that would set the limit back to the - original value but it doesn't work because some actors will keep running - even after the end of the session and fail after the original value was - reestablished, getting into an infinite loop again. + Example: + ```python + with actor_restart_limit(0): # No restart + async with MyActor() as actor: + # Do something with actor + ``` - Note: Test class must derive after unittest.IsolatedAsyncioTestCase. - Otherwise this fixture won't run. + Args: + limit: The new limit. """ - _decorator.BaseActor.restart_limit = 0 + # pylint: disable=protected-access + original_limit = _actor.Actor._restart_limit + print( + f" Changing the restart limit from {original_limit} to {limit}" + ) + _actor.Actor._restart_limit = limit + yield + print(f" Resetting restart limit to {original_limit}") + _actor.Actor._restart_limit = original_limit + + +@pytest.fixture(scope="session", autouse=True) +def disable_actor_auto_restart() -> collections.abc.Iterator[None]: + """Disable auto-restart of actors while running tests.""" + with actor_restart_limit(0): + yield + + +@pytest.fixture +def actor_auto_restart_once() -> collections.abc.Iterator[None]: + """Make actors restart only once.""" + with actor_restart_limit(1): + yield diff --git a/tests/microgrid/mock_api.py b/tests/microgrid/mock_api.py index 6d6dfea04..37d5bb86f 100644 --- a/tests/microgrid/mock_api.py +++ b/tests/microgrid/mock_api.py @@ -17,21 +17,25 @@ from typing import Iterable, Iterator, List, Optional, Tuple import grpc +from frequenz.api.common.components_pb2 import ( + COMPONENT_CATEGORY_BATTERY, + COMPONENT_CATEGORY_EV_CHARGER, + COMPONENT_CATEGORY_INVERTER, + COMPONENT_CATEGORY_METER, + ComponentCategory, + InverterType, +) +from frequenz.api.common.metrics.electrical_pb2 import AC +from frequenz.api.common.metrics_pb2 import Metric, MetricAggregation from frequenz.api.microgrid.battery_pb2 import Battery from frequenz.api.microgrid.battery_pb2 import Data as BatteryData -from frequenz.api.microgrid.common_pb2 import AC, Metric, MetricAggregation -from frequenz.api.microgrid.ev_charger_pb2 import EVCharger +from frequenz.api.microgrid.ev_charger_pb2 import EvCharger from frequenz.api.microgrid.inverter_pb2 import Inverter -from frequenz.api.microgrid.inverter_pb2 import Type as InverterType +from frequenz.api.microgrid.inverter_pb2 import Metadata as InverterMetadata from frequenz.api.microgrid.meter_pb2 import Data as MeterData from frequenz.api.microgrid.meter_pb2 import Meter from frequenz.api.microgrid.microgrid_pb2 import ( - COMPONENT_CATEGORY_BATTERY, - COMPONENT_CATEGORY_EV_CHARGER, - COMPONENT_CATEGORY_INVERTER, - COMPONENT_CATEGORY_METER, Component, - ComponentCategory, ComponentData, ComponentFilter, ComponentIdParam, @@ -39,8 +43,11 @@ Connection, ConnectionFilter, ConnectionList, + MicrogridMetadata, PowerLevelParam, SetBoundsParam, + SetPowerActiveParam, + SetPowerReactiveParam, ) from frequenz.api.microgrid.microgrid_pb2_grpc import ( MicrogridServicer, @@ -76,20 +83,21 @@ def __init__( if connections is not None: self.set_connections(connections) - self._latest_charge: Optional[PowerLevelParam] = None - self._latest_discharge: Optional[PowerLevelParam] = None + self._latest_power: Optional[SetPowerActiveParam] = None def add_component( self, component_id: int, component_category: ComponentCategory.V, - inverter_type: InverterType.V = InverterType.TYPE_UNSPECIFIED, + inverter_type: InverterType.V = InverterType.INVERTER_TYPE_UNSPECIFIED, ) -> None: """Add a component to the mock service.""" if component_category == ComponentCategory.COMPONENT_CATEGORY_INVERTER: self._components.append( Component( - id=component_id, category=component_category, inverter=inverter_type + id=component_id, + category=component_category, + inverter=InverterMetadata(type=inverter_type), ) ) else: @@ -116,14 +124,9 @@ def set_connections(self, connections: List[Tuple[int, int]]) -> None: ) @property - def latest_charge(self) -> Optional[PowerLevelParam]: + def latest_power(self) -> SetPowerActiveParam | None: """Get argumetns of the latest charge request.""" - return self._latest_charge - - @property - def latest_discharge(self) -> Optional[PowerLevelParam]: - """Get arguments of the latest discharge request.""" - return self._latest_discharge + return self._latest_power def get_bounds(self) -> List[SetBoundsParam]: """Return the list of received bounds.""" @@ -159,7 +162,7 @@ def ListConnections( connections = filter(lambda c: c.end in request.ends, connections) return ConnectionList(connections=connections) - def GetComponentData( + def StreamComponentData( self, request: ComponentIdParam, context: grpc.ServicerContext ) -> Iterator[ComponentData]: """Return an iterator for mock ComponentData.""" @@ -194,7 +197,7 @@ def next_msg() -> ComponentData: if component.category == COMPONENT_CATEGORY_INVERTER: return ComponentData(id=request.id, inverter=Inverter()) if component.category == COMPONENT_CATEGORY_EV_CHARGER: - return ComponentData(id=request.id, ev_charger=EVCharger()) + return ComponentData(id=request.id, ev_charger=EvCharger()) return ComponentData() num_messages = 3 @@ -202,32 +205,44 @@ def next_msg() -> ComponentData: msg = next_msg() yield msg - def SetBounds( - self, request_iterator: Iterator[SetBoundsParam], context: grpc.ServicerContext + def SetPowerActive( + self, request: SetPowerActiveParam, context: grpc.ServicerContext ) -> Empty: - """/nitrogen.Nitrogen/SetBounds method stub.""" - for bound in request_iterator: - self._bounds.append(bound) + """/nitrogen.Nitrogen/SetPowerActive method stub.""" + self._latest_power = request return Empty() - def Charge(self, request: PowerLevelParam, context: grpc.ServicerContext) -> Empty: - """/nitrogen.Nitrogen/Charge method stub.""" - self._latest_charge = request - return Empty() - - def Discharge( - self, request: PowerLevelParam, context: grpc.ServicerContext + def SetPowerReactive( + self, request: SetPowerReactiveParam, context: grpc.ServicerContext ) -> Empty: - """/nitrogen.Nitrogen/Discharge method stub.""" - self._latest_discharge = request + """/nitrogen.Nitrogen/SetPowerReactive method stub.""" return Empty() + def GetMicrogridMetadata( + self, request: Empty, context: grpc.ServicerContext + ) -> MicrogridMetadata: + """/nitrogen.Nitrogen/GetMicrogridMetadata method stub.""" + return MicrogridMetadata() + def CanStreamData( self, request: ComponentIdParam, context: grpc.ServicerContext ) -> BoolValue: """/nitrogen.Nitrogen/CanStreamData method stub.""" return BoolValue(value=True) + def AddExclusionBounds( + self, request: SetBoundsParam, context: grpc.ServicerContext + ) -> Timestamp: + """/nitrogen.Nitrogen/AddExclusionBounds method stub.""" + return Timestamp() + + def AddInclusionBounds( + self, request: SetBoundsParam, context: grpc.ServicerContext + ) -> Timestamp: + """/nitrogen.Nitrogen/AddExclusionBounds method stub.""" + self._bounds.append(request) + return Timestamp() + def HotStandby( self, request: ComponentIdParam, context: grpc.ServicerContext ) -> Empty: diff --git a/tests/microgrid/test_client.py b/tests/microgrid/test_client.py index 223f4f947..853d183ad 100644 --- a/tests/microgrid/test_client.py +++ b/tests/microgrid/test_client.py @@ -9,7 +9,8 @@ import grpc import pytest -from frequenz.api.microgrid import common_pb2 as common_pb +from frequenz.api.common import components_pb2 as components_pb +from frequenz.api.common import metrics_pb2 as metrics_pb from frequenz.api.microgrid import microgrid_pb2 as microgrid_pb from google.protobuf.empty_pb2 import Empty # pylint: disable=no-name-in-module @@ -51,14 +52,14 @@ async def test_components(self) -> None: assert set(await microgrid.components()) == set() servicer.add_component( - 0, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_METER + 0, components_pb.ComponentCategory.COMPONENT_CATEGORY_METER ) assert set(await microgrid.components()) == { Component(0, ComponentCategory.METER) } servicer.add_component( - 0, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_BATTERY + 0, components_pb.ComponentCategory.COMPONENT_CATEGORY_BATTERY ) assert set(await microgrid.components()) == { Component(0, ComponentCategory.METER), @@ -66,7 +67,7 @@ async def test_components(self) -> None: } servicer.add_component( - 0, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_METER + 0, components_pb.ComponentCategory.COMPONENT_CATEGORY_METER ) assert set(await microgrid.components()) == { Component(0, ComponentCategory.METER), @@ -76,7 +77,7 @@ async def test_components(self) -> None: # sensors are not counted as components by the API client servicer.add_component( - 1, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_SENSOR + 1, components_pb.ComponentCategory.COMPONENT_CATEGORY_SENSOR ) assert set(await microgrid.components()) == { Component(0, ComponentCategory.METER), @@ -86,10 +87,10 @@ async def test_components(self) -> None: servicer.set_components( [ - (9, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_METER), - (99, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_INVERTER), - (666, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_SENSOR), - (999, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_BATTERY), + (9, components_pb.ComponentCategory.COMPONENT_CATEGORY_METER), + (99, components_pb.ComponentCategory.COMPONENT_CATEGORY_INVERTER), + (666, components_pb.ComponentCategory.COMPONENT_CATEGORY_SENSOR), + (999, components_pb.ComponentCategory.COMPONENT_CATEGORY_BATTERY), ] ) assert set(await microgrid.components()) == { @@ -100,17 +101,20 @@ async def test_components(self) -> None: servicer.set_components( [ - (99, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_SENSOR), + (99, components_pb.ComponentCategory.COMPONENT_CATEGORY_SENSOR), ( 100, - microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_UNSPECIFIED, + components_pb.ComponentCategory.COMPONENT_CATEGORY_UNSPECIFIED, ), - (101, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_GRID), - (104, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_METER), - (105, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_INVERTER), - (106, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_BATTERY), - (107, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_EV_CHARGER), - (999, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_SENSOR), + (101, components_pb.ComponentCategory.COMPONENT_CATEGORY_GRID), + (104, components_pb.ComponentCategory.COMPONENT_CATEGORY_METER), + (105, components_pb.ComponentCategory.COMPONENT_CATEGORY_INVERTER), + (106, components_pb.ComponentCategory.COMPONENT_CATEGORY_BATTERY), + ( + 107, + components_pb.ComponentCategory.COMPONENT_CATEGORY_EV_CHARGER, + ), + (999, components_pb.ComponentCategory.COMPONENT_CATEGORY_SENSOR), ] ) assert set(await microgrid.components()) == { @@ -122,6 +126,25 @@ async def test_components(self) -> None: Component(107, ComponentCategory.EV_CHARGER), } + servicer.set_components( + [ + (9, components_pb.ComponentCategory.COMPONENT_CATEGORY_METER), + (666, components_pb.ComponentCategory.COMPONENT_CATEGORY_SENSOR), + (999, components_pb.ComponentCategory.COMPONENT_CATEGORY_BATTERY), + ] + ) + servicer.add_component( + 99, + components_pb.ComponentCategory.COMPONENT_CATEGORY_INVERTER, + components_pb.InverterType.INVERTER_TYPE_BATTERY, + ) + + assert set(await microgrid.components()) == { + Component(9, ComponentCategory.METER), + Component(99, ComponentCategory.INVERTER, InverterType.BATTERY), + Component(999, ComponentCategory.BATTERY), + } + finally: assert await server.graceful_shutdown() @@ -141,11 +164,11 @@ async def test_connections(self) -> None: servicer.add_connection(7, 9) servicer.add_component( 7, - component_category=microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_BATTERY, + component_category=components_pb.ComponentCategory.COMPONENT_CATEGORY_BATTERY, ) servicer.add_component( 9, - component_category=microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_INVERTER, + component_category=components_pb.ComponentCategory.COMPONENT_CATEGORY_INVERTER, ) assert set(await microgrid.connections()) == { Connection(0, 0), @@ -163,7 +186,7 @@ async def test_connections(self) -> None: for component_id in [999, 99, 19, 909, 101, 91]: servicer.add_component( component_id, - microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_BATTERY, + components_pb.ComponentCategory.COMPONENT_CATEGORY_BATTERY, ) assert set(await microgrid.connections()) == { @@ -176,7 +199,7 @@ async def test_connections(self) -> None: for component_id in [1, 2, 3, 4, 5, 6, 7, 8]: servicer.add_component( component_id, - microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_BATTERY, + components_pb.ComponentCategory.COMPONENT_CATEGORY_BATTERY, ) servicer.set_connections( @@ -308,7 +331,7 @@ def ListAllComponents( for component_id in [1, 2, 3, 4, 5, 6, 7, 8, 9]: servicer.add_component( component_id, - microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_BATTERY, + components_pb.ComponentCategory.COMPONENT_CATEGORY_BATTERY, ) servicer.set_connections( [ @@ -359,10 +382,10 @@ async def test_meter_data(self) -> None: microgrid = self.create_client(57899) servicer.add_component( - 83, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_METER + 83, components_pb.ComponentCategory.COMPONENT_CATEGORY_METER ) servicer.add_component( - 38, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_BATTERY + 38, components_pb.ComponentCategory.COMPONENT_CATEGORY_BATTERY ) with pytest.raises(ValueError): @@ -391,10 +414,10 @@ async def test_battery_data(self) -> None: microgrid = self.create_client(57899) servicer.add_component( - 83, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_BATTERY + 83, components_pb.ComponentCategory.COMPONENT_CATEGORY_BATTERY ) servicer.add_component( - 38, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_INVERTER + 38, components_pb.ComponentCategory.COMPONENT_CATEGORY_INVERTER ) with pytest.raises(ValueError): @@ -423,10 +446,10 @@ async def test_inverter_data(self) -> None: microgrid = self.create_client(57899) servicer.add_component( - 83, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_INVERTER + 83, components_pb.ComponentCategory.COMPONENT_CATEGORY_INVERTER ) servicer.add_component( - 38, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_BATTERY + 38, components_pb.ComponentCategory.COMPONENT_CATEGORY_BATTERY ) with pytest.raises(ValueError): @@ -455,10 +478,10 @@ async def test_ev_charger_data(self) -> None: microgrid = self.create_client(57899) servicer.add_component( - 83, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_EV_CHARGER + 83, components_pb.ComponentCategory.COMPONENT_CATEGORY_EV_CHARGER ) servicer.add_component( - 38, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_BATTERY + 38, components_pb.ComponentCategory.COMPONENT_CATEGORY_BATTERY ) with pytest.raises(ValueError): @@ -489,14 +512,14 @@ async def test_charge(self) -> None: microgrid = self.create_client(57899) servicer.add_component( - 83, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_METER + 83, components_pb.ComponentCategory.COMPONENT_CATEGORY_METER ) await microgrid.set_power(component_id=83, power_w=12) - assert servicer.latest_charge is not None - assert servicer.latest_charge.component_id == 83 - assert servicer.latest_charge.power_w == 12 + assert servicer.latest_power is not None + assert servicer.latest_power.component_id == 83 + assert servicer.latest_power.power == 12 finally: assert await server.graceful_shutdown() @@ -512,14 +535,14 @@ async def test_discharge(self) -> None: microgrid = self.create_client(57899) servicer.add_component( - 73, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_METER + 73, components_pb.ComponentCategory.COMPONENT_CATEGORY_METER ) await microgrid.set_power(component_id=73, power_w=-15) - assert servicer.latest_discharge is not None - assert servicer.latest_discharge.component_id == 73 - assert servicer.latest_discharge.power_w == 15 + assert servicer.latest_power is not None + assert servicer.latest_power.component_id == 73 + assert servicer.latest_power.power == -15 finally: assert await server.graceful_shutdown() @@ -532,7 +555,7 @@ async def test_set_bounds(self) -> None: microgrid = self.create_client(57899) servicer.add_component( - 38, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_INVERTER + 38, components_pb.ComponentCategory.COMPONENT_CATEGORY_INVERTER ) num_calls = 4 @@ -542,7 +565,7 @@ async def test_set_bounds(self) -> None: microgrid_pb.SetBoundsParam( component_id=comp_id, target_metric=target_metric.TARGET_METRIC_POWER_ACTIVE, - bounds=common_pb.Bounds(lower=-10, upper=2), + bounds=metrics_pb.Bounds(lower=-10, upper=2), ) for comp_id in range(num_calls) ] diff --git a/tests/microgrid/test_component.py b/tests/microgrid/test_component.py index c7f3f9243..3d8ff2773 100644 --- a/tests/microgrid/test_component.py +++ b/tests/microgrid/test_component.py @@ -5,53 +5,55 @@ Tests for the microgrid component wrapper. """ -import frequenz.api.microgrid.microgrid_pb2 as microgrid_pb +import frequenz.api.common.components_pb2 as components_pb import pytest import frequenz.sdk.microgrid.component._component as cp +# pylint:disable=no-member + # pylint: disable=protected-access def test_component_category_from_protobuf() -> None: """Test the creating component category from protobuf.""" assert ( cp._component_category_from_protobuf( - microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_UNSPECIFIED + components_pb.ComponentCategory.COMPONENT_CATEGORY_UNSPECIFIED ) == cp.ComponentCategory.NONE ) assert ( cp._component_category_from_protobuf( - microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_GRID + components_pb.ComponentCategory.COMPONENT_CATEGORY_GRID ) == cp.ComponentCategory.GRID ) assert ( cp._component_category_from_protobuf( - microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_METER + components_pb.ComponentCategory.COMPONENT_CATEGORY_METER ) == cp.ComponentCategory.METER ) assert ( cp._component_category_from_protobuf( - microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_INVERTER + components_pb.ComponentCategory.COMPONENT_CATEGORY_INVERTER ) == cp.ComponentCategory.INVERTER ) assert ( cp._component_category_from_protobuf( - microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_BATTERY + components_pb.ComponentCategory.COMPONENT_CATEGORY_BATTERY ) == cp.ComponentCategory.BATTERY ) assert ( cp._component_category_from_protobuf( - microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_EV_CHARGER + components_pb.ComponentCategory.COMPONENT_CATEGORY_EV_CHARGER ) == cp.ComponentCategory.EV_CHARGER ) @@ -60,7 +62,7 @@ def test_component_category_from_protobuf() -> None: with pytest.raises(ValueError): cp._component_category_from_protobuf( - microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_SENSOR + components_pb.ComponentCategory.COMPONENT_CATEGORY_SENSOR ) diff --git a/tests/microgrid/test_graph.py b/tests/microgrid/test_graph.py index b5dae8e74..a58bdb769 100644 --- a/tests/microgrid/test_graph.py +++ b/tests/microgrid/test_graph.py @@ -7,12 +7,12 @@ # pylint: disable=too-many-lines,use-implicit-booleaness-not-comparison # pylint: disable=invalid-name,missing-function-docstring,too-many-statements -# pylint: disable=too-many-lines,protected-access +# pylint: disable=too-many-lines,protected-access,no-member from dataclasses import asdict from typing import Dict, Set -import frequenz.api.microgrid.microgrid_pb2 as microgrid_pb +import frequenz.api.common.components_pb2 as components_pb import grpc import pytest @@ -403,6 +403,116 @@ def test_connection_filters(self) -> None: Connection(2, 6), } + def test_dfs_search_two_grid_meters(self) -> None: + """Test DFS searching PV components in a graph with two grid meters.""" + grid = Component(1, ComponentCategory.GRID) + pv_inverters = { + Component(4, ComponentCategory.INVERTER, InverterType.SOLAR), + Component(5, ComponentCategory.INVERTER, InverterType.SOLAR), + } + + graph = gr._MicrogridComponentGraph( + components={ + grid, + Component(2, ComponentCategory.METER), + Component(3, ComponentCategory.METER), + }.union(pv_inverters), + connections={ + Connection(1, 2), + Connection(1, 3), + Connection(2, 4), + Connection(2, 5), + }, + ) + + result = graph.dfs(grid, set(), graph.is_pv_inverter) + assert result == pv_inverters + + def test_dfs_search_grid_meter(self) -> None: + """Test DFS searching PV components in a graph with a single grid meter.""" + grid = Component(1, ComponentCategory.GRID) + pv_meters = { + Component(3, ComponentCategory.METER), + Component(4, ComponentCategory.METER), + } + + graph = gr._MicrogridComponentGraph( + components={ + grid, + Component(2, ComponentCategory.METER), + Component(5, ComponentCategory.INVERTER, InverterType.SOLAR), + Component(6, ComponentCategory.INVERTER, InverterType.SOLAR), + }.union(pv_meters), + connections={ + Connection(1, 2), + Connection(2, 3), + Connection(2, 4), + Connection(3, 5), + Connection(4, 6), + }, + ) + + result = graph.dfs(grid, set(), graph.is_pv_chain) + assert result == pv_meters + + def test_dfs_search_no_grid_meter(self) -> None: + """Test DFS searching PV components in a graph with no grid meter.""" + grid = Component(1, ComponentCategory.GRID) + pv_meters = { + Component(3, ComponentCategory.METER), + Component(4, ComponentCategory.METER), + } + + graph = gr._MicrogridComponentGraph( + components={ + grid, + Component(2, ComponentCategory.METER), + Component(5, ComponentCategory.INVERTER, InverterType.SOLAR), + Component(6, ComponentCategory.INVERTER, InverterType.SOLAR), + }.union(pv_meters), + connections={ + Connection(1, 2), + Connection(1, 3), + Connection(1, 4), + Connection(3, 5), + Connection(4, 6), + }, + ) + + result = graph.dfs(grid, set(), graph.is_pv_chain) + assert result == pv_meters + + def test_dfs_search_nested_components(self) -> None: + """Test DFS searching PV components in a graph with nested components.""" + grid = Component(1, ComponentCategory.GRID) + battery_components = { + Component(4, ComponentCategory.METER), + Component(5, ComponentCategory.METER), + Component(6, ComponentCategory.INVERTER, InverterType.BATTERY), + } + + graph = gr._MicrogridComponentGraph( + components={ + grid, + Component(2, ComponentCategory.METER), + Component(3, ComponentCategory.METER), + Component(7, ComponentCategory.INVERTER, InverterType.BATTERY), + Component(8, ComponentCategory.INVERTER, InverterType.BATTERY), + }.union(battery_components), + connections={ + Connection(1, 2), + Connection(2, 3), + Connection(2, 6), + Connection(3, 4), + Connection(3, 5), + Connection(4, 7), + Connection(5, 8), + }, + ) + + assert set() == graph.dfs(grid, set(), graph.is_pv_chain) + assert battery_components == graph.dfs(grid, set(), graph.is_battery_chain) + class Test_MicrogridComponentGraph: """Test cases for the package-internal implementation of the ComponentGraph. @@ -674,7 +784,7 @@ async def test_refresh_from_api(self) -> None: graph.validate() servicer.set_components( - [(1, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_GRID)] + [(1, components_pb.ComponentCategory.COMPONENT_CATEGORY_GRID)] ) servicer.set_connections([]) with pytest.raises(gr.InvalidGraphError): @@ -698,9 +808,9 @@ async def test_refresh_from_api(self) -> None: # valid graph with meter, and EV charger servicer.set_components( [ - (101, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_GRID), - (111, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_METER), - (131, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_EV_CHARGER), + (101, components_pb.ComponentCategory.COMPONENT_CATEGORY_GRID), + (111, components_pb.ComponentCategory.COMPONENT_CATEGORY_METER), + (131, components_pb.ComponentCategory.COMPONENT_CATEGORY_EV_CHARGER), ] ) servicer.set_connections([(101, 111), (111, 131)]) @@ -723,11 +833,11 @@ async def test_refresh_from_api(self) -> None: # contents will be overwritten servicer.set_components( [ - (707, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_GRID), - (717, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_METER), - (727, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_INVERTER), - (737, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_BATTERY), - (747, microgrid_pb.ComponentCategory.COMPONENT_CATEGORY_METER), + (707, components_pb.ComponentCategory.COMPONENT_CATEGORY_GRID), + (717, components_pb.ComponentCategory.COMPONENT_CATEGORY_METER), + (727, components_pb.ComponentCategory.COMPONENT_CATEGORY_INVERTER), + (737, components_pb.ComponentCategory.COMPONENT_CATEGORY_BATTERY), + (747, components_pb.ComponentCategory.COMPONENT_CATEGORY_METER), ] ) servicer.set_connections([(707, 717), (717, 727), (727, 737), (717, 747)]) diff --git a/tests/microgrid/test_mock_api.py b/tests/microgrid/test_mock_api.py index 7f2b25945..9d1ffa18c 100644 --- a/tests/microgrid/test_mock_api.py +++ b/tests/microgrid/test_mock_api.py @@ -11,9 +11,9 @@ from unittest.mock import Mock import grpc +from frequenz.api.common.components_pb2 import ComponentCategory from frequenz.api.microgrid.microgrid_pb2 import ( Component, - ComponentCategory, ComponentFilter, Connection, ConnectionFilter, diff --git a/tests/microgrid/test_timeout.py b/tests/microgrid/test_timeout.py index d6ab56b12..21edddf99 100644 --- a/tests/microgrid/test_timeout.py +++ b/tests/microgrid/test_timeout.py @@ -8,6 +8,8 @@ import grpc import pytest + +# pylint: disable=no-name-in-module from frequenz.api.microgrid.microgrid_pb2 import ( ComponentFilter, ComponentList, @@ -15,7 +17,9 @@ ConnectionList, PowerLevelParam, ) -from google.protobuf.empty_pb2 import Empty # pylint: disable=no-name-in-module +from google.protobuf.empty_pb2 import Empty + +# pylint: enable=no-name-in-module from pytest_mock import MockerFixture from frequenz.sdk.microgrid.client import MicrogridGrpcClient @@ -98,8 +102,7 @@ def mock_set_power( time.sleep(GRPC_SERVER_DELAY) return Empty() - mocker.patch.object(servicer, "Charge", mock_set_power) - mocker.patch.object(servicer, "Discharge", mock_set_power) + mocker.patch.object(servicer, "SetPowerActive", mock_set_power) server = MockGrpcServer(servicer, port=57809) await server.start() diff --git a/tests/timeseries/_battery_pool/test_battery_pool.py b/tests/timeseries/_battery_pool/test_battery_pool.py index 81368e83b..34923e855 100644 --- a/tests/timeseries/_battery_pool/test_battery_pool.py +++ b/tests/timeseries/_battery_pool/test_battery_pool.py @@ -2,6 +2,9 @@ # Copyright © 2023 Frequenz Energy-as-a-Service GmbH """Tests for battery pool.""" + +# pylint: disable=too-many-lines + from __future__ import annotations import asyncio @@ -25,8 +28,8 @@ from frequenz.sdk.actor import ResamplerConfig from frequenz.sdk.actor.power_distributing import BatteryStatus from frequenz.sdk.microgrid.component import ComponentCategory -from frequenz.sdk.timeseries import Energy, Percentage, Power, Sample -from frequenz.sdk.timeseries.battery_pool import BatteryPool, Bound, PowerMetrics +from frequenz.sdk.timeseries import Energy, Percentage, Power, Sample, Temperature +from frequenz.sdk.timeseries.battery_pool import BatteryPool, Bounds, PowerMetrics from frequenz.sdk.timeseries.battery_pool._metric_calculator import ( battery_inverter_mapping, ) @@ -45,6 +48,8 @@ # pylint doesn't understand fixtures. It thinks it is redefined name. # pylint: disable=redefined-outer-name +# pylint: disable=too-many-lines + @pytest.fixture() def event_loop() -> Iterator[async_solipsism.EventLoop]: @@ -131,7 +136,7 @@ async def setup_all_batteries(mocker: MockerFixture) -> AsyncIterator[SetupArgs] min_update_interval: float = 0.2 # pylint: disable=protected-access microgrid._data_pipeline._DATA_PIPELINE = None - microgrid._data_pipeline.initialize( + await microgrid._data_pipeline.initialize( ResamplerConfig(resampling_period=timedelta(seconds=min_update_interval)) ) streamer = MockComponentDataStreamer(mock_microgrid) @@ -183,7 +188,7 @@ async def setup_batteries_pool(mocker: MockerFixture) -> AsyncIterator[SetupArgs min_update_interval: float = 0.2 # pylint: disable=protected-access microgrid._data_pipeline._DATA_PIPELINE = None - microgrid._data_pipeline.initialize( + await microgrid._data_pipeline.initialize( ResamplerConfig(resampling_period=timedelta(seconds=min_update_interval)) ) @@ -337,6 +342,24 @@ async def test_battery_pool_power_bounds(setup_batteries_pool: SetupArgs) -> Non await run_power_bounds_test(setup_batteries_pool) +async def test_all_batteries_temperature(setup_all_batteries: SetupArgs) -> None: + """Test temperature for battery pool with all components in the microgrid. + + Args: + setup_all_batteries: Fixture that creates needed microgrid tools. + """ + await run_temperature_test(setup_all_batteries) + + +async def test_battery_pool_temperature(setup_batteries_pool: SetupArgs) -> None: + """Test temperature for battery pool with subset of components in the microgrid. + + Args: + setup_all_batteries: Fixture that creates needed microgrid tools. + """ + await run_temperature_test(setup_batteries_pool) + + def assert_dataclass(arg: Any) -> None: """Raise assert error if argument is not dataclass. @@ -438,7 +461,7 @@ async def run_test_battery_status_channel( # pylint: disable=too-many-arguments async def test_battery_pool_power(mocker: MockerFixture) -> None: """Test `BatteryPool.{,production,consumption}_power` methods.""" - mockgrid = MockMicrogrid(grid_side_meter=True) + mockgrid = MockMicrogrid(grid_meter=True) mockgrid.add_batteries(2) await mockgrid.start(mocker) @@ -808,8 +831,10 @@ async def run_power_bounds_test( # pylint: disable=too-many-locals BatteryDataWrapper( component_id=battery_id, timestamp=datetime.now(tz=timezone.utc), - power_lower_bound=-1000, - power_upper_bound=5000, + power_inclusion_lower_bound=-1000, + power_inclusion_upper_bound=5000, + power_exclusion_lower_bound=-300, + power_exclusion_upper_bound=300, ), sampling_rate=0.05, ) @@ -817,8 +842,10 @@ async def run_power_bounds_test( # pylint: disable=too-many-locals InverterDataWrapper( component_id=inverter_id, timestamp=datetime.now(tz=timezone.utc), - active_power_lower_bound=-900, - active_power_upper_bound=6000, + active_power_inclusion_lower_bound=-900, + active_power_inclusion_upper_bound=6000, + active_power_exclusion_lower_bound=-200, + active_power_exclusion_upper_bound=200, ), sampling_rate=0.1, ) @@ -832,8 +859,8 @@ async def run_power_bounds_test( # pylint: disable=too-many-locals now = datetime.now(tz=timezone.utc) expected = PowerMetrics( timestamp=now, - supply_bound=Bound(-1800, 0), - consume_bound=Bound(0, 10000), + inclusion_bounds=Bounds(Power.from_watts(-1800), Power.from_watts(10000)), + exclusion_bounds=Bounds(Power.from_watts(-600), Power.from_watts(600)), ) compare_messages(msg, expected, WAIT_FOR_COMPONENT_DATA_SEC + 0.2) @@ -841,25 +868,53 @@ async def run_power_bounds_test( # pylint: disable=too-many-locals scenarios: list[Scenario[PowerMetrics]] = [ Scenario( bat_inv_map[batteries_in_pool[0]], - {"active_power_lower_bound": -100}, - PowerMetrics(now, Bound(-1000, 0), Bound(0, 10000)), + { + "active_power_inclusion_lower_bound": -100, + "active_power_exclusion_lower_bound": -400, + }, + PowerMetrics( + now, + Bounds(Power.from_watts(-1000), Power.from_watts(10000)), + Bounds(Power.from_watts(-700), Power.from_watts(600)), + ), ), # Inverter bound changed, but metric result should not change. Scenario( component_id=bat_inv_map[batteries_in_pool[0]], - new_metrics={"active_power_upper_bound": 9000}, + new_metrics={ + "active_power_inclusion_upper_bound": 9000, + "active_power_exclusion_upper_bound": 250, + }, expected_result=None, wait_for_result=False, ), Scenario( batteries_in_pool[0], - {"power_lower_bound": 0, "power_upper_bound": 4000}, - PowerMetrics(now, Bound(-900, 0), Bound(0, 9000)), + { + "power_inclusion_lower_bound": 0, + "power_inclusion_upper_bound": 4000, + "power_exclusion_lower_bound": 0, + "power_exclusion_upper_bound": 100, + }, + PowerMetrics( + now, + Bounds(Power.from_watts(-900), Power.from_watts(9000)), + Bounds(Power.from_watts(-700), Power.from_watts(550)), + ), ), Scenario( batteries_in_pool[1], - {"power_lower_bound": -10, "power_upper_bound": 200}, - PowerMetrics(now, Bound(-10, 0), Bound(0, 4200)), + { + "power_inclusion_lower_bound": -10, + "power_inclusion_upper_bound": 200, + "power_exclusion_lower_bound": -5, + "power_exclusion_upper_bound": 5, + }, + PowerMetrics( + now, + Bounds(Power.from_watts(-10), Power.from_watts(4200)), + Bounds(Power.from_watts(-600), Power.from_watts(450)), + ), ), # Test 2 things: # 1. Battery is sending upper bounds=NaN, use only inverter upper bounds @@ -867,78 +922,138 @@ async def run_power_bounds_test( # pylint: disable=too-many-locals # Setting upper bound to NaN should not influence lower bound Scenario( batteries_in_pool[0], - {"power_lower_bound": -50, "power_upper_bound": math.nan}, - PowerMetrics(now, Bound(-60, 0), Bound(0, 9200)), + { + "power_inclusion_lower_bound": -50, + "power_inclusion_upper_bound": math.nan, + "power_exclusion_lower_bound": -30, + "power_exclusion_upper_bound": 300, + }, + PowerMetrics( + now, + Bounds(Power.from_watts(-60), Power.from_watts(9200)), + Bounds(Power.from_watts(-600), Power.from_watts(500)), + ), ), Scenario( bat_inv_map[batteries_in_pool[0]], { - "active_power_lower_bound": math.nan, - "active_power_upper_bound": math.nan, + "active_power_inclusion_lower_bound": math.nan, + "active_power_inclusion_upper_bound": math.nan, + "active_power_exclusion_lower_bound": math.nan, + "active_power_exclusion_upper_bound": math.nan, }, - PowerMetrics(now, Bound(-60, 0), Bound(0, 200)), + PowerMetrics( + now, + Bounds(Power.from_watts(-60), Power.from_watts(200)), + Bounds(Power.from_watts(-230), Power.from_watts(500)), + ), ), Scenario( batteries_in_pool[0], - {"power_lower_bound": math.nan}, - PowerMetrics(now, Bound(-10, 0), Bound(0, 200)), + { + "power_inclusion_lower_bound": math.nan, + "power_exclusion_lower_bound": math.nan, + }, + PowerMetrics( + now, + Bounds(Power.from_watts(-10), Power.from_watts(200)), + Bounds(Power.from_watts(-200), Power.from_watts(500)), + ), ), Scenario( batteries_in_pool[1], { - "power_lower_bound": -100, - "power_upper_bound": math.nan, + "power_inclusion_lower_bound": -100, + "power_inclusion_upper_bound": math.nan, + "power_exclusion_lower_bound": -50, + "power_exclusion_upper_bound": 50, }, - PowerMetrics(now, Bound(-100, 0), Bound(0, 6000)), + PowerMetrics( + now, + Bounds(Power.from_watts(-100), Power.from_watts(6000)), + Bounds(Power.from_watts(-200), Power.from_watts(500)), + ), ), Scenario( bat_inv_map[batteries_in_pool[1]], { - "active_power_lower_bound": math.nan, - "active_power_upper_bound": math.nan, + "active_power_inclusion_lower_bound": math.nan, + "active_power_inclusion_upper_bound": math.nan, + "active_power_exclusion_lower_bound": math.nan, + "active_power_exclusion_upper_bound": math.nan, }, - PowerMetrics(now, Bound(-100, 0), Bound(0, 0)), + PowerMetrics( + now, + Bounds(Power.from_watts(-100), Power.zero()), + Bounds(Power.from_watts(-50), Power.from_watts(350)), + ), ), # All components are sending NaN, can't calculate bounds Scenario( batteries_in_pool[1], { - "power_lower_bound": math.nan, - "power_upper_bound": math.nan, + "power_inclusion_lower_bound": math.nan, + "power_inclusion_upper_bound": math.nan, }, None, ), Scenario( batteries_in_pool[0], - {"power_lower_bound": -100, "power_upper_bound": 100}, - PowerMetrics(now, Bound(-100, 0), Bound(0, 100)), + { + "power_inclusion_lower_bound": -100, + "power_inclusion_upper_bound": 100, + "power_exclusion_lower_bound": -20, + "power_exclusion_upper_bound": 20, + }, + PowerMetrics( + now, + Bounds(Power.from_watts(-100), Power.from_watts(100)), + Bounds(Power.from_watts(-70), Power.from_watts(70)), + ), ), Scenario( bat_inv_map[batteries_in_pool[1]], { - "active_power_lower_bound": -400, - "active_power_upper_bound": 400, + "active_power_inclusion_lower_bound": -400, + "active_power_inclusion_upper_bound": 400, + "active_power_exclusion_lower_bound": -100, + "active_power_exclusion_upper_bound": 100, }, - PowerMetrics(now, Bound(-500, 0), Bound(0, 500)), + PowerMetrics( + now, + Bounds(Power.from_watts(-500), Power.from_watts(500)), + Bounds(Power.from_watts(-120), Power.from_watts(120)), + ), ), Scenario( batteries_in_pool[1], { - "power_lower_bound": -300, - "power_upper_bound": 700, + "power_inclusion_lower_bound": -300, + "power_inclusion_upper_bound": 700, + "power_exclusion_lower_bound": -130, + "power_exclusion_upper_bound": 130, }, - PowerMetrics(now, Bound(-400, 0), Bound(0, 500)), + PowerMetrics( + now, + Bounds(Power.from_watts(-400), Power.from_watts(500)), + Bounds(Power.from_watts(-150), Power.from_watts(150)), + ), ), Scenario( bat_inv_map[batteries_in_pool[0]], { - "active_power_lower_bound": -200, - "active_power_upper_bound": 50, + "active_power_inclusion_lower_bound": -200, + "active_power_inclusion_upper_bound": 50, + "active_power_exclusion_lower_bound": -80, + "active_power_exclusion_upper_bound": 80, }, - PowerMetrics(now, Bound(-400, 0), Bound(0, 450)), + PowerMetrics( + now, + Bounds(Power.from_watts(-400), Power.from_watts(450)), + Bounds(Power.from_watts(-210), Power.from_watts(210)), + ), ), ] - waiting_time_sec = setup_args.min_update_interval + 0.02 await run_scenarios(scenarios, streamer, receiver, waiting_time_sec) @@ -948,27 +1063,59 @@ async def run_power_bounds_test( # pylint: disable=too-many-locals all_batteries=all_batteries, batteries_in_pool=batteries_in_pool, waiting_time_sec=waiting_time_sec, - all_pool_result=PowerMetrics(now, Bound(-400, 0), Bound(0, 450)), - only_first_battery_result=PowerMetrics(now, Bound(-100, 0), Bound(0, 50)), + all_pool_result=PowerMetrics( + now, + Bounds(Power.from_watts(-400), Power.from_watts(450)), + Bounds(Power.from_watts(-210), Power.from_watts(210)), + ), + only_first_battery_result=PowerMetrics( + now, + Bounds(Power.from_watts(-100), Power.from_watts(50)), + Bounds(Power.from_watts(-80), Power.from_watts(80)), + ), ) # One battery stopped sending data, inverter data should be used. await streamer.stop_streaming(batteries_in_pool[1]) await asyncio.sleep(MAX_BATTERY_DATA_AGE_SEC + 0.2) msg = await asyncio.wait_for(receiver.receive(), timeout=waiting_time_sec) - compare_messages(msg, PowerMetrics(now, Bound(-500, 0), Bound(0, 450)), 0.2) + compare_messages( + msg, + PowerMetrics( + now, + Bounds(Power.from_watts(-500), Power.from_watts(450)), + Bounds(Power.from_watts(-180), Power.from_watts(180)), + ), + 0.2, + ) # All batteries stopped sending data, use inverters only. await streamer.stop_streaming(batteries_in_pool[0]) await asyncio.sleep(MAX_BATTERY_DATA_AGE_SEC + 0.2) msg = await asyncio.wait_for(receiver.receive(), timeout=waiting_time_sec) - compare_messages(msg, PowerMetrics(now, Bound(-600, 0), Bound(0, 450)), 0.2) + compare_messages( + msg, + PowerMetrics( + now, + Bounds(Power.from_watts(-600), Power.from_watts(450)), + Bounds(Power.from_watts(-180), Power.from_watts(180)), + ), + 0.2, + ) # One inverter stopped sending data, use one remaining inverter await streamer.stop_streaming(bat_inv_map[batteries_in_pool[0]]) await asyncio.sleep(MAX_BATTERY_DATA_AGE_SEC + 0.2) msg = await asyncio.wait_for(receiver.receive(), timeout=waiting_time_sec) - compare_messages(msg, PowerMetrics(now, Bound(-400, 0), Bound(0, 400)), 0.2) + compare_messages( + msg, + PowerMetrics( + now, + Bounds(Power.from_watts(-400), Power.from_watts(400)), + Bounds(Power.from_watts(-100), Power.from_watts(100)), + ), + 0.2, + ) # All components stopped sending data, we can assume that power bounds are 0 await streamer.stop_streaming(bat_inv_map[batteries_in_pool[1]]) @@ -980,4 +1127,121 @@ async def run_power_bounds_test( # pylint: disable=too-many-locals latest_data = streamer.get_current_component_data(batteries_in_pool[0]) streamer.start_streaming(latest_data, sampling_rate=0.1) msg = await asyncio.wait_for(receiver.receive(), timeout=waiting_time_sec) - compare_messages(msg, PowerMetrics(now, Bound(-100, 0), Bound(0, 100)), 0.2) + compare_messages( + msg, + PowerMetrics( + now, + Bounds(Power.from_watts(-100), Power.from_watts(100)), + Bounds(Power.from_watts(-20), Power.from_watts(20)), + ), + 0.2, + ) + + +async def run_temperature_test( # pylint: disable=too-many-locals + setup_args: SetupArgs, +) -> None: + """Test if temperature metric is working as expected.""" + battery_pool = setup_args.battery_pool + mock_microgrid = setup_args.mock_microgrid + streamer = setup_args.streamer + battery_status_sender = setup_args.battery_status_sender + + all_batteries = get_components(mock_microgrid, ComponentCategory.BATTERY) + await battery_status_sender.send( + BatteryStatus(working=all_batteries, uncertain=set()) + ) + bat_inv_map = battery_inverter_mapping(all_batteries) + + for battery_id, inverter_id in bat_inv_map.items(): + # Sampling rate choose to reflect real application. + streamer.start_streaming( + BatteryDataWrapper( + component_id=battery_id, + timestamp=datetime.now(tz=timezone.utc), + temperature=25.0, + ), + sampling_rate=0.05, + ) + streamer.start_streaming( + InverterDataWrapper( + component_id=inverter_id, + timestamp=datetime.now(tz=timezone.utc), + ), + sampling_rate=0.1, + ) + + receiver = battery_pool.temperature.new_receiver() + + msg = await asyncio.wait_for( + receiver.receive(), timeout=WAIT_FOR_COMPONENT_DATA_SEC + 0.2 + ) + now = datetime.now(tz=timezone.utc) + expected = Sample(now, value=Temperature.from_celsius(25.0)) + compare_messages(msg, expected, WAIT_FOR_COMPONENT_DATA_SEC + 0.2) + + batteries_in_pool = list(battery_pool.battery_ids) + bat_0, bat_1 = batteries_in_pool + scenarios: list[Scenario[Sample[Temperature]]] = [ + Scenario( + bat_0, + {"temperature": 30.0}, + Sample(now, value=Temperature.from_celsius(27.5)), + ), + Scenario( + bat_1, + {"temperature": 20.0}, + Sample(now, value=Temperature.from_celsius(25.0)), + ), + Scenario( + bat_0, + {"temperature": math.nan}, + Sample(now, value=Temperature.from_celsius(20.0)), + ), + Scenario( + bat_1, + {"temperature": math.nan}, + None, + ), + Scenario( + bat_0, + {"temperature": 30.0}, + Sample(now, value=Temperature.from_celsius(30.0)), + ), + Scenario( + bat_1, + {"temperature": 15.0}, + Sample(now, value=Temperature.from_celsius(22.5)), + ), + ] + + waiting_time_sec = setup_args.min_update_interval + 0.02 + await run_scenarios(scenarios, streamer, receiver, waiting_time_sec) + + await run_test_battery_status_channel( + battery_status_sender=battery_status_sender, + battery_pool_metric_receiver=receiver, + all_batteries=all_batteries, + batteries_in_pool=batteries_in_pool, + waiting_time_sec=waiting_time_sec, + all_pool_result=Sample(now, Temperature.from_celsius(22.5)), + only_first_battery_result=Sample(now, Temperature.from_celsius(30.0)), + ) + + # one battery stops sending data. + await streamer.stop_streaming(bat_1) + await asyncio.sleep(MAX_BATTERY_DATA_AGE_SEC + 0.2) + msg = await asyncio.wait_for(receiver.receive(), timeout=waiting_time_sec) + compare_messages(msg, Sample(now, Temperature.from_celsius(30.0)), 0.2) + + # All batteries stopped sending data. + await streamer.stop_streaming(bat_0) + await asyncio.sleep(MAX_BATTERY_DATA_AGE_SEC + 0.2) + msg = await asyncio.wait_for(receiver.receive(), timeout=waiting_time_sec) + assert msg is None + + # one battery started sending data. + latest_data = streamer.get_current_component_data(bat_1) + streamer.start_streaming(latest_data, sampling_rate=0.1) + msg = await asyncio.wait_for(receiver.receive(), timeout=waiting_time_sec) + compare_messages(msg, Sample(now, Temperature.from_celsius(15.0)), 0.2) diff --git a/tests/timeseries/_formula_engine/test_formula_composition.py b/tests/timeseries/_formula_engine/test_formula_composition.py index 48d10b967..0d9fdef9f 100644 --- a/tests/timeseries/_formula_engine/test_formula_composition.py +++ b/tests/timeseries/_formula_engine/test_formula_composition.py @@ -6,6 +6,7 @@ import math +import pytest from pytest_mock import MockerFixture from frequenz.sdk import microgrid @@ -24,16 +25,17 @@ async def test_formula_composition( # pylint: disable=too-many-locals mocker: MockerFixture, ) -> None: """Test the composition of formulas.""" - mockgrid = MockMicrogrid(grid_side_meter=False) + mockgrid = MockMicrogrid(grid_meter=False) + mockgrid.add_consumer_meters() mockgrid.add_batteries(3) mockgrid.add_solar_inverters(2) await mockgrid.start(mocker) logical_meter = microgrid.logical_meter() battery_pool = microgrid.battery_pool() - main_meter_recv = get_resampled_stream( + grid_meter_recv = get_resampled_stream( logical_meter._namespace, # pylint: disable=protected-access - 4, + mockgrid.meter_ids[0], ComponentMetricId.ACTIVE_POWER, Power.from_watts, ) @@ -52,7 +54,7 @@ async def test_formula_composition( # pylint: disable=too-many-locals grid_pow = await grid_power_recv.receive() pv_pow = await pv_power_recv.receive() bat_pow = await battery_power_recv.receive() - main_pow = await main_meter_recv.receive() + main_pow = await grid_meter_recv.receive() inv_calc_pow = await inv_calc_recv.receive() assert ( @@ -97,7 +99,7 @@ async def test_formula_composition( # pylint: disable=too-many-locals async def test_formula_composition_missing_pv(self, mocker: MockerFixture) -> None: """Test the composition of formulas with missing PV power data.""" - mockgrid = MockMicrogrid(grid_side_meter=False) + mockgrid = MockMicrogrid(grid_meter=False) mockgrid.add_batteries(3) await mockgrid.start(mocker) battery_pool = microgrid.battery_pool() @@ -135,7 +137,7 @@ async def test_formula_composition_missing_pv(self, mocker: MockerFixture) -> No async def test_formula_composition_missing_bat(self, mocker: MockerFixture) -> None: """Test the composition of formulas with missing battery power data.""" - mockgrid = MockMicrogrid(grid_side_meter=False) + mockgrid = MockMicrogrid(grid_meter=False) mockgrid.add_solar_inverters(2) await mockgrid.start(mocker) battery_pool = microgrid.battery_pool() @@ -148,9 +150,7 @@ async def test_formula_composition_missing_bat(self, mocker: MockerFixture) -> N count = 0 for _ in range(10): - await mockgrid.mock_resampler.send_meter_power( - [10.0 + count, 12.0 + count, 14.0 + count] - ) + await mockgrid.mock_resampler.send_meter_power([12.0 + count, 14.0 + count]) await mockgrid.mock_resampler.send_non_existing_component_value() bat_pow = await battery_power_recv.receive() pv_pow = await pv_power_recv.receive() @@ -170,11 +170,79 @@ async def test_formula_composition_missing_bat(self, mocker: MockerFixture) -> N assert count == 10 + async def test_formula_composition_constant(self, mocker: MockerFixture) -> None: + """Test the composition of formulas with constant values.""" + mockgrid = MockMicrogrid(grid_meter=True) + await mockgrid.start(mocker) + + logical_meter = microgrid.logical_meter() + engine_add = (logical_meter.grid_power + Power.from_watts(50)).build( + "grid_power_addition" + ) + engine_sub = (logical_meter.grid_power - Power.from_watts(100)).build( + "grid_power_subtraction" + ) + engine_mul = (logical_meter.grid_power * 2.0).build("grid_power_multiplication") + engine_div = (logical_meter.grid_power / 2.0).build("grid_power_division") + + await mockgrid.mock_resampler.send_meter_power([100.0]) + + # Test addition + grid_power_addition = await engine_add.new_receiver().receive() + assert grid_power_addition.value is not None + assert math.isclose( + grid_power_addition.value.as_watts(), + 150.0, + ) + + # Test subtraction + grid_power_subtraction = await engine_sub.new_receiver().receive() + assert grid_power_subtraction.value is not None + assert math.isclose( + grid_power_subtraction.value.as_watts(), + 0.0, + ) + + # Test multiplication + grid_power_multiplication = await engine_mul.new_receiver().receive() + assert grid_power_multiplication.value is not None + assert math.isclose( + grid_power_multiplication.value.as_watts(), + 200.0, + ) + + # Test division + grid_power_division = await engine_div.new_receiver().receive() + assert grid_power_division.value is not None + assert math.isclose( + grid_power_division.value.as_watts(), + 50.0, + ) + + # Test multiplication with a Quantity + with pytest.raises(RuntimeError): + engine_assert = ( + logical_meter.grid_power * Power.from_watts(2.0) # type: ignore + ).build("grid_power_multiplication") + await engine_assert.new_receiver().receive() + + # Test addition with a float + with pytest.raises(RuntimeError): + engine_assert = (logical_meter.grid_power + 2.0).build( # type: ignore + "grid_power_multiplication" + ) + await engine_assert.new_receiver().receive() + + await engine_add._stop() # pylint: disable=protected-access + await engine_sub._stop() # pylint: disable=protected-access + await engine_mul._stop() # pylint: disable=protected-access + await engine_div._stop() # pylint: disable=protected-access + await mockgrid.cleanup() + await logical_meter.stop() + async def test_3_phase_formulas(self, mocker: MockerFixture) -> None: """Test 3 phase formulas current formulas and their composition.""" - mockgrid = MockMicrogrid( - grid_side_meter=False, sample_rate_s=0.05, num_namespaces=2 - ) + mockgrid = MockMicrogrid(grid_meter=False, sample_rate_s=0.05, num_namespaces=2) mockgrid.add_batteries(3) mockgrid.add_ev_chargers(1) await mockgrid.start(mocker) @@ -195,7 +263,6 @@ async def test_3_phase_formulas(self, mocker: MockerFixture) -> None: [10.0, 12.0, 14.0], [10.0, 12.0, 14.0], [10.0, 12.0, 14.0], - [10.0, 12.0, 14.0], ] ) await mockgrid.mock_resampler.send_evc_current( diff --git a/tests/timeseries/mock_microgrid.py b/tests/timeseries/mock_microgrid.py index 0377a64a0..9f1bebaca 100644 --- a/tests/timeseries/mock_microgrid.py +++ b/tests/timeseries/mock_microgrid.py @@ -40,7 +40,7 @@ class MockMicrogrid: # pylint: disable=too-many-instance-attributes """Setup a MockApi instance with multiple component layouts for tests.""" grid_id = 1 - main_meter_id = 4 + _grid_meter_id = 4 chp_id_suffix = 5 evc_id_suffix = 6 @@ -48,12 +48,12 @@ class MockMicrogrid: # pylint: disable=too-many-instance-attributes inverter_id_suffix = 8 battery_id_suffix = 9 - _microgrid: MockMicrogridClient + mock_client: MockMicrogridClient mock_resampler: MockResampler def __init__( # pylint: disable=too-many-arguments self, - grid_side_meter: bool, + grid_meter: bool, api_client_streaming: bool = False, num_values: int = 2000, sample_rate_s: float = 0.01, @@ -62,7 +62,7 @@ def __init__( # pylint: disable=too-many-arguments """Create a new instance. Args: - grid_side_meter: whether the main meter should be on the grid side or not. + grid_meter: whether there is a meter successor of the GRID component. api_client_streaming: whether the mock client should be configured to stream raw data from the API client. num_values: number of values to generate for each component. @@ -75,27 +75,23 @@ def __init__( # pylint: disable=too-many-arguments self._components: Set[Component] = set( [ Component(1, ComponentCategory.GRID), - Component(4, ComponentCategory.METER), ] ) - self._connections: Set[Connection] = set([Connection(1, 4)]) + self._connections: Set[Connection] = set() self._id_increment = 0 - self._grid_side_meter = grid_side_meter self._api_client_streaming = api_client_streaming self._num_values = num_values self._sample_rate_s = sample_rate_s self._namespaces = num_namespaces self._connect_to = self.grid_id - if self._grid_side_meter: - self._connect_to = self.main_meter_id self.chp_ids: list[int] = [] self.battery_inverter_ids: list[int] = [] self.pv_inverter_ids: list[int] = [] self.battery_ids: list[int] = [] self.evc_ids: list[int] = [] - self.meter_ids: list[int] = [4] + self.meter_ids: list[int] = [] self.bat_inv_map: dict[int, int] = {} self.evc_component_states: dict[int, EVChargerComponentState] = {} @@ -103,7 +99,15 @@ def __init__( # pylint: disable=too-many-arguments self._streaming_coros: list[typing.Coroutine[None, None, None]] = [] self._streaming_tasks: list[asyncio.Task[None]] = [] - self._start_meter_streaming(4) + + if grid_meter: + self._connect_to = self._grid_meter_id + self._connections.add(Connection(self.grid_id, self._grid_meter_id)) + self._components.add( + Component(self._grid_meter_id, ComponentCategory.METER) + ) + self.meter_ids.append(self._grid_meter_id) + self._start_meter_streaming(self._grid_meter_id) async def start(self, mocker: MockerFixture) -> None: """Init the mock microgrid client and start the mock resampler.""" @@ -123,8 +127,8 @@ def init_mock_client( self, initialize_cb: Callable[[MockMicrogridClient], None] ) -> None: """Set up the mock client. Does not start the streaming tasks.""" - self._microgrid = MockMicrogridClient(self._components, self._connections) - initialize_cb(self._microgrid) + self.mock_client = MockMicrogridClient(self._components, self._connections) + initialize_cb(self.mock_client) def start_mock_client( self, initialize_cb: Callable[[MockMicrogridClient], None] @@ -146,7 +150,7 @@ def start_mock_client( self._streaming_tasks = [ asyncio.create_task(coro) for coro in self._streaming_coros ] - return self._microgrid + return self.mock_client async def _comp_data_send_task( self, comp_id: int, make_comp_data: Callable[[int, datetime], ComponentData] @@ -157,12 +161,12 @@ async def _comp_data_send_task( # for inverters with component_id > 100, send only half the messages. if comp_id % 10 == self.inverter_id_suffix: if comp_id < 100 or value <= 5: - await self._microgrid.send(make_comp_data(val_to_send, timestamp)) + await self.mock_client.send(make_comp_data(val_to_send, timestamp)) else: - await self._microgrid.send(make_comp_data(val_to_send, timestamp)) + await self.mock_client.send(make_comp_data(val_to_send, timestamp)) await asyncio.sleep(self._sample_rate_s) - await self._microgrid.close_channel(comp_id) + await self.mock_client.close_channel(comp_id) def _start_meter_streaming(self, meter_id: int) -> None: if not self._api_client_streaming: @@ -220,7 +224,32 @@ def _start_ev_charger_streaming(self, evc_id: int) -> None: ), ) - def add_chps(self, count: int) -> None: + def add_consumer_meters(self, count: int = 1) -> None: + """Add consumer meters to the mock microgrid. + + A consumer meter is a meter with unknown successors + that draw a certain amount of power. + + We use it to calculate the total power consumption + at the grid connection point. + + Args: + count: number of consumer meters to add. + """ + for _ in range(count): + meter_id = self._id_increment * 10 + self.meter_id_suffix + self._id_increment += 1 + self.meter_ids.append(meter_id) + self._components.add( + Component( + meter_id, + ComponentCategory.METER, + ) + ) + self._connections.add(Connection(self._connect_to, meter_id)) + self._start_meter_streaming(meter_id) + + def add_chps(self, count: int, no_meters: bool = False) -> None: """Add CHPs with connected meters to the mock microgrid. Args: @@ -234,12 +263,13 @@ def add_chps(self, count: int) -> None: self.meter_ids.append(meter_id) self.chp_ids.append(chp_id) - self._components.add( - Component( - meter_id, - ComponentCategory.METER, + if not no_meters: + self._components.add( + Component( + meter_id, + ComponentCategory.METER, + ) ) - ) self._components.add( Component( chp_id, @@ -248,14 +278,18 @@ def add_chps(self, count: int) -> None: ) self._start_meter_streaming(meter_id) - self._connections.add(Connection(self._connect_to, meter_id)) - self._connections.add(Connection(meter_id, chp_id)) + if no_meters: + self._connections.add(Connection(self._connect_to, chp_id)) + else: + self._connections.add(Connection(self._connect_to, meter_id)) + self._connections.add(Connection(meter_id, chp_id)) - def add_batteries(self, count: int) -> None: + def add_batteries(self, count: int, no_meter: bool = False) -> None: """Add batteries with connected inverters and meters to the microgrid. Args: count: number of battery sets to add. + no_meter: if True, do not add a meter for each battery set. """ for _ in range(count): meter_id = self._id_increment * 10 + self.meter_id_suffix @@ -263,17 +297,10 @@ def add_batteries(self, count: int) -> None: bat_id = self._id_increment * 10 + self.battery_id_suffix self._id_increment += 1 - self.meter_ids.append(meter_id) self.battery_inverter_ids.append(inv_id) self.battery_ids.append(bat_id) self.bat_inv_map[bat_id] = inv_id - self._components.add( - Component( - meter_id, - ComponentCategory.METER, - ) - ) self._components.add( Component(inv_id, ComponentCategory.INVERTER, InverterType.BATTERY) ) @@ -285,31 +312,36 @@ def add_batteries(self, count: int) -> None: ) self._start_battery_streaming(bat_id) self._start_inverter_streaming(inv_id) - self._start_meter_streaming(meter_id) - self._connections.add(Connection(self._connect_to, meter_id)) - self._connections.add(Connection(meter_id, inv_id)) + + if no_meter: + self._connections.add(Connection(self._connect_to, inv_id)) + else: + self.meter_ids.append(meter_id) + self._components.add( + Component( + meter_id, + ComponentCategory.METER, + ) + ) + self._start_meter_streaming(meter_id) + self._connections.add(Connection(self._connect_to, meter_id)) + self._connections.add(Connection(meter_id, inv_id)) self._connections.add(Connection(inv_id, bat_id)) - def add_solar_inverters(self, count: int) -> None: + def add_solar_inverters(self, count: int, no_meter: bool = False) -> None: """Add pv inverters and connected pv meters to the microgrid. Args: count: number of inverters to add to the microgrid. + no_meter: if True, do not add a meter for each inverter. """ for _ in range(count): meter_id = self._id_increment * 10 + self.meter_id_suffix inv_id = self._id_increment * 10 + self.inverter_id_suffix self._id_increment += 1 - self.meter_ids.append(meter_id) self.pv_inverter_ids.append(inv_id) - self._components.add( - Component( - meter_id, - ComponentCategory.METER, - ) - ) self._components.add( Component( inv_id, @@ -318,9 +350,20 @@ def add_solar_inverters(self, count: int) -> None: ) ) self._start_inverter_streaming(inv_id) - self._start_meter_streaming(meter_id) - self._connections.add(Connection(self._connect_to, meter_id)) - self._connections.add(Connection(meter_id, inv_id)) + + if no_meter: + self._connections.add(Connection(self._connect_to, inv_id)) + else: + self.meter_ids.append(meter_id) + self._components.add( + Component( + meter_id, + ComponentCategory.METER, + ) + ) + self._start_meter_streaming(meter_id) + self._connections.add(Connection(self._connect_to, meter_id)) + self._connections.add(Connection(meter_id, inv_id)) def add_ev_chargers(self, count: int) -> None: """Add EV Chargers to the microgrid. @@ -354,7 +397,7 @@ async def send_meter_data(self, values: list[float]) -> None: assert len(values) == len(self.meter_ids) timestamp = datetime.now(tz=timezone.utc) for comp_id, value in zip(self.meter_ids, values): - await self._microgrid.send( + await self.mock_client.send( MeterDataWrapper( component_id=comp_id, timestamp=timestamp, @@ -376,7 +419,7 @@ async def send_battery_data(self, socs: list[float]) -> None: assert len(socs) == len(self.battery_ids) timestamp = datetime.now(tz=timezone.utc) for comp_id, value in zip(self.battery_ids, socs): - await self._microgrid.send( + await self.mock_client.send( BatteryDataWrapper(component_id=comp_id, timestamp=timestamp, soc=value) ) @@ -389,7 +432,7 @@ async def send_battery_inverter_data(self, values: list[float]) -> None: assert len(values) == len(self.battery_inverter_ids) timestamp = datetime.now(tz=timezone.utc) for comp_id, value in zip(self.battery_inverter_ids, values): - await self._microgrid.send( + await self.mock_client.send( InverterDataWrapper( component_id=comp_id, timestamp=timestamp, active_power=value ) @@ -404,7 +447,7 @@ async def send_pv_inverter_data(self, values: list[float]) -> None: assert len(values) == len(self.pv_inverter_ids) timestamp = datetime.now(tz=timezone.utc) for comp_id, value in zip(self.pv_inverter_ids, values): - await self._microgrid.send( + await self.mock_client.send( InverterDataWrapper( component_id=comp_id, timestamp=timestamp, active_power=value ) @@ -419,7 +462,7 @@ async def send_ev_charger_data(self, values: list[float]) -> None: assert len(values) == len(self.evc_ids) timestamp = datetime.now(tz=timezone.utc) for comp_id, value in zip(self.evc_ids, values): - await self._microgrid.send( + await self.mock_client.send( EvChargerDataWrapper( component_id=comp_id, timestamp=timestamp, diff --git a/tests/timeseries/mock_resampler.py b/tests/timeseries/mock_resampler.py index e60104a6e..8caeafb02 100644 --- a/tests/timeseries/mock_resampler.py +++ b/tests/timeseries/mock_resampler.py @@ -145,6 +145,20 @@ async def send_meter_power(self, values: list[float | None]) -> None: sample = Sample(self._next_ts, None if not value else Quantity(value)) await chan.send(sample) + async def send_chp_power(self, values: list[float | None]) -> None: + """Send the given values as resampler output for CHP power.""" + assert len(values) == len(self._chp_power_senders) + for chan, value in zip(self._chp_power_senders, values): + sample = Sample(self._next_ts, None if not value else Quantity(value)) + await chan.send(sample) + + async def send_pv_inverter_power(self, values: list[float | None]) -> None: + """Send the given values as resampler output for PV Inverter power.""" + assert len(values) == len(self._pv_inverter_power_senders) + for chan, value in zip(self._pv_inverter_power_senders, values): + sample = Sample(self._next_ts, None if not value else Quantity(value)) + await chan.send(sample) + async def send_evc_power(self, values: list[float | None]) -> None: """Send the given values as resampler output for EV Charger power.""" assert len(values) == len(self._ev_power_senders) diff --git a/tests/timeseries/test_ev_charger_pool.py b/tests/timeseries/test_ev_charger_pool.py index 3146b2f7a..32ba8970f 100644 --- a/tests/timeseries/test_ev_charger_pool.py +++ b/tests/timeseries/test_ev_charger_pool.py @@ -29,7 +29,7 @@ async def test_state_updates(self, mocker: MockerFixture) -> None: """Test ev charger state updates are visible.""" mockgrid = MockMicrogrid( - grid_side_meter=False, api_client_streaming=True, sample_rate_s=0.01 + grid_meter=False, api_client_streaming=True, sample_rate_s=0.01 ) mockgrid.add_ev_chargers(5) await mockgrid.start(mocker) @@ -81,7 +81,7 @@ async def test_ev_power( # pylint: disable=too-many-locals mocker: MockerFixture, ) -> None: """Test the ev power formula.""" - mockgrid = MockMicrogrid(grid_side_meter=False) + mockgrid = MockMicrogrid(grid_meter=False) mockgrid.add_ev_chargers(3) await mockgrid.start(mocker) @@ -105,7 +105,7 @@ async def test_ev_power( # pylint: disable=too-many-locals async def test_ev_component_data(self, mocker: MockerFixture) -> None: """Test the component_data method of EVChargerPool.""" mockgrid = MockMicrogrid( - grid_side_meter=False, + grid_meter=False, api_client_streaming=True, sample_rate_s=0.05, ) diff --git a/tests/timeseries/test_logical_meter.py b/tests/timeseries/test_logical_meter.py index f39c89add..0153c927e 100644 --- a/tests/timeseries/test_logical_meter.py +++ b/tests/timeseries/test_logical_meter.py @@ -22,7 +22,7 @@ class TestLogicalMeter: async def test_grid_power_1(self, mocker: MockerFixture) -> None: """Test the grid power formula with a grid side meter.""" - mockgrid = MockMicrogrid(grid_side_meter=True) + mockgrid = MockMicrogrid(grid_meter=True) mockgrid.add_batteries(2) mockgrid.add_solar_inverters(1) await mockgrid.start(mocker) @@ -30,64 +30,69 @@ async def test_grid_power_1(self, mocker: MockerFixture) -> None: grid_power_recv = logical_meter.grid_power.new_receiver() - main_meter_recv = get_resampled_stream( + grid_meter_recv = get_resampled_stream( logical_meter._namespace, # pylint: disable=protected-access - mockgrid.main_meter_id, + mockgrid.meter_ids[0], ComponentMetricId.ACTIVE_POWER, Power.from_watts, ) results = [] - main_meter_data = [] + grid_meter_data = [] for count in range(10): await mockgrid.mock_resampler.send_meter_power( [20.0 + count, 12.0, -13.0, -5.0] ) - val = await main_meter_recv.receive() + val = await grid_meter_recv.receive() assert ( val is not None and val.value is not None and val.value.as_watts() != 0.0 ) - main_meter_data.append(val.value) + grid_meter_data.append(val.value) val = await grid_power_recv.receive() assert val is not None and val.value is not None results.append(val.value) await mockgrid.cleanup() - assert equal_float_lists(results, main_meter_data) + assert equal_float_lists(results, grid_meter_data) async def test_grid_power_2( self, mocker: MockerFixture, ) -> None: """Test the grid power formula without a grid side meter.""" - mockgrid = MockMicrogrid(grid_side_meter=False) - mockgrid.add_batteries(2) + mockgrid = MockMicrogrid(grid_meter=False) + mockgrid.add_consumer_meters(1) + mockgrid.add_batteries(1, no_meter=False) + mockgrid.add_batteries(1, no_meter=True) mockgrid.add_solar_inverters(1) await mockgrid.start(mocker) logical_meter = microgrid.logical_meter() grid_power_recv = logical_meter.grid_power.new_receiver() - meter_receivers = [ + component_receivers = [ get_resampled_stream( logical_meter._namespace, # pylint: disable=protected-access - meter_id, + component_id, ComponentMetricId.ACTIVE_POWER, Power.from_watts, ) - for meter_id in mockgrid.meter_ids + for component_id in [ + *mockgrid.meter_ids, + # The last battery has no meter, so we get the power from the inverter + mockgrid.battery_inverter_ids[-1], + ] ] results: list[Quantity] = [] meter_sums: list[Quantity] = [] for count in range(10): - await mockgrid.mock_resampler.send_meter_power( - [20.0 + count, 12.0, -13.0, -5.0] - ) + await mockgrid.mock_resampler.send_meter_power([20.0 + count, 12.0, -13.0]) + await mockgrid.mock_resampler.send_bat_inverter_power([0.0, -5.0]) meter_sum = 0.0 - for recv in meter_receivers: + for recv in component_receivers: val = await recv.receive() assert ( val is not None @@ -106,12 +111,13 @@ async def test_grid_power_2( assert len(results) == 10 assert equal_float_lists(results, meter_sums) - async def test_grid_production_consumption_power( + async def test_grid_production_consumption_power_consumer_meter( self, mocker: MockerFixture, ) -> None: """Test the grid production and consumption power formulas.""" - mockgrid = MockMicrogrid(grid_side_meter=False) + mockgrid = MockMicrogrid(grid_meter=False) + mockgrid.add_consumer_meters() mockgrid.add_batteries(2) mockgrid.add_solar_inverters(1) await mockgrid.start(mocker) @@ -131,9 +137,34 @@ async def test_grid_production_consumption_power( assert (await grid_production_recv.receive()).value == Power.from_watts(4.0) assert (await grid_consumption_recv.receive()).value == Power.from_watts(0.0) + async def test_grid_production_consumption_power_no_grid_meter( + self, + mocker: MockerFixture, + ) -> None: + """Test the grid production and consumption power formulas.""" + mockgrid = MockMicrogrid(grid_meter=False) + mockgrid.add_batteries(2) + mockgrid.add_solar_inverters(1) + await mockgrid.start(mocker) + + logical_meter = microgrid.logical_meter() + grid_recv = logical_meter.grid_power.new_receiver() + grid_production_recv = logical_meter.grid_production_power.new_receiver() + grid_consumption_recv = logical_meter.grid_consumption_power.new_receiver() + + await mockgrid.mock_resampler.send_meter_power([2.5, 3.5, 4.0]) + assert (await grid_recv.receive()).value == Power.from_watts(10.0) + assert (await grid_production_recv.receive()).value == Power.from_watts(0.0) + assert (await grid_consumption_recv.receive()).value == Power.from_watts(10.0) + + await mockgrid.mock_resampler.send_meter_power([3.0, -3.0, -4.0]) + assert (await grid_recv.receive()).value == Power.from_watts(-4.0) + assert (await grid_production_recv.receive()).value == Power.from_watts(4.0) + assert (await grid_consumption_recv.receive()).value == Power.from_watts(0.0) + async def test_chp_power(self, mocker: MockerFixture) -> None: """Test the chp power formula.""" - mockgrid = MockMicrogrid(grid_side_meter=False) + mockgrid = MockMicrogrid(grid_meter=False) mockgrid.add_chps(1) mockgrid.add_batteries(2) await mockgrid.start(mocker) @@ -147,7 +178,7 @@ async def test_chp_power(self, mocker: MockerFixture) -> None: logical_meter.chp_consumption_power.new_receiver() ) - await mockgrid.mock_resampler.send_meter_power([1.0, 2.0, 3.0, 4.0]) + await mockgrid.mock_resampler.send_meter_power([2.0, 3.0, 4.0]) assert (await chp_power_receiver.receive()).value == Power.from_watts(2.0) assert ( await chp_production_power_receiver.receive() @@ -156,7 +187,7 @@ async def test_chp_power(self, mocker: MockerFixture) -> None: await chp_consumption_power_receiver.receive() ).value == Power.from_watts(2.0) - await mockgrid.mock_resampler.send_meter_power([-4.0, -12.0, None, 10.2]) + await mockgrid.mock_resampler.send_meter_power([-12.0, None, 10.2]) assert (await chp_power_receiver.receive()).value == Power.from_watts(-12.0) assert ( await chp_production_power_receiver.receive() @@ -167,7 +198,7 @@ async def test_chp_power(self, mocker: MockerFixture) -> None: async def test_pv_power(self, mocker: MockerFixture) -> None: """Test the pv power formula.""" - mockgrid = MockMicrogrid(grid_side_meter=False) + mockgrid = MockMicrogrid(grid_meter=False) mockgrid.add_solar_inverters(2) await mockgrid.start(mocker) @@ -178,7 +209,29 @@ async def test_pv_power(self, mocker: MockerFixture) -> None: logical_meter.pv_consumption_power.new_receiver() ) - await mockgrid.mock_resampler.send_meter_power([10.0, -1.0, -2.0]) + await mockgrid.mock_resampler.send_meter_power([-1.0, -2.0]) + assert (await pv_power_receiver.receive()).value == Power.from_watts(-3.0) + assert (await pv_production_power_receiver.receive()).value == Power.from_watts( + 3.0 + ) + assert ( + await pv_consumption_power_receiver.receive() + ).value == Power.from_watts(0.0) + + async def test_pv_power_no_meter(self, mocker: MockerFixture) -> None: + """Test the pv power formula.""" + mockgrid = MockMicrogrid(grid_meter=False) + mockgrid.add_solar_inverters(2, no_meter=True) + await mockgrid.start(mocker) + + logical_meter = microgrid.logical_meter() + pv_power_receiver = logical_meter.pv_power.new_receiver() + pv_production_power_receiver = logical_meter.pv_production_power.new_receiver() + pv_consumption_power_receiver = ( + logical_meter.pv_consumption_power.new_receiver() + ) + + await mockgrid.mock_resampler.send_pv_inverter_power([-1.0, -2.0]) assert (await pv_power_receiver.receive()).value == Power.from_watts(-3.0) assert (await pv_production_power_receiver.receive()).value == Power.from_watts( 3.0 @@ -187,9 +240,20 @@ async def test_pv_power(self, mocker: MockerFixture) -> None: await pv_consumption_power_receiver.receive() ).value == Power.from_watts(0.0) + async def test_pv_power_no_pv_components(self, mocker: MockerFixture) -> None: + """Test the pv power formula without having any pv components.""" + mockgrid = MockMicrogrid(grid_meter=True) + await mockgrid.start(mocker) + + logical_meter = microgrid.logical_meter() + pv_power_receiver = logical_meter.pv_power.new_receiver() + + await mockgrid.mock_resampler.send_non_existing_component_value() + assert (await pv_power_receiver.receive()).value == Power.zero() + async def test_consumer_power_grid_meter(self, mocker: MockerFixture) -> None: """Test the consumer power formula with a grid meter.""" - mockgrid = MockMicrogrid(grid_side_meter=True) + mockgrid = MockMicrogrid(grid_meter=True) mockgrid.add_batteries(2) mockgrid.add_solar_inverters(2) await mockgrid.start(mocker) @@ -202,7 +266,8 @@ async def test_consumer_power_grid_meter(self, mocker: MockerFixture) -> None: async def test_consumer_power_no_grid_meter(self, mocker: MockerFixture) -> None: """Test the consumer power formula without a grid meter.""" - mockgrid = MockMicrogrid(grid_side_meter=False) + mockgrid = MockMicrogrid(grid_meter=False) + mockgrid.add_consumer_meters() mockgrid.add_batteries(2) mockgrid.add_solar_inverters(2) await mockgrid.start(mocker) @@ -213,9 +278,24 @@ async def test_consumer_power_no_grid_meter(self, mocker: MockerFixture) -> None await mockgrid.mock_resampler.send_meter_power([20.0, 2.0, 3.0, 4.0, 5.0]) assert (await consumer_power_receiver.receive()).value == Power.from_watts(20.0) + async def test_consumer_power_no_grid_meter_no_consumer_meter( + self, mocker: MockerFixture + ) -> None: + """Test the consumer power formula without a grid meter.""" + mockgrid = MockMicrogrid(grid_meter=False) + mockgrid.add_batteries(2) + mockgrid.add_solar_inverters(2) + await mockgrid.start(mocker) + + logical_meter = microgrid.logical_meter() + consumer_power_receiver = logical_meter.consumer_power.new_receiver() + + await mockgrid.mock_resampler.send_non_existing_component_value() + assert (await consumer_power_receiver.receive()).value == Power.from_watts(0.0) + async def test_producer_power(self, mocker: MockerFixture) -> None: """Test the producer power formula.""" - mockgrid = MockMicrogrid(grid_side_meter=False) + mockgrid = MockMicrogrid(grid_meter=False) mockgrid.add_solar_inverters(2) mockgrid.add_chps(2) await mockgrid.start(mocker) @@ -223,24 +303,40 @@ async def test_producer_power(self, mocker: MockerFixture) -> None: logical_meter = microgrid.logical_meter() producer_power_receiver = logical_meter.producer_power.new_receiver() - await mockgrid.mock_resampler.send_meter_power([20.0, 2.0, 3.0, 4.0, 5.0]) + await mockgrid.mock_resampler.send_meter_power([2.0, 3.0, 4.0, 5.0]) assert (await producer_power_receiver.receive()).value == Power.from_watts(14.0) async def test_producer_power_no_chp(self, mocker: MockerFixture) -> None: """Test the producer power formula without a chp.""" - mockgrid = MockMicrogrid(grid_side_meter=False) + mockgrid = MockMicrogrid(grid_meter=False) mockgrid.add_solar_inverters(2) + await mockgrid.start(mocker) logical_meter = microgrid.logical_meter() producer_power_receiver = logical_meter.producer_power.new_receiver() - await mockgrid.mock_resampler.send_meter_power([20.0, 2.0, 3.0]) + await mockgrid.mock_resampler.send_meter_power([2.0, 3.0]) assert (await producer_power_receiver.receive()).value == Power.from_watts(5.0) + async def test_producer_power_no_pv_no_consumer_meter( + self, mocker: MockerFixture + ) -> None: + """Test the producer power formula without pv and without consumer meter.""" + mockgrid = MockMicrogrid(grid_meter=False) + mockgrid.add_chps(1, True) + await mockgrid.start(mocker) + + logical_meter = microgrid.logical_meter() + producer_power_receiver = logical_meter.producer_power.new_receiver() + + await mockgrid.mock_resampler.send_chp_power([2.0]) + assert (await producer_power_receiver.receive()).value == Power.from_watts(2.0) + async def test_producer_power_no_pv(self, mocker: MockerFixture) -> None: """Test the producer power formula without pv.""" - mockgrid = MockMicrogrid(grid_side_meter=False) + mockgrid = MockMicrogrid(grid_meter=False) + mockgrid.add_consumer_meters() mockgrid.add_chps(1) await mockgrid.start(mocker) @@ -252,7 +348,7 @@ async def test_producer_power_no_pv(self, mocker: MockerFixture) -> None: async def test_no_producer_power(self, mocker: MockerFixture) -> None: """Test the producer power formula without producers.""" - mockgrid = MockMicrogrid(grid_side_meter=False) + mockgrid = MockMicrogrid(grid_meter=True) await mockgrid.start(mocker) logical_meter = microgrid.logical_meter() diff --git a/tests/timeseries/test_moving_window.py b/tests/timeseries/test_moving_window.py index 032075c31..7635d49b8 100644 --- a/tests/timeseries/test_moving_window.py +++ b/tests/timeseries/test_moving_window.py @@ -75,15 +75,17 @@ def init_moving_window( async def test_access_window_by_index() -> None: """Test indexing a window by integer index""" window, sender = init_moving_window(timedelta(seconds=1)) - await push_logical_meter_data(sender, [1]) - assert np.array_equal(window[0], 1.0) + async with window: + await push_logical_meter_data(sender, [1]) + assert np.array_equal(window[0], 1.0) async def test_access_window_by_timestamp() -> None: """Test indexing a window by timestamp""" window, sender = init_moving_window(timedelta(seconds=1)) - await push_logical_meter_data(sender, [1]) - assert np.array_equal(window[UNIX_EPOCH], 1.0) + async with window: + await push_logical_meter_data(sender, [1]) + assert np.array_equal(window[UNIX_EPOCH], 1.0) async def test_access_window_by_int_slice() -> None: @@ -94,28 +96,39 @@ async def test_access_window_by_int_slice() -> None: since the push_lm_data function is starting with the same initial timestamp. """ window, sender = init_moving_window(timedelta(seconds=14)) - await push_logical_meter_data(sender, range(0, 5)) - assert np.array_equal(window[3:5], np.array([3.0, 4.0])) + async with window: + await push_logical_meter_data(sender, range(0, 5)) + assert np.array_equal(window[3:5], np.array([3.0, 4.0])) - data = [1, 2, 2.5, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1] - await push_logical_meter_data(sender, data) - assert np.array_equal(window[5:14], np.array(data[5:14])) + data = [1, 2, 2.5, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1] + await push_logical_meter_data(sender, data) + assert np.array_equal(window[5:14], np.array(data[5:14])) async def test_access_window_by_ts_slice() -> None: """Test accessing a subwindow with a timestamp slice""" window, sender = init_moving_window(timedelta(seconds=5)) - await push_logical_meter_data(sender, range(0, 5)) - time_start = UNIX_EPOCH + timedelta(seconds=3) - time_end = time_start + timedelta(seconds=2) - assert np.array_equal(window[time_start:time_end], np.array([3.0, 4.0])) # type: ignore + async with window: + await push_logical_meter_data(sender, range(0, 5)) + time_start = UNIX_EPOCH + timedelta(seconds=3) + time_end = time_start + timedelta(seconds=2) + assert np.array_equal(window[time_start:time_end], np.array([3.0, 4.0])) # type: ignore + + +async def test_access_empty_window() -> None: + """Test accessing an empty window, should throw IndexError""" + window, _ = init_moving_window(timedelta(seconds=5)) + async with window: + with pytest.raises(IndexError, match=r"^The buffer is empty\.$"): + _ = window[42] async def test_window_size() -> None: """Test the size of the window.""" window, sender = init_moving_window(timedelta(seconds=5)) - await push_logical_meter_data(sender, range(0, 20)) - assert len(window) == 5 + async with window: + await push_logical_meter_data(sender, range(0, 20)) + assert len(window) == 5 # pylint: disable=redefined-outer-name @@ -129,21 +142,20 @@ async def test_resampling_window(fake_time: time_machine.Coordinates) -> None: output_sampling = timedelta(seconds=2) resampler_config = ResamplerConfig(resampling_period=output_sampling) - window = MovingWindow( + async with MovingWindow( size=window_size, resampled_data_recv=channel.new_receiver(), input_sampling_period=input_sampling, resampler_config=resampler_config, - ) - - stream_values = [4.0, 8.0, 2.0, 6.0, 5.0] * 100 - for value in stream_values: - timestamp = datetime.now(tz=timezone.utc) - sample = Sample(timestamp, Quantity(float(value))) - await sender.send(sample) - await asyncio.sleep(0.1) - fake_time.shift(0.1) - - assert len(window) == window_size / output_sampling - for value in window: # type: ignore - assert 4.9 < value < 5.1 + ) as window: + stream_values = [4.0, 8.0, 2.0, 6.0, 5.0] * 100 + for value in stream_values: + timestamp = datetime.now(tz=timezone.utc) + sample = Sample(timestamp, Quantity(float(value))) + await sender.send(sample) + await asyncio.sleep(0.1) + fake_time.shift(0.1) + + assert len(window) == window_size / output_sampling + for value in window: # type: ignore + assert 4.9 < value < 5.1 diff --git a/tests/timeseries/test_periodic_feature_extractor.py b/tests/timeseries/test_periodic_feature_extractor.py index d6199a632..7320db130 100644 --- a/tests/timeseries/test_periodic_feature_extractor.py +++ b/tests/timeseries/test_periodic_feature_extractor.py @@ -3,6 +3,8 @@ """Tests for the timeseries averager.""" +import collections.abc +import contextlib from datetime import datetime, timedelta, timezone from typing import List @@ -23,9 +25,10 @@ ) +@contextlib.asynccontextmanager async def init_feature_extractor( data: List[float], period: timedelta -) -> PeriodicFeatureExtractor: +) -> collections.abc.AsyncIterator[PeriodicFeatureExtractor]: """ Initialize a PeriodicFeatureExtractor with a `MovingWindow` that contains the data. @@ -37,12 +40,15 @@ async def init_feature_extractor( PeriodicFeatureExtractor """ window, sender = init_moving_window(timedelta(seconds=len(data))) - await push_logical_meter_data(sender, data) + async with window: + await push_logical_meter_data(sender, data) + yield PeriodicFeatureExtractor(moving_window=window, period=period) - return PeriodicFeatureExtractor(moving_window=window, period=period) - -async def init_feature_extractor_no_data(period: int) -> PeriodicFeatureExtractor: +@contextlib.asynccontextmanager +async def init_feature_extractor_no_data( + period: int, +) -> collections.abc.AsyncIterator[PeriodicFeatureExtractor]: """ Initialize a PeriodicFeatureExtractor with a `MovingWindow` that contains no data. @@ -57,51 +63,52 @@ async def init_feature_extractor_no_data(period: int) -> PeriodicFeatureExtracto moving_window = MovingWindow( timedelta(seconds=1), lm_chan.new_receiver(), timedelta(seconds=1) ) + async with moving_window: + await lm_chan.new_sender().send( + Sample(datetime.now(tz=timezone.utc), Quantity(0)) + ) - await lm_chan.new_sender().send(Sample(datetime.now(tz=timezone.utc), Quantity(0))) - - # Initialize the PeriodicFeatureExtractor class with a period of period seconds. - # This works since the sampling period is set to 1 second. - return PeriodicFeatureExtractor(moving_window, timedelta(seconds=period)) + # Initialize the PeriodicFeatureExtractor class with a period of period seconds. + # This works since the sampling period is set to 1 second. + yield PeriodicFeatureExtractor(moving_window, timedelta(seconds=period)) async def test_interval_shifting() -> None: """ Test if a interval is properly shifted into a moving window """ - feature_extractor = await init_feature_extractor( + async with init_feature_extractor( [1, 2, 2, 1, 1, 1, 2, 2, 1, 1], timedelta(seconds=5) - ) - - # Test if the timestamp is not shifted - timestamp = datetime(2023, 1, 1, 0, 0, 1, tzinfo=timezone.utc) - index_not_shifted = ( - feature_extractor._timestamp_to_rel_index( # pylint: disable=protected-access - timestamp + ) as feature_extractor: + # Test if the timestamp is not shifted + timestamp = datetime(2023, 1, 1, 0, 0, 1, tzinfo=timezone.utc) + index_not_shifted = ( + feature_extractor._timestamp_to_rel_index( # pylint: disable=protected-access + timestamp + ) + % feature_extractor._period # pylint: disable=protected-access ) - % feature_extractor._period # pylint: disable=protected-access - ) - assert index_not_shifted == 1 - - # Test if a timestamp in the window is shifted to the first appearance of the window - timestamp = datetime(2023, 1, 1, 0, 0, 6, tzinfo=timezone.utc) - index_shifted = ( - feature_extractor._timestamp_to_rel_index( # pylint: disable=protected-access - timestamp + assert index_not_shifted == 1 + + # Test if a timestamp in the window is shifted to the first appearance of the window + timestamp = datetime(2023, 1, 1, 0, 0, 6, tzinfo=timezone.utc) + index_shifted = ( + feature_extractor._timestamp_to_rel_index( # pylint: disable=protected-access + timestamp + ) + % feature_extractor._period # pylint: disable=protected-access ) - % feature_extractor._period # pylint: disable=protected-access - ) - assert index_shifted == 1 - - # Test if a timestamp outside the window is shifted - timestamp = datetime(2023, 1, 1, 0, 0, 11, tzinfo=timezone.utc) - index_shifted = ( - feature_extractor._timestamp_to_rel_index( # pylint: disable=protected-access - timestamp + assert index_shifted == 1 + + # Test if a timestamp outside the window is shifted + timestamp = datetime(2023, 1, 1, 0, 0, 11, tzinfo=timezone.utc) + index_shifted = ( + feature_extractor._timestamp_to_rel_index( # pylint: disable=protected-access + timestamp + ) + % feature_extractor._period # pylint: disable=protected-access ) - % feature_extractor._period # pylint: disable=protected-access - ) - assert index_shifted == 1 + assert index_shifted == 1 async def test_feature_extractor() -> None: # pylint: disable=too-many-statements @@ -111,16 +118,16 @@ async def test_feature_extractor() -> None: # pylint: disable=too-many-statemen data: List[float] = [1, 2, 2.5, 1, 1, 1, 2, 2, 1, 1, 2, 2] - feature_extractor = await init_feature_extractor(data, timedelta(seconds=3)) - assert np.allclose(feature_extractor.avg(start, end), [5 / 3, 4 / 3]) + async with init_feature_extractor(data, timedelta(seconds=3)) as feature_extractor: + assert np.allclose(feature_extractor.avg(start, end), [5 / 3, 4 / 3]) - feature_extractor = await init_feature_extractor(data, timedelta(seconds=4)) - assert np.allclose(feature_extractor.avg(start, end), [1, 2]) + async with init_feature_extractor(data, timedelta(seconds=4)) as feature_extractor: + assert np.allclose(feature_extractor.avg(start, end), [1, 2]) data: List[float] = [1, 2, 2.5, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 1, 1] # type: ignore[no-redef] - feature_extractor = await init_feature_extractor(data, timedelta(seconds=5)) - assert np.allclose(feature_extractor.avg(start, end), [1.5, 1.5]) + async with init_feature_extractor(data, timedelta(seconds=5)) as feature_extractor: + assert np.allclose(feature_extractor.avg(start, end), [1.5, 1.5]) async def _test_fun( # pylint: disable=too-many-arguments data: List[float], @@ -130,15 +137,15 @@ async def _test_fun( # pylint: disable=too-many-arguments expected: List[float], weights: List[float] | None = None, ) -> None: - feature_extractor = await init_feature_extractor( + async with init_feature_extractor( data, timedelta(seconds=period) - ) - ret = feature_extractor.avg( - UNIX_EPOCH + timedelta(seconds=start), - UNIX_EPOCH + timedelta(seconds=end), - weights=weights, - ) - assert np.allclose(ret, expected) + ) as feature_extractor: + ret = feature_extractor.avg( + UNIX_EPOCH + timedelta(seconds=start), + UNIX_EPOCH + timedelta(seconds=end), + weights=weights, + ) + assert np.allclose(ret, expected) async def test_09( period: int, @@ -236,18 +243,22 @@ async def test_profiler_calculate_np() -> None: against the pure python method with the same functionality. """ data = np.array([2, 2.5, 1, 1, 1, 2]) - feature_extractor = await init_feature_extractor_no_data(4) - window_size = 2 - reshaped = feature_extractor._reshape_np_array( # pylint: disable=protected-access - data, window_size - ) - result = np.average(reshaped[:, :window_size], axis=0) - assert np.allclose(result, np.array([1.5, 2.25])) + async with init_feature_extractor_no_data(4) as feature_extractor: + window_size = 2 + reshaped = ( + feature_extractor._reshape_np_array( # pylint: disable=protected-access + data, window_size + ) + ) + result = np.average(reshaped[:, :window_size], axis=0) + assert np.allclose(result, np.array([1.5, 2.25])) data = np.array([2, 2, 1, 1, 2]) - feature_extractor = await init_feature_extractor_no_data(5) - reshaped = feature_extractor._reshape_np_array( # pylint: disable=protected-access - data, window_size - ) - result = np.average(reshaped[:, :window_size], axis=0) - assert np.allclose(result, np.array([2, 2])) + async with init_feature_extractor_no_data(5) as feature_extractor: + reshaped = ( + feature_extractor._reshape_np_array( # pylint: disable=protected-access + data, window_size + ) + ) + result = np.average(reshaped[:, :window_size], axis=0) + assert np.allclose(result, np.array([2, 2])) diff --git a/tests/timeseries/test_quantities.py b/tests/timeseries/test_quantities.py index 14b45eb7b..c9e5311bd 100644 --- a/tests/timeseries/test_quantities.py +++ b/tests/timeseries/test_quantities.py @@ -14,6 +14,7 @@ Percentage, Power, Quantity, + Temperature, Voltage, ) @@ -42,6 +43,62 @@ class Fz2( """Frequency quantity with broad exponent unit map.""" +def test_zero() -> None: + """Test the zero value for quantity.""" + assert Quantity(0.0) == Quantity.zero() + assert Quantity(0.0, exponent=100) == Quantity.zero() + assert Quantity.zero() is Quantity.zero() # It is a "singleton" + assert Quantity.zero().base_value == 0.0 + + # Test the singleton is immutable + one = Quantity.zero() + one += Quantity(1.0) + assert one != Quantity.zero() + assert Quantity.zero() == Quantity(0.0) + + assert Power.from_watts(0.0) == Power.zero() + assert Power.from_kilowatts(0.0) == Power.zero() + assert isinstance(Power.zero(), Power) + assert Power.zero().as_watts() == 0.0 + assert Power.zero().as_kilowatts() == 0.0 + assert Power.zero() is Power.zero() # It is a "singleton" + + assert Current.from_amperes(0.0) == Current.zero() + assert Current.from_milliamperes(0.0) == Current.zero() + assert isinstance(Current.zero(), Current) + assert Current.zero().as_amperes() == 0.0 + assert Current.zero().as_milliamperes() == 0.0 + assert Current.zero() is Current.zero() # It is a "singleton" + + assert Voltage.from_volts(0.0) == Voltage.zero() + assert Voltage.from_kilovolts(0.0) == Voltage.zero() + assert isinstance(Voltage.zero(), Voltage) + assert Voltage.zero().as_volts() == 0.0 + assert Voltage.zero().as_kilovolts() == 0.0 + assert Voltage.zero() is Voltage.zero() # It is a "singleton" + + assert Energy.from_kilowatt_hours(0.0) == Energy.zero() + assert Energy.from_megawatt_hours(0.0) == Energy.zero() + assert isinstance(Energy.zero(), Energy) + assert Energy.zero().as_kilowatt_hours() == 0.0 + assert Energy.zero().as_megawatt_hours() == 0.0 + assert Energy.zero() is Energy.zero() # It is a "singleton" + + assert Frequency.from_hertz(0.0) == Frequency.zero() + assert Frequency.from_megahertz(0.0) == Frequency.zero() + assert isinstance(Frequency.zero(), Frequency) + assert Frequency.zero().as_hertz() == 0.0 + assert Frequency.zero().as_megahertz() == 0.0 + assert Frequency.zero() is Frequency.zero() # It is a "singleton" + + assert Percentage.from_percent(0.0) == Percentage.zero() + assert Percentage.from_fraction(0.0) == Percentage.zero() + assert isinstance(Percentage.zero(), Percentage) + assert Percentage.zero().as_percent() == 0.0 + assert Percentage.zero().as_fraction() == 0.0 + assert Percentage.zero() is Percentage.zero() # It is a "singleton" + + def test_string_representation() -> None: """Test the string representation of the quantities.""" assert str(Quantity(1.024445, exponent=0)) == "1.024" @@ -91,6 +148,12 @@ def test_string_representation() -> None: assert f"{Fz1(-20000)}" == "-20 kHz" +def test_isclose() -> None: + """Test the isclose method of the quantities.""" + assert Fz1(1.024445).isclose(Fz1(1.024445)) + assert not Fz1(1.024445).isclose(Fz1(1.0)) + + def test_addition_subtraction() -> None: """Test the addition and subtraction of the quantities.""" assert Quantity(1) + Quantity(1, exponent=0) == Quantity(2, exponent=0) @@ -105,6 +168,15 @@ def test_addition_subtraction() -> None: assert Fz1(1) - Fz2(1) # type: ignore assert excinfo.value.args[0] == "unsupported operand type(s) for -: 'Fz1' and 'Fz2'" + fz1 = Fz1(1.0) + fz1 += Fz1(4.0) + assert fz1 == Fz1(5.0) + fz1 -= Fz1(9.0) + assert fz1 == Fz1(-4.0) + + with pytest.raises(TypeError) as excinfo: + fz1 += Fz2(1.0) # type: ignore + def test_comparison() -> None: """Test the comparison of the quantities.""" @@ -249,6 +321,19 @@ def test_energy() -> None: Energy(1.0, exponent=0) +def test_temperature() -> None: + """Test the temperature class.""" + temp = Temperature.from_celsius(30.4) + assert f"{temp}" == "30.4 °C" + + assert temp.as_celsius() == 30.4 + assert temp != Temperature.from_celsius(5.0) + + with pytest.raises(TypeError): + # using the default constructor should raise. + Temperature(1.0, exponent=0) + + def test_quantity_compositions() -> None: """Test the composition of quantities.""" power = Power.from_watts(1000.0) @@ -377,3 +462,68 @@ def test_abs() -> None: pct = Percentage.from_fraction(30) assert abs(pct) == Percentage.from_fraction(30) assert abs(-pct) == Percentage.from_fraction(30) + + +def test_quantity_multiplied_with_precentage() -> None: + """Test the multiplication of all quantities with percentage.""" + percentage = Percentage.from_percent(50) + power = Power.from_watts(1000.0) + voltage = Voltage.from_volts(230.0) + current = Current.from_amperes(2) + energy = Energy.from_kilowatt_hours(12) + percentage_ = Percentage.from_percent(50) + + assert power * percentage == Power.from_watts(500.0) + assert voltage * percentage == Voltage.from_volts(115.0) + assert current * percentage == Current.from_amperes(1) + assert energy * percentage == Energy.from_kilowatt_hours(6) + assert percentage_ * percentage == Percentage.from_percent(25) + + power *= percentage + assert power == Power.from_watts(500.0) + voltage *= percentage + assert voltage == Voltage.from_volts(115.0) + current *= percentage + assert current == Current.from_amperes(1) + energy *= percentage + assert energy == Energy.from_kilowatt_hours(6) + percentage_ *= percentage + assert percentage_ == Percentage.from_percent(25) + + +def test_invalid_multiplications() -> None: + """Test the multiplication of quantities with invalid quantities.""" + power = Power.from_watts(1000.0) + voltage = Voltage.from_volts(230.0) + current = Current.from_amperes(2) + energy = Energy.from_kilowatt_hours(12) + + for quantity in [power, voltage, current, energy]: + with pytest.raises(TypeError): + _ = power * quantity # type: ignore + with pytest.raises(TypeError): + power *= quantity # type: ignore + + for quantity in [voltage, power, energy]: + with pytest.raises(TypeError): + _ = voltage * quantity # type: ignore + with pytest.raises(TypeError): + voltage *= quantity # type: ignore + + for quantity in [current, power, energy]: + with pytest.raises(TypeError): + _ = current * quantity # type: ignore + with pytest.raises(TypeError): + current *= quantity # type: ignore + + for quantity in [energy, power, voltage, current]: + with pytest.raises(TypeError): + _ = energy * quantity # type: ignore + with pytest.raises(TypeError): + energy *= quantity # type: ignore + + for quantity in [power, voltage, current, energy, Percentage.from_percent(50)]: + with pytest.raises(TypeError): + _ = quantity * 200.0 # type: ignore + with pytest.raises(TypeError): + quantity *= 200.0 # type: ignore diff --git a/tests/timeseries/test_resampling.py b/tests/timeseries/test_resampling.py index c01b0c07a..6f8ab5a6e 100644 --- a/tests/timeseries/test_resampling.py +++ b/tests/timeseries/test_resampling.py @@ -7,6 +7,7 @@ from __future__ import annotations +import asyncio import logging from datetime import datetime, timedelta, timezone from typing import AsyncIterator, Iterator @@ -63,6 +64,19 @@ async def source_chan() -> AsyncIterator[Broadcast[Sample[Quantity]]]: await chan.close() +async def _advance_time(fake_time: time_machine.Coordinates, seconds: float) -> None: + """Advance the time by the given number of seconds. + + This advances both the wall clock and the time machine fake time. + + Args: + fake_time: The time machine fake time. + seconds: The number of seconds to advance the time by. + """ + await asyncio.sleep(seconds) + fake_time.shift(seconds) + + async def _assert_no_more_samples( # pylint: disable=too-many-arguments resampler: Resampler, initial_time: datetime, @@ -76,7 +90,7 @@ async def _assert_no_more_samples( # pylint: disable=too-many-arguments # Resample 3 more times making sure no more valid samples are used for i in range(3): # Third resampling run (no more samples) - fake_time.shift(resampling_period_s) + await _advance_time(fake_time, resampling_period_s) await resampler.resample(one_shot=True) iteration_delta = resampling_period_s * (current_iteration + i) @@ -154,7 +168,7 @@ async def test_helper_buffer_too_big( for i in range(DEFAULT_BUFFER_LEN_MAX + 1): sample = Sample(datetime.now(timezone.utc), Quantity(i)) helper.add_sample(sample) - fake_time.shift(1) + await _advance_time(fake_time, 1) _ = helper.resample(datetime.now(timezone.utc)) # Ignore errors produced by wrongly finalized gRPC server in unrelated tests @@ -177,35 +191,47 @@ async def test_helper_buffer_too_big( 1.0, datetime(2020, 1, 1, 2, 3, 5, 300000, tzinfo=timezone.utc), datetime(2020, 1, 1, tzinfo=timezone.utc), - datetime(2020, 1, 1, 2, 3, 6, tzinfo=timezone.utc), + ( + datetime(2020, 1, 1, 2, 3, 7, tzinfo=timezone.utc), + timedelta(seconds=0.7), + ), ), ( 3.0, datetime(2020, 1, 1, 2, 3, 5, 300000, tzinfo=timezone.utc), datetime(2020, 1, 1, 0, 0, 5, tzinfo=timezone.utc), - datetime(2020, 1, 1, 2, 3, 8, tzinfo=timezone.utc), + ( + datetime(2020, 1, 1, 2, 3, 11, tzinfo=timezone.utc), + timedelta(seconds=2.7), + ), ), ( 10.0, datetime(2020, 1, 1, 2, 3, 5, 300000, tzinfo=timezone.utc), datetime(2020, 1, 1, 0, 0, 5, tzinfo=timezone.utc), - datetime(2020, 1, 1, 2, 3, 15, tzinfo=timezone.utc), + ( + datetime(2020, 1, 1, 2, 3, 25, tzinfo=timezone.utc), + timedelta(seconds=9.7), + ), ), # Future align_to ( 10.0, datetime(2020, 1, 1, 2, 3, 5, 300000, tzinfo=timezone.utc), datetime(2020, 1, 1, 2, 3, 18, tzinfo=timezone.utc), - datetime(2020, 1, 1, 2, 3, 8, tzinfo=timezone.utc), + ( + datetime(2020, 1, 1, 2, 3, 18, tzinfo=timezone.utc), + timedelta(seconds=2.7), + ), ), ), ) -def test_calculate_window_end_trivial_cases( +async def test_calculate_window_end_trivial_cases( fake_time: time_machine.Coordinates, resampling_period_s: float, now: datetime, align_to: datetime, - result: datetime, + result: tuple[datetime, timedelta], ) -> None: """Test the calculation of the resampling window end for simple cases.""" resampling_period = timedelta(seconds=resampling_period_s) @@ -235,11 +261,10 @@ def test_calculate_window_end_trivial_cases( ) fake_time.move_to(now) # pylint: disable=protected-access - assert ( - resampler_now._calculate_window_end() == resampler_none._calculate_window_end() - ) + none_result = resampler_none._calculate_window_end() + assert resampler_now._calculate_window_end() == none_result # pylint: disable=protected-access - assert resampler_none._calculate_window_end() == now + resampling_period + assert none_result[0] == now + resampling_period async def test_resampling_window_size_is_constant( @@ -283,10 +308,13 @@ async def test_resampling_window_size_is_constant( sample1s = Sample(timestamp + timedelta(seconds=1), value=Quantity(12.0)) await source_sender.send(sample0s) await source_sender.send(sample1s) - fake_time.shift(resampling_period_s) # timer matches resampling period + await _advance_time( + fake_time, resampling_period_s + ) # timer matches resampling period await resampler.resample(one_shot=True) assert datetime.now(timezone.utc).timestamp() == 2 + assert asyncio.get_event_loop().time() == 2 sink_mock.assert_called_once_with( Sample( timestamp + timedelta(seconds=resampling_period_s), @@ -306,7 +334,9 @@ async def test_resampling_window_size_is_constant( await source_sender.send(sample2_5s) await source_sender.send(sample3s) await source_sender.send(sample4s) - fake_time.shift(resampling_period_s + 0.5) # Timer fired with some delay + await _advance_time( + fake_time, resampling_period_s + 0.5 + ) # Timer fired with some delay await resampler.resample(one_shot=True) assert datetime.now(timezone.utc).timestamp() == 4.5 @@ -325,7 +355,7 @@ async def test_resampling_window_size_is_constant( resampling_fun_mock.reset_mock() -async def test_timer_errors_are_logged( +async def test_timer_errors_are_logged( # pylint: disable=too-many-statements fake_time: time_machine.Coordinates, source_chan: Broadcast[Sample[Quantity]], caplog: pytest.LogCaptureFixture, @@ -357,21 +387,27 @@ async def test_timer_errors_are_logged( # Test timeline # - # t(s) 0 1 2 2.5 3 4 - # |----------|----------R----|-----|----------R-----> (no more samples) - # value 5.0 12.0 2.0 4.0 5.0 + # trigger T = 2.0 T = 4.1998 T = 6.3998 + # t(s) 0 1 2 2.5 3 4|4.5 5 6 | + # |-----|-----R--|--|-----R+-|--|-----R---+---> (no more samples) + # value 5.0 12.0 2.0 4.0 5.0 2.0 4.0 5.0 # # R = resampling is done + # T = timer tick # Send a few samples and run a resample tick, advancing the fake time by one period + # No log message should be produced sample0s = Sample(timestamp, value=Quantity(5.0)) sample1s = Sample(timestamp + timedelta(seconds=1.0), value=Quantity(12.0)) await source_sender.send(sample0s) await source_sender.send(sample1s) - fake_time.shift(resampling_period_s * 1.0999) # Timer is delayed 9.99% + # Here we need to advance only the wall clock because the resampler timer is not yet + # started, otherwise the loop time will be advanced twice + await _advance_time(fake_time, resampling_period_s) await resampler.resample(one_shot=True) - assert datetime.now(timezone.utc).timestamp() == pytest.approx(2.1998) + assert datetime.now(timezone.utc).timestamp() == pytest.approx(2) + assert asyncio.get_running_loop().time() == pytest.approx(2) sink_mock.assert_called_once_with( Sample( timestamp + timedelta(seconds=resampling_period_s), @@ -390,17 +426,20 @@ async def test_timer_errors_are_logged( sink_mock.reset_mock() resampling_fun_mock.reset_mock() - # Second resampling run, now with 10% delay + # Second resampling run, now with 9.99% delay sample2_5s = Sample(timestamp + timedelta(seconds=2.5), value=Quantity(2.0)) sample3s = Sample(timestamp + timedelta(seconds=3), value=Quantity(4.0)) sample4s = Sample(timestamp + timedelta(seconds=4), value=Quantity(5.0)) await source_sender.send(sample2_5s) await source_sender.send(sample3s) await source_sender.send(sample4s) - fake_time.shift(resampling_period_s * 1.10) # Timer delayed 10% + await _advance_time( + fake_time, resampling_period_s * 1.0999 + ) # Timer is delayed 9.99% await resampler.resample(one_shot=True) - assert datetime.now(timezone.utc).timestamp() == pytest.approx(2.1998 + 2.2) + assert datetime.now(timezone.utc).timestamp() == pytest.approx(4.1998) + assert asyncio.get_running_loop().time() == pytest.approx(4.1998) sink_mock.assert_called_once_with( Sample( # But the sample still gets 4s as timestamp, because we are keeping @@ -414,12 +453,46 @@ async def test_timer_errors_are_logged( config, source_props, ) + assert not [ + *_filter_logs( + caplog.record_tuples, + logger_level=logging.WARNING, + ) + ] + sink_mock.reset_mock() + resampling_fun_mock.reset_mock() + + # Third resampling run, now with 10% delay + sample4_5s = Sample(timestamp + timedelta(seconds=4.5), value=Quantity(2.0)) + sample5s = Sample(timestamp + timedelta(seconds=5), value=Quantity(4.0)) + sample6s = Sample(timestamp + timedelta(seconds=6), value=Quantity(5.0)) + await source_sender.send(sample4_5s) + await source_sender.send(sample5s) + await source_sender.send(sample6s) + await _advance_time(fake_time, resampling_period_s * 1.10) # Timer delayed 10% + await resampler.resample(one_shot=True) + + assert datetime.now(timezone.utc).timestamp() == pytest.approx(6.3998) + assert asyncio.get_running_loop().time() == pytest.approx(6.3998) + sink_mock.assert_called_once_with( + Sample( + # But the sample still gets 4s as timestamp, because we are keeping + # the window size constant, not dependent on when the timer fired + timestamp + timedelta(seconds=resampling_period_s * 3), + Quantity(expected_resampled_value), + ) + ) + resampling_fun_mock.assert_called_once_with( + a_sequence(sample3s, sample4s, sample4_5s, sample5s, sample6s), + config, + source_props, + ) assert ( "frequenz.sdk.timeseries._resampling", logging.WARNING, "The resampling task woke up too late. Resampling should have started at " - "1970-01-01 00:00:04+00:00, but it started at 1970-01-01 " - "00:00:04.399800+00:00 (tolerance: 0:00:00.200000, difference: " + "1970-01-01 00:00:06+00:00, but it started at 1970-01-01 " + "00:00:06.399800+00:00 (tolerance: 0:00:00.200000, difference: " "0:00:00.399800; resampling period: 0:00:02)", ) in _filter_logs(caplog.record_tuples, logger_level=logging.WARNING) sink_mock.reset_mock() @@ -470,7 +543,7 @@ async def test_future_samples_not_included( await source_sender.send(sample0s) await source_sender.send(sample1s) await source_sender.send(sample2_1s) - fake_time.shift(resampling_period_s) + await _advance_time(fake_time, resampling_period_s) await resampler.resample(one_shot=True) assert datetime.now(timezone.utc).timestamp() == 2 @@ -495,7 +568,7 @@ async def test_future_samples_not_included( sample4_1s = Sample(timestamp + timedelta(seconds=4.1), value=Quantity(3.0)) await source_sender.send(sample3s) await source_sender.send(sample4_1s) - fake_time.shift(resampling_period_s + 0.2) + await _advance_time(fake_time, resampling_period_s + 0.2) await resampler.resample(one_shot=True) assert datetime.now(timezone.utc).timestamp() == 4.2 @@ -532,13 +605,13 @@ async def test_resampling_with_one_window( ) resampler = Resampler(config) - source_recvr = source_chan.new_receiver() - source_sendr = source_chan.new_sender() + source_receiver = source_chan.new_receiver() + source_sender = source_chan.new_sender() sink_mock = AsyncMock(spec=Sink, return_value=True) - resampler.add_timeseries("test", source_recvr, sink_mock) - source_props = resampler.get_source_properties(source_recvr) + resampler.add_timeseries("test", source_receiver, sink_mock) + source_props = resampler.get_source_properties(source_receiver) # Test timeline # @@ -551,9 +624,9 @@ async def test_resampling_with_one_window( # Send a few samples and run a resample tick, advancing the fake time by one period sample0s = Sample(timestamp, value=Quantity(5.0)) sample1s = Sample(timestamp + timedelta(seconds=1), value=Quantity(12.0)) - await source_sendr.send(sample0s) - await source_sendr.send(sample1s) - fake_time.shift(resampling_period_s) + await source_sender.send(sample0s) + await source_sender.send(sample1s) + await _advance_time(fake_time, resampling_period_s) await resampler.resample(one_shot=True) assert datetime.now(timezone.utc).timestamp() == 2 @@ -569,7 +642,7 @@ async def test_resampling_with_one_window( assert source_props == SourceProperties( sampling_start=timestamp, received_samples=2, sampling_period=None ) - assert _get_buffer_len(resampler, source_recvr) == config.initial_buffer_len + assert _get_buffer_len(resampler, source_receiver) == config.initial_buffer_len sink_mock.reset_mock() resampling_fun_mock.reset_mock() @@ -577,10 +650,10 @@ async def test_resampling_with_one_window( sample2_5s = Sample(timestamp + timedelta(seconds=2.5), value=Quantity(2.0)) sample3s = Sample(timestamp + timedelta(seconds=3), value=Quantity(4.0)) sample4s = Sample(timestamp + timedelta(seconds=4), value=Quantity(5.0)) - await source_sendr.send(sample2_5s) - await source_sendr.send(sample3s) - await source_sendr.send(sample4s) - fake_time.shift(resampling_period_s) + await source_sender.send(sample2_5s) + await source_sender.send(sample3s) + await source_sender.send(sample4s) + await _advance_time(fake_time, resampling_period_s) await resampler.resample(one_shot=True) assert datetime.now(timezone.utc).timestamp() == 4 @@ -602,7 +675,7 @@ async def test_resampling_with_one_window( ) # The buffer should be able to hold 2 seconds of data, and data is coming # every 0.8 seconds, so we should be able to store 3 samples. - assert _get_buffer_len(resampler, source_recvr) == 3 + assert _get_buffer_len(resampler, source_receiver) == 3 sink_mock.reset_mock() resampling_fun_mock.reset_mock() @@ -620,7 +693,7 @@ async def test_resampling_with_one_window( received_samples=5, sampling_period=timedelta(seconds=0.8), ) - assert _get_buffer_len(resampler, source_recvr) == 3 + assert _get_buffer_len(resampler, source_receiver) == 3 # Even when a lot could be refactored to use smaller functions, I'm allowing @@ -646,13 +719,13 @@ async def test_resampling_with_one_and_a_half_windows( # pylint: disable=too-ma ) resampler = Resampler(config) - source_recvr = source_chan.new_receiver() - source_sendr = source_chan.new_sender() + source_receiver = source_chan.new_receiver() + source_sender = source_chan.new_sender() sink_mock = AsyncMock(spec=Sink, return_value=True) - resampler.add_timeseries("test", source_recvr, sink_mock) - source_props = resampler.get_source_properties(source_recvr) + resampler.add_timeseries("test", source_receiver, sink_mock) + source_props = resampler.get_source_properties(source_receiver) # Test timeline # @@ -665,9 +738,9 @@ async def test_resampling_with_one_and_a_half_windows( # pylint: disable=too-ma # Send a few samples and run a resample tick, advancing the fake time by one period sample0s = Sample(timestamp, value=Quantity(5.0)) sample1s = Sample(timestamp + timedelta(seconds=1), value=Quantity(12.0)) - await source_sendr.send(sample0s) - await source_sendr.send(sample1s) - fake_time.shift(resampling_period_s) + await source_sender.send(sample0s) + await source_sender.send(sample1s) + await _advance_time(fake_time, resampling_period_s) await resampler.resample(one_shot=True) assert datetime.now(timezone.utc).timestamp() == 2 @@ -683,7 +756,7 @@ async def test_resampling_with_one_and_a_half_windows( # pylint: disable=too-ma assert source_props == SourceProperties( sampling_start=timestamp, received_samples=2, sampling_period=None ) - assert _get_buffer_len(resampler, source_recvr) == config.initial_buffer_len + assert _get_buffer_len(resampler, source_receiver) == config.initial_buffer_len sink_mock.reset_mock() resampling_fun_mock.reset_mock() @@ -691,10 +764,10 @@ async def test_resampling_with_one_and_a_half_windows( # pylint: disable=too-ma sample2_5s = Sample(timestamp + timedelta(seconds=2.5), value=Quantity(2.0)) sample3s = Sample(timestamp + timedelta(seconds=3), value=Quantity(4.0)) sample4s = Sample(timestamp + timedelta(seconds=4), value=Quantity(5.0)) - await source_sendr.send(sample2_5s) - await source_sendr.send(sample3s) - await source_sendr.send(sample4s) - fake_time.shift(resampling_period_s) + await source_sender.send(sample2_5s) + await source_sender.send(sample3s) + await source_sender.send(sample4s) + await _advance_time(fake_time, resampling_period_s) await resampler.resample(one_shot=True) assert datetime.now(timezone.utc).timestamp() == 4 @@ -711,16 +784,16 @@ async def test_resampling_with_one_and_a_half_windows( # pylint: disable=too-ma assert source_props == SourceProperties( sampling_start=timestamp, received_samples=5, sampling_period=None ) - assert _get_buffer_len(resampler, source_recvr) == config.initial_buffer_len + assert _get_buffer_len(resampler, source_receiver) == config.initial_buffer_len sink_mock.reset_mock() resampling_fun_mock.reset_mock() # Third resampling run sample5s = Sample(timestamp + timedelta(seconds=5), value=Quantity(1.0)) sample6s = Sample(timestamp + timedelta(seconds=6), value=Quantity(3.0)) - await source_sendr.send(sample5s) - await source_sendr.send(sample6s) - fake_time.shift(resampling_period_s) + await source_sender.send(sample5s) + await source_sender.send(sample6s) + await _advance_time(fake_time, resampling_period_s) await resampler.resample(one_shot=True) assert datetime.now(timezone.utc).timestamp() == 6 @@ -744,12 +817,12 @@ async def test_resampling_with_one_and_a_half_windows( # pylint: disable=too-ma # The buffer should be able to hold 2 * 1.5 (3) seconds of data, and data # is coming every 6/7 seconds (~0.857s), so we should be able to store # 4 samples. - assert _get_buffer_len(resampler, source_recvr) == 4 + assert _get_buffer_len(resampler, source_receiver) == 4 sink_mock.reset_mock() resampling_fun_mock.reset_mock() # Fourth resampling run - fake_time.shift(resampling_period_s) + await _advance_time(fake_time, resampling_period_s) await resampler.resample(one_shot=True) assert datetime.now(timezone.utc).timestamp() == 8 @@ -782,7 +855,7 @@ async def test_resampling_with_one_and_a_half_windows( # pylint: disable=too-ma received_samples=7, sampling_period=timedelta(seconds=6 / 7), ) - assert _get_buffer_len(resampler, source_recvr) == 4 + assert _get_buffer_len(resampler, source_receiver) == 4 # Even when a lot could be refactored to use smaller functions, I'm allowing @@ -808,13 +881,13 @@ async def test_resampling_with_two_windows( # pylint: disable=too-many-statemen ) resampler = Resampler(config) - source_recvr = source_chan.new_receiver() - source_sendr = source_chan.new_sender() + source_receiver = source_chan.new_receiver() + source_sender = source_chan.new_sender() sink_mock = AsyncMock(spec=Sink, return_value=True) - resampler.add_timeseries("test", source_recvr, sink_mock) - source_props = resampler.get_source_properties(source_recvr) + resampler.add_timeseries("test", source_receiver, sink_mock) + source_props = resampler.get_source_properties(source_receiver) # Test timeline # @@ -827,9 +900,9 @@ async def test_resampling_with_two_windows( # pylint: disable=too-many-statemen # Send a few samples and run a resample tick, advancing the fake time by one period sample0s = Sample(timestamp, value=Quantity(5.0)) sample1s = Sample(timestamp + timedelta(seconds=1), value=Quantity(12.0)) - await source_sendr.send(sample0s) - await source_sendr.send(sample1s) - fake_time.shift(resampling_period_s) + await source_sender.send(sample0s) + await source_sender.send(sample1s) + await _advance_time(fake_time, resampling_period_s) await resampler.resample(one_shot=True) assert datetime.now(timezone.utc).timestamp() == 2 @@ -845,7 +918,7 @@ async def test_resampling_with_two_windows( # pylint: disable=too-many-statemen assert source_props == SourceProperties( sampling_start=timestamp, received_samples=2, sampling_period=None ) - assert _get_buffer_len(resampler, source_recvr) == config.initial_buffer_len + assert _get_buffer_len(resampler, source_receiver) == config.initial_buffer_len sink_mock.reset_mock() resampling_fun_mock.reset_mock() @@ -853,10 +926,10 @@ async def test_resampling_with_two_windows( # pylint: disable=too-many-statemen sample2_5s = Sample(timestamp + timedelta(seconds=2.5), value=Quantity(2.0)) sample3s = Sample(timestamp + timedelta(seconds=3), value=Quantity(4.0)) sample4s = Sample(timestamp + timedelta(seconds=4), value=Quantity(5.0)) - await source_sendr.send(sample2_5s) - await source_sendr.send(sample3s) - await source_sendr.send(sample4s) - fake_time.shift(resampling_period_s) + await source_sender.send(sample2_5s) + await source_sender.send(sample3s) + await source_sender.send(sample4s) + await _advance_time(fake_time, resampling_period_s) await resampler.resample(one_shot=True) assert datetime.now(timezone.utc).timestamp() == 4 @@ -873,16 +946,16 @@ async def test_resampling_with_two_windows( # pylint: disable=too-many-statemen assert source_props == SourceProperties( sampling_start=timestamp, received_samples=5, sampling_period=None ) - assert _get_buffer_len(resampler, source_recvr) == config.initial_buffer_len + assert _get_buffer_len(resampler, source_receiver) == config.initial_buffer_len sink_mock.reset_mock() resampling_fun_mock.reset_mock() # Third resampling run sample5s = Sample(timestamp + timedelta(seconds=5), value=Quantity(1.0)) sample6s = Sample(timestamp + timedelta(seconds=6), value=Quantity(3.0)) - await source_sendr.send(sample5s) - await source_sendr.send(sample6s) - fake_time.shift(resampling_period_s) + await source_sender.send(sample5s) + await source_sender.send(sample6s) + await _advance_time(fake_time, resampling_period_s) await resampler.resample(one_shot=True) assert datetime.now(timezone.utc).timestamp() == 6 @@ -901,12 +974,12 @@ async def test_resampling_with_two_windows( # pylint: disable=too-many-statemen assert source_props == SourceProperties( sampling_start=timestamp, received_samples=7, sampling_period=None ) - assert _get_buffer_len(resampler, source_recvr) == config.initial_buffer_len + assert _get_buffer_len(resampler, source_receiver) == config.initial_buffer_len sink_mock.reset_mock() resampling_fun_mock.reset_mock() # Fourth resampling run - fake_time.shift(resampling_period_s) + await _advance_time(fake_time, resampling_period_s) await resampler.resample(one_shot=True) assert datetime.now(timezone.utc).timestamp() == 8 @@ -923,7 +996,7 @@ async def test_resampling_with_two_windows( # pylint: disable=too-many-statemen assert source_props == SourceProperties( sampling_start=timestamp, received_samples=7, sampling_period=None ) - assert _get_buffer_len(resampler, source_recvr) == config.initial_buffer_len + assert _get_buffer_len(resampler, source_receiver) == config.initial_buffer_len sink_mock.reset_mock() resampling_fun_mock.reset_mock() @@ -939,7 +1012,7 @@ async def test_resampling_with_two_windows( # pylint: disable=too-many-statemen assert source_props == SourceProperties( sampling_start=timestamp, received_samples=7, sampling_period=None ) - assert _get_buffer_len(resampler, source_recvr) == config.initial_buffer_len + assert _get_buffer_len(resampler, source_receiver) == config.initial_buffer_len async def test_receiving_stopped_resampling_error( @@ -961,18 +1034,18 @@ async def test_receiving_stopped_resampling_error( ) resampler = Resampler(config) - source_recvr = source_chan.new_receiver() - source_sendr = source_chan.new_sender() + source_receiver = source_chan.new_receiver() + source_sender = source_chan.new_sender() sink_mock = AsyncMock(spec=Sink, return_value=True) - resampler.add_timeseries("test", source_recvr, sink_mock) - source_props = resampler.get_source_properties(source_recvr) + resampler.add_timeseries("test", source_receiver, sink_mock) + source_props = resampler.get_source_properties(source_receiver) # Send a sample and run a resample tick, advancing the fake time by one period sample0s = Sample(timestamp, value=Quantity(5.0)) - await source_sendr.send(sample0s) - fake_time.shift(resampling_period_s) + await source_sender.send(sample0s) + await _advance_time(fake_time, resampling_period_s) await resampler.resample(one_shot=True) assert datetime.now(timezone.utc).timestamp() == 2 @@ -991,18 +1064,18 @@ async def test_receiving_stopped_resampling_error( # Close channel, try to resample again await source_chan.close() with pytest.raises(SenderError): - await source_sendr.send(sample0s) - fake_time.shift(resampling_period_s) + await source_sender.send(sample0s) + await _advance_time(fake_time, resampling_period_s) with pytest.raises(ResamplingError) as excinfo: await resampler.resample(one_shot=True) assert datetime.now(timezone.utc).timestamp() == 4 exceptions = excinfo.value.exceptions assert len(exceptions) == 1 - assert source_recvr in exceptions - timeseries_error = exceptions[source_recvr] + assert source_receiver in exceptions + timeseries_error = exceptions[source_receiver] assert isinstance(timeseries_error, SourceStoppedError) - assert timeseries_error.source is source_recvr + assert timeseries_error.source is source_receiver async def test_receiving_resampling_error(fake_time: time_machine.Coordinates) -> None: @@ -1038,7 +1111,7 @@ async def make_fake_source() -> Source: resampler.add_timeseries("test", fake_source, sink_mock) # Try to resample - fake_time.shift(resampling_period_s) + await _advance_time(fake_time, resampling_period_s) with pytest.raises(ResamplingError) as excinfo: await resampler.resample(one_shot=True) @@ -1050,9 +1123,91 @@ async def make_fake_source() -> Source: assert isinstance(timeseries_error, TestException) -def _get_buffer_len(resampler: Resampler, source_recvr: Source) -> int: +async def test_timer_is_aligned( + fake_time: time_machine.Coordinates, + source_chan: Broadcast[Sample[Quantity]], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that big differences between the expected window end and the fired timer are logged.""" + timestamp = datetime.now(timezone.utc) + + resampling_period_s = 2 + expected_resampled_value = 42.0 + + resampling_fun_mock = MagicMock( + spec=ResamplingFunction, return_value=expected_resampled_value + ) + config = ResamplerConfig( + resampling_period=timedelta(seconds=resampling_period_s), + max_data_age_in_periods=2.0, + resampling_function=resampling_fun_mock, + initial_buffer_len=5, + ) + + # Advance the time a bit so that the resampler is not aligned to the resampling + # period + await _advance_time(fake_time, resampling_period_s / 3) + + resampler = Resampler(config) + + source_receiver = source_chan.new_receiver() + source_sender = source_chan.new_sender() + + sink_mock = AsyncMock(spec=Sink, return_value=True) + + resampler.add_timeseries("test", source_receiver, sink_mock) + source_props = resampler.get_source_properties(source_receiver) + + # Test timeline + # start delay timer start + # ,-------------|---------------------| + # start = 0.667 + # t(s) 0 | 1 1.5 2 2.5 3 4 + # |-------+--|-----|----|----|-----|----------R-----> (no more samples) + # value 5.0 12.0 2.0 4.0 5.0 + # + # R = resampling is done + + # Send samples and resample + sample1s = Sample(timestamp + timedelta(seconds=1.0), value=Quantity(5.0)) + sample1_5s = Sample(timestamp + timedelta(seconds=1.5), value=Quantity(12.0)) + sample2_5s = Sample(timestamp + timedelta(seconds=2.5), value=Quantity(2.0)) + sample3s = Sample(timestamp + timedelta(seconds=3), value=Quantity(4.0)) + sample4s = Sample(timestamp + timedelta(seconds=4), value=Quantity(5.0)) + await source_sender.send(sample1s) + await source_sender.send(sample1_5s) + await source_sender.send(sample2_5s) + await source_sender.send(sample3s) + await source_sender.send(sample4s) + await _advance_time(fake_time, resampling_period_s * (1 + 2 / 3)) + await resampler.resample(one_shot=True) + + assert datetime.now(timezone.utc).timestamp() == pytest.approx(4) + assert asyncio.get_running_loop().time() == pytest.approx(4) + sink_mock.assert_called_once_with( + Sample( + timestamp + timedelta(seconds=resampling_period_s * 2), + Quantity(expected_resampled_value), + ) + ) + resampling_fun_mock.assert_called_once_with( + a_sequence(sample1s, sample1_5s, sample2_5s, sample3s, sample4s), + config, + source_props, + ) + assert not [ + *_filter_logs( + caplog.record_tuples, + logger_level=logging.WARNING, + ) + ] + sink_mock.reset_mock() + resampling_fun_mock.reset_mock() + + +def _get_buffer_len(resampler: Resampler, source_receiver: Source) -> int: # pylint: disable=protected-access - blen = resampler._resamplers[source_recvr]._helper._buffer.maxlen + blen = resampler._resamplers[source_receiver]._helper._buffer.maxlen assert blen is not None return blen diff --git a/tests/timeseries/test_ringbuffer.py b/tests/timeseries/test_ringbuffer.py index 958a62cea..f1b4e6708 100644 --- a/tests/timeseries/test_ringbuffer.py +++ b/tests/timeseries/test_ringbuffer.py @@ -369,3 +369,46 @@ def test_off_by_one_gap_logic_bug() -> None: assert buffer.is_missing(times[0]) is False assert buffer.is_missing(times[1]) is False + + +def test_cleanup_oldest_gap_timestamp() -> None: + """Test that gaps are updated such that they are fully contained in the buffer.""" + buffer = OrderedRingBuffer( + np.empty(shape=15, dtype=float), + sampling_period=timedelta(seconds=1), + align_to=datetime(1, 1, 1, tzinfo=timezone.utc), + ) + + for i in range(10): + buffer.update( + Sample(datetime.fromtimestamp(200 + i, tz=timezone.utc), Quantity(i)) + ) + + gap = Gap( + datetime.fromtimestamp(195, tz=timezone.utc), + datetime.fromtimestamp(200, tz=timezone.utc), + ) + + assert gap == buffer.gaps[0] + + +def test_delete_oudated_gap() -> None: + """ + Update the buffer such that the gap is no longer valid. + We introduce two gaps and check that the oldest is removed. + """ + buffer = OrderedRingBuffer( + np.empty(shape=3, dtype=float), + sampling_period=timedelta(seconds=1), + align_to=datetime(1, 1, 1, tzinfo=timezone.utc), + ) + + for i in range(2): + buffer.update( + Sample(datetime.fromtimestamp(200 + i, tz=timezone.utc), Quantity(i)) + ) + assert len(buffer.gaps) == 1 + + buffer.update(Sample(datetime.fromtimestamp(202, tz=timezone.utc), Quantity(2))) + + assert len(buffer.gaps) == 0 diff --git a/tests/utils/component_data_wrapper.py b/tests/utils/component_data_wrapper.py index e83db3af1..9dd74e4e4 100644 --- a/tests/utils/component_data_wrapper.py +++ b/tests/utils/component_data_wrapper.py @@ -13,7 +13,7 @@ from __future__ import annotations import math -from dataclasses import dataclass, field, replace +from dataclasses import dataclass, replace from datetime import datetime from typing import Tuple @@ -29,25 +29,54 @@ MeterData, ) +# pylint: disable=no-member + -@dataclass(frozen=True) class BatteryDataWrapper(BatteryData): """Wrapper for the BatteryData with default arguments.""" - soc: float = math.nan - soc_lower_bound: float = math.nan - soc_upper_bound: float = math.nan - capacity: float = math.nan - power_lower_bound: float = math.nan - power_upper_bound: float = math.nan - temperature_max: float = math.nan - _relay_state: battery_pb.RelayState.ValueType = ( - battery_pb.RelayState.RELAY_STATE_UNSPECIFIED - ) - _component_state: battery_pb.ComponentState.ValueType = ( - battery_pb.ComponentState.COMPONENT_STATE_UNSPECIFIED - ) - _errors: list[battery_pb.Error] = field(default_factory=list) + def __init__( # pylint: disable=too-many-arguments + self, + component_id: int, + timestamp: datetime, + soc: float = math.nan, + soc_lower_bound: float = math.nan, + soc_upper_bound: float = math.nan, + capacity: float = math.nan, + power_inclusion_lower_bound: float = math.nan, + power_exclusion_lower_bound: float = math.nan, + power_inclusion_upper_bound: float = math.nan, + power_exclusion_upper_bound: float = math.nan, + temperature: float = math.nan, + _relay_state: battery_pb.RelayState.ValueType = ( + battery_pb.RelayState.RELAY_STATE_UNSPECIFIED + ), + _component_state: battery_pb.ComponentState.ValueType = ( + battery_pb.ComponentState.COMPONENT_STATE_UNSPECIFIED + ), + _errors: list[battery_pb.Error] | None = None, + ) -> None: + """Initialize the BatteryDataWrapper. + + This is a wrapper for the BatteryData with default arguments. The parameters are + documented in the BatteryData class. + """ + super().__init__( + component_id=component_id, + timestamp=timestamp, + soc=soc, + soc_lower_bound=soc_lower_bound, + soc_upper_bound=soc_upper_bound, + capacity=capacity, + power_inclusion_lower_bound=power_inclusion_lower_bound, + power_exclusion_lower_bound=power_exclusion_lower_bound, + power_inclusion_upper_bound=power_inclusion_upper_bound, + power_exclusion_upper_bound=power_exclusion_upper_bound, + temperature=temperature, + _relay_state=_relay_state, + _component_state=_component_state, + _errors=_errors if _errors else [], + ) def copy_with_new_timestamp(self, new_timestamp: datetime) -> BatteryDataWrapper: """Copy the component data but insert new timestamp. @@ -68,13 +97,36 @@ def copy_with_new_timestamp(self, new_timestamp: datetime) -> BatteryDataWrapper class InverterDataWrapper(InverterData): """Wrapper for the InverterData with default arguments.""" - active_power: float = math.nan - active_power_lower_bound: float = math.nan - active_power_upper_bound: float = math.nan - _component_state: inverter_pb.ComponentState.ValueType = ( - inverter_pb.ComponentState.COMPONENT_STATE_UNSPECIFIED - ) - _errors: list[inverter_pb.Error] = field(default_factory=list) + def __init__( # pylint: disable=too-many-arguments + self, + component_id: int, + timestamp: datetime, + active_power: float = math.nan, + active_power_inclusion_lower_bound: float = math.nan, + active_power_exclusion_lower_bound: float = math.nan, + active_power_inclusion_upper_bound: float = math.nan, + active_power_exclusion_upper_bound: float = math.nan, + _component_state: inverter_pb.ComponentState.ValueType = ( + inverter_pb.ComponentState.COMPONENT_STATE_UNSPECIFIED + ), + _errors: list[inverter_pb.Error] | None = None, + ) -> None: + """Initialize the InverterDataWrapper. + + This is a wrapper for the InverterData with default arguments. The parameters + are documented in the InverterData class. + """ + super().__init__( + component_id=component_id, + timestamp=timestamp, + active_power=active_power, + active_power_inclusion_lower_bound=active_power_inclusion_lower_bound, + active_power_exclusion_lower_bound=active_power_exclusion_lower_bound, + active_power_inclusion_upper_bound=active_power_inclusion_upper_bound, + active_power_exclusion_upper_bound=active_power_exclusion_upper_bound, + _component_state=_component_state, + _errors=_errors if _errors else [], + ) def copy_with_new_timestamp(self, new_timestamp: datetime) -> InverterDataWrapper: """Copy the component data but insert new timestamp. @@ -95,15 +147,38 @@ def copy_with_new_timestamp(self, new_timestamp: datetime) -> InverterDataWrappe class EvChargerDataWrapper(EVChargerData): """Wrapper for the EvChargerData with default arguments.""" - active_power: float = math.nan - current_per_phase: Tuple[float, float, float] = field( - default_factory=lambda: (math.nan, math.nan, math.nan) - ) - voltage_per_phase: Tuple[float, float, float] = field( - default_factory=lambda: (math.nan, math.nan, math.nan) - ) - cable_state: EVChargerCableState = EVChargerCableState.UNSPECIFIED - component_state: EVChargerComponentState = EVChargerComponentState.UNSPECIFIED + def __init__( # pylint: disable=too-many-arguments + self, + component_id: int, + timestamp: datetime, + active_power: float = math.nan, + current_per_phase: Tuple[float, float, float] | None = None, + voltage_per_phase: Tuple[float, float, float] | None = None, + cable_state: EVChargerCableState = EVChargerCableState.UNSPECIFIED, + component_state: EVChargerComponentState = EVChargerComponentState.UNSPECIFIED, + ) -> None: + """Initialize the EvChargerDataWrapper. + + This is a wrapper for the EvChargerData with default arguments. The parameters + are documented in the EvChargerData class. + """ + super().__init__( + component_id=component_id, + timestamp=timestamp, + active_power=active_power, + current_per_phase=( + current_per_phase + if current_per_phase + else (math.nan, math.nan, math.nan) + ), + voltage_per_phase=( + voltage_per_phase + if voltage_per_phase + else (math.nan, math.nan, math.nan) + ), + cable_state=cable_state, + component_state=component_state, + ) def copy_with_new_timestamp(self, new_timestamp: datetime) -> EvChargerDataWrapper: """Copy the component data but insert new timestamp. @@ -124,14 +199,36 @@ def copy_with_new_timestamp(self, new_timestamp: datetime) -> EvChargerDataWrapp class MeterDataWrapper(MeterData): """Wrapper for the MeterData with default arguments.""" - active_power: float = math.nan - current_per_phase: Tuple[float, float, float] = field( - default_factory=lambda: (math.nan, math.nan, math.nan) - ) - voltage_per_phase: Tuple[float, float, float] = field( - default_factory=lambda: (math.nan, math.nan, math.nan) - ) - frequency: float = math.nan + def __init__( # pylint: disable=too-many-arguments + self, + component_id: int, + timestamp: datetime, + active_power: float = math.nan, + current_per_phase: Tuple[float, float, float] | None = None, + voltage_per_phase: Tuple[float, float, float] | None = None, + frequency: float = math.nan, + ) -> None: + """Initialize the MeterDataWrapper. + + This is a wrapper for the MeterData with default arguments. The parameters are + documented in the MeterData class. + """ + super().__init__( + component_id=component_id, + timestamp=timestamp, + active_power=active_power, + current_per_phase=( + current_per_phase + if current_per_phase + else (math.nan, math.nan, math.nan) + ), + voltage_per_phase=( + voltage_per_phase + if voltage_per_phase + else (math.nan, math.nan, math.nan) + ), + frequency=frequency, + ) def copy_with_new_timestamp(self, new_timestamp: datetime) -> MeterDataWrapper: """Copy the component data but insert new timestamp.