Open Data Schema for Energy
Grid Analytics

When Solar Goes Dark: Predicting Neighborhood Demand Swings During Winter Storms

Winter Storm Fern proved the Texas grid can hold. The next question is where demand spikes hit hardest when solar drops to zero — and how free public data lets any utility analyst answer it.

The Storm That Wasn't Uri

Winter Storm Fern hit Texas on January 24–27, 2026 — almost exactly five years after Uri knocked out power for 4.5 million homes and became shorthand for grid failure. The "renewables failed" headline returned immediately. Solar was generating 0 MW at 6 AM during peak morning stress. Over 14,000 MW of wind and solar capacity went offline. The takes wrote themselves.

But the grid held. About 50,000 homes lost power briefly, compared to 4.5 million during Uri. The difference? Battery storage delivered 7,000 MW at peak morning demand — 9.5% of ERCOT's total load — from infrastructure that barely existed in 2021. The Department of Energy issued an emergency order under Section 202(c) of the Federal Power Act, but as a precaution, not a bailout. Wholesale prices spiked but didn't spiral. The structural investments since Uri — weatherization mandates, battery buildout, grid hardening — worked.

The more interesting question isn't whether the grid survived. It's what happens next, as rooftop solar penetration keeps climbing. And the answer requires a kind of analysis that most utilities aren't set up to do yet — but that any analyst with Python and a free dataset can start doing today.

The Hidden Problem: Solar Whiteout Demand Swing

Here's the scenario that keeps grid planners up at night.

On a normal sunny day in Houston, a neighborhood with 20% rooftop solar penetration looks like it's drawing 80% of its actual building load from the grid. The other 20% is offset by local generation. The utility's demand forecast sees 80%, plans for 80%, dispatches for 80%.

Then a winter storm rolls in. Snow covers panels. Clouds cut irradiance to near zero. Solar output drops from 20% to 0%. The grid suddenly sees the full 100% of building load — a 25% jump in apparent demand from that neighborhood, with no warning in the forecast.

Scale this across a metro area with uneven solar adoption, and you get demand spikes that are invisible to any utility that doesn't track solar penetration at the neighborhood level. The swing is proportional to solar penetration: a neighborhood at 40% solar sees a potential 67% demand swing. At 10%, it's 11%. The math is simple. The problem is that most utilities don't have the neighborhood-level data to do the math.

This is the "solar whiteout demand swing" — and it gets worse every year as rooftop solar adoption accelerates.

What You Need: Two Data Sources

To quantify this risk for a specific metro area, you need two things:

This article uses the ResStock 2024 Release 2 baseline for Texas (free, from the OEDI S3 bucket) plus production data from a fictional 5 MW solar farm — generated synthetically but structured exactly like real inverter output, then normalized through ODS-E.

Step 0: Download the Data

You need the AWS CLI (any version) and about 130 MB of disk space:

aws s3 cp --no-sign-request \
  "s3://oedi-data-lake/nrel-pds-building-stock/end-use-load-profiles-for-us-building-stock/2024/resstock_tmy3_release_2/metadata_and_annual_results/by_state/state=TX/csv/TX_baseline_metadata_and_annual_results.csv" \
  ./texas_resstock_baseline.csv

This gives you 43,089 building models across all of Texas, with 289 columns covering building characteristics (in.* columns), simulated energy consumption by end use (out.* columns), and a weight column that tells you how many real buildings each model represents.

Step 1: Profile Your County's Building Stock

Load the data and filter to Harris County (Houston metro):

import pandas as pd

df = pd.read_csv("texas_resstock_baseline.csv", low_memory=False)

# Harris County, TX — GISJOIN code G4802010
harris = df[df["in.county"] == "G4802010"].copy()
print(f"Harris County: {len(harris):,} models")
print(f"Representing: {harris['weight'].sum():,.0f} dwellings")

Result: 6,967 building models representing 1,757,786 dwellings. Each model is a statistically representative sample — the weight column scales it to the real building count.

Now look at heating demand by building vintage. During a winter storm, heating is the dominant load — and older buildings use dramatically more:

heating_col = "out.electricity.heating.energy_consumption.kwh"

vintage_heating = (
    harris.groupby("in.vintage")
    .apply(lambda g: (g[heating_col] * g["weight"]).sum() / g["weight"].sum(),
           include_groups=False)
    .sort_index()
)

print("Heating electricity by construction era (kWh/dwelling/year):")
for vintage, kwh in vintage_heating.items():
    print(f"  {vintage:<20} {kwh:>8,.0f}")
Bar chart showing heating electricity demand by building vintage in Harris County. Pre-1940 homes use 2,309 kWh per year versus 674 kWh for 2010s construction — a 3.4x difference.

Harris County heating electricity by construction era. Pre-1940 homes draw 3.4× more heating power than 2010s construction. Source: ResStock 2024 R2 baseline.

The insight: during a freeze, neighborhoods with older housing stock draw dramatically more heating power. A pre-1940 home uses 2,309 kWh of electric heating annually versus 674 kWh for a 2010s home — a 3.4× difference. When you're trying to predict where storm demand will spike, building age is one of the strongest signals in the data.

Step 2: Your Solar Farm's Data

You're a utility analyst. Your grid planning team just got a data export from Bayou Solar Farm — a 5 MW ground-mounted installation in eastern Harris County that feeds into several neighborhoods via the local distribution grid. The data is a raw CSV from the farm's SMA Sunny Portal.

Here's what it looks like:

Timestamp,Total Power (kW),Grid Frequency (Hz),Status,Daily Yield (kWh)
2026-01-20 07:00:00,142.3,60.01,Ok,0.0
2026-01-20 07:05:00,287.6,60.00,Ok,23.9
2026-01-20 12:00:00,3487.2,59.98,Ok,10241.5
2026-01-20 18:30:00,12.1,60.02,Ok,19847.3
...
2026-01-25 12:00:00,203.4,59.97,Warning,892.1
2026-01-25 12:05:00,0.0,59.96,Disturbance,892.1
2026-01-25 12:10:00,87.2,60.01,Warning,899.4

On normal January days, the farm peaks around 3,500 kW at midday and produces roughly 19–21 MWh per day. Then Fern arrives. On January 25 — the worst day — ice coats the trackers, clouds cut irradiance to near zero, and the inverters cycle between Warning and Disturbance states. Daily production drops to 1.2 MWh. The farm effectively goes dark.

Normalize with ODS-E

Raw SMA exports can't be directly joined with ResStock building data — the column names, timestamp formats, energy semantics, and status codes are all SMA-specific. This is where ODS-E comes in. Transform the raw CSV into standardized energy-timeseries records:

from odse import transform

# Transform raw SMA Sunny Portal export to ODS-E
records = transform(
    "bayou_solar_farm_export.csv",
    source="csv",
    asset_id="BAYOU-SOLAR-001",
    mapping={
        "timestamp": "Timestamp",
        "power_kw": "Total Power (kW)",
        "error_type": "Status",
    },
    error_code_mapping={
        "normal": ["Ok"],
        "warning": ["Warning"],
        "critical": ["Disturbance", "Off"],
    },
    interval_minutes=5,
)

print(f"Transformed {len(records)} records")

Each record now looks like this — regardless of whether the original data came from SMA, Enphase, SolarEdge, or any other OEM:

// Normal production — clear day
{
  "timestamp": "2026-01-20T12:00:00-06:00",
  "kWh": 29.1,
  "error_type": "normal",
  "direction": "generation"
}

// Storm day — inverter struggling
{
  "timestamp": "2026-01-25T12:00:00-06:00",
  "kWh": 1.7,
  "error_type": "warning",
  "direction": "generation"
}

And the asset metadata record for the farm:

{
  "asset_id": "BAYOU-SOLAR-001",
  "asset_type": "solar_pv",
  "capacity_kw": 5000,
  "site_id": "bayou-solar-farm",
  "oem": "SMA",
  "location": {
    "latitude": 29.78,
    "longitude": -95.18,
    "timezone": "America/Chicago",
    "county": "Harris County",
    "state": "TX"
  }
}

Now aggregate the ODS-E records to daily production:

import pandas as pd

production = pd.DataFrame(records)
production["date"] = pd.to_datetime(production["timestamp"]).dt.date
daily = production.groupby("date")["kWh"].sum() / 1000  # MWh

for date, mwh in daily.items():
    marker = " << STORM" if date.day in [24, 25, 26, 27] else ""
    print(f"  {date}: {mwh:>6.1f} MWh{marker}")
Bar chart showing Bayou Solar Farm daily production. Normal days produce 19-21 MWh. During Winter Storm Fern (Jan 24-27), production collapses to 1-5 MWh per day — an 85% drop.

Bayou Solar Farm daily production during Winter Storm Fern. Normal January output: ~20 MWh/day. Storm days (red): production collapses 85%, bottoming at 1.2 MWh on Jan 25.

The chart tells the story: 20 MWh/day of solar generation that neighborhoods were counting on — suddenly gone. For four days, the grid has to supply that load directly. The question is which neighborhoods feel it most.

Step 3: Calculate the Demand Swing

Now join the two datasets: ODS-E solar production (what the farm normally generates) with ResStock building consumption (what the neighborhoods actually need). The difference on a storm day is the demand swing.

energy_col = "out.site_energy.total.energy_consumption.kwh"

# Per-PUMA total building consumption from ResStock
puma_consumption = harris.groupby("in.puma").agg(
    total_energy_gwh=pd.NamedAgg(
        column=energy_col,
        aggfunc=lambda x: (x * harris.loc[x.index, "weight"]).sum() / 1_000_000
    ),
    total_dwellings=pd.NamedAgg(column="weight", aggfunc="sum")
).sort_values("total_energy_gwh", ascending=False)

# From ODS-E records: Bayou Solar Farm normal daily production
normal_daily_mwh = 19.8   # from our aggregated ODS-E data
storm_daily_mwh = 2.4     # average during Fern (Jan 24-27)

# The farm serves ~12,000 homes across several PUMAs
# On a normal day: 19.8 MWh offsets grid demand
# On a storm day: only 2.4 MWh — grid must supply the missing 17.4 MWh
demand_swing_mwh = normal_daily_mwh - storm_daily_mwh

print(f"Normal daily farm output:  {normal_daily_mwh:.1f} MWh")
print(f"Storm daily farm output:   {storm_daily_mwh:.1f} MWh")
print(f"Daily demand swing:        {demand_swing_mwh:.1f} MWh")
print(f"4-day storm total swing:   {demand_swing_mwh * 4:.1f} MWh")

That's 17.4 MWh per day of extra grid demand during the storm — and that's just one 5 MW farm. Harris County has multiple solar installations. Scale this across the county's total solar capacity at projected future penetration levels:

# Scale to projected county-wide solar capacity
# Current: ~30 MW installed. Projected: 150 MW (5x), 500 MW (16x)
for capacity_mw, label in [(30, "Current ~30 MW"), (150, "Near-term 150 MW"),
                            (500, "Projected 500 MW")]:
    scale = capacity_mw / 5  # relative to our 5 MW farm
    swing = demand_swing_mwh * scale
    print(f"  {label:<22} Daily storm swing: {swing:>7.0f} MWh  "
          f"({swing/1000:.1f} GWh over 4 days)")
Grouped bar chart showing predicted demand swing by PUMA at projected solar penetration levels. The highest-consumption PUMAs show the biggest absolute swing when solar goes dark.

Predicted demand swing by PUMA when solar drops to zero, at three projected penetration levels. Higher bars = bigger demand surprise during a storm. Source: ResStock 2024 R2 baseline + ODS-E production data, Harris County.

At projected 500 MW county-wide solar capacity, a Fern-like storm creates a 1,740 MWh daily demand swing — nearly 7 GWh over a four-day event. The critical question for grid planners isn't the aggregate number. It's which neighborhoods absorb that swing, and whether those neighborhoods also happen to have the highest heating demand.

Step 4: Map Storm Vulnerability by Neighborhood

The real power of this analysis comes from putting it on a map. Not all demand swing is equal — a neighborhood that has high solar dependency and older housing stock and electric heating is a qualitatively different risk than one with new construction and heat pumps.

Combine four signals from the ResStock data into a composite vulnerability score, then map it onto actual Harris County PUMA boundaries using Census TIGER shapefiles:

import numpy as np
import geopandas as gpd

heating_col = "out.electricity.heating.energy_consumption.kwh"

# Build per-PUMA risk factors from ResStock
puma_risk = harris.groupby("in.puma").apply(
    lambda g: pd.Series({
        "heating_intensity": (
            (g[heating_col] * g["weight"]).sum() / g["weight"].sum()
        ),
        "dwelling_count": g["weight"].sum(),
        "pre_1980_pct": (
            g[g["in.vintage"].isin([
                v for v in g["in.vintage"].unique()
                if any(era in str(v) for era in
                       ["<1940", "1940s", "1950s", "1960s", "1970s"])
            ])]["weight"].sum() / g["weight"].sum() * 100
        ),
        "electric_heat_pct": (
            g[g["in.hvac_heating_type_and_fuel"].str.contains(
                "Electric", na=False
            )]["weight"].sum() / g["weight"].sum() * 100
        ),
    }),
    include_groups=False
)

# Normalize each factor to 0-1
for col in puma_risk.columns:
    lo, hi = puma_risk[col].min(), puma_risk[col].max()
    puma_risk[col] = (puma_risk[col] - lo) / (hi - lo) if hi > lo else 0.5

# Composite vulnerability score
puma_risk["vulnerability"] = (
    puma_risk["heating_intensity"] * 0.30 +
    puma_risk["dwelling_count"] * 0.25 +
    puma_risk["pre_1980_pct"] * 0.25 +
    puma_risk["electric_heat_pct"] * 0.20
)

Now load the Census TIGER 2020 PUMA boundaries (the same boundaries ResStock uses) and join the scores:

# Download PUMA shapefile for Texas:
# https://www2.census.gov/geo/tiger/TIGER2022/PUMA/tl_2022_48_puma20.zip

gdf = gpd.read_file("tl_2022_48_puma20/tl_2022_48_puma20.shp")

# Map ResStock PUMA codes to Census: G48004601 → 04601
puma_risk["PUMACE20"] = puma_risk.index.str.replace("G480", "")

# Filter to Harris County and join
harris_gdf = gdf[gdf["PUMACE20"].isin(puma_risk["PUMACE20"])].copy()
harris_gdf = harris_gdf.merge(puma_risk[["PUMACE20", "vulnerability"]],
                               on="PUMACE20")

# Plot choropleth
harris_gdf.plot(column="vulnerability", cmap="YlOrRd",
                legend=True, edgecolor="white", linewidth=0.8)
plt.title("Storm Demand Vulnerability — Harris County PUMAs")
plt.axis("off")
plt.savefig("harris_county_vulnerability.svg")
Geographic choropleth map of Harris County, Texas showing storm demand vulnerability by PUMA. Darker red areas indicate neighborhoods with higher composite risk from heating intensity, dwelling density, older housing stock, and electric heating dependency.

Storm demand vulnerability by Harris County neighborhood (PUMA). Composite score: heating intensity (30%), dwelling density (25%), pre-1980 stock (25%), electric heating (20%). Darker = higher risk. Source: ResStock 2024 R2 + Census TIGER 2020 PUMA boundaries.

The map reveals the geography of risk. The most vulnerable PUMAs cluster in Houston's inner core — East Central Houston, North Houston inside the Beltway, and North Central Houston inside Loop I-610. These neighborhoods combine the worst risk factors: dense older housing stock with high heating demand and significant electric heating dependency. During a storm like Fern, these are the areas where demand spikes hardest — and where solar production loss is felt most acutely on the distribution grid.

What Fern Actually Showed Us

Winter Storm Fern wasn't a failure. It was a proof point for $14 billion in grid investment since Uri. Battery storage alone delivered 7,000 MW at peak — capacity that didn't exist five years ago. Weatherization mandates reduced residential demand during the cold snap. The emergency order was precautionary.

But Fern also previewed the next planning challenge. Solar at 0 MW during a pre-dawn winter peak isn't a failure of solar — it's a structural fact about solar physics. The planning question is what happens when a growing share of a metro area's load is normally offset by solar generation, and then that generation disappears in a storm. The swing is predictable, locatable, and quantifiable. It just requires two things most utilities don't combine today: normalized solar production data and neighborhood-level building consumption profiles.

That's exactly what this analysis demonstrates. ODS-E normalizes the solar farm's inverter data into a standard schema — regardless of whether it comes from SMA, Enphase, SolarEdge, or any other OEM. ResStock provides the building-level consumption baseline. Joining them gives you a neighborhood-by-neighborhood storm vulnerability map that no single data source can produce alone.

The analysis takes about an hour. The building data is free (ResStock 2024 R2, modeled from actual building stock surveys and NOAA weather). The ODS-E transform is open source. The code is reproducible — download the same CSV, run the same steps, get the same results. No proprietary platform required. We've packaged the entire workflow as a self-contained Jupyter notebook with bundled SMA data — it installs its own dependencies, downloads ResStock and the Census PUMA shapefile, and reproduces every chart in this article.

That's the "citizen data scientist" version of storm readiness: one analyst, two datasets, one afternoon, and a forecast that most utilities don't have.

Try It Yourself

← Back to Blog