Walter-Lieth Climate Diagram — Altair

A Walter-Lieth climate diagram (climograph) is the canonical visualization of a location's annual climate, plotting monthly mean temperature and monthly precipitation over the twelve months on a single panel. It uses the classic 1:2 axis-scaling convention where 10 °C on the temperature axis aligns with 20 mm on the precipitation axis, so the relationship between the two curves directly reveals water availability: where precipitation exceeds the temperature curve the period is humid (filled blue/hatched), and where the temperature curve rises above precipitation the period is arid (filled red/dotted). A header carries station metadata and annual means, while frost indicators along the baseline mark cold months.

Walter-Lieth Climate Diagram rendered with Altair

Python source (Altair)

""" anyplot.ai
climograph-walter-lieth: Walter-Lieth Climate Diagram
Library: altair 6.2.1 | Python 3.13.13
Quality: 86/100 | Created: 2026-06-15
"""

import importlib
import os
import sys


# Drop script directory from sys.path so `altair` resolves to the package, not this file
sys.path[:] = [p for p in sys.path if os.path.abspath(p or ".") != os.path.dirname(os.path.abspath(__file__))]
alt = importlib.import_module("altair")
np = importlib.import_module("numpy")
pd = importlib.import_module("pandas")
Image = importlib.import_module("PIL.Image")

# Theme tokens — Imprint palette chrome
THEME = os.getenv("ANYPLOT_THEME", "light")

# Theme-conditional fill opacities — dark background needs higher opacity for visibility
ARID_OPACITY = 0.45 if THEME == "dark" else 0.22
HUMID_OPACITY = 0.40 if THEME == "dark" else 0.22
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Semantic colour assignments (Walter-Lieth convention)
# Imprint position 5 (matte red) — temperature/heat
TEMP_COLOR = "#AE3030"
# Imprint position 3 (blue) — precipitation/water
PRECIP_COLOR = "#4467A3"
# Imprint position 6 (cyan) — frost/ice
FROST_COLOR = "#2ABCCD"

# Station: Ankara, Turkey — climate normals 1991–2020
STATION = "Ankara, Turkey"
ELEVATION = 891
PERIOD = "1991–2020"
MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]

temp_vals = np.array([-0.2, 1.3, 5.8, 11.3, 16.0, 20.0, 23.4, 23.4, 18.3, 12.5, 6.3, 2.0])
precip_vals = np.array([39, 32, 37, 43, 51, 37, 15, 10, 19, 28, 37, 43])

annual_temp = round(float(np.mean(temp_vals)), 1)
annual_precip = int(np.sum(precip_vals))

# Walter-Lieth: 10 °C ↔ 20 mm — divide precip by 2 for temp-scale comparison
precip_t = precip_vals / 2.0
PERHUMID_THRESH = 50.0  # °C-equiv of 100 mm

# Y-axis domains (1:2 ratio enforced between temp and precip axes)
TEMP_MIN, TEMP_MAX = -10, 40
PRECIP_MIN, PRECIP_MAX = -20, 80  # exactly 2× temp domain

# Build DataFrame
df = pd.DataFrame({"month": MONTHS, "temp": temp_vals, "precip": precip_vals.astype(float), "precip_t": precip_t})

# Fill zones in temperature-equivalent units
arid_mask = df["temp"] > df["precip_t"]
humid_mask = df["precip_t"] > df["temp"]
perhumid_mask = df["precip_t"] > PERHUMID_THRESH

df["arid_top"] = np.where(arid_mask, df["temp"], np.nan)
df["arid_bot"] = np.where(arid_mask, df["precip_t"], np.nan)
df["humid_top"] = np.where(humid_mask, np.minimum(df["precip_t"], PERHUMID_THRESH), np.nan)
df["humid_bot"] = np.where(humid_mask, df["temp"], np.nan)
df["phum_top"] = np.where(perhumid_mask, df["precip_t"], np.nan)
df["phum_bot"] = np.where(perhumid_mask, PERHUMID_THRESH, np.nan)
# Frost indicator with minimum visual height (2 °C) so near-zero months remain legible
FROST_MIN_H = 2.0
df["frost_top"] = np.where(df["temp"] < 0, 0.0, np.nan)
df["frost_bot"] = np.where(df["temp"] < 0, np.minimum(df["temp"], -FROST_MIN_H), np.nan)

# Scales
temp_scale = alt.Scale(domain=[TEMP_MIN, TEMP_MAX])
precip_scale = alt.Scale(domain=[PRECIP_MIN, PRECIP_MAX])

# Shared x encoding
x_enc = alt.X(
    "month:O",
    sort=MONTHS,
    axis=alt.Axis(title=None, labelFontSize=10, labelColor=INK_SOFT, domainColor=INK_SOFT, tickColor=INK_SOFT),
)

# Left Y-axis — defined by the temperature-line layer
left_axis = alt.Axis(
    title="Temperature (°C)",
    titleColor=TEMP_COLOR,
    orient="left",
    labelColor=INK_SOFT,
    domainColor=INK_SOFT,
    tickColor=INK_SOFT,
    gridColor=INK,
    gridOpacity=0.1,
    values=[-10, 0, 10, 20, 30, 40],
    labelFontSize=10,
    titleFontSize=11,
)

# Right Y-axis — defined by the invisible anchor layer
right_axis = alt.Axis(
    title="Precipitation (mm)",
    titleColor=PRECIP_COLOR,
    orient="right",
    labelColor=INK_SOFT,
    domainColor=INK_SOFT,
    tickColor=INK_SOFT,
    gridOpacity=0,
    values=[0, 20, 40, 60, 80],
    labelFontSize=10,
    titleFontSize=11,
)

base = alt.Chart(df)

# Frost indicator — cyan bars below 0 °C for frost months
frost_fill = base.mark_bar(color=FROST_COLOR, opacity=0.45, width={"band": 0.9}).encode(
    x=x_enc, y=alt.Y("frost_bot:Q", scale=temp_scale, axis=None), y2=alt.Y2("frost_top:Q")
)

# Arid fill — red area between temperature (top) and precip_t (bottom)
arid_fill = base.mark_area(color=TEMP_COLOR, opacity=ARID_OPACITY).encode(
    x=x_enc, y=alt.Y("arid_bot:Q", scale=temp_scale, axis=None), y2=alt.Y2("arid_top:Q")
)

# Humid fill — blue area between precip_t (top) and temperature (bottom), ≤ 100 mm
humid_fill = base.mark_area(color=PRECIP_COLOR, opacity=HUMID_OPACITY).encode(
    x=x_enc, y=alt.Y("humid_bot:Q", scale=temp_scale, axis=None), y2=alt.Y2("humid_top:Q")
)

# Perhumid fill — solid blue area above 100 mm threshold (compressed scale zone)
phum_fill = base.mark_area(color=PRECIP_COLOR, opacity=0.65).encode(
    x=x_enc, y=alt.Y("phum_bot:Q", scale=temp_scale, axis=None), y2=alt.Y2("phum_top:Q")
)

# 0 °C reference line
zero_rule = (
    alt.Chart(pd.DataFrame({"y": [0.0]}))
    .mark_rule(color=INK_SOFT, strokeWidth=0.9, strokeDash=[4, 4])
    .encode(y=alt.Y("y:Q", scale=temp_scale, axis=None))
)

# Precipitation curve drawn on temperature-equivalent scale
precip_line = base.mark_line(color=PRECIP_COLOR, strokeWidth=2.8, point=alt.OverlayMarkDef(size=40)).encode(
    x=x_enc,
    y=alt.Y("precip_t:Q", scale=temp_scale, axis=None),
    tooltip=[alt.Tooltip("month:O", title="Month"), alt.Tooltip("precip:Q", title="Precipitation (mm)", format=".0f")],
)

# Temperature curve — also anchors the left Y-axis definition
temp_line = base.mark_line(color=TEMP_COLOR, strokeWidth=2.8, point=alt.OverlayMarkDef(size=40)).encode(
    x=x_enc,
    y=alt.Y("temp:Q", scale=temp_scale, axis=left_axis),
    tooltip=[alt.Tooltip("month:O", title="Month"), alt.Tooltip("temp:Q", title="Temperature (°C)", format=".1f")],
)

# Invisible layer that anchors the right precipitation axis
right_layer = base.mark_point(opacity=0, size=0).encode(
    x=x_enc, y=alt.Y("precip:Q", scale=precip_scale, axis=right_axis)
)

# Title
title_str = "climograph-walter-lieth · python · altair · anyplot.ai"
n_chars = len(title_str)
title_fs = round(16 * min(1.0, 67 / n_chars))
subtitle_str = f"{STATION}  ·  {ELEVATION} m a.s.l.  ·  {PERIOD}  ·  T̄ = {annual_temp} °C  ·  P = {annual_precip} mm"

# Flat layer: all temp-scale layers use axis=None (except temp_line which defines
# the left Y-axis); right_layer carries the independent right Y-axis.
# resolve_scale(y='independent') gives each layer its own Y scale — layers with
# axis=None show nothing; only temp_line (left) and right_layer (right) show axes.
chart = (
    alt.layer(frost_fill, arid_fill, humid_fill, phum_fill, zero_rule, precip_line, temp_line, right_layer)
    .resolve_scale(y="independent")
    .properties(
        width=620,
        height=300,
        background=PAGE_BG,
        title=alt.Title(
            text=title_str,
            subtitle=subtitle_str,
            fontSize=title_fs,
            subtitleFontSize=10,
            color=INK,
            subtitleColor=INK_MUTED,
            anchor="start",
        ),
    )
    .configure_view(fill=PAGE_BG, stroke=INK_SOFT, strokeWidth=0.5)
    .configure_axis(labelColor=INK_SOFT, titleColor=INK, domainColor=INK_SOFT, tickColor=INK_SOFT)
    .configure_legend(fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK)
)

# Save — pad to exact 3200 × 1800 target
TW, TH = 3200, 1800
chart.save(f"plot-{THEME}.png", scale_factor=4.0)

_img = Image.open(f"plot-{THEME}.png").convert("RGB")
_w, _h = _img.size
if _w > TW or _h > TH:
    raise SystemExit(
        f"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. "
        f"Shrink chart .properties(width=, height=) values and re-render."
    )
if _w < TW or _h < TH:
    _canvas = Image.new("RGB", (TW, TH), PAGE_BG)
    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))
    _canvas.save(f"plot-{THEME}.png")

chart.save(f"plot-{THEME}.html")

Part of Walter-Lieth Climate Diagram on anyplot.ai.

Other implementations