Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,18 @@

## Summary

<!-- Here goes a general summary of what this release is about -->
This release adds default formulas for AC energy metrics when they are not
explicitly configured.

## Upgrading

* The minimum required version of `frequenz-microgrid-component-graph` is now `v0.3.4`.

## New Features

* Added `gridpool-cli render-graph` to visualize microgrid component graphs using the
Assets API credentials (`ASSETS_API_URL`, `ASSETS_API_AUTH_KEY`, and
`ASSETS_API_SIGN_SECRET`).
* Added default summation formulas for `AC_ENERGY_ACTIVE`,
`AC_ENERGY_ACTIVE_CONSUMED`, and `AC_ENERGY_ACTIVE_DELIVERED` when formulas are
not provided. The defaults are built from the component IDs configured for the
component type.

## Bug Fixes

- Fixed component graph rendering so children follow the vertical order of their parents, keeping upper-level branches above lower ones in the layered layout.
146 changes: 137 additions & 9 deletions src/frequenz/gridpool/_microgrid_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@
from pathlib import Path
from typing import Any, ClassVar, Literal, Self, Type, cast, get_args

from frequenz.client.assets import AssetsApiClient
from frequenz.client.common.microgrid import MicrogridId
from marshmallow import Schema
from marshmallow_dataclass import dataclass

from ._graph_generator import ComponentGraphGenerator

_logger = logging.getLogger(__name__)

ComponentType = Literal["grid", "pv", "battery", "consumption", "chp", "ev"]
Expand Down Expand Up @@ -47,15 +51,16 @@ def __post_init__(self) -> None:
"please use 'AC_POWER_ACTIVE' instead."
)

if "AC_POWER_ACTIVE" not in self.formula:
if "AC_ACTIVE_POWER" in self.formula:
self.formula["AC_POWER_ACTIVE"] = self.formula["AC_ACTIVE_POWER"]
else:
_logger.warning(
"ComponentTypeConfig: No formula provided for 'AC_POWER_ACTIVE', "
"using default summation formula."
)
self.formula["AC_POWER_ACTIVE"] = "+".join(
defaults = {
"AC_POWER_ACTIVE",
"AC_ENERGY_ACTIVE",
"AC_ENERGY_ACTIVE_CONSUMED",
"AC_ENERGY_ACTIVE_DELIVERED",
}

for metric in defaults:
if metric not in self.formula:
self.formula[metric] = "+".join(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's completely get rid of this, this is actually dangerous. If you prefer to break it slower can also be a separate PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't this remove the support for manual formulas then?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is adding a fallback so that if it has not been specified at this point it adds a default summing up the default CIDs. So this silently adds a formula. I think it's better to be explicit about this and fail in case we don't have a formula neither from the formula generator nor via config. Or do I misunderstand something?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, got it. I was thinking we might not want to fail in that case either. But yeah, it makes sense to throw an error instead of returning potentially incorrect interpreted formulas.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will make another PR for that then.

[f"#{cid}" for cid in self._default_cids()]
)

Expand Down Expand Up @@ -401,3 +406,126 @@ def load_configs(
microgrid_configs.update({str(key): value for key, value in mcfgs.items()})

return microgrid_configs

@staticmethod
async def load_configs_with_formulas(
assets_url: str,
assets_auth_key: str,
assets_sign_secret: str,
microgrid_config_files: str | Path | list[str | Path] | None = None,
microgrid_config_dir: str | Path | None = None,
) -> dict[str, "MicrogridConfig"]:
"""Load microgrid configurations and ensure formulas are populated.

Loads microgrid configuration files and enriches them with automatically
generated formulas obtained from the Assets API. Missing formulas are filled
using the component graph generator while preserving any formulas already
defined in the configuration.

Args:
assets_url:
Base URL of the Assets API.
assets_auth_key:
Authentication key used to access the Assets API.
assets_sign_secret:
Signing secret used for authenticated API requests.
microgrid_config_files:
Optional path or list of paths to individual microgrid configuration
files.
microgrid_config_dir:
Optional directory containing microgrid configuration files.

Returns:
dict[str, MicrogridConfig]:
Mapping from microgrid ID (as string) to the corresponding populated
``MicrogridConfig`` instance.

Notes:
- Configuration files are first loaded via ``MicrogridConfig.load_configs``.
- Any missing formulas are populated by querying the Assets API and
generating formulas from the microgrid component graph.
"""
microgrid_configs = MicrogridConfig.load_configs(
microgrid_config_files=microgrid_config_files,
microgrid_config_dir=microgrid_config_dir,
)

async with AssetsApiClient(
assets_url, auth_key=assets_auth_key, sign_secret=assets_sign_secret
) as assets_client:
for microgrid_id, config in microgrid_configs.items():
await populate_missing_formulas(
microgrid_id=int(microgrid_id),
config=config,
assets_client=assets_client,
)

return microgrid_configs


async def populate_missing_formulas(
microgrid_id: int,
config: "MicrogridConfig",
assets_client: AssetsApiClient,
) -> None:
"""Populate missing component formulas from the assets API graph.

Builds a component graph for the given microgrid and derives default formulas
for common component types such as consumption, generation, grid, PV, battery,
CHP, and EV charging. Existing formulas already present in the configuration
are preserved; only missing component-type entries or missing metric formulas
are filled in.

Args:
microgrid_id:
Identifier of the microgrid whose component graph should be used to
derive formulas.
config:
Microgrid configuration object to update in place.
assets_client:
Assets API client used to fetch the component graph.

Returns:
None. The configuration is modified in place.

Notes:
- Existing formulas in ``config`` are never overwritten.
- For missing component types, a new ``ComponentTypeConfig`` is created.
- The same derived formula is assigned to all supported metric keys for a
given component type when missing.
"""
cgg = ComponentGraphGenerator(assets_client)
graph = await cgg.get_component_graph(MicrogridId(microgrid_id))

auto_formulas = {
"consumption": graph.consumer_formula(),
"generation": graph.producer_formula(),
"grid": graph.grid_formula(),
"pv": graph.pv_formula(None),
"battery": graph.battery_formula(None),
"chp": graph.chp_formula(None),
"ev": graph.ev_charger_formula(None),
}

metrics = (
"AC_POWER_ACTIVE",
"AC_ACTIVE_POWER",
"AC_ENERGY_ACTIVE",
"AC_ENERGY_ACTIVE_CONSUMED",
"AC_ENERGY_ACTIVE_DELIVERED",
)

for ctype, formula in auto_formulas.items():
cfg = config.ctype.get(ctype)
if cfg is None:
config.ctype[ctype] = ComponentTypeConfig(
formula={metric: formula for metric in metrics}
)
continue

if cfg.formula is None:
cfg.formula = {}

for metric in metrics:
if metric not in cfg.formula:
cfg.formula[metric] = formula
Loading