View
Show Apply to Keep Panels Show as Pages Figures Text Size
section 1

Southern Hemisphere blocking & ENSO diagnostics

markdown

Southern Hemisphere blocking & ENSO diagnostics

A worked example of an exploratory climate notebook, written to show what Junoview does. The science is driven by synthetic data so it runs anywhere, but the structure — dataset → transform → diagnostic → figure → interpretation — mirrors a real reanalysis workflow.

section 1.1

Dataset

markdown

Dataset

The synthetic field is composed from the same ingredients a reanalysis
would contain, added explicitly:

$$z(x,t) = \bar{z}_{d(t)}(x) + E(t)\,P_E(x) + b(t)\,G(x) + \varepsilon(x,t)$$

  • $\bar{z}_d$ — day-of-year climatology: zonal gradient, wave-3 ridge, seasonal cycle
  • $E(t)\,P_E(x)$ — an ENSO-like slow mode with a fixed spatial pattern
  • $b(t)\,G(x)$ — episodic Tasman blocking events, a Gaussian blob switched on ~9% of days
  • $\varepsilon$ — weather noise with $\sigma = 22$ m

Because the truth is imposed, every diagnostic below has a known answer to
recover.

code

Imports & plotting style

%matplotlib inline
import numpy as np
import pandas as pd
import xarray as xr
import matplotlib.pyplot as plt

rng = np.random.default_rng(20260626)
plt.rcParams.update({
    'figure.dpi': 108, 'savefig.dpi': 108, 'figure.facecolor': 'white',
    'axes.facecolor': 'white', 'font.size': 10,
    'axes.spines.top': False, 'axes.spines.right': False,
    'axes.titlesize': 11, 'axes.titleweight': 'semibold',
    'axes.labelcolor': '#33414f', 'text.color': '#16202b',
    'xtick.color': '#69788a', 'ytick.color': '#69788a',
    'axes.edgecolor': '#c9d3dc',
})
INK, CYAN, AMBER = '#16202b', '#1f7e93', '#cf9a4e'
transform

Grid & time axis

grid
lat = np.arange(-80, -19, 2.5)
lon = np.arange(0, 360, 2.5)
time = pd.date_range('2008-01-01', '2022-12-31', freq='D')
doy = time.dayofyear.values
LON, LAT = np.meshgrid(np.deg2rad(lon), lat)
transform

Synthetic climatology & variability modes

modes

The imposed truth: a wave-3 climatology with a seasonal cycle, an ENSO-like slow mode, and episodic Tasman blocking.

usesgrid
# stationary climatology: zonal gradient + wave-3, with a seasonal cycle
base = 5500 + 38 * (lat[:, None] + 50)
wave3 = 70 * np.cos(3 * LON + np.deg2rad(40)) * np.cos(np.deg2rad(LAT + 50))
clim_doy = np.empty((366, lat.size, lon.size), np.float32)
for d in range(366):
    seasonal = 55 * np.cos(2 * np.pi * (d - 15) / 365.25)
    clim_doy[d] = base + wave3 + seasonal * (lat[:, None] + 50) / 30

# ENSO-like index: two slow oscillations + red noise, standardised
t_yr = (time - time[0]).days.values / 365.25
enso = (np.sin(2*np.pi*t_yr/3.6) + 0.7*np.sin(2*np.pi*t_yr/5.2 + 1.1))
enso += np.cumsum(rng.standard_normal(time.size)) * 0.015
enso = pd.Series(enso).rolling(45, center=True, min_periods=1).mean().values
enso = (enso - enso.mean()) / enso.std()
enso_pattern = (np.exp(-((lat[:, None] + 45)**2)/450)
                * np.cos(2*np.deg2rad(lon)[None, :] - 0.6)) * 42

# episodic blocking south of Australia (~50S, 150E)
block_days = rng.random(time.size) < 0.09
blob = np.exp(-(((lat[:, None]+50)/9)**2) - (((lon[None, :]-150)/16)**2)) * 130
dataset

Daily Z500 field, Southern Hemisphere (synthetic)

z500
<xarray.Dataset> Size: 79MB
Dimensions:  (time: 5479, lat: 25, lon: 144)
Coordinates:
  * time     (time) datetime64[us] 44kB 2008-01-01 2008-01-02 ... 2022-12-31
  * lat      (lat) float64 200B -80.0 -77.5 -75.0 -72.5 ... -25.0 -22.5 -20.0
  * lon      (lon) float64 1kB 0.0 2.5 5.0 7.5 10.0 ... 350.0 352.5 355.0 357.5
Data variables:
    z500     (time, lat, lon) float32 79MB 4.322e+03 4.344e+03 ... 6.8e+03
Attributes:
    title:    Synthetic SH Z500
    units:    m
    source:   demo

Fifteen years of daily 500 hPa geopotential height on a 2.5 deg grid from 80 deg S to 20 deg S. Climatology plus modes plus weather noise, assembled into one dataset.

anom = (enso[:, None, None] * enso_pattern[None]
        + block_days[:, None, None] * blob[None]
        + rng.standard_normal((time.size, lat.size, lon.size)) * 22
       ).astype(np.float32)
z = clim_doy[doy - 1] + anom

ds = xr.Dataset(
    {'z500': (('time', 'lat', 'lon'), z)},
    coords={'time': time, 'lat': lat, 'lon': lon},
    attrs={'title': 'Synthetic SH Z500', 'units': 'm', 'source': 'demo'},
)
ds.z500.attrs.update(long_name='500 hPa geopotential height', units='m')
ds
print

Temporal & spatial coverage

coverage
period      : 2008-01-01 -> 2022-12-31 (5,479 days)
grid        : 25 lat x 144 lon  (2.5 deg)
domain      : -80 to -20 deg lat, full longitude
memory (z)  : 79 MB
usesz500
print(f"period      : {time[0]:%Y-%m-%d} -> {time[-1]:%Y-%m-%d} "
      f"({time.size:,} days)")
print(f"grid        : {lat.size} lat x {lon.size} lon  (2.5 deg)")
print(f"domain      : {lat.min():.0f} to {lat.max():.0f} deg lat, full longitude")
print(f"memory (z)  : {ds.z500.nbytes/1e6:.0f} MB")
section 1.2

Transforms

markdown

Transforms

Anomalies are defined against a day-of-year climatology,

$$z'(x,t) = z(x,t) - \bar{z}_{d(t)}(x), \qquad \bar{z}_d(x) = \frac{1}{N_y} \sum_{y=1}^{N_y} z(x, t_{y,d})$$

so the seasonal cycle drops out and what remains is the circulation
variability the diagnostics are after. The groupby('time.dayofyear')
idiom computes $\bar{z}_d$ and subtracts it in one pass.

transform

Day-of-year climatology

clim
usesz500
clim = ds.z500.groupby('time.dayofyear').mean('time')
dataset

Seasonal-cycle removal -> daily anomalies

anom
<xarray.DataArray 'z500_anom' (time: 5479, lat: 25, lon: 144)> Size: 79MB
array([[[-32.958984  ,  -1.4008789 ,  24.528809  , ..., -26.481445  ,
          12.447754  , -31.006836  ],
        [  9.697754  , -26.5625    , -13.802246  , ..., -14.928711  ,
         -18.674316  ,  11.130371  ],
        [  5.0458984 , -20.719727  , -12.470215  , ..., -19.528809  ,
           3.0576172 ,  -5.8310547 ],
        ...,
        [-30.375488  ,  32.984863  ,   2.4516602 , ..., -27.28711   ,
          16.3208    , -10.076172  ],
        [ 23.362305  , -40.655273  ,  -2.947754  , ..., -15.523926  ,
          23.419434  ,  -1.7099609 ],
        [-28.580566  ,   1.434082  , -17.516602  , ...,  24.082031  ,
         -27.280762  ,  24.213379  ]],

       [[-30.13379   ,   4.725586  , -22.334473  , ...,  -1.1762695 ,
          16.500488  ,  -6.442383  ],
        [ -0.5131836 ,  -5.2597656 , -23.04248   , ...,   1.9790039 ,
          -5.2558594 , -27.24414   ],
        [ -3.7885742 ,  -0.21679688, -39.809082  , ...,  10.312012  ,
         -21.496094  ,   4.8754883 ],
...
          17.131348  ,  42.959473  ],
        [ -6.0512695 ,  96.3418    ,  33.51123   , ...,   4.2700195 ,
          28.506836  ,  28.219727  ],
        [ 11.719727  , -19.930176  , -28.118164  , ...,  -6.051758  ,
         -13.0859375 ,  -9.544922  ]],

       [[ 27.333984  , -12.894531  ,   3.1279297 , ...,   3.0966797 ,
           5.2490234 ,   9.540527  ],
        [ -3.7792969 , -17.707031  , -29.161133  , ...,  -5.3115234 ,
           1.6396484 ,  -1.9956055 ],
        [ 33.339355  , -28.6958    ,  -5.4848633 , ...,  -1.5776367 ,
          17.161621  ,  19.255371  ],
        ...,
        [ 68.359375  ,  18.163086  ,  52.73584   , ...,  20.04248   ,
          28.811035  ,  33.554688  ],
        [ 18.203613  ,  -5.229492  ,  14.475586  , ...,  17.726562  ,
          14.447266  ,  17.998535  ],
        [ 12.708496  ,  22.623047  ,  14.988281  , ...,  -9.17334   ,
          -7.897949  ,  48.46582   ]]],
      shape=(5479, 25, 144), dtype=float32)
Coordinates:
  * time       (time) datetime64[us] 44kB 2008-01-01 2008-01-02 ... 2022-12-31
    dayofyear  (time) int64 44kB 1 2 3 4 5 6 7 8 ... 359 360 361 362 363 364 365
  * lat        (lat) float64 200B -80.0 -77.5 -75.0 -72.5 ... -25.0 -22.5 -20.0
  * lon        (lon) float64 1kB 0.0 2.5 5.0 7.5 ... 350.0 352.5 355.0 357.5
Attributes:
    long_name:  Z500 anomaly
    units:      m

Day-of-year climatology subtracted with a groupby, the standard first move before any circulation diagnostic. Everything downstream reads from these anomalies.

usesclim
z_anom = (ds.z500.groupby('time.dayofyear') - clim).rename('z500_anom')
z_anom.attrs.update(long_name='Z500 anomaly', units='m')
z_anom
section 1.3

Blocking diagnostics

markdown

Blocking diagnostics

Three complementary views of the same phenomenon: the summer climatology
that sets the stage, an interannual count of blocked days, and the
composite anomaly showing what a blocked day actually looks like.

figure

DJF mean Z500 and the climatological ridge

clim_map
figure output

Austral-summer mean. The wave-3 ridge poleward of Australia is the background state that blocking events amplify.

usesz500
djf = ds.z500.sel(time=ds['time.month'].isin([12, 1, 2])).mean('time')
fig, ax = plt.subplots(figsize=(7.2, 3.5))
cf = ax.contourf(lon, lat, djf, levels=16, cmap='viridis')
ax.contour(lon, lat, djf, levels=8, colors='white', linewidths=.4, alpha=.6)
ax.set_xlabel('longitude'); ax.set_ylabel('latitude')
ax.set_title('DJF mean 500 hPa height')
fig.colorbar(cf, ax=ax, label='m', pad=.02, shrink=.92)
fig.tight_layout(); plt.show()
section 1.3.1

The blocking index

markdown

The blocking index

Blocking is measured from the box-mean anomaly over the Tasman sector,

$$B(t) = \left\langle z'(x,t) \right\rangle_{x \in \mathrm{box}}, \qquad \mathrm{box} = [58^\circ\mathrm{S},\,42^\circ\mathrm{S}] \times [135^\circ\mathrm{E},\,170^\circ\mathrm{E}]$$

A day counts as blocked when $B(t) > \sigma_B$, one standard deviation
of the box series, and the seasonal frequency is the blocked fraction of
austral-summer days:

$$f_y = \frac{ \#\{\, t \in \mathrm{DJF}_y : B(t) > \sigma_B \,\} }{ \#\,\mathrm{DJF}_y }$$

The $1\sigma$ threshold is the one free parameter — on real data the
composites below should be checked for sensitivity to it.

transform

Tasman sector box-mean series

tasman
usesanom
box = z_anom.sel(lat=slice(-58, -42), lon=slice(135, 170)).mean(['lat', 'lon'])
transform

Blocking threshold & DJF frequency

blockidx

A day is blocked when the box-mean anomaly exceeds +1 sigma; the frequency is the fraction of blocked DJF days per summer.

usestasman
blocked = box > box.std()
freq = (blocked.sel(time=blocked['time.month'].isin([12, 1, 2]))
        .groupby('time.year').mean('time'))
figure

Tasman blocking frequency by austral summer

block_freq
figure output

Fraction of DJF days on which the box-mean anomaly over the Tasman sector exceeds +1 sigma. Interannual swings track the ENSO state.

fig, ax = plt.subplots(figsize=(7.2, 3.0))
ax.bar(freq.year, freq*100, color=CYAN, width=.66)
ax.axhline((freq*100).mean(), color=AMBER, lw=1.2, ls='--',
           label='period mean')
ax.set_xlabel('year'); ax.set_ylabel('blocked DJF days (%)')
ax.set_title('Tasman blocking frequency'); ax.legend(frameon=False)
fig.tight_layout(); plt.show()
print

Blocking summary statistics

block_stat
mean DJF blocking frequency :   9.3 %
most blocked summer         : 2013 (14%)
least blocked summer        : 2018 (2%)
/usr/local/lib/python3.12/dist-packages/xarray/core/dataarray.py:6439: FutureWarning: Behaviour of argmin/argmax with neither dim nor axis argument will change to return a dict of indices of each dimension. To get a single, flat index, please use np.argmin(da.data) or np.argmax(da.data) instead of da.argmin() or da.argmax().
  result = self.variable.argmax(dim, axis, keep_attrs, skipna)
/usr/local/lib/python3.12/dist-packages/xarray/core/dataarray.py:6318: FutureWarning: Behaviour of argmin/argmax with neither dim nor axis argument will change to return a dict of indices of each dimension. To get a single, flat index, please use np.argmin(da.data) or np.argmax(da.data) instead of da.argmin() or da.argmax().
  result = self.variable.argmin(dim, axis, keep_attrs, skipna)
print(f"mean DJF blocking frequency : {float(freq.mean())*100:5.1f} %")
print(f"most blocked summer         : {int(freq.year[freq.argmax()])} "
      f"({float(freq.max())*100:.0f}%)")
print(f"least blocked summer        : {int(freq.year[freq.argmin()])} "
      f"({float(freq.min())*100:.0f}%)")
figure

Composite Z500 anomaly on blocked days

block_comp
figure output

Mean anomaly over all blocked days. The localised positive centre is the blocking high. Drawn with the shared map helper, folded in as step 1.

1shared map helper
def plot_anom_map(da, ax, title, vmax=None):
    """Filled SH anomaly map on a lon-lat axis; shared by the composites."""
    vmax = vmax or float(abs(da).quantile(.98))
    pc = ax.pcolormesh(da.lon, da.lat, da, cmap='RdBu_r',
                       vmin=-vmax, vmax=vmax, shading='auto')
    ax.set_xlabel('longitude'); ax.set_ylabel('latitude')
    ax.set_title(title)
    return pc
2plot
comp = z_anom.sel(time=blocked).mean('time')
fig, ax = plt.subplots(figsize=(7.2, 3.5))
pc = plot_anom_map(comp, ax, 'Blocked-day composite anomaly')
ax.plot(150, -50, marker='x', color='k', ms=8, mew=2)
fig.colorbar(pc, ax=ax, label='m', pad=.02, shrink=.92)
fig.tight_layout(); plt.show()
section 1.3.2

Zonal-mean evolution

markdown

Zonal-mean evolution

Averaging the anomaly around each latitude circle, $\langle z' \rangle_{\lambda}(\phi, t)$, collapses the field onto a time–latitude plane. A 30-day running mean strips the synoptic noise so the slow, ENSO-paced pulses stand out.

figure

Zonal-mean Z500 anomaly (time-latitude)

zonal_hov
figure output

A Hovmoller of the zonally averaged anomaly. The point of grouping: the zonal-mean and smoothing prep folds behind this one figure as a numbered step, instead of taking up its own card.

usesanom
1zonal mean + 30-day smoothing
zm = z_anom.mean('lon')                         # zonal-mean anomaly
zm = zm.rolling(time=30, center=True).mean()    # 30-day running mean
zm_mon = zm.resample(time='1MS').mean()         # monthly, for a clean plot
print('zonal-mean field:', dict(zm_mon.sizes))
zonal-mean field: {'time': 180, 'lat': 25}
2plot Hovmoller
vmax = float(abs(zm_mon).quantile(.98))
fig, ax = plt.subplots(figsize=(7.2, 3.4))
pc = ax.pcolormesh(zm_mon.time.values, lat, zm_mon.T.values,
                   cmap='RdBu_r', vmin=-vmax, vmax=vmax, shading='auto')
ax.set_ylabel('latitude'); ax.set_xlabel('time')
ax.set_title('Zonal-mean anomaly Hovmoller')
fig.colorbar(pc, ax=ax, label='m', pad=.02, shrink=.92)
fig.tight_layout(); plt.show()
section 1.4

ENSO diagnostics

transform

Project anomalies onto the ENSO pattern

enso_idx

Least-squares projection of the daily anomalies onto the ENSO spatial mode, standardised to sigma units, then monthly-averaged.

pat = xr.DataArray(enso_pattern, coords={'lat': lat, 'lon': lon},
                   dims=['lat', 'lon'])
idx = ((z_anom * pat).sum(['lat', 'lon'])
       / (pat**2).sum(['lat', 'lon']))
idx = (idx - idx.mean()) / idx.std()
idx_m = idx.resample(time='1MS').mean()
section 1.4.1

Index construction

markdown

Index construction

The ENSO index is the least-squares projection of each day's anomaly map
onto the fixed spatial pattern $P(x)$:

$$I(t) = \frac{ \sum_x z'(x,t)\, P(x) }{ \sum_x P(x)^2 }, \qquad \tilde{I}(t) = \frac{I(t) - \mu_I}{\sigma_I}$$

$I(t)$ is the amplitude that, multiplied by the pattern, best reconstructs
that day's anomaly field. Standardising to $\tilde{I}$ puts the phase
thresholds in sigma units; monthly averaging removes the daily jitter.

figure

ENSO index from the leading SH anomaly mode

nino34
figure output

The standardised index, monthly. Warm and cool phases are shaded at the +/- 0.5 sigma thresholds.

fig, ax = plt.subplots(figsize=(7.2, 3.0))
ax.fill_between(idx_m.time, 0, idx_m.where(idx_m > .5),
                color='#c0504d', alpha=.85, label='warm')
ax.fill_between(idx_m.time, 0, idx_m.where(idx_m < -.5),
                color='#3a7ca5', alpha=.85, label='cool')
ax.plot(idx_m.time, idx_m, color=INK, lw=.7)
ax.axhline(0, color='#69788a', lw=.6)
ax.set_ylabel('sigma'); ax.set_title('ENSO index (monthly)')
ax.legend(frameon=False, ncol=2)
fig.tight_layout(); plt.show()
figure

Warm-phase teleconnection composite

enso_comp
figure output

Mean anomaly across warm-phase months (index > +0.8 sigma). Reuses the same map helper as the blocking composite, stacked under this figure too.

1shared map helper
def plot_anom_map(da, ax, title, vmax=None):
    """Filled SH anomaly map on a lon-lat axis; shared by the composites."""
    vmax = vmax or float(abs(da).quantile(.98))
    pc = ax.pcolormesh(da.lon, da.lat, da, cmap='RdBu_r',
                       vmin=-vmax, vmax=vmax, shading='auto')
    ax.set_xlabel('longitude'); ax.set_ylabel('latitude')
    ax.set_title(title)
    return pc
2plot
warm = idx > 0.8
tele = z_anom.sel(time=warm).mean('time')
fig, ax = plt.subplots(figsize=(7.2, 3.5))
pc = plot_anom_map(tele, ax, 'Warm-phase composite anomaly')
fig.colorbar(pc, ax=ax, label='m', pad=.02, shrink=.92)
fig.tight_layout(); plt.show()
figure

Power spectrum of the ENSO index

enso_spec
figure output

Periodogram of the monthly index. Variance concentrates in the 3-6 year band (shaded), as expected for the synthetic forcing.

x = (idx_m - idx_m.mean()).values
freqs = np.fft.rfftfreq(x.size, d=1/12)  # cycles per year
power = np.abs(np.fft.rfft(x))**2
mask = freqs > 0
fig, ax = plt.subplots(figsize=(7.2, 3.0))
ax.axvspan(1/6, 1/3, color=AMBER, alpha=.16)
ax.plot(1/freqs[mask], power[mask], color=CYAN, lw=1.4)
ax.set_xlim(0, 9); ax.set_xlabel('period (years)')
ax.set_ylabel('power'); ax.set_title('ENSO index spectrum')
fig.tight_layout(); plt.show()
section 1.5

Interpretation

markdown

Interpretation

The diagnostics hang together. The DJF climatology sets up a wave-3 ridge near the Tasman sector; blocked days compositie into a localised high in exactly that region; and the years with the most blocking line up with the warm phase of the ENSO index. The teleconnection composite recovers the same spatial mode the index was built to track, so the index and the blocking metric are measuring consistent physics rather than noise.

section 1.5.1

Caveats

markdown

Caveats

This is synthetic data with an imposed relationship, so the ENSO–blocking link here is true by construction. On reanalysis the same pipeline would need significance testing on the composites and a sensitivity check on the +1 sigma blocking threshold before any of this could be read as a real teleconnection.