Madden-Julian Oscillation#

Overview#

The Madden-Julian Oscillation (MJO) represents the largest subseasonal (30 to 90 day) atmospheric variability in the low latitude tropical atmosphere. MJO is an eastward traveling atmospheric pattern, moving between 4 to 8 m/s, which influences the location and strength of rainfall in the tropics[1].

(Image credit: NOAA-PSL)

MJO was discovered in 1971 by Roland A. Madden and Paul R. Julian[2] at the National Center for Atmospheric Research (NCAR).

Wheeler-Hendon Phase Diagram#

Overview#

The Wheeler-Hendon diagram is a phase plot and diagnostics tool[3] to quantify the strength and significance of the MJO over time. The plot represents the Real-Time Multivariate MJO (RMM) which is made up of a pair of empirical orthogonal functions (EOF). EOFs are created from the combined equatorially averaged 850-hPa and 200-hPa zonal wind and the outgoing longwave radiation (OLR).

  • Outgoing longwave radiation: The longwave radiation emitted from the top of the Earth’s atmosphere into space

  • Zonal wind: The zonal wind anomaly at 200-hPa and 850-hPa represents the changes in the atmospheric circulation

The pair of principal components from the EOFs form two indices: RMM1 and RMM2. The phase relationship between the two zonal winds determine the motion of the MJO.

  • RMM1: Represents the MJO’s amplitude. A larger RMM1 indicates stronger MJO activity. Negative values indicate a inactive or suppressed MJO phase and positive values indicate an active or enhanced MJO circulation

  • RMM2: Represents the MJO’s phase. The phase determines the geographic position of the MJO’s convection center. It is split into eight phases, each representing to a specific equatorial region

Workflow#

import pandas as pd
import numpy as np
from matplotlib import pyplot as plt
import matplotlib.ticker as ticker  # force integer ticks on axis
import matplotlib.lines as lines  # add phase division lines

Example Data#

For the purpose of this notebook, we will be pulling data from NOAA’s Physical Science Laboratory (PSL). PSL offers near real-time and historical RMM MJO data.

def retrieve_NOAA_data(data_type=None):
    # https://www.psl.noaa.gov/mjo/
    url = f"https://www.psl.noaa.gov/mjo/mjoindex/{data_type.lower()}.cpcolr.1x.txt"
    column_names = ["YYYY", "MM", "DD", "HH", "RMM1", "RMM2", "Phase"]
    try:
        df = pd.read_csv(url, sep=r'\s+', header=None, names=column_names)
        print(f"Retrieving data from: {url}\n")
        return df
    except Exception:
        print(f"invalid url: {url}")
        return pd.DataFrame(columns=column_names)

We will be retrieving the data related to the Real-time OLR MJO Index (ROMI). See PSL MJO for more daily MJO index values for dates from 1979.

noaa_df = retrieve_NOAA_data("ROMI")
noaa_df
Retrieving data from: https://www.psl.noaa.gov/mjo/mjoindex/romi.cpcolr.1x.txt
YYYY MM DD HH RMM1 RMM2 Phase
0 1991 1 1 0 0.12526 -0.06945 0.14323
1 1991 1 2 0 0.18542 -0.04887 0.19175
2 1991 1 3 0 0.23960 -0.03933 0.24281
3 1991 1 4 0 0.27446 0.01990 0.27518
4 1991 1 5 0 0.27714 0.07555 0.28725
... ... ... ... ... ... ... ...
12852 2026 3 10 0 -1.40194 0.00433 1.40195
12853 2026 3 11 0 -1.41309 -0.13210 1.41925
12854 2026 3 12 0 -1.41134 -0.27556 1.43799
12855 2026 3 13 0 -1.43734 -0.42081 1.49768
12856 2026 3 14 0 -1.45038 -0.63790 1.58446

12857 rows × 7 columns

The table above shows NOAA PSL MJO time series data as:

  • YYYY: Year

  • MM: Month (1-12)

  • DD: Day (1-31)

  • HH: Hour (always 0, can be ignored)

  • RMM1: Real-Time Multivariate MJO Principal Component 1

  • RMM2: Real-Time Multivariate MJO Principal Component 2

  • Phase: Amplitude of MJO where sqrt(RMM1^2 + RMM2^2)

For the purpose of a plot, we will be using the datetime (YYYY-MM-DD) and RMM1 and RMM2.

Plotting Phase Diagram#

The function mjo_phase_plot() below has four input arguments, including three optional arguments with default values.

  • data: pandas dataframe containing data (data requires the columns: ["YYYY", "MM", "DD", "RMM1", "RMM2"])

  • radius: circle in the center of the plot which represents when MJO phase value is significant (defaults to 1)

  • month_colors: colors for each month on plot (color for the 12 months defaults to ["tomato", "dodgerblue", "orange", "green", "magenta", "yellow", "pink", "cyan", "gold", "lime", "darkviolet", "olive"])

  • rmm_axis_label: replace phase axis labels with RMM1/RMM2 labels (defaults to False, with all phase and geographical locations)

def mjo_phase_plot(data=None, radius=1, month_colors=[], rmm_axis_label=False):
    fig = plt.figure(figsize=(8, 8))
    ax = fig.add_subplot(111)

    # set x and y axis labels
    ax.set_xlim(-4, 4)
    ax.set_ylim(-4, 4)
    ax2 = ax.twinx()
    ax3 = ax.twiny()
    ax2.set_xlim(-4, 4)
    ax2.set_ylim(-4, 4)
    ax3.set_xlim(-4, 4)
    ax3.set_ylim(-4, 4)
    if not rmm_axis_label:
        ax.set_ylabel(
            "Phase 1 (Western Hemisphere, Africa) Phase 8", fontsize=20, labelpad=20
        )
        ax.set_xlabel("Phase 2 (Indian Ocean) Phase 3", fontsize=20, labelpad=20)
        ax2.set_ylabel(
            "Phase 5 (Maritime) Phase 4", fontsize=20, labelpad=40, rotation=270
        )
        ax3.set_xlabel("Phase 7 (Western Pacific) Phase 6", fontsize=20, labelpad=20)
    else:
        ax.set_xlabel("RMM2", fontsize=20, labelpad=20)
        ax.set_ylabel("RMM1", fontsize=20, labelpad=20)

    # add amplitude circle
    amplitude_circle = plt.Circle((0, 0), radius, fill=False)
    ax.add_patch(amplitude_circle)

    # add phase diagram lines
    ## Phase 1-8
    phase_line = lines.Line2D([-4, -radius], [0, 0], linestyle="--", dashes=(5, 5))
    ax.add_line(phase_line)
    ## Phase 1-2
    phase_line = lines.Line2D(
        [-radius / np.sqrt(2), -4],
        [-radius / np.sqrt(2), -4],
        linestyle="--",
        dashes=(5, 5),
    )
    ax.add_line(phase_line)
    ## Phase 2-3
    phase_line = lines.Line2D([0, 0], [-radius, -4], linestyle="--", dashes=(5, 5))

    ax.add_line(phase_line)
    ## Phase 3-4
    phase_line = lines.Line2D(
        [radius / np.sqrt(2), 4],
        [-radius / np.sqrt(2), -4],
        linestyle="--",
        dashes=(5, 5),
    )
    ax.add_line(phase_line)
    ## Phase 4-5
    phase_line = lines.Line2D([radius, 4], [0, 0], linestyle="--", dashes=(5, 5))

    ax.add_line(phase_line)
    ## Phase 5-6
    phase_line = lines.Line2D(
        [radius / np.sqrt(2), 4],
        [radius / np.sqrt(2), 4],
        linestyle="--",
        dashes=(5, 5),
    )
    ax.add_line(phase_line)
    ## Phase 6-7
    phase_line = lines.Line2D([0, 0], [radius, 4], linestyle="--", dashes=(5, 5))
    ax.add_line(phase_line)
    ## Phase 7-8
    phase_line = lines.Line2D(
        [-radius / np.sqrt(2), -4],
        [radius / np.sqrt(2), 4],
        linestyle="--",
        dashes=(5, 5),
    )
    ax.add_line(phase_line)

    # add tick marks
    full_tickmarks = np.arange(-4, 4.5, 0.5)
    ax.set_xticks(full_tickmarks)
    ax.set_yticks(full_tickmarks)
    # hide tickmarks for top/right axis)
    ax2.set_yticks(full_tickmarks)
    ax3.set_xticks(full_tickmarks)

    # plot whole numbers integar tick marks
    ax.xaxis.set_major_locator(ticker.MultipleLocator(1.0))
    ax.yaxis.set_major_locator(ticker.MultipleLocator(1.0))
    ax2.yaxis.set_major_locator(ticker.MultipleLocator(1.0))
    ax3.xaxis.set_major_locator(ticker.MultipleLocator(1.0))
    # increase length of major ticks
    ax.tick_params(which="major", length=10)
    ax2.tick_params(which="major", length=10)
    ax3.tick_params(which="major", length=10)

    # plot minor ticks every 0.25
    ax.xaxis.set_minor_locator(ticker.MultipleLocator(0.25))
    ax.yaxis.set_minor_locator(ticker.MultipleLocator(0.25))
    ax2.yaxis.set_minor_locator(ticker.MultipleLocator(0.25))
    ax3.xaxis.set_minor_locator(ticker.MultipleLocator(0.25))
    # increase length of minor ticks
    ax.tick_params(which="minor", length=5)
    ax2.tick_params(which="minor", length=5)
    ax3.tick_params(which="minor", length=5)

    # plot with title
    first_index = data.iloc[0]
    start_date = (
        f'{int(first_index["YYYY"])}-{int(first_index["MM"])}-{int(first_index["DD"])}'
    )
    last_index = data.iloc[-1]
    end_date = (
        f'{int(last_index["YYYY"])}-{int(last_index["MM"])}-{int(last_index["DD"])}'
    )

    plt.title(
        f"MJO Phase: ROMI: {start_date} to {end_date}",
        fontsize=30,
        fontweight="bold",
        pad=20,
    )

    # Setup Months Text at Bottom of Plot
    if len(month_colors) == 0:
        # default colors for all twelve months
        month_colors = [
            "tomato",
            "dodgerblue",
            "orange",
            "green",
            "magenta",
            "yellow",
            "pink",
            "cyan",
            "gold",
            "lime",
            "darkviolet",
            "olive",
        ]

    month_dict = {
        1: "Jan",
        2: "Feb",
        3: "Mar",
        4: "Apr",
        5: "May",
        6: "Jun",
        7: "Jul",
        8: "Aug",
        9: "Sep",
        10: "Oct",
        11: "Nov",
        12: "Dec",
    }

    all_months = data["MM"].unique()
    all_month_names = [
        month_name
        for month_mm, month_name in month_dict.items()
        if month_mm in all_months
    ]  # convert number to name (example: 01 to Jan)

    # dynamic spacing for month labels
    start_index = -3.3 + ((12 - len(all_month_names)) * 0.28)
    for i, month in enumerate(all_month_names):
        if i == 0:
            # First Element
            text = ax.text(start_index, -3.7, month, color=month_colors[i], fontsize=13)
        else:
            # Remaining Elements
            text = ax.annotate(
                f"  {month}",
                xycoords=text,
                xy=(1, 0),
                verticalalignment="bottom",
                fontsize=13,
                color=month_colors[i],
            )

        # filter data based on each month
        last_index = data[data["MM"] == all_months[i]].index[-1]

        # include the first day of the next month in data to preserve continuity
        next_index = []
        if last_index + 1 in data.index:
            next_index = [last_index + 1]
        filtered_df = data.loc[
            (data["MM"] == all_months[i]) | (data.index.isin(next_index))
        ]
        x_data = filtered_df["RMM2"]
        y_data = -filtered_df["RMM1"]

        # Plot data
        plt.plot(x_data, y_data, zorder=1, c=month_colors[i])
        plt.scatter(x_data, y_data, zorder=1, c=month_colors[i])

        # plot dark mark at the start and end
        if i == 0:
            plt.scatter(x_data.iloc[[0]], y_data.iloc[[0]], c="black", s=150)
        if i + 1 == len(all_month_names):
            plt.scatter(x_data.iloc[[-1]], y_data.iloc[[-1]], c="black", s=150)

    # Annotate text every 5 days
    days_text = [5, 10, 15, 20, 25, 30]
    days_filtered = data[data["DD"].isin(days_text)]
    for index, row in days_filtered.iterrows():
        plt.text(
            x=row["RMM2"],
            y=-row["RMM1"],
            s=int(row["DD"]),
            ha="center",
            zorder=2,
            fontweight="bold",
        )
    plt.show()

Plot Diagram for 2025#

Collect a subset of the data, the first five months of 2025.

df_2026 = noaa_df[(noaa_df["YYYY"] == 2025) & (noaa_df.MM.between(1, 5))]

df_2026
YYYY MM DD HH RMM1 RMM2 Phase
12419 2025 1 1 0 -0.15161 -0.83951 0.85309
12420 2025 1 2 0 -0.01538 -1.03555 1.03566
12421 2025 1 3 0 0.04397 -1.19613 1.19693
12422 2025 1 4 0 0.09447 -1.28177 1.28524
12423 2025 1 5 0 0.16386 -1.34737 1.35730
... ... ... ... ... ... ... ...
12565 2025 5 27 0 0.12277 -0.03427 0.12746
12566 2025 5 28 0 0.06407 -0.14853 0.16176
12567 2025 5 29 0 -0.01231 -0.20038 0.20076
12568 2025 5 30 0 -0.06424 -0.25173 0.25980
12569 2025 5 31 0 -0.13798 -0.25259 0.28782

151 rows × 7 columns

mjo_phase_plot(data=df_2026)
../_images/869c5ba46d2d55ab4e91d9f450097e9f037f80d841de2c803180d1923be25be2.png

Plot Diagram Most Recent Data#

Display months of the most recent year.

all_years = noaa_df["YYYY"].unique()
most_recent_year = all_years[-1]
all_months_in_recent_year = noaa_df[(noaa_df["YYYY"] == most_recent_year)][
    "MM"
].unique()
print(
    f"Most recent year {most_recent_year} has {len(all_months_in_recent_year)} months"
)
Most recent year 2026 has 3 months
df_recent = noaa_df[(noaa_df["YYYY"] == most_recent_year)]
mjo_phase_plot(data=df_recent)
../_images/8fe42106b34579a12dd53c5a2ae2e5e96545f78752bfd1e4498ee0df7a026d85.png

Curated Resources#

To learn more about Madden-Julian Oscillation:

References:#