Electricity grids already run on models. Every transmission operator forecasts load hours and days ahead, every utility with wind and solar forecasts their output, a state estimator reconstructs voltages and flows from noisy telemetry several times a minute, and a growing fleet of batteries, heat pumps and electric vehicle chargers is dispatched by software through aggregators. Machine learning has moved into each of these: gradient boosted and neural forecasters, learned anomaly detectors, reinforcement learning for battery bidding, and now language-model copilots that summarise alarms and draft switching orders.
That makes the security question specific. An attacker who wants to hurt a grid, or profit from one, does not need to break into a breaker controller if they can quietly bend the numbers that humans and automation trust. This article walks through the four places where AI changes the grid's attack surface: forecasts, state estimation, distributed energy dispatch and control-room assistants, with working controls and a mapping onto the NERC CIP standards for North American operators.
Where AI sits in a grid operator
Start with a map. The diagram below separates three zones: external feeds that the operator does not control, the analytics layer where forecasts and the state estimator run, and the operational side where decisions turn into market bids and control actions. The dashed box is the electronic security perimeter. The important design choice is that nothing learned writes directly to the operational side: forecasts and estimates inform decisions, and any automated action passes through a dispatch envelope that a model cannot widen.
Three kinds of harm follow from that map. Economic harm comes from a biased forecast that makes the operator over-commit generation or buy reserves at the wrong price; an attacker holding a position in the market can profit from it. Reliability harm comes from a wrong picture of the grid, where the operator or automation acts on flows that do not exist. Physical harm comes from coordinated commands to many small devices at once, which individually look harmless and together move hundreds of megawatts. Each needs a different control, so it helps to be explicit about which one a given model can cause.
Forecast integrity
A load or renewable forecaster is a function of weather forecasts, calendar features, recent telemetry and sometimes price signals. Most of those inputs arrive from outside the operator, over APIs and file drops, and the model has no way to tell a corrupted temperature field from a real cold snap. Poisoning can happen at inference time, by tampering with the live feed, or at training time, by seeding the history the model is retrained on so it learns a bias that fires under specific conditions.
The defence is to treat a forecast as a claim that must agree with independent evidence before it drives a decision. Run a cheap, structurally different baseline next to the production model: a similar-day or persistence forecast that uses no external feed at all. Cross-check each external input against a second source where one exists, such as two weather providers or the operator's own weather stations. When the production forecast and the baseline disagree by more than their historical error band, the system should not silently pick one; it should flag the hour, show both to the operator and fall back to the conservative choice.
from dataclasses import dataclass
@dataclass
class ForecastCheck:
hour: str
model_mw: float
baseline_mw: float # similar-day forecast, no external feeds
band_mw: float # p99 historical |model - baseline| for this hour type
temp_primary_c: float
temp_secondary_c: float # second provider or own weather stations
def judge(fc: ForecastCheck, prefer_high: bool, temp_tol_c: float = 3.0) -> dict:
reasons = []
if abs(fc.temp_primary_c - fc.temp_secondary_c) > temp_tol_c:
reasons.append("weather feeds disagree")
if abs(fc.model_mw - fc.baseline_mw) > fc.band_mw:
reasons.append("model outside baseline band")
if not reasons:
return {"hour": fc.hour, "use": fc.model_mw, "status": "ok"}
# Conservative fallback. Load: plan high. Wind/solar output: plan low.
pick = max if prefer_high else min
return {"hour": fc.hour, "use": pick(fc.model_mw, fc.baseline_mw),
"status": "review", "reasons": reasons}For training-time poisoning, keep retraining data content-addressed and versioned, retrain from raw telemetry rather than from previously cleaned exports that someone could have edited, and evaluate every candidate model on a frozen backtest that includes known extreme days. A model that suddenly does better on ordinary days and worse on the frozen extremes deserves suspicion before promotion.
State estimation and false data injection
The state estimator is the grid's sense of sight. It takes hundreds or thousands of measurements z, relates them to the unknown state x (bus voltage magnitudes and angles) through a measurement model H, and solves a weighted least squares problem. Classical bad-data detection then checks the residual, the gap between what was measured and what the estimated state predicts. A broken meter or a crude tampering attempt produces a large residual and gets flagged.
The uncomfortable result, published by Liu, Ning and Reiter in 2009, is that an attacker who knows H can add an attack vector of the form a = Hc to the measurements. The estimator then shifts its answer by exactly c and the residual does not change at all, so the test passes while the operator sees a false state. The linearised example below demonstrates it in a few lines.
import numpy as np
rng = np.random.default_rng(0)
H = rng.normal(size=(12, 4)) # 12 measurements, 4 state variables (DC model)
x_true = rng.normal(size=4)
z = H @ x_true + rng.normal(scale=0.01, size=12)
def estimate(z):
x_hat, *_ = np.linalg.lstsq(H, z, rcond=None)
return x_hat, np.linalg.norm(z - H @ x_hat)
_, r_clean = estimate(z)
_, r_noise = estimate(z + np.eye(12)[3] * 0.5) # one crude tampered meter
c = np.array([0.0, 0.3, 0.0, 0.0]) # attacker's chosen state shift
x_bad, r_stealth = estimate(z + H @ c)
print(f"clean {r_clean:.4f} crude {r_noise:.4f} stealth {r_stealth:.4f}")
print("state shift seen by operator:", np.round(x_bad - x_true, 3))Run it and the crude attack lifts the residual by more than an order of magnitude, while the stealthy one leaves it equal to the clean case and moves the second state variable by about 0.3. Learned anomaly detectors trained on residual statistics inherit the same blind spot, so the defences have to change what the attacker can reach rather than what the detector looks at. Authenticate and protect a basis set of measurements, enough independent ones that no consistent attack is possible without compromising a protected one; phasor measurement units with authenticated streams are the usual choice. Add temporal checks, because a real state evolves smoothly under physics and load patterns while an injected one often steps. Keep H itself confidential, since the full model of the network is what makes the stealthy attack constructible.
Distributed energy: agents that move megawatts
The newest surface is distributed energy. A virtual power plant aggregates thousands of batteries, inverters, thermostats and chargers and offers their combined flexibility to the grid. Software, increasingly learned policies, decides when each device charges or discharges. A single device is small; the fleet is not. Ten thousand home batteries at 5 kW each are 50 MW, and if they all switch in the same second the local distribution network sees a step it was never planned for.
Put a dispatch envelope service between any optimiser, learned or not, and the device API. The optimiser proposes; the envelope enforces per-device limits, fleet-wide ramp caps, per-feeder caps from the distribution operator and a staggered schedule. Envelope limits live in configuration that requires two approvers to change, and the service is the only holder of the device-command credential.
def enforce(proposals, cfg, last_fleet_mw):
# proposals: list of (device_id, feeder, mw) with +discharge / -charge
out, feeder_tot, fleet = [], {}, 0.0
for dev, feeder, mw in proposals:
mw = max(-cfg.dev_max_mw, min(cfg.dev_max_mw, mw)) # per-device clamp
if abs(feeder_tot.get(feeder, 0.0) + mw) > cfg.feeder_cap_mw[feeder]:
continue # drop, never widen
feeder_tot[feeder] = feeder_tot.get(feeder, 0.0) + mw
fleet += mw
out.append((dev, mw))
step = fleet - last_fleet_mw
if abs(step) > cfg.fleet_ramp_mw_per_min:
target = last_fleet_mw + cfg.fleet_ramp_mw_per_min * (1 if step > 0 else -1)
factor = target / fleet if fleet else -1.0
if not 0.0 <= factor <= 1.0:
return HOLD_PREVIOUS # scaling may only shrink
out = [(d, m * factor) for d, m in out]
return stagger(out, window_s=cfg.stagger_s) # spread over timeThe ramp scaling shown is deliberately simple; the point is that the cap is applied outside the optimiser and cannot be argued away by it. Log every proposal next to what was actually sent, so a policy that keeps hitting the envelope is visible as a model problem instead of silently being corrected forever.
Control-room copilots
Language-model assistants in the control room are useful for alarm triage, procedure lookup and drafting switching orders. They are also a path for indirect prompt injection: an alarm text, a vendor bulletin or an outage ticket can contain instructions the model will follow. Keep the assistant on the IT side, reading replicated historian data and documents, with no credential that can write to operational systems. Its output is a proposal that an operator executes through the normal tools. The design is covered in detail in the water utility article, whose bounded-assistant pattern transfers directly, and the review step should follow the guidance in human-in-the-loop for high-risk actions.
Mapping to NERC CIP
For North American bulk electric system entities, the controls above map onto existing NERC CIP requirements. Treat the mapping as an engineering starting point for a conversation with your compliance team, not as a compliance determination.
| Standard | What it covers | How AI systems fit |
|---|---|---|
| CIP-002 | Categorising BES cyber systems | Decide whether a forecaster or estimator host is in scope; a model that feeds automatic control usually is |
| CIP-005 | Electronic security perimeters and remote access | Copilots and training pipelines stay outside; data crosses one way via replicas |
| CIP-007 | Ports, patching, malicious code, logging | ML runtimes and their Python dependencies are software to patch and log |
| CIP-010 | Configuration change management | Treat a new model version or threshold as a baseline change with testing evidence |
| CIP-011 | Protecting BES cyber system information | Network models (H) and topology sent to a cloud model are sensitive information |
| CIP-013 | Supply chain risk management | Model vendors, weather providers and aggregator platforms belong in the plan |
| CIP-015 | Internal network security monitoring | Baseline east-west traffic, including model-serving hosts inside the perimeter |
CIP-015-1 is the newest entry. FERC approved it in Order No. 907 in June 2025 with an effective date of 2 September 2025; control centres have 36 months to comply and other applicable systems 60. A model server inside the perimeter that suddenly talks to a new host is exactly the east-west anomaly that monitoring is meant to surface.
Worked example: a poisoned wind forecast
Consider a regional operator on a windy autumn evening. At 16:00 the wind forecast for 19:00 shows 2,400 MW, the similar-day baseline shows 1,500 MW, and the historical p99 gap for that hour type is 600 MW. The forecast check fires. Looking at inputs, the primary weather provider's hub-height wind speed for two large farms is 4 m/s higher than both the secondary provider and the farms' own met masts.
Without the gate, the operator would have planned for 900 MW of wind that would not arrive and covered the shortfall in real time at scarcity prices, or worse, run short on reserves. With it, the hour is planned on the baseline, the feed is quarantined and the provider is contacted. Whether the cause was tampering or a provider bug, the response is identical, and the forecast pipeline records which input caused the deviation so the retraining set excludes it.
Failure modes
- Gates tuned so tight they fire daily. Operators learn to click through. Tune bands on a year of history and report the alert rate weekly.
- A baseline that shares the attacked input. If both models read the same weather feed, the cross-check proves nothing. Independence is the whole point.
- Learned detectors trusted against adaptive attackers. They catch faults well and constructed attacks poorly; pair them with authenticated measurements.
- Envelope limits that drift upward. Every temporary widening for an event becomes permanent. Expire changes automatically.
- Network models leaking. Topology and impedance data shared with a vendor or pasted into a cloud assistant hand an attacker H.
Trade-offs
Every gate costs some value: a conservative fallback buys reserves you may not need, a ramp cap leaves flexibility unsold, and read-only copilots mean operators still type the commands. Those costs are small and predictable; the failures they prevent are large and rare. The harder call is investment in authenticated measurement, expensive to retrofit but the only robust answer to constructed injection.
What to do next
- List every model that influences a market bid or control action and tag which of the three harms it can cause.
- Add an independent baseline forecast and a second weather source, and log disagreement rates for a month before enforcing.
- Run the residual demo against your own linearised network to show leadership why authenticated measurements matter.
- Put a dispatch envelope with fleet and feeder caps in front of every DER optimiser.
- Keep LLM assistants on replicas with no write credentials, and red-team them with injected alarm text (see indirect prompt injection).
- Version and freeze training data with a backtest of extreme days, following the practices in data poisoning attacks.
- Walk the CIP mapping with compliance and add model versions to change management.