In production, the optimizer no longer runs once and forgets. You get irregular updates from a stream (for example Kafka) about restrictions, derates, and new operational rules. This lesson shows how the optimizer changes when real-time data becomes a first-class input.
┌──────────────────────────────┐ ┌──────────────────────┐
│ Kafka stream / events │──▶ │ Realtime data layer │
│ • restrictions │ │ • normalize messages │
│ • battery status │ │ • active constraint │
│ • market events │ │ snapshot │
└──────────────────────────────┘ └──────────────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌─────────────────────────┐
│ Event-driven plan │◀──────────│ Rolling horizon optimizer │
│ trigger / reopt │ │ • time-varying bounds │
└──────────────────┘ └─────────────────────────┘
@dataclass
class RealtimeRestriction:
source: str
valid_from: datetime
valid_until: datetime
max_charge_mw: float
max_discharge_mw: float
soc_min_pct: float
soc_max_pct: float
reserved_energy_mwh: float
allow_charge: bool
allow_discharge: bool
note: str
The real-time feed becomes a time-indexed constraint bundle. The optimizer sees these values as parameter vectors instead of fixed scalars.
model.T = pyo.RangeSet(0, H - 1)
model.p_charge_max = pyo.Param(model.T, initialize=p_max_charge, mutable=True)
model.p_discharge_max = pyo.Param(model.T, initialize=p_max_discharge, mutable=True)
model.soc_min = pyo.Param(model.T, initialize=soc_min, mutable=True)
model.soc_max = pyo.Param(model.T, initialize=soc_max, mutable=True)
model.reserve = pyo.Param(model.T, initialize=reserved_energy, mutable=True)
model.p_c = pyo.Var(model.T, bounds=(0, p.p_max))
model.p_d = pyo.Var(model.T, bounds=(0, p.p_max))
model.soc = pyo.Var(model.T, bounds=(0, p.e_max))
# Real-time power limits
def charge_limit(m, t):
return m.p_c[t] <= m.p_charge_max[t]
model.charge_limit = pyo.Constraint(model.T, rule=charge_limit)
def discharge_limit(m, t):
return m.p_d[t] <= m.p_discharge_max[t]
model.discharge_limit = pyo.Constraint(model.T, rule=discharge_limit)
# Real-time SoC headroom and reservation
def soc_min_rule(m, t):
return m.soc[t] >= m.soc_min[t] * p.e_max + m.reserve[t]
model.soc_min_con = pyo.Constraint(model.T, rule=soc_min_rule)
def soc_max_rule(m, t):
return m.soc[t] <= m.soc_max[t] * p.e_max
model.soc_max_con = pyo.Constraint(model.T, rule=soc_max_rule)
If a restriction forbids discharge for hour 13, the data layer simply sets
p_discharge_max[13] = 0. If the BMS reports a 6 MW derate for the next hour,
the same parameter is adjusted before solving.
Irregular Kafka updates should not be ignored until the next hourly cycle. Your system needs a small event loop that does two things:
def on_realtime_update(message):
restriction = parse_message(message)
data_layer.apply_realtime_restriction(restriction)
optimizer.update_constraints(data_layer.active_constraints)
schedule = optimizer.solve(current_time, horizon=next_6_hours)
execution.submit(schedule)
Use a hybrid approach:
This keeps the optimizer manageable: it still solves over a horizon, but it also reacts quickly when the system receives a binding stream event.
Confluent blog: Data pipelines with Kafka — learn how streaming events become operational inputs for decision systems.
What should your optimizer do when a Kafka message arrives saying discharge is forbidden for the next two hours?
Rechtliches: Impressum