Fitting the Burton–McPherron–Russell Equation with SciPy
Geomagnetic storms are driven by the interaction between the solar wind and Earth’s magnetosphere, and the Dst index (Disturbance Storm Time index) is the standard way to quantify how strong a storm is. A sudden southward turn in the interplanetary magnetic field lets solar wind energy leak into the magnetosphere, intensifies the ring current, and drives Dst sharply negative — sometimes below -200 nT during severe storms. Forecasting Dst even a few hours ahead is valuable for satellite operators, power grid managers, and anyone who needs early warning of a geomagnetic disturbance.
In this post we build a small but complete example: a physics-based Dst forecasting model whose free parameters are optimized against observation data using nonlinear least squares. Rather than a black-box machine learning model, we use a classic semi-empirical formulation — the Burton–McPherron–Russell (BMR) model — and recover its coefficients numerically. This keeps the model interpretable while still requiring real numerical optimization.
The Physics: A Leaky-Integrator Ring Current Model
The BMR model treats the ring current as a reservoir that fills when the solar wind injects energy and empties through a decay process:
$$\frac{dDst^*(t)}{dt} = Q(t) - \frac{Dst^*(t)}{\tau}$$
Here $Dst^*(t)$ is the (baseline-corrected) ring current index, $\tau$ is a decay time constant, and $Q(t)$ is an injection term that switches on only when the interplanetary electric field exceeds a coupling threshold:
$$Q(t) = \begin{cases} -a,\bigl(E_y(t) - E_c\bigr), & E_y(t) > E_c \[4pt] 0, & E_y(t) \le E_c \end{cases}$$
The driving electric field comes from the solar wind speed $V(t)$ and the southward component of the interplanetary magnetic field:
$$E_y(t) = V(t),B_s(t)\times 10^{-3}, \qquad B_s(t) = \max\bigl(-B_z(t),,0\bigr)$$
The four unknowns we need to estimate are the coupling efficiency $a$, the ring-current decay time $\tau$, the threshold $E_c$, and a baseline offset $b$.
From a Differential Equation to a Fast Update Rule
Rather than integrating this ODE with a generic solver (which becomes painfully slow once you need thousands of evaluations for an optimizer), we exploit the fact that it is linear between samples. Treating $Q(t)$ as piecewise constant over each time step $\Delta t$, the exact solution of the ODE gives a simple recursive update:
$$Dst^*_{n+1} = Dst^*_{n},e^{-\Delta t/\tau} + Q_n,\tau\left(1-e^{-\Delta t/\tau}\right)$$
This recursion is a first-order IIR filter, so instead of looping over it in pure Python we implement it with scipy.signal.lfilter, which runs the whole time series through compiled C code in one call. This is the “speed trick” that makes it practical to evaluate the model hundreds of thousands of times during optimization and grid search.
The Optimization Problem
Given a noisy observed time series $Dst^*_{obs}$, we search for the parameter vector $\theta = (a, \tau, E_c, b)$ that minimizes the sum of squared residuals:
$$\min_{\theta}; \sum_{n=1}^{N}\Bigl(Dst^*_{obs,n} - Dst^*_{model,n}(\theta)\Bigr)^2$$
This is solved with the Levenberg–Marquardt-style trust-region reflective algorithm implemented in scipy.optimize.least_squares.
Full Python Implementation
1 | import numpy as np |

=== Optimization Result === Parameter True Optimized a 1.200 1.256 tau [h] 8.000 7.766 Ec 0.500 0.534 b [nT] -8.000 -7.768 RMSE : 2.810 nT Correlation : 0.9860 Optimization time: 40.38 ms over 30 evaluations Grid search (1600 evaluations) took 325.6 ms
Code Walkthrough
Synthetic storm scenario. Since we want a fully self-contained, reproducible example, the interplanetary field $B_z$ and solar wind speed $V$ are generated as smooth Gaussian-shaped disturbances layered on a quiet background, with a fixed random seed so results are repeatable. This mimics a realistic storm sudden commencement followed by a main phase and recovery, without depending on any external data source.
simulate_dst_fast. This function is the heart of the model. Instead of stepping through the ODE with a generic Runge–Kutta solver, it uses the closed-form exponential update derived above. Because that update is a linear recursion of the form $y_n = c,y_{n-1} + g,x_{n-1}$, it is mathematically identical to a one-pole digital filter. scipy.signal.lfilter evaluates that recursion across the entire array in a single compiled call, and lfiltic is used to seed the filter’s internal state with the correct initial Dst value. This turns what would otherwise be a slow, per-sample Python loop into a vectorized operation — critical since the optimizer and the grid search below call this function tens of thousands of times.
Generating pseudo-observations. We simulate a “true” Dst curve with known parameters, then add Gaussian noise to imitate real measurement/data uncertainty. This gives us a ground truth to check whether the optimizer actually recovers the correct physics.
residuals and least_squares. The residual function returns the vector of observed-minus-modeled differences that least_squares tries to drive toward zero. Bounds are supplied for every parameter to keep the search physically meaningful (for instance, $\tau$ and $a$ must stay positive). Each time the residual function is called, the current sum-of-squares is appended to cost_history, which lets us plot the optimizer’s convergence afterward without any extra bookkeeping.
Grid search for the loss landscape. To visualize why the optimizer converges where it does, we independently sweep $a$ and $\tau$ over a 40×40 grid, holding the other two parameters fixed at their optimized values, and record the sum-of-squared-error at every combination. Thanks to the fast filter-based simulator, all 1,600 evaluations complete in well under a tenth of a second.
Reading the Graphs
- Top-left (solar wind driver): shows the southward excursion of $B_z$ and the resulting spike in the coupling electric field $E_y$ — this is the “fuel” that drives the storm.
- Top-right (Dst comparison): gray dots are the noisy pseudo-observations, the dashed black line is the true underlying signal, and the orange line is the model fitted purely from the noisy data. A close match between orange and black confirms the optimizer recovered the correct dynamics even though it never saw the true parameters.
- Bottom-left (3D loss landscape): plotting $\log_{10}(\text{SSE})$ over the $(a, \tau)$ plane reveals a curved valley — many combinations of coupling strength and decay time can produce similar-looking storms, but the valley has a clear minimum, marked in red, matching the values returned by
least_squares. This is a good way to visually communicate parameter identifiability in a physical model. - Bottom-right (convergence): the RMS residual drops sharply within the first several evaluations and then flattens, showing the trust-region algorithm homing in on the minimum quickly rather than wandering.
Recovering physically meaningful coefficients ($a$, $\tau$, $E_c$) rather than an opaque set of neural network weights means this kind of model stays interpretable — a forecaster can look at the fitted decay time and immediately understand how long the storm’s recovery phase should take, something that is much harder to extract from a purely data-driven predictor.





















