Skip to content

PowerManagingActor drops first proposal when system bounds haven't arrived yet #1404

@matthias-wende-frequenz

Description

Description

The PowerManagingActor silently drops the very first proposal for a set of component IDs when system bounds have not yet been received from the bounds tracker. This is a race condition between the proposal arrival and the asynchronous bounds computation pipeline.

The bug is most easily triggered with EV chargers due to the multi-step dependency chain in their bounds tracker, but it affects all component types (batteries, EV chargers, PV inverters) and can theoretically be triggered for any of them under the right timing conditions.

Affected files

  • src/frequenz/sdk/microgrid/_power_managing/_matryoshka.py
  • src/frequenz/sdk/microgrid/_power_managing/_shifting_matryoshka.py

Root cause

When a proposal arrives for a previously unseen set of component IDs, PowerManagingActor._run() does the following (in _power_managing_actor.py, lines ~215–228):

if proposal.component_ids not in self._bound_tracker_tasks:
    self._add_system_bounds_tracker(proposal.component_ids)   # (1)

await self._send_updated_target_power(proposal.component_ids, proposal)  # (2)

Step (1)_add_system_bounds_tracker() initializes self._system_bounds[component_ids] with a placeholder:

self._system_bounds[component_ids] = SystemBounds(
    timestamp=datetime.now(tz=timezone.utc),
    inclusion_bounds=None,    # ← placeholder
    exclusion_bounds=None,    # ← placeholder
)

It then spawns an async task (_bounds_tracker) that will eventually receive and populate real bounds. But since it's started with asyncio.create_task, it won't execute until the current coroutine yields to the event loop.

Step (2)_send_updated_target_power() is called immediately in the same execution frame, with self._system_bounds[component_ids] still holding the None/None placeholder.

This flows into Matryoshka.calculate_target_power() (or ShiftingMatryoshka.calculate_target_power()), which calls _validate_component_ids(). That method checks:

if component_ids not in self._component_buckets:
    if (
        system_bounds.inclusion_bounds is None
        and system_bounds.exclusion_bounds is None
    ):
        if proposal is not None:
            _logger.warning(
                "PowerManagingActor: No system bounds available for component "
                "IDs %s, but a proposal was given.  The proposal will be ignored.",
                component_ids,
            )
        return False   # ← validation fails

Since this is the first proposal, there's no bucket yet, and the bounds are the None/None placeholder — validation fails and returns False before _update_buckets() is called. The proposal is never stored.

When real bounds eventually arrive via the _bounds_tracker task, _send_updated_target_power(component_ids, None) is called with proposal=None, but there are no stored proposals to process — the first one was discarded.

Observed behavior

The following warning is logged and the proposal has no effect:

PowerManagingActor: No system bounds available for component IDs frozenset({ComponentId(1370)}),
but a proposal was given.  The proposal will be ignored.

The user must send a second proposal after bounds arrive for it to take effect.

Why EV chargers are most affected

The bug is a timing issue and can theoretically affect all component types. However, in practice it almost exclusively manifests with EV chargers because of differences in how bounds are produced:

Battery EV Charger
Bounds computation Lazy — SendOnUpdate(PowerBoundsCalculator) created on first system_power_bounds access Eager — EVCSystemBoundsTracker started during reference store construction
Dependencies before first bounds Resampler data (arrives quickly, typically within 1–3s) ComponentPoolStatus from PowerDistributingActor AND EVChargerData from microgrid API
Typical user pattern Accesses power_status first (which triggers _add_system_bounds_tracker via ReportRequest), then waits for a report before proposing — bounds are ready by proposal time May call propose_power immediately, before the two-gate dependency in EVCSystemBoundsTracker has been satisfied
Shared state helps? Yes — user's pool and internal pool share the same SendOnUpdate instance (via shared BatteryPoolReferenceStore), and resend_latest=True delivers cached bounds to new receivers No — EVCSystemBoundsTracker initializes with ComponentPoolStatus(working=set(), uncertain=set()), so all incoming EV data is discarded until a status update arrives

The EVCSystemBoundsTracker has a cold-start problem: it initializes _component_pool_status with empty working and uncertain sets. Until the PowerDistributingActor sends a ComponentPoolStatus marking the EV charger as working, all incoming EVChargerData is discarded:

# In EVCSystemBoundsTracker._run():
if (
    comp_id not in self._component_pool_status.working
    and comp_id not in self._component_pool_status.uncertain
):
    continue  # ← data thrown away

This means the bounds_channel (even with resend_latest=True) has nothing to resend when the PowerManagingActor subscribes.

Proposed fix

Split the old _validate_component_ids method (in both Matryoshka and ShiftingMatryoshka) into two separate concerns:

  1. _check_overlapping_buckets(component_ids) — Structural invariant check. Runs before storing the proposal to prevent creating overlapping buckets. Raises NotImplementedError if overlap is detected.

  2. _have_system_bounds(component_ids, proposal, system_bounds) — Timing/readiness check. Runs after storing the proposal. If bounds aren't available yet, returns False to skip power calculation, but the proposal is already persisted in the bucket.

The reordering in calculate_target_power() changes from:

OLD:  validate → store → calculate
NEW:  overlap_check → store → bounds_check → calculate

This ensures that when bounds eventually arrive via the _bounds_tracker task (which calls _send_updated_target_power(component_ids, None)), the stored proposal is picked up and processed correctly.

The log level is also changed from WARNING to INFO, since this is now an expected transient condition rather than a lost proposal.

How to reproduce

  1. Start a microgrid with at least one EV charger
  2. Create an EVChargerPool and immediately call propose_power() without first waiting for power_status or system_power_bounds
  3. Observe the warning log and note that the proposal has no effect
  4. Send a second proposal after a few seconds — this one succeeds because bounds have arrived

Log output

May 19 13:42:29 c287m260 184e547eb68b[6912]: 2026-05-19T13:42:29+0000 WARNING  
frequenz.sdk.microgrid._power_managing._matryoshka:141: PowerManagingActor: No system 
bounds available for component IDs frozenset({ComponentId(1370)}), but a proposal was 
given.  The proposal will be ignored.

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Priority

None yet

Projects

Status

To do

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions