Fix type checking in the `timeseries._formula_engine` package
This is the package (several modules are affected) with the most type checking errors and which look the harder to fix, because there is a lot of generic code with complicated types.

_Originally posted by @leandro-lucarella-frequenz in https://github.com/frequenz-floss/frequenz-sdk-python/issues/219#issuecomment-1444080540_
diff --git a/noxfile.py b/noxfile.py
index 271f2d9..be43dcd 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -50,13 +50,29 @@ Usage:
         nox -R -e pytest_min -- -s -x tests/timeseries/test_logical_meter.py
 """
 
-from typing import List
+from __future__ import annotations
+
+from typing import Any, Iterable
 
 import nox
 import toml
 
+DEFAULT_PATH_PACKAGES = {
+    "benchmarks": "benchmarks",
+    "docs": "docs",
+    "examples": "examples",
+    "src": "frequenz.sdk",
+    "tests": "tests",
+    "noxfile.py": "noxfile",
+}
+"""A list of path to be used by default and its corresponding package name.
+
+The package name is needed for mypy, as it takes packages when full import
+checking needs to be done.
+"""
+
 
-def min_dependencies() -> List[str]:
+def min_dependencies() -> list[str]:
     """Extract the minimum dependencies from pyproject.toml.
 
     Raises:
@@ -74,7 +90,7 @@ def min_dependencies() -> List[str]:
     if not dependencies:
         raise RuntimeError(f"No dependencies found in file: {toml_file.name}")
 
-    min_deps: List[str] = []
+    min_deps: list[str] = []
     for dep in dependencies:
         min_dep = dep.split(",")[0]
         if any(op in min_dep for op in (">=", "==")):
@@ -84,7 +100,7 @@ def min_dependencies() -> List[str]:
     return min_deps
 
 
-def _source_file_paths(session: nox.Session) -> List[str]:
+def _source_file_paths(session: nox.Session) -> list[str]:
     """Return the file paths to run the checks on.
 
     If positional arguments are present in the nox session, we use those as the
@@ -98,14 +114,7 @@ def _source_file_paths(session: nox.Session) -> List[str]:
     """
     if session.posargs:
         return session.posargs
-    return [
-        "benchmarks",
-        "docs",
-        "examples",
-        "src",
-        "tests",
-        "noxfile.py",
-    ]
+    return list(DEFAULT_PATH_PACKAGES.keys())
 
 
 # Run all checks except `ci_checks` by default.  When running locally with just
@@ -131,7 +140,7 @@ def ci_checks_max(session: nox.Session) -> None:
     Args:
         session: the nox session.
     """
-    session.install(".[dev]")
+    session.install("-e", ".[dev]")
 
     formatting(session, False)
     mypy(session, False)
@@ -149,7 +158,7 @@ def formatting(session: nox.Session, install_deps: bool = True) -> None:
         install_deps: True if dependencies should be installed.
     """
     if install_deps:
-        session.install(".[format]")
+        session.install("-e", ".[format]")
 
     paths = _source_file_paths(session)
     session.run("black", "--check", *paths)
@@ -169,36 +178,24 @@ def mypy(session: nox.Session, install_deps: bool = True) -> None:
         # fast local tests with `nox -R -e mypy`.
         session.install("-e", ".[mypy]")
 
-    common_args = [
+    def _flatten(iterable: Iterable[Iterable[Any]]) -> Iterable[Any]:
+        return [item for sublist in iterable for item in sublist]
+
+    args = (
+        session.posargs
+        if session.posargs
+        else _flatten(("-p", p) for p in DEFAULT_PATH_PACKAGES.values())
+    )
+
+    session.run(
+        "mypy",
         "--install-types",
         "--namespace-packages",
         "--non-interactive",
         "--explicit-package-bases",
         "--strict",
-    ]
-
-    # If we have session arguments, we just use those...
-    if session.posargs:
-        session.run("mypy", *common_args, *session.posargs)
-        return
-
-    check_paths = _source_file_paths(session)
-    pkg_paths = [
-        path
-        for path in check_paths
-        if not path.startswith("src") and not path.endswith(".py")
-    ]
-    file_paths = [path for path in check_paths if path.endswith(".py")]
-
-    pkg_args = []
-    for pkg in pkg_paths:
-        if pkg == "src":
-            pkg = "frequenz.channels"
-        pkg_args.append("-p")
-        pkg_args.append(pkg)
-
-    session.run("mypy", *common_args, *pkg_args)
-    session.run("mypy", *common_args, *file_paths)
+        *args,
+    )
 
 
 @nox.session
@@ -231,7 +228,7 @@ def docstrings(session: nox.Session, install_deps: bool = True) -> None:
         install_deps: True if dependencies should be installed.
     """
     if install_deps:
-        session.install(".[docs-lint]")
+        session.install("-e", ".[docs-lint]")
 
     paths = _source_file_paths(session)
     session.run("pydocstyle", *paths)
diff --git a/pyproject.toml b/pyproject.toml
index bb895c1..957f9db 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -33,7 +33,6 @@ dependencies = [
     "networkx >= 2.8, < 3",
     "pandas >= 1.5.2, < 2",
     "protobuf >= 4.21.6, < 5",
-    "pyarrow >= 10.0.1, < 11",
     "pydantic >= 1.9",
     "sympy >= 1.10.1, < 2",
     "toml >= 0.10",
@@ -59,6 +58,7 @@ docs-gen = [
 docs-lint = [
     "pydocstyle",
     "darglint",
+    "tomli",  # Needed by pydocstyle to read pyproject.toml
 ]
 format = [
     "black",
@@ -135,10 +135,19 @@ src_paths = ["src", "examples", "tests"]
 asyncio_mode = "auto"
 required_plugins = [ "pytest-asyncio", "pytest-mock" ]
 
+[tool.mypy]
+# This is a temporary hack, for details see:
+# * https://github.com/frequenz-floss/frequenz-sdk-python/pull/219
+# * https://github.com/frequenz-floss/frequenz-sdk-python/issues/226
+exclude = ["src/frequenz/sdk/timeseries/_formula_engine/"]
+
 [[tool.mypy.overrides]]
 module = [
     "grpc.aio",
-    "grpc.aio.*"
+    "grpc.aio.*",
+    # There is a stubs package available, but it's not working:
+    # https://github.com/eggplants/networkx-stubs/issues/1
+    "networkx",
 ]
 ignore_missing_imports = true
 
diff --git a/src/frequenz/sdk/_internal/asyncio.py b/src/frequenz/sdk/_internal/asyncio.py
index 6d2cae9..f5fdbbf 100644
--- a/src/frequenz/sdk/_internal/asyncio.py
+++ b/src/frequenz/sdk/_internal/asyncio.py
@@ -3,11 +3,14 @@
 
 """General purpose async tools."""
 
+from __future__ import annotations
+
 import asyncio
 from abc import ABC
+from typing import Any
 
 
-async def cancel_and_await(task: asyncio.Task) -> None:
+async def cancel_and_await(task: asyncio.Task[Any]) -> None:
     """Cancel a task and wait for it to finish.
 
     The `CancelledError` is suppresed, but any other exception will be propagated.
diff --git a/src/frequenz/sdk/actor/__init__.py b/src/frequenz/sdk/actor/__init__.py
index abc94a2..6917895 100644
--- a/src/frequenz/sdk/actor/__init__.py
+++ b/src/frequenz/sdk/actor/__init__.py
@@ -3,11 +3,12 @@
 
 """A base class for creating simple composable actors."""
 
+from ..timeseries._resampling import ResamplerConfig
 from ._channel_registry import ChannelRegistry
 from ._config_managing import ConfigManagingActor
 from ._data_sourcing import ComponentMetricRequest, DataSourcingActor
 from ._decorator import actor
-from ._resampling import ComponentMetricsResamplingActor, ResamplerConfig
+from ._resampling import ComponentMetricsResamplingActor
 
 __all__ = [
     "ChannelRegistry",
diff --git a/src/frequenz/sdk/actor/_resampling.py b/src/frequenz/sdk/actor/_resampling.py
index 591982c..1fbadae 100644
--- a/src/frequenz/sdk/actor/_resampling.py
+++ b/src/frequenz/sdk/actor/_resampling.py
@@ -105,7 +105,7 @@ class ComponentMetricsResamplingActor:
 
         # noqa: DAR401 error
         """
-        tasks_to_cancel: set[asyncio.Task] = set()
+        tasks_to_cancel: set[asyncio.Task[None]] = set()
         try:
             subscriptions_task = asyncio.create_task(
                 self._process_resampling_requests()
diff --git a/src/frequenz/sdk/actor/power_distributing/_battery_pool_status.py b/src/frequenz/sdk/actor/power_distributing/_battery_pool_status.py
index 2b06a63..b1d80ea 100644
--- a/src/frequenz/sdk/actor/power_distributing/_battery_pool_status.py
+++ b/src/frequenz/sdk/actor/power_distributing/_battery_pool_status.py
@@ -54,7 +54,7 @@ class _BatteryStatusChannelHelper:
     battery_id: int
     """Id of the battery for which we should create channel."""
 
-    def __post_init__(self):
+    def __post_init__(self) -> None:
         self.name: str = f"battery-{self.battery_id}-status"
         channel = Broadcast[Status](self.name)
 
diff --git a/src/frequenz/sdk/actor/power_distributing/_battery_status.py b/src/frequenz/sdk/actor/power_distributing/_battery_status.py
index 8e5e6d1..f3f8859 100644
--- a/src/frequenz/sdk/actor/power_distributing/_battery_status.py
+++ b/src/frequenz/sdk/actor/power_distributing/_battery_status.py
@@ -79,7 +79,7 @@ class _BlockingStatus:
     min_duration_sec: float
     max_duration_sec: float
 
-    def __post_init__(self):
+    def __post_init__(self) -> None:
         self.last_blocking_duration_sec: float = self.min_duration_sec
         self.blocked_until: Optional[datetime] = None
 
@@ -114,7 +114,7 @@ class _BlockingStatus:
 
         return self.last_blocking_duration_sec
 
-    def unblock(self):
+    def unblock(self) -> None:
         """Unblock battery.
 
         This will reset duration of the next blocking timeout.
@@ -186,24 +186,26 @@ class BatteryStatusTracker:
         self._max_data_age = max_data_age_sec
         # First battery is considered as not working.
         # Change status after first messages are received.
-        self._last_status = Status.NOT_WORKING
-        self._blocking_status = _BlockingStatus(1.0, max_blocking_duration_sec)
+        self._last_status: Status = Status.NOT_WORKING
+        self._blocking_status: _BlockingStatus = _BlockingStatus(
+            1.0, max_blocking_duration_sec
+        )
 
         inverter_id = self._find_adjacent_inverter_id(battery_id)
         if inverter_id is None:
             raise RuntimeError(f"Can't find inverter adjacent to battery: {battery_id}")
 
-        self._battery = _ComponentStreamStatus(
+        self._battery: _ComponentStreamStatus = _ComponentStreamStatus(
             battery_id, data_recv_timer=Timer(max_data_age_sec)
         )
-        self._inverter = _ComponentStreamStatus(
+        self._inverter: _ComponentStreamStatus = _ComponentStreamStatus(
             inverter_id, data_recv_timer=Timer(max_data_age_sec)
         )
 
         # Select needs receivers that can be get in async way only.
-        self._select = None
+        self._select: Select | None = None
 
-        self._task = asyncio.create_task(
+        self._task: asyncio.Task[None] = asyncio.create_task(
             self._run(status_sender, set_power_result_receiver)
         )
 
diff --git a/src/frequenz/sdk/actor/power_distributing/power_distributing.py b/src/frequenz/sdk/actor/power_distributing/power_distributing.py
index d4a964d..8472461 100644
--- a/src/frequenz/sdk/actor/power_distributing/power_distributing.py
+++ b/src/frequenz/sdk/actor/power_distributing/power_distributing.py
@@ -189,7 +189,7 @@ class PowerDistributingActor:
             max_data_age_sec=10.0,
         )
 
-    def _create_users_tasks(self) -> List[asyncio.Task[Empty]]:
+    def _create_users_tasks(self) -> List[asyncio.Task[None]]:
         """For each user create a task to wait for request.
 
         Returns:
@@ -298,6 +298,7 @@ class PowerDistributingActor:
                 api, distribution, request.request_timeout_sec
             )
 
+            response: Success | PartialFailure
             if len(failed_batteries) > 0:
                 succeed_batteries = set(battery_distribution.keys()) - failed_batteries
                 response = PartialFailure(
diff --git a/src/frequenz/sdk/microgrid/_graph.py b/src/frequenz/sdk/microgrid/_graph.py
index 045c22c..970686c 100644
--- a/src/frequenz/sdk/microgrid/_graph.py
+++ b/src/frequenz/sdk/microgrid/_graph.py
@@ -139,7 +139,7 @@ class _MicrogridComponentGraph(ComponentGraph):
             InvalidGraphError: if `components` and `connections` are not both `None`
                 and either of them is either `None` or empty
         """
-        self._graph = nx.DiGraph()
+        self._graph: nx.DiGraph = nx.DiGraph()
 
         if components is None and connections is None:
             return
diff --git a/src/frequenz/sdk/microgrid/client/_client.py b/src/frequenz/sdk/microgrid/client/_client.py
index 68a16f2..8983701 100644
--- a/src/frequenz/sdk/microgrid/client/_client.py
+++ b/src/frequenz/sdk/microgrid/client/_client.py
@@ -6,7 +6,17 @@
 import asyncio
 import logging
 from abc import ABC, abstractmethod
-from typing import Any, Callable, Dict, Iterable, Optional, Set, TypeVar
+from typing import (
+    Any,
+    Awaitable,
+    Callable,
+    Dict,
+    Iterable,
+    Optional,
+    Set,
+    TypeVar,
+    cast,
+)
 
 import grpc
 from frequenz.api.microgrid import common_pb2 as common_pb
@@ -210,10 +220,12 @@ class MicrogridGrpcClient(MicrogridApiClient):
                 when the api call exceeded timeout
         """
         try:
+            # grpc.aio is missing types and mypy thinks this is not awaitable,
+            # but it is
             component_list = await self.api.ListComponents(
                 microgrid_pb.ComponentFilter(),
-                timeout=DEFAULT_GRPC_CALL_TIMEOUT,
-            )
+                timeout=DEFAULT_GRPC_CALL_TIMEOUT,  # type: ignore[arg-type]
+            )  # type: ignore[misc]
         except grpc.aio.AioRpcError as err:
             msg = f"Failed to list components. Microgrid API: {self.target}. Err: {err.details()}"
             raise grpc.aio.AioRpcError(
@@ -266,8 +278,14 @@ class MicrogridGrpcClient(MicrogridApiClient):
         try:
             valid_components, all_connections = await asyncio.gather(
                 self.components(),
-                self.api.ListConnections(
-                    connection_filter, timeout=DEFAULT_GRPC_CALL_TIMEOUT
+                # grpc.aio is missing types and mypy thinks this is not
+                # awaitable, but it is
+                cast(
+                    Awaitable[microgrid_pb.ConnectionList],
+                    self.api.ListConnections(
+                        connection_filter,
+                        timeout=DEFAULT_GRPC_CALL_TIMEOUT,  # type: ignore[arg-type]
+                    ),
                 ),
             )
         except grpc.aio.AioRpcError as err:
@@ -321,7 +339,9 @@ class MicrogridGrpcClient(MicrogridApiClient):
                 call = self.api.GetComponentData(
                     microgrid_pb.ComponentIdParam(id=component_id),
                 )
-                async for msg in call:
+                # grpc.aio is missing types and mypy thinks this is not
+                # async iterable, but it is
+                async for msg in call:  # type: ignore[attr-defined]
                     await sender.send(transform(msg))
             except grpc.aio.AioRpcError as err:
                 api_details = f"Microgrid API: {self.target}."
@@ -340,7 +360,7 @@ class MicrogridGrpcClient(MicrogridApiClient):
                     retry_spec.get_progress(),
                     interval,
                 )
-                await asyncio.sleep(interval)  # type: ignore
+                await asyncio.sleep(interval)
             else:
                 logger.warning(
                     "`GetComponentData`, for component_id=%d: connection ended, "
@@ -550,20 +570,24 @@ class MicrogridGrpcClient(MicrogridApiClient):
         """
         try:
             if power_w >= 0:
+                # grpc.aio is missing types and mypy thinks this is not
+                # async iterable, but it is
                 result: Empty = await self.api.Charge(
                     microgrid_pb.PowerLevelParam(
                         component_id=component_id, power_w=power_w
                     ),
-                    timeout=DEFAULT_GRPC_CALL_TIMEOUT,
-                )
+                    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
                 result = await self.api.Discharge(
                     microgrid_pb.PowerLevelParam(
                         component_id=component_id, power_w=power_w
                     ),
-                    timeout=DEFAULT_GRPC_CALL_TIMEOUT,
-                )
+                    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(
@@ -600,8 +624,14 @@ class MicrogridGrpcClient(MicrogridApiClient):
         if lower > 0:
             raise ValueError(f"Lower bound {upper} must be less than or equal to 0.")
 
-        set_bounds_call = self.api.SetBounds(timeout=DEFAULT_GRPC_CALL_TIMEOUT)
+        # 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(
                 microgrid_pb.SetBoundsParam(
                     component_id=component_id,
diff --git a/src/frequenz/sdk/timeseries/_formula_engine/_formula_engine.py b/src/frequenz/sdk/timeseries/_formula_engine/_formula_engine.py
index d90879d..cba14d9 100644
--- a/src/frequenz/sdk/timeseries/_formula_engine/_formula_engine.py
+++ b/src/frequenz/sdk/timeseries/_formula_engine/_formula_engine.py
@@ -140,9 +140,9 @@ class FormulaEvaluator:
         if self._first_run:
             metric_ts = await self._synchronize_metric_timestamps(ready_metrics)
         else:
-            res = next(iter(ready_metrics)).result()
-            assert res is not None
-            metric_ts = res.timestamp
+            sample = next(iter(ready_metrics)).result()
+            assert sample is not None
+            metric_ts = sample.timestamp
 
         for step in self._steps:
             step.apply(eval_stack)
@@ -154,7 +154,7 @@ class FormulaEvaluator:
 
         res = eval_stack.pop()
         if isnan(res) or isinf(res):
-            res = None
+            return Sample(metric_ts, None)
 
         return Sample(metric_ts, res)
 
@@ -179,9 +179,9 @@ class FormulaEngine:
             steps: Steps for the engine to execute, in post-fix order.
             metric_fetchers: Fetchers for each metric stream the formula depends on.
         """
-        self._name = name
-        self._channel = FormulaChannel(self._name, self)
-        self._task = None
+        self._name: str = name
+        self._channel: FormulaChannel = FormulaChannel(self._name, self)
+        self._task: asyncio.Task[None] | None = None
         self._evaluator = FormulaEvaluator(name, steps, metric_fetchers)
 
     async def _run(self) -> None:
@@ -241,10 +241,12 @@ class FormulaEngine3Phase:
             name: A name for the formula.
             phase_streams: output streams of formula engines running per-phase formulas.
         """
-        self._name = name
-        self._channel = FormulaChannel3Phase(self._name, self)
-        self._task = None
-        self._streams = phase_streams
+        self._name: str = name
+        self._channel: FormulaChannel3Phase = FormulaChannel3Phase(self._name, self)
+        self._task: asyncio.Task[None] | None = None
+        self._streams: tuple[
+            FormulaReceiver, FormulaReceiver, FormulaReceiver
+        ] = phase_streams
 
     async def _run(self) -> None:
         sender = self._channel.new_sender()
@@ -434,7 +436,7 @@ class _BaseFormulaChannel(
             resend_latest: Whether to resend latest channel values to newly created
                 receivers, like in `Broadcast` channels.
         """
-        self._engine = engine
+        self._engine: _GenericEngine = engine
         super().__init__(name, resend_latest)
 
     @property
diff --git a/src/frequenz/sdk/timeseries/_formula_engine/_formula_steps.py b/src/frequenz/sdk/timeseries/_formula_engine/_formula_steps.py
index 7b09216..68ec9f0 100644
--- a/src/frequenz/sdk/timeseries/_formula_engine/_formula_steps.py
+++ b/src/frequenz/sdk/timeseries/_formula_engine/_formula_steps.py
@@ -145,7 +145,7 @@ class OpenParen(FormulaStep):
         """
         return "("
 
-    def apply(self, _: List[Optional[float]]) -> None:
+    def apply(self, _: List[float]) -> None:
         """No-op."""
 
 
diff --git a/src/frequenz/sdk/timeseries/_moving_window.py b/src/frequenz/sdk/timeseries/_moving_window.py
index ac42fa6..5284480 100644
--- a/src/frequenz/sdk/timeseries/_moving_window.py
+++ b/src/frequenz/sdk/timeseries/_moving_window.py
@@ -9,6 +9,7 @@ import asyncio
 import logging
 from collections.abc import Sequence
 from datetime import datetime, timedelta
+from typing import SupportsIndex, overload
 
 import numpy as np
 from frequenz.channels import Receiver
@@ -21,7 +22,7 @@ from ._ringbuffer import OrderedRingBuffer
 log = logging.getLogger(__name__)
 
 
-class MovingWindow(Sequence):
+class MovingWindow:
     """
     A data window that moves with the latest datapoints of a data stream.
 
@@ -115,7 +116,9 @@ class MovingWindow(Sequence):
             time_index_alignment=window_alignment,
         )
         self._copy_buffer = False
-        self._update_window_task: asyncio.Task = asyncio.create_task(self._run_impl())
+        self._update_window_task: asyncio.Task[None] = asyncio.create_task(
+            self._run_impl()
+        )
         log.debug("Cancelling MovingWindow task: %s", __name__)
 
     async def _run_impl(self) -> None:
@@ -143,7 +146,28 @@ class MovingWindow(Sequence):
         """
         return len(self._buffer)
 
-    def __getitem__(self, key: int | datetime | slice) -> float | ArrayLike:
+    @overload
+    def __getitem__(self, key: SupportsIndex) -> float:
+        """See the main __getitem__ method.
+
+        # noqa: DAR101 key
+        """
+
+    @overload
+    def __getitem__(self, key: datetime) -> float:
+        """See the main __getitem__ method.
+
+        # noqa: DAR101 key
+        """
+
+    @overload
+    def __getitem__(self, key: slice) -> ArrayLike:
+        """See the main __getitem__ method.
+
+        # noqa: DAR101 key
+        """
+
+    def __getitem__(self, key: SupportsIndex | datetime | slice) -> float | ArrayLike:
         """
         Return a sub window of the `MovingWindow`.
 
@@ -180,10 +204,17 @@ class MovingWindow(Sequence):
         elif isinstance(key, datetime):
             log.debug("Returning value at time %s ", key)
             return self._buffer[self._buffer.datetime_to_index(key)]
-        elif isinstance(key, int):
+        elif isinstance(key, SupportsIndex):
             return self._buffer[key]
 
         raise TypeError(
             "Key has to be either a timestamp or an integer "
             "or a slice of timestamps or integers"
         )
+
+
+# We need to register the class as a subclass of Sequence like this because
+# otherwise type-checking fails complaining that MovingWindow has more
+# overloads of __getitem__() than Sequence (which doesn't have an overload with
+# a datetime key)
+Sequence.register(MovingWindow)
diff --git a/src/frequenz/sdk/timeseries/_resampling.py b/src/frequenz/sdk/timeseries/_resampling.py
index 31a42d9..664f45f 100644
--- a/src/frequenz/sdk/timeseries/_resampling.py
+++ b/src/frequenz/sdk/timeseries/_resampling.py
@@ -657,7 +657,7 @@ class _StreamingHelper:
         self._helper: _ResamplingHelper = helper
         self._source: Source = source
         self._sink: Sink = sink
-        self._receiving_task: asyncio.Task = asyncio.create_task(
+        self._receiving_task: asyncio.Task[None] = asyncio.create_task(
             self._receive_samples()
         )
 
diff --git a/src/frequenz/sdk/timeseries/_ringbuffer.py b/src/frequenz/sdk/timeseries/_ringbuffer.py
index 330eb27..3faf345 100644
--- a/src/frequenz/sdk/timeseries/_ringbuffer.py
+++ b/src/frequenz/sdk/timeseries/_ringbuffer.py
@@ -9,7 +9,7 @@ from collections.abc import Iterable
 from copy import deepcopy
 from dataclasses import dataclass
 from datetime import datetime, timedelta
-from typing import Any, Generic, List, TypeVar, overload
+from typing import Generic, List, SupportsFloat, SupportsIndex, TypeVar, overload
 
 import numpy as np
 import numpy.typing as npt
@@ -28,7 +28,7 @@ class Gap:
     end: datetime
     """End of the range, exclusive."""
 
-    def contains(self, timestamp: datetime):
+    def contains(self, timestamp: datetime) -> bool:
         """Check if a given timestamp is inside this gap.
 
         Args:
@@ -69,14 +69,14 @@ class OrderedRingBuffer(Generic[FloatArray]):
                 "2022-01-01 12:00:00" to "2022-01-02 12:00:00" (date chosen
                 arbitrarily here).
         """
-        self._buffer = buffer
-        self._sampling_period = sampling_period
-        self._time_index_alignment = time_index_alignment
+        self._buffer: FloatArray = buffer
+        self._sampling_period: timedelta = sampling_period
+        self._time_index_alignment: datetime = time_index_alignment
 
         self._gaps: list[Gap] = []
-        self._datetime_newest = datetime.min
-        self._datetime_oldest = datetime.max
-        self._time_range = (len(self._buffer) - 1) * sampling_period
+        self._datetime_newest: datetime = datetime.min
+        self._datetime_oldest: datetime = datetime.max
+        self._time_range: timedelta = (len(self._buffer) - 1) * sampling_period
 
     @property
     def sampling_period(self) -> timedelta:
@@ -220,14 +220,15 @@ class OrderedRingBuffer(Generic[FloatArray]):
 
         # Requested window wraps around the ends
         if start_index >= end_index:
-            window: Any = self._buffer[start_index:]
-
             if end_index > 0:
                 if isinstance(self._buffer, list):
-                    window += self._buffer[0:end_index]
-                else:
-                    window = np.concatenate((window, self._buffer[0:end_index]))
-            return window
+                    return self._buffer[start_index:] + self._buffer[0:end_index]
+                if isinstance(self._buffer, np.ndarray):
+                    return np.concatenate(
+                        (self._buffer[start_index:], self._buffer[0:end_index])
+                    )
+                assert False, f"Unknown _buffer type: {type(self._buffer)}"
+            return self._buffer[start_index:]
 
         # Return a copy if there are none-values in the data
         if force_copy or any(
@@ -399,7 +400,7 @@ class OrderedRingBuffer(Generic[FloatArray]):
         """
 
     @overload
-    def __setitem__(self, index_or_slice: int, value: float) -> None:
+    def __setitem__(self, index_or_slice: SupportsIndex, value: float) -> None:
         """Set value at requested index.
 
         No wrapping of the index will be done.
@@ -411,7 +412,7 @@ class OrderedRingBuffer(Generic[FloatArray]):
         """
 
     def __setitem__(
-        self, index_or_slice: int | slice, value: float | Iterable[float]
+        self, index_or_slice: SupportsIndex | slice, value: float | Iterable[float]
     ) -> None:
         """Set item or slice at requested position.
 
@@ -422,10 +423,26 @@ class OrderedRingBuffer(Generic[FloatArray]):
             index_or_slice: Index or slice specification of the requested data.
             value: Value to set at the given position.
         """
-        self._buffer.__setitem__(index_or_slice, value)
+        # There seem to be 2 different mypy bugs at play here.
+        # First we need to check that the combination of input arguments are
+        # correct to make the type checker happy (I guess it could be inferred
+        # from the @overloads, but it's not currently working without this
+        # hack).
+        # Then we need to ignore a no-untyped-call error, for some reason it
+        # can't get the type for self._buffer.__setitem__()
+        if isinstance(index_or_slice, SupportsIndex) and isinstance(
+            value, SupportsFloat
+        ):
+            self._buffer.__setitem__(index_or_slice, value)  # type: ignore[no-untyped-call]
+        elif isinstance(index_or_slice, slice) and isinstance(value, Iterable):
+            self._buffer.__setitem__(index_or_slice, value)  # type: ignore[no-untyped-call]
+        else:
+            assert (
+                False
+            ), f"Incompatible input arguments: {type(index_or_slice)=} {type(value)=}"
 
     @overload
-    def __getitem__(self, index_or_slice: int) -> float:
+    def __getitem__(self, index_or_slice: SupportsIndex) -> float:
         """Get item at requested position.
 
         No wrapping of the index will be done.
@@ -446,7 +463,7 @@ class OrderedRingBuffer(Generic[FloatArray]):
             index_or_slice: Slice specification of where the requested data is.
         """
 
-    def __getitem__(self, index_or_slice: int | slice) -> float | FloatArray:
+    def __getitem__(self, index_or_slice: SupportsIndex | slice) -> float | FloatArray:
         """Get item or slice at requested position.
 
         No wrapping of the index will be done.
diff --git a/src/frequenz/sdk/timeseries/_serializable_ringbuffer.py b/src/frequenz/sdk/timeseries/_serializable_ringbuffer.py
index 2c02c75..1a18647 100644
--- a/src/frequenz/sdk/timeseries/_serializable_ringbuffer.py
+++ b/src/frequenz/sdk/timeseries/_serializable_ringbuffer.py
@@ -44,7 +44,8 @@ class SerializableRingBuffer(OrderedRingBuffer[FloatArray]):
                 "2022-01-01 12:00:00" to "2022-01-02 12:00:00" (date chosen
                 arbitrarily here).
         """
-        super().__init__(buffer, sampling_period, time_index_alignment)
+        # Overcome a bug in mypy: https://github.com/python/mypy/issues/14774
+        super().__init__(buffer, sampling_period, time_index_alignment)  # type: ignore[arg-type]
         self._path = path
         self._file_format_version = FILE_FORMAT_VERSION
 
@@ -76,7 +77,7 @@ class SerializableRingBuffer(OrderedRingBuffer[FloatArray]):
             return None
 
         with open(path, mode="rb") as fileobj:
-            instance: SerializableRingBuffer = pickle.load(fileobj)
+            instance: SerializableRingBuffer[FloatArray] = pickle.load(fileobj)
             instance._path = path  # pylint: disable=protected-access
             # Set latest file format version for next time it dumps.
             # pylint: disable=protected-access
diff --git a/src/frequenz/sdk/timeseries/ev_charger_pool/_ev_charger_pool.py b/src/frequenz/sdk/timeseries/ev_charger_pool/_ev_charger_pool.py
index fc25886..c7a8818 100644
--- a/src/frequenz/sdk/timeseries/ev_charger_pool/_ev_charger_pool.py
+++ b/src/frequenz/sdk/timeseries/ev_charger_pool/_ev_charger_pool.py
@@ -47,8 +47,10 @@ class EVChargerPool:
                 not specified, IDs of all EV Chargers in the microgrid will be fetched
                 from the component graph.
         """
-        self._channel_registry = channel_registry
-        self._resampler_subscription_sender = resampler_subscription_sender
+        self._channel_registry: ChannelRegistry = channel_registry
+        self._resampler_subscription_sender: Sender[
+            ComponentMetricRequest
+        ] = resampler_subscription_sender
         self._component_ids: set[int] = set()
         if component_ids is not None:
             self._component_ids = component_ids
@@ -60,8 +62,8 @@ class EVChargerPool:
                     component_category={ComponentCategory.EV_CHARGER}
                 )
             }
-        self._namespace = f"ev-charger-pool-{uuid.uuid4()}"
-        self._formula_pool = FormulaEnginePool(
+        self._namespace: str = f"ev-charger-pool-{uuid.uuid4()}"
+        self._formula_pool: FormulaEnginePool = FormulaEnginePool(
             self._namespace,
             self._channel_registry,
             self._resampler_subscription_sender,
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 6dae38b..c3b199f 100644
--- a/src/frequenz/sdk/timeseries/ev_charger_pool/_state_tracker.py
+++ b/src/frequenz/sdk/timeseries/ev_charger_pool/_state_tracker.py
@@ -107,7 +107,7 @@ class StateTracker:
             "EVCharger States", resend_latest=True
         )
         self._task: Optional[asyncio.Task[None]] = None
-        self._merged_stream: Optional[Merge] = None
+        self._merged_stream: Optional[Merge[EVChargerData]] = None
         self._states: dict[int, EVChargerState] = {}
 
     def _get(self) -> EVChargerPoolStates:
['src/frequenz/sdk/timeseries/_resampling.py', 'src/frequenz/sdk/actor/power_distributing/_battery_pool_status.py', 'src/frequenz/sdk/timeseries/_ringbuffer.py', 'src/frequenz/sdk/timeseries/_formula_engine/_formula_steps.py', 'src/frequenz/sdk/actor/power_distributing/power_distributing.py', 'noxfile.py', 'src/frequenz/sdk/microgrid/client/_client.py', 'src/frequenz/sdk/actor/__init__.py', 'src/frequenz/sdk/_internal/asyncio.py', 'pyproject.toml', 'src/frequenz/sdk/microgrid/_graph.py', 'src/frequenz/sdk/timeseries/ev_charger_pool/_ev_charger_pool.py', 'src/frequenz/sdk/timeseries/_formula_engine/_formula_engine.py', 'src/frequenz/sdk/timeseries/_serializable_ringbuffer.py', 'src/frequenz/sdk/actor/_resampling.py', 'src/frequenz/sdk/timeseries/_moving_window.py', 'src/frequenz/sdk/actor/power_distributing/_battery_status.py', 'src/frequenz/sdk/timeseries/ev_charger_pool/_state_tracker.py']