diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f842ffd..05bf955 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,5 @@ # Frequenz Microgrid API Client Release Notes -## Bug Fixes +## New Features -- Fix a bug where SSL was enabled by default. It is now disabled by default as in previous versions. +- The client now supports setting reactive power for components through the new `set_reactive_power` method. diff --git a/pyproject.toml b/pyproject.toml index 2e75cf3..09f31d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,7 +73,9 @@ dev-mkdocs = [ ] dev-mypy = [ "mypy == 1.11.2", + "grpc-stubs == 1.53.0.5", "types-Markdown == 3.7.0.20240822", + "types-protobuf == 5.28.3.20241030", # For checking the noxfile, docs/ script, and tests "frequenz-client-microgrid[dev-mkdocs,dev-noxfile,dev-pytest]", ] diff --git a/src/frequenz/client/microgrid/_client.py b/src/frequenz/client/microgrid/_client.py index 7177538..e84a95c 100644 --- a/src/frequenz/client/microgrid/_client.py +++ b/src/frequenz/client/microgrid/_client.py @@ -441,7 +441,41 @@ async def set_power(self, component_id: int, power_w: float) -> None: grpc_error=grpc_error, ) from grpc_error - async def set_bounds( + async def set_reactive_power( # noqa: DOC502 (raises ApiClientError indirectly) + self, component_id: int, reactive_power_var: float + ) -> None: + """Send request to the Microgrid to set reactive power for component. + + Negative values are for inductive (lagging) power , and positive values are for + capacitive (leading) power. + + Args: + component_id: id of the component to set power. + reactive_power_var: reactive power to set for the component. + + Raises: + ApiClientError: If the are any errors communicating with the Microgrid API, + most likely a subclass of + [GrpcError][frequenz.client.microgrid.GrpcError]. + """ + try: + await cast( + Awaitable[Empty], + self.api.SetPowerReactive( + microgrid_pb2.SetPowerReactiveParam( + component_id=component_id, power=reactive_power_var + ), + timeout=int(DEFAULT_GRPC_CALL_TIMEOUT), + ), + ) + except grpc.aio.AioRpcError as grpc_error: + raise ApiClientError.from_grpc_error( + server_url=self._server_url, + operation="SetPowerReactive", + grpc_error=grpc_error, + ) from grpc_error + + async def set_bounds( # noqa: DOC503 (raises ApiClientError indirectly) self, component_id: int, lower: float, diff --git a/tests/test_client.py b/tests/test_client.py index ff0c308..5293d8d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -42,6 +42,7 @@ def __init__(self, *, retry_strategy: retry.Strategy | None = None) -> None: mock_stub.ListComponents = mock.AsyncMock("ListComponents") mock_stub.ListConnections = mock.AsyncMock("ListConnections") mock_stub.SetPowerActive = mock.AsyncMock("SetPowerActive") + mock_stub.SetPowerReactive = mock.AsyncMock("SetPowerReactive") mock_stub.AddInclusionBounds = mock.AsyncMock("AddInclusionBounds") mock_stub.StreamComponentData = mock.Mock("StreamComponentData") super().__init__("grpc://mock_host:1234", retry_strategy=retry_strategy) @@ -607,6 +608,48 @@ async def test_set_power_grpc_error() -> None: await client.set_power(component_id=83, power_w=100.0) +@pytest.mark.parametrize( + "reactive_power_var", + [0, 0.0, 12, -75, 0.1, -0.0001, 134.0], +) +async def test_set_reactive_power_ok( + reactive_power_var: float, meter83: microgrid_pb2.Component +) -> None: + """Test if charge is able to charge component.""" + client = _TestClient() + client.mock_stub.ListComponents.return_value = microgrid_pb2.ComponentList( + components=[meter83] + ) + + await client.set_reactive_power( + component_id=83, reactive_power_var=reactive_power_var + ) + client.mock_stub.SetPowerReactive.assert_called_once() + call_args = client.mock_stub.SetPowerReactive.call_args[0] + assert call_args[0] == microgrid_pb2.SetPowerReactiveParam( + component_id=83, power=reactive_power_var + ) + + +async def test_set_reactive_power_grpc_error() -> None: + """Test set_power() raises ApiClientError when the gRPC call fails.""" + client = _TestClient() + client.mock_stub.SetPowerReactive.side_effect = grpc.aio.AioRpcError( + mock.MagicMock(name="mock_status"), + mock.MagicMock(name="mock_initial_metadata"), + mock.MagicMock(name="mock_trailing_metadata"), + "fake grpc details", + "fake grpc debug_error_string", + ) + with pytest.raises( + ApiClientError, + match=r"Failed calling 'SetPowerReactive' on 'grpc://mock_host:1234': .* " + r">: fake grpc details " + r"\(fake grpc debug_error_string\)", + ): + await client.set_reactive_power(component_id=83, reactive_power_var=100.0) + + @pytest.mark.parametrize( "bounds", [