Repository: frequenz-floss/frequenz-sdk-python
Pull request #1216: Ensure root component is as close as possible to the grid component
Author: login: shsms
Labels: name: part:docs
description: Affects the documentation
name: part:tests
description: Affects the unit, integration and performance (benchmarks) tests
name: part:data-pipeline
description: Affects the data pipeline
name: part:microgrid
description: Affects the interactions with the microgrid
Created: 2025-05-12T16:18:21Z | Merged: 2025-05-14T09:02:16Z

The `find_first_descendant_component` accepts a `root_category` and finds
a component matching that category to begin traversing from.  This PR
ensures that the matching root component that's found is as close as
possible to the GRID component.


Diff:
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 0ed3b86e0..b4e9d02b9 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -55,3 +55,5 @@
   | EV Chargers        | Maximum power (aka max consumption power) |
 
 - PV Pool instances can now be created in sites without any PV.  This allows for writing generic code that works for all locations, that depends on the PV power formula, for example.
+
+- The `find_first_descendant_component` method in the component graph was allowing non-root components to be used as the root component during traversal.  This was leading to confusing behaviour when the root component couldn't be identified deterministically.  For example, if the root category was specified as a meter, it could start traversing from a different meter each time.  It is no-longer possible to specify a root category anymore and it always traverses from the `GRID` component.
diff --git a/src/frequenz/sdk/microgrid/component_graph.py b/src/frequenz/sdk/microgrid/component_graph.py
index 1b0be619e..51bb8c023 100644
--- a/src/frequenz/sdk/microgrid/component_graph.py
+++ b/src/frequenz/sdk/microgrid/component_graph.py
@@ -311,16 +311,11 @@ def dfs(
     def find_first_descendant_component(
         self,
         *,
-        root_category: ComponentCategory,
         descendant_categories: Iterable[ComponentCategory],
     ) -> Component:
         """Find the first descendant component given root and descendant categories.
 
-        This method searches for the root component within the provided root
-        category. If multiple components share the same root category, the
-        first found one is considered as the root component.
-
-        Subsequently, it looks for the first descendant component from the root
+        This method looks for the first descendant component from the GRID
         component, considering only the immediate descendants.
 
         The priority of the component to search for is determined by the order
@@ -328,13 +323,12 @@ def find_first_descendant_component(
         highest priority.
 
         Args:
-            root_category: The class of the root component to search for.
             descendant_categories: The descendant classes to search for the first
                 descendant component in.
 
         Returns:
             The first descendant component found in the component graph,
-                considering the specified `root` and `descendants` categories.
+                considering the specified `descendants` categories.
         """
 
 
@@ -824,16 +818,11 @@ def dfs(
     def find_first_descendant_component(
         self,
         *,
-        root_category: ComponentCategory,
         descendant_categories: Iterable[ComponentCategory],
     ) -> Component:
         """Find the first descendant component given root and descendant categories.
 
-        This method searches for the root component within the provided root
-        category. If multiple components share the same root category, the
-        first found one is considered as the root component.
-
-        Subsequently, it looks for the first descendant component from the root
+        This method looks for the first descendant component from the GRID
         component, considering only the immediate descendants.
 
         The priority of the component to search for is determined by the order
@@ -841,25 +830,28 @@ def find_first_descendant_component(
         highest priority.
 
         Args:
-            root_category: The class of the root component to search for.
             descendant_categories: The descendant classes to search for the first
                 descendant component in.
 
         Returns:
             The first descendant component found in the component graph,
-                considering the specified `root` and `descendants` categories.
+                considering the specified `descendants` categories.
 
         Raises:
-            ValueError: When the root component is not found in the component
-                graph or when no component is found in the given categories.
+            InvalidGraphError: When no GRID component is found in the graph.
+            ValueError: When no component is found in the given categories.
         """
         root_component = next(
-            (comp for comp in self.components(component_categories={root_category})),
+            (
+                comp
+                for comp in self.components(
+                    component_categories={ComponentCategory.GRID}
+                )
+            ),
             None,
         )
-
         if root_component is None:
-            raise ValueError(f"Root component not found for {root_category.name}")
+            raise InvalidGraphError("No GRID component found in the component graph!")
 
         # Sort by component ID to ensure consistent results.
         successors = sorted(
diff --git a/src/frequenz/sdk/timeseries/_grid_frequency.py b/src/frequenz/sdk/timeseries/_grid_frequency.py
index e645f4f69..54ea1a96b 100644
--- a/src/frequenz/sdk/timeseries/_grid_frequency.py
+++ b/src/frequenz/sdk/timeseries/_grid_frequency.py
@@ -53,7 +53,6 @@ def __init__(
         if not source:
             component_graph = connection_manager.get().component_graph
             source = component_graph.find_first_descendant_component(
-                root_category=ComponentCategory.GRID,
                 descendant_categories=(
                     ComponentCategory.METER,
                     ComponentCategory.INVERTER,
diff --git a/src/frequenz/sdk/timeseries/_voltage_streamer.py b/src/frequenz/sdk/timeseries/_voltage_streamer.py
index 193988401..bf8b489f2 100644
--- a/src/frequenz/sdk/timeseries/_voltage_streamer.py
+++ b/src/frequenz/sdk/timeseries/_voltage_streamer.py
@@ -81,7 +81,6 @@ def __init__(
         if not source_component:
             component_graph = connection_manager.get().component_graph
             source_component = component_graph.find_first_descendant_component(
-                root_category=ComponentCategory.GRID,
                 descendant_categories=[
                     ComponentCategory.METER,
                     ComponentCategory.INVERTER,
diff --git a/tests/microgrid/test_graph.py b/tests/microgrid/test_graph.py
index cd522a788..fe865851c 100644
--- a/tests/microgrid/test_graph.py
+++ b/tests/microgrid/test_graph.py
@@ -590,45 +590,21 @@ def test_find_first_descendant_component(self) -> None:
 
         # Find the first descendant component of the grid endpoint.
         result = graph.find_first_descendant_component(
-            root_category=ComponentCategory.GRID,
             descendant_categories=(ComponentCategory.METER,),
         )
         assert result == Component(2, ComponentCategory.METER)
 
-        # Find the first descendant component of the first meter found.
-        result = graph.find_first_descendant_component(
-            root_category=ComponentCategory.METER,
-            descendant_categories=(ComponentCategory.INVERTER,),
-        )
-        assert result == Component(4, ComponentCategory.INVERTER, InverterType.BATTERY)
-
         # Find the first descendant component of the grid,
         # considering meter or inverter categories.
         result = graph.find_first_descendant_component(
-            root_category=ComponentCategory.GRID,
             descendant_categories=(ComponentCategory.METER, ComponentCategory.INVERTER),
         )
         assert result == Component(2, ComponentCategory.METER)
 
-        # Find the first descendant component of the first meter with nested meters.
-        result = graph.find_first_descendant_component(
-            root_category=ComponentCategory.METER,
-            descendant_categories=(ComponentCategory.METER,),
-        )
-        assert result == Component(3, ComponentCategory.METER)
-
-        # Verify behavior when root component is not found.
-        with pytest.raises(ValueError):
-            graph.find_first_descendant_component(
-                root_category=ComponentCategory.CHP,
-                descendant_categories=(ComponentCategory.INVERTER,),
-            )
-
         # Verify behavior when component is not found in immediate descendant
         # categories for the first meter.
         with pytest.raises(ValueError):
             graph.find_first_descendant_component(
-                root_category=ComponentCategory.METER,
                 descendant_categories=(
                     ComponentCategory.EV_CHARGER,
                     ComponentCategory.BATTERY,
@@ -639,7 +615,6 @@ def test_find_first_descendant_component(self) -> None:
         # categories from the grid component as root.
         with pytest.raises(ValueError):
             graph.find_first_descendant_component(
-                root_category=ComponentCategory.GRID,
                 descendant_categories=(ComponentCategory.INVERTER,),
             )
 


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR improves the behavior of find_first_descendant_component so that when searching for a component with a non‑GRID root category, the method uses the grid component as the base and returns the closest matching descendant.
- Introduces a separate branch for GRID components that directly selects the component from self.components.
- For non‑GRID components, validates the existence of a grid component and then recursively locates the appropriate descendant.

### Reviewed Changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

| File                                              | Description                                                                           |
| ------------------------------------------------- | ------------------------------------------------------------------------------------- |
| src/frequenz/sdk/microgrid/component_graph.py     | Adjusted find_first_descendant_component method for deterministic grid component look‑up. |
| RELEASE_NOTES.md                                  | Updated release notes to describe the fixed bug in the find_first_descendant_component method. |


<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**src/frequenz/sdk/microgrid/component_graph.py:877**
* [nitpick] Consider reusing the already obtained grid_component to search for the descendant instead of making a recursive call, which would reduce redundant graph traversals.
```
root_component = self.find_first_descendant_component(root_category=ComponentCategory.GRID, descendant_categories=[root_category])
```
</details>


state: COMMENTED
author_association: NONE
submitted_at: 2025-05-12T16:18:35Z
user: login: llucax
body: The other comments are optional, but I also wonder what happens with islands, we are supposed to support islands. Isn't there any way to find descendants if there is no grid connection?
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T07:30:41Z
user: login: shsms
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T08:40:27Z
user: login: shsms
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T08:40:50Z
user: login: llucax
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T08:57:04Z

Review comments:
diff_hunk: @@ -853,10 +853,31 @@ def find_first_descendant_component(
             ValueError: When the root component is not found in the component
                 graph or when no component is found in the given categories.
         """
-        root_component = next(
-            (comp for comp in self.components(component_categories={root_category})),
-            None,
-        )
+        if root_category == ComponentCategory.GRID:
+            root_component = next(
+                (
+                    comp
+                    for comp in self.components(component_categories={root_category})
+                ),
+                None,
+            )
+        else:
+            grid_component = next(
+                (
+                    comp
+                    for comp in self.components(
+                        component_categories={ComponentCategory.GRID}
+                    )
+                ),
+                None,
+            )
+            if grid_component is None:
+                raise ValueError("Grid component not found in component graph!")
+
+            root_component = self.find_first_descendant_component(
+                root_category=ComponentCategory.GRID,
+                descendant_categories=[root_category],
+            )
path: src/frequenz/sdk/microgrid/component_graph.py
user: login: llucax
body: Alternative with less duplication for finding the grid component:

```suggestion
        grid_component = next(
            (
                comp
                for comp in self.components(
                    component_categories={ComponentCategory.GRID}
                )
            ),
            None,
        )
        if root_category == ComponentCategory.GRID:
            root_component = grid_component
        else:
            if grid_component is None:
                raise InvalidGraphError("Grid component not found in component graph")

            root_component = self.find_first_descendant_component(
                root_category=ComponentCategory.GRID,
                descendant_categories=[root_category],
            )
```
created_at: 2025-05-13T07:28:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -853,10 +853,31 @@ def find_first_descendant_component(
             ValueError: When the root component is not found in the component
                 graph or when no component is found in the given categories.
         """
-        root_component = next(
-            (comp for comp in self.components(component_categories={root_category})),
-            None,
-        )
+        if root_category == ComponentCategory.GRID:
+            root_component = next(
+                (
+                    comp
+                    for comp in self.components(component_categories={root_category})
+                ),
+                None,
+            )
+        else:
+            grid_component = next(
+                (
+                    comp
+                    for comp in self.components(
+                        component_categories={ComponentCategory.GRID}
+                    )
+                ),
+                None,
+            )
+            if grid_component is None:
+                raise ValueError("Grid component not found in component graph!")
path: src/frequenz/sdk/microgrid/component_graph.py
user: login: llucax
body: I would raise a `InvalidGraphError` as there is really nothing wrong with the values passed to the function, is just the component graph is not suitable for this operation, right?
```suggestion
                raise InvalidGraphError("Grid component not found in component graph!")
```
created_at: 2025-05-13T07:29:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -853,10 +853,31 @@ def find_first_descendant_component(
             ValueError: When the root component is not found in the component
                 graph or when no component is found in the given categories.
         """
-        root_component = next(
-            (comp for comp in self.components(component_categories={root_category})),
-            None,
-        )
+        if root_category == ComponentCategory.GRID:
+            root_component = next(
+                (
+                    comp
+                    for comp in self.components(component_categories={root_category})
+                ),
+                None,
+            )
+        else:
+            grid_component = next(
+                (
+                    comp
+                    for comp in self.components(
+                        component_categories={ComponentCategory.GRID}
+                    )
+                ),
+                None,
+            )
+            if grid_component is None:
+                raise ValueError("Grid component not found in component graph!")
path: src/frequenz/sdk/microgrid/component_graph.py
user: login: shsms
body: done
created_at: 2025-05-13T08:40:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -853,10 +853,31 @@ def find_first_descendant_component(
             ValueError: When the root component is not found in the component
                 graph or when no component is found in the given categories.
         """
-        root_component = next(
-            (comp for comp in self.components(component_categories={root_category})),
-            None,
-        )
+        if root_category == ComponentCategory.GRID:
+            root_component = next(
+                (
+                    comp
+                    for comp in self.components(component_categories={root_category})
+                ),
+                None,
+            )
+        else:
+            grid_component = next(
+                (
+                    comp
+                    for comp in self.components(
+                        component_categories={ComponentCategory.GRID}
+                    )
+                ),
+                None,
+            )
+            if grid_component is None:
+                raise ValueError("Grid component not found in component graph!")
+
+            root_component = self.find_first_descendant_component(
+                root_category=ComponentCategory.GRID,
+                descendant_categories=[root_category],
+            )
path: src/frequenz/sdk/microgrid/component_graph.py
user: login: shsms
body: dropped this section.
created_at: 2025-05-13T08:40:50Z
author_association: CONTRIBUTOR

PR comments:
user: login: shsms
created_at: 2025-05-13T08:18:25Z
body: I'm actually wondering why we need to support this `root_category == Meter`, etc.  The root category is the GRID.  The two places where this function is being used are specifying `GRID` as the root component.

Unless you're against, I'll remove this parameter.  But if we keep it instead, we should reject everything that's not the GRID.  The rest are not "root", because there can be multiple of them.  But there can only be one GRID component in the graph.
author_association: CONTRIBUTOR
user: login: shsms
created_at: 2025-05-13T08:25:37Z
body: Ideally this function is replaced by a bfs, but I will not do that now.
author_association: CONTRIBUTOR
user: login: llucax
created_at: 2025-05-14T08:58:16Z
body: > Ideally this function is replaced by a bfs, but I will not do that now.

There was some bfs implemented by @matthias-wende-frequenz somewhere, IIRC.
author_association: CONTRIBUTOR
user: login: shsms
created_at: 2025-05-14T09:20:40Z
body: > There was some bfs implemented by @matthias-wende-frequenz somewhere, IIRC.

No, he made a dfs
author_association: CONTRIBUTOR