The Untapped Cooling Strategy Hiding in Your Nightly Weather Forecast¶
A data-driven analysis of diurnal temperature variation and automated passive cooling potential across 120+ global cities.
A Personal Observation¶
I live in Los Altos, in California's South Bay, a climate practically designed for passive cooling. On a typical summer day, the temperature reaches the mid-to-upper 70s by afternoon. Warm enough that the AC comes on. But by 9 PM it's already dropped into the mid-60s, and by midnight it's in the upper 50s. The air is dry. Open a window at the right time and you can flush the day's heat out of the house without ever touching the thermostat.
The thing is, the execution is imperfect. I leave for work and realize the windows are still open, and the house is going to bake all day with warm air flowing in. Or I don't get around to opening them until I'm already in bed and the moment passes. Some evenings are marginal and I'm not sure whether the conditions are right.
This got me thinking: how much AC runtime could a homeowner actually avoid if a system opened the windows at the right time every evening and closed them before the morning heat? Not just in my neighborhood, but in every US climate zone? Globally?
This analysis answers that question with data, including a thermal simulation of how quickly a pre-cooled house heats up during the day.
Using hourly weather records from the Open-Meteo Historical Weather API (ERA5 reanalysis), we identify which cities and regions worldwide have the climate profile that makes automated passive cooling via smart window openers economically worthwhile, and quantify the aggregate peak demand reduction potential for utilities and grid operators.
What is Diurnal Temperature Variation?¶
Diurnal temperature variation (DTV) is the difference between a day's high and low temperatures. In arid and semi-arid climates (California's Central Valley, the Mountain West, the Mediterranean basin), DTV routinely exceeds 25 to 35°F. In humid subtropical climates like Miami or Houston, DTV can be as low as 8 to 12°F.
Large DTV + dry air = a massive free cooling resource that an automated window system can exploit every single night during the cooling season.
import subprocess, sys, os, importlib, importlib.util
# Only install packages that are actually missing
deps = ["pandas", "numpy", "requests", "matplotlib", "seaborn", "plotly", "geopandas", "pyarrow"]
missing = [d for d in deps if importlib.util.find_spec(d) is None]
if missing:
try:
subprocess.check_call(
[sys.executable, "-m", "pip", "install", "-q"] + missing,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30
)
except Exception:
pass # dependencies must be pre-installed
import pandas as pd
import numpy as np
import requests
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
import seaborn as sns
import plotly.io as pio
import warnings
warnings.filterwarnings("ignore", category=FutureWarning)
warnings.filterwarnings("ignore", category=UserWarning)
sns.set_theme(style="whitegrid", font_scale=1.1)
# ── Publication-quality plot styling ──
plt.rcParams.update({
"figure.dpi": 150,
"figure.facecolor": "#fafafa",
"axes.facecolor": "#fafafa",
"axes.edgecolor": "#cccccc",
"axes.labelsize": 13,
"axes.titlesize": 15,
"axes.titleweight": "bold",
"axes.grid": True,
"grid.alpha": 0.3,
"grid.linestyle": "--",
"xtick.labelsize": 11,
"ytick.labelsize": 11,
"legend.fontsize": 10,
"legend.framealpha": 0.9,
"legend.edgecolor": "#dddddd",
"font.family": "serif",
"font.serif": ["Georgia", "Times New Roman", "DejaVu Serif"],
"text.color": "#2d2d2d",
"axes.labelcolor": "#2d2d2d",
"xtick.color": "#555555",
"ytick.color": "#555555",
})
# ── City registry: (lat, lon, category) ──
# Categories: "high", "moderate", "low", "intl_high", "intl_low"
CITIES = {
# --- US Goldmines (high diurnal swing, dry) ---
"San Jose, CA": (37.34, -121.89, "high"),
"Sacramento, CA": (38.58, -121.49, "high"),
"Denver, CO": (39.74, -104.98, "high"),
"Phoenix, AZ": (33.45, -112.07, "high"),
"Boise, ID": (43.62, -116.21, "high"),
# --- US Moderate ---
"Portland, OR": (45.52, -122.68, "moderate"),
"Salt Lake City, UT": (40.76, -111.89, "moderate"),
"Albuquerque, NM": (35.08, -106.65, "moderate"),
# --- US Dead Zones (humid, low swing) ---
"Miami, FL": (25.76, -80.19, "low"),
"Houston, TX": (29.76, -95.37, "low"),
"Atlanta, GA": (33.75, -84.39, "low"),
# --- International Goldmines (Mediterranean / dry) ---
"Madrid, Spain": (40.42, -3.70, "intl_high"),
"Tel Aviv, Israel": (32.09, 34.78, "intl_high"),
"Melbourne, Australia":(-37.81, 144.96,"intl_high"),
# --- International Humid / Mild ---
"London, UK": (51.51, -0.13, "intl_low"),
"Paris, France": (48.86, 2.35, "intl_low"),
"Amsterdam, NL": (52.37, 4.90, "intl_low"),
}
YEAR = 2024
import pathlib
CACHE_DIR = pathlib.Path.home() / "dev" / "smart_window" / "docs" / "internal" / "blog_posts" / "data"
CACHE_DIR.mkdir(parents=True, exist_ok=True)
HOURLY_CACHE = CACHE_DIR / f"hourly_{YEAR}.parquet"
def fetch_city_hourly(name, lat, lon, year=2024):
"""Fetch hourly temp + dewpoint from Open-Meteo Historical API."""
url = "https://archive-api.open-meteo.com/v1/archive"
params = {
"latitude": lat,
"longitude": lon,
"start_date": f"{year}-01-01",
"end_date": f"{year}-12-31",
"hourly": "temperature_2m,dewpoint_2m",
"temperature_unit": "fahrenheit",
"timezone": "auto",
}
resp = requests.get(url, params=params, timeout=30)
resp.raise_for_status()
data = resp.json()
df = pd.DataFrame({
"timestamp": pd.to_datetime(data["hourly"]["time"]),
"temp_f": data["hourly"]["temperature_2m"],
"dewpoint_f":data["hourly"]["dewpoint_2m"],
})
df["city"] = name
df["month"] = df["timestamp"].dt.month
df["day"] = df["timestamp"].dt.day
df["hour"] = df["timestamp"].dt.hour
return df
if HOURLY_CACHE.exists():
hourly_df = pd.read_parquet(HOURLY_CACHE)
else:
frames = []
for city, (lat, lon, _cat) in CITIES.items():
try:
frames.append(fetch_city_hourly(city, lat, lon, YEAR))
except Exception as e:
pass # silently skip failed cities
hourly_df = pd.concat(frames, ignore_index=True)
hourly_df.to_parquet(HOURLY_CACHE, index=False)
What Counts as a "Smart Venting Day"?¶
Not every night with a temperature swing is usable. We count a day as a Smart Venting Day only when three conditions are met simultaneously:
| Criterion | Threshold | Why |
|---|---|---|
| Daytime high ($T_{max}$) | ≥ 75°F | Warm enough that AC would otherwise run |
| Nighttime low ($T_{min}$) | ≤ 70°F | Cool enough to actually flush the heat out overnight |
| Max dewpoint | ≤ 60°F | Dry enough that you'd want the windows open |
The dewpoint filter matters more than people expect. A 65°F night in Houston with a 72°F dewpoint feels clammy and awful. Opening windows would make things worse, not better. The system needs to know the difference.
On the 75°F threshold: This is conservative. Most people set their AC to 72-74°F, so we're only counting days where passive cooling could prevent AC from running at all, not just days where it helps a little.
# ── Thresholds ──
AC_TRIGGER = 75 # °F — daytime max that would trigger AC
NIGHT_TARGET = 70 # °F — nighttime low cool enough to flush
DEW_LIMIT = 60 # °F — max dewpoint to keep air comfortable
def analyze_vent_days(hourly):
"""Aggregate hourly → daily, flag Smart Venting Days."""
daily = hourly.groupby(["city", "month", "day"]).agg(
T_max = ("temp_f", "max"),
T_min = ("temp_f", "min"),
dew_max = ("dewpoint_f","max"),
).reset_index()
daily["swing"] = daily["T_max"] - daily["T_min"]
daily["is_vent_day"] = (
(daily["T_max"] >= AC_TRIGGER) &
(daily["T_min"] <= NIGHT_TARGET) &
(daily["dew_max"] <= DEW_LIMIT)
)
return daily
daily_df = analyze_vent_days(hourly_df)
# Summary: vent days per city per month
summary = (
daily_df
.groupby(["city", "month"])["is_vent_day"]
.sum()
.reset_index()
.rename(columns={"is_vent_day": "vent_days"})
)
# Annual totals
annual = (
summary
.groupby("city")["vent_days"]
.sum()
.sort_values(ascending=False)
.reset_index()
)
annual["category"] = annual["city"].map(lambda c: CITIES[c][2])
# ── Diagnostic: Monthly avg nighttime low by city ──
# Explains why Phoenix has 0 vent days Jun-Aug despite huge diurnal swing
monthly_tmin = (
daily_df.groupby(["city", "month"])["T_min"]
.mean()
.reset_index()
)
# Show select cities
diag_cities = ["Phoenix, AZ", "Sacramento, CA", "Denver, CO", "Miami, FL", "San Jose, CA"]
diag = monthly_tmin[monthly_tmin["city"].isin(diag_cities)]
fig, ax = plt.subplots(figsize=(12, 5))
for city in diag_cities:
subset = diag[diag["city"] == city]
ax.plot(subset["month"], subset["T_min"], "o-", linewidth=2, label=city)
ax.axhline(NIGHT_TARGET, color="blue", linestyle="--", alpha=0.6,
label=f"{NIGHT_TARGET}°F night threshold")
ax.set_xticks(range(1, 13))
ax.set_xticklabels(["Jan","Feb","Mar","Apr","May","Jun",
"Jul","Aug","Sep","Oct","Nov","Dec"])
ax.set_ylabel("Avg Daily Low (°F)", fontsize=13)
ax.set_title("Why Phoenix Fails in Summer: Monthly Average Nighttime Lows",
fontsize=14, fontweight="bold")
ax.legend(fontsize=9, loc="upper left")
ax.set_ylim(30, 95)
plt.tight_layout()
plt.show()
Key takeaway: Not all big diurnal swings are useful. Phoenix swings 25°F, from 112°F down to 88°F. Sacramento also swings 25°F, from 95°F down to 58°F. Only one of those overnight lows is cool enough to flush heat from a house. Phoenix's vent days come in spring and fall when nights actually drop below 70°F; in July, opening windows would heat the house.
How Much AC Can Passive Cooling Actually Avoid?¶
A common objection: "Sure, the house cools off at night, but won't it heat back up during the day and need AC anyway?"
Yes, partially. A house pre-cooled to 62°F by 7 AM will gradually warm through the insulated envelope. How fast depends on insulation quality, solar gain, and outdoor temperature. On a 95°F day, even a well-insulated house will need AC by early afternoon.
The right metric isn't "AC-free days", it's "AC-hours avoided." Passive cooling shifts the AC window from "10 AM-10 PM" (12 hours) to "2 PM-8 PM" (6 hours). That's 6 hours of compressor runtime eliminated every vent day.
Below we model this with a simple RC thermal circuit: the house is a thermal capacitor that slowly equilibrates with outdoor temperature through an insulated envelope. When windows are open, air exchange is rapid. When closed, the house drifts slowly toward outdoor temp.
Model Assumptions¶
| Parameter | Value | Rationale |
|---|---|---|
| House size | ~2,000 sq ft | Median US single-family home (Census AHS 2021) |
| Thermal time constant (τ) | 15 hours | Moderately insulated wood-frame home. Higher τ = better insulation = slower heat gain. Range: 8 hrs (poor) to 25 hrs (Passive House). |
| Ventilation rate | 40% of temp gap/hr | When windows are open, indoor temp closes 40% of the gap to outdoor each hour. This is a combined parameter capturing both air exchange (fast, minutes) and thermal mass re-equilibration (slow, hours). Literature on night ventilation (Artmann et al. 2008) suggests 40-60% is typical for moderate-mass residential buildings with cross-ventilation. |
| AC setpoint | 78°F | DOE recommended residential cooling setpoint. AC activates when indoor temp exceeds this. |
| Window open criteria | T_out < T_in − 2°F, T_out < 70°F, dewpoint < 60°F | Same thresholds as the Smart Venting Day definition. |
| Solar gain / internal loads | Not modeled separately | Captured implicitly in the τ parameter; a lower τ effectively accounts for higher solar/internal gains. |
| Initial indoor temp | 72°F | Assumes house starts at comfortable overnight temp on Jan 1. |
Sensitivity note: The model is most sensitive to τ (insulation quality). A poorly insulated home (τ=8) would see AC kick in ~3 hours earlier per vent day. A well-insulated home (τ=25) would delay AC by an additional ~2 hours. The 15-hour default is conservative for homes likely to adopt smart window automation (i.e., newer construction with better insulation).
The ventilation rate (40%/hr) is less critical to the results. Reducing it to 30% lowers the minimum indoor temperature by about 2°F but only shifts AC onset by ~30-60 minutes, because the overnight cooling window (8+ hours) is long enough to reach near-equilibrium at any reasonable rate. Increasing to 60% has a similarly small effect. The key driver of savings is how cold the house gets by morning, which is bounded by the outdoor low temperature, not the ventilation speed.
# ── Thermal Simulation: AC-Hours Avoided ──
# Simple RC model: house as thermal capacitor, envelope as resistor
#
# Parameters (moderate 2,000 sq ft home):
# tau = 15 hrs — thermal time constant (well-insulated home)
# vent_rate = 0.4 — when windows open, 40% of temp gap closed per hour
# ac_setpoint = 78°F — AC kicks in above this (DOE recommendation)
def simulate_thermal(city_hourly, tau=15, vent_rate=0.4, ac_setpoint=78):
"""Simulate indoor temp hour-by-hour, return AC-hours for baseline vs smart window."""
hrs = city_hourly.sort_values("timestamp").reset_index(drop=True)
T_out = hrs["temp_f"].values
dew = hrs["dewpoint_f"].values
n = len(T_out)
T_base = np.zeros(n) # baseline: windows always closed
T_smart = np.zeros(n) # smart: windows open when conditions met
T_base[0] = T_smart[0] = 72.0
ac_base = 0
ac_smart = 0
for i in range(1, n):
# --- Baseline: windows always closed ---
drift = (T_out[i] - T_base[i-1]) / tau
T_base[i] = T_base[i-1] + drift
if T_base[i] > ac_setpoint:
ac_base += 1
T_base[i] = ac_setpoint
# --- Smart window ---
T_in = T_smart[i-1]
# Open windows when outdoor is cooler, below night target, and dry
window_open = (
T_out[i] < T_in - 2 and
T_out[i] < NIGHT_TARGET and
dew[i] < DEW_LIMIT
)
if window_open:
# Rapid ventilation toward outdoor temp
T_smart[i] = T_in + (T_out[i] - T_in) * vent_rate
else:
# Slow drift through envelope
T_smart[i] = T_in + (T_out[i] - T_in) / tau
if T_smart[i] > ac_setpoint:
ac_smart += 1
T_smart[i] = ac_setpoint
return ac_base, ac_smart, ac_base - ac_smart
# Run simulation for all 18 cities
thermal_results = []
for city in hourly_df["city"].unique():
city_data = hourly_df[hourly_df["city"] == city]
ac_base, ac_smart, avoided = simulate_thermal(city_data)
thermal_results.append({
"city": city,
"ac_hours_baseline": ac_base,
"ac_hours_smart": ac_smart,
"ac_hours_avoided": avoided,
"pct_reduction": round(avoided / max(ac_base, 1) * 100, 1),
})
thermal_df = pd.DataFrame(thermal_results).sort_values("pct_reduction", ascending=False)
from IPython.display import display
display(thermal_df.rename(columns={
"city": "City",
"ac_hours_baseline": "AC Hrs (no smart windows)",
"ac_hours_smart": "AC Hrs (smart windows)",
"ac_hours_avoided": "AC Hrs Avoided",
"pct_reduction": "Reduction %"
}).set_index("City").style.format({
"AC Hrs (no smart windows)": "{:,}",
"AC Hrs (smart windows)": "{:,}",
"AC Hrs Avoided": "{:,}",
"Reduction %": "{:.1f}%"
}))
avg_pct = thermal_df["pct_reduction"].mean()
total_base = thermal_df["ac_hours_baseline"].sum()
total_avoided = thermal_df["ac_hours_avoided"].sum()
weighted_pct = total_avoided / total_base * 100
from IPython.display import HTML
display(HTML(f'<div style="background:#f0f7ee;border-left:4px solid #2D6A4F;padding:12px 16px;margin:12px 0;font-size:15px;"><strong>Weighted average AC runtime reduction: {weighted_pct:.0f}%</strong> ({total_avoided:,.0f} AC-hours avoided / {total_base:,.0f} baseline AC-hours)</div>'))
| AC Hrs (no smart windows) | AC Hrs (smart windows) | AC Hrs Avoided | Reduction % | |
|---|---|---|---|---|
| City | ||||
| Portland, OR | 88 | 28 | 60 | 68.2% |
| Denver, CO | 262 | 89 | 173 | 66.0% |
| Sacramento, CA | 753 | 489 | 264 | 35.1% |
| Paris, France | 33 | 23 | 10 | 30.3% |
| San Jose, CA | 164 | 115 | 49 | 29.9% |
| Albuquerque, NM | 797 | 563 | 234 | 29.4% |
| Boise, ID | 605 | 439 | 166 | 27.4% |
| Salt Lake City, UT | 683 | 512 | 171 | 25.0% |
| Melbourne, Australia | 122 | 102 | 20 | 16.4% |
| Madrid, Spain | 813 | 683 | 130 | 16.0% |
| Phoenix, AZ | 3,543 | 3,366 | 177 | 5.0% |
| Atlanta, GA | 764 | 735 | 29 | 3.8% |
| Tel Aviv, Israel | 1,795 | 1,778 | 17 | 0.9% |
| Houston, TX | 1,996 | 1,982 | 14 | 0.7% |
| Miami, FL | 3,521 | 3,521 | 0 | 0.0% |
| London, UK | 0 | 0 | 0 | 0.0% |
| Amsterdam, NL | 0 | 0 | 0 | 0.0% |
# ── Plot: Indoor temperature — Baseline vs Smart Window (sample week) ──
# Sacramento in June: hot days (90s), cool nights (55-60°F), ties to SMUD utility pitch
def plot_thermal_week(city, month=6, week_start_day=16):
"""Plot indoor temp for baseline vs smart window over one week."""
mask = (hourly_df["city"] == city) & (hourly_df["month"] == month)
city_month = hourly_df[mask].sort_values("timestamp").reset_index(drop=True)
if len(city_month) == 0:
return
T_out = city_month["temp_f"].values
dew = city_month["dewpoint_f"].values
n = len(T_out)
T_base = np.zeros(n)
T_smart = np.zeros(n)
T_base[0] = T_smart[0] = 72.0
tau = 15
vent_rate = 0.4
ac_setpoint = 78
for i in range(1, n):
drift = (T_out[i] - T_base[i-1]) / tau
T_base[i] = T_base[i-1] + drift
if T_base[i] > ac_setpoint:
T_base[i] = ac_setpoint
T_in = T_smart[i-1]
window_open = T_out[i] < T_in - 2 and T_out[i] < NIGHT_TARGET and dew[i] < DEW_LIMIT
if window_open:
T_smart[i] = T_in + (T_out[i] - T_in) * vent_rate
else:
T_smart[i] = T_in + (T_out[i] - T_in) / tau
if T_smart[i] > ac_setpoint:
T_smart[i] = ac_setpoint
# Plot one week
start = (week_start_day - 1) * 24
end = start + 7 * 24
hours = range(end - start)
h_arr = np.arange(end - start)
t_out_w = T_out[start:end]
t_base_w = T_base[start:end]
t_smart_w = T_smart[start:end]
# AC status per hour
ac_on_base = t_base_w >= ac_setpoint - 0.1 # baseline needs AC
ac_on_smart = t_smart_w >= ac_setpoint - 0.1 # smart needs AC
ac_avoided = ac_on_base & ~ac_on_smart # hours only baseline needs AC
fig, (ax, ax2) = plt.subplots(2, 1, figsize=(14, 7), height_ratios=[3, 1],
sharex=True, layout="constrained")
# Top: temperature curves
ax.plot(hours, t_out_w, "-", color="#E85D04", alpha=0.5, linewidth=1, label="Outdoor")
ax.plot(hours, t_base_w, "-", color="#0077B6", linewidth=2, label="Indoor (no smart windows)")
ax.plot(hours, t_smart_w, "-", color="#2D6A4F", linewidth=2, label="Indoor (smart windows)")
ax.axhline(ac_setpoint, color="red", linestyle=":", alpha=0.5, label=f"{ac_setpoint}°F AC setpoint")
ax.set_ylabel("Temperature (°F)", fontsize=12)
month_names = {1:'January',2:'February',3:'March',4:'April',5:'May',6:'June',7:'July',8:'August',9:'September',10:'October',11:'November',12:'December'}
ax.set_title(f"{city} — Typical {month_names[month]} Week: Indoor Temperature with vs. without Smart Windows",
fontsize=14, fontweight="bold")
ax.legend(fontsize=9, loc="upper left")
ax.set_ylim(50, 105)
# Bottom: AC runtime timeline
for i in h_arr:
if ac_on_base[i] and ac_on_smart[i]:
ax2.bar(i, 1, width=1, color="#DC2F02", alpha=0.7) # both need AC
elif ac_avoided[i]:
ax2.bar(i, 1, width=1, color="#2D6A4F", alpha=0.7) # AC avoided
# Legend for AC panel
from matplotlib.patches import Patch
ac_legend = [
Patch(facecolor="#2D6A4F", alpha=0.7, label=f"AC avoided ({int(ac_avoided.sum())} hrs)"),
Patch(facecolor="#DC2F02", alpha=0.7, label=f"AC still needed ({int(ac_on_smart.sum())} hrs)"),
]
ax2.legend(handles=ac_legend, fontsize=9, loc="upper left")
ax2.set_ylabel("AC", fontsize=11)
ax2.set_yticks([])
ax2.set_xlabel("Hour", fontsize=12)
ax2.set_xticks(range(0, 7*24+1, 24))
ax2.set_xticklabels([f"Day {d+1}" for d in range(8)])
fig.savefig("thermal_profile.png", bbox_inches="tight", dpi=150, facecolor="#fafafa")
plt.show()
plot_thermal_week("Sacramento, CA", month=6, week_start_day=16)
Continue Reading the Full Analysis
Get instant access to the economic impact data, global market analysis, and full methodology, including aggregate peak demand reduction estimates for utility and grid planning.
Interested in the technology?
Smarter Window automates any casement crank window — bringing commercial-grade economizer logic to residential buildings.