Real-World Climate Analysis Case Study — dask_setup Recipe

This notebook walks through a complete scientific data processing workflow:

  1. Generate a synthetic multi-year climate dataset

  2. Quality-control the data (flag fill values, range checks)

  3. Compute temporal aggregations — annual mean, seasonal climatology

  4. Fit a linear temperature trend

  5. Save processed outputs to Zarr

  6. Generate summary plots (if matplotlib is available)

All steps are designed to work on a laptop using profile="development", and scale to a full HPC node by switching to profile="climate_analysis".

Setup

import tempfile
import time
from pathlib import Path

import numpy as np
import pandas as pd
import xarray as xr

from dask_setup import setup_dask_client

try:
    import matplotlib.pyplot as plt
    PLOT = True
except ImportError:
    PLOT = False
    print("matplotlib not found — plots will be skipped")

try:
    import zarr
    ZARR = True
except ImportError:
    ZARR = False
    print("zarr not found — Zarr output will be skipped")

WORK_DIR = Path(tempfile.mkdtemp(prefix="climate_case_"))
print(f"Work dir: {WORK_DIR}")
print("Imports OK")
Work dir: /tmp/climate_case_k8aftkqg
Imports OK

1. Start Cluster

Use profile="development" for laptops. Switch to profile="climate_analysis" on a Gadi node.

client, cluster, tmp = setup_dask_client(
    profile="development",
    fallback_on_detection_failure=True,
)
print(f"Workers    : {len(client.nthreads())}")
print(f"Temp dir   : {tmp}")
if hasattr(cluster, 'dashboard_link'):
    print(f"Dashboard  : {cluster.dashboard_link}")
[setup_dask_client] Configuration summary
temp/spill dir: /tmp/dask-3153
Workers: 2 | threads/worker: 2 | processes: True
Memory: total ~15.6 GiB | usable ~7.6 GiB | per-worker ~3.8 GiB
Compression: spill=auto | comm=False
Workers    : 2
Temp dir   : /tmp/dask-3153
Dashboard  : http://127.0.0.1:38797/status

2. Generate Synthetic Climate Dataset

We create 10 years of daily data on a coarse global grid — temperature with a warming trend and a seasonal cycle, plus precipitation with log-normal noise.

YEARS = 10
N_TIME = 365 * YEARS
LAT = np.linspace(-90, 90, 36)
LON = np.linspace(-180, 180, 72)

np.random.seed(42)
t = np.arange(N_TIME)

# Temperature: base + seasonal + warming trend + noise
seasonal  = 8 * np.sin(2 * np.pi * t / 365.25)
trend     = 0.02 * t / 365.25          # 0.02 K/year warming
noise     = np.random.randn(N_TIME, len(LAT), len(LON)) * 1.5
T_base    = 288.15 + seasonal[:, None, None] + trend[:, None, None] + noise

# Add some fill values for QC demo
T_base[100:102, 5:8, 10:15] = np.nan

# Precipitation
P_base = np.abs(np.random.gamma(2, 2, (N_TIME, len(LAT), len(LON))))

time_index = pd.date_range("2010-01-01", periods=N_TIME, freq="D")

ds_raw = xr.Dataset(
    {
        "temperature":   (["time", "lat", "lon"], T_base.astype("float32"),
                          {"units": "K", "long_name": "Near-surface air temperature"}),
        "precipitation": (["time", "lat", "lon"], P_base.astype("float32"),
                          {"units": "mm/day", "long_name": "Daily precipitation"}),
    },
    coords={
        "time": time_index,
        "lat":  ("lat", LAT, {"units": "degrees_north"}),
        "lon":  ("lon", LON, {"units": "degrees_east"}),
    },
)
ds_raw.attrs.update({"title": "Synthetic climate case study", "conventions": "CF-1.8"})

print(f"Dataset: {dict(ds_raw.sizes)}")
print(f"Size   : {ds_raw.nbytes / 1e6:.1f} MB")
Dataset: {'time': 3650, 'lat': 36, 'lon': 72}
Size   : 75.7 MB

3. Quality Control

Apply basic QC:

  • Flag and fill physically impossible values (T < 150 K or T > 340 K)

  • Report the fraction of fill values

ds = ds_raw.chunk({"time": 365, "lat": len(LAT), "lon": len(LON)})

# Count NaN before QC
nan_fraction = float(ds.temperature.isnull().mean().compute())
print(f"NaN fraction before QC : {nan_fraction:.4%}")

# Physical range check
T = ds.temperature
invalid = (T < 150) | (T > 340)
n_invalid = int(invalid.sum().compute())
print(f"Out-of-range values    : {n_invalid}")

# Mask out-of-range values and fill NaN with temporal climatology
ds_qc = ds.copy()
ds_qc["temperature"] = T.where(~invalid)

# Fill remaining NaN with global mean
global_mean_T = float(ds_qc.temperature.mean().compute())
ds_qc["temperature"] = ds_qc.temperature.fillna(global_mean_T)

nan_after = float(ds_qc.temperature.isnull().mean().compute())
print(f"NaN fraction after QC  : {nan_after:.4%}")
print(f"Global mean temperature: {global_mean_T:.2f} K")
/opt/hostedtoolcache/Python/3.13.15/x64/lib/python3.13/site-packages/distributed/client.py:3429: UserWarning: Sending large graph of size 36.09 MiB.
This may cause some slowdown.
Consider loading the data with Dask directly
 or using futures or delayed objects to embed the data into the graph without repetition.
See also https://docs.dask.org/en/stable/best-practices.html#load-data-with-dask for more information.
  warnings.warn(
NaN fraction before QC : 0.0003%
Out-of-range values    : 0
/opt/hostedtoolcache/Python/3.13.15/x64/lib/python3.13/site-packages/distributed/client.py:3429: UserWarning: Sending large graph of size 36.09 MiB.
This may cause some slowdown.
Consider loading the data with Dask directly
 or using futures or delayed objects to embed the data into the graph without repetition.
See also https://docs.dask.org/en/stable/best-practices.html#load-data-with-dask for more information.
  warnings.warn(
/opt/hostedtoolcache/Python/3.13.15/x64/lib/python3.13/site-packages/distributed/client.py:3429: UserWarning: Sending large graph of size 36.09 MiB.
This may cause some slowdown.
Consider loading the data with Dask directly
 or using futures or delayed objects to embed the data into the graph without repetition.
See also https://docs.dask.org/en/stable/best-practices.html#load-data-with-dask for more information.
  warnings.warn(
NaN fraction after QC  : 0.0000%
Global mean temperature: 288.25 K
/opt/hostedtoolcache/Python/3.13.15/x64/lib/python3.13/site-packages/distributed/client.py:3429: UserWarning: Sending large graph of size 36.10 MiB.
This may cause some slowdown.
Consider loading the data with Dask directly
 or using futures or delayed objects to embed the data into the graph without repetition.
See also https://docs.dask.org/en/stable/best-practices.html#load-data-with-dask for more information.
  warnings.warn(

4. Temporal Aggregations

Compute annual means and a seasonal (DJF/MAM/JJA/SON) climatology.

# Annual mean
t0 = time.perf_counter()
annual_mean = ds_qc.temperature.resample(time="1YE").mean().compute()
print(f"Annual mean — shape: {annual_mean.shape}  ({time.perf_counter() - t0:.2f}s)")

# Seasonal climatology (groupby season)
t0 = time.perf_counter()
seasonal_clim = ds_qc.temperature.groupby("time.season").mean().compute()
print(f"Seasonal climatology — shape: {seasonal_clim.shape}  ({time.perf_counter() - t0:.2f}s)")
print(f"Seasons: {seasonal_clim.season.values.tolist()}")
/opt/hostedtoolcache/Python/3.13.15/x64/lib/python3.13/site-packages/distributed/client.py:3429: UserWarning: Sending large graph of size 36.10 MiB.
This may cause some slowdown.
Consider loading the data with Dask directly
 or using futures or delayed objects to embed the data into the graph without repetition.
See also https://docs.dask.org/en/stable/best-practices.html#load-data-with-dask for more information.
  warnings.warn(
Annual mean — shape: (10, 36, 72)  (0.14s)
/opt/hostedtoolcache/Python/3.13.15/x64/lib/python3.13/site-packages/distributed/client.py:3429: UserWarning: Sending large graph of size 36.12 MiB.
This may cause some slowdown.
Consider loading the data with Dask directly
 or using futures or delayed objects to embed the data into the graph without repetition.
See also https://docs.dask.org/en/stable/best-practices.html#load-data-with-dask for more information.
  warnings.warn(
Seasonal climatology — shape: (4, 36, 72)  (0.24s)
Seasons: ['DJF', 'JJA', 'MAM', 'SON']

5. Temperature Trend Analysis

Fit a linear trend to the annual global-mean temperature series.

# Global mean temperature time series
global_ts = float(ds_qc.temperature.mean(dim=["lat", "lon"]).mean(dim="time").compute())

# Annual global mean
annual_global = (
    ds_qc.temperature
    .mean(dim=["lat", "lon"])
    .resample(time="1YE")
    .mean()
    .compute()
)

# Linear fit
years = np.arange(len(annual_global))
T_vals = annual_global.values
slope, intercept = np.polyfit(years, T_vals, 1)

print(f"Annual global mean temperature:")
print(f"  First year  : {T_vals[0]:.3f} K")
print(f"  Last year   : {T_vals[-1]:.3f} K")
print(f"  Trend       : {slope:.4f} K/year  ({slope * 10:.3f} K/decade)")
print(f"  Expected    : 0.02 K/year (injected warming rate)")
/opt/hostedtoolcache/Python/3.13.15/x64/lib/python3.13/site-packages/distributed/client.py:3429: UserWarning: Sending large graph of size 36.10 MiB.
This may cause some slowdown.
Consider loading the data with Dask directly
 or using futures or delayed objects to embed the data into the graph without repetition.
See also https://docs.dask.org/en/stable/best-practices.html#load-data-with-dask for more information.
  warnings.warn(
/opt/hostedtoolcache/Python/3.13.15/x64/lib/python3.13/site-packages/distributed/client.py:3429: UserWarning: Sending large graph of size 36.10 MiB.
This may cause some slowdown.
Consider loading the data with Dask directly
 or using futures or delayed objects to embed the data into the graph without repetition.
See also https://docs.dask.org/en/stable/best-practices.html#load-data-with-dask for more information.
  warnings.warn(
Annual global mean temperature:
  First year  : 288.158 K
  Last year   : 288.341 K
  Trend       : 0.0202 K/year  (0.202 K/decade)
  Expected    : 0.02 K/year (injected warming rate)

6. Visualisation

if PLOT:
    fig, axes = plt.subplots(1, 2, figsize=(12, 4))

    # Annual global mean temperature trend
    ax = axes[0]
    ax.plot(years + 2010, T_vals, "o-", color="steelblue", label="Annual mean")
    ax.plot(years + 2010, intercept + slope * years, "--", color="tomato",
            label=f"Trend {slope:.3f} K/yr")
    ax.set_xlabel("Year")
    ax.set_ylabel("Temperature (K)")
    ax.set_title("Global Mean Temperature")
    ax.legend()
    ax.grid(alpha=0.3)

    # Seasonal climatology map (JJA)
    ax = axes[1]
    jja = seasonal_clim.sel(season="JJA")
    im = ax.pcolormesh(LON, LAT, jja.values, cmap="RdYlBu_r", shading="auto")
    plt.colorbar(im, ax=ax, label="K")
    ax.set_xlabel("Longitude")
    ax.set_ylabel("Latitude")
    ax.set_title("JJA Climatology")

    plt.tight_layout()
    fig_path = WORK_DIR / "climate_summary.png"
    plt.savefig(fig_path, dpi=120, bbox_inches="tight")
    print(f"Figure saved to {fig_path}")
    plt.show()
else:
    print("matplotlib not available — skipping plots")
Figure saved to /tmp/climate_case_k8aftkqg/climate_summary.png
../../../_images/062fb5bf6bf16f179e2de2c1420ace5fb7f642c9d1433147e7e23482af65f3be.png

7. Save to Zarr

Write the QC’d dataset and derived products to Zarr for fast downstream analysis.

if ZARR:
    # Save QC'd daily data
    qc_store = WORK_DIR / "climate_qc.zarr"
    t0 = time.perf_counter()
    ds_qc.to_zarr(qc_store, mode="w", consolidated=True)
    elapsed = time.perf_counter() - t0

    store_mb = sum(f.stat().st_size for f in qc_store.rglob("*") if f.is_file()) / 1e6
    print(f"QC dataset saved: {qc_store} ({store_mb:.1f} MB, {elapsed:.2f}s)")

    # Verify roundtrip
    ds_check = xr.open_zarr(qc_store, consolidated=True)
    t_check = float(ds_check.temperature.mean().compute())
    print(f"Roundtrip verify — global mean: {t_check:.4f} K")
    ds_check.close()
else:
    print("zarr not available — skipping output")
/opt/hostedtoolcache/Python/3.13.15/x64/lib/python3.13/site-packages/zarr/api/asynchronous.py:246: ZarrUserWarning: Consolidated metadata is currently not part in the Zarr format 3 specification. It may not be supported by other zarr implementations and may change in the future.
  warnings.warn(
/opt/hostedtoolcache/Python/3.13.15/x64/lib/python3.13/site-packages/distributed/client.py:3429: UserWarning: Sending large graph of size 72.19 MiB.
This may cause some slowdown.
Consider loading the data with Dask directly
 or using futures or delayed objects to embed the data into the graph without repetition.
See also https://docs.dask.org/en/stable/best-practices.html#load-data-with-dask for more information.
  warnings.warn(
QC dataset saved: /tmp/climate_case_k8aftkqg/climate_qc.zarr (61.4 MB, 0.42s)
Roundtrip verify — global mean: 288.2499 K
client.close()
cluster.close()

import shutil
shutil.rmtree(WORK_DIR, ignore_errors=True)
print("Cluster closed, temp files removed.")
Cluster closed, temp files removed.

Scaling to Production

To run this workflow at full scale on NCI Gadi:

# In your PBS job script. mode="local" keeps it on the job's node:
# inside a batch job the default mode="auto" picks the multi-node backend.
client, cluster, tmp = setup_dask_client(profile="climate_analysis", mode="local")
# → CPU workload, 60 GB reserve, all the cores PBS gave the job

For very large datasets spanning many nodes, use the v2.0 multi-node API:

from dask_setup import MultiNodeConfig
client, cluster, _ = setup_dask_client(
    mode="pbs",
    multi_node_config=MultiNodeConfig(
        workers_per_node=4, cores_per_worker=12,
        mem_per_worker_gb=32.0, walltime="08:00:00",
    ),
)

See the Multi-Node wiki page for details.