Why Can't We Forecast Rain Six Months From Now?¶
Ask a meteorologist "will it rain on my birthday, six months from today?" and you'll get a shrug, not a number. Yet the same scientist can confidently tell you whether this coming winter is likely to be wetter or drier than usual overall. Same atmosphere, wildly different confidence — so what's going on?
The short answer is chaos. Not the everyday "things are messy" kind, but a precise phenomenon where tiny, unmeasurable differences in today's atmosphere snowball into completely different outcomes within a couple of weeks. We'll build the intuition with a wandering chess knight, then look at the real physics behind it — the same equations that gave chaos theory its name.
Chaos isn't randomness — it's sensitivity¶
A chaotic system is fully deterministic: the same starting point always leads to the same outcome, no dice rolls involved. The catch is that two starting points that are almost identical can lead to wildly different outcomes, and the gap between them grows exponentially, not gradually.
The atmosphere behaves exactly like this. We never know its exact state — every thermometer, weather balloon, and satellite carries some tiny measurement error. Those tiny errors don't stay tiny. They double, and double again, until a forecast has no more skill than a random guess.
Let's watch this play out somewhere lower-stakes than the weather: a chessboard.
import re
import numpy as np
from scipy.integrate import solve_ivp
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from IPython.display import HTML, display
def show(fig, div_id, height=420):
"""Render a self-contained, interactive Plotly figure (hover, zoom, pan, rotate)."""
fig.update_layout(
paper_bgcolor="white",
plot_bgcolor="white",
margin=dict(t=60, b=40, l=50, r=20),
)
html = fig.to_html(
full_html=False,
include_plotlyjs="cdn",
default_height=f"{height}px",
div_id=div_id,
)
# Strip the auto-generated SRI hash on the CDN <script> tag -- it's a long
# base64 string that trips secret scanners as a false positive.
html = re.sub(r'\s+integrity="[^"]*"\s+crossorigin="anonymous"', "", html)
display(HTML(html))
Demo 1 — A knight that forgets where it started¶
Picture a knight starting at the center of a chessboard. At every move, instead of a grandmaster choosing the best square, it picks a completely random legal knight move. If we repeat this 200,000 times and track where the knight ends up, we get a map of how likely it is to be on each square after 1 move, 3 moves, 8 moves, and 20 moves.
There's no chaos in this rule — a knight's move is simple and fully determined once chosen. But it mimics the same core idea as a weather forecast: information about the exact starting square gets diluted a little more with every step, until it's nearly useless.
KNIGHT_MOVES = np.array(
[
(1, 2),
(1, -2),
(-1, 2),
(-1, -2),
(2, 1),
(2, -1),
(-2, 1),
(-2, -1),
]
)
def simulate_knight_walk(n_trials, n_steps, start, board=8, seed=7):
"""Track `n_trials` knights taking `n_steps` uniformly random legal moves."""
rng = np.random.default_rng(seed)
pos = np.tile(np.array(start), (n_trials, 1))
snapshots = {}
for step in range(1, n_steps + 1):
candidate = pos + KNIGHT_MOVES[rng.integers(0, 8, size=n_trials)]
off_board = ((candidate < 0) | (candidate >= board)).any(axis=1)
while off_board.any():
n_bad = off_board.sum()
candidate[off_board] = (
pos[off_board] + KNIGHT_MOVES[rng.integers(0, 8, size=n_bad)]
)
off_board = ((candidate < 0) | (candidate >= board)).any(axis=1)
pos = candidate
if step in (1, 3, 8, 20):
snapshots[step] = pos.copy()
return snapshots
START_SQUARE = (3, 3)
snapshots = simulate_knight_walk(n_trials=200_000, n_steps=20, start=START_SQUARE)
heatmaps = {}
for step, pos in snapshots.items():
heat = np.zeros((8, 8))
np.add.at(heat, (pos[:, 1], pos[:, 0]), 1)
heatmaps[step] = heat / heat.sum()
vmax = max(h.max() for h in heatmaps.values())
steps_to_show = [1, 3, 8, 20]
fig = make_subplots(
rows=1,
cols=4,
subplot_titles=[f"After {s} move{'s' if s > 1 else ''}" for s in steps_to_show],
horizontal_spacing=0.04,
)
for i, step in enumerate(steps_to_show, start=1):
is_last = i == len(steps_to_show)
heatmap_kwargs = dict(
z=heatmaps[step].tolist(),
colorscale="Magma",
zmin=0,
zmax=vmax,
showscale=is_last,
hovertemplate="file %{x}, rank %{y}<br>probability: %{z:.3f}<extra></extra>",
)
if is_last:
heatmap_kwargs["colorbar"] = dict(title="Probability", len=0.9)
fig.add_trace(go.Heatmap(**heatmap_kwargs), row=1, col=i)
fig.add_trace(
go.Scatter(
x=[START_SQUARE[0]],
y=[START_SQUARE[1]],
mode="markers",
marker=dict(
symbol="star", size=16, color="cyan", line=dict(color="white", width=1)
),
hoverinfo="skip",
showlegend=False,
),
row=1,
col=i,
)
fig.update_yaxes(autorange="reversed", showticklabels=False, row=1, col=i)
fig.update_xaxes(showticklabels=False, row=1, col=i)
fig.update_layout(
title="Where's the knight? Confident after 1 move, clueless after 20. (hover a square to see its odds)"
)
show(fig, "knight-heatmap", height=340)
What just happened¶
- After 1 move, the knight can only be on one of 8 squares (marked with the star showing where it started) — you basically know exactly where it is.
- After 3 moves, that's already spread across 32 squares.
- By 8 and 20 moves, the distribution has essentially stopped changing — it has "forgotten" its exact starting square. All you can say now is roughly where the knight tends to be (more central, less cornered), not exactly where it is.
(Sharp-eyed readers might notice the knight is always confined to half the board. A knight flips square color — light to dark or dark to light — with every single move, so it can only ever land on 32 of the 64 squares at a time, depending on whether the move count is odd or even. A fun quirk, but it doesn't change the big picture: exact position becomes unknowable fast, even though every rule is simple and deterministic.)
This is the shape of every weather forecast: sharp and confident for tomorrow, blurrier for next week, and past about two weeks, close to "climatological" — you know the kind of weather to expect, not the specific day.
Demo 2 — The equations that discovered chaos¶
The knight demo is an analogy. Real atmospheric chaos comes from equations like these, first studied by MIT meteorologist Edward Lorenz in the early 1960s. He was running a toy model of atmospheric convection — hot air rising, cool air sinking — on an early computer, and stumbled onto something by accident.
Wanting to rerun part of a simulation, Lorenz re-typed a starting value from a printout that showed it rounded to three decimals (0.506) instead of the computer's full six decimals (0.506127) — a difference smaller than the width of the period at the end of this sentence. He expected a nearly identical result. Instead, the two runs tracked each other for a while, then diverged completely. That accident became the founding story of chaos theory and the "butterfly effect."
Let's reproduce it with the actual Lorenz equations.
def lorenz(t, state, sigma=10.0, rho=28.0, beta=8.0 / 3.0):
x, y, z = state
return [sigma * (y - x), x * (rho - z) - y, x * y - beta * z]
t_span = (0, 40)
t_eval = np.linspace(*t_span, 4000)
state_a = np.array([0.0, 1.0, 1.05])
state_b = state_a + np.array(
[1e-5, 0.0, 0.0]
) # a gap smaller than Lorenz's rounding error
sol_a = solve_ivp(lorenz, t_span, state_a, t_eval=t_eval)
sol_b = solve_ivp(lorenz, t_span, state_b, t_eval=t_eval)
diff = np.abs(sol_a.y[0] - sol_b.y[0])
divergence_time = t_eval[np.argmax(diff > 1.0)]
fig = make_subplots(
rows=2,
cols=1,
shared_xaxes=True,
row_heights=[0.55, 0.45],
vertical_spacing=0.1,
subplot_titles=(
'Two "forecasts" that start almost identically',
"Gap between A and B (log scale)",
),
)
fig.add_trace(
go.Scatter(
x=t_eval.tolist(),
y=sol_a.y[0].tolist(),
mode="lines",
name="Forecast A",
line=dict(color="#1b6ca8", width=1.4),
hovertemplate="t=%{x:.1f}<br>x=%{y:.2f}<extra>Forecast A</extra>",
),
row=1,
col=1,
)
fig.add_trace(
go.Scatter(
x=t_eval.tolist(),
y=sol_b.y[0].tolist(),
mode="lines",
name="Forecast B (started 0.00001 apart)",
line=dict(color="#e8702a", width=1.4),
hovertemplate="t=%{x:.1f}<br>x=%{y:.2f}<extra>Forecast B</extra>",
),
row=1,
col=1,
)
fig.add_trace(
go.Scatter(
x=t_eval.tolist(),
y=diff.tolist(),
mode="lines",
line=dict(color="#333333", width=1.3),
showlegend=False,
hovertemplate="t=%{x:.1f}<br>gap=%{y:.4f}<extra></extra>",
),
row=2,
col=1,
)
fig.add_vline(
x=divergence_time, line=dict(color="crimson", dash="dash", width=1), row=2, col=1
)
fig.add_annotation(
x=divergence_time + 0.5,
y=diff.max() * 0.05,
text="now unrecognizably different",
showarrow=False,
font=dict(color="crimson"),
xanchor="left",
row=2,
col=1,
)
fig.update_yaxes(type="log", row=2, col=1)
fig.update_yaxes(title="x(t)", row=1, col=1)
fig.update_xaxes(title="Time", row=2, col=1)
fig.update_layout(legend=dict(orientation="h", y=1.18))
show(fig, "lorenz-divergence", height=560)
Two nearly identical starts, two unrecognizable forecasts¶
For the first several time units the two lines sit right on top of each other — same as the knight's first move being totally predictable. Then, quite suddenly, they peel apart, and by the end of the run the two "forecasts" look like they came from two different storms entirely.
This is exactly the challenge facing a real forecast model. We never know the atmosphere's exact state — only a good estimate of it. That tiny gap grows just like the gap in this simulation, and it's why forecast confidence craters after roughly 10–14 days, no matter how powerful the supercomputer behind it.
As a bonus, if we plot the trajectory itself instead of just one variable over time, we get the shape that gave chaos theory its nickname:
fig = go.Figure(
data=go.Scatter3d(
x=sol_a.y[0].tolist(),
y=sol_a.y[1].tolist(),
z=sol_a.y[2].tolist(),
mode="markers",
marker=dict(
size=2,
color=t_eval.tolist(),
colorscale="Viridis",
colorbar=dict(title="Time"),
opacity=0.85,
),
hovertemplate="x=%{x:.1f}<br>y=%{y:.1f}<br>z=%{z:.1f}<extra></extra>",
)
)
fig.update_layout(
title='The Lorenz attractor — nicknamed "the butterfly" (drag to rotate, scroll to zoom)',
scene=dict(xaxis_title="x", yaxis_title="y", zaxis_title="z"),
)
show(fig, "lorenz-attractor", height=560)
Demo 3 — How forecasters actually deal with this¶
If a single "best guess" simulation is doomed to become useless, what do meteorologists do instead? They don't run one forecast — they run dozens, called an ensemble, each starting from a slightly different (equally plausible) initial state that reflects the genuine uncertainty in today's observations.
Let's do that with our Lorenz system: 30 runs, all starting almost exactly at the same point.
rng = np.random.default_rng(3)
n_members = 30
ensemble_t_span = (0, 30)
ensemble_t_eval = np.linspace(*ensemble_t_span, 3000)
members_x = []
for _ in range(n_members):
s0 = state_a + rng.normal(scale=1e-3, size=3)
sol = solve_ivp(lorenz, ensemble_t_span, s0, t_eval=ensemble_t_eval)
members_x.append(sol.y[0])
members_x = np.array(members_x)
fig = go.Figure()
for i, member in enumerate(members_x):
fig.add_trace(
go.Scatter(
x=ensemble_t_eval.tolist(),
y=member.tolist(),
mode="lines",
line=dict(color="#1b6ca8", width=1),
opacity=0.25,
hovertemplate=f"member {i + 1}<br>t=%{{x:.1f}}<br>x=%{{y:.2f}}<extra></extra>",
showlegend=False,
)
)
fig.add_trace(
go.Scatter(
x=ensemble_t_eval.tolist(),
y=members_x.mean(axis=0).tolist(),
mode="lines",
line=dict(color="crimson", width=2.5),
name="Ensemble mean",
hovertemplate="t=%{x:.1f}<br>mean x=%{y:.2f}<extra></extra>",
)
)
fig.update_layout(
title="30 nearly identical starting points → 30 very different stories",
xaxis_title="Time (think: forecast lead time)",
yaxis_title="x(t)",
legend=dict(orientation="h", y=1.15),
)
show(fig, "ensemble-spaghetti", height=440)
The "cone of uncertainty," reinvented¶
Notice the shape: a tight bundle of lines that fans out into a wide spread. This is precisely the logic behind a hurricane's "cone of uncertainty," and behind the percentage on a 10-day forecast ("70% chance of rain" means 70% of ensemble members had rain there, not that it will rain 70% as hard).
Past about two weeks, the spread is so wide that individual members are no more informative than picking a random day from the historical record. No better model, no bigger supercomputer, and no more data fixes this — it's a mathematical ceiling, not an engineering problem.
So how does a seasonal outlook work, then?¶
If a specific day's rainfall is unknowable past two weeks, how can forecasters issue seasonal outlooks — like "this winter is likely to be wetter than normal in California"?
The trick is to stop asking a chaotic question. Instead of "what will the atmosphere be doing on March 4th," seasonal forecasting asks "given slow, predictable boundary conditions — ocean temperatures (especially El Niño/La Niña patterns), soil moisture, sea ice — what's the statistical tilt of the whole season?" Oceans change over months, not days, so they're far more predictable than day-to-day weather. You trade an impossible question (which day will it rain?) for an answerable one (is this a wetter-than-average season overall?).
That's why a seasonal forecast is issued as a probability — "above normal / near normal / below normal" — for a three-month window, never as a specific rainfall total on a specific day six months out.
Matching the tool to the forecast horizon¶
flowchart LR
A["How far out?"] --> B["1-2 days"]
A --> C["3-14 days"]
A --> D["Months (seasonal)"]
B --> E["Persistence / Markov chain -- today predicts tomorrow well"]
C --> F["Ensemble forecasts -- many plausible futures, spread grows"]
D --> G["Climate outlook -- slow ocean/land signals, probabilities only"]
Each tool exploits a different, honest amount of predictability -- none of them are trying to beat chaos, just to use exactly as much of it as survives at that lead time.
The takeaway¶
- Weather is chaotic: tiny, unavoidable uncertainties in today's atmosphere grow exponentially, not gradually.
- The knight shows how fast exact information gets diluted, even in a simple, fully deterministic system.
- The Lorenz equations show the same thing happening in a toy model of real atmospheric physics — and gave us the terms "chaos theory" and "the butterfly effect."
- Ensembles are how real forecasts represent this honestly: many plausible futures, not one confident answer.
- Seasonal outlooks work differently — they lean on slow-moving ocean and land signals to make statistical, not day-specific, predictions.
So next time a seasonal outlook says "wetter than normal winter," believe it — but don't ask it what will happen on a particular Tuesday in March.