History / release 1 · saved , the current version · released as https://doi.org/10.83371/sh.2610.sdyee
Cite this release
@software{snowball_earth_2026, author = {ScienceHub}, title = {Snowball Earth}, version = {1}, year = {2026}, publisher = {ScienceHub}, doi = {10.83371/sh.2610.sdyee} }
ScienceHub (2026). Snowball Earth (Version 1) [Computer software]. ScienceHub. https://doi.org/10.83371/sh.2610.sdyeecff-version: 1.2.0 message: "If you use this version, cite it as below." title: "Snowball Earth" type: software authors: - name: "ScienceHub" version: "1" date-released: "2026-10-13" doi: "10.83371/sh.2610.sdyee" url: "https://doi.org/10.83371/sh.2610.sdyee"
The DOI names exactly this release. The project page cites all releases.
snowball-earth.py
# /// script
# requires-python = ">=3.12"
# dependencies = ["marimo>=0.25.1", "numpy", "matplotlib"]
# ///
import marimo
__generated_with = "0.25.1"
app = marimo.App(width="medium")
@app.cell(hide_code=True)
def _(mo):
mo.md(
r"""
# Two climates under the same Sun
The simplest climate model balances sunlight absorbed against heat
radiated to space, for the planet as a whole:
$$C\,\frac{dT}{dt} = \frac{S}{4}\bigl(1 - \alpha(T)\bigr) - \varepsilon\,\sigma T^4$$
$S$ is the solar constant, $\alpha$ the fraction of sunlight reflected
(the albedo), and $\varepsilon\sigma T^4$ the infrared emission, with an
effective emissivity $\varepsilon$ that stands in for the greenhouse
effect. Ice reflects more sunlight than ocean and land, so the albedo
rises as the planet cools. That one feedback, studied by Budyko and by
Sellers in 1969, gives this model two stable climates at today's
sunlight: the one we live in, and a frozen one.
"""
)
return
@app.cell
def _():
import marimo as mo
import numpy as np
import matplotlib.pyplot as plt
return mo, np, plt
@app.cell
def _(np):
SIGMA = 5.670e-8 # Stefan-Boltzmann constant, W m^-2 K^-4
S_TODAY = 1361.0 # solar constant, W m^-2
EMISSIVITY = 0.61 # effective, chosen so today's climate sits near 288 K
HEAT_CAPACITY = 4.0e8 # J m^-2 K^-1, about a 100 m ocean mixed layer
YEAR = 3.156e7
def albedo(T, ice=0.7, warm=0.3):
"""Albedo falls smoothly from an ice-covered to an ice-free planet near 265 K."""
return ice + (warm - ice) * (1 + np.tanh((T - 265.0) / 10.0)) / 2
def net_heating(T, solar):
"""Absorbed minus emitted flux, W m^-2, at a solar constant `solar` * S_TODAY."""
return solar * S_TODAY / 4 * (1 - albedo(T)) - EMISSIVITY * SIGMA * T**4
def equilibria(solar):
"""Equilibrium temperatures and whether each one is stable."""
T = np.linspace(150, 360, 4200)
f = net_heating(T, solar)
crossing = np.flatnonzero(np.sign(f[:-1]) != np.sign(f[1:]))
roots = T[crossing] - f[crossing] * (T[1] - T[0]) / (f[crossing + 1] - f[crossing])
stable = f[crossing + 1] < f[crossing]
return roots, stable
return HEAT_CAPACITY, S_TODAY, YEAR, albedo, equilibria, net_heating
@app.cell
def _(mo):
solar = mo.ui.slider(0.70, 1.60, step=0.01, value=1.00, label="Sunlight (fraction of today's)")
solar
return (solar,)
@app.cell
def _(S_TODAY, albedo, equilibria, mo, np, plt, solar):
T = np.linspace(180, 340, 400)
roots, stable = equilibria(solar.value)
fig, ax = plt.subplots(figsize=(7, 3.8))
ax.plot(T, solar.value * S_TODAY / 4 * (1 - albedo(T)), color="#9a6700", lw=2, label="absorbed sunlight")
ax.plot(T, 0.61 * 5.670e-8 * T**4, color="#0969da", lw=2, label="emitted infrared")
for _root, _ok in zip(roots, stable):
ax.plot(
_root,
0.61 * 5.670e-8 * _root**4,
"o",
ms=8,
color="#1f2328" if _ok else "white",
markeredgecolor="#1f2328",
)
ax.set(xlabel="global mean temperature (K)", ylabel="flux (W m⁻²)")
ax.legend(frameon=False, loc="upper left")
fig.tight_layout()
described = ", ".join(
f"{_root:.0f} K ({_root - 273.15:+.0f} °C, {'stable' if _ok else 'unstable'})"
for _root, _ok in zip(roots, stable)
)
mo.vstack([fig, mo.md(f"Equilibria at {solar.value:.2f} × today's sunlight: {described}.")])
return
@app.cell(hide_code=True)
def _(mo):
mo.md(
r"""
Where the curves cross, the planet neither warms nor cools. A filled
point is stable: nudge the temperature and it returns. The open point is
unstable and separates the two basins. Lower the sunlight until the warm
crossing disappears; the planet then has nowhere to go but the frozen
state.
## The hysteresis loop
Follow the equilibria as the Sun slowly dims and then brightens again.
A warm planet stays warm until the warm branch ends, then drops to a
snowball. Once frozen it stays frozen until the cold branch ends, at a
much brighter Sun. This is the Snowball Earth puzzle: how the planet
froze over during the Neoproterozoic, and how it ever thawed. The
accepted answer is volcanic carbon dioxide accumulating for millions of
years under the ice, which this model represents only through
$\varepsilon$.
"""
)
return
@app.cell
def _(equilibria, np, plt, solar):
fractions = np.linspace(0.7, 1.6, 361)
points = [(s, r, ok) for s in fractions for r, ok in zip(*equilibria(s))]
s_all, r_all, ok_all = map(np.array, zip(*points))
warm_end = min(s for s, r, ok in points if ok and r > 260)
cold_end = max(s for s, r, ok in points if ok and r < 260)
fig2, ax2 = plt.subplots(figsize=(7, 3.8))
ax2.scatter(s_all[ok_all], r_all[ok_all], s=3, color="#1f2328", label="stable")
ax2.scatter(s_all[~ok_all], r_all[~ok_all], s=3, color="#8c959f", label="unstable")
ax2.annotate("", xy=(warm_end, 225), xytext=(warm_end, 270), arrowprops={"arrowstyle": "->", "color": "#0969da"})
ax2.annotate("", xy=(cold_end, 300), xytext=(cold_end, 255), arrowprops={"arrowstyle": "->", "color": "#cf222e"})
ax2.axvline(solar.value, color="#9a6700", lw=0.8)
ax2.set(xlabel="sunlight (fraction of today's)", ylabel="equilibrium temperature (K)")
ax2.legend(frameon=False, loc="upper left")
fig2.tight_layout()
fig2
return cold_end, warm_end
@app.cell
def _(cold_end, mo, warm_end):
mo.md(
f"""
In this model the warm climate ends at **{warm_end:.2f}** times today's
sunlight and the frozen one only at **{cold_end:.2f}** times. Between
the two, which climate the planet has depends on where it came from.
The Sun was about 70 percent as bright four billion years ago, when
geology shows liquid water: the faint young Sun paradox. A stronger
greenhouse effect then is the usual resolution. Lower the emissivity
in your fork to try it.
"""
)
return
@app.cell
def _(mo):
start_T = mo.ui.slider(200, 320, step=1, value=262, label="Starting temperature (K)")
start_T
return (start_T,)
@app.cell
def _(HEAT_CAPACITY, YEAR, net_heating, np, plt, solar, start_T):
dt = 0.05 * YEAR
path = [float(start_T.value)]
for _ in range(1200):
path.append(path[-1] + dt * net_heating(path[-1], solar.value) / HEAT_CAPACITY)
years = np.arange(len(path)) * dt / YEAR
fig3, ax3 = plt.subplots(figsize=(7, 3))
ax3.plot(years, path, color="#0969da", lw=2)
ax3.set(xlabel="years", ylabel="temperature (K)")
ax3.set_title(f"From {start_T.value} K at {solar.value:.2f} × today's sunlight: {path[-1]:.0f} K after {years[-1]:.0f} years")
fig3.tight_layout()
fig3
return
@app.cell(hide_code=True)
def _(mo):
mo.md(
r"""
---
**Model and limits.** Zero-dimensional energy balance: one temperature
for the whole planet, no latitude, no ocean circulation, no carbon
cycle. Albedo 0.7 for an ice-covered and 0.3 for an ice-free planet,
joined smoothly around 265 K; effective emissivity 0.61; heat capacity
of a 100 m ocean layer, which sets only the time scale. The thresholds
depend on these choices, the existence of two branches does not.
**References.** M. I. Budyko, *Tellus* 21 (1969) 611;
W. D. Sellers, *J. Appl. Meteor.* 8 (1969) 392;
P. F. Hoffman et al., *Science* 281 (1998) 1342.
Fork it to add latitude bands, as Budyko and Sellers did.
"""
)
return
if __name__ == "__main__":
app.run()