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:
-
_check_overlapping_buckets(component_ids) — Structural invariant check. Runs before storing the proposal to prevent creating overlapping buckets. Raises NotImplementedError if overlap is detected.
-
_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
- Start a microgrid with at least one EV charger
- Create an
EVChargerPool and immediately call propose_power() without first waiting for power_status or system_power_bounds
- Observe the warning log and note that the proposal has no effect
- 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.
Description
The
PowerManagingActorsilently 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.pysrc/frequenz/sdk/microgrid/_power_managing/_shifting_matryoshka.pyRoot 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):Step (1) —
_add_system_bounds_tracker()initializesself._system_bounds[component_ids]with a placeholder:It then spawns an async task (
_bounds_tracker) that will eventually receive and populate real bounds. But since it's started withasyncio.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, withself._system_bounds[component_ids]still holding theNone/Noneplaceholder.This flows into
Matryoshka.calculate_target_power()(orShiftingMatryoshka.calculate_target_power()), which calls_validate_component_ids(). That method checks:Since this is the first proposal, there's no bucket yet, and the bounds are the
None/Noneplaceholder — validation fails and returnsFalsebefore_update_buckets()is called. The proposal is never stored.When real bounds eventually arrive via the
_bounds_trackertask,_send_updated_target_power(component_ids, None)is called withproposal=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:
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:
SendOnUpdate(PowerBoundsCalculator)created on firstsystem_power_boundsaccessEVCSystemBoundsTrackerstarted during reference store constructionComponentPoolStatusfromPowerDistributingActorANDEVChargerDatafrom microgrid APIpower_statusfirst (which triggers_add_system_bounds_trackerviaReportRequest), then waits for a report before proposing — bounds are ready by proposal timepropose_powerimmediately, before the two-gate dependency inEVCSystemBoundsTrackerhas been satisfiedSendOnUpdateinstance (via sharedBatteryPoolReferenceStore), andresend_latest=Truedelivers cached bounds to new receiversEVCSystemBoundsTrackerinitializes withComponentPoolStatus(working=set(), uncertain=set()), so all incoming EV data is discarded until a status update arrivesThe
EVCSystemBoundsTrackerhas a cold-start problem: it initializes_component_pool_statuswith emptyworkinganduncertainsets. Until thePowerDistributingActorsends aComponentPoolStatusmarking the EV charger as working, all incomingEVChargerDatais discarded:This means the
bounds_channel(even withresend_latest=True) has nothing to resend when thePowerManagingActorsubscribes.Proposed fix
Split the old
_validate_component_idsmethod (in bothMatryoshkaandShiftingMatryoshka) into two separate concerns:_check_overlapping_buckets(component_ids)— Structural invariant check. Runs before storing the proposal to prevent creating overlapping buckets. RaisesNotImplementedErrorif overlap is detected._have_system_bounds(component_ids, proposal, system_bounds)— Timing/readiness check. Runs after storing the proposal. If bounds aren't available yet, returnsFalseto skip power calculation, but the proposal is already persisted in the bucket.The reordering in
calculate_target_power()changes from:This ensures that when bounds eventually arrive via the
_bounds_trackertask (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
WARNINGtoINFO, since this is now an expected transient condition rather than a lost proposal.How to reproduce
EVChargerPooland immediately callpropose_power()without first waiting forpower_statusorsystem_power_boundsLog output