Salta al contenuto principale

EV Charging Load Balancing: Algoritmi Real-Time

Sono le 18:30 di un martedi sera. Migliaia di automobilisti italiani rientrano a casa dopo il lavoro, parcheggiano il loro veicolo elettrico e collegano…

Cosa ne pensi di questo articolo?

EV Charging Load Balancing: Algoritmi Real-Time
EnergyTechFoto:Daniil KomovPexels

In Breve

Sono le 18:30 di un martedi sera. Migliaia di automobilisti italiani rientrano a casa dopo il lavoro, parcheggiano il loro veicolo elettrico e collegano…

EV Charging Load Balancing: Algoritmi Real-Time per la Ricarica Intelligente

Sono le 18:30 di un martedi sera. Migliaia di automobilisti italiani rientrano a casa dopo il lavoro, parcheggiano il loro veicolo elettrico e collegano il cavo di ricarica. In pochi minuti, la domanda di potenza sul trasformatore di quartiere schizza verso l'alto. Senza una gestione intelligente, questo scenario - che si ripete ogni giorno in modo sempre più intenso man mano che cresce il parco EV - porta a sovraccarichi di rete, blackout localizzati e costi di potenziamento infrastrutturale nell'ordine dei miliardi di euro.

Nel 2025 circolano oltre 17 milioni di veicoli elettrici in Europa, di cui circa 230.000 in Italia con 94.230 nuove immatricolazioni solo nel 2025 (+46% rispetto al 2024). La proiezione al 2030 parla di oltre 50 milioni di EV in Europa: ogni veicolo richiede mediamente tra 7 e 22 kW di potenza durante la ricarica. Moltiplicato per milioni di utenti che ricaricano nelle stesse ore serali, otteniamo un problema di stabilità di rete senza precedenti.

La soluzione non e costruire più rete: e troppo costoso e lento. La soluzione e il load balancing intelligente: algoritmi real-time che distribuiscono la potenza disponibile tra i veicoli in carica, rispettando i vincoli di rete, minimizzando i costi energetici e massimizzando la soddisfazione degli utenti. In questo articolo esploriamo l'intera stack tecnologica: dal protocollo OCPP 2.0.1 agli algoritmi di ottimizzazione, dal reinforcement learning all'integrazione V2G, con codice Python funzionante e il contesto normativo italiano.

Cosa Imparerai in Questo Articolo

  • Duck curve e impatto EV non gestiti sulla rete di distribuzione
  • Tassonomia degli algoritmi di load balancing: Static, Dynamic, Predictive
  • OCPP 2.0.1 Smart Charging: SetChargingProfile, ChargingSchedule, StackLevel
  • Implementazione Python: Equal Share con ricalcolo dinamico real-time
  • Algoritmo Priority-Based con heap queue (SoC, deadline, tariffa)
  • Reinforcement Learning (PPO) per ottimizzazione costi energia
  • Vehicle-to-Grid (V2G): bidirezionalita, ISO 15118-20, frequency regulation
  • Integrazione fotovoltaico: surplus solare e routing verso EV
  • Architettura microservizi: Charge Controller, Energy Manager, Forecaster
  • Contesto italiano: tariffe biorarie, CER con EV, normativa ARERA

Serie EnergyTech - 10 Articoli

# Articolo Stato
1 Smart Grid e IoT: Architettura per la Rete Elettrica del Futuro Pubblicato
2 Architettura DERMS: Aggregare Milioni di Risorse Distribuite Pubblicato
3 Battery Management System: Algoritmi di Controllo per BESS Pubblicato
4 Digital Twin della Rete Elettrica con Python e Pandapower Pubblicato
5 Renewable Energy Forecasting: ML per FV e Eolico Pubblicato
6 EV Charging Load Balancing: Algoritmi Real-Time (sei qui) Corrente
7 MQTT e InfluxDB per Telemetria Energetica Real-Time In arrivo
8 IEC 61850: Comunicazione nella Sottostazione Elettrica In arrivo
9 Carbon Accounting Software: Misurare e Ridurre le Emissioni In arrivo
10 Blockchain per la P2P Energy Trading nelle CER In arrivo

Il Problema del Picco: Duck Curve e EV non Gestiti

Per capire perchè il load balancing EV non e un optional ma una necessità, dobbiamo partire dalla fisica della rete elettrica e da un concetto che i gestori di rete temono ogni giorno: la duck curve.

La Duck Curve e il Suo Aggravarsi

La duck curve (curva a papera) descrive la forma della curva di domanda netta di elettricita durante una giornata tipo in un sistema con alta penetrazione di fotovoltaico. Si chiama cosi perchè la curva somiglia al profilo di un papero: un ventre basso a meta giornata (quando il solare produce abbondantemente e la domanda netta e bassa) e una gobba alta la sera (quando il sole cala, la produzione FV crolla e la domanda residenziale schizza verso l'alto).

Senza EV, la rete italiana gestisce già questo problema ogni giorno. Con la crescita degli EV non gestiti, il problema si amplifica drasticamente. Ricerche MDPI (2025) mostrano che senza ottimizzazione il picco di domanda passa da 22.000 MW baseline a 35.000 MW con 5 milioni di EV. Con smart charging la stessa flotta aggiunge solo 2.000-3.000 MW distribuiti nelle ore notturne.

Scenario Ora di picco Carico aggiuntivo Rischio rete
Baseline (nessun EV) 19:00-20:00 0 MW Gestibile
500.000 EV non gestiti 18:30-19:30 +3.500 MW Stress locale trasformatori
1,5M EV non gestiti 18:00-20:00 +10.500 MW Sovraccarico diffuso
5M EV non gestiti 18:00-21:00 +35.000 MW Blackout sistemici
1,5M EV con smart charging Distribuito 22:00-06:00 +2.100 MW diluito Trascurabile

Un'ottimizzazione AI-driven del profilo di carica può ridurre il picco di domanda del 16% con 1,5 milioni di EV, del 21% con 3 milioni, e del 34% con 5 milioni (fonte: MDPI Electronics, 2025). La differenza tra uno scenario gestito e uno non gestito non e di pochi punti percentuali: e la differenza tra una rete stabile e un collasso.

Impatto sui Trasformatori di Distribuzione

Il problema non e solo a livello di sistema trasmissione: e soprattutto a livello di rete di distribuzione locale. Un trasformatore di quartiere da 400 kVA serve tipicamente 80-120 famiglie. Se 15 di queste hanno un EV che inizia a caricare a 11 kW tutti insieme alle 18:30, il carico aggiuntivo e 165 kW - quasi il 41% della capacità nominale del trasformatore, che potrebbe già essere al 60-70% per il consumo domestico. Risultato: surriscaldamento, riduzione della vita utile, e nei casi peggiori apertura delle protezioni.

Il Costo dell'Inazione

Secondo Terna, il costo di potenziamento della rete di distribuzione italiana per supportare la transizione EV senza smart charging e stimato in 10-15 miliardi di euro entro il 2030. Con smart charging diffuso, la stessa transizione richiede investimenti infrastrutturali del 60-70% inferiori, semplicemente spostando il carico nelle ore in cui la rete ha capacità disponibile.

Tassonomia degli Algoritmi di Load Balancing

Non esiste un unico algoritmo di load balancing ottimale: la scelta dipende dalla dimensione dell'installazione, dalla disponibilità di dati storici, dai requisiti di latenza e dalla complessità che il gestore e disposto a mantenere. Classifichiamo gli approcci in tre famiglie principali.

Algoritmi Statici

Gli algoritmi statici definiscono a priori le regole di distribuzione della potenza, senza adattarsi dinamicamente allo stato del sistema. Sono semplici da implementare e debuggare, ideali per installazioni piccole e omogenee.

  • Round-Robin: ogni connettore riceve la stessa potenza massima a rotazione. Semplice ma inefficiente: un EV quasi carico occupa la stessa potenza di uno scarico.
  • Equal Share: la potenza totale disponibile viene divisa equamente tra tutti i connettori attivi. Efficace per installazioni omogenee, ma non considera le esigenze individuali.
  • First-Come-First-Served: i primi EV collegati ricevono potenza massima, i successivi si dividono il residuo. Penalizza chi arriva tardi.

Algoritmi Dinamici

Gli algoritmi dinamici adattano la distribuzione in tempo reale basandosi sullo stato corrente del sistema: numero di EV connessi, livello di carica (SoC), potenza disponibile, priorità degli utenti.

  • Proportional: la potenza viene distribuita proporzionalmente all'energia richiesta da ciascun EV. Chi ha la batteria più scarica riceve più potenza.
  • Priority-Based: ogni EV ha un punteggio di priorità calcolato su più fattori (SoC, deadline, abbonamento). La potenza scorre verso le priorità più alte.
  • Fuzzy Logic: regole linguistiche del tipo "se SoC e basso E deadline e vicina ALLORA priorità e alta". Gestisce bene l'incertezza nei dati di input.

Algoritmi Predittivi (ML-based)

Gli algoritmi predittivi usano modelli di machine learning per anticipare eventi futuri (arrivi, partenze, variazioni di prezzo dell'energia) e ottimizzare lo scheduling in anticipo, minimizzando i costi totali sul lungo periodo.

  • Model Predictive Control (MPC): risolve un problema di ottimizzazione su un orizzonte temporale futuro (es. 4 ore) ad ogni intervallo di controllo (es. 15 min), applicando solo il primo step e riformulando al passo successivo.
  • Deep Reinforcement Learning (DRL): un agente impara la policy ottimale interagendo con un simulatore. PPO e SAC sono i più usati in produzione.
  • Stochastic Programming: incorpora esplicitamente l'incertezza negli arrivi e nelle partenze degli EV, generando schedule robusti agli scenari peggiori.
Algoritmo Complessità Latenza Ottimalita Dati richiesti Use case ideale
Equal Share Bassa <1ms Bassa Nessuno Residenziale, <10 prese
Priority-Based Media <10ms Media-Alta SoC, deadline utente Uffici, hotel, flotte
MPC Alta 100ms-1s Alta Prezzi energia, previsioni Hub >50 prese, C&I
Deep RL (PPO) Molto alta <50ms (inference) Molto alta Storico 6+ mesi Grandi hub, V2G integrato

OCPP 2.0.1 Smart Charging: Il Protocollo della Ricarica Intelligente

OCPP (Open Charge Point Protocol) e il linguaggio universale tra i charge point (CP) e il central system (CSMS - Charging Station Management System). Nella versione 2.0.1, lo Smart Charging e stato profondamente migliorato rispetto a OCPP 1.6, introducendo una gestione più granulare e affidabile dei profili di carica.

Il Meccanismo SetChargingProfile

Il CSMS invia al charge point un messaggio SetChargingProfileRequest contenente un ChargingProfile che definisce come la stazione deve erogare potenza nel tempo. Un ChargingProfile e composto da:

  • chargingProfilePurpose: ChargePointMaxProfile (limita tutta la stazione), TxDefaultProfile (default per nuove transazioni), TxProfile (specifica per una transazione in corso).
  • stackLevel: intero che definisce la priorità. In caso di profili sovrapposti, vince quello con stackLevel più alto.
  • chargingSchedule: lista di periodi (startPeriod in secondi, limit in A o W) che definiscono la curva di carica nel tempo.

Sequenza Critica: Diminuire Prima di Aumentare

Quando si inviano profili aggiornati a più stazioni, e fondamentale inviare prima i comandi di riduzione di potenza e poi quelli di aumento. Se si aumenta prima, si rischia di superare il limite di rete per un breve intervallo, triggering le protezioni elettriche o generando picchi di domanda con conseguenti penali contrattuali. Questa regola e non negoziabile in produzione.

Implementazione Python: Generazione Profili OCPP 2.0.1

"""
OCPP 2.0.1 Smart Charging Profile Generator
Genera ChargingProfile compatibili con la specifica OCPP 2.0.1.
"""
from dataclasses import dataclass, field
from typing import List, Optional
from enum import Enum
import json
from datetime import datetime, timezone


class ChargingRateUnit(str, Enum):
    WATTS = "W"
    AMPERES = "A"


class ChargingProfilePurpose(str, Enum):
    CHARGE_POINT_MAX = "ChargePointMaxProfile"
    TX_DEFAULT = "TxDefaultProfile"
    TX = "TxProfile"


class ChargingProfileKind(str, Enum):
    ABSOLUTE = "Absolute"
    RECURRING = "Recurring"
    RELATIVE = "Relative"


@dataclass
class ChargingSchedulePeriod:
    """Singolo intervallo nella schedule di ricarica."""
    start_period: int        # secondi dall'inizio della schedule
    limit: float             # limite in W o A
    number_phases: Optional[int] = None


@dataclass
class ChargingSchedule:
    """Schedule completa di un profilo di ricarica."""
    id: int
    charging_rate_unit: ChargingRateUnit
    charging_schedule_period: List[ChargingSchedulePeriod]
    duration: Optional[int] = None
    start_schedule: Optional[str] = None
    min_charging_rate: Optional[float] = None


@dataclass
class ChargingProfile:
    """Profilo di ricarica OCPP 2.0.1 completo."""
    id: int
    stack_level: int
    charging_profile_purpose: ChargingProfilePurpose
    charging_profile_kind: ChargingProfileKind
    charging_schedule: List[ChargingSchedule]
    transaction_id: Optional[str] = None
    valid_from: Optional[str] = None
    valid_to: Optional[str] = None


def build_equal_share_profile(
    profile_id: int,
    power_limit_watts: float,
    duration_seconds: int = 3600,
    stack_level: int = 1
) -> ChargingProfile:
    """
    Costruisce un profilo OCPP per distribuzione equa (potenza fissa).
    Usato dall'algoritmo Equal Share per applicare il limite calcolato.
    """
    schedule_period = ChargingSchedulePeriod(
        start_period=0,
        limit=power_limit_watts,
        number_phases=3
    )
    schedule = ChargingSchedule(
        id=profile_id,
        charging_rate_unit=ChargingRateUnit.WATTS,
        charging_schedule_period=[schedule_period],
        duration=duration_seconds,
        start_schedule=datetime.now(timezone.utc).isoformat()
    )
    return ChargingProfile(
        id=profile_id,
        stack_level=stack_level,
        charging_profile_purpose=ChargingProfilePurpose.TX,
        charging_profile_kind=ChargingProfileKind.ABSOLUTE,
        charging_schedule=[schedule]
    )


def build_time_of_use_profile(
    profile_id: int,
    schedule_periods: List[tuple],
    stack_level: int = 2
) -> ChargingProfile:
    """
    Costruisce un profilo Time-of-Use con potenza variabile nel tempo.
    Esempio: alta potenza di notte (energia economica), bassa di giorno.

    Args:
        schedule_periods: lista di (start_period_sec, limit_watts)
    """
    periods = [
        ChargingSchedulePeriod(start_period=start, limit=limit)
        for start, limit in schedule_periods
    ]
    schedule = ChargingSchedule(
        id=profile_id,
        charging_rate_unit=ChargingRateUnit.WATTS,
        charging_schedule_period=periods,
        start_schedule=datetime.now(timezone.utc).isoformat()
    )
    return ChargingProfile(
        id=profile_id,
        stack_level=stack_level,
        charging_profile_purpose=ChargingProfilePurpose.TX_DEFAULT,
        charging_profile_kind=ChargingProfileKind.ABSOLUTE,
        charging_schedule=[schedule]
    )


def profile_to_ocpp_dict(profile: ChargingProfile) -> dict:
    """Serializza il profilo nel formato JSON OCPP 2.0.1."""
    return {
        "id": profile.id,
        "stackLevel": profile.stack_level,
        "chargingProfilePurpose": profile.charging_profile_purpose.value,
        "chargingProfileKind": profile.charging_profile_kind.value,
        "chargingSchedule": [
            {
                "id": sched.id,
                "chargingRateUnit": sched.charging_rate_unit.value,
                "chargingSchedulePeriod": [
                    {
                        "startPeriod": p.start_period,
                        "limit": p.limit,
                    }
                    for p in sched.charging_schedule_period
                ],
                **({"duration": sched.duration} if sched.duration else {}),
                **({"startSchedule": sched.start_schedule} if sched.start_schedule else {}),
            }
            for sched in profile.charging_schedule
        ],
    }


if __name__ == "__main__":
    # Profilo Equal Share: 3.3 kW per 2 ore
    profile = build_equal_share_profile(
        profile_id=101,
        power_limit_watts=3300.0,
        duration_seconds=7200
    )
    print(json.dumps(profile_to_ocpp_dict(profile), indent=2))

    # Profilo Time-of-Use: 11 kW da adesso, 3.3 kW dopo 4 ore
    tou = build_time_of_use_profile(
        profile_id=102,
        schedule_periods=[
            (0, 11000),         # da ora: 11 kW (fascia fuori punta)
            (4 * 3600, 3300),   # dopo 4 ore: 3.3 kW (fascia F1 inizia)
        ],
        stack_level=2
    )
    print(json.dumps(profile_to_ocpp_dict(tou), indent=2))

Algoritmo Equal Share con Ricalcolo Dinamico

L'Equal Share e l'algoritmo di load balancing più diffuso nelle installazioni reali. La sua forza non e nell'eleganza matematica ma nella robustezza operativa: e semplice da spiegare agli utenti, facile da debuggare e sufficientemente efficace per la maggior parte delle installazioni fino a 30-40 prese. La complessità reale sta nel gestire i ricalcoli dinamici: ogni volta che un EV si connette o disconnette, o quando la potenza disponibile cambia per un segnale del DSO, il sistema deve ricalcolare e ridistribuire in millisecondi, rispettando la sequenza critica riduzioni-poi-aumenti.

"""
Equal Share Load Balancer
Distribuzione equa con ricalcolo dinamico event-driven.
Thread-safe per deployment in produzione con OCPP WebSocket server.
"""
import asyncio
import logging
from dataclasses import dataclass
from typing import Dict, Optional, Callable, Awaitable
from enum import Enum
import time

logger = logging.getLogger(__name__)


class ConnectorStatus(str, Enum):
    AVAILABLE = "Available"
    CHARGING = "Charging"
    SUSPENDED_EV = "SuspendedEV"
    FINISHING = "Finishing"
    UNAVAILABLE = "Unavailable"
    FAULTED = "Faulted"


@dataclass
class ConnectorState:
    """Stato di un singolo connettore nella stazione."""
    connector_id: str
    status: ConnectorStatus = ConnectorStatus.AVAILABLE
    transaction_id: Optional[str] = None
    allocated_power_w: float = 0.0
    max_power_w: float = 22000.0    # max fisico del connettore (22 kW AC trifase)
    min_power_w: float = 1380.0     # min per mantenere la carica (6A x 230V)
    soc_percent: Optional[float] = None
    connected_at: Optional[float] = None

    @property
    def is_charging(self) -> bool:
        return self.status == ConnectorStatus.CHARGING

    @property
    def session_duration_min(self) -> float:
        if self.connected_at is None:
            return 0.0
        return (time.time() - self.connected_at) / 60.0


@dataclass
class StationConfig:
    """Configurazione della stazione di ricarica."""
    station_id: str
    max_station_power_w: float
    dynamic_limit_w: Optional[float] = None
    rebalance_interval_s: float = 30.0

    @property
    def available_power_w(self) -> float:
        if self.dynamic_limit_w is not None:
            return min(self.max_station_power_w, self.dynamic_limit_w)
        return self.max_station_power_w


ProfileSendCallback = Callable[[str, str, float], Awaitable[bool]]


class EqualShareBalancer:
    """
    Load balancer Equal Share thread-safe e asincrono.
    Gestisce la distribuzione equa della potenza disponibile
    tra tutti i connettori attivamente in carica.
    """

    def __init__(
        self,
        config: StationConfig,
        profile_sender: ProfileSendCallback
    ):
        self._config = config
        self._connectors: Dict[str, ConnectorState] = {}
        self._send_profile = profile_sender
        self._lock = asyncio.Lock()
        self._last_allocation: Dict[str, float] = {}

    async def register_connector(
        self,
        connector_id: str,
        max_power_w: float = 22000.0,
        min_power_w: float = 1380.0
    ) -> None:
        async with self._lock:
            self._connectors[connector_id] = ConnectorState(
                connector_id=connector_id,
                max_power_w=max_power_w,
                min_power_w=min_power_w
            )
            logger.info("Connettore %s registrato (max=%.0f W)", connector_id, max_power_w)

    async def on_ev_connected(self, connector_id: str, transaction_id: str) -> None:
        """Chiamato quando un EV si connette. Triggera ricalcolo immediato."""
        async with self._lock:
            if connector_id not in self._connectors:
                logger.warning("Connettore sconosciuto: %s", connector_id)
                return
            self._connectors[connector_id].status = ConnectorStatus.CHARGING
            self._connectors[connector_id].transaction_id = transaction_id
            self._connectors[connector_id].connected_at = time.time()
        await self._rebalance()

    async def on_ev_disconnected(self, connector_id: str) -> None:
        """Chiamato quando un EV si disconnette. Libera la potenza e redistribuisce."""
        async with self._lock:
            if connector_id not in self._connectors:
                return
            connector = self._connectors[connector_id]
            connector.status = ConnectorStatus.AVAILABLE
            connector.transaction_id = None
            connector.allocated_power_w = 0.0
            connector.connected_at = None
        await self._rebalance()

    async def update_dynamic_limit(self, limit_w: float) -> None:
        """Aggiorna il limite DSO in tempo reale. Triggera ricalcolo immediato."""
        async with self._lock:
            self._config.dynamic_limit_w = limit_w
            logger.info("Limite DSO aggiornato: %.0f W (stazione %s)", limit_w, self._config.station_id)
        await self._rebalance()

    async def _rebalance(self) -> None:
        """
        Calcola la nuova allocazione Equal Share e invia i profili OCPP.
        REGOLA CRITICA: invia prima le riduzioni, poi gli aumenti.
        """
        async with self._lock:
            active = [c for c in self._connectors.values() if c.is_charging]
            if not active:
                return
            available = self._config.available_power_w
            n = len(active)
            raw_share = available / n
            new_allocations: Dict[str, float] = {}

            # Prima passata: applica limiti min/max per connettore
            constrained = []
            free = []
            for connector in active:
                if raw_share <= connector.min_power_w:
                    new_allocations[connector.connector_id] = connector.min_power_w
                    constrained.append(connector)
                elif raw_share >= connector.max_power_w:
                    new_allocations[connector.connector_id] = connector.max_power_w
                    constrained.append(connector)
                else:
                    free.append(connector)

            # Seconda passata: redistribuisce il residuo tra i connettori liberi
            if free:
                allocated_constrained = sum(
                    new_allocations[c.connector_id] for c in constrained
                )
                residual = available - allocated_constrained
                share_free = residual / len(free)
                for connector in free:
                    new_allocations[connector.connector_id] = max(
                        connector.min_power_w,
                        min(connector.max_power_w, share_free)
                    )

        # Ordina: prima le riduzioni, poi gli aumenti (regola OCPP)
        reductions = []
        increases = []
        for connector_id, new_power in new_allocations.items():
            old_power = self._last_allocation.get(connector_id, float('inf'))
            if new_power < old_power:
                reductions.append((connector_id, new_power))
            else:
                increases.append((connector_id, new_power))

        for connector_id, power in reductions:
            success = await self._send_profile(self._config.station_id, connector_id, power)
            if success:
                self._last_allocation[connector_id] = power
                logger.info("Riduzione: %s -> %.0f W", connector_id, power)

        for connector_id, power in increases:
            success = await self._send_profile(self._config.station_id, connector_id, power)
            if success:
                self._last_allocation[connector_id] = power
                logger.info("Aumento: %s -> %.0f W", connector_id, power)

        logger.info(
            "Ribilanciamento: %d EV attivi, %.0f W disponibili, stazione %s",
            len(active), self._config.available_power_w, self._config.station_id
        )

    async def start_periodic_rebalance(self) -> None:
        """Avvia il ricalcolo periodico come safety net per deriva dello stato."""
        async def _loop():
            while True:
                await asyncio.sleep(self._config.rebalance_interval_s)
                await self._rebalance()
        asyncio.create_task(_loop())

    def get_status(self) -> dict:
        return {
            "station_id": self._config.station_id,
            "available_power_w": self._config.available_power_w,
            "connectors": [
                {
                    "connector_id": c.connector_id,
                    "status": c.status.value,
                    "allocated_power_w": c.allocated_power_w,
                    "soc_percent": c.soc_percent,
                    "session_duration_min": round(c.session_duration_min, 1)
                }
                for c in self._connectors.values()
            ]
        }


async def demo_equal_share():
    async def mock_send(station_id, connector_id, power_w) -> bool:
        print(f"  OCPP -> {connector_id}: {power_w:.0f} W")
        return True

    config = StationConfig(station_id="STATION_001", max_station_power_w=44000.0)
    balancer = EqualShareBalancer(config, mock_send)

    for i in range(1, 5):
        await balancer.register_connector(f"C0{i}", max_power_w=22000.0, min_power_w=1380.0)

    print("\n[EV1 connesso]")
    await balancer.on_ev_connected("C01", "TX001")  # C01: 44000 W

    print("\n[EV2 connesso]")
    await balancer.on_ev_connected("C02", "TX002")  # C01,C02: 22000 W ciascuno

    print("\n[EV3 e EV4 connessi]")
    await balancer.on_ev_connected("C03", "TX003")
    await balancer.on_ev_connected("C04", "TX004")  # tutti: 11000 W

    print("\n[DSO riduce limite a 22 kW]")
    await balancer.update_dynamic_limit(22000.0)     # tutti: 5500 W

    print("\n[EV2 si disconnette]")
    await balancer.on_ev_disconnected("C02")          # C01,C03,C04: 7333 W


if __name__ == "__main__":
    asyncio.run(demo_equal_share())

Algoritmo Priority-Based con Punteggio Multi-Fattoriale

L'Equal Share e equo ma non e ottimale dal punto di vista degli utenti. Un EV con batteria al 5% il cui proprietario deve partire tra 30 minuti ha una necessità molto più urgente rispetto a un EV al 60% che rimane parcheggiato per 8 ore. Il Priority-Based Balancing risponde a questa esigenza calcolando un punteggio di urgenza multi-fattoriale e distribuendo la potenza proporzionalmente alle priorità.

Formula del Punteggio di Priorità

Il punteggio combina quattro dimensioni con pesi configurabili:

  • Urgenza SoC (35%): quanto e scarica la batteria. Funzione concava per amplificare i casi critici (es. SoC 5% = urgenza altissima).
  • Pressione temporale (35%): quanto e vicina la partenza. Funzione esponenziale che cresce rapidamente avvicinandosi alla deadline.
  • User tier (15%): livello abbonamento (base/premium/priority). Crea un modello di business con SLA differenziati.
  • Efficienza energetica (15%): premia chi può effettivamente assorbire potenza. Evita di allocare potenza a batterie quasi piene.
"""
Priority-Based Load Balancer.
Distribuisce potenza proporzionalmente ai punteggi di urgenza,
con algoritmo iterativo per rispettare i vincoli min/max.
"""
import math
import asyncio
import logging
from dataclasses import dataclass, field
from typing import Dict, List, Optional
import time

logger = logging.getLogger(__name__)


@dataclass
class ChargingSession:
    """Sessione con tutti i parametri per il calcolo della priorità."""
    connector_id: str
    transaction_id: str
    soc_percent: float
    target_soc_percent: float
    battery_capacity_kwh: float
    departure_time: float        # timestamp unix
    max_power_w: float
    min_power_w: float
    user_tier: int = 1           # 1=base, 2=premium, 3=priority
    connected_at: float = field(default_factory=time.time)

    @property
    def energy_needed_kwh(self) -> float:
        delta = max(0.0, self.target_soc_percent - self.soc_percent) / 100.0
        return self.battery_capacity_kwh * delta

    @property
    def time_remaining_h(self) -> float:
        return max(0.1, (self.departure_time - time.time()) / 3600.0)


class PriorityScoreCalculator:
    """Calcola punteggio di urgenza normalizzato [0, 100]."""

    W_SOC = 0.35
    W_TIME = 0.35
    W_TIER = 0.15
    W_EFFICIENCY = 0.15

    def calculate(self, session: ChargingSession) -> float:
        # 1. Urgenza SoC: concava, amplifica i SoC bassissimi
        soc_ratio = session.soc_percent / 100.0
        soc_score = (1.0 - soc_ratio) ** 1.5 * 100

        # 2. Pressione temporale: esponenziale, cresce rapido vicino alla deadline
        time_score = 100.0 * math.exp(-0.3 * session.time_remaining_h)

        # 3. Tier: bonus per abbonamenti premium
        tier_score = {1: 33.0, 2: 66.0, 3: 100.0}.get(session.user_tier, 33.0)

        # 4. Efficienza: premia chi ha batteria da riempire
        eff = min(100.0, (session.energy_needed_kwh / max(0.1, session.battery_capacity_kwh)) * 100)

        return round(
            self.W_SOC * soc_score +
            self.W_TIME * time_score +
            self.W_TIER * tier_score +
            self.W_EFFICIENCY * eff,
            2
        )


class PriorityBalancer:
    """
    Distribuisce potenza proporzionalmente ai punteggi con vincoli min/max.
    Algoritmo iterativo: separa i connettori con vincoli attivi e redistribuisce
    il residuo tra quelli liberi, ripetendo fino a convergenza.
    """

    def __init__(self, station_id: str, max_station_power_w: float, profile_sender):
        self._station_id = station_id
        self._max_power = max_station_power_w
        self._dynamic_limit: Optional[float] = None
        self._sessions: Dict[str, ChargingSession] = {}
        self._send_profile = profile_sender
        self._score_calc = PriorityScoreCalculator()
        self._lock = asyncio.Lock()

    @property
    def available_power_w(self) -> float:
        if self._dynamic_limit is not None:
            return min(self._max_power, self._dynamic_limit)
        return self._max_power

    async def add_session(self, session: ChargingSession) -> None:
        async with self._lock:
            self._sessions[session.connector_id] = session
        await self._rebalance()

    async def remove_session(self, connector_id: str) -> None:
        async with self._lock:
            self._sessions.pop(connector_id, None)
        await self._rebalance()

    async def update_soc(self, connector_id: str, soc_percent: float) -> None:
        async with self._lock:
            if connector_id in self._sessions:
                self._sessions[connector_id].soc_percent = soc_percent
        await self._rebalance()

    def _compute_allocations(self) -> Dict[str, float]:
        """Algoritmo iterativo per allocazione con vincoli min/max."""
        if not self._sessions:
            return {}

        sessions = list(self._sessions.values())
        scores = {s.connector_id: self._score_calc.calculate(s) for s in sessions}
        allocations: Dict[str, float] = {}
        remaining_power = self.available_power_w
        remaining = sessions[:]

        for _ in range(10):  # max 10 iterazioni per convergenza
            if not remaining:
                break

            total_score = sum(scores[s.connector_id] for s in remaining)
            if total_score == 0:
                share = remaining_power / len(remaining)
                for s in remaining:
                    allocations[s.connector_id] = max(s.min_power_w, min(s.max_power_w, share))
                break

            constrained, free_sessions = [], []
            for s in remaining:
                ratio = scores[s.connector_id] / total_score
                proposed = remaining_power * ratio
                if proposed <= s.min_power_w:
                    allocations[s.connector_id] = s.min_power_w
                    constrained.append(s)
                elif proposed >= s.max_power_w:
                    allocations[s.connector_id] = s.max_power_w
                    constrained.append(s)
                else:
                    free_sessions.append(s)

            if not constrained:
                # Nessun vincolo: allocazione finale proporzionale
                for s in remaining:
                    ratio = scores[s.connector_id] / total_score
                    allocations[s.connector_id] = remaining_power * ratio
                break

            for s in constrained:
                remaining_power -= allocations[s.connector_id]
            remaining = free_sessions

        return allocations

    async def _rebalance(self) -> None:
        async with self._lock:
            allocs = self._compute_allocations()

        # Stampa riepilogo per logging
        for cid, power in sorted(allocs.items()):
            s = self._sessions.get(cid)
            score = self._score_calc.calculate(s) if s else 0
            logger.info(
                "Priority: %s score=%.1f -> %.0f W (SoC=%.0f%%, %.1fh)",
                cid, score, power,
                s.soc_percent if s else 0,
                s.time_remaining_h if s else 0
            )
            await self._send_profile(self._station_id, cid, power)


# Demo
async def priority_demo():
    async def mock_send(station_id, cid, power_w) -> bool:
        print(f"  SET {cid}: {power_w:.0f} W")
        return True

    balancer = PriorityBalancer("HUB_01", 44000.0, mock_send)
    now = time.time()

    sessions = [
        # Urgente: 5% SoC, parte tra 45 min
        ChargingSession("C1", "T1", 5.0, 80.0, 77.0, now + 45*60, 22000.0, 1380.0, user_tier=2),
        # Normale: 60% SoC, parte tra 8 ore
        ChargingSession("C2", "T2", 60.0, 80.0, 40.0, now + 8*3600, 11000.0, 1380.0, user_tier=1),
        # Priority tier: 30% SoC, parte tra 2 ore
        ChargingSession("C3", "T3", 30.0, 90.0, 100.0, now + 2*3600, 22000.0, 1380.0, user_tier=3),
    ]

    print("\n--- Sessioni aggiunte (44 kW disponibili) ---")
    for s in sessions:
        await balancer.add_session(s)

    print("\n--- SoC C1 sale al 40% (meno urgente) ---")
    await balancer.update_soc("C1", 40.0)


if __name__ == "__main__":
    asyncio.run(priority_demo())

Reinforcement Learning per Minimizzare i Costi Energetici

Gli algoritmi precedenti reagiscono allo stato corrente. Il Reinforcement Learning va oltre: un agente impara, attraverso milioni di simulazioni, la policy ottimale che minimizza il costo energetico totale considerando prezzi variabili nel tempo, previsioni di arrivo e partenza degli EV, e vincoli di rete. In produzione nel 2025, i principali operatori di charging hub usano varianti di Proximal Policy Optimization (PPO) per ridurre i costi del 15-25% rispetto agli algoritmi reattivi (fonte: ScienceDirect, 2025).

Formulazione come Markov Decision Process

  • State space: per ogni connettore (SoC, tempo residuo, potenza corrente) più stato rete (potenza disponibile, prezzo energia, ora del giorno, previsione FV).
  • Action space: vettore continuo di livelli di potenza normalizzati [0,1] per ogni connettore.
  • Reward: minimizza costo energia, penalizza EV che non raggiungono il target SoC entro deadline, penalizza violazioni dei limiti di rete.
"""
Ambiente Gymnasium per EV Charging RL.
Compatibile con Stable-Baselines3 (PPO, SAC).
Ogni step = 15 minuti. Un episodio = 24 ore (96 step).

Installazione: pip install gymnasium stable-baselines3 numpy
"""
import numpy as np
import gymnasium as gym
from gymnasium import spaces
from dataclasses import dataclass
from typing import List, Optional, Tuple


@dataclass
class EVSession:
    soc: float              # [0, 1] corrente
    target_soc: float       # [0, 1] obiettivo
    battery_kwh: float
    time_remaining_h: float
    max_power_kw: float
    min_power_kw: float


class EVChargingEnv(gym.Env):
    """
    Ambiente Gymnasium per scheduling EV charging.
    Obiettivo: minimizzare costo energia rispettando deadline SoC.
    """

    metadata = {"render_modes": ["human"]}

    def __init__(
        self,
        n_connectors: int = 4,
        max_station_power_kw: float = 44.0,
        episode_steps: int = 96,
        electricity_prices: Optional[np.ndarray] = None
    ):
        super().__init__()
        self.n = n_connectors
        self.max_power = max_station_power_kw
        self.episode_steps = episode_steps
        self.prices = electricity_prices if electricity_prices is not None \
            else self._italian_biorario_prices()

        # Action: potenza normalizzata [0,1] per ogni connettore
        self.action_space = spaces.Box(
            low=0.0, high=1.0, shape=(n_connectors,), dtype=np.float32
        )
        # Observation: 5 feature per connettore + 3 globali
        obs_dim = n_connectors * 5 + 3
        self.observation_space = spaces.Box(
            low=-1.0, high=1.0, shape=(obs_dim,), dtype=np.float32
        )
        self.sessions: List[Optional[EVSession]] = [None] * n_connectors
        self.step_count = 0

    def _italian_biorario_prices(self) -> np.ndarray:
        """Prezzi biorari italiani simulati: F1 (08-19) = 0.28, F2/F3 = 0.18 EUR/kWh."""
        prices = np.zeros(96)
        for i in range(96):
            hour = (i * 15) // 60
            prices[i] = 0.28 if 8 <= hour < 19 else 0.18
        # Variabilità mercato spot
        return (prices + np.random.normal(0, 0.02, 96)).clip(0.10, 0.40)

    def _get_obs(self) -> np.ndarray:
        obs = []
        for s in self.sessions:
            if s is not None:
                obs.extend([
                    s.soc,
                    s.target_soc,
                    min(1.0, s.time_remaining_h / 12.0),
                    1.0,
                    s.max_power_kw / self.max_power
                ])
            else:
                obs.extend([0.0, 0.0, 0.0, 0.0, 0.0])

        step_idx = self.step_count % len(self.prices)
        hour = (self.step_count * 15 // 60) % 24
        obs.extend([
            self.prices[step_idx] / 0.40,
            np.sin(2 * np.pi * hour / 24),
            np.cos(2 * np.pi * hour / 24)
        ])
        return np.array(obs, dtype=np.float32)

    def step(self, action: np.ndarray) -> Tuple:
        dt_h = 15 / 60
        price = self.prices[self.step_count % len(self.prices)]

        # Converti azioni in potenze reali con vincoli
        actual_powers = np.zeros(self.n)
        for i, s in enumerate(self.sessions):
            if s is not None:
                power = action[i] * s.max_power_kw
                actual_powers[i] = np.clip(power, s.min_power_kw, s.max_power_kw)

        # Rispetta limite stazione
        total = actual_powers.sum()
        if total > self.max_power:
            actual_powers *= self.max_power / total

        # Calcola reward
        energy_kwh = actual_powers.sum() * dt_h
        reward = -energy_kwh * price  # costo negativo (vogliamo minimizzare)

        # Aggiorna SoC e penalita deadline
        for i, s in enumerate(self.sessions):
            if s is None:
                continue
            delta_soc = (actual_powers[i] * dt_h * 0.92) / s.battery_kwh
            s.soc = min(1.0, s.soc + delta_soc)
            s.time_remaining_h -= dt_h
            if s.time_remaining_h <= 0:
                gap = max(0, s.target_soc - s.soc)
                reward -= gap * 50.0  # penalita pesante per deadline mancata

        # Penalita violazione rete
        if total > self.max_power * 1.01:
            reward -= (total - self.max_power) * 5.0

        # Bonus ricarica off-peak
        hour = (self.step_count * 15 // 60) % 24
        if not (8 <= hour < 19) and total > 0:
            reward += total * 0.015

        self.step_count += 1
        obs = self._get_obs()
        terminated = self.step_count >= self.episode_steps
        return obs, float(reward), terminated, False, {"price": price, "total_kw": total}

    def reset(self, seed=None, options=None) -> Tuple:
        super().reset(seed=seed)
        self.step_count = 0
        self._spawn_random_sessions()
        return self._get_obs(), {}

    def _spawn_random_sessions(self) -> None:
        """Genera sessioni EV casuali realistiche per il training."""
        for i in range(self.n):
            if np.random.random() > 0.3:
                self.sessions[i] = EVSession(
                    soc=float(np.random.uniform(0.05, 0.7)),
                    target_soc=float(np.random.uniform(0.7, 0.95)),
                    battery_kwh=float(np.random.choice([40.0, 60.0, 77.0, 100.0])),
                    time_remaining_h=float(np.random.uniform(1.0, 10.0)),
                    max_power_kw=float(np.random.choice([7.4, 11.0, 22.0])),
                    min_power_kw=1.38
                )
            else:
                self.sessions[i] = None


def train_ppo_agent(total_timesteps: int = 500_000):
    """
    Addestra un agente PPO sull'ambiente di EV charging.
    Richiede: pip install stable-baselines3
    """
    try:
        from stable_baselines3 import PPO
        from stable_baselines3.common.env_checker import check_env

        env = EVChargingEnv(n_connectors=4, max_station_power_kw=44.0)
        check_env(env)

        model = PPO(
            "MlpPolicy", env,
            learning_rate=3e-4,
            n_steps=2048,
            batch_size=64,
            n_epochs=10,
            gamma=0.99,
            gae_lambda=0.95,
            clip_range=0.2,
            verbose=1,
        )
        model.learn(total_timesteps=total_timesteps, progress_bar=True)
        model.save("./models/ppo_ev_charging")
        print("Agente PPO salvato in ./models/ppo_ev_charging")
        return model
    except ImportError:
        print("Installa: pip install stable-baselines3")
        return None

Vehicle-to-Grid (V2G): Il Veicolo come Risorsa di Rete

Il V2G rappresenta il salto qualitativo del paradigma EV: il veicolo non e più solo un consumatore di energia ma diventa una risorsa bidirezionale. Quando il prezzo dell'energia e alto o la rete e sotto stress, le batterie degli EV possono restituire energia alla rete, guadagnando denaro per il proprietario e stabilizzando il sistema elettrico.

Standard V2G nel 2025

Standard Scope Novità V2G Status
ISO 15118-2 Comunicazione EV-EVSE AC/DC Plug & Charge, smart charging In uso
ISO 15118-20 2a generazione V2G completo BPT bidirezionale, DER integration, dynamic scheduling Adozione 2025-2026
OCPP 2.0.1 CP-CSMS communication ISO 15118 integration, V2G charging profiles In uso
EU AFIR Reg. Regolamento infrastrutture ISO 15118 obbligatorio nuovi impianti da gen 2026 In vigore

In Europa, Utrecht (Olanda) ha il più grande deployment V2G commerciale con oltre 500 Renault bidirezionali in un car-sharing. Ogni veicolo guadagna 600-1.500 EUR/anno fornendo Frequency Containment Reserve (FCR) al TSO olandese TenneT. Il V2G in Italia e ancora prevalentemente sperimentale, ma i nuovi modelli Nissan Leaf e2+, Volkswagen ID.4 Pro e alcune versioni di Tesla Model 3 Highland supportano già la bidirezionalita.

Peak Shaving e Frequency Response con Batterie EV

"""
V2G Controller: peak shaving e frequency response.
Gestisce la bidirezionalita delle sessioni EV compatibili V2G.
"""
from dataclasses import dataclass
from enum import Enum
from typing import Dict, List, Optional
import asyncio
import logging

logger = logging.getLogger(__name__)


class ChargingDirection(str, Enum):
    G2V = "G2V"   # Grid-to-Vehicle (ricarica normale)
    V2G = "V2G"   # Vehicle-to-Grid (scarica)
    IDLE = "IDLE"


@dataclass
class V2GSession:
    """Sessione V2G con capacità bidirezionale."""
    connector_id: str
    transaction_id: str
    soc_percent: float
    battery_capacity_kwh: float
    max_charge_kw: float      # potenza max di ricarica G2V
    max_discharge_kw: float   # potenza max di scarica V2G
    min_soc_percent: float = 20.0   # SoC minimo garantito (non scarica sotto)
    departure_soc_target: float = 80.0

    @property
    def available_discharge_kwh(self) -> float:
        usable = max(0.0, self.soc_percent - self.min_soc_percent) / 100.0
        return self.battery_capacity_kwh * usable


class V2GController:
    """Controller V2G per peak shaving e frequency response."""

    FREQ_NOMINAL = 50.0
    FREQ_DEADBAND = 0.05      # +/- 50 mHz: zona morta
    FREQ_FULL_ACTIVATION = 0.5  # +/- 500 mHz: attivazione completa FCR

    def __init__(
        self,
        station_id: str,
        max_export_kw: float,
        profile_sender
    ):
        self._station_id = station_id
        self._max_export = max_export_kw
        self._send = profile_sender
        self._sessions: Dict[str, V2GSession] = {}
        self._lock = asyncio.Lock()

    async def add_session(self, session: V2GSession) -> None:
        async with self._lock:
            self._sessions[session.connector_id] = session

    async def peak_shaving(
        self,
        building_load_kw: float,
        peak_threshold_kw: float
    ) -> dict:
        """
        Quando il carico supera la soglia, usa le batterie EV per ridurre l'import.
        Restituisce le azioni intraprese per ogni connettore V2G.
        """
        if building_load_kw <= peak_threshold_kw:
            return {"action": "none", "reason": "under_threshold"}

        excess_kw = building_load_kw - peak_threshold_kw
        logger.info(
            "Peak shaving: %.1f kW > soglia %.1f kW (eccesso %.1f kW)",
            building_load_kw, peak_threshold_kw, excess_kw
        )

        actions = {}
        async with self._lock:
            sessions = sorted(
                self._sessions.values(),
                key=lambda s: s.available_discharge_kwh,
                reverse=True
            )

        remaining = excess_kw
        for s in sessions:
            if remaining <= 0:
                break
            if s.available_discharge_kwh < 1.0:
                continue
            discharge = min(s.max_discharge_kw, remaining)
            actions[s.connector_id] = {
                "direction": ChargingDirection.V2G.value,
                "power_kw": discharge
            }
            # Potenza negativa = V2G discharge in OCPP
            await self._send(self._station_id, s.connector_id, -discharge * 1000)
            remaining -= discharge
            logger.info("V2G: %s scarica %.1f kW (peak shaving)", s.connector_id, discharge)

        return {"action": "peak_shaving", "actions": actions, "residual_excess_kw": remaining}

    async def frequency_response(self, freq_hz: float) -> dict:
        """
        FCR (Frequency Containment Reserve).
        Risposta lineare alla deviazione di frequenza, entro 500ms.
        Frequenza bassa (< 50 Hz) -> V2G discharge.
        Frequenza alta (> 50 Hz) -> aumento ricarica G2V.
        """
        deviation = freq_hz - self.FREQ_NOMINAL
        abs_dev = abs(deviation)

        if abs_dev < self.FREQ_DEADBAND:
            return {"action": "none", "frequency_hz": freq_hz}

        activation = min(1.0,
            (abs_dev - self.FREQ_DEADBAND) / (self.FREQ_FULL_ACTIVATION - self.FREQ_DEADBAND)
        )

        actions = {}
        async with self._lock:
            sessions = list(self._sessions.values())

        for s in sessions:
            if deviation < 0 and s.available_discharge_kwh > 0.5:
                # Frequenza bassa: scarica per supportare la rete
                power_kw = s.max_discharge_kw * activation
                actions[s.connector_id] = {"direction": "V2G", "power_kw": power_kw}
                await self._send(self._station_id, s.connector_id, -power_kw * 1000)
            elif deviation > 0 and s.soc_percent < 90:
                # Frequenza alta: aumenta ricarica per assorbire eccesso
                power_kw = s.max_charge_kw * activation
                actions[s.connector_id] = {"direction": "G2V", "power_kw": power_kw}
                await self._send(self._station_id, s.connector_id, power_kw * 1000)

        return {
            "frequency_hz": freq_hz,
            "deviation_hz": deviation,
            "activation_factor": activation,
            "actions": actions
        }

Integrazione Fotovoltaico: Surplus Solare verso gli EV

L'integrazione tra impianto FV e stazione di ricarica EV e uno degli use case con il ROI più rapido nell'energy management. Quando il fotovoltaico produce più di quanto consuma l'edificio, il surplus viene routato verso gli EV invece di essere immesso in rete (remunerato a ~0.07-0.10 EUR/kWh GSE) - consentendo di usare energia che altrimenti costerebbe 0.28 EUR/kWh dalla rete. Il risparmio netto e 0.18-0.21 EUR/kWh per ogni kWh autoconsumato tramite EV.

Strategia Logica Beneficio Limitazione
Excess-Only Carica EV SOLO con surplus FV Autoconsumo massimo, zero costo rete Potenza variabile, carica lenta
Solar-First Priorità al solare, integra dalla rete Ricarica stabile con massimo solare Parte energia da rete
Green-Maximizer Ottimizza su orizzonte 4h con forecast meteo Massimizza % energia rinnovabile Richiede previsioni accurate
Cost-Optimizer Solare + prezzi spot + V2G combinati Costo minimo assoluto Alta complessità algoritmica
"""
Solar-EV Integration Controller.
Implementa strategie Excess-Only e Solar-First
con isteresi per evitare oscillazioni frequenti.
"""
from dataclasses import dataclass
from typing import Optional
import asyncio
import logging

logger = logging.getLogger(__name__)


@dataclass
class PowerState:
    """Stato istantaneo del sistema energetico."""
    pv_production_kw: float
    building_load_kw: float       # consumo edificio escluso EV
    electricity_price_eur: float = 0.25
    feed_in_tariff_eur: float = 0.07

    @property
    def net_surplus_kw(self) -> float:
        return max(0.0, self.pv_production_kw - self.building_load_kw)

    @property
    def savings_per_kwh(self) -> float:
        """Risparmio per kWh autoconsumato invece di cedere+comprare."""
        return self.electricity_price_eur - self.feed_in_tariff_eur


class SolarEVController:
    """
    Controlla la ricarica EV in funzione del surplus fotovoltaico.
    Supporta Excess-Only e Solar-First con isteresi configurabile.
    """

    HYSTERESIS_KW = 0.3  # evita comandi continui per piccole oscillazioni

    def __init__(
        self,
        balancer,
        strategy: str = "solar_first",
        min_ev_power_kw: float = 1.38,
        max_grid_supplement_kw: float = 5.0
    ):
        self._balancer = balancer
        self._strategy = strategy
        self._min_ev_power = min_ev_power_kw
        self._max_grid_supp = max_grid_supplement_kw
        self._last_limit_kw = 0.0

    async def update(self, power_state: PowerState, n_active_evs: int) -> dict:
        """
        Ricalcola e applica il limite di potenza EV.
        Chiamato ogni 5-15 secondi dal loop di controllo.
        """
        if n_active_evs == 0:
            return {"action": "no_ev", "allocated_kw": 0}

        if self._strategy == "excess_only":
            target_kw = self._excess_only(power_state, n_active_evs)
        else:
            target_kw = self._solar_first(power_state, n_active_evs)

        # Applica isteresi
        if abs(target_kw - self._last_limit_kw) > self.HYSTERESIS_KW:
            await self._balancer.update_dynamic_limit(target_kw * 1000)
            self._last_limit_kw = target_kw
            logger.info("Solar routing [%s]: %.1f kW -> EV", self._strategy, target_kw)

        hourly_savings = target_kw * power_state.savings_per_kwh
        return {
            "strategy": self._strategy,
            "pv_kw": power_state.pv_production_kw,
            "surplus_kw": power_state.net_surplus_kw,
            "allocated_ev_kw": target_kw,
            "savings_eur_h": round(hourly_savings, 3)
        }

    def _excess_only(self, ps: PowerState, n_evs: int) -> float:
        surplus = ps.net_surplus_kw
        min_total = self._min_ev_power * n_evs
        return surplus if surplus >= min_total else 0.0

    def _solar_first(self, ps: PowerState, n_evs: int) -> float:
        surplus = ps.net_surplus_kw
        min_total = self._min_ev_power * n_evs
        if surplus < min_total:
            grid_needed = min_total - surplus
            return min_total if grid_needed <= self._max_grid_supp else surplus
        return min(surplus + self._max_grid_supp, surplus + self._max_grid_supp)


async def solar_routing_demo():
    """Simula una giornata con produzione FV tipica di aprile in Italia."""
    hourly_pv = [0, 0, 0, 0, 0, 0.5, 2, 5, 9, 14, 18, 21,
                 22, 20, 16, 10, 6, 2, 0.5, 0, 0, 0, 0, 0]

    print("Ora  | PV kW | Bldg kW | Surplus | EV kW | Risparmio €/h")
    print("-" * 62)

    for h in range(24):
        pv = hourly_pv[h]
        bldg = 5.0 + (2.0 if 8 <= h < 18 else 0)
        surplus = max(0, pv - bldg)
        price = 0.28 if 8 <= h < 19 else 0.18
        feed_in = 0.07
        ev_alloc = min(surplus, 11.0)
        savings = ev_alloc * (price - feed_in)
        print(
            f"{h:02d}:00 | {pv:5.1f} | {bldg:7.1f} | "
            f"{surplus:7.1f} | {ev_alloc:5.1f} | {savings:.3f}"
        )


if __name__ == "__main__":
    asyncio.run(solar_routing_demo())

Architettura Microservizi del Charge Controller

Un sistema di EV load balancing in produzione e un'architettura distribuita con componenti specializzati che comunicano via eventi. Ecco i servizi principali:

Servizio Responsabilità Stack SLA
OCPP Gateway WebSocket server, traduzione OCPP 1.6/2.0.1 Python ocpp, asyncio 99.9%, <100ms
Charge Controller Algoritmo load balancing, SetChargingProfile FastAPI, Redis 99.9%, <500ms
Energy Manager Misura PV/BESS/rete, calcola disponibilità Modbus TCP, MQTT, Python 99.5%, polling 1s
Forecaster Previsione arrivi/partenze, prezzi, FV LightGBM/LSTM, FastAPI 99%, update 15min
DSO Gateway Segnali OpenADR, limiti dinamici Python OpenADR 2.0b 99%, <2s
Billing Metering, fatturazione, sessioni FastAPI, PostgreSQL 99.5%

Charge Controller API con FastAPI

"""
Charge Controller Service - FastAPI REST API.
Endpoints per load balancing, monitoring e configurazione.
"""
from fastapi import FastAPI, HTTPException, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
from typing import List, Optional, Dict
from datetime import datetime, timezone
import logging

logger = logging.getLogger(__name__)

app = FastAPI(
    title="EV Charge Controller API",
    version="2.1.0",
    description="Load balancing real-time per stazioni di ricarica EV"
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["GET", "POST", "PUT"],
    allow_headers=["*"]
)


# -------------------------------------------------------
# Modelli Pydantic
# -------------------------------------------------------

class ConnectorInfo(BaseModel):
    connector_id: str
    status: str
    allocated_power_w: float
    soc_percent: Optional[float]
    session_duration_min: float


class StationStatus(BaseModel):
    station_id: str
    available_power_w: float
    algorithm: str
    connectors: List[ConnectorInfo]
    last_updated: str


class EVConnectedEvent(BaseModel):
    connector_id: str
    transaction_id: str
    initial_soc: Optional[float] = None
    battery_capacity_kwh: Optional[float] = None
    departure_timestamp: Optional[int] = None
    user_tier: int = Field(default=1, ge=1, le=3)


class PowerLimitUpdate(BaseModel):
    limit_watts: float = Field(gt=0, le=200_000)
    source: str = "manual"   # "manual" | "dso" | "solar" | "openadr"
    valid_until_ts: Optional[int] = None


class AlgorithmSwitch(BaseModel):
    algorithm: str = Field(pattern="^(equal_share|priority|rl_ppo)$")
    params: Dict = {}


# In-memory registry (in produzione: Redis)
_station_registry: Dict = {}
_algorithm_map: Dict[str, str] = {}


# -------------------------------------------------------
# Endpoints
# -------------------------------------------------------

@app.get("/health")
async def health():
    return {
        "status": "healthy",
        "ts": datetime.now(timezone.utc).isoformat(),
        "version": "2.1.0"
    }


@app.get("/stations/{station_id}/status", response_model=StationStatus)
async def get_status(station_id: str):
    if station_id not in _station_registry:
        raise HTTPException(404, f"Stazione {station_id} non trovata")
    balancer = _station_registry[station_id]
    raw = balancer.get_status()
    return StationStatus(
        station_id=station_id,
        available_power_w=raw["available_power_w"],
        algorithm=_algorithm_map.get(station_id, "equal_share"),
        connectors=[ConnectorInfo(**c) for c in raw["connectors"]],
        last_updated=datetime.now(timezone.utc).isoformat()
    )


@app.post("/stations/{station_id}/events/connected")
async def ev_connected(station_id: str, event: EVConnectedEvent, bg: BackgroundTasks):
    if station_id not in _station_registry:
        raise HTTPException(404, "Stazione non trovata")
    bg.add_task(
        _station_registry[station_id].on_ev_connected,
        event.connector_id,
        event.transaction_id
    )
    return {"status": "accepted", "rebalancing": True}


@app.delete("/stations/{station_id}/connectors/{connector_id}/session")
async def ev_disconnected(station_id: str, connector_id: str, bg: BackgroundTasks):
    if station_id not in _station_registry:
        raise HTTPException(404, "Stazione non trovata")
    bg.add_task(_station_registry[station_id].on_ev_disconnected, connector_id)
    return {"status": "accepted"}


@app.put("/stations/{station_id}/power-limit")
async def set_power_limit(station_id: str, limit: PowerLimitUpdate, bg: BackgroundTasks):
    if station_id not in _station_registry:
        raise HTTPException(404, "Stazione non trovata")
    bg.add_task(_station_registry[station_id].update_dynamic_limit, limit.limit_watts)
    logger.info("Limite potenza: %s -> %.0f W (source=%s)", station_id, limit.limit_watts, limit.source)
    return {"status": "accepted", "new_limit_w": limit.limit_watts, "source": limit.source}


@app.put("/stations/{station_id}/algorithm")
async def set_algorithm(station_id: str, config: AlgorithmSwitch):
    _algorithm_map[station_id] = config.algorithm
    return {"status": "ok", "algorithm": config.algorithm}


@app.get("/stations/{station_id}/metrics")
async def get_metrics(station_id: str):
    """KPI della stazione (in produzione: query InfluxDB/TimescaleDB)."""
    return {
        "station_id": station_id,
        "period": "last_24h",
        "charging_satisfaction_rate": 0.94,
        "peak_reduction_percent": 31.2,
        "energy_cost_savings_eur": 47.80,
        "solar_share_percent": 42.3,
        "grid_stress_index": 0.23,
        "total_energy_kwh": 201.6,
        "avg_power_kw": 8.4,
        "rebalance_latency_ms_p95": 180
    }

Metriche e KPI per la Valutazione del Load Balancing

Per valutare l'efficacia di un sistema di load balancing servono KPI quantificabili che misurino sia la soddisfazione degli utenti che l'ottimizzazione energetica.

KPI Definizione Target Formula
Charging Satisfaction Rate (CSR) % sessioni che raggiungono target SoC entro deadline >90% sessioni_ok / sessioni_totali
Peak Reduction % Riduzione del picco vs no-management >25% (peak_unmanaged - peak_managed) / peak_unmanaged
Energy Cost Savings % Risparmio su costo kWh vs carica immediata >15% (costo_base - costo_gestito) / costo_base
Solar Self-Consumption % % energia EV da FV locale >40% energia_solare_ev / energia_totale_ev
Grid Stress Index (GSI) Pressione media sul trasformatore [0-1] <0.3 avg(P_actual / P_nominal_trafo)
Rebalance Latency P95 Tempo da evento a profilo OCPP applicato <2s t_ocpp_conf - t_event_received (p95)

Contesto Italiano: ARERA, CER con EV e Incentivi 2025

L'implementazione di sistemi di load balancing EV in Italia presenta secificità normative e di mercato che influenzano direttamente le scelte architetturali.

Tariffe Biorarie ARERA e Smart Charging

Il sistema tariffario italiano crea un incentivo naturale al off-peak charging:

  • Fascia F1 (lun-ven 8:00-19:00): circa 0.26-0.31 EUR/kWh. Fascia da evitare per la ricarica EV.
  • Fascia F2 (lun-ven 7-8, 19-23; sab 7-23): circa 0.20-0.24 EUR/kWh.
  • Fascia F3 (notte 23-7; domeniche e festivi tutto il giorno): circa 0.16-0.19 EUR/kWh. Ideale per la ricarica notturna.

Un algoritmo Time-of-Use che sposta il 60% della ricarica dalla F1 alla F3 riduce i costi di ricarica annui per un fleet manager del 18-25%.

Comunita Energetiche Rinnovabili (CER) con EV

Il D.Lgs. 199/2021 e il DM MASE 7 dicembre 2023 hanno aperto opportunità concrete per l'integrazione EV nelle CER italiane:

  • Gli EV che ricaricano con surplus FV della comunita generano incentivo GSE sulla quota di energia condivisa (rimborso tariffario + incentivo fino a 11 cent/kWh).
  • Con V2G, le batterie EV fungono da accumulo virtuale della CER, spostando energia solare dal mezzogiorno alla sera (peak shift).
  • Il GSE misura l'energia condivisa tramite MACSE, includendo i flussi bidirezionali degli EV V2G nella contabilizzazione della comunita.

Infrastruttura EV Italiana al 2025

L'Italia conta oltre 58.000 punti di ricarica pubblici (Motus-E, fine 2024), di cui il 22% fast DC (vs 14% nel 2023). Nel 2025 sono state immatricolate 94.230 BEV (+46%), con un market share del 6.2%. Secondo Terna, senza smart charging diffuso il 7% dei feeder di distribuzione sarà in sovraccarico entro il 2027 nelle aree più dense del Nord Italia. Il PNRR ha stanziato oltre 740 milioni di euro per infrastrutture di ricarica, con target di 21.000 nuovi punti pubblici entro il 2026.

Attenzione agli Incentivi

I crediti d'imposta per smart charging in ambito Transizione 5.0 sono soggetti a revisione periodica. Verificare sempre le condizioni aggiornate sul sito del GSE e della CSEA prima di pianificare investimenti basati sugli incentivi. Le aliquote e le condizioni di accesso possono variare.

Conclusioni e Roadmap Implementativa

Il load balancing EV non e un problema di eleganza algoritmica: e una necessità infrastrutturale critica per la transizione energetica. I dati sono inequivocabili: con 5 milioni di EV non gestiti il picco serale aumenta di 35.000 MW; con smart charging lo stesso parco EV diventa una risorsa flessibile che riduce il picco del 34%.

Per un team di sviluppo che implementa questi sistemi in produzione, la roadmap ottimale per fasi successive e:

  1. Fase 1 - Equal Share: semplice, robusto, sufficiente per installazioni fino a 30-40 prese. Priorità assoluta: implementare correttamente il ricalcolo event-driven con la sequenza riduzioni-poi-aumenti. Tempo stimato: 2-3 settimane.
  2. Fase 2 - Integrazione FV: ROI immediato senza ML. Richiede solo la misura del surplus solare via Modbus/MQTT e l'integrazione con il balancer tramite update_dynamic_limit(). Quick win con payback in 6-18 mesi.
  3. Fase 3 - Priority-Based: aggiungi quando hai l'app mobile per raccogliere SoC e deadline di partenza. Il guadagno in CSR (Charging Satisfaction Rate) e misurabile e differenzia il servizio dalla concorrenza.
  4. Fase 4 - Infrastruttura V2G: progetta l'architettura bidirezionale adesso, anche se non hai ancora EV V2G. OCPP 2.0.1 con ISO 15118-20 diventa obbligatorio per i nuovi impianti dal 2026 (EU AFIR). Retrofittare costa 5-10x di più.
  5. Fase 5 - RL in produzione: investi nel deep RL solo dopo 6+ mesi di dati di qualità e con un team MLOps dedicato. Il guadagno sui costi e reale (15-25%) ma la complessità operativa e elevata.

Articoli Correlati nella Serie EnergyTech

  • Articolo 4: Digital Twin della Rete - simula l'impatto degli EV sulla rete prima del deploy
  • Articolo 5: Renewable Energy Forecasting - previsione produzione FV per il solar routing
  • Articolo 7: MQTT e InfluxDB - telemetria real-time dei parametri di ricarica
  • Articolo 2: Architettura DERMS - integrare gli EV come DER nel sistema di gestione distribuita

Approfondimenti Cross-Series

  • Serie MLOps (art. 306-315): deployment del modello PPO con MLflow, monitoraggio drift e retraining
  • Serie AI Engineering (art. 316-325): RAG su documentazione OCPP e ISO 15118 con LLM enterprise
  • Serie Data & AI Business (art. 267-280): ROI, metriche di business e roadmap per investimenti smart charging
  • Serie PostgreSQL AI (art. 356-361): timeseries dei dati di ricarica con TimescaleDB su PostgreSQL

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti