How the virtual actor model maps perfectly to managing thousands of distributed energy resources
A Virtual Power Plant is a geographically dispersed energy assets solar panels, batteries, EV chargers, heat pumps orchestrated through a single Command And Control software to behave like a single power plant.
But here’s the engineering problem: each asset has its own identity, its own state, its own schedule, and its own hardware communication protocol. Sarah’s Tesla Powerwall should never stop charging her EV because Bob’s SolarEdge battery in a different suburb received a conflicting dispatch command. The software must manage state for hundreds or thousands of assets, independently, concurrently, and without data loss during failures.
This is the problem. And a not easy one to solve but Microsoft Orleans allows us to solve this problem a lot easier. The mapping between Orleans virtual actor model and a VPP’s asset hierarchy is very natural.
The Hierarchy: Sites, Assets, and Aggregators
A real VPP doesn’t just have a flat list of assets. It has a tree:
graph TB
Grid[Grid Operator / Energy Market]
Grid --> Aggregator
Aggregator["VppAggregatorGrain
fleet-wide decisions"]
Aggregator --> Site1["SiteGrain
S-12091
Household"]
Aggregator --> Site2["SiteGrain
S-44722
Business"]
Aggregator --> Site3["SiteGrain
S-88103
Community Battery"]
Site1 --> Solar1["SolarPanelGrain
SP-77341"]
Site1 --> Battery1["BatteryGrain
BATT-88291"]
Site1 --> EV1["EvChargerGrain
EV-33210"]
Site2 --> Solar2["SolarPanelGrain
SP-94112"]
Site2 --> Battery2["BatteryGrain
BATT-55034"]
Site2 --> HP2["HeatPumpGrain
HP-77109"]
Site3 --> Battery3a["BatteryGrain
BATT-10012"]
Site3 --> Battery3b["BatteryGrain
BATT-10013"]
Site3 --> Battery3c["BatteryGrain
BATT-10014"]
classDef aggregator fill:#1a1a2e,stroke:#e94560,color:#fff
classDef site fill:#16213e,stroke:#0f3460,color:#fff
classDef asset fill:#0f3460,stroke:#533483,color:#fff
classDef grid fill:#2d2d2d,stroke:#666,color:#fff
class Aggregator aggregator
class Site1,Site2,Site3 site
class Solar1,Solar2,Battery1,Battery2,Battery3a,Battery3b,Battery3c,EV1,HP2 asset
class Grid gridEach box above is an independent stateful grain in Orleans.
Site grains own the household or business. They know the baseline consumption profile, and the site-specific energy strategy. When the aggregator issues a fleet-wide command, the site resolves how to execute it locally: “During peak, discharge the battery first, then curtail EV charging if needed.”
Asset grains own individual hardware. They hold the asset’s serial number, its operational schedule, its current state (power output, battery charge percentage, inverter status), and its communication protocol adapter. They don’t know or care what other assets are doing.
The aggregator grain queries fleet capacity with a single call and dispatches overrides with a broadcast. It doesn’t need to know that James’s battery is a Tesla and Bob’s is an LG it just calls IBatteryGrain.GetAvailableCapacity() on every registered battery.
This simplifies the overhead and complexity of a project like and means:
-
Each grain is single-threaded. A dispatch command and a telemetry tick can never race inside the same battery grain. No locks. No transactions. No deadlocks.
-
Each grain is independently persistent. If the silo hosting Sarah’s site grain crashes, it reactivates on another silo, reloads all state from PostgreSQL, and none of Sarah’s data is lost. Bob’s grains were on a different silo and never noticed.
-
Each grain is independently addressable. A technician diagnosing a fault in solar panel
SP-77341callscluster.GetGrain<ISolarPanelGrain>("SP-77341")directly. They don’t need to know which site it belongs to or which silo hosts it.
The Asset Grain in Code
Here’s what a real battery grain looks like. It owns three things:
- State (persisted to PostgreSQL): charge percentage, capacity, health metrics, current operating mode and more
- Schedule (per-asset time windows): when to charge, when to discharge, reserve floor percentages
- A timer (periodic telemetry): reads the actual hardware status every N seconds
public interface IBatteryGrain : IGrainWithStringKey
{
Task ReportTelemetry(double socPercent, double actualKw);
Task SetDischargeRate(double kw);
Task SetSchedule(TimeWindow[] windows);
Task<double> GetAvailableCapacityKwh();
Task<BatteryStatus> GetStatus();
}
[Alias("BatteryState")]
public record BatteryState
{
[Id(0)] public string AssetId { get; set; } = "";
[Id(1)] public double ChargePercent { get; set; }
[Id(2)] public double ActualKw { get; set; }
[Id(3)] public double DesiredDischargeKw { get; set; }
[Id(4)] public double TotalCapacityKwh { get; set; } = 13.5;
[Id(5)] public TimeWindow[] Schedule { get; set; } = [];
[Id(6)] public double ReserveFloorPercent { get; set; } = 20;
[Id(7)] public DateTimeOffset LastTelemetry { get; set; }
}
public class BatteryGrain : Grain, IBatteryGrain
{
private readonly IPersistentState<BatteryState> _state;
private readonly IProtocolAdapter _protocol;
private IGrainTimer? _telemetryTimer;
public BatteryGrain(
[PersistentState("battery", "AdoNet")] IPersistentState<BatteryState> state,
IProtocolAdapter protocol)
{
_state = state;
_protocol = protocol;
}
public override Task OnActivateAsync(CancellationToken ct)
{
_telemetryTimer = this.RegisterGrainTimer(
static (grain, ct) => ((BatteryGrain)grain).ReadHardware(ct),
this,
new GrainTimerCreationOptions
{
DueTime = TimeSpan.FromSeconds(5),
Period = TimeSpan.FromSeconds(15), // BMS polling interval
KeepAlive = true
});
return Task.CompletedTask;
}
public async Task SetDischargeRate(double kw)
{
_state.DesiredDischargeKw = kw;
await _state.WriteStateAsync(); // Persist intent immediately
await _protocol.SendCommand( // Tell the physical hardware
_state.AssetId, "set_discharge", new { power_kw = kw });
}
public Task<double> GetAvailableCapacityKwh()
{
var available = Math.Max(0,
_state.ChargePercent - _state.ReserveFloorPercent);
return Task.FromResult(_state.TotalCapacityKwh * available / 100.0);
}
public Task<BatteryStatus> GetStatus() => Task.FromResult(new BatteryStatus
{
AssetId = _state.AssetId,
ChargePercent = _state.ChargePercent,
ActualKw = _state.ActualKw,
Mode = _state.DesiredDischargeKw > 0 ? "discharging" : "idle",
LastTelemetry = _state.LastTelemetry
});
private async Task ReadHardware(CancellationToken ct)
{
var status = await _protocol.ReadStatus(_state.AssetId);
if (status is null) return;
_state.ChargePercent = status.SocPercent;
_state.ActualKw = status.ActualPowerKw;
_state.LastTelemetry = DateTimeOffset.UtcNow;
await _state.WriteStateAsync();
if (_state.DesiredDischargeKw > 0
&& Math.Abs(_state.ActualKw - _state.DesiredDischargeKw) > 0.5)
{
await _protocol.SendCommand(
_state.AssetId, "set_discharge",
new { power_kw = _state.DesiredDischargeKw });
}
}
}
Notice the separation: _state holds the grain’s truth (what the battery should be doing). _protocol communicates with the physical hardware (what the battery is actually doing). The timer reconciles them every 15 seconds. If there’s a discrepancy the desired discharge is 2kW but the hardware reports 1.2kW the timer resends the command to the battery again.
The Site Grain: Coordinating Within a Household
A site with solar, a battery, and an EV charger has competing priorities. The battery should charge during off-peak, but if the EV is plugged in and the driver leaves at 7am, the EV gets priority. The site grain resolves this:
public class SiteGrain : Grain, ISiteGrain
{
private readonly IPersistentState<SiteState> _state;
public SiteGrain(
[PersistentState("site", "AdoNet")] IPersistentState<SiteState> state)
{
_state = state;
}
public async Task OnFleetDischargeCommand(double kwPerSite)
{
// 1. Check if battery can fulfill the request
var battery = GrainFactory.GetGrain<IBatteryGrain>(_state.BatteryId);
var available = await battery.GetAvailableCapacityKwh();
if (available * 1000 >= kwPerSite)
{
await battery.SetDischargeRate(kwPerSite);
return;
}
// 2. If battery is insufficient, curtail EV charging
var ev = GrainFactory.GetGrain<IEvChargerGrain>(_state.EvChargerId);
var evStatus = await ev.GetStatus();
if (evStatus.IsCharging)
{
await ev.PauseCharging();
await battery.SetDischargeRate(kwPerSite);
}
}
public async Task<double> GetFleetCapacityKwh()
{
var battery = GrainFactory.GetGrain<IBatteryGrain>(_state.BatteryId);
return await battery.GetAvailableCapacityKwh();
}
public async Task RegisterAsset(string assetType, string assetId)
{
switch (assetType)
{
case "battery": _state.BatteryId = assetId; break;
case "solar": _state.SolarPanelId = assetId; break;
case "ev": _state.EvChargerId = assetId; break;
}
await _state.WriteStateAsync();
}
}
The site grain orchestrates locally. The aggregator grain orchestrates globally:
public async Task DispatchFleetDischarge(double targetMw)
{
double totalCapacity = 0;
foreach (var siteId in _state.RegisteredSites)
{
var site = GrainFactory.GetGrain<ISiteGrain>(siteId);
totalCapacity += await site.GetFleetCapacityKwh();
}
if (totalCapacity * 1000 < targetMw)
{
// Not enough capacity — partial dispatch or alert grid operator
targetMw = totalCapacity * 1000;
}
var kwPerSite = targetMw * 1000 / _state.RegisteredSites.Count;
var tasks = _state.RegisteredSites.Select(id =>
GrainFactory.GetGrain<ISiteGrain>(id).OnFleetDischargeCommand(kwPerSite));
await Task.WhenAll(tasks); // Parallel — thousands of sites concurrently
}
How Orleans Hosts It All: The Silo Architecture
An Orleans silo is the process that hosts grain activations. In production, you run multiple silos, and Orleans distributes grains across them automatically:
graph TB
subgraph Silo 1: US-West-1
SG1["SiteGrain
S-12091"]
SG2["SiteGrain
S-99243"]
BG1["BatteryGrain
BATT-88291"]
SP1["SolarPanelGrain
SP-77341"]
EV1["EvChargerGrain
EV-33210"]
BG2["BatteryGrain
BATT-11205"]
SG1 --> BG1
SG1 --> SP1
SG1 --> EV1
SG2 --> BG2
end
subgraph Silo 2: US-West-2
SG3["SiteGrain
S-88103"]
SG4["SiteGrain
S-66219"]
BGa["BatteryGrain
BATT-10012"]
BGb["BatteryGrain
BATT-10013"]
BGc["BatteryGrain
BATT-10014"]
SP2["SolarPanelGrain
SP-94112"]
SG3 --> BGa
SG3 --> BGb
SG3 --> BGc
SG4 --> SP2
end
subgraph Silo 3: US-East-1
SG5["SiteGrain
S-44722"]
BG3["BatteryGrain
BATT-55034"]
HP1["HeatPumpGrain
HP-77109"]
EV2["EvChargerGrain
EV-33901"]
AG["VppAggregatorGrain
primary"]
SG5 --> BG3
SG5 --> HP1
SG5 --> EV2
end
DB[(Shared PostgreSQL)]
Silo1[fa:fa-server Silo 1] --> DB
Silo2[fa:fa-server Silo 2] --> DB
Silo3[fa:fa-server Silo 3] --> DBKey design decisions:
1. Clustering via PostgreSQL Silos discover each other by reading the OrleansMembershipTable in PostgreSQL. When a new silo starts, it registers itself. When one fails, the others detect it within seconds.
2. Grain state lives in PostgreSQL When a grain calls await _state.WriteStateAsync(), its state is serialized as binary and persisted. If the silo crashes, the grain reactivates on another silo, reads its state from PostgreSQL, and continues. No data loss.
Stateless Protocol Adapters
Not every grain needs to own state. Protocol adapters the code that translates set_discharge into a vendor-specific binary command. A Tesla Powerwall speaks one protocol; an LG battery speaks another. Neither needs to remember anything between calls.
Orleans provides stateless worker grains for exactly this:
public interface ITeslaProtocolAdapter : IGrainWithIntegerKey
{
Task SendCommand(string assetId, string command, object parameters);
Task<HardwareStatus?> ReadStatus(string assetId);
}
[StatelessWorker(maxLocalWorkers: 64)]
public class TeslaProtocolAdapter : Grain, ITeslaProtocolAdapter
{
private readonly HttpClient _http;
public TeslaProtocolAdapter(IHttpClientFactory httpFactory)
{
_http = httpFactory.CreateClient("tesla");
}
public async Task SendCommand(string assetId, string command, object parameters)
{
var payload = new { gateway_id = assetId, command, parameters };
await _http.PostAsJsonAsync("/api/command", payload);
}
public async Task<HardwareStatus?> ReadStatus(string assetId)
{
return await _http.GetFromJsonAsync<HardwareStatus>(
$"/api/status/{assetId}");
}
}
The [StatelessWorker(maxLocalWorkers: 64)] attribute tells Orleans: activate up to 64 copies of this grain per silo, route requests to any of them, and never persist anything. The battery grain injects or resolves its matching adapter by vendor type, and the adapter handles the protocol details without burdening the stateful grain.
This separation keeps the battery grain focused on state, schedule, and decisions not on Modbus bit-banging or REST retry logic. If a protocol adapter’s HTTP client pool is saturated, Orleans spins up another activation automatically. No thread pool management, no connection pooling code.
Why This Beats Microservices
For a VPP, each DER is a stateful entity that must be managed independently. With microservices, you’d need:
- A database per service (or careful partitioning across Postgres tables)
- A service mesh for routing requests to the right instance
- Manual retry logic with exponential backoff
- An external scheduler for periodic telemetry collection
- A distributed lock or transaction manager to prevent conflicting commands
With Orleans, you get all of this for free:
| Concern | Microservices | Orleans |
|---|---|---|
| Stateful entities | Stateless + external DB per call | Grain holds state in memory, syncs to DB on write |
| Concurrency | Distributed locks, sagas | Single-threaded grain no locks needed |
| Failover | Manual health checks, DNS re-routing | Automatic activation on healthy silo |
| Telemetry polling | External cron/job scheduler | Built-in RegisterGrainTimer |
| Scheduled operations | External scheduler + database | Built-in persistent IRemindable |
| Request routing | Service mesh, circuit breakers | Orleans runtime routes transparently |
The key insight: grains aren’t just a different way to organize code at the base a different execution model. Each grain is single-threaded, persistent, and independently lifecycle-managed. This maps perfectly to a physical DER that is single-threaded (one set of hardware at a time), persistent (has a known serial number and configuration), and independently lifecycle-managed (connects and disconnects from the grid on its own terms).
References: Tesla South Australia VPP (50,000 homes), AGL VPP program ($1/kWh credits), California VPP pilot (100,000 batteries, 535 MW), Wood Mackenzie 2025 VPP capacity report