Skip to main content

qg-leapfrog-stability

DOI License: MIT

QG-Python: a reproducible 1.5-layer quasi-geostrophic modelling service with spectral stability and filter sensitivity diagnostics

This repository contains the QG-Python model, the analysis scripts, and the shell drivers associated with the Data Services Article published in Geoscience Data Journal (Royal Meteorological Society / Wiley):

Nino-Ruiz, E. D. (2026). QG-Python: A Reproducible 1.5-Layer Quasi-Geostrophic Modelling Service with Direct Sparse Solvers, Spectral Stability Diagnostics, and Filter Sensitivity Data. Geoscience Data Journal. doi:10.1002/gdj3.70107

The archived snapshot of this repository together with the simulation outputs is the citable version of record: doi:10.5281/zenodo.22168803


Install

pip install qg-python          # model only (numpy, scipy)
pip install "qg-python[all]"   # + matplotlib and netCDF4 for the viewer and .nc output
from qg_model import QGModel, QGParams

prm = QGParams(mrefin=6, tend=1000, dtout=20, scheme='leapfrog',
               ra_alpha=0.1, raw_filter=True, outfname='qg_run.npz')
psi, q = QGModel(prm).run('fourier', seed=42)

The same model is available on the command line as qg-python (run qg-python --help). To reproduce the experiments of the paper, clone this repository and run bash run_all.sh.


Releasing to PyPI

pip install build twine
python -m build                       # dist/qg_python-1.1.0.tar.gz and .whl
twine check dist/*
twine upload dist/*                   # user __token__, password: the PyPI token
git tag v1.1.0 && git push --tags     # Zenodo archives the release

Regimes and presets (v1.1)

pip install qg-python            # https://pypi.org/project/qg-python/ ; import as `qgpython`, `import qg_model` still works

The defaults of QGParams are those of QG-C and of the article above, so that every script in this repository reproduces the published results. For data assimilation (cycling a filter over hundreds of analyses) use the stationary preset instead:

from qgpython import stationary_model, api
model = stationary_model(mrefin=5)                      # 49x49, rkh2 = 1e-11, RK4, dt = 0.3125
x = api.propagate(model, api.pack(model.q, model.psi), [0.0, 20000.0])   # spin-up
q, psi = api.unpack(model, x)

Why a preset: with the QG-C default dissipation (rkh2 = 1e-12) the flow keeps intensifying, the standard deviation of q is still rising at t = 8000 and beyond, so there is no stationary regime to sample a climatology from. With ten times the dissipation (rkh2 = 1e-11) it settles: 1.107e4 at t = 20000 against 1.222e4 at t = 60000. Sakov & Oke (2008) make the same adjustment. What we measured on this implementation (September 2026, mrefin = 5):

rkh2 = 1e-12 (default) rkh2 = 1e-11 (preset)
std of q at t = 8000 / 20000 / 60000 rising / rising / rising 1.10e4 / 1.11e4 / 1.22e4
climatology (snapshots every 250 units after t = 20000) not stationary 200 decorrelated states
forecast blow-ups in 500+ cycled runs (60-120 analyses) n/a none
error growth per 20 units, climatological ensemble n/a 1.9x
point perturbation of q after 20 / 200 units n/a stays in place; centroid moves 0.1 / 1.4 cells

The last line matters for localization: in this model the forecast error grows locally by instability and is not advected within an assimilation interval (the same holds at rkh2 = 1e-12 and at mrefin = 6). The figure figures/qg_resolutions.png shows the same stationary state at 49x49, 97x97 and 193x193.

api gives the state-vector interface a filter needs: pack/unpack, propagate(model, x, [t0, t1]) (stateless between calls, leapfrog memory reset), psi_from_q, and interior_mask (the points off the Dirichlet boundary, the ones to estimate). Neighbourhoods for localization must not wrap: the basin is closed.

Overview

We implement the Leapfrog scheme with Robert-Asselin (RA) and Robert-Asselin-Williams (RAW) filters in a 1.5-layer quasi-geostrophic (QG) model, and carry out a systematic numerical analysis covering:

  • Replacement of the original Fortran multigrid Helmholtz solver with a direct sparse LU factorization (SuperLU via SciPy), enabling unambiguous stability analysis
  • Eigenspectrum of the linearized operator $J_\mathcal{F}(q_\mathrm{eq})$ at two grid resolutions (49×49 and 97×97), revealing resolution-dependent deviation from the classical von Neumann bound
  • Maximum stable time step $\Delta t_{\max}$ as a function of grid spacing $h$
  • First estimate of the maximum Lyapunov exponent for this model ($\sigma_1 = 0.0318$, $\tau_L = 31.5$ time units)
  • Statistical uncertainty propagation: kinetic energy and enstrophy PDFs, energy spectra, and KL divergences across schemes
  • Sensitivity of filter-induced dissipation to the RA parameter $\alpha \in {0.05, 0.10, 0.20, 0.40}$

The QG model is a faithful Python port of the QG-C model by Pavel Sakov (Bureau of Meteorology), extended with the Leapfrog family of integrators.


Repository structure

qg-leapfrog-stability/
│
├── qg_model.py                  # QG model, all time-stepping schemes
├── qg_viewer.py                 # Interactive field viewer (.npz files)
├── run_all.sh                   # Single script to reproduce all results
│
├── analysis/
│   ├── plot_style.py            # Shared publication style settings
│   ├── jacobian_spectrum.py     # Build J_F and compute eigenspectrum
│   ├── stability_regions.py     # Plot eigenspectrum + stability regions
│   ├── lyapunov.py              # Benettin algorithm for Lyapunov exponent
│   ├── uncertainty.py           # PDFs, power spectra, KL divergence
│   ├── plot_fields.py           # Spatial snapshots of streamfunction
│   └── plot_alpha_sensitivity.py  # RA filter sensitivity figures
│
├── experiments/
│   ├── run_reference.sh         # Spin-up + RK4 reference run
│   ├── run_leapfrog_long.sh     # LF+RA and LF+RAW main runs
│   ├── run_leapfrog_sweep.sh    # RA filter alpha sweep
│   └── run_analysis.sh          # Eigenspectrum + Lyapunov diagnostics
│
├── data/
│   ├── README.md                # Exact command line behind every run
│   ├── blowup_times.txt         # dt=1.0 instability onset, LF+RA / LF+RAW
│   ├── lyapunov_results.txt     # Benettin MLE summary
│   └── uncertainty_results.txt  # KE / enstrophy / KL divergence summary
│
├── paper/
│   └── README.md                # Pointer to the published article
│
├── figures/                     # Generated locally, not tracked by git
├── requirements.txt
├── CITATION.cff
├── LICENSE
└── README.md

Dependencies

numpy >= 1.24
scipy >= 1.10
matplotlib >= 3.7

Install with:

pip install -r requirements.txt

No additional compiled extensions are required. The Helmholtz solver uses scipy.sparse.linalg.factorized (SuperLU backend).


Reproducing the results

All steps are collected in a single script. Run from the root of the repository:

bash run_all.sh

The script executes the following steps in order:

Step Description Runtime (approx.)
1 Spin-up from rest (mrefin=6, tend=100000, RK4, dt=1) ~45 min
2 RK4 reference run (dt=1, t=100000→110000) ~25 min
3 LF+RA main run (α=0.10, dt=0.5) ~12 min
4 LF+RAW main run (α=0.10, dt=0.5) ~12 min
5 Alpha sensitivity sweep (α=0.05, 0.20, 0.40) ~36 min
6 Jacobian eigenspectrum, mrefin=6 (97×97) ~2 min
7a Spin-up from rest, mrefin=5 (dt=0.1) ~45 min
7b Jacobian eigenspectrum, mrefin=5 (49×49) ~1 min
8 Lyapunov exponent (Benettin, 50000 steps) ~90 min
9 All figures ~1 min

Total runtime on a modern laptop: approximately 4-5 hours.

Note: The mrefin=5 spin-up (Step 7a) requires dt=0.1, larger time steps cause blow-up during spin-up at this resolution.

Running individual steps

The pipeline is also split into four self-contained drivers under experiments/, which can be run in isolation for partial reproduction:

bash experiments/run_reference.sh       # spin-up + RK4 reference
bash experiments/run_leapfrog_long.sh   # LF+RA and LF+RAW main runs
bash experiments/run_leapfrog_sweep.sh  # alpha sensitivity sweep
bash experiments/run_analysis.sh        # eigenspectrum + Lyapunov

Each command in run_all.sh is likewise self-contained. For example, to recompute only the eigenspectrum:

python analysis/jacobian_spectrum.py --restart qg_zero.npz --neigs 200 --no-matrix --out jacobian_spectrum.npz

Key results

Quantity Value
Grid (main experiments) 97×97 (mrefin=6), $h=0.01042$
Analytical $\Delta t_{\max}^{LF}$ (mrefin=6) 0.090
Numerical $\Delta t_{\max}^{LF}$ (mrefin=6) 1.236 (factor 13.7× larger)
Analytical $\Delta t_{\max}^{LF}$ (mrefin=5) 0.362
Numerical $\Delta t_{\max}^{LF}$ (mrefin=5) 0.235 (factor 1.54× smaller)
Maximum Lyapunov exponent $\sigma_1$ 0.0318
Lyapunov time $\tau_L$ 31.5 time units
LF+RA kinetic energy bias (α=0.10) −21%
LF+RA enstrophy bias (α=0.10) −46%
LF+RAW kinetic energy bias −2%
LF+RAW enstrophy bias +4%

Time-stepping schemes

Scheme Order RHS evals / step Notes
order1 1 1 Explicit Euler
order2 2 2 Midpoint RK
rk4 4 4 Default in QG-C
dp5 5 6 Dormand-Prince
leapfrog 2 1 + RA or RAW filter

The first Leapfrog step uses a single RK4 step as bootstrap to avoid exciting the computational mode from the start.

Leapfrog filter options

# Robert-Asselin filter (classic, Asselin 1972)
python qg_model.py --scheme leapfrog --ra-alpha 0.1 ...

# Robert-Asselin-Williams filter (less dissipative, Williams 2009)
python qg_model.py --scheme leapfrog --ra-alpha 0.1 --raw ...

Data availability

The simulation output files (.npz) are several hundred megabytes and are therefore not tracked by git. They are archived, together with a snapshot of this repository, on Zenodo:

DOI: 10.5281/zenodo.22168803 Record: https://doi.org/10.5281/zenodo.22168803 Development version: https://github.com/enino84/qg-leapfrog-stability

The Zenodo record is the authoritative citation target: it holds both the tagged source snapshot (qg-python-v1.0.zip) and the simulation outputs. This GitHub repository is the development mirror and tracks the source only.

The archive contains the equilibrium restart fields at t = 100000 for both resolutions, the eigenvalue clouds of the linearised operator, the Benettin trace of the maximum Lyapunov exponent, and the time-resolved kinetic energy, enstrophy and isotropic spectra for RK4, LF+RA and LF+RAW across alpha in {0.05, 0.10, 0.20, 0.40}. data/README.md documents the exact command line behind each file, so every output can also be regenerated from scratch.


Reference model

This work builds on:

Sakov, P. (2024). QG-C: A quasi-geostrophic model in C. Bureau of Meteorology, Australia. https://github.com/sakov/qg-c

Key numerical methods:

Arakawa, A. (1966). Computational design for long-term numerical integration of the equations of fluid motion. Journal of Computational Physics, 1(1), 119-143.

Asselin, R. (1972). Frequency filter for time integrations. Monthly Weather Review, 100(6), 487-490.

Williams, P. D. (2009). A proposed modification to the Robert-Asselin time filter. Monthly Weather Review, 137(8), 2538-2546.

Li, X. S., & Demmel, J. W. (2003). SuperLU_DIST: A scalable distributed-memory sparse direct solver for unsymmetric linear systems. ACM Transactions on Mathematical Software, 29(2), 110-140.


License

The source code in this repository is released under the MIT License, see LICENSE. The simulation output files distributed through the Zenodo archive are released under CC-BY-4.0.


Citation

Please cite both the article and the archived software release. Machine-readable metadata is in CITATION.cff.

@article{NinoRuiz2026qgpython,
  author  = {Nino-Ruiz, Elias D.},
  title   = {{QG-Python}: A Reproducible 1.5-Layer Quasi-Geostrophic Modelling
             Service with Direct Sparse Solvers, Spectral Stability Diagnostics,
             and Filter Sensitivity Data},
  journal = {Geoscience Data Journal},
  year    = {2026},
  doi     = {10.1002/gdj3.70107}
}

@software{NinoRuiz2026qgpythonsoftware,
  author    = {Nino-Ruiz, Elias D.},
  title     = {{QG-Python}: a reproducible 1.5-layer quasi-geostrophic modelling
               service with spectral stability and filter sensitivity diagnostics},
  version   = {1.0},
  year      = {2026},
  publisher = {Zenodo},
  doi       = {10.5281/zenodo.22168803}
}

A note on the enstrophy diagnostic

Enstrophy is reported throughout as the domain average of (nabla^2 psi)^2, evaluated with the standard 5-point Laplacian on the interior nodes only. This is the definition in analysis/uncertainty.py::enstrophy, and it is the one every other script imports.

The wall ring must be excluded from the average. qg_model.py imposes homogeneous Dirichlet conditions psi = 0 on all four walls, so at a wall node the 5-point stencil collapses to psi_adjacent / h^2; with h ~ 0.01 that is O(1e5), and squaring it lets a one-node ring, about 4% of the points - dominate the domain average and inflate its variance by considerably more, while leaving the relative biases between time-stepping schemes almost unchanged.

analysis/make_tables.py regenerates the LaTeX rows of both tables in the article directly from the .npz runs, so the statistical summary and the alpha sweep cannot drift apart. Pass --compare to also print the wall-inclusive average.


Metadata

Release files for qg-python 1.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for qg-python 1.1.0
File Size Uploaded
qg_python-1.1.0.tar.gz 22.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for qg-python 1.1.0
File Interpreter ABI Platform
qg_python-1.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 42.7 kB

Release files / qg_python-1.1.0.tar.gz

Download URL qg_python-1.1.0.tar.gz
Size 22.6 kB
Tags Source
SHA-256 checksum
How to use checksums
7dcd4e8dc065883290f719acd963f03d33a162b65b79b44a6f6e928eac8c0a2a
BLAKE2b-256 checksum
How to use checksums
058a51cfbf0171955b610c2146c7845969dfb94bef4c61ea130bfe114ae12ff0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.12.7

Release files / qg_python-1.1.0-py3-none-any.whl

Download URL qg_python-1.1.0-py3-none-any.whl
Size 20.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
cca732c65b4817a1db5d85647bf766c3e8997812d3229fd9dcc78f04c98ba86b
BLAKE2b-256 checksum
How to use checksums
53033f2861b89af56cf4f78e306fde90fdd989a27662af830a00392977a4cc96
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.12.7

Release history Release notifications | RSS feed

This release

1.1.0 This release

2 release files

1.0.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page