Building a Virtual Power Plant with Microsoft Orleans - Part 1 Architecture & Data Model

Jul 31, 2026    #opinion   #programming   #csharp  

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 grid

Each 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:

  1. 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.

  2. 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.

  3. Each grain is independently addressable. A technician diagnosing a fault in solar panel SP-77341 calls cluster.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:

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] --> DB

Key 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:

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



Next: Learn The Tools