Optimal Allocation of Space Weather Observation Resources

A Concrete Example Solved with Python

Introduction

A solar flare can disturb HF radio within minutes. A coronal mass ejection (CME) can trigger a geomagnetic storm a day or two later, degrading GNSS positioning, stressing power grids, and endangering satellites. Forecasters cannot watch everything all the time. Coronagraph time, magnetograph scans, ground magnetometer sampling, ionosonde sounding, GNSS-TEC processing, and L1 solar wind monitoring all have finite budgets.

This article poses a concrete allocation problem, derives its exact solution with Lagrange multipliers, and solves it in Python. Along the way we compare a generic solver with a fast semi-analytic algorithm and visualize everything in a single multi-panel figure that includes 3D graphs.

Problem Setting

We plan the next $T = 24$ hours in one-hour slots and have $I = 6$ observation resources:

Index Resource Role
1 Coronagraph CME detection and speed estimation
2 Magnetograph Active-region magnetic-field evolution
3 Ground magnetometers Geomagnetic disturbance monitoring
4 Ionosondes Ionospheric state
5 GNSS-TEC receivers Total electron content mapping
6 L1 solar wind monitor In-situ solar wind measurement

Let $x_{t,i} \ge 0$ be the observation effort given to resource $i$ in slot $t$. Each resource has:

  • a value weight $w_i$,
  • a saturation rate $a_i$ (how quickly extra effort stops adding information),
  • a unit cost $c_i$.

Each slot has a risk level $r_t$, meaning how much an accurate observation is worth at that hour. We model one flare-related risk pulse around hour 6 and one CME-arrival pulse around hour 15:

$$
r_t = r_0 + s\left[A_1 \exp!\left(-\frac{(t-t_1)^2}{2\sigma_1^2}\right) + A_2 \exp!\left(-\frac{(t-t_2)^2}{2\sigma_2^2}\right)\right]
$$

with $r_0 = 0.15$, $s = 1$, $(A_1, t_1, \sigma_1) = (1.0, 6, 1.5)$, and $(A_2, t_2, \sigma_2) = (1.6, 15, 2.0)$.

Mathematical Formulation

The information gained from resource $i$ in slot $t$ saturates exponentially with effort. Maximizing the total risk-weighted information gain gives

$$
\max_{x}; U(x) = \sum_{t=1}^{T}\sum_{i=1}^{I} r_t, w_i \left(1 - e^{-a_i x_{t,i}}\right)
$$

subject to a total cost budget and per-slot effort caps:

$$
\sum_{t=1}^{T}\sum_{i=1}^{I} c_i, x_{t,i} \le B, \qquad 0 \le x_{t,i} \le x_{\max}.
$$

Each term of $U$ is concave in $x_{t,i}$ and all constraints are linear, so this is a convex optimization problem. The KKT conditions are therefore both necessary and sufficient.

Closed-form structure via the Lagrangian

Introduce a multiplier $\lambda \ge 0$ for the budget constraint:

$$
\mathcal{L}(x,\lambda) = \sum_{t,i} r_t w_i\left(1-e^{-a_i x_{t,i}}\right) - \lambda\left(\sum_{t,i} c_i x_{t,i} - B\right).
$$

Setting the derivative with respect to an interior $x_{t,i}$ to zero gives

$$
r_t, w_i, a_i, e^{-a_i x_{t,i}} = \lambda, c_i .
$$

In words, at the optimum the marginal information gain per unit cost is equalized across all active (slot, resource) pairs, and that common value is $\lambda$. Solving for $x$ and applying the bounds gives

A pair $(t,i)$ receives effort only if $r_t w_i a_i / c_i > \lambda$. The total cost $\sum c_i x^{*}_{t,i}(\lambda)$ is monotonically decreasing in $\lambda$, so the $\lambda$ that exactly exhausts the budget can be found by bisection. This is a water-filling type solution.

Source Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
import time
import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D # noqa: F401
from scipy.optimize import minimize

plt.style.use("dark_background")

# ------------------------------------------------------------------
# 1. Problem data
# ------------------------------------------------------------------
names = ["Coronagraph", "Magnetograph", "Magnetometers",
"Ionosondes", "GNSS-TEC", "L1 Monitor"]
w = np.array([1.00, 0.80, 0.90, 0.60, 0.70, 1.20]) # value weights
a = np.array([0.90, 0.70, 1.10, 0.80, 0.90, 1.30]) # saturation rates
c = np.array([3.00, 2.50, 1.50, 1.20, 1.00, 4.00]) # unit costs

I = len(names)
T = 24
hours = np.arange(T)
BUDGET = 60.0
XMAX = 4.0
R0 = 0.15


def risk_profile(scale=1.0):
flare = 1.0 * np.exp(-0.5 * ((hours - 6.0) / 1.5) ** 2)
cme = 1.6 * np.exp(-0.5 * ((hours - 15.0) / 2.0) ** 2)
return R0 + scale * (flare + cme)


# ------------------------------------------------------------------
# 2. Objective and gradient
# ------------------------------------------------------------------
def utility(X, r):
return float((r[:, None] * w[None, :] * (1.0 - np.exp(-a[None, :] * X))).sum())


def gradient(X, r):
return r[:, None] * (w * a)[None, :] * np.exp(-a[None, :] * X)


# ------------------------------------------------------------------
# 3. Generic solver (SLSQP) - reference implementation
# ------------------------------------------------------------------
def solve_slsqp(r, budget):
cvec = np.tile(c, T)
x0 = np.full(T * I, budget / (T * c.sum()))

def fun(z):
return -utility(z.reshape(T, I), r)

def jac(z):
return -gradient(z.reshape(T, I), r).ravel()

cons = [{"type": "ineq",
"fun": lambda z: budget - cvec @ z,
"jac": lambda z: -cvec}]
res = minimize(fun, x0, jac=jac, bounds=[(0.0, XMAX)] * (T * I),
constraints=cons, method="SLSQP",
options={"maxiter": 500, "ftol": 1e-12})
X = np.clip(res.x.reshape(T, I), 0.0, XMAX)
cost = float((X * c[None, :]).sum())
if cost > budget:
X = X * (budget / cost)
return X


# ------------------------------------------------------------------
# 4. Fast solver: Lagrange multiplier + bisection (vectorized)
# ------------------------------------------------------------------
def allocation_for_lambda(r, lam):
arg = np.outer(r, w * a / c) / lam
X = np.log(np.maximum(arg, 1e-300)) / a[None, :]
return np.clip(X, 0.0, XMAX)


def solve_fast(r, budget, iters=100):
lo, hi = 1e-9, 1e3
X_lo = allocation_for_lambda(r, lo)
if float((X_lo * c[None, :]).sum()) <= budget:
return X_lo, lo
for _ in range(iters):
mid = np.sqrt(lo * hi)
X_mid = allocation_for_lambda(r, mid)
if float((X_mid * c[None, :]).sum()) > budget:
lo = mid
else:
hi = mid
return allocation_for_lambda(r, hi), hi


# ------------------------------------------------------------------
# 5. Solve the base case and compare strategies
# ------------------------------------------------------------------
r = risk_profile(1.0)

t0 = time.perf_counter()
X_slsqp = solve_slsqp(r, BUDGET)
t_slsqp = time.perf_counter() - t0

n_rep = 50
t0 = time.perf_counter()
for _ in range(n_rep):
X_opt, lam_opt = solve_fast(r, BUDGET)
t_fast = (time.perf_counter() - t0) / n_rep

X_uni = np.full((T, I), BUDGET / (T * c.sum()))
X_rp = np.outer(r / r.sum(), np.ones(I)) * BUDGET / c.sum()

U_uni = utility(X_uni, r)
U_rp = utility(X_rp, r)
U_slsqp = utility(X_slsqp, r)
U_opt = utility(X_opt, r)

cost_opt = float((X_opt * c[None, :]).sum())
mg = r[:, None] * (w * a / c)[None, :] * np.exp(-a[None, :] * X_opt)
interior = (X_opt > 1e-9) & (X_opt < XMAX - 1e-9)
kkt_dev = float(np.max(np.abs(mg[interior] / lam_opt - 1.0))) if interior.any() else 0.0

print("=" * 66)
print(" Space Weather Observation Resource Allocation - Results")
print("=" * 66)
print(f" Budget B : {BUDGET:.2f}")
print(f" Cost used (Lagrangian solution) : {cost_opt:.4f}")
print(f" Optimal multiplier lambda* : {lam_opt:.6f}")
print(f" Max KKT deviation (interior) : {kkt_dev:.3e}")
print("-" * 66)
print(f" Utility Uniform : {U_uni:.4f}")
print(f" Utility Risk-proportional : {U_rp:.4f}")
print(f" Utility SLSQP (generic) : {U_slsqp:.4f}")
print(f" Utility Lagrangian bisection : {U_opt:.4f}")
print(f" Gain over uniform : {100.0 * (U_opt / U_uni - 1.0):.2f} %")
print(f" Gain over risk-proportional : {100.0 * (U_opt / U_rp - 1.0):.2f} %")
print("-" * 66)
print(f" Time SLSQP : {t_slsqp:.4f} s")
print(f" Time Lagrangian bisection : {t_fast:.6f} s")
print(f" Speed-up : {t_slsqp / max(t_fast, 1e-12):.1f} x")
print("-" * 66)
print(f" {'Resource':<15}{'Total effort':>14}{'Cost share [%]':>18}")
for i in range(I):
eff = float(X_opt[:, i].sum())
share = 100.0 * c[i] * eff / cost_opt
print(f" {names[i]:<15}{eff:>14.3f}{share:>18.2f}")
print("=" * 66)

# ------------------------------------------------------------------
# 6. Sensitivity surface: utility vs. budget and storm intensity
# ------------------------------------------------------------------
budgets = np.linspace(10.0, 120.0, 16)
scales = np.linspace(0.5, 3.0, 16)
U_surf = np.zeros((len(scales), len(budgets)))
for j, s in enumerate(scales):
r_s = risk_profile(s)
for k, b in enumerate(budgets):
X_s, _ = solve_fast(r_s, b)
U_surf[j, k] = utility(X_s, r_s)
Bg, Sg = np.meshgrid(budgets, scales)

# ------------------------------------------------------------------
# 7. Single combined figure
# ------------------------------------------------------------------
short = ["Corona", "Magneto", "Ground", "Iono", "GNSS", "L1"]
Ig, Hg = np.meshgrid(np.arange(I), hours)

fig = plt.figure(figsize=(21, 12))

ax1 = fig.add_subplot(2, 3, 1, projection="3d")
surf1 = ax1.plot_surface(Ig, Hg, X_opt, cmap="plasma", edgecolor="none", alpha=0.95)
ax1.set_xticks(np.arange(I))
ax1.set_xticklabels(short, fontsize=7)
ax1.set_ylabel("Hour")
ax1.set_zlabel("Effort $x_{t,i}$")
ax1.set_title("3D optimal allocation surface")
ax1.view_init(elev=28, azim=-125)
fig.colorbar(surf1, ax=ax1, shrink=0.55, pad=0.1)

ax2 = fig.add_subplot(2, 3, 2)
im = ax2.imshow(X_opt.T, aspect="auto", origin="lower", cmap="magma",
extent=[-0.5, T - 0.5, -0.5, I - 0.5])
ax2.set_yticks(np.arange(I))
ax2.set_yticklabels(names, fontsize=8)
ax2.set_xlabel("Hour")
ax2.set_title("Allocation heatmap (resource x hour)")
fig.colorbar(im, ax=ax2, shrink=0.8)

ax3 = fig.add_subplot(2, 3, 3)
ax3.stackplot(hours, (X_opt * c[None, :]).T, labels=names, alpha=0.9)
ax3.set_xlabel("Hour")
ax3.set_ylabel("Cost spent per slot")
ax3.set_title("Cost composition over time")
ax3.legend(fontsize=7, loc="upper left", ncol=2)

ax4 = fig.add_subplot(2, 3, 4)
labels = ["Uniform", "Risk-\nproportional", "SLSQP", "Lagrangian\nbisection"]
vals = [U_uni, U_rp, U_slsqp, U_opt]
bars = ax4.bar(labels, vals, color=["#7f8c8d", "#3498db", "#e67e22", "#2ecc71"])
for bar, v in zip(bars, vals):
ax4.text(bar.get_x() + bar.get_width() / 2, v, f"{v:.2f}",
ha="center", va="bottom", fontsize=9)
ax4.set_ylim(0, max(vals) * 1.15)
ax4.set_ylabel("Total utility $U$")
ax4.set_title("Strategy comparison")

ax5 = fig.add_subplot(2, 3, 5, projection="3d")
ax5.plot_surface(Bg, Sg, U_surf, cmap="viridis", edgecolor="none", alpha=0.95)
ax5.scatter([BUDGET], [1.0], [U_opt], color="red", s=60)
ax5.set_xlabel("Budget $B$")
ax5.set_ylabel("Storm scale $s$")
ax5.set_zlabel("Optimal utility")
ax5.set_title("3D sensitivity: budget x storm intensity")
ax5.view_init(elev=25, azim=-130)

ax6 = fig.add_subplot(2, 3, 6)
ax6.plot(hours, r, color="#f1c40f", lw=2.5, marker="o", label="Risk $r_t$")
ax6.set_xlabel("Hour")
ax6.set_ylabel("Risk $r_t$", color="#f1c40f")
ax6b = ax6.twinx()
ax6b.bar(hours, (X_opt * c[None, :]).sum(axis=1), alpha=0.45, color="#1abc9c")
ax6b.set_ylabel("Total cost per slot", color="#1abc9c")
ax6.set_title("Risk profile vs. resource spending")

fig.suptitle("Optimal Allocation of Space Weather Observation Resources", fontsize=17)
fig.tight_layout(rect=[0, 0, 1, 0.96])
plt.show()

Detailed Code Walkthrough

1. Problem data. The arrays w, a, and c hold $w_i$, $a_i$, and $c_i$. The L1 monitor has the highest weight and saturation rate, since in-situ measurements are the most informative, but also the highest cost. The GNSS-TEC receivers are cheap but lower in value. risk_profile builds $r_t$ as a constant floor plus two Gaussian pulses, and the scale argument plays the role of $s$ so that storm intensity can be varied later.

2. Objective and gradient. utility evaluates $U(x)$ with fully vectorized broadcasting over the $T \times I$ matrix. gradient returns

$$
\frac{\partial U}{\partial x_{t,i}} = r_t, w_i, a_i, e^{-a_i x_{t,i}},
$$

which the generic solver needs for fast convergence.

3. Generic solver. solve_slsqp flattens the $24 \times 6$ matrix into 144 variables. np.tile(c, T) produces a cost vector that matches row-major flattening, so index $t \cdot I + i$ maps to $c_i$. The budget is passed as a linear inequality constraint with its exact Jacobian, and the bounds enforce $0 \le x \le x_{\max}$. After convergence, the solution is clipped to the bounds and rescaled if it exceeds the budget by rounding error, which guarantees feasibility.

4. Fast solver. allocation_for_lambda implements

in one vectorized expression. np.outer(r, w*a/c) builds the whole matrix of $r_t w_i a_i / c_i$, and np.maximum(arg, 1e-300) protects the logarithm. solve_fast then bisects on $\log \lambda$. The interval $[10^{-9}, 10^{3}]$ spans 12 orders of magnitude, and 100 halvings shrink it far below floating-point resolution. Because total cost is decreasing in $\lambda$, the invariant “lower end infeasible, upper end feasible” is maintained. If even $\lambda = 10^{-9}$ fits within the budget, every effort is already saturated and that allocation is returned directly.

5. Comparison and diagnostics. Two intuitive baselines are computed alongside the optimum: uniform allocation and allocation proportional to risk. Both spend exactly the budget $B$, so the comparison is fair. The KKT check evaluates the marginal gain per unit cost

$$
\frac{r_t w_i a_i e^{-a_i x_{t,i}}}{c_i}
$$

on all interior pairs and confirms that it equals $\lambda^{*}$ up to floating-point error.

6. Sensitivity surface. The fast solver is called for a $16 \times 16$ grid of budgets and storm scales, 256 full optimizations in total. This is only practical because each solve takes a fraction of a millisecond.

7. Figure. All six panels are drawn into one figure and shown with a single plt.show().

Making It Fast

The generic SLSQP solver treats the problem as a black-box nonlinear program with 144 variables. Each iteration solves a dense quadratic subproblem whose cost grows roughly cubically with the number of variables. It also needs many iterations, and its runtime grows quickly as $T$ or $I$ increases.

The Lagrangian approach exploits the structure of the problem. The coupling between all $T \times I$ variables happens only through one scalar constraint, so the problem collapses to a one-dimensional root-finding problem in $\lambda$. Each bisection step is a single vectorized array operation, and the complexity is $O(T \cdot I \cdot \text{iters})$, which is linear in problem size. This is why the 256-point sensitivity surface costs almost nothing. The same idea scales to thousands of slots and hundreds of instruments.

Execution Results

==================================================================
 Space Weather Observation Resource Allocation - Results
==================================================================
 Budget B                          : 60.00
 Cost used (Lagrangian solution)   : 60.0000
 Optimal multiplier lambda*        : 0.266670
 Max KKT deviation (interior)      : 2.220e-16
------------------------------------------------------------------
 Utility  Uniform                  : 13.5547
 Utility  Risk-proportional        : 19.9097
 Utility  SLSQP (generic)          : 24.9295
 Utility  Lagrangian bisection     : 24.9295
 Gain over uniform                 : 83.92 %
 Gain over risk-proportional       : 25.21 %
------------------------------------------------------------------
 Time  SLSQP                       : 0.4205 s
 Time  Lagrangian bisection        : 0.003741 s
 Speed-up                          : 112.4 x
------------------------------------------------------------------
 Resource         Total effort    Cost share [%]
 Coronagraph             2.956             14.78
 Magnetograph            1.326              5.53
 Magnetometers           9.674             24.19
 Ionosondes              6.213             12.43
 GNSS-TEC               11.204             18.67
 L1 Monitor              3.661             24.41
==================================================================

Interpreting the Results

Console output. The cost used should match the budget $B$ almost exactly, which confirms that the budget constraint is active. The KKT deviation should be on the order of machine precision, showing that all active pairs share the same marginal value per unit cost $\lambda^{*}$. The utility list ranks the four strategies. The Lagrangian solution is the exact optimum of the convex problem, so SLSQP can match it but never beat it beyond numerical tolerance, and both must exceed the two heuristics. The timing lines quantify the benefit of exploiting problem structure. The per-resource table shows where the budget actually goes.

Panel 1 (3D allocation surface). Ridges appear at the two risk pulses, around hours 6 and 15. Along the resource axis the surface is highest for resources with a large ratio $w_i a_i / c_i$, because those cross the activation threshold first and are then pushed further by the logarithmic growth of $x^{*}$. Expensive resources appear mainly at the ridge peaks, and cheap resources also cover the quiet hours.

Panel 2 (heatmap). This is the same information from above. Bright vertical bands mark the flare and CME windows. The dark cells show pairs where the threshold is not met, meaning it is optimal to spend nothing there. This sparsity pattern is exactly the water-filling structure of the KKT solution.

Panel 3 (cost composition). The stacked areas show how the spending per hour is split among resources. The total height follows the risk profile, and the colored layers show that additional resources are “switched on” progressively as risk rises, rather than all scaling together.

Panel 4 (strategy comparison). Uniform allocation ignores when observations matter. Risk-proportional allocation ignores which resources are cost-effective and how quickly each saturates. The optimum accounts for both, which is why it clearly outperforms both baselines.

Panel 5 (3D sensitivity surface). Utility increases along both axes, but with different curvature. Along the budget axis the surface flattens, showing diminishing returns: the exponential saturation makes each extra unit of budget less valuable. Along the storm-scale axis, stronger events raise the achievable utility because more risk-weighted information is available to capture. The red marker shows the base case. The surface answers practical questions such as how much additional budget is needed to keep the same utility during a more active period.

Panel 6 (risk versus spending). The yellow curve is the risk profile and the teal bars are the total cost spent per hour. The bars follow the curve but are not proportional to it. Because of the concave utility, the optimal spending rises more gently than the risk itself, which avoids over-investing in a single hour.

Conclusion

The allocation of space weather observation resources can be written as a convex program with exponential-saturation utilities. Its KKT conditions yield a closed-form water-filling solution parametrized by a single Lagrange multiplier, which can be found by bisection in a fraction of a millisecond. Compared with uniform and risk-proportional allocation, the optimal plan concentrates effort where risk is high and where information is cheap, and the sensitivity surface shows how the optimal value responds to budget and storm intensity. The same framework extends naturally to additional instruments, finer time resolution, or richer risk forecasts.

Optimizing the Timing of Space Weather Warnings

A Worked Example in Python

Space weather forecasters face a very concrete trade-off every time a fast solar wind stream or a CME-driven shock approaches Earth: warn too early on noisy data and you get false alarms that erode public trust; wait too long to be sure and the warning becomes useless because the storm has already arrived. This article works through a simplified but physically grounded version of that problem, builds a cost-function optimization for it in Python, and visualizes the result — including a 3D cost surface.

1. The physical setup

Real-time solar wind monitors (like the ones sitting at the L1 Lagrange point, about 1.5 million km upstream of Earth) give operators a finite warning window before the same plasma reaches the magnetosphere. The nominal propagation time is

$$\tau = \frac{D_{L1}}{v_{sw}}$$

where $D_{L1} \approx 1.5\times10^{6},\text{km}$ and $v_{sw}$ is the solar wind speed. Faster wind means less warning time — a 400 km/s wind gives roughly an hour of lead time, while an 800 km/s shock front gives barely half that.

The classic trigger condition for a geomagnetic storm warning is a sustained southward turning of the interplanetary magnetic field ($B_z \ll 0$), because southward $B_z$ reconnects efficiently with Earth’s northward-pointing field and drives geomagnetic activity. In practice, raw $B_z$ measurements are noisy, so operators smooth the signal before comparing it against a threshold — but smoothing itself eats into the available lead time.

This gives us two knobs to tune:

  • Smoothing window length $w$ (minutes) — larger $w$ suppresses noise but adds detection delay.
  • Detection threshold $\theta$ (nT) — a less negative threshold triggers sooner but generates more false alarms.

2. Formulating the optimization problem

For a given $(w, \theta)$ pair, three quantities matter operationally:

  • $P_{FA}(w,\theta)$ — probability that a quiet (non-storm) period crosses the threshold and triggers a false alarm.
  • $P_{MD}(w,\theta)$ — probability that a real storm is missed (never crosses the threshold before impact).
  • $\overline{L}(w,\theta)$ — the mean effective lead time, in minutes, among correctly detected storms.

We combine these into a single operational cost function to minimize:

$$C(w,\theta) = \alpha, P_{FA}(w,\theta) ;+; \beta, P_{MD}(w,\theta) ;-; \gamma, \frac{\overline{L}(w,\theta)}{L_{max}}$$

Here $\alpha, \beta, \gamma$ are relative weights (missed storms are usually considered worse than false alarms, so $\beta > \alpha$), and $L_{max}$ is the full observation window used for normalization. The optimal warning strategy is then

To generate realistic southward-turning events for the simulation, each synthetic storm’s $B_z$ ramp follows a logistic (sigmoid) onset:

$$B_z(t) = B_{z,\min}\cdot \sigma!\left(\frac{t - t_0}{\tau_{ramp}/4}\right), \qquad \sigma(x) = \frac{1}{1+e^{-x}}$$

on top of an AR(1) noise process representing normal solar wind fluctuations.

3. Python implementation

The code below simulates 3,000 synthetic solar wind passages (1,500 quiet, 1,500 storm), performs a full grid search over smoothing windows and thresholds, computes the cost surface, and produces a combined diagnostic figure. Everything is vectorized with NumPy — the only Python-level loops are over the (small) parameter grids themselves, not over individual events, so the whole search runs in a few seconds even in the free Colab tier.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D # noqa: F401 (enables 3D projection)
from matplotlib import cm

# ---------------------------------------------------------------
# 1. Reproducibility
# ---------------------------------------------------------------
rng = np.random.default_rng(42)

# ---------------------------------------------------------------
# 2. Simulation parameters
# ---------------------------------------------------------------
N_QUIET = 1500
N_STORM = 1500
N_TOTAL = N_QUIET + N_STORM
T_OBS = 180 # observation window before Earth impact [minutes]

D_L1 = 1.5e6 # Earth-L1 distance [km]

# ---------------------------------------------------------------
# 3. Vectorized AR(1) noise generator (one Python loop over time,
# fully vectorized across all events simultaneously)
# ---------------------------------------------------------------
def generate_ar1_noise(n_events, n_steps, phi, sigma, rng):
eps = rng.normal(0.0, sigma, size=(n_events, n_steps))
noise = np.empty_like(eps)
noise[:, 0] = eps[:, 0]
for t in range(1, n_steps):
noise[:, t] = phi * noise[:, t - 1] + eps[:, t]
return noise

# ---------------------------------------------------------------
# 4. Synthetic quiet-time Bz and solar wind speed
# ---------------------------------------------------------------
Bz_quiet = generate_ar1_noise(N_QUIET, T_OBS, phi=0.9, sigma=0.9, rng=rng)
v_quiet = np.clip(rng.normal(400, 40, size=(N_QUIET, T_OBS)), 300, 550)

# ---------------------------------------------------------------
# 5. Synthetic storm events: AR(1) noise + logistic southward ramp
# ---------------------------------------------------------------
Bz_storm_noise = generate_ar1_noise(N_STORM, T_OBS, phi=0.9, sigma=0.9, rng=rng)

t_axis = np.arange(T_OBS)
onset_time = rng.uniform(20, 150, size=(N_STORM, 1)) # ramp start [min before impact]
ramp_dur = rng.uniform(15, 50, size=(N_STORM, 1)) # ramp duration [min]
Bz_min = rng.uniform(-22, -8, size=(N_STORM, 1)) # most negative Bz reached [nT]

sigmoid_arg = (t_axis[None, :] - onset_time) / (ramp_dur / 4.0)
ramp = Bz_min / (1.0 + np.exp(-sigmoid_arg))
Bz_storm = Bz_storm_noise + ramp

v_storm_base = rng.normal(420, 40, size=(N_STORM, T_OBS))
speed_gain = rng.uniform(150, 350, size=(N_STORM, 1))
v_storm = np.clip(v_storm_base + speed_gain / (1.0 + np.exp(-sigmoid_arg)), 300, 900)

Bz_all = np.vstack([Bz_quiet, Bz_storm])
v_all = np.vstack([v_quiet, v_storm])

# ---------------------------------------------------------------
# 6. Reference propagation times (background physics)
# ---------------------------------------------------------------
print("Reference L1 -> Earth propagation time (tau = D_L1 / v_sw):")
for v_ref in (400, 600, 800):
tau_min = (D_L1 / v_ref) / 60.0
print(f" v_sw = {v_ref} km/s -> tau approx {tau_min:.1f} min")

# ---------------------------------------------------------------
# 7. Causal moving average (uses only past/current samples,
# matching a real-time operational filter)
# ---------------------------------------------------------------
def causal_moving_average(x, w):
n = x.shape[1]
csum = np.cumsum(x, axis=1)
csum = np.concatenate([np.zeros((x.shape[0], 1)), csum], axis=1)
out = np.empty_like(x)
for t in range(n):
lo = max(0, t - w + 1)
out[:, t] = (csum[:, t + 1] - csum[:, lo]) / (t - lo + 1)
return out

# ---------------------------------------------------------------
# 8. Grid search over (window w, threshold theta)
# ---------------------------------------------------------------
W_GRID = np.arange(1, 25, 2) # smoothing window [min]
THETA_GRID = np.linspace(-3.0, -13.0, 21) # Bz threshold [nT]
n_w, n_th = len(W_GRID), len(THETA_GRID)

ALPHA, BETA, GAMMA = 1.0, 2.0, 1.5 # cost weights: false-alarm, missed-storm, lead-time reward

P_FA = np.zeros((n_w, n_th))
P_MD = np.zeros((n_w, n_th))
LEAD = np.zeros((n_w, n_th))

for i, w in enumerate(W_GRID):
w_int = int(w)
smoothed = causal_moving_average(Bz_all, w_int)
delay = (w_int - 1) / 2.0 # average delay introduced by the causal filter

crossed = smoothed[:, :, None] <= THETA_GRID[None, None, :] # (N, T, M)
any_cross = crossed.any(axis=1) # (N, M)
first_idx = crossed.argmax(axis=1).astype(float) # (N, M)

for j in range(n_th):
trig_quiet = any_cross[:N_QUIET, j]
trig_storm = any_cross[N_QUIET:, j]

P_FA[i, j] = trig_quiet.mean()
P_MD[i, j] = 1.0 - trig_storm.mean()

if trig_storm.any():
idx_detected = first_idx[N_QUIET:, j][trig_storm]
lead_time = np.clip((T_OBS - idx_detected) - delay, 0, None)
LEAD[i, j] = lead_time.mean()

COST = ALPHA * P_FA + BETA * P_MD - GAMMA * (LEAD / T_OBS)

i_opt, j_opt = np.unravel_index(np.argmin(COST), COST.shape)
w_opt, theta_opt = W_GRID[i_opt], THETA_GRID[j_opt]

print("\n=== Optimal warning-timing parameters ===")
print(f"optimal smoothing window w* = {w_opt} min")
print(f"optimal Bz threshold theta* = {theta_opt:.2f} nT")
print(f"minimum cost C* = {COST[i_opt, j_opt]:.4f}")
print(f"false alarm rate at optimum = {P_FA[i_opt, j_opt]*100:.2f} %")
print(f"missed detection rate at optimum = {P_MD[i_opt, j_opt]*100:.2f} %")
print(f"mean effective lead time at optimum = {LEAD[i_opt, j_opt]:.1f} min")

# ---------------------------------------------------------------
# 9. Visualization (single combined figure)
# ---------------------------------------------------------------
fig = plt.figure(figsize=(18, 11))
gs = fig.add_gridspec(2, 3, hspace=0.4, wspace=0.35)

# -- 3D cost surface --
ax1 = fig.add_subplot(gs[0, 0], projection='3d')
W_mesh, TH_mesh = np.meshgrid(W_GRID, THETA_GRID, indexing='ij')
surf = ax1.plot_surface(W_mesh, TH_mesh, COST, cmap=cm.viridis, edgecolor='none', alpha=0.9)
ax1.scatter([w_opt], [theta_opt], [COST[i_opt, j_opt]], color='red', s=60)
ax1.set_xlabel('window w [min]')
ax1.set_ylabel('threshold θ [nT]')
ax1.set_zlabel('cost C(w,θ)')
ax1.set_title('Cost surface C(w, θ)')
fig.colorbar(surf, ax=ax1, shrink=0.6, pad=0.1)

# -- top-view contour --
ax2 = fig.add_subplot(gs[0, 1])
im = ax2.contourf(THETA_GRID, W_GRID, COST, levels=20, cmap='viridis')
ax2.plot(theta_opt, w_opt, 'r*', markersize=16)
ax2.set_xlabel('threshold θ [nT]')
ax2.set_ylabel('window w [min]')
ax2.set_title('Cost contour (top view)')
fig.colorbar(im, ax=ax2)

# -- ROC-like curve at w_opt --
ax3 = fig.add_subplot(gs[0, 2])
det_rate = 1 - P_MD[i_opt, :]
ax3.plot(P_FA[i_opt, :], det_rate, 'o-', color='tab:blue')
ax3.plot([0, 1], [0, 1], 'k--', alpha=0.4)
ax3.set_xlabel('false alarm rate')
ax3.set_ylabel('detection rate')
ax3.set_title(f'ROC-like curve (w = {w_opt} min)')

# -- lead-time distribution at optimum --
w_int = int(w_opt)
smoothed_opt = causal_moving_average(Bz_all, w_int)
delay_opt = (w_int - 1) / 2.0
crossed_opt = smoothed_opt <= theta_opt
any_cross_opt = crossed_opt.any(axis=1)
first_idx_opt = crossed_opt.argmax(axis=1).astype(float)
storm_trig = any_cross_opt[N_QUIET:]
lead_all = np.clip((T_OBS - first_idx_opt[N_QUIET:][storm_trig]) - delay_opt, 0, None)

ax4 = fig.add_subplot(gs[1, 0])
ax4.hist(lead_all, bins=25, color='tab:orange', edgecolor='k', alpha=0.8)
ax4.axvline(lead_all.mean(), color='red', linestyle='--', label=f'mean = {lead_all.mean():.1f} min')
ax4.set_xlabel('effective lead time [min]')
ax4.set_ylabel('count')
ax4.set_title('Lead-time distribution at optimum')
ax4.legend()

# -- example raw vs. smoothed Bz with threshold --
ax5 = fig.add_subplot(gs[1, 1:])
q_idx, s_idx = 0, N_QUIET + 3
ax5.plot(t_axis, Bz_all[q_idx], color='gray', alpha=0.6, label='quiet Bz (raw)')
ax5.plot(t_axis, Bz_all[s_idx], color='tab:blue', alpha=0.4, label='storm Bz (raw)')
ax5.plot(t_axis, smoothed_opt[s_idx], color='tab:red', linewidth=2,
label=f'storm Bz (smoothed, w={w_int})')
ax5.axhline(theta_opt, color='k', linestyle='--', label=f'threshold θ={theta_opt:.1f} nT')
ax5.set_xlabel('time [min from window start]')
ax5.set_ylabel('Bz [nT]')
ax5.set_title('Example raw / smoothed Bz and detection threshold')
ax5.legend(loc='lower left', fontsize=8)

plt.suptitle('Space-Weather Warning Timing Optimization', fontsize=16, y=1.02)
plt.show()

4. Walking through the code

Data simulation (sections 4–5). Rather than pulling live satellite data (which would make the article non-reproducible), the script generates 1,500 “quiet” and 1,500 “storm” synthetic passages. Quiet-time $B_z$ is a pure AR(1) noise process centered on zero, mimicking the natural jitter of the interplanetary magnetic field. Storm events add a logistic southward ramp on top of the same noise process, with a randomized onset time, ramp duration, and minimum $B_z$ — so every simulated storm looks slightly different, just like real events. Solar wind speed is generated the same way, rising together with the $B_z$ ramp to represent the arrival of a fast stream or shock.

Causal smoothing (section 7). A real-time forecasting system cannot use a centered moving average, because that would require future data it doesn’t have yet. causal_moving_average uses a cumulative-sum trick to compute, at each time step, the average of only the current and preceding samples — this is what an operational monitor actually sees. The associated delay is $\approx (w-1)/2$ samples, which is subtracted from the raw lead time to get the effective lead time.

Grid search (section 8). For each smoothing window $w$ in W_GRID, the whole ensemble of 3,000 time series is smoothed once, and then compared against every threshold in THETA_GRID simultaneously using NumPy broadcasting (smoothed[:, :, None] <= THETA_GRID[None, None, :]). This produces a 3D boolean array (events × time × thresholds) in one shot, letting argmax find each event’s first threshold-crossing instant for every threshold value without any Python-level loop over individual events. Only the outer loop over $w$ (12 values) and an inner loop over $\theta$ (21 values) run in Python — 252 iterations total, each doing large vectorized array operations, which is why the whole grid search finishes in a few seconds rather than minutes. This vectorized structure is effectively the “fast” version of what would otherwise be a triple nested loop over events, time, and thresholds.

Cost evaluation. For every $(w,\theta)$ cell, the script computes the false-alarm rate among quiet events, the missed-detection rate among storm events, and the mean effective lead time among storms that were correctly detected, then combines them into $C(w,\theta)$ using the weights $\alpha=1.0$, $\beta=2.0$, $\gamma=1.5$ — reflecting the operational reality that missing a real storm is treated as roughly twice as costly as a false alarm, while longer lead time is explicitly rewarded.

Finding the optimum. np.argmin(COST) combined with np.unravel_index locates the $(w^{}, \theta^{})$ pair that minimizes the full cost surface — this is the numerical solution to the optimization problem stated in Section 2.

5. Reading the plots

  • Top-left (3D surface): the full cost landscape $C(w,\theta)$. You should see a bowl-shaped region — too small a window (noisy, high false-alarm cost) and too conservative a threshold (high missed-detection cost) both push the cost up, with a visible minimum marked in red.
  • Top-middle (contour): the same surface viewed from above, useful for reading off the optimal region at a glance.
  • Top-right (ROC-like curve): at the optimal window, this shows how detection rate trades off against false-alarm rate as the threshold varies — the classic signal-detection trade-off curve.
  • Bottom-left (histogram): the spread of effective lead times actually achieved at the optimal settings — useful for communicating “how much warning time” to stakeholders, not just an average number.
  • Bottom-right (example traces): a concrete illustration of one storm’s raw vs. smoothed $B_z$ crossing the optimal threshold, which makes the abstract optimization tangible.

Reference L1 -> Earth propagation time (tau = D_L1 / v_sw):
  v_sw = 400 km/s  ->  tau approx 62.5 min
  v_sw = 600 km/s  ->  tau approx 41.7 min
  v_sw = 800 km/s  ->  tau approx 31.2 min

=== Optimal warning-timing parameters ===
optimal smoothing window   w*      = 1 min
optimal Bz threshold       theta*  = -7.00 nT
minimum cost                C*      = -0.7656
false alarm rate at optimum         = 2.20 %
missed detection rate at optimum    = 0.13 %
mean effective lead time at optimum = 94.8 min

6. Takeaways and possible extensions

Even in this simplified model, the optimization consistently favors a short-to-moderate smoothing window paired with a moderately conservative threshold — reflecting the fundamental tension between noise suppression and warning latency. In a real operational system, this framework could be extended by:

  • Using actual DSCOVR/ACE $B_z$ and speed time series instead of synthetic data.
  • Making $\alpha, \beta, \gamma$ time-varying (e.g., raising the missed-detection penalty during solar maximum).
  • Replacing the grid search with a continuous optimizer (scipy.optimize.minimize) once the cost surface is confirmed to be reasonably smooth, as seen in the 3D plot above.
  • Incorporating multiple correlated parameters (e.g., $B_z$, speed, and density jointly) rather than a single threshold variable.

Minimizing Geomagnetically Induced Current (GIC) Risk in Power Grids

A Hands-On Python Example

Geomagnetically Induced Currents (GIC) are quasi-DC currents driven into power transmission networks during geomagnetic storms. When the Sun ejects a coronal mass ejection toward Earth, the resulting disturbance in the geomagnetic field induces an electric field at ground level. Long transmission lines act like giant antennas for this field, and the induced currents flow into the earth through transformer neutral grounding points. Because power transformers are designed for 50/60 Hz AC, this quasi-DC current can saturate the transformer core half-cycle by half-cycle, causing overheating, harmonic distortion, and — in the worst historical cases (Quebec 1989, Halloween Storm 2003) — cascading blackouts.

Utilities mitigate this risk by installing series blocking devices (capacitors or resistors) in the transformer neutral-to-ground path at selected substations. Since blocking devices are expensive, the practical engineering question is:

Given a limited budget of k blocking devices, which substations should receive them so that the worst-case GIC across every possible storm direction is minimized?

This article walks through a complete, runnable example: modeling a small transmission network with the classic Lehtinen–Pirjola (LP) method, then solving the placement problem as a robust (minimax) combinatorial optimization.


1. The Physics: Lehtinen–Pirjola Network Model

A transmission network is modeled as a resistive DC circuit. Each substation i has a transformer neutral grounding resistance $R_{g,i}$, and each transmission line $(i,j)$ has a series resistance $R_{ij}$ carrying an induced EMF $E_{ij}$ caused by the geoelectric field.

The nodal voltage equation (Kirchhoff’s Current Law at every substation) is:

$$
(\mathbf{Y} + \mathbf{Y}_e),\mathbf{V} = \mathbf{J}
$$

where $\mathbf{Y}$ is the network admittance matrix built from line conductances $g_{ij} = 1/R_{ij}$, $\mathbf{Y}e = \mathrm{diag}(1/R{g,i})$ is the grounding admittance, and $\mathbf{J}$ is the current injection vector caused by the storm’s electric field:

$$
J_i = \sum_{j \in \mathcal{N}(i)} g_{ij}, \big(\mathbf{E}\cdot \mathbf{L}_{ij}\big)
$$

Here $\mathbf{E}$ is the uniform horizontal geoelectric field vector (V/km) and $\mathbf{L}_{ij}$ is the displacement vector (in km) from node i to node j.

Once $\mathbf{V}$ is solved, the GIC flowing through each transformer’s neutral (the quantity that damages equipment) is simply:

$$
I_{g,i} = \frac{V_i}{R_{g,i}}
$$

A blocking device is modeled by setting $R_{g,i}$ to an effectively infinite value, which drives $I_{g,i}$ toward zero at that node.

The Key Trick: Linearity in Storm Direction

If the storm direction is $\theta$, then $\mathbf{E} = E_0(\cos\theta, \sin\theta)$, and since $\mathbf{J}$ is linear in $\mathbf{E}$:

$$
\mathbf{J}(\theta) = \cos\theta \cdot \mathbf{A} + \sin\theta \cdot \mathbf{B}
$$

where $\mathbf{A}$ and $\mathbf{B}$ are constant vectors (independent of $\theta$). Because the system matrix $(\mathbf{Y}+\mathbf{Y}_e)$ does not depend on $\theta$ either, the solution is also linear in $\theta$:

$$
\mathbf{V}(\theta) = \cos\theta \cdot \mathbf{V}_A + \sin\theta \cdot \mathbf{V}_B
$$

This means we never need to re-solve the linear system for every storm angle — we solve it twice (for $\mathbf{A}$ and $\mathbf{B}$) and reconstruct every angle by simple trigonometric scaling. This is the speed optimization used below.


2. The Concrete Example

We model an 8-substation regional grid (a ring topology with a central hub, which is a common real-world structure). We test every 1-degree storm direction (360 angles) and evaluate every possible placement of 2 blocking devices ($\binom{8}{2} = 28$ combinations), selecting the placement that minimizes the worst-case GIC across all storm directions and all unprotected substations.


3. Python Implementation (Google Colab Ready)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
# ============================================================
# GIC (Geomagnetically Induced Current) Risk Minimization
# Lehtinen-Pirjola network model + robust blocking-device placement
# ============================================================

import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
from itertools import combinations
import time

np.random.seed(42)

# ---------------------------------------------------------------
# 1. Define the power grid topology (8 substations)
# ---------------------------------------------------------------
n_nodes = 8

coords = np.array([
[0.0, 0.0],
[150.0, 40.0],
[280.0, 0.0],
[320.0, 160.0],
[200.0, 260.0],
[60.0, 220.0],
[-40.0, 120.0],
[140.0, 130.0], # central hub
])

edges = [
(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6), (6, 0),
(7, 0), (7, 2), (7, 4), (7, 6)
]

r_per_km = 0.045 # ohm/km, typical EHV line resistance

def line_length(i, j):
return np.linalg.norm(coords[j] - coords[i])

R_line = {e: r_per_km * line_length(*e) for e in edges}

# ---------------------------------------------------------------
# 2. Baseline transformer neutral grounding resistances
# ---------------------------------------------------------------
R_ground_base = np.full(n_nodes, 0.6) # ohm, typical neutral grounding
R_block = 1.0e6 # ohm, effective open circuit

E0 = 8.0 # V/km, severe geomagnetic storm field magnitude
n_angles = 360
thetas = np.linspace(0, 2*np.pi, n_angles, endpoint=False)

# ---------------------------------------------------------------
# 3. Build network admittance matrix Y (independent of storm angle)
# ---------------------------------------------------------------
Y = np.zeros((n_nodes, n_nodes))
Lx = np.zeros(n_nodes)
Ly = np.zeros(n_nodes)

for (i, j) in edges:
g = 1.0 / R_line[(i, j)]
Y[i, i] += g
Y[j, j] += g
Y[i, j] -= g
Y[j, i] -= g

dx = coords[j, 0] - coords[i, 0]
dy = coords[j, 1] - coords[i, 1]

Lx[i] += g * dx; Ly[i] += g * dy
Lx[j] -= g * dx; Ly[j] -= g * dy

A = E0 * Lx
B = E0 * Ly

# ---------------------------------------------------------------
# 4. Fast solver exploiting linearity in theta
# V(theta) = cos(theta)*VA + sin(theta)*VB
# -> only 2 linear solves per candidate placement (not n_angles!)
# ---------------------------------------------------------------
def solve_gic(blocked_nodes):
Rg = R_ground_base.copy()
for b in blocked_nodes:
Rg[b] = R_block
Yg = np.diag(1.0 / Rg)
M = Y + Yg

VA, VB = np.linalg.solve(M, np.column_stack([A, B])).T
V_all = np.outer(np.cos(thetas), VA) + np.outer(np.sin(thetas), VB)
GIC_all = V_all * (1.0 / Rg)
return GIC_all

# ---------------------------------------------------------------
# 5. Robust (worst-case) placement search
# ---------------------------------------------------------------
k_devices = 2
candidates = list(combinations(range(n_nodes), k_devices))

t0 = time.time()
baseline_GIC = solve_gic([])
worst_baseline = np.max(np.abs(baseline_GIC))

best_score = np.inf
best_combo = None
best_GIC = None

for combo in candidates:
GIC_all = solve_gic(combo)
score = np.max(np.abs(GIC_all))
if score < best_score:
best_score = score
best_combo = combo
best_GIC = GIC_all

elapsed = time.time() - t0

print(f"Number of candidate placements evaluated : {len(candidates)}")
print(f"Angular resolution : {n_angles} steps")
print(f"Total computation time : {elapsed:.4f} s")
print(f"Baseline worst-case GIC (no mitigation) : {worst_baseline:.2f} A")
print(f"Best blocking-device placement (nodes) : {best_combo}")
print(f"Worst-case GIC after mitigation : {best_score:.2f} A")
print(f"Risk reduction : {(1 - best_score/worst_baseline)*100:.1f} %")

# ---------------------------------------------------------------
# 6. Visualization (single combined figure)
# ---------------------------------------------------------------
fig = plt.figure(figsize=(16, 12))

ax1 = fig.add_subplot(2, 2, 1, projection='3d')
Theta_deg = np.degrees(thetas)
Node_idx = np.arange(n_nodes)
TT, NN = np.meshgrid(Theta_deg, Node_idx, indexing='ij')
ax1.plot_surface(TT, NN, np.abs(baseline_GIC), cmap='inferno', edgecolor='none')
ax1.set_xlabel('Storm direction θ (deg)')
ax1.set_ylabel('Node index')
ax1.set_zlabel('|GIC| (A)')
ax1.set_title('Baseline: |GIC| vs storm direction (no mitigation)')

ax2 = fig.add_subplot(2, 2, 2, projection='3d')
ax2.plot_surface(TT, NN, np.abs(best_GIC), cmap='viridis', edgecolor='none')
ax2.set_xlabel('Storm direction θ (deg)')
ax2.set_ylabel('Node index')
ax2.set_zlabel('|GIC| (A)')
ax2.set_title(f'After mitigation at nodes {best_combo}')

ax3 = fig.add_subplot(2, 2, 3)
ax3.plot(Theta_deg, np.max(np.abs(baseline_GIC), axis=1), label='Baseline (no mitigation)', color='crimson')
ax3.plot(Theta_deg, np.max(np.abs(best_GIC), axis=1), label=f'Mitigated {best_combo}', color='seagreen')
ax3.axhline(best_score, color='seagreen', linestyle='--', linewidth=1, alpha=0.7)
ax3.set_xlabel('Storm direction θ (deg)')
ax3.set_ylabel('Max |GIC| across grid (A)')
ax3.set_title('Worst-node GIC vs storm direction')
ax3.legend()
ax3.grid(alpha=0.3)

ax4 = fig.add_subplot(2, 2, 4)
worst_case_vals = np.max(np.abs(best_GIC), axis=0)
for (i, j) in edges:
ax4.plot([coords[i,0], coords[j,0]], [coords[i,1], coords[j,1]], color='gray', zorder=1, linewidth=1.2)
sizes = 300 + 40 * worst_case_vals
colors = ['red' if idx in best_combo else 'steelblue' for idx in range(n_nodes)]
ax4.scatter(coords[:,0], coords[:,1], s=sizes, c=colors, edgecolor='black', zorder=2)
for idx in range(n_nodes):
ax4.annotate(f"{idx}", (coords[idx,0], coords[idx,1]), ha='center', va='center', fontsize=9, color='white', zorder=3)
ax4.set_xlabel('X (km)')
ax4.set_ylabel('Y (km)')
ax4.set_title('Grid topology (red = blocking device installed)')
ax4.set_aspect('equal')

plt.tight_layout()
plt.show()

4. Code Walkthrough

Section 1 — Topology: Eight substations are placed on a 2D plane (in kilometers) and connected as a ring with a central hub, which mirrors how many regional transmission networks are actually built for redundancy. Line resistance is computed from geographic distance using a realistic resistance-per-kilometer figure for extra-high-voltage lines.

Section 2 — Grounding parameters: Every transformer neutral starts with a small, realistic grounding resistance (0.6 Ω). A “blocked” node is simulated by replacing that resistance with an enormous value (1 MΩ), which forces near-zero current through that neutral — exactly what a physical blocking capacitor does for DC/quasi-DC current.

Section 3 — Admittance matrix construction: This builds the graph Laplacian-style matrix $\mathbf{Y}$ from line conductances, plus two auxiliary vectors $\mathbf{A}$ and $\mathbf{B}$ that capture how much current each node “collects” per unit of eastward or northward geoelectric field, respectively. These vectors are computed once and reused for every storm direction.

Section 4 — The fast solver: Instead of solving the linear system once per storm angle, we solve it exactly twice (np.linalg.solve with a 2-column right-hand side) to get $\mathbf{V}_A$ and $\mathbf{V}_B$. Every angle’s voltage vector is then reconstructed instantly via cos(θ)·V_A + sin(θ)·V_B, vectorized across all 360 angles simultaneously with np.outer.

Section 5 — Robust placement search: For each of the 28 possible 2-node placements, the code computes the worst-case (maximum absolute) GIC across all substations and all 360 storm directions, then keeps the placement with the smallest worst case — a classic minimax robust design.

Section 6 — Visualization: Builds one combined figure with four panels described below.


5. Why This Runs Fast (Speed Optimization Explained)

A naive implementation would loop over all 360 storm angles inside the loop over 28 placements, rebuilding and re-solving an 8×8 linear system 10,080 times. Because the system matrix $(\mathbf{Y}+\mathbf{Y}_e)$ does not change with storm angle — only the right-hand side does — we exploit the linearity shown in Section 1 of the physics discussion to solve the system only twice per placement (56 solves total instead of 10,080), then reconstruct all 360 angles algebraically. For this small 8-node network the difference is invisible in wall-clock time, but the technique matters enormously at realistic scale: a continent-scale grid model with hundreds of substations and thousands of candidate placements would make the naive approach computationally prohibitive, while this vectorized approach scales linearly with the number of placements rather than the product of placements and angular resolution.


6. Execution Results

Number of candidate placements evaluated : 28
Angular resolution                        : 360 steps
Total computation time                    : 0.0177 s
Baseline worst-case GIC (no mitigation)    : 346.79 A
Best blocking-device placement (nodes)     : (2, 7)
Worst-case GIC after mitigation            : 323.44 A
Risk reduction                             : 6.7 %


7. Interpreting the Graphs

Top-left (3D surface, baseline): This shows the magnitude of GIC at every substation (y-axis) as the storm direction sweeps through 360 degrees (x-axis), with no mitigation installed. The tall ridges reveal which substations are most exposed, and at which storm orientation — typically substations aligned with the dominant line direction see the highest peaks, since the induced EMF is strongest when the geoelectric field is parallel to the transmission line.

Top-right (3D surface, mitigated): The same surface after installing blocking devices at the optimal two substations. The peaks at the protected nodes are flattened to near zero, and — importantly — the peaks at the remaining nodes are also reduced, because blocking a node changes the current distribution throughout the whole network, not just at that node.

Bottom-left (worst-case curve): This directly compares, for every storm direction, the single highest GIC value anywhere in the network, before (red) and after (green) mitigation. The dashed horizontal line marks the guaranteed worst-case ceiling achieved by the optimal placement — this is the number a grid operator would actually report as “maximum expected GIC under any storm orientation.”

Bottom-right (network map): A geographic view of the grid, with node size proportional to its worst-case GIC after mitigation and the two selected blocking-device substations highlighted in red. This panel is the most operationally useful output: it tells an engineer exactly where to schedule equipment installation.


8. Takeaways for Real-World Grid Operators

This example uses a small, illustrative network, but the underlying method — the Lehtinen–Pirjola model combined with a linearity-exploiting solver and a robust minimax search over storm direction — is exactly the kind of approach used in academic and utility-industry studies of GIC mitigation. For larger real-world grids (hundreds of substations), exhaustive combination search becomes impractical once the number of devices $k$ grows, and the same linear-algebra speedup would typically be paired with a greedy or integer-programming heuristic rather than brute-force enumeration. Nonetheless, the physics, the math, and the vectorization trick shown here remain the foundation of that larger-scale analysis.

Optimizing an Ionospheric TEC Estimation Model

A Hands-On Example in Python

Introduction

Total Electron Content (TEC) — the number of free electrons integrated along a signal path through the ionosphere — is one of the most important quantities in GNSS positioning, space weather monitoring, and radio propagation studies. A single GPS/GNSS receiver only measures Slant TEC (STEC), the TEC along the actual line-of-sight to a satellite, which is contaminated by satellite/receiver hardware biases and depends heavily on the elevation angle of the signal. To recover a physically meaningful Vertical TEC (VTEC) map of the ionosphere, we need to solve an inverse (optimization) problem that jointly estimates:

  • the spatial and temporal shape of the ionosphere,
  • unknown receiver and satellite hardware biases,
  • and even the effective height of the ionospheric “thin shell” itself.

This is exactly the kind of nonlinear least-squares optimization problem that shows up in real-world GNSS ionospheric modeling (as used by IGS analysis centers such as CODE, JPL, and ESA). In this post, we’ll build a compact but realistic synthetic example, solve it with scipy.optimize.least_squares, and visualize the results — including a 3D reconstruction of the ionosphere.

Mathematical Formulation

1. Background VTEC surface

We model the “quiet-time” background ionosphere over a local region as a quadratic surface in local coordinates $x$ (longitude-like) and $y$ (latitude-like):

$$
V_{bg}(x,y) = c_0 + c_1x + c_2y + c_3x^2 + c_4y^2 + c_5xy
$$

2. Diurnal variation

The ionosphere is driven by solar illumination, producing a strong diurnal cycle that peaks in the early afternoon:

$$
D(t;A) = 1 + A\sin!\left(\frac{2\pi(t-6)}{24}\right)
$$

3. Traveling Ionospheric Disturbance (TID)

To make the problem realistic and visually interesting, we add a traveling wave-like electron density enhancement (a simplified TID) that drifts across the region over time:

$$
B(x,y,t;A_b,v) = A_b\exp!\left[-\frac{(x-x_0(t))^2}{2\sigma_x^2}-\frac{(y-y_0(t))^2}{2\sigma_y^2}\right], \qquad x_0(t) = x_{00} + vt
$$

4. Full VTEC model

$$
V(x,y,t) = V_{bg}(x,y),D(t;A) + B(x,y,t;A_b,v)
$$

5. Single-layer mapping function

STEC and VTEC are related through the classic thin-shell mapping function, parameterized by the effective ionospheric shell height $H$:

$$
\text{STEC} = \text{VTEC}\cdot M(el,H), \qquad
M(el,H) = \frac{1}{\sqrt{1-\left(\dfrac{R_E}{R_E+H}\cos el\right)^2}}
$$

6. Observation equation

Each slant TEC observation from receiver $r$ to satellite $s$ also contains unknown hardware biases:

7. The optimization problem

All unknowns — the surface coefficients, diurnal amplitude, TID amplitude/speed, shell height, and all biases — are stacked into a single parameter vector $\theta$, estimated by nonlinear least squares:

$$
\hat{\theta} = \arg\min_{\theta}\sum_{i=1}^{N}\Big[\text{STEC}^{model}_i(\theta) - \text{STEC}^{obs}_i\Big]^2
$$

Because one satellite bias and one background level are not jointly identifiable (a classic rank-deficiency problem in GNSS bias estimation), we fix one satellite’s bias as the reference datum ($b_{s_0}=0$).

Python Implementation (Google Colab)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D # noqa: F401 (enables 3D projection)
from scipy.optimize import least_squares
import time

np.random.seed(42)

# ----------------------------------------------------------
# 1. Simulation settings
# ----------------------------------------------------------
Re = 6371.0 # Earth radius [km]
H_true = 350.0 # true ionospheric single-layer height [km]

n_stations = 8
n_sats = 6
n_epochs = 24 # hourly epochs over one day (t = 0..23 h)

x_range = (-5.0, 5.0) # local "longitude-like" coordinate [deg]
y_range = (-5.0, 5.0) # local "latitude-like" coordinate [deg]

# ----------------------------------------------------------
# 2. True VTEC field (ground truth, unknown to the estimator)
# ----------------------------------------------------------
c_true = np.array([20.0, 1.2, -0.8, 0.15, 0.10, -0.05])
diurnal_amp_true = 0.65
bump_amp_true = 12.0
bump_v_true = 0.35 # deg/hour, drift speed of the disturbance

def background_surface(x, y, c):
c0, c1, c2, c3, c4, c5 = c
return c0 + c1*x + c2*y + c3*x**2 + c4*y**2 + c5*x*y

def diurnal_factor(t, amp):
return 1.0 + amp*np.sin(2*np.pi*(t - 6.0)/24.0)

def tid_bump(x, y, t, amp, v):
x0 = -4.0 + v*t
y0 = 0.5*np.sin(2*np.pi*t/24.0)
sx, sy = 1.2, 1.5
return amp*np.exp(-(((x-x0)**2)/(2*sx**2) + ((y-y0)**2)/(2*sy**2)))

def true_vtec(x, y, t):
return background_surface(x, y, c_true) * diurnal_factor(t, diurnal_amp_true) \
+ tid_bump(x, y, t, bump_amp_true, bump_v_true)

# ----------------------------------------------------------
# 3. Mapping function (single layer model)
# ----------------------------------------------------------
def mapping_function(elev_deg, H):
elev = np.deg2rad(elev_deg)
sin_z = (Re/(Re+H))*np.cos(elev)
sin_z = np.clip(sin_z, -0.9999, 0.9999)
return 1.0/np.sqrt(1.0 - sin_z**2)

# ----------------------------------------------------------
# 4. Generate synthetic slant TEC observations
# ----------------------------------------------------------
b_r_true = np.random.uniform(-3.0, 3.0, n_stations)
b_s_true = np.random.uniform(-2.0, 2.0, n_sats)
b_s_true[0] = 0.0 # reference satellite (datum fix)

obs_list = []
for st in range(n_stations):
for sv in range(n_sats):
for t in range(n_epochs):
elev = np.random.uniform(15.0, 85.0)
x_ipp = np.random.uniform(*x_range)
y_ipp = np.random.uniform(*y_range)
vtec = true_vtec(x_ipp, y_ipp, t)
mf = mapping_function(elev, H_true)
noise = np.random.normal(0, 0.3)
stec = vtec*mf + b_r_true[st] - b_s_true[sv] + noise
obs_list.append((st, sv, t, x_ipp, y_ipp, elev, stec))

obs = np.array(obs_list, dtype=float)
station_id = obs[:,0].astype(int)
sat_id = obs[:,1].astype(int)
epoch_t = obs[:,2]
x_ipp = obs[:,3]
y_ipp = obs[:,4]
elev_ipp = obs[:,5]
stec_obs = obs[:,6]

n_obs = len(stec_obs)
print(f"Generated {n_obs} synthetic slant-TEC observations "
f"from {n_stations} stations and {n_sats} satellites over {n_epochs} epochs.")

# ----------------------------------------------------------
# 5. Parameter layout & residual function (vectorized)
# ----------------------------------------------------------
n_c = 6
idx_c = slice(0, n_c)
idx_damp = n_c
idx_bamp = n_c+1
idx_bv = n_c+2
idx_H = n_c+3
idx_br = slice(n_c+4, n_c+4+n_stations)
idx_bs = slice(n_c+4+n_stations, n_c+4+n_stations+(n_sats-1))

n_params = n_c + 4 + n_stations + (n_sats-1)

def unpack(theta):
c = theta[idx_c]
damp = theta[idx_damp]
bamp = theta[idx_bamp]
bv = theta[idx_bv]
H = theta[idx_H]
b_r = theta[idx_br]
b_s = np.zeros(n_sats)
b_s[1:] = theta[idx_bs]
return c, damp, bamp, bv, H, b_r, b_s

def residuals(theta):
c, damp, bamp, bv, H, b_r, b_s = unpack(theta)
vtec_model = background_surface(x_ipp, y_ipp, c) * diurnal_factor(epoch_t, damp) \
+ tid_bump(x_ipp, y_ipp, epoch_t, bamp, bv)
mf = mapping_function(elev_ipp, H)
stec_model = vtec_model*mf + b_r[station_id] - b_s[sat_id]
return stec_model - stec_obs

def residuals_naive(theta):
"""Same computation as `residuals`, but with an explicit Python loop
(kept only to benchmark the speed gain from vectorization)."""
c, damp, bamp, bv, H, b_r, b_s = unpack(theta)
res = np.empty(n_obs)
for i in range(n_obs):
vtec_i = background_surface(x_ipp[i], y_ipp[i], c)*diurnal_factor(epoch_t[i], damp) \
+ tid_bump(x_ipp[i], y_ipp[i], epoch_t[i], bamp, bv)
mf_i = mapping_function(elev_ipp[i], H)
stec_i = vtec_i*mf_i + b_r[station_id[i]] - b_s[sat_id[i]]
res[i] = stec_i - stec_obs[i]
return res

theta0_test = np.zeros(n_params)
theta0_test[idx_H] = 350.0

tb0 = time.time(); _ = residuals(theta0_test); t_vec = time.time()-tb0
tb0 = time.time(); _ = residuals_naive(theta0_test); t_naive = time.time()-tb0
print(f"Vectorized residual evaluation: {t_vec*1000:.3f} ms")
print(f"Naive loop residual evaluation: {t_naive*1000:.3f} ms")
print(f"Speed-up factor: {t_naive/max(t_vec,1e-9):.1f}x")

# ----------------------------------------------------------
# 6. Nonlinear least-squares optimization
# ----------------------------------------------------------
theta0 = np.zeros(n_params)
theta0[0] = 15.0
theta0[idx_damp] = 0.3
theta0[idx_bamp] = 5.0
theta0[idx_bv] = 0.3
theta0[idx_H] = 300.0

lower = -np.inf*np.ones(n_params)
upper = np.inf*np.ones(n_params)
lower[idx_H] = 200.0
upper[idx_H] = 600.0
lower[idx_bamp] = 0.0
lower[idx_damp] = -1.0
upper[idx_damp] = 1.0

t_start = time.time()
result = least_squares(residuals, theta0, bounds=(lower, upper), method='trf')
t_end = time.time()

theta_hat = result.x
c_hat, damp_hat, bamp_hat, bv_hat, H_hat, br_hat, bs_hat = unpack(theta_hat)

print(f"Optimization finished in {t_end-t_start:.3f} s, cost={result.cost:.4f}, nfev={result.nfev}")
print(f"Estimated shell height H = {H_hat:.2f} km (true: {H_true} km)")
print(f"Estimated diurnal amplitude = {damp_hat:.3f} (true: {diurnal_amp_true})")
print(f"Estimated bump amplitude = {bamp_hat:.3f} (true: {bump_amp_true})")
print(f"Estimated bump drift speed = {bv_hat:.3f} deg/h (true: {bump_v_true})")

resid_final = residuals(theta_hat)
rmse = np.sqrt(np.mean(resid_final**2))
print(f"Final RMSE of slant TEC residuals: {rmse:.3f} TECU")

# ----------------------------------------------------------
# 7. Build true vs estimated VTEC maps for visualization
# ----------------------------------------------------------
t_snapshot = 14.0
nx, ny = 60, 60
xg = np.linspace(*x_range, nx)
yg = np.linspace(*y_range, ny)
XG, YG = np.meshgrid(xg, yg)

VTEC_true_map = background_surface(XG, YG, c_true)*diurnal_factor(t_snapshot, diurnal_amp_true) \
+ tid_bump(XG, YG, t_snapshot, bump_amp_true, bump_v_true)

VTEC_est_map = background_surface(XG, YG, c_hat)*diurnal_factor(t_snapshot, damp_hat) \
+ tid_bump(XG, YG, t_snapshot, bamp_hat, bv_hat)

VTEC_diff_map = VTEC_est_map - VTEC_true_map
map_rmse = np.sqrt(np.mean(VTEC_diff_map**2))
print(f"VTEC map RMSE at t={t_snapshot}h : {map_rmse:.3f} TECU")

# ----------------------------------------------------------
# 8. Visualization (single consolidated figure)
# ----------------------------------------------------------
fig = plt.figure(figsize=(20, 16))
gs = fig.add_gridspec(3, 3, hspace=0.4, wspace=0.35)

ax1 = fig.add_subplot(gs[0, 0], projection='3d')
surf1 = ax1.plot_surface(XG, YG, VTEC_true_map, cmap='viridis', linewidth=0, antialiased=True)
ax1.set_title(f'True VTEC map (t = {t_snapshot:.0f}h)')
ax1.set_xlabel('x [deg]'); ax1.set_ylabel('y [deg]'); ax1.set_zlabel('VTEC [TECU]')
fig.colorbar(surf1, ax=ax1, shrink=0.6, pad=0.1)

ax2 = fig.add_subplot(gs[0, 1], projection='3d')
surf2 = ax2.plot_surface(XG, YG, VTEC_est_map, cmap='plasma', linewidth=0, antialiased=True)
ax2.set_title(f'Estimated VTEC map (t = {t_snapshot:.0f}h)')
ax2.set_xlabel('x [deg]'); ax2.set_ylabel('y [deg]'); ax2.set_zlabel('VTEC [TECU]')
fig.colorbar(surf2, ax=ax2, shrink=0.6, pad=0.1)

ax3 = fig.add_subplot(gs[0, 2])
cf = ax3.contourf(XG, YG, VTEC_diff_map, levels=20, cmap='RdBu_r')
ax3.set_title(f'Estimation error map (RMSE={map_rmse:.2f} TECU)')
ax3.set_xlabel('x [deg]'); ax3.set_ylabel('y [deg]')
fig.colorbar(cf, ax=ax3)

ax4 = fig.add_subplot(gs[1, 0])
stec_model_final = stec_obs + resid_final
ax4.scatter(stec_obs, stec_model_final, s=6, alpha=0.4, color='teal')
lims = [min(stec_obs.min(), stec_model_final.min()), max(stec_obs.max(), stec_model_final.max())]
ax4.plot(lims, lims, 'r--', linewidth=1)
ax4.set_xlabel('Observed STEC [TECU]'); ax4.set_ylabel('Modeled STEC [TECU]')
ax4.set_title('Fit quality: observed vs modeled STEC')

ax5 = fig.add_subplot(gs[1, 1])
ax5.hist(resid_final, bins=30, color='slategray', edgecolor='white')
ax5.set_xlabel('Residual [TECU]'); ax5.set_ylabel('Count')
ax5.set_title(f'Residual distribution (RMSE={rmse:.3f} TECU)')

ax6 = fig.add_subplot(gs[1, 2])
t_arr = np.linspace(0, 24, 200)
ax6.plot(t_arr, diurnal_factor(t_arr, diurnal_amp_true), label='True', linewidth=2)
ax6.plot(t_arr, diurnal_factor(t_arr, damp_hat), '--', label='Estimated', linewidth=2)
ax6.set_xlabel('Local time [h]'); ax6.set_ylabel('Diurnal factor')
ax6.set_title('Diurnal variation model')
ax6.legend()

ax7 = fig.add_subplot(gs[2, 0])
xs = np.arange(n_stations)
w = 0.35
ax7.bar(xs - w/2, b_r_true, width=w, label='True', color='steelblue')
ax7.bar(xs + w/2, br_hat, width=w, label='Estimated', color='orange')
ax7.set_xlabel('Station ID'); ax7.set_ylabel('Bias [TECU]')
ax7.set_title('Receiver bias recovery')
ax7.legend()

ax8 = fig.add_subplot(gs[2, 1])
xs2 = np.arange(n_sats)
ax8.bar(xs2 - w/2, b_s_true, width=w, label='True', color='steelblue')
ax8.bar(xs2 + w/2, bs_hat, width=w, label='Estimated', color='orange')
ax8.set_xlabel('Satellite ID'); ax8.set_ylabel('Bias [TECU]')
ax8.set_title('Satellite bias recovery (Sat0 = reference)')
ax8.legend()

ax9 = fig.add_subplot(gs[2, 2])
ax9.axis('off')
summary_text = (
f"Optimization summary\n"
f"---------------------------\n"
f"n_obs : {n_obs}\n"
f"n_params : {n_params}\n"
f"nfev : {result.nfev}\n"
f"Final cost : {result.cost:.3f}\n"
f"STEC RMSE : {rmse:.3f} TECU\n"
f"VTEC map RMSE : {map_rmse:.3f} TECU\n\n"
f"H_true={H_true:.1f} H_hat={H_hat:.1f} km\n"
f"amp_true={diurnal_amp_true:.2f} amp_hat={damp_hat:.2f}\n"
f"bump_true={bump_amp_true:.1f} bump_hat={bamp_hat:.1f}\n"
f"v_true={bump_v_true:.2f} v_hat={bv_hat:.2f}"
)
ax9.text(0.02, 0.98, summary_text, transform=ax9.transAxes, va='top', ha='left',
family='monospace', fontsize=10)

plt.suptitle('Ionospheric TEC Estimation via Nonlinear Least-Squares Optimization', fontsize=16, y=0.995)
plt.show()

Code Walkthrough

Section 1–2 (simulation setup and true VTEC field). We define the Earth radius, the “true” ionospheric shell height (350 km, a typical F2-layer peak height), and a 10°×10° local region. The true_vtec() function is our ground truth generator: a smooth quadratic background surface, modulated by a diurnal factor that peaks around 14:00 local time, plus a moving Gaussian “bump” that represents a traveling ionospheric disturbance drifting eastward at 0.35°/hour. This ground truth is what our optimizer will try to recover — it is never given to the estimator directly.

Section 3 (mapping function). mapping_function() implements the standard single-layer thin-shell obliquity factor. Low elevation angles produce large mapping-function values (STEC much larger than VTEC), which is why elevation-dependent weighting matters so much in real ionospheric estimation.

Section 4 (synthetic observations). We simulate 8 ground stations tracking 6 satellites over 24 hourly epochs, each observation carrying a random ionospheric pierce-point (IPP) location, a random elevation angle, and small Gaussian noise (σ = 0.3 TECU). Random receiver and satellite hardware biases are injected, with one satellite fixed as the zero-bias reference to keep the system identifiable — mirroring how real GNSS bias estimation handles rank deficiency.

Section 5 (vectorized residual function). This is the computational core of the optimization. scipy.optimize.least_squares calls the residual function dozens of times per run, so it must be fast. The residuals() function uses pure NumPy broadcasting and fancy indexing (b_r[station_id], b_s[sat_id]) to evaluate all 1,152 observations at once, with no Python-level loop. For comparison, residuals_naive() performs an equivalent per-observation loop. In practice the vectorized version is roughly 30–45× faster than the loop-based version — the printed benchmark shows this difference directly, which matters a lot once you scale up to realistic GNSS networks with tens of thousands of observations.

Section 6 (optimization). All 23 unknowns (6 surface coefficients, diurnal amplitude, TID amplitude and speed, shell height, 8 receiver biases, 5 satellite biases) are packed into one vector theta and estimated jointly with the Trust Region Reflective (trf) algorithm, which supports bound constraints — here used to keep the shell height physically plausible (200–600 km) and the diurnal/TID amplitudes sign-consistent. Notice how the initial guess for the TID drift speed (theta0[idx_bv] = 0.3) is chosen close to a physically reasonable value: nonlinear least squares is sensitive to the starting point because the TID term is a non-convex Gaussian bump, and a poor initial guess can trap the optimizer in a local minimum that mostly “ignores” the disturbance. This is a genuine and important lesson from real ionospheric TID inversion — good priors or multi-start strategies matter.

Section 7–8 (VTEC maps and visualization). We evaluate the true and estimated VTEC models on a 60×60 spatial grid at a single snapshot time (14:00, near the diurnal peak, when the TID bump is also easiest to see) and build one consolidated figure with nine panels.

Understanding the Visualization

The final figure combines everything into a single 3×3 grid:

  • Top-left / top-middle (3D surfaces): the true and estimated VTEC fields at 14:00 local time, letting you visually compare the recovered ionospheric shape — including the traveling disturbance bump — against ground truth.
  • Top-right (error contour): the spatial difference between estimated and true VTEC, showing where the reconstruction is most and least accurate.
  • Middle-left (scatter fit): modeled vs. observed slant TEC for every observation; points hugging the red diagonal indicate a good fit.
  • Middle-center (residual histogram): the distribution of fit residuals, which should look roughly Gaussian and centered near zero if the model and noise assumptions are consistent.
  • Middle-right (diurnal curve): true vs. estimated diurnal variation curves over a full day.
  • Bottom-left / bottom-middle (bias bars): recovered receiver and satellite hardware biases compared to their true (simulated) values — a direct check on whether the bias-separation part of the inversion worked.
  • Bottom-right (scorecard): a compact text summary of all key numbers — RMSE, optimizer iterations, and true-vs-estimated parameter values — for quick reference.


Generated 1152 synthetic slant-TEC observations from 8 stations and 6 satellites over 24 epochs.
Vectorized residual evaluation: 0.480 ms
Naive loop residual evaluation: 27.279 ms
Speed-up factor: 56.8x
Optimization finished in 0.090 s, cost=47.5828, nfev=8
Estimated shell height H = 348.81 km (true: 350.0 km)
Estimated diurnal amplitude = 0.651 (true: 0.65)
Estimated bump amplitude = 11.992 (true: 12.0)
Estimated bump drift speed = 0.349 deg/h (true: 0.35)
Final RMSE of slant TEC residuals: 0.287 TECU
VTEC map RMSE at t=14.0h : 0.059 TECU

Discussion

With a reasonable initial guess, this nonlinear least-squares approach recovers the shell height, the diurnal amplitude, the traveling-disturbance amplitude and speed, and every receiver/satellite bias to within a fraction of a TECU of their true simulated values, while the final slant-TEC residual RMSE stays close to the injected noise level — a strong indicator that the model is neither over- nor under-fitting the data. The most instructive part of this exercise, though, is what happens when the optimizer is given a poor starting guess for the traveling-disturbance parameters: because that term is a non-convex Gaussian in space and time, least_squares can converge to a local minimum where the disturbance is essentially averaged away into the background surface, even though the overall cost still looks “reasonably small.” This mirrors a real, well-known difficulty in operational ionospheric monitoring — smooth, large-scale background TEC is comparatively easy to estimate from sparse ground networks, but transient, localized structures like traveling ionospheric disturbances require denser spatial sampling and often benefit from better-informed initial guesses, regularization, or multi-start/global optimization strategies to be reliably recovered.

Minimizing GNSS Positioning Error in Python

From Least Squares to Fault Exclusion

A GNSS receiver never observes its position directly. All it measures are pseudoranges, which are distances to satellites contaminated by clock error and noise. How well we can turn those measurements into a position depends on three things: the estimator, how we treat measurements of different quality, and whether we notice when one measurement is simply wrong.

In this article we build a concrete example and attack it step by step. The scenario has nine satellites and elevation-dependent noise, and one low-elevation satellite is corrupted by a 30 m multipath-like fault. We compare three estimators over 20,000 Monte Carlo trials:

  1. OLS: ordinary least squares
  2. WLS: weighted least squares
  3. WLS + FDE: weighted least squares with fault detection and exclusion

1. Problem Setup

Work in a local East-North-Up (ENU) frame with the true receiver position at the origin. For satellite $i$ at position $\mathbf{s}_i$, the pseudorange is

$$
\rho_i = \lVert \mathbf{s}_i - \mathbf{x} \rVert + b + \varepsilon_i + \delta_i ,
$$

where $\mathbf{x}=(E,N,U)^\top$ is the receiver position, $b = c,\delta t_r$ is the receiver clock bias expressed in meters, $\varepsilon_i$ is random noise, and $\delta_i$ is a fault bias that is nonzero for only one satellite.

The unknown vector is $\mathbf{p} = (E, N, U, b)^\top$. Linearizing around an approximate position gives

$$
\Delta\boldsymbol{\rho} = H,\Delta\mathbf{p} + \boldsymbol{\varepsilon}, \qquad
H = \begin{bmatrix} -\mathbf{u}_1^\top & 1 \ \vdots & \vdots \ -\mathbf{u}_n^\top & 1 \end{bmatrix}, \qquad
\mathbf{u}_i = \frac{\mathbf{s}_i - \mathbf{x}}{\lVert \mathbf{s}_i - \mathbf{x} \rVert}.
$$

Low-elevation signals travel through more atmosphere and are more prone to multipath, so we model the noise as elevation dependent:

$$
\sigma_i^2 = \sigma_a^2 + \frac{\sigma_b^2}{\sin^2\theta_i}, \qquad \sigma_a = 0.3\ \text{m},\quad \sigma_b = 1.5\ \text{m},
$$

where $\theta_i$ is the elevation angle of satellite $i$.

2. Three Estimators

OLS treats every satellite equally:

$$
\Delta\hat{\mathbf{p}}_{\text{OLS}} = (H^\top H)^{-1} H^\top \Delta\boldsymbol{\rho}.
$$

WLS gives noisy satellites less influence through $W=\mathrm{diag}(1/\sigma_1^2,\dots,1/\sigma_n^2)$:

The geometry quality is summarized by the dilution of precision. With $Q=(H^\top H)^{-1}$:

$$
\mathrm{GDOP}=\sqrt{\operatorname{tr}Q},\quad
\mathrm{PDOP}=\sqrt{Q_{11}+Q_{22}+Q_{33}},\quad
\mathrm{HDOP}=\sqrt{Q_{11}+Q_{22}},\quad
\mathrm{VDOP}=\sqrt{Q_{33}}.
$$

WLS + FDE adds a consistency check. With $K=(H^\top W H)^{-1}H^\top W$, the weighted residual sum of squares

$$
T = \lVert W^{1/2}(I - HK),\Delta\boldsymbol{\rho} \rVert^2
$$

follows a $\chi^2_{n-4}$ distribution when all measurements are healthy. If $T$ exceeds the threshold $\chi^2_{n-4,,1-P_{FA}}$, a fault is declared. We then recompute the solution with each satellite left out in turn and keep the subset whose residual statistic $T_{(-j)}$ is smallest:

$$
j^\ast = \arg\min_{j}, T_{(-j)} .
$$

Finally, to visualize how the WLS solution is found, we use the cost function with the vertical and clock components profiled out:

$$
J(E,N)=\min_{U,,b};(\Delta\boldsymbol{\rho}-H\Delta\mathbf{p})^\top W(\Delta\boldsymbol{\rho}-H\Delta\mathbf{p}).
$$

3. Full Source Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
import time
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.lines import Line2D
from scipy.stats import chi2

np.set_printoptions(precision=3, suppress=True, linewidth=120)
rng = np.random.default_rng(2026)

# ============================================================
# 1. Scenario definition (local ENU frame, receiver at the origin)
# ============================================================
R_SAT = 2.2e7 # receiver-to-satellite distance [m]
CLOCK_BIAS = 300.0 # true receiver clock bias c*dt [m]
FAULT_IDX = 3 # index of the satellite with a multipath-like fault
FAULT_BIAS = 30.0 # size of the fault [m]
N_TRIALS = 20000 # number of Monte Carlo trials
P_FA = 1e-3 # false alarm probability of the residual test

az_deg = np.array([20, 75, 130, 185, 240, 300, 340, 100, 210], dtype=float)
el_deg = np.array([78, 55, 35, 22, 48, 15, 62, 12, 30], dtype=float)
n_sat = len(az_deg)
names = [f"G{i + 1:02d}" for i in range(n_sat)]

az, el = np.deg2rad(az_deg), np.deg2rad(el_deg)
u = np.column_stack([np.cos(el) * np.sin(az),
np.cos(el) * np.cos(az),
np.sin(el)]) # unit line-of-sight vectors (E, N, U)
sat_pos = R_SAT * u

# Elevation-dependent noise model: sigma_i^2 = a^2 + b^2 / sin^2(el_i)
sigma = np.sqrt(0.3 ** 2 + (1.5 / np.sin(el)) ** 2)
w = 1.0 / sigma ** 2
W = np.diag(w)

print("=== Satellite geometry and noise model ===")
print(f"{'Sat':<5}{'Az[deg]':>9}{'El[deg]':>9}{'sigma[m]':>10}{'weight':>10}")
for i in range(n_sat):
tag = " <-- faulty" if i == FAULT_IDX else ""
print(f"{names[i]:<5}{az_deg[i]:>9.1f}{el_deg[i]:>9.1f}{sigma[i]:>10.2f}{w[i]:>10.3f}{tag}")

# ============================================================
# 2. Design matrix and DOP
# ============================================================
H = np.hstack([-u, np.ones((n_sat, 1))]) # [-u^T, 1]


def dop_values(H, weights=None):
Wm = np.eye(len(H)) if weights is None else np.diag(weights)
Q = np.linalg.inv(H.T @ Wm @ H)
return {"GDOP": np.sqrt(np.trace(Q)),
"PDOP": np.sqrt(Q[0, 0] + Q[1, 1] + Q[2, 2]),
"HDOP": np.sqrt(Q[0, 0] + Q[1, 1]),
"VDOP": np.sqrt(Q[2, 2]),
"TDOP": np.sqrt(Q[3, 3])}


print("\n=== DOP (all satellites, unweighted) ===")
for k, v in dop_values(H).items():
print(f"{k}: {v:.3f}")


# ============================================================
# 3. Nonlinear weighted least squares (Gauss-Newton)
# ============================================================
def gauss_newton(sat_pos, pr, weights, x0, max_iter=10, tol=1e-4):
x = np.array(x0, dtype=float) # [E, N, U, clock bias]
Wm = np.diag(weights)
history = [x.copy()]
for _ in range(max_iter):
diff = sat_pos - x[:3]
rho = np.linalg.norm(diff, axis=1)
Hk = np.hstack([-diff / rho[:, None], np.ones((len(pr), 1))])
dx = np.linalg.solve(Hk.T @ Wm @ Hk, Hk.T @ Wm @ (pr - (rho + x[3])))
x += dx
history.append(x.copy())
if np.linalg.norm(dx) < tol:
break
return x, np.array(history)


bias_vec = np.zeros(n_sat)
bias_vec[FAULT_IDX] = FAULT_BIAS
noise_one = rng.standard_normal(n_sat) * sigma
pr_one = np.linalg.norm(sat_pos, axis=1) + CLOCK_BIAS + noise_one + bias_vec

x_init = [5.0e4, -8.0e4, 3.0e4, 0.0] # initial guess: tens of km away
x_hat, hist = gauss_newton(sat_pos, pr_one, w, x_init)
truth = np.array([0.0, 0.0, 0.0, CLOCK_BIAS])
gn_pos_err = np.linalg.norm(hist[:, :3] - truth[:3], axis=1)
gn_clk_err = np.abs(hist[:, 3] - truth[3])

print("\n=== Gauss-Newton iterations (weighted, single epoch) ===")
print(f"{'iter':>4}{'3D pos err [m]':>18}{'clock err [m]':>16}")
for k in range(len(hist)):
print(f"{k:>4}{gn_pos_err[k]:>18.4f}{gn_clk_err[k]:>16.4f}")

# ============================================================
# 4. Vectorized Monte Carlo (linearized around the true position)
# ============================================================
t0 = time.perf_counter()

noise = rng.standard_normal((N_TRIALS, n_sat)) * sigma
dy = noise + bias_vec # pseudorange residuals

# (A) Ordinary least squares
K_ols = np.linalg.solve(H.T @ H, H.T)
err_ols = dy @ K_ols.T

# (B) Weighted least squares
K_wls = np.linalg.solve(H.T @ W @ H, H.T @ W)
err_wls = dy @ K_wls.T

# (C) Weighted least squares + fault detection and exclusion
sqrt_w = np.sqrt(w)
M_full = sqrt_w[:, None] * (np.eye(n_sat) - H @ K_wls) # whitened residual operator
K_loo = np.zeros((n_sat, 4, n_sat)) # leave-one-out estimators
M_loo = np.zeros((n_sat, n_sat, n_sat)) # leave-one-out residual operators
for j in range(n_sat):
keep = np.arange(n_sat) != j
Hj, wj = H[keep], w[keep]
Kj = np.linalg.solve(Hj.T @ (wj[:, None] * Hj), (Hj * wj[:, None]).T)
K_loo[j][:, keep] = Kj
Mj = sqrt_w[:, None] * (np.eye(n_sat) - H @ K_loo[j])
Mj[j, :] = 0.0
M_loo[j] = Mj

T_full = ((dy @ M_full.T) ** 2).sum(axis=1) # (N,)
sol_loo = np.einsum("jik,tk->tji", K_loo, dy) # (N, n_sat, 4)
T_loo = (np.einsum("jrk,tk->tjr", M_loo, dy) ** 2).sum(axis=2) # (N, n_sat)

threshold = chi2.ppf(1.0 - P_FA, df=n_sat - 4)
detected = T_full > threshold
j_star = np.argmin(T_loo, axis=1)
chosen = sol_loo[np.arange(N_TRIALS), j_star]
err_fde = np.where(detected[:, None], chosen, err_wls)

elapsed = time.perf_counter() - t0


def summarize(err):
e = err[:, :3]
d3 = np.linalg.norm(e, axis=1)
hor = np.linalg.norm(e[:, :2], axis=1)
ver = np.abs(e[:, 2])
return {"rms3d": np.sqrt(np.mean(d3 ** 2)),
"rmsh": np.sqrt(np.mean(hor ** 2)),
"rmsv": np.sqrt(np.mean(ver ** 2)),
"p95": np.percentile(d3, 95),
"mean": e.mean(axis=0),
"d3": d3}


results = {"OLS": summarize(err_ols),
"WLS": summarize(err_wls),
"WLS + FDE": summarize(err_fde)}

print(f"\n=== Monte Carlo ({N_TRIALS} trials, vectorized: {elapsed:.3f} s) ===")
print(f"Test threshold (chi-square, dof={n_sat - 4}, P_FA={P_FA}): {threshold:.2f}")
print(f"Fault detection rate : {detected.mean() * 100:.2f} %")
print(f"Correct exclusion rate : {(detected & (j_star == FAULT_IDX)).mean() * 100:.2f} %")
print(f"\n{'Method':<11}{'RMS 3D':>9}{'RMS H':>9}{'RMS V':>9}{'95% 3D':>9} mean error (E, N, U) [m]")
for name, r in results.items():
print(f"{name:<11}{r['rms3d']:>9.2f}{r['rmsh']:>9.2f}{r['rmsv']:>9.2f}{r['p95']:>9.2f} {r['mean']}")

ols_rms = results["OLS"]["rms3d"]
fde_rms = results["WLS + FDE"]["rms3d"]
print(f"\n3D RMS reduction (OLS -> WLS + FDE): {(1 - fde_rms / ols_rms) * 100:.1f} %")


# ============================================================
# 5. Cost surface over the horizontal plane (U and clock profiled out)
# ============================================================
def profile_cost(points_en, dy_one):
H_en, H_ub = H[:, :2], H[:, 2:]
K2 = np.linalg.solve(H_ub.T @ W @ H_ub, H_ub.T @ W)
Y = dy_one[None, :] - points_en @ H_en.T
res = Y - (Y @ K2.T) @ H_ub.T
return (res ** 2 * w).sum(axis=1)


dy_one = dy[0]
est_en = err_wls[0, :2]
lim = 1.6 * np.max(np.abs(est_en)) + 3.0
ge = np.linspace(-lim, lim, 70)
E_g, N_g = np.meshgrid(ge, ge)
J_g = profile_cost(np.column_stack([E_g.ravel(), N_g.ravel()]), dy_one).reshape(E_g.shape)
J_truth = profile_cost(np.array([[0.0, 0.0]]), dy_one)[0]
J_min = profile_cost(est_en[None, :], dy_one)[0]

# ============================================================
# 6. Visualization (one figure)
# ============================================================
colors = {"OLS": "#d62728", "WLS": "#ff7f0e", "WLS + FDE": "#1f77b4"}
fig = plt.figure(figsize=(21, 12), constrained_layout=True)
fig.suptitle("GNSS Positioning Error Minimization: OLS vs WLS vs WLS + Fault Exclusion",
fontsize=18, fontweight="bold")

# (1) Sky plot
ax1 = fig.add_subplot(2, 3, 1, projection="polar")
ax1.set_theta_zero_location("N")
ax1.set_theta_direction(-1)
ax1.set_rlim(0, 90)
ax1.set_xticks(np.deg2rad([0, 90, 180, 270]))
ax1.set_xticklabels(["N", "E", "S", "W"], fontsize=12)
ax1.set_yticks([0, 30, 60, 90])
ax1.set_yticklabels(["90°", "60°", "30°", "0°"], fontsize=8)
ax1.set_rlabel_position(157.5)
sc = ax1.scatter(az, 90 - el_deg, c=sigma, cmap="viridis_r", s=260,
edgecolor="k", zorder=3)
ax1.scatter(az[FAULT_IDX], 90 - el_deg[FAULT_IDX], s=620, facecolor="none",
edgecolor="red", linewidth=2.5, zorder=4, label="Faulty satellite")
for i in range(n_sat):
ax1.annotate(names[i], (az[i], 90 - el_deg[i]), xytext=(9, 9),
textcoords="offset points", fontsize=9)
ax1.set_title("(1) Sky plot (color = noise sigma [m])", pad=22)
ax1.legend(loc="lower left", bbox_to_anchor=(-0.12, -0.08), markerscale=0.35)
fig.colorbar(sc, ax=ax1, shrink=0.7, pad=0.1)

# (2) Gauss-Newton convergence
ax2 = fig.add_subplot(2, 3, 2)
it = np.arange(len(hist))
ax2.semilogy(it, np.maximum(gn_pos_err, 1e-6), "o-", lw=2, label="3D position error")
ax2.semilogy(it, np.maximum(gn_clk_err, 1e-6), "s--", lw=2, label="Clock bias error")
ax2.set_xticks(it)
ax2.set_xlabel("Iteration")
ax2.set_ylabel("Error [m]")
ax2.set_title("(2) Gauss-Newton convergence")
ax2.grid(True, which="both", alpha=0.3)
ax2.legend()

# (3) 3D scatter of error clouds
ax3 = fig.add_subplot(2, 3, 3, projection="3d")
n_plot = 1500
for name, err in [("OLS", err_ols), ("WLS", err_wls), ("WLS + FDE", err_fde)]:
ax3.scatter(err[:n_plot, 0], err[:n_plot, 1], err[:n_plot, 2],
s=6, alpha=0.35, color=colors[name])
ax3.scatter([0], [0], [0], marker="*", s=500, color="k", depthshade=False)
lim3 = 1.05 * max(np.abs(e[:n_plot, :3]).max() for e in (err_ols, err_wls, err_fde))
ax3.set_xlim(-lim3, lim3)
ax3.set_ylim(-lim3, lim3)
ax3.set_zlim(-lim3, lim3)
ax3.set_box_aspect((1, 1, 1))
ax3.set_xlabel("East error [m]")
ax3.set_ylabel("North error [m]")
ax3.set_zlabel("Up error [m]")
ax3.set_title("(3) 3D error clouds")
handles3 = [Line2D([0], [0], marker="o", ls="", color=colors[m], label=m) for m in colors]
handles3.append(Line2D([0], [0], marker="*", ls="", color="k", markersize=12, label="True position"))
ax3.legend(handles=handles3, loc="upper left")
ax3.view_init(elev=20, azim=-45)

# (4) 3D cost surface
ax4 = fig.add_subplot(2, 3, 4, projection="3d")
ax4.computed_zorder = False
z_floor = J_g.min() - 0.25 * (J_g.max() - J_g.min())
ax4.plot_surface(E_g, N_g, J_g, cmap="viridis", alpha=0.7, linewidth=0,
antialiased=True, zorder=1)
ax4.contourf(E_g, N_g, J_g, zdir="z", offset=z_floor, levels=20, cmap="viridis", zorder=0)
z_off = 0.10 * (J_g.max() - J_g.min())
ax4.scatter([0], [0], [J_truth + z_off], s=300, color="k", marker="*",
depthshade=False, zorder=10, label="True position")
ax4.scatter([est_en[0]], [est_en[1]], [J_min + z_off], s=140, color="red", marker="o",
depthshade=False, zorder=11, label="WLS minimum")
ax4.set_zlim(z_floor, J_g.max())
ax4.set_xlabel("East [m]")
ax4.set_ylabel("North [m]")
ax4.set_zlabel("Weighted cost J")
ax4.set_title("(4) Cost surface (U and clock profiled out)")
ax4.legend(loc="upper left")
ax4.view_init(elev=45, azim=-60)

# (5) RMS bar chart
ax5 = fig.add_subplot(2, 3, 5)
labels = list(results.keys())
x = np.arange(len(labels))
bw = 0.26
for k, (key, lab) in enumerate([("rmsh", "RMS horizontal"),
("rmsv", "RMS vertical"),
("rms3d", "RMS 3D")]):
vals = [results[m][key] for m in labels]
bars = ax5.bar(x + (k - 1) * bw, vals, bw, label=lab)
for b, v in zip(bars, vals):
ax5.text(b.get_x() + b.get_width() / 2, v + 0.15, f"{v:.1f}",
ha="center", fontsize=9)
ax5.set_xticks(x)
ax5.set_xticklabels(labels)
ax5.set_ylabel("Error [m]")
ax5.set_title("(5) RMS error by method")
ax5.grid(True, axis="y", alpha=0.3)
ax5.legend()

# (6) CDF of 3D error
ax6 = fig.add_subplot(2, 3, 6)
for name in labels:
d = np.sort(results[name]["d3"])
ax6.plot(d, np.arange(1, len(d) + 1) / len(d), lw=2.2, color=colors[name], label=name)
ax6.axhline(0.95, color="gray", ls="--", lw=1)
ax6.text(ax6.get_xlim()[1] * 0.98, 0.93, "95 %", ha="right", va="top", color="gray")
ax6.set_xlabel("3D position error [m]")
ax6.set_ylabel("Cumulative probability")
ax6.set_title("(6) CDF of 3D position error")
ax6.grid(True, alpha=0.3)
ax6.legend(loc="lower right")

plt.show()

4. Code Walkthrough

Section 1: Scenario definition

Instead of full orbital mechanics, the receiver sits at the origin of an ENU frame and each satellite is placed 22,000 km away in the direction given by its azimuth and elevation. This keeps the geometry realistic enough to study estimation error without any ephemeris handling.

The nine satellites are deliberately unevenly spread. G01, G02, G05 and G07 are high, while G06 and G08 sit at 15° and 12°, which is typical of a real sky. The array u holds the unit line-of-sight vectors, and sigma implements the noise model $\sigma_i^2=\sigma_a^2+\sigma_b^2/\sin^2\theta_i$. The weights w are simply $1/\sigma_i^2$.

The faulty satellite is G04 (index 3, elevation 22°). Its 30 m bias is about seven times its own standard deviation, which is large but plausible for a strong multipath reflection.

Section 2: Design matrix and DOP

H is built by stacking $-\mathbf{u}_i^\top$ with a column of ones for the clock. The function dop_values inverts $H^\top H$ (or $H^\top W H$ if weights are given) and extracts the DOP components. Because the frame is ENU, HDOP and VDOP fall directly out of the diagonal of $Q$ with no rotation needed.

Section 3: Gauss-Newton

This is the true nonlinear solver. At each iteration it recomputes the ranges $\rho_i$ from the current estimate, rebuilds $H$ from the updated line-of-sight vectors, and solves the weighted normal equations for the step dx. The loop stops when the step is smaller than 0.1 mm.

The initial guess is deliberately poor, roughly 99 km off in position and 300 m off in clock. This shows that the method does not need a good starting point. The single-epoch measurement pr_one contains noise and the 30 m fault, so the converged answer will not be exactly zero error. It will be the WLS answer for that noisy, faulty epoch.

Section 4: Vectorized Monte Carlo

A naive implementation would loop over 20,000 trials and, for the FDE method, solve nine leave-one-out least squares problems per trial, which is 180,000 small linear solves in a Python loop. That takes seconds to minutes depending on the machine.

The speedup rests on one observation. Every estimator here is linear in the measurement residual vector $\Delta\boldsymbol{\rho}$. So the operators can be computed once, outside the trial loop:

  • K_ols and K_wls map residuals to state corrections, so all 20,000 solutions are a single matrix product dy @ K.T.
  • K_loo[j] is the estimator with satellite $j$ removed, stored as a $4\times n$ matrix whose $j$-th column is zero. M_loo[j] is the matching whitened residual operator, with row $j$ zeroed so the excluded satellite does not contribute to the test statistic.
  • np.einsum applies all nine leave-one-out operators to all 20,000 trials at once, giving sol_loo with shape $(N, n, 4)$ and T_loo with shape $(N, n)$.

The decision logic is also vectorized. detected compares each trial’s full-set statistic with the $\chi^2$ threshold. j_star picks the best satellite to exclude for every trial, and np.where selects either the excluded solution or the plain WLS solution. The printed timing is the time for this whole block.

Linearizing around the true position for the Monte Carlo is legitimate here. With errors of tens of meters and satellites 22,000 km away, the neglected second-order term is about $\lVert\Delta\mathbf{x}\rVert^2/(2R)\approx 30^2/(2\times 2.2\times10^{7})\approx 2\times10^{-5}$ m, which is negligible.

Section 5: Cost surface

profile_cost evaluates $J(E,N)$ on a grid. For each horizontal point $(E,N)$ it subtracts that point’s contribution from the residuals and then solves the remaining two-parameter problem $(U,b)$ in closed form, again for all grid points at once. The result is a smooth bowl over the horizontal plane, and its lowest point is the WLS horizontal estimate. The grid is scaled from the actual WLS error so the truth and the minimum both fit in the picture.

Section 6: Visualization

All six panels go into one figure with constrained_layout, which handles the polar plot, the two 3D axes and the color bar without overlaps. In the 3D cost surface, computed_zorder = False together with explicit zorder values keeps the markers drawn on top of the semi-transparent surface. Only 1,500 of the 20,000 points are drawn in the 3D scatter to keep it readable, while the bar chart and the CDF use all 20,000.

5. Execution Results

=== Satellite geometry and noise model ===
Sat    Az[deg]  El[deg]  sigma[m]    weight
G01       20.0     78.0      1.56     0.410
G02       75.0     55.0      1.86     0.290
G03      130.0     35.0      2.63     0.144
G04      185.0     22.0      4.02     0.062  <-- faulty
G05      240.0     48.0      2.04     0.240
G06      300.0     15.0      5.80     0.030
G07      340.0     62.0      1.73     0.336
G08      100.0     12.0      7.22     0.019
G09      210.0     30.0      3.01     0.110

=== DOP (all satellites, unweighted) ===
GDOP: 1.872
PDOP: 1.640
HDOP: 0.944
VDOP: 1.341
TDOP: 0.904

=== Gauss-Newton iterations (weighted, single epoch) ===
iter    3D pos err [m]   clock err [m]
   0        98994.9494        300.0000
   1          196.5270         39.6186
   2            8.8030          6.6882
   3            8.8020          6.6870
   4            8.8020          6.6870

=== Monte Carlo (20000 trials, vectorized: 0.133 s) ===
Test threshold (chi-square, dof=5, P_FA=0.001): 20.52
Fault detection rate     : 98.95 %
Correct exclusion rate   : 98.93 %

Method        RMS 3D    RMS H    RMS V   95% 3D   mean error (E, N, U) [m]
OLS            14.14    11.47     8.28    19.34   [ 1.965 10.395  4.459]
WLS            10.27     5.12     8.90    16.59   [-0.019  3.923  6.725]
WLS + FDE       6.84     3.36     5.95    12.49   [0.003 0.015 0.098]

3D RMS reduction (OLS -> WLS + FDE): 51.7 %

6. Reading the Results

The geometry (panel 1)

The sky plot shows where the satellites sit and how noisy each one is. High-elevation satellites near the center (G01, G07, G02) are light yellow-green, meaning $\sigma$ below 2 m. Satellites near the horizon (G08 at 12° and G06 at 15°) are dark, with $\sigma$ of 7.2 m and 5.8 m. The red circle marks G04, the faulty satellite. It is a low-elevation satellite with a fairly large $\sigma$ of about 4 m, which matters for detection. A fault on a noisy satellite is harder to notice than one on a clean satellite.

The unweighted DOP values printed in the console tell the same story. The horizontal geometry is good, with HDOP below 1, while the vertical is weaker with VDOP around 1.3. That is the usual GNSS pattern, because all satellites are above the receiver and none are below.

Nonlinear convergence (panel 2)

Starting about 99 km from the truth, Gauss-Newton drops the position error to roughly 200 m after one iteration and to under 10 m after two. Iterations three and onward change nothing visible. The clock error follows the same pattern. The curve flattens at a level of a few meters because that is the noise and fault floor of this single epoch, not a convergence failure. This is also why the linearized Monte Carlo in the next step is justified. Once you are within tens of meters, one linear step is essentially the final answer.

The 3D error clouds (panel 3)

Each cloud is the distribution of position errors in East, North and Up, and the black star is the truth. The three clouds are different in both center and spread.

The OLS cloud (red) is displaced from the origin. The fault at G04, which lies to the south at low elevation, pushes the estimate away from the satellite, mostly toward the north. OLS gives G04 the same weight as a satellite at 78°, so it absorbs the full bias. The mean error from the console confirms this, with a North component of about 10 m.

The WLS cloud (orange) is closer to the truth in the horizontal plane because G04’s weight is small, but it is still shifted, particularly in the vertical.

The WLS + FDE cloud (blue) is centered on the star and tighter overall. Once the faulty satellite is excluded, the bias disappears, and the mean error drops to a few centimeters or less on every axis.

The cost surface (panel 4)

This panel shows why a least squares solution exists and where it lands. The surface is a smooth bowl, and the weighted cost $J$ has a single minimum, marked by the red dot. The black star is the true position. They do not coincide. The gap between them is the bias introduced by the fault. The bowl is also elongated, being steeper in one horizontal direction than the other. That elongation is the geometry at work, since directions with well-spread satellites are tightly constrained and directions with poor coverage are shallow, allowing larger error. The filled contour map on the floor is the same information seen from above.

RMS by method (panel 5)

The bar chart condenses the Monte Carlo into numbers. In my run the 3D RMS error fell from about 14 m with OLS to about 10 m with WLS and about 7 m with WLS + FDE, a reduction of roughly half. Two details are worth noting.

First, the horizontal improvement is much larger than the vertical one. Horizontal RMS drops from about 11.5 m to 5 m with weighting alone and to about 3.4 m with FDE. Vertical RMS barely moves with weighting and is even slightly worse for WLS than for OLS. The reason is that low-elevation satellites are the main source of vertical information, so downweighting them, while sensible for noise, weakens the vertical geometry. WLS is not a free lunch, and it trades vertical strength for lower noise.

Second, FDE reduces error in every category. Removing the biased measurement fixes the bias that weighting could only dampen.

The error distribution (panel 6)

The cumulative distribution shows that the improvement is not just in the average. The blue curve (WLS + FDE) sits far to the left of the orange (WLS) and red (OLS) curves across the entire probability range. At the 95 % line, the 3D error falls from roughly 19 m for OLS to roughly 12.5 m for WLS + FDE. The OLS curve also has essentially zero probability of an error below 5 m, which is the fingerprint of a systematic bias, since a zero-mean estimator would have plenty of small errors.

The console reports how the test behaved. With a false alarm probability of $10^{-3}$ and 30 m of fault on a satellite with $\sigma\approx 4$ m, the fault was detected in about 99 % of trials and the right satellite was excluded in nearly all of those. The small remaining fraction is where the noise happened to cancel the fault and the test statistic stayed below the threshold.

7. Conclusion

Starting from the same nine pseudoranges, three levels of processing produced very different accuracy:

  • Weighting by elevation-dependent noise removes much of the horizontal error but weakens vertical geometry.
  • A residual test with leave-one-out exclusion removes the systematic bias that neither OLS nor WLS can handle, and it did so with a correct identification rate near 99 %.
  • Because every estimator is linear in the residuals, precomputing the operators turns a 180,000-solve loop into a handful of matrix products, so the full Monte Carlo runs in a fraction of a second.

The same structure extends naturally to more realistic setups. The elevation model can be replaced by C/N0-based weights, the single fault by several candidates, and the single-epoch solution by a Kalman filter. The core idea stays the same: model the noise honestly, check the measurements for consisten

Choosing the Best HF Frequency

MUF, LUF and Reliability Optimization in Python

Anyone who has operated on shortwave knows the feeling: at noon the 20 m band roars with signals, while at midnight the same band sounds like static. HF sky-wave propagation depends on the ionosphere, and the ionosphere depends on the Sun. Choosing a frequency is therefore not a one-time decision. It is an optimization problem that changes every hour.

In this article we build a compact but physically meaningful model of a single-hop HF link. We then find the frequency that maximizes link reliability for every hour of the day.

The Example Problem

Consider a 2,500 km sky-wave link over a mid-latitude path (midpoint at 25°N) at the equinox, with a smoothed sunspot number $R_{12}=100$.

Item Value
Transmit power 100 W (20 dBW)
Antenna gain (TX / RX) 3 dBi / 3 dBi
Mode / bandwidth SSB voice, 2.7 kHz
Required SNR 10 dB
Reflecting layer F2 layer, virtual height 300 km
Search range 3 to 30 MHz

Goal: for every hour $t$, find the operating frequency $f^*(t)$ that maximizes the probability that the link works.

The Mathematical Model

1. Geometry of a single hop

With Earth radius $R_E$, ground distance $D$ and reflection height $h$, the half central angle is

$$\alpha = \frac{D}{2R_E}$$

The take-off (elevation) angle $\Delta$ and the incidence angle $i$ at the layer are

$$\tan\Delta = \frac{\cos\alpha - \dfrac{R_E}{R_E+h}}{\sin\alpha}, \qquad \sin i = \frac{R_E\cos\Delta}{R_E+h}$$

The one-hop path length is

$$L = 2\sqrt{R_E^2 + (R_E+h)^2 - 2R_E(R_E+h)\cos\alpha}$$

The maximum usable frequency follows from the secant law:

$$\mathrm{MUF} = f_oF_2 \cdot \sec i$$

2. Day–night behavior of the F2 layer

The solar zenith angle $\chi$ at the path midpoint (latitude $\varphi$, declination $\delta$, hour angle $H = 15^\circ (t-12)$) satisfies

$$\cos\chi = \sin\varphi\sin\delta + \cos\varphi\cos\delta\cos H$$

The critical frequency blends a nighttime and a daytime value through a smooth switch:

$$S(t) = \frac{1}{2}\left[1+\tanh\left(3\cos\chi\right)\right], \qquad f_oF_2 = f_n + (f_d - f_n),S(t)$$

$$f_d = 3.5 + 0.05R_{12}, \qquad f_n = 2.0 + 0.015R_{12}$$

3. D-layer absorption

Lower frequencies are absorbed in the daytime D layer. An empirical form is

$$L_a(f) = \frac{677.2, I, \sec i_D}{(f+f_H)^2 + 10.2}, \qquad I = (1+0.0037R_{12})\left[\cos^{1.3}(0.881\chi) + 0.02\right]$$

Here $\chi$ is in degrees and capped at $102^\circ$, $f_H$ is an effective gyro-frequency, and $i_D$ is the incidence angle at the D layer (about 90 km).

Free-space loss (with $f$ in MHz and $L$ in km) is

$$L_{fs} = 32.45 + 20\log_{10} f + 20\log_{10} L$$

The external noise figure decreases with frequency:

$$F_a = c - d\log_{10} f, \qquad N = F_a + 10\log_{10} B - 204 \ \ [\mathrm{dBW}]$$

Combining everything gives the signal-to-noise ratio:

$$\mathrm{SNR}(f,t) = P_t + G_t + G_r - L_{fs} - L_a - L_o - N$$

where $L_o$ lumps ground reflection, polarization and other losses.

5. Reliability as the objective function

Two independent things must go right for the link to work. First, the ionosphere must actually reflect the wave, meaning $f$ stays below the day-to-day MUF, which fluctuates by roughly $\sigma_M$ (a fraction of MUF). Second, the SNR must exceed the requirement, with fading spread $\sigma_S$. With $\Phi$ the standard normal CDF, the reliability is

$$R(f,t) = \Phi!\left(\frac{\mathrm{MUF}(t) - f}{\sigma_M,\mathrm{MUF}(t)}\right)\cdot \Phi!\left(\frac{\mathrm{SNR}(f,t) - \mathrm{SNR}_{\mathrm{req}}}{\sigma_S}\right)$$

The optimization problem is then

$$f^*(t) = \underset{3,\mathrm{MHz},\le, f,\le, 30,\mathrm{MHz}}{\arg\max}; R(f,t)$$

The first factor falls as $f$ approaches the MUF, while the second factor rises with $f$ because absorption and noise both drop. Their product has a clear peak.

Full Source Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
import time
import numpy as np
import matplotlib.pyplot as plt
from scipy.special import ndtr
from scipy.optimize import minimize_scalar

# =====================================================
# 1. Scenario parameters
# =====================================================
R_E = 6371.0 # Earth radius [km]
D_KM = 2500.0 # Ground distance of the link [km]
H_F2 = 300.0 # Virtual height of the F2 layer [km]
H_D = 90.0 # Height of the D layer [km]
LAT_MID = 25.0 # Latitude of the path midpoint [deg]
DECL = 0.0 # Solar declination (equinox) [deg]
R12 = 100.0 # Smoothed sunspot number

PT_DBW = 20.0 # Transmit power: 100 W = 20 dBW
GT_DBI = 3.0 # Transmit antenna gain [dBi]
GR_DBI = 3.0 # Receive antenna gain [dBi]
BW_HZ = 2700.0 # SSB voice bandwidth [Hz]
NOISE_C = 72.5 # Man-made noise model (residential): Fa = c - d*log10(f)
NOISE_D = 27.7
L_OTHER = 10.0 # Ground reflection, polarization and other losses [dB]
FH_MHZ = 1.0 # Effective gyro-frequency [MHz]

SNR_REQ = 10.0 # Required SNR [dB]
SIGMA_SNR = 8.0 # Standard deviation of SNR fluctuation [dB]
SIGMA_MUF = 0.10 # Day-to-day MUF deviation (fraction of MUF)

# =====================================================
# 2. One-hop geometry (spherical Earth, thin-layer model)
# =====================================================
def hop_geometry(d_km, h_km):
alpha = d_km / (2.0 * R_E)
leg = np.sqrt(R_E**2 + (R_E + h_km)**2 - 2.0 * R_E * (R_E + h_km) * np.cos(alpha))
elev = np.arctan2(np.cos(alpha) - R_E / (R_E + h_km), np.sin(alpha))
sin_i = R_E * np.cos(elev) / (R_E + h_km)
sec_i = 1.0 / np.sqrt(1.0 - sin_i**2)
return 2.0 * leg, elev, sec_i

PATH_KM, ELEV_RAD, SEC_F2 = hop_geometry(D_KM, H_F2)
SEC_D = 1.0 / np.sqrt(1.0 - (R_E * np.cos(ELEV_RAD) / (R_E + H_D))**2)

# =====================================================
# 3. Ionosphere and link-budget model
# (every function accepts scalars or broadcastable arrays)
# =====================================================
def cos_zenith(hour):
lat = np.radians(LAT_MID)
dec = np.radians(DECL)
h_ang = np.radians(15.0 * (hour - 12.0))
return np.sin(lat) * np.sin(dec) + np.cos(lat) * np.cos(dec) * np.cos(h_ang)

def fo_f2(hour, r12):
s = 0.5 * (1.0 + np.tanh(3.0 * cos_zenith(hour)))
f_day = 3.5 + 0.05 * r12
f_night = 2.0 + 0.015 * r12
return f_night + (f_day - f_night) * s

def muf(hour, r12):
return fo_f2(hour, r12) * SEC_F2

def d_layer_absorption(f, hour, r12):
chi = np.degrees(np.arccos(np.clip(cos_zenith(hour), -1.0, 1.0)))
chi = np.minimum(chi, 102.0)
ir = (1.0 + 0.0037 * r12) * (np.maximum(np.cos(np.radians(0.881 * chi)), 0.0) ** 1.3 + 0.02)
return 677.2 * ir * SEC_D / ((f + FH_MHZ) ** 2 + 10.2)

def free_space_loss(f):
return 32.45 + 20.0 * np.log10(f) + 20.0 * np.log10(PATH_KM)

def noise_power_dbw(f):
fa = NOISE_C - NOISE_D * np.log10(f)
return fa + 10.0 * np.log10(BW_HZ) - 204.0

def snr_db(f, hour, r12):
pr = (PT_DBW + GT_DBI + GR_DBI
- free_space_loss(f) - d_layer_absorption(f, hour, r12) - L_OTHER)
return pr - noise_power_dbw(f)

def reliability(f, hour, r12):
m = muf(hour, r12)
p_mode = ndtr((m - f) / (SIGMA_MUF * m))
p_snr = ndtr((snr_db(f, hour, r12) - SNR_REQ) / SIGMA_SNR)
return p_mode * p_snr

# =====================================================
# 4. Grid search: naive loop vs. vectorized
# =====================================================
hours = np.arange(0.0, 24.01, 0.5)
freqs = np.arange(3.0, 30.01, 0.1)

def grid_naive(hours, freqs, r12):
out = np.empty((len(hours), len(freqs)))
for i, h in enumerate(hours):
for j, f in enumerate(freqs):
out[i, j] = reliability(f, h, r12)
return out

def grid_vectorized(hours, freqs, r12):
return reliability(freqs[None, :], hours[:, None], r12)

t0 = time.perf_counter()
rel_naive = grid_naive(hours, freqs, R12)
t_naive = time.perf_counter() - t0

t0 = time.perf_counter()
rel = grid_vectorized(hours, freqs, R12)
t_vec = time.perf_counter() - t0

print("=== Computation time ===")
print(f"Evaluations : {rel.size:,}")
print(f"Naive double loop : {t_naive:8.4f} s")
print(f"Vectorized (NumPy) : {t_vec:8.4f} s")
print(f"Speed-up : {t_naive / max(t_vec, 1e-9):8.1f} x")
print(f"Max abs difference : {np.max(np.abs(rel - rel_naive)):.2e}")

# =====================================================
# 5. Optimal frequency for every hour
# =====================================================
idx_best = np.argmax(rel, axis=1)
f_star = freqs[idx_best]
r_star = rel[np.arange(len(hours)), idx_best]
muf_h = muf(hours, R12)
snr_grid = snr_db(freqs[None, :], hours[:, None], R12)
snr_star = snr_grid[np.arange(len(hours)), idx_best]

usable = (snr_grid >= SNR_REQ) & (freqs[None, :] <= muf_h[:, None])
luf = np.where(usable.any(axis=1), freqs[usable.argmax(axis=1)], np.nan)

print("\n=== Model summary ===")
print(f"Ground distance : {D_KM:.0f} km")
print(f"Path length (1 hop) : {PATH_KM:.1f} km")
print(f"Take-off angle : {np.degrees(ELEV_RAD):.2f} deg")
print(f"sec(i) at F2 layer : {SEC_F2:.3f}")

print("\n=== Optimal frequency (R12 = %.0f) ===" % R12)
print(f"{'Hour':>5} {'MUF':>7} {'f*':>7} {'f*/MUF':>7} {'SNR':>7} {'R*':>7}")
for hh in range(0, 24, 2):
k = int(np.argmin(np.abs(hours - hh)))
print(f"{hh:5d} {muf_h[k]:7.2f} {f_star[k]:7.2f} {f_star[k] / muf_h[k]:7.3f} "
f"{snr_star[k]:7.2f} {r_star[k]:7.3f}")

# Continuous refinement with a bounded scalar optimizer
print("\n=== Refinement with scipy.optimize.minimize_scalar ===")
print(f"{'Hour':>5} {'Grid f*':>9} {'Refined f*':>11} {'Refined R*':>11}")
for hh in (3.0, 12.0, 21.0):
k = int(np.argmin(np.abs(hours - hh)))
upper = min(30.0, 1.3 * float(muf(hh, R12)))
res = minimize_scalar(lambda x: -float(reliability(x, hh, R12)),
bounds=(3.0, upper), method="bounded",
options={"xatol": 1e-6})
print(f"{hh:5.0f} {f_star[k]:9.2f} {res.x:11.3f} {-res.fun:11.4f}")

# =====================================================
# 6. Sensitivity: optimal frequency vs. hour and sunspot number
# =====================================================
r12_axis = np.linspace(10.0, 150.0, 29)
rel3 = reliability(freqs[None, :, None], hours[:, None, None], r12_axis[None, None, :])
f_star_map = freqs[np.argmax(rel3, axis=1)]

# =====================================================
# 7. Visualization (all panels in one figure)
# =====================================================
fig = plt.figure(figsize=(22, 13))
fig.suptitle("HF Frequency Optimization for a 2,500 km Sky-wave Link", fontsize=20, fontweight="bold")

# (1) MUF / FOT / LUF and optimal frequency
ax1 = fig.add_subplot(2, 3, 1)
ax1.fill_between(hours, luf, muf_h, color="tab:green", alpha=0.15, label="Usable window")
ax1.plot(hours, muf_h, color="tab:blue", lw=2, label="MUF")
ax1.plot(hours, 0.85 * muf_h, color="tab:blue", lw=1.5, ls="--", label="0.85 x MUF")
ax1.plot(hours, luf, color="tab:orange", lw=2, label="LUF")
ax1.plot(hours, f_star, color="red", lw=3, label="Optimal f*")
ax1.set_xlim(0, 24); ax1.set_xticks(range(0, 25, 3))
ax1.set_xlabel("Local time at path midpoint [h]"); ax1.set_ylabel("Frequency [MHz]")
ax1.set_title("(1) MUF, LUF and optimal frequency")
ax1.grid(alpha=0.3); ax1.legend(loc="upper left")

# (2) 3D reliability surface
ax2 = fig.add_subplot(2, 3, 2, projection="3d")
Fm, Hm = np.meshgrid(freqs, hours)
ax2.plot_surface(Hm, Fm, rel, cmap="viridis", linewidth=0, antialiased=True, alpha=0.92)
ax2.plot(hours, f_star, r_star + 0.02, color="red", lw=3, label="Optimal f*")
ax2.set_xlabel("Hour [h]"); ax2.set_ylabel("Frequency [MHz]"); ax2.set_zlabel("Reliability")
ax2.set_zlim(0, 1.05); ax2.view_init(elev=30, azim=-55)
ax2.set_title("(2) Reliability surface R(f, t)")
ax2.legend(loc="upper left")

# (3) Heat map
ax3 = fig.add_subplot(2, 3, 3)
pm = ax3.pcolormesh(hours, freqs, rel.T, shading="auto", cmap="viridis", vmin=0, vmax=1)
ax3.plot(hours, muf_h, color="cyan", lw=2, ls="--", label="MUF")
ax3.plot(hours, f_star, color="white", lw=2.5, label="Optimal f*")
ax3.set_ylim(freqs[0], freqs[-1]); ax3.set_xlim(0, 24); ax3.set_xticks(range(0, 25, 3))
ax3.set_xlabel("Local time at path midpoint [h]"); ax3.set_ylabel("Frequency [MHz]")
ax3.set_title("(3) Reliability heat map")
ax3.legend(loc="upper left")
fig.colorbar(pm, ax=ax3, label="Reliability")

# (4) Reliability vs frequency at selected hours
ax4 = fig.add_subplot(2, 3, 4)
for hh, col in zip((0, 6, 12, 18), ("tab:purple", "tab:orange", "tab:red", "tab:blue")):
k = int(np.argmin(np.abs(hours - hh)))
ax4.plot(freqs, rel[k], color=col, lw=2, label=f"{hh:02d}:00")
ax4.plot(f_star[k], r_star[k], "o", color=col, ms=10, mec="black")
ax4.set_xlabel("Frequency [MHz]"); ax4.set_ylabel("Reliability")
ax4.set_title("(4) Reliability vs. frequency (dots = optimum)")
ax4.set_ylim(0, 1.02); ax4.grid(alpha=0.3); ax4.legend()

# (5) 3D: optimal frequency vs hour and sunspot number
ax5 = fig.add_subplot(2, 3, 5, projection="3d")
Rm, Hm2 = np.meshgrid(r12_axis, hours)
ax5.plot_surface(Hm2, Rm, f_star_map, cmap="plasma", linewidth=0, antialiased=True, alpha=0.95)
ax5.set_xlabel("Hour [h]"); ax5.set_ylabel("Sunspot number R12"); ax5.set_zlabel("Optimal f* [MHz]")
ax5.view_init(elev=28, azim=-60)
ax5.set_title("(5) Optimal frequency vs. time and solar activity")

# (6) Maximum reliability and SNR at the optimum
ax6 = fig.add_subplot(2, 3, 6)
ax6.plot(hours, r_star, color="tab:green", lw=3, label="Max reliability R*")
ax6.set_xlabel("Local time at path midpoint [h]"); ax6.set_ylabel("Max reliability", color="tab:green")
ax6.set_ylim(0, 1.02); ax6.set_xlim(0, 24); ax6.set_xticks(range(0, 25, 3)); ax6.grid(alpha=0.3)
ax6b = ax6.twinx()
ax6b.plot(hours, snr_star, color="tab:red", lw=2, ls="--", label="SNR at f*")
ax6b.axhline(SNR_REQ, color="gray", ls=":", label="Required SNR")
ax6b.set_ylabel("SNR [dB]", color="tab:red")
ax6.set_title("(6) Achievable performance at the optimum")
lines = ax6.get_lines() + ax6b.get_lines()
ax6.legend(lines, [l.get_label() for l in lines], loc="lower left")

fig.tight_layout(rect=[0, 0, 1, 0.96])
fig.savefig("hf_frequency_optimization.png", dpi=150, bbox_inches="tight")
plt.show()

Code Walkthrough

Section 1: Scenario parameters

All physical constants and design choices live at the top, so you can play with them easily. Change D_KM to test a different distance, R12 to move between solar minimum and maximum, or PT_DBW to see how much a linear amplifier really buys you. SIGMA_MUF and SIGMA_SNR control how conservative the optimizer will be: larger values mean less predictable propagation, which pushes the optimum further from the MUF.

Section 2: Geometry

hop_geometry implements the spherical-Earth formulas above. It returns three values: the total one-hop path length, the take-off angle, and $\sec i$ at the reflection layer. arctan2 is used instead of arctan so the quotient is handled safely. For a 2,500 km path the take-off angle is only about 7.5°, and $\sec i\approx 3.1$. This is why the MUF is roughly three times the critical frequency: a low take-off angle means a grazing incidence on the layer, so much higher frequencies can still be reflected.

SEC_D reuses the same take-off angle but evaluates the incidence angle at 90 km. That number tells us how obliquely the ray crosses the absorbing D layer.

Each formula from the mathematical section is one small function. The important design decision is that every function accepts scalars or NumPy arrays that broadcast against each other. There is no if statement and no explicit loop inside them. This is what makes the acceleration in Section 4 possible with zero code duplication.

  • cos_zenith computes $\cos\chi$ from latitude, declination and local time.
  • fo_f2 blends the day and night critical frequencies with the smooth $\tanh$ switch, so the transition at sunrise and sunset is gradual rather than a hard step.
  • d_layer_absorption clips $\chi$ at 102° (beyond which the D layer disappears), then applies the $1/[(f+f_H)^2+10.2]$ dependence. The small constant 0.02 keeps a minimal residual absorption at night.
  • snr_db assembles the link budget: transmit power plus antenna gains minus free-space loss, absorption and miscellaneous loss, minus the noise power.
  • reliability multiplies the two normal-CDF terms. ndtr is SciPy’s fast, vectorized implementation of $\Phi$.

Section 4: Grid search, naive versus accelerated

Two versions of the same computation are provided.

  • grid_naive uses a double for loop and calls reliability once per (hour, frequency) pair. This is the most direct translation of the math, but Python-level loops are slow, and every scalar call pays NumPy’s function-call overhead.
  • grid_vectorized passes freqs[None, :] (shape $1\times F$) and hours[:, None] (shape $H\times 1$) to the same function. NumPy broadcasting expands them into a full $H\times F$ table in one shot, using compiled loops internally.

The script times both, prints the speed-up, and checks that the maximum difference between the two results is at floating-point rounding level. The vectorized version is the one used from here on. The naive version is kept only as a benchmark, and it becomes unusable as soon as you add another dimension such as the sunspot number in Section 6.

Section 5: Extracting the optimum

np.argmax(rel, axis=1) finds, for every hour, the column index of the highest reliability. From that index we read the optimal frequency f_star, the peak reliability r_star, and the SNR at the optimum.

The LUF is computed with a boolean mask: a frequency counts as usable when the SNR meets the requirement and the frequency lies below the MUF. argmax on the mask returns the first True, which is the lowest usable frequency. If no frequency qualifies, the hour gets NaN.

The grid has a 0.1 MHz resolution, so as a cross-check we refine the optimum at 03:00, 12:00 and 21:00 with scipy.optimize.minimize_scalar. It searches the continuous interval between 3 MHz and 1.3 times the MUF. The refined values should agree with the grid to within the grid spacing.

Section 6: Sensitivity to solar activity

To see how the answer changes over the solar cycle, we add a third axis. The arrays have shapes $H\times1\times1$, $1\times F\times1$ and $1\times1\times R$, so one call to reliability produces a full $H\times F\times R$ tensor of about 390,000 values. Taking argmax along the frequency axis yields f_star_map, the optimal frequency as a function of hour and $R_{12}$. Running this through the naive loop would be hopeless by comparison.

Section 7: Visualization

Everything is drawn in a single figure with six panels: four 2D plots and two 3D surfaces. plot_surface draws the 3D graphs, pcolormesh draws the heat map, and twinx gives the last panel two vertical axes. The finished image is also saved as a PNG so you can reuse it directly.

Execution Results

Console Output

=== Computation time ===
Evaluations         : 13,279
Naive double loop   :   1.6166 s
Vectorized (NumPy)  :   0.0036 s
Speed-up            :    449.3 x
Max abs difference  : 6.66e-16

=== Model summary ===
Ground distance     : 2500 km
Path length (1 hop) : 2623.6 km
Take-off angle      : 7.53 deg
sec(i) at F2 layer  : 3.107

=== Optimal frequency (R12 = 100) ===
 Hour     MUF      f*  f*/MUF     SNR      R*
    0   10.94    8.30   0.759   18.51   0.849
    2   11.01    8.30   0.754   18.51   0.850
    4   11.83    8.90   0.752   18.84   0.860
    6   18.64   14.40   0.772   18.92   0.858
    8   25.45   20.40   0.802   17.77   0.815
   10   26.27   21.80   0.830   15.96   0.738
   12   26.34   22.10   0.839   15.21   0.703
   14   26.27   21.80   0.830   15.96   0.738
   16   25.45   20.40   0.802   17.77   0.815
   18   18.64   14.40   0.772   18.92   0.858
   20   11.83    8.90   0.752   18.84   0.860
   22   11.01    8.30   0.754   18.51   0.850

=== Refinement with scipy.optimize.minimize_scalar ===
 Hour   Grid f*  Refined f*  Refined R*
    3      8.50       8.470      0.8526
   12     22.10      22.075      0.7027
   21      8.50       8.470      0.8526

Graph Output

Reading the Results

Console output

The first block reports the computation time. Because the two implementations call exactly the same model function, the vectorized version reproduces the loop result to rounding error while running in a small fraction of the time.

The model summary confirms the geometry: a path of about 2,624 km, a take-off angle of roughly 7.5°, and $\sec i\approx 3.107$.

The optimal-frequency table shows the essence of the problem:

  • At 00:00, the MUF is about 10.9 MHz and the best frequency is 8.3 MHz, only 76% of the MUF. The SNR is about 18.5 dB and the reliability about 0.85.
  • At 12:00, the MUF rises to about 26.3 MHz and the best frequency is 22.1 MHz, or 84% of the MUF. The SNR is about 15.2 dB and the reliability about 0.70.

The ratio $f^*/\mathrm{MUF}$ stays between roughly 0.75 and 0.84 all day. This agrees with the classic operating rule of using about 85% of the MUF (the “frequency of optimum traffic”), but here the ratio comes out of the optimization instead of being assumed. The refinement table confirms that the continuous optimizer lands within 0.03 MHz of the grid result (for example 22.075 MHz against 22.1 MHz at noon).

Panel (1): MUF, LUF and the optimal frequency

The blue MUF curve is a flat plateau of about 11 MHz at night, rises steeply between 05:00 and 08:00 as the F2 layer ionizes, and peaks at about 26 MHz around noon. The orange LUF curve is the mirror image: at noon the D layer absorbs so strongly that frequencies below about 17.3 MHz cannot meet the 10 dB SNR requirement. At night the LUF sits at the 3 MHz floor of the search range, which only means that every frequency in the range meets the SNR target. The green usable window is therefore narrow at noon (roughly 17 to 26 MHz) and wide at night. The red optimal line runs inside this window and always stays below the 0.85 MUF dashed line, tracking the MUF at a safe distance.

Panel (2): 3D reliability surface

The surface shows reliability over time and frequency. It has a clear ridge that follows the MUF curve: high on the left of the ridge (frequency low enough to reflect and strong enough to be heard), and a sharp cliff on the right (frequency above the MUF, where the wave escapes into space). The red line traces the ridge crest. At night the ridge is low in frequency and broad along the frequency axis, and during the day it climbs to high frequencies. At noon its crest is visibly lower than at night, which is the fingerprint of D-layer absorption. The three-dimensional view makes it obvious that choosing a frequency without regard to the time of day would put you either over the cliff or in the valley.

Panel (3): Heat map

The same data viewed from above. The bright band is the region of high reliability, and the cyan dashed MUF marks the edge of the cliff. The white optimal line hugs the crest of the band, just under the cliff. Below the band the color fades gradually because of absorption and noise, while above it the color drops abruptly to zero. This asymmetry is the reason the optimum sits closer to the MUF than to the LUF but never touches it: the penalty for overshooting is far more severe than the penalty for undershooting.

Panel (4): Reliability versus frequency

Four cross sections at 00:00, 06:00, 12:00 and 18:00 make the trade-off concrete. Each curve rises slowly and falls quickly, and the dot marks its peak. The 00:00 curve peaks near 8 MHz, the 18:00 curve near 14 MHz, and the 12:00 curve near 22 MHz. Note how narrow the 00:00 curve is: it collapses beyond about 11 MHz. Picking 14 MHz at midnight (a common daytime choice) yields essentially zero reliability. The noon curve is broader but lower, peaking around 0.70.

Panel (5): 3D optimal frequency versus time and solar activity

This surface shows how the entire day-night pattern scales with the solar cycle. At solar minimum ($R_{12}=10$) the optimum is about 5.2 MHz at midnight and 12.0 MHz at noon. At $R_{12}=100$ it is 8.3 MHz and 22.1 MHz, and at $R_{12}=150$ it reaches 10.0 MHz and 27.5 MHz. The daytime plateau grows much faster than the nighttime floor, which reflects the stronger solar dependence of the daytime F2 layer. In practice this means that the same station needs a very different band plan at solar minimum than at solar maximum, with the higher bands opening only when the sunspot number is high.

Panel (6): Achievable performance at the optimum

The green line is the best reliability achievable at each hour. It is highest, at about 0.88, just before sunrise and after sunset, and lowest, at about 0.70, at noon. The red dashed line shows the SNR at the chosen frequency and follows the same pattern, staying well above the 10 dB requirement throughout. The dip at midday might look surprising, since the ionosphere is at its “best” then. The cause is that the optimum frequency is forced to be high (22 MHz), which costs free-space loss, and the residual absorption remains larger in daylight. The transitions at dawn and dusk give the best compromise: the MUF is high enough to allow a comfortable frequency, but the D layer is only weakly ionized.

Conclusion

We turned the qualitative rule of thumb “use a frequency somewhat below the MUF” into a quantitative optimization. By combining a geometric model, a simple ionosphere model, a link budget and a probabilistic reliability function, we found an optimal frequency for each hour of the day. The optimum lands naturally at 75 to 85 percent of the MUF, and it moves by more than a factor of two between night and day. Because every function was written to broadcast over NumPy arrays, adding another dimension such as solar activity cost nothing in code complexity, and a full three-dimensional sweep ran in a fraction of a second.

The same framework extends easily. You can add multi-hop paths, replace the toy ionosphere with real foF2 predictions, sweep the path distance, or compare antenna designs by changing GT_DBI and GR_DBI. Since the objective function is a plain Python function, any of these changes only requires editing the model in Section 3.

Minimizing Cosmic Radiation Exposure on Flight Routes

A Python Simulation

Every time an aircraft climbs above 30,000 feet, it leaves most of the atmosphere’s shielding behind. At cruise altitude, passengers and crew are exposed to galactic cosmic rays (GCR) at rates many times higher than at sea level. Polar and near-polar routes — like the great-circle path from Tokyo to New York — pass through regions where the Earth’s magnetic field offers the least protection, making cosmic radiation dose a real operational consideration for airlines, especially for frequent flyers and aircrew.

In this article, we build a simplified physical model of cosmic radiation dose rate as a function of altitude, geomagnetic latitude, and solar activity, then apply it to a concrete example: optimizing the cruise altitude for a Tokyo (NRT) → New York (JFK) flight to minimize total radiation exposure.

Note on scope: the model below is an illustrative, simplified approximation built for demonstrating the methodology in Python. It is not a certified dosimetry tool. For real operational or regulatory dose assessments, tools such as CARI-7 (FAA), EPCARD, or NAIRAS (NOAA) should be used instead.

The Physics Behind the Model

Three effects dominate GCR dose rate for a commercial flight:

1. Altitude shielding. Atmospheric mass shields cosmic rays. As altitude increases, the remaining atmospheric depth decreases roughly exponentially, so dose rate grows exponentially with altitude:

$$
D_{alt}(h) = \exp\left(\frac{h}{H}\right)
$$

where $h$ is altitude in km and $H$ is an effective atmospheric scale height.

2. Geomagnetic shielding. The Earth’s magnetic field deflects charged particles, and this shielding is strongest near the geomagnetic equator and weakest near the poles. This is approximated with the classical Störmer cutoff-rigidity dependence on geomagnetic latitude $\phi_m$:

$$
D_{lat}(\phi_m) = 1 - k_\lambda \cos^{4}(\phi_m)
$$

3. Solar modulation. During solar maximum, a stronger solar wind partially deflects incoming GCR, reducing dose; during solar minimum, GCR flux — and dose — is higher:

$$
D_{solar}(S) = 1 - \alpha_S , S, \qquad S \in [0,1]
$$

Combining all three, the effective dose rate (µSv/h) is:

$$
D(h, \phi_m, S) = D_0 \cdot \exp\left(\frac{h}{H}\right) \cdot \left[1 - k_\lambda \cos^{4}(\phi_m)\right] \cdot (1 - \alpha_S S)
$$

Geomagnetic latitude itself is derived from geographic coordinates via a dipole approximation:

$$
\sin(\phi_m) = \sin(\phi)\sin(\phi_p) + \cos(\phi)\cos(\phi_p)\cos(\lambda - \lambda_p)
$$

where $(\phi_p, \lambda_p)$ is the geomagnetic north pole location.

Example Problem

Route: NRT (35.76°N, 140.39°E) → JFK (40.64°N, 73.78°W), flown along the great-circle path (which passes close to the Arctic).

Goal: For cruise levels FL290 through FL410, compute the total radiation dose accumulated over the flight, under both solar minimum and solar maximum conditions, and identify which cruise altitude minimizes exposure.

Full Source Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
# =====================================================================
# Cosmic Radiation Dose Along Flight Routes — Colab-ready simulation
# =====================================================================
import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D # noqa: F401 (enables 3D projection)
from matplotlib import cm
from matplotlib.gridspec import GridSpec

# ---------------------------------------------------------------------
# 1. Simplified galactic cosmic ray (GCR) dose-rate model
# (illustrative model for educational purposes; NOT a certified
# dosimetry tool — use CARI-7 / EPCARD / NAIRAS for real assessments)
# ---------------------------------------------------------------------
D0 = 1.2 # reference dose-rate coefficient [uSv/h]
H_SCALE = 6.5 # atmospheric scale height for GCR build-up [km]
K_LAT = 0.75 # geomagnetic shielding coefficient (0-1)
ALPHA_S = 0.35 # solar-cycle modulation coefficient (0-1)

GM_POLE_LAT = 80.7 # geomagnetic north pole latitude [deg] (approx., IGRF)
GM_POLE_LON = -72.7 # geomagnetic north pole longitude [deg]

def geomagnetic_latitude(lat_deg, lon_deg):
"""Dipole approximation of geomagnetic latitude [deg]."""
lat, lon = np.radians(lat_deg), np.radians(lon_deg)
lat_p, lon_p = np.radians(GM_POLE_LAT), np.radians(GM_POLE_LON)
sin_phi_m = (np.sin(lat) * np.sin(lat_p) +
np.cos(lat) * np.cos(lat_p) * np.cos(lon - lon_p))
return np.degrees(np.arcsin(np.clip(sin_phi_m, -1.0, 1.0)))

def dose_rate(h_km, phi_m_deg, S):
"""
Effective dose rate [uSv/h].
h_km : cruise altitude [km]
phi_m_deg : geomagnetic latitude [deg]
S : solar activity index, 0 = solar minimum, 1 = solar maximum
"""
phi_m = np.radians(phi_m_deg)
altitude_term = np.exp(h_km / H_SCALE)
latitude_term = 1.0 - K_LAT * np.cos(phi_m) ** 4
solar_term = 1.0 - ALPHA_S * S
return D0 * altitude_term * latitude_term * solar_term

# ---------------------------------------------------------------------
# 2. Great-circle route generator (spherical linear interpolation)
# ---------------------------------------------------------------------
R_EARTH = 6371.0 # km

def latlon_to_vec(lat_deg, lon_deg):
lat, lon = np.radians(lat_deg), np.radians(lon_deg)
return np.array([np.cos(lat) * np.cos(lon),
np.cos(lat) * np.sin(lon),
np.sin(lat)])

def great_circle_route(lat1, lon1, lat2, lon2, n=300):
v1, v2 = latlon_to_vec(lat1, lon1), latlon_to_vec(lat2, lon2)
omega = np.arccos(np.clip(np.dot(v1, v2), -1.0, 1.0))
t = np.linspace(0.0, 1.0, n)
sin_o = np.sin(omega)
if sin_o < 1e-10:
pts = np.tile(v1, (n, 1))
else:
a = (np.sin((1 - t) * omega) / sin_o)[:, None]
b = (np.sin(t * omega) / sin_o)[:, None]
pts = a * v1 + b * v2
lat = np.degrees(np.arcsin(np.clip(pts[:, 2], -1.0, 1.0)))
lon = np.degrees(np.arctan2(pts[:, 1], pts[:, 0]))
distance_km = omega * R_EARTH
return lat, lon, distance_km

# ---------------------------------------------------------------------
# 3. Route dose integration
# ---------------------------------------------------------------------
def total_route_dose(lat, lon, h_km, S, distance_km, ground_speed_kmh=900.0):
phi_m = geomagnetic_latitude(lat, lon)
rate = dose_rate(h_km, phi_m, S) # uSv/h along the route
n = len(lat)
seg_time_h = (distance_km / (n - 1)) / ground_speed_kmh
dose = np.trapz(rate, dx=seg_time_h) # uSv
flight_time_h = distance_km / ground_speed_kmh
return dose, flight_time_h, rate, phi_m

# ---------------------------------------------------------------------
# 4. Example case: Tokyo–Narita (NRT) to New York–JFK (near-polar route)
# ---------------------------------------------------------------------
LAT1, LON1 = 35.76, 140.39 # NRT
LAT2, LON2 = 40.64, -73.78 # JFK

lat_route, lon_route, distance_km = great_circle_route(LAT1, LON1, LAT2, LON2, n=300)

FL_LIST_FT = [29000, 33000, 35000, 37000, 39000, 41000]
FL_LABELS = ["FL290", "FL330", "FL350", "FL370", "FL390", "FL410"]
FL_KM = [ft * 0.0003048 for ft in FL_LIST_FT]

dose_min_list, dose_max_list, time_list = [], [], []
for h_km in FL_KM:
d_min, t_h, _, _ = total_route_dose(lat_route, lon_route, h_km, S=0.0, distance_km=distance_km)
d_max, _, _, _ = total_route_dose(lat_route, lon_route, h_km, S=1.0, distance_km=distance_km)
dose_min_list.append(d_min)
dose_max_list.append(d_max)
time_list.append(t_h)

best_idx = int(np.argmin(dose_min_list))

print("=" * 62)
print(f"Route : NRT ({LAT1:.2f}N, {LON1:.2f}E) -> JFK ({LAT2:.2f}N, {LON2:.2f}E)")
print(f"Great-circle dist : {distance_km:,.0f} km")
print(f"Assumed groundspeed: 900 km/h -> flight time ~ {time_list[0]:.2f} h")
print("-" * 62)
print(f"{'FL':<8}{'Alt[km]':<10}{'Dose(min)[uSv]':<18}{'Dose(max)[uSv]':<18}")
for lbl, hk, dmin, dmax in zip(FL_LABELS, FL_KM, dose_min_list, dose_max_list):
print(f"{lbl:<8}{hk:<10.2f}{dmin:<18.2f}{dmax:<18.2f}")
print("-" * 62)
print(f"Lowest-dose cruise level (solar minimum): {FL_LABELS[best_idx]} "
f"({dose_min_list[best_idx]:.2f} uSv)")
saving = (dose_min_list[-1] - dose_min_list[best_idx]) / dose_min_list[-1] * 100
print(f"Dose saving vs highest FL ({FL_LABELS[-1]}): {saving:.1f} %")
print("=" * 62)

# ---------------------------------------------------------------------
# 5. Detailed profile at FL350 for the route-map and line-chart panels
# ---------------------------------------------------------------------
H_FL350 = 35000 * 0.0003048
_, _, rate_fl350_min, phi_m_route = total_route_dose(lat_route, lon_route, H_FL350, 0.0, distance_km)
_, _, rate_fl350_max, _ = total_route_dose(lat_route, lon_route, H_FL350, 1.0, distance_km)
cum_dist_km = np.linspace(0, distance_km, len(lat_route))

# ---------------------------------------------------------------------
# 6. Combined figure (2x2): 3D surface + route map + profile + bar chart
# ---------------------------------------------------------------------
fig = plt.figure(figsize=(16, 13))
gs = GridSpec(2, 2, figure=fig, hspace=0.35, wspace=0.3)

# --- Panel 1: 3D dose-rate surface ---
ax1 = fig.add_subplot(gs[0, 0], projection="3d")
h_grid = np.linspace(6.0, 13.0, 60)
phi_grid = np.linspace(-90.0, 90.0, 60)
Hg, Pg = np.meshgrid(h_grid, phi_grid)
Dg = dose_rate(Hg, Pg, S=0.0)
surf = ax1.plot_surface(Hg, Pg, Dg, cmap=cm.viridis, linewidth=0, antialiased=True)
ax1.set_xlabel("Altitude [km]")
ax1.set_ylabel("Geomagnetic latitude [deg]")
ax1.set_zlabel("Dose rate [uSv/h]")
ax1.set_title("GCR Dose Rate vs Altitude & Geomagnetic Latitude\n(Solar Minimum)")
fig.colorbar(surf, ax=ax1, shrink=0.6, pad=0.12, label="uSv/h")

# --- Panel 2: route map colored by dose rate ---
ax2 = fig.add_subplot(gs[0, 1])
ax2.plot(lon_route, lat_route, color="gray", lw=0.6, alpha=0.6, zorder=1)
sc = ax2.scatter(lon_route, lat_route, c=rate_fl350_min, cmap="inferno", s=14, zorder=2)
ax2.scatter([LON1, LON2], [LAT1, LAT2], color="deepskyblue", marker="*",
s=250, edgecolor="black", zorder=3, label="NRT / JFK")
ax2.set_xlabel("Longitude [deg]")
ax2.set_ylabel("Latitude [deg]")
ax2.set_title("Great-Circle Route: NRT -> JFK\n(color = dose rate at FL350, solar minimum)")
ax2.legend(loc="lower right")
ax2.grid(alpha=0.3)
fig.colorbar(sc, ax=ax2, label="uSv/h")

# --- Panel 3: dose-rate profile along the route ---
ax3 = fig.add_subplot(gs[1, 0])
ax3.plot(cum_dist_km, rate_fl350_min, color="crimson", label="Solar minimum")
ax3.plot(cum_dist_km, rate_fl350_max, color="dodgerblue", label="Solar maximum")
ax3.set_xlabel("Distance along route [km]")
ax3.set_ylabel("Dose rate [uSv/h]")
ax3.set_title("Dose Rate Profile Along Route (Cruise FL350)")
ax3.legend()
ax3.grid(alpha=0.3)

# --- Panel 4: total dose per cruise altitude ---
ax4 = fig.add_subplot(gs[1, 1])
x = np.arange(len(FL_LABELS))
w = 0.35
ax4.bar(x - w/2, dose_min_list, w, color="crimson", label="Solar minimum")
ax4.bar(x + w/2, dose_max_list, w, color="dodgerblue", label="Solar maximum")
ax4.set_xticks(x)
ax4.set_xticklabels(FL_LABELS)
ax4.set_xlabel("Cruise flight level")
ax4.set_ylabel("Total route dose [uSv]")
ax4.set_title("Total Effective Dose per Cruise Altitude")
ax4.legend()
ax4.grid(alpha=0.3, axis="y")

plt.suptitle("Cosmic Radiation Exposure Analysis — Tokyo(NRT) to New York(JFK)",
fontsize=15, y=1.02)
plt.tight_layout()
plt.savefig("cosmic_radiation_analysis.png", dpi=150, bbox_inches="tight")
plt.show()

Code Walkthrough

Section 1 — the dose-rate model. geomagnetic_latitude() converts geographic coordinates into geomagnetic latitude using the dipole formula shown earlier, fully vectorized with NumPy so it works on both single points and entire route arrays. dose_rate() combines the three multiplicative terms — altitude, geomagnetic latitude, and solar activity — into a single effective dose rate in µSv/h. All operations use NumPy ufuncs (np.exp, np.cos, np.arcsin), so the function evaluates instantly whether given a scalar or a 300-point array.

Section 2 — the great-circle route. Rather than naive linear interpolation in latitude/longitude (which does not represent the true shortest path on a sphere and breaks down near the poles), the route is generated with spherical linear interpolation (slerp): both endpoints are converted to 3D unit vectors, interpolated along the great-circle arc using the angle $\omega$ between them, then converted back to latitude/longitude. This correctly handles the Tokyo–New York route, which passes near the Arctic.

Section 3 — dose integration. total_route_dose() computes the dose rate at every point along the route, then integrates it over flight time using np.trapz (trapezoidal integration), assuming a constant ground speed of 900 km/h. This gives the total accumulated dose in µSv for the whole flight.

Section 4 — the example run. The route is generated once (300 points), and dose is computed for six candidate cruise levels (FL290–FL410) at both solar minimum (S=0) and solar maximum (S=1). Results are printed as a formatted table, and the flight level with the lowest total dose is identified automatically.

Section 5 — profile extraction. For the visualizations, the dose-rate profile at the standard cruise level FL350 is computed point-by-point along the route, for both solar conditions.

Section 6 — the combined figure. All four plots are placed into a single 2×2 GridSpec figure so only one image is produced. No further performance optimization is needed here: the entire computation is vectorized NumPy over an array of just 300 points across 6 altitudes — it completes in well under a second, so no parallelization, JIT compilation, or GPU acceleration is required.



==============================================================
Route             : NRT (35.76N, 140.39E) -> JFK (40.64N, -73.78E)
Great-circle dist : 10,831 km
Assumed groundspeed: 900 km/h  ->  flight time ~ 12.03 h
--------------------------------------------------------------
FL      Alt[km]   Dose(min)[uSv]    Dose(max)[uSv]    
FL290   8.84      50.74             32.98             
FL330   10.06     61.21             39.79             
FL350   10.67     67.23             43.70             
FL370   11.28     73.84             47.99             
FL390   11.89     81.10             52.71             
FL410   12.50     89.07             57.90             
--------------------------------------------------------------
Lowest-dose cruise level (solar minimum): FL290 (50.74 uSv)
Dose saving vs highest FL (FL410): 43.0 %
==============================================================

Understanding the Graphs

Top-left — 3D dose-rate surface. This shows dose rate as a function of both altitude (6–13 km) and geomagnetic latitude (−90° to 90°) at solar minimum. The surface rises steeply toward higher altitudes (exponential altitude term) and toward the poles (weaker geomagnetic shielding), while dipping toward the geomagnetic equator. This single plot summarizes the entire physical model: the highest exposure risk is high-altitude, high-latitude flight during solar minimum.

Top-right — route map. The great-circle path from NRT to JFK is plotted in longitude/latitude space, with each point colored by its FL350 dose rate at solar minimum. Because the route swings up toward high geomagnetic latitudes over the North Pacific/Arctic region, you can see the color shift toward higher dose rates in the middle portion of the flight compared to the endpoints.

Bottom-left — dose-rate profile along the route. This line chart tracks dose rate versus cumulative distance flown, comparing solar minimum (red) and solar maximum (blue) at fixed FL350. The gap between the two curves is the solar-cycle modulation effect — roughly a 35% reduction in dose rate during solar maximum, consistent across the whole route since the solar term is a simple multiplicative factor.

Bottom-right — total dose by cruise altitude. This bar chart is the answer to the original optimization question: it shows total accumulated dose (µSv) for each candidate flight level, for both solar conditions. Total dose increases monotonically with cruise altitude, so — from a pure radiation-minimization standpoint — the lowest feasible cruise altitude (FL290) yields the least exposure, while FL410 yields the most. In practice this must be balanced against fuel efficiency, air traffic control constraints, and turbulence avoidance, since lower cruise altitudes generally burn more fuel per distance flown.

Takeaways

The simulation illustrates three practical levers for reducing cosmic radiation exposure on long-haul flights: flying at lower cruise altitudes, avoiding high-geomagnetic-latitude routings when feasible, and — outside of operational control — timing (dose is naturally lower during solar maximum). For actual flight planning or occupational dose monitoring, airlines and regulators rely on validated tools such as CARI-7, EPCARD, or NAIRAS, which incorporate measured cosmic ray spectra and real-time solar activity data rather than the simplified analytical model used here for demonstration.

Minimizing Astronaut Radiation Exposure

An Optimization Approach with Python

Space radiation is one of the most persistent hazards astronauts face on long-duration missions. Outside the protective bubble of Earth’s magnetosphere, crews are exposed to three very different radiation environments: galactic cosmic rays (GCR) that stream in continuously from outside the solar system, sporadic but intense solar particle events (SPE) triggered by solar flares and coronal mass ejections, and trapped radiation belts (Van Allen belts) encountered while passing through certain low Earth orbit (LEO) altitudes.

Simply adding more shielding mass is not a free lunch. High-energy GCR particles interact with shielding material and produce secondary particles — a phenomenon that means the dose-reduction benefit per additional gram of shielding shrinks the thicker the wall gets. At the same time, every kilogram of shielding competes with fuel, life support, and payload in a spacecraft’s mass budget. This turns radiation protection into a genuine optimization problem: given a fixed mass budget, what shielding thickness and orbital strategy minimizes the total dose a crew receives?

In this post, we build a simplified but physically motivated model of mission radiation dose, then use Python to find the shielding thickness and orbital altitude that minimizes total exposure under a realistic mass constraint.

The Physical Model

We model the total dose received during a mission as the sum of three contributions, each attenuated by shielding thickness $x$ (measured in areal density, $\text{g/cm}^2$ of aluminum-equivalent material).

Galactic cosmic ray dose, including the buildup of secondary particles produced when high-energy GCR ions fragment inside the shield:

$$
D_{\text{GCR}}(x) = D_0^{\text{GCR}} e^{-x/L_{\text{GCR}}} + k_{\text{sec}}, x, e^{-x/L_{\text{sec}}}
$$

The first term is the familiar exponential attenuation of primary particles; the second term captures the secondary-particle production that partially offsets the benefit of thicker shielding.

Solar particle event dose, which attenuates faster than GCR because SPE protons are lower in energy:

$$
D_{\text{SPE}}(x) = D_0^{\text{SPE}} e^{-x/L_{\text{SPE}}}
$$

Trapped radiation dose, which depends on orbital altitude $h$ because the inner Van Allen belt intensifies as altitude increases through LEO:

$$
D_{\text{trap}}(x, h) = A_{\text{trap}}, e^{(h-h_0)/H}, e^{-x/L_{\text{trap}}}
$$

The total mission dose, combining a deep-space transit phase, a number of solar events, and a stay in LEO, is:

$$
D_{\text{total}}(x,h) = t_{\text{transit}},D_{\text{GCR}}(x) + n_{\text{events}},D_{\text{SPE}}(x) + t_{\text{LEO}},D_{\text{trap}}(x,h)
$$

Finally, the mass budget $M_{\max}$ (kg) available for shielding over a hull area $A$ (m²) sets an upper bound on thickness:

$$
x_{\max} = \frac{M_{\max} \times 1000}{A \times 10000}\ \left[\text{g/cm}^2\right]
$$

The Optimization Problem

Example mission: a 180-day deep-space transit, 2 major solar particle events, and a 30-day stay in LEO, with a shielding mass budget of 3000 kg spread over a 15 m² hull.

$$
\min_{x,,h} \ D_{\text{total}}(x,h) \quad \text{subject to} \quad 0 \le x \le x_{\max}, \ \ 300\ \text{km} \le h \le 800\ \text{km}
$$

We solve this with scipy.optimize.minimize, and visualize the full dose landscape with a 3D surface plot plus a component breakdown.

Source Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
from scipy.optimize import minimize

# =====================================================================
# 1. Physical model parameters (simplified, order-of-magnitude realistic)
# =====================================================================
D0_GCR = 0.60 # mSv/day, unshielded GCR dose rate in deep space
L_GCR = 20.0 # g/cm^2, GCR attenuation length
K_SEC = 0.05 # mSv/day per g/cm^2, secondary-particle production coefficient
L_SEC = 8.0 # g/cm^2, secondary-particle decay length

D0_SPE = 150.0 # mSv, unshielded dose per major solar particle event
L_SPE = 6.0 # g/cm^2, SPE attenuation length

A_TRAP = 0.01 # mSv/day, trapped-radiation dose rate at reference altitude
H_REF = 300.0 # km, reference altitude
H_SCALE = 120.0 # km, altitude scale for trapped radiation growth
L_TRAP = 8.0 # g/cm^2, trapped-radiation attenuation length

T_TRANSIT = 180 # days, deep-space transit duration
T_LEO = 30 # days, LEO stay duration
N_EVENTS = 2 # number of major SPEs during the mission

AREA_M2 = 15.0 # m^2, shielded hull area
MASS_BUDGET = 3000.0 # kg, total mass available for shielding
X_MAX = (MASS_BUDGET * 1000.0) / (AREA_M2 * 10000.0) # g/cm^2

# =====================================================================
# 2. Dose functions (vectorized with NumPy — no Python loops)
# =====================================================================
def dose_gcr(x):
x = np.asarray(x, dtype=float)
return D0_GCR * np.exp(-x / L_GCR) + K_SEC * x * np.exp(-x / L_SEC)

def dose_spe(x):
x = np.asarray(x, dtype=float)
return D0_SPE * np.exp(-x / L_SPE)

def dose_trapped(x, h):
x = np.asarray(x, dtype=float)
d0_h = A_TRAP * np.exp((h - H_REF) / H_SCALE)
return d0_h * np.exp(-x / L_TRAP)

def total_dose(x, h):
return (T_TRANSIT * dose_gcr(x)
+ N_EVENTS * dose_spe(x)
+ T_LEO * dose_trapped(x, h))

def objective(params):
x, h = params
return total_dose(x, h)

# =====================================================================
# 3. Optimization
# =====================================================================
x0 = np.array([X_MAX * 0.5, 500.0])
bounds = [(0.0, X_MAX), (300.0, 800.0)]
res = minimize(objective, x0, method="L-BFGS-B", bounds=bounds)
x_opt, h_opt = res.x
dose_opt = res.fun
dose_unshielded = total_dose(0.0, h_opt)
reduction_pct = 100.0 * (1.0 - dose_opt / dose_unshielded)

print("===== Mission Radiation Shielding Optimization =====")
print(f"Areal-density budget (X_MAX): {X_MAX:6.2f} g/cm^2")
print(f"Optimal shielding thickness x*: {x_opt:6.2f} g/cm^2")
print(f"Optimal orbit altitude h*: {h_opt:6.1f} km")
print(f"Minimum total mission dose D*: {dose_opt:6.2f} mSv")
print(f"Unshielded reference dose (x=0): {dose_unshielded:6.2f} mSv")
print(f"Dose reduction achieved: {reduction_pct:5.1f} %")

# =====================================================================
# 4. Build the dose landscape (vectorized meshgrid, fast)
# =====================================================================
x_grid = np.linspace(0.01, X_MAX, 150)
h_grid = np.linspace(300, 800, 150)
X, H = np.meshgrid(x_grid, h_grid)
D = total_dose(X, H)

x_line = np.linspace(0.01, X_MAX, 300)
d_gcr_line = T_TRANSIT * dose_gcr(x_line)
d_spe_line = N_EVENTS * dose_spe(x_line)
d_trap_line = T_LEO * dose_trapped(x_line, h_opt)
d_total_line = d_gcr_line + d_spe_line + d_trap_line

# =====================================================================
# 5. Visualization: 3D dose surface + 2D component breakdown
# =====================================================================
fig = plt.figure(figsize=(15, 6))

ax1 = fig.add_subplot(1, 2, 1, projection="3d")
surf = ax1.plot_surface(X, H, D, cmap="viridis", alpha=0.9,
linewidth=0, antialiased=True)
ax1.scatter([x_opt], [h_opt], [dose_opt], color="red", s=60,
depthshade=False, label="Optimal point")
ax1.set_xlabel("Shielding thickness x [g/cm^2]")
ax1.set_ylabel("Orbit altitude h [km]")
ax1.set_zlabel("Total mission dose [mSv]")
ax1.set_title("Total Mission Dose Surface D(x, h)")
fig.colorbar(surf, ax=ax1, shrink=0.6, aspect=12, pad=0.1, label="Dose [mSv]")
ax1.legend()

ax2 = fig.add_subplot(1, 2, 2)
ax2.plot(x_line, d_total_line, color="black", linewidth=2.5, label="Total dose")
ax2.plot(x_line, d_gcr_line, "--", color="tab:blue", label="GCR + secondary")
ax2.plot(x_line, d_spe_line, "--", color="tab:orange", label="Solar particle events")
ax2.plot(x_line, d_trap_line, "--", color="tab:green", label="Trapped radiation (LEO)")
ax2.axvline(x_opt, color="red", linestyle=":", linewidth=2)
ax2.scatter([x_opt], [dose_opt], color="red", zorder=5,
label=f"Optimum x*={x_opt:.2f}")
ax2.set_xlabel("Shielding thickness x [g/cm^2]")
ax2.set_ylabel("Dose contribution [mSv]")
ax2.set_title(f"Dose Components vs Shielding Thickness (h = {h_opt:.0f} km)")
ax2.legend()
ax2.grid(alpha=0.3)

plt.tight_layout()
plt.show()

Code Walkthrough

Model parameters (Section 1). Each constant maps directly to a physical quantity: D0_GCR and L_GCR describe the unshielded GCR dose rate and how quickly it falls off with shielding depth; K_SEC and L_SEC describe the secondary-particle buildup term that limits the effectiveness of thick shielding; D0_SPE/L_SPE describe solar event dose, which attenuates much faster than GCR since SPE protons are lower-energy; and A_TRAP/H_SCALE/L_TRAP describe how trapped-belt dose grows with altitude. X_MAX converts the mass budget and hull area into a maximum areal-density shielding thickness — this is the hard engineering constraint the optimizer must respect.

Dose functions (Section 2). Each function is written with NumPy array operations (np.exp, elementwise arithmetic) rather than for loops, so they evaluate a single point or an entire array of thousands of points in the same call. This is what keeps the script fast: it never iterates in pure Python.

Optimization (Section 3). scipy.optimize.minimize with the L-BFGS-B method is used because it natively supports box constraints (bounds), which is exactly what we need for $0 \le x \le x_{\max}$ and $300 \le h \le 800$. The solver converges in a handful of iterations since the objective is smooth. The printed summary reports the optimal thickness, optimal altitude, the resulting minimum dose, and how much that represents as a percentage reduction versus an unshielded spacecraft.

Building the dose landscape (Section 4). Rather than looping over every $(x, h)$ pair, np.meshgrid creates two 2D coordinate arrays, and total_dose(X, H) evaluates the entire $150 \times 150$ grid in one vectorized call. This produces the full dose surface in well under a second, so no further speed optimization (batching, multiprocessing, etc.) is needed here — the vectorized NumPy approach is already the fast path.

Visualization (Section 5). The left panel is a 3D surface plot of $D_{\text{total}}(x,h)$ with the optimizer’s solution marked as a red point, so you can see at a glance where it sits on the landscape (and confirm visually whether it’s an interior minimum or lies on the boundary of the mass/altitude constraints). The right panel decomposes the total dose along the optimal-altitude slice into its three physical contributions, making the trade-offs — and the diminishing-returns “knee” in the GCR curve caused by secondary-particle production — directly visible.

===== Mission Radiation Shielding Optimization =====
Areal-density budget (X_MAX):      20.00 g/cm^2
Optimal shielding thickness x*:     20.00 g/cm^2
Optimal orbit altitude h*:          300.0 km
Minimum total mission dose D*:      65.23 mSv
Unshielded reference dose (x=0):   408.30 mSv
Dose reduction achieved:            84.0 %

Interpreting the Results

For the example mission parameters used here, the optimizer pushes shielding thickness to the edge of the mass budget (roughly 20 g/cm² under a 3000 kg / 15 m² budget) and selects the lowest available orbital altitude, since trapped-belt dose grows with altitude in this model. The right-hand panel shows why: solar-particle-event dose falls off steeply with even modest shielding, making early shielding very cost-effective, while the GCR curve flattens out — a visible reminder that beyond a certain thickness, adding more aluminum mass buys progressively less protection because of secondary-particle production. In a real mission this is exactly why shielding strategy is combined with operational measures — scheduling extravehicular activity around solar-quiet periods, using consumables and water tanks as auxiliary shielding, and choosing transit windows during solar maximum when GCR flux is naturally lower — rather than relying on mass alone.

Caveats

This model is intentionally simplified for illustration: it uses single exponential attenuation terms rather than full particle-transport physics (e.g., NASA’s HZETRN or Geant4 simulations), and the numerical constants are representative rather than mission-specific. It is meant to demonstrate the optimization methodology — how shielding mass, orbital geometry, and mission duration interact — not to serve as an actual mission radiation budget.

Designing Solar Arrays That Survive Space

Risk-Optimized Degradation Planning with Python

A satellite lives or dies by its power budget. The solar array is the only source of electricity for the entire mission, yet it is under constant attack from trapped radiation, solar particle events, thermal cycling, and ultraviolet exposure. Every year in orbit, the array delivers a little less power than the year before.

The naive engineering answer is to size the array for the average degradation. That design misses its end-of-life requirement roughly half of the time. The opposite answer is to add a huge safety margin, which costs launch mass, and mass is money. In this article we treat the problem as a chance-constrained optimization and solve it with Monte Carlo simulation in Python.

1. The Problem

We design a solar array for a satellite in a harsh radiation environment (think of a medium-altitude orbit). The mission requirements are:

  • Mission life: $T = 15$ years
  • Required end-of-life (EOL) power: $P_{\mathrm{req}} = 6000\ \mathrm{W}$
  • The probability of falling short of $P_{\mathrm{req}}$ at EOL must not exceed $\alpha = 5%$

We have two design variables:

  • $t$: the equivalent shield (coverglass) thickness in mm. A thicker shield blocks more radiation, but it is heavy.
  • $A$: the array area in $\mathrm{m}^2$. A larger array produces more power, but it is also heavy.

The objective is to minimize the array mass:

$$
\min_{t,,A}; M(t,A) = A,\bigl(m_0 + \rho,t\bigr)
$$

$$
\text{subject to}\quad \Pr\bigl[,P_{\mathrm{EOL}}(t,A,\omega) \ge P_{\mathrm{req}},\bigr] \ge 1-\alpha
$$

Here $m_0 = 4.2\ \mathrm{kg/m^2}$ is the areal mass of the bare array, $\rho = 2.6\ \mathrm{kg/m^2/mm}$ is the areal mass added per millimeter of shield, and $\omega$ denotes one random scenario.

2. The Degradation Model

The power at end of life is

$$
P_{\mathrm{EOL}} = A, p_{\mathrm{BOL}},\bigl(1 - L_{\mathrm{rad}}\bigr)\bigl(1 - k,T\bigr)
$$

where $p_{\mathrm{BOL}} = S_0,\eta,f_{\mathrm{pack}},f_{\cos},f_{\mathrm{temp}} \approx 306.6\ \mathrm{W/m^2}$ is the beginning-of-life power density.

The radiation loss follows the well-known semi-empirical logarithmic law:

$$
L_{\mathrm{rad}} = C,\log_{10}!\left(1 + \frac{\Phi_{\mathrm{eff}}}{\Phi_x}\right)
$$

The effective fluence behind the shield is attenuated exponentially with thickness. It has two components, a continuous background and the sum of discrete solar particle events (SPEs):

$$
\Phi_{\mathrm{eff}} = \Phi_{\mathrm{bg}},e^{-t/\lambda_{\mathrm{bg}}} + \Phi_{\mathrm{spe}},e^{-t/\lambda_{\mathrm{spe}}}
$$

$$
\Phi_{\mathrm{bg}} = r_{\mathrm{bg}},T, \qquad \Phi_{\mathrm{spe}} = \sum_{j=1}^{N} \phi_j, \qquad N \sim \mathrm{Poisson}(\lambda_{\mathrm{ev}} T)
$$

The random ingredients are:

Quantity Distribution
Background fluence rate $r_{\mathrm{bg}}$ Log-normal, median $4\times10^{14}$, $\sigma = 0.30$
SPE fluence per event $\phi_j$ Log-normal, median $8\times10^{13}$, $\sigma = 1.5$
Number of SPEs $N$ Poisson, 1 event per year
Damage coefficient $C$ Normal, mean 0.18, std 0.02
Thermal/UV loss rate $k$ Normal, mean 0.4 %/year, std 0.12 %/year

3. A Fast Solution Strategy

A brute-force approach would simulate every design pair $(t, A)$ separately. The chance constraint has a much better structure. For a fixed thickness $t$, define the EOL power per unit area $u(t,\omega) = P_{\mathrm{EOL}}/A$. The shortfall event is $A,u < P_{\mathrm{req}}$, and therefore

$$
\Pr\bigl[A,u(t,\omega) \ge P_{\mathrm{req}}\bigr] \ge 1-\alpha
;\Longleftrightarrow;
A \ge A_{\mathrm{req}}(t) = \frac{P_{\mathrm{req}}}{Q_{\alpha}\bigl[u(t,\cdot)\bigr]}
$$

where $Q_\alpha$ is the $\alpha$-quantile of the scenario distribution. The two-dimensional stochastic problem collapses into a one-dimensional deterministic problem:

$$
\min_{t}; M(t) = A_{\mathrm{req}}(t),\bigl(m_0 + \rho,t\bigr)
$$

The full risk map over $(t, A)$ is also cheap. After sorting the scenarios once per thickness, the shortfall probability for any area is a single binary search. All 20,000 scenarios share the same random numbers across every design (common random numbers), which keeps the risk surface smooth and the comparison between designs fair. Everything is vectorized with NumPy, with no Python loop over scenarios.

4. The Complete Source Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
import time
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.colors import Normalize
from mpl_toolkits.mplot3d import Axes3D # noqa: F401

plt.style.use("dark_background")

# ------------------------------------------------------------
# 1. Problem parameters
# ------------------------------------------------------------
SEED = 2026
N_MC = 20000
T_YEARS = 15.0
P_REQ = 6000.0
CONFIDENCE = 0.95
ALPHA = 1.0 - CONFIDENCE

S0 = 1361.0
ETA_BOL = 0.30
PACKING = 0.85
COS_LOSS = 0.95
TEMP_FACTOR = 0.93
UNIT_POWER_BOL = S0 * ETA_BOL * PACKING * COS_LOSS * TEMP_FACTOR

M_BASE = 4.2
RHO_GLASS = 2.6

PHI_X = 5.0e13
LAM_BG = 0.10
LAM_SPE = 0.12
BG_RATE_MEDIAN = 4.0e14
BG_SIGMA = 0.30
SPE_RATE = 1.0
SPE_MEDIAN = 8.0e13
SPE_SIGMA = 1.5
C_MEAN, C_STD = 0.18, 0.02
K_MEAN, K_STD = 0.004, 0.0012

# ------------------------------------------------------------
# 2. Monte Carlo scenarios (shared by every design candidate)
# ------------------------------------------------------------
rng = np.random.default_rng(SEED)
BG_RATE = rng.lognormal(np.log(BG_RATE_MEDIAN), BG_SIGMA, N_MC)
C_SAMPLE = np.clip(rng.normal(C_MEAN, C_STD, N_MC), 0.10, 0.26)
K_SAMPLE = np.clip(rng.normal(K_MEAN, K_STD, N_MC), 0.0, None)
N_EVENTS = rng.poisson(SPE_RATE * T_YEARS, N_MC)
MAX_EV = max(int(N_EVENTS.max()), 1)
EV_MAG = rng.lognormal(np.log(SPE_MEDIAN), SPE_SIGMA, (N_MC, MAX_EV))
EV_TIME = rng.uniform(0.0, T_YEARS, (N_MC, MAX_EV))
EV_MASK = np.arange(MAX_EV)[None, :] < N_EVENTS[:, None]
EV_MAG = EV_MAG * EV_MASK
PHI_BG_EOL = BG_RATE * T_YEARS
PHI_SPE_EOL = EV_MAG.sum(axis=1)


def eol_unit_power(t_mm):
"""End-of-life power per square metre, shape (n_thickness, N_MC)."""
t = np.atleast_1d(np.asarray(t_mm, dtype=float))[:, None]
phi = PHI_BG_EOL[None, :] * np.exp(-t / LAM_BG) + PHI_SPE_EOL[None, :] * np.exp(-t / LAM_SPE)
rad_loss = C_SAMPLE[None, :] * np.log10(1.0 + phi / PHI_X)
other_loss = K_SAMPLE[None, :] * T_YEARS
return UNIT_POWER_BOL * (1.0 - rad_loss) * (1.0 - other_loss)


def power_timeseries(t_mm, area, n_paths=4000, n_steps=181):
"""Power history of one design for the first n_paths scenarios."""
times = np.linspace(0.0, T_YEARS, n_steps)
att_bg = np.exp(-t_mm / LAM_BG)
att_spe = np.exp(-t_mm / LAM_SPE)
ev_m = EV_MAG[:n_paths]
ev_t = EV_TIME[:n_paths]
out = np.empty((n_steps, n_paths))
for i, tau in enumerate(times):
spe = (ev_m * (ev_t <= tau)).sum(axis=1)
phi = BG_RATE[:n_paths] * tau * att_bg + spe * att_spe
rad = C_SAMPLE[:n_paths] * np.log10(1.0 + phi / PHI_X)
oth = K_SAMPLE[:n_paths] * tau
out[i] = area * UNIT_POWER_BOL * (1.0 - rad) * (1.0 - oth)
return times, out


# ------------------------------------------------------------
# 3. Optimisation (quantile reduction + vectorised risk map)
# ------------------------------------------------------------
T_GRID = np.linspace(0.05, 0.80, 121)
A_GRID = np.linspace(18.0, 44.0, 61)

t_start = time.perf_counter()
U = eol_unit_power(T_GRID)
U_SORTED = np.sort(U, axis=1)
U_Q = np.quantile(U, ALPHA, axis=1)
A_REQ = P_REQ / U_Q
MASS_REQ = A_REQ * (M_BASE + RHO_GLASS * T_GRID)
i_opt = int(np.argmin(MASS_REQ))
T_OPT = float(T_GRID[i_opt])
A_OPT = float(A_REQ[i_opt])
M_OPT = float(MASS_REQ[i_opt])

thresholds = P_REQ / A_GRID
RISK = np.empty((T_GRID.size, A_GRID.size))
for i in range(T_GRID.size):
RISK[i] = np.searchsorted(U_SORTED[i], thresholds, side="left") / N_MC
RISK_PCT = 100.0 * RISK
MASS_MAP = (M_BASE + RHO_GLASS * T_GRID)[:, None] * A_GRID[None, :]

CONF_GRID = np.linspace(0.50, 0.99, 50)
Q = np.quantile(U, 1.0 - CONF_GRID, axis=1)
M_CONF = (P_REQ / Q) * (M_BASE + RHO_GLASS * T_GRID)[None, :]
best_idx = M_CONF.argmin(axis=1)
BEST_MASS = M_CONF[np.arange(CONF_GRID.size), best_idx]
BEST_T = T_GRID[best_idx]
elapsed = time.perf_counter() - t_start

u_opt = U[i_opt]
p_eol = A_OPT * u_opt
risk_opt = float(np.mean(p_eol < P_REQ))
A_NOM = P_REQ / float(np.median(u_opt))
risk_nom = float(np.mean(A_NOM * u_opt < P_REQ))
mass_nom = A_NOM * (M_BASE + RHO_GLASS * T_OPT)

# ------------------------------------------------------------
# 4. Console report
# ------------------------------------------------------------
print("=" * 72)
print(" Solar array degradation risk optimisation (chance-constrained design)")
print("=" * 72)
print(f" Mission life : {T_YEARS:.0f} years")
print(f" Required EOL power : {P_REQ:.0f} W")
print(f" Required confidence : {100 * CONFIDENCE:.1f} %")
print(f" Monte Carlo scenarios : {N_MC:,}")
print(f" Design points evaluated : {T_GRID.size} x {A_GRID.size} = {T_GRID.size * A_GRID.size:,}")
print(f" Computation time : {elapsed:.2f} s")
print("-" * 72)
print(" Optimal design")
print(f" Shield thickness : {T_OPT:.3f} mm")
print(f" Array area : {A_OPT:.2f} m^2")
print(f" Array mass : {M_OPT:.1f} kg")
print(f" Shortfall probability : {100 * risk_opt:.2f} %")
print("-" * 72)
print(" Nominal (median-sized) design at the same thickness")
print(f" Array area : {A_NOM:.2f} m^2")
print(f" Array mass : {mass_nom:.1f} kg")
print(f" Shortfall probability : {100 * risk_nom:.2f} %")
print("-" * 72)
print(f" {'t [mm]':>8} {'A_req [m^2]':>13} {'Mass [kg]':>11} {'vs optimum [%]':>16}")
for tv in [0.10, 0.20, 0.30, 0.40, T_OPT, 0.60, 0.70]:
k = int(np.argmin(np.abs(T_GRID - tv)))
print(f" {T_GRID[k]:8.3f} {A_REQ[k]:13.2f} {MASS_REQ[k]:11.1f} {100 * (MASS_REQ[k] / M_OPT - 1.0):16.2f}")
print("=" * 72)

# ------------------------------------------------------------
# 5. Visualisation (single combined figure)
# ------------------------------------------------------------
times, PS = power_timeseries(T_OPT, A_OPT)
q05, q25, q50, q75, q95 = np.percentile(PS, [5, 25, 50, 75, 95], axis=1)

Xg, Yg = np.meshgrid(T_GRID, A_GRID)

fig = plt.figure(figsize=(21, 12))
gs = fig.add_gridspec(2, 3)

# (1) 3D risk surface
ax1 = fig.add_subplot(gs[0, 0], projection="3d")
ax1.plot_surface(Xg, Yg, RISK_PCT.T, cmap="plasma", edgecolor="none", rstride=2, cstride=2, alpha=0.95)
ax1.contour(Xg, Yg, RISK_PCT.T, levels=[100.0 * ALPHA], colors="cyan", linewidths=2.5)
ax1.scatter([T_OPT], [A_OPT], [100.0 * ALPHA], s=180, c="white", marker="*", depthshade=False)
ax1.set_xlabel("Shield thickness t [mm]")
ax1.set_ylabel("Array area A [m$^2$]")
ax1.set_zlabel("Shortfall risk [%]")
ax1.set_title("3D risk surface (cyan = 5 % chance constraint)")
ax1.view_init(elev=26, azim=-125)

# (2) 3D mass surface with feasibility
ax2 = fig.add_subplot(gs[0, 1], projection="3d")
norm = Normalize(vmin=float(MASS_MAP.min()), vmax=float(MASS_MAP.max()))
face = plt.get_cmap("viridis")(norm(MASS_MAP.T))
face[RISK.T > ALPHA] = (0.25, 0.25, 0.28, 0.35)
ax2.plot_surface(Xg, Yg, MASS_MAP.T, facecolors=face, shade=False, rstride=2, cstride=2, edgecolor="none")
ax2.scatter([T_OPT], [A_OPT], [M_OPT], s=200, c="red", marker="*", depthshade=False)
ax2.set_xlabel("Shield thickness t [mm]")
ax2.set_ylabel("Array area A [m$^2$]")
ax2.set_zlabel("Array mass [kg]")
ax2.set_title("3D mass surface (gray = infeasible, red star = optimum)")
ax2.view_init(elev=26, azim=-45)

# (3) Required area and mass versus thickness
ax3 = fig.add_subplot(gs[0, 2])
ax3b = ax3.twinx()
l1, = ax3.plot(T_GRID, A_REQ, color="#00e5ff", lw=2.4)
l2, = ax3b.plot(T_GRID, MASS_REQ, color="#ff9100", lw=2.4)
ax3.axvline(T_OPT, color="white", ls="--", lw=1.2)
ax3b.scatter([T_OPT], [M_OPT], s=140, c="red", zorder=5)
ax3.set_xlabel("Shield thickness t [mm]")
ax3.set_ylabel("Required area A [m$^2$]", color="#00e5ff")
ax3b.set_ylabel("Array mass [kg]", color="#ff9100")
ax3.set_title("Area-mass trade-off at 95 % confidence")
ax3.legend(handles=[l1, l2], labels=["Required area", "Array mass"], loc="upper center")
ax3.grid(alpha=0.2)

# (4) Power history fan chart
ax4 = fig.add_subplot(gs[1, 0])
for k in range(25):
ax4.plot(times, PS[:, k], color="white", lw=0.5, alpha=0.3)
ax4.fill_between(times, q05, q95, color="#00e5ff", alpha=0.22, label="5-95 % band")
ax4.fill_between(times, q25, q75, color="#00e5ff", alpha=0.35, label="25-75 % band")
ax4.plot(times, q50, color="#ffea00", lw=2.4, label="Median")
ax4.axhline(P_REQ, color="#ff1744", ls="--", lw=2.0, label="Requirement")
ax4.set_xlabel("Mission time [years]")
ax4.set_ylabel("Array power [W]")
ax4.set_title("Power history of the optimal design")
ax4.legend(loc="lower left")
ax4.grid(alpha=0.2)

# (5) EOL power distribution
ax5 = fig.add_subplot(gs[1, 1])
ax5.hist(p_eol, bins=80, color="#7c4dff", alpha=0.9)
ax5.axvspan(float(p_eol.min()), P_REQ, color="#ff1744", alpha=0.25)
ax5.axvline(P_REQ, color="#ff1744", ls="--", lw=2.0, label=f"Requirement (risk {100 * risk_opt:.1f} %)")
ax5.axvline(float(np.median(p_eol)), color="#ffea00", lw=2.0, label="Median")
ax5.set_xlabel("End-of-life power [W]")
ax5.set_ylabel("Number of scenarios")
ax5.set_title("End-of-life power distribution")
ax5.legend(loc="upper right")
ax5.grid(alpha=0.2)

# (6) Price of confidence
ax6 = fig.add_subplot(gs[1, 2])
ax6b = ax6.twinx()
m1, = ax6.plot(100.0 * CONF_GRID, BEST_MASS, color="#ff9100", lw=2.4)
m2, = ax6b.plot(100.0 * CONF_GRID, BEST_T, color="#69f0ae", lw=2.4)
ax6.axvline(100.0 * CONFIDENCE, color="white", ls="--", lw=1.2)
ax6.set_xlabel("Required confidence [%]")
ax6.set_ylabel("Minimum array mass [kg]", color="#ff9100")
ax6b.set_ylabel("Optimal thickness t [mm]", color="#69f0ae")
ax6.set_title("The price of confidence")
ax6.legend(handles=[m1, m2], labels=["Minimum mass", "Optimal thickness"], loc="upper left")
ax6.grid(alpha=0.2)

fig.suptitle("Solar Array Degradation Risk Optimization", fontsize=20, y=0.97)
fig.subplots_adjust(left=0.04, right=0.97, top=0.91, bottom=0.07, wspace=0.28, hspace=0.30)
plt.show()

5. Code Walkthrough

5.1 Parameters

The first block collects every physical and economic assumption in one place. UNIT_POWER_BOL multiplies the solar constant by the cell efficiency, the packing factor, the average cosine loss, and a temperature derating factor, giving about 306.6 W per square meter at beginning of life. M_BASE and RHO_GLASS define the mass model $M = A(m_0 + \rho t)$. The remaining constants describe the radiation environment and the uncertainty of each ingredient.

5.2 Scenario Generation

Each of the 20,000 scenarios carries its own background fluence rate, damage coefficient $C$, and thermal/UV loss rate $k$. Solar particle events need more care because the number of events varies from scenario to scenario. We draw a Poisson count per scenario, then generate a padded matrix of event magnitudes and event times with MAX_EV columns. A boolean mask, EV_MASK, zeroes out the unused columns. Summing along the event axis gives the total SPE fluence per scenario, PHI_SPE_EOL. The event times are kept because we need them later for the power history.

The clipping of $C$ and $k$ keeps the sampled values physically meaningful, since a negative degradation rate has no meaning.

5.3 The Vectorized Core

eol_unit_power is the heart of the model. Given a whole vector of shield thicknesses, it returns a matrix of shape (thickness, scenario). Broadcasting handles the exponential attenuation, the logarithmic radiation law, and the linear thermal/UV loss in a single expression. No Python loop touches the scenarios, so evaluating all 121 thicknesses against 20,000 scenarios takes a fraction of a second.

5.4 Quantile Reduction

The line U_Q = np.quantile(U, ALPHA, axis=1) returns, for every thickness, the power density that 95% of the scenarios exceed. Dividing $P_{\mathrm{req}}$ by it gives the smallest area that satisfies the chance constraint, exactly as derived in Section 3. Multiplying by the areal mass gives the mass curve, and argmin picks the optimal thickness.

5.5 The Risk Map

To draw the full 3D risk surface we still need the shortfall probability for arbitrary $(t, A)$ pairs. Each row of U is sorted once, after which np.searchsorted counts how many scenarios fall below the threshold $P_{\mathrm{req}}/A$ for all 61 areas simultaneously. The loop runs only over the 121 thicknesses, never over scenarios or areas.

5.6 The Price of Confidence

The same quantile trick is repeated for 50 different confidence levels between 50% and 99% with a single call to np.quantile using a vector of probabilities. This yields the minimum achievable mass as a function of the required confidence, which is the practical trade-off a program manager cares about.

5.7 Time History

power_timeseries replays the first 4,000 scenarios through time. At every time step, only the solar particle events that have already occurred contribute to the fluence, while the background fluence grows linearly. The final time step reproduces the EOL values used in the optimization, so the fan chart is consistent with the optimizer.

5.8 Visualization

All six panels are drawn into one figure. The two 3D panels show the risk surface and the mass surface, the latter with infeasible designs painted gray so that the feasible region and the optimum stand out at a glance.

6. Execution Results

========================================================================
 Solar array degradation risk optimisation (chance-constrained design)
========================================================================
 Mission life               : 15 years
 Required EOL power         : 6000 W
 Required confidence        : 95.0 %
 Monte Carlo scenarios      : 20,000
 Design points evaluated    : 121 x 61 = 7,381
 Computation time           : 0.29 s
------------------------------------------------------------------------
 Optimal design
   Shield thickness         : 0.575 mm
   Array area               : 23.01 m^2
   Array mass               : 131.0 kg
   Shortfall probability    : 5.00 %
------------------------------------------------------------------------
 Nominal (median-sized) design at the same thickness
   Array area               : 21.96 m^2
   Array mass               : 125.0 kg
   Shortfall probability    : 50.00 %
------------------------------------------------------------------------
   t [mm]   A_req [m^2]   Mass [kg]   vs optimum [%]
    0.100         35.70       159.2            21.50
    0.200         31.41       148.3            13.14
    0.300         28.18       140.3             7.10
    0.400         25.75       134.9             2.96
    0.575         23.01       131.0             0.00
    0.600         22.75       131.1             0.02
    0.700         22.07       132.9             1.39
========================================================================

7. Interpreting the Results

The optimal design. With the random seed used here, the optimizer selects a shield thickness of about 0.575 mm and an array area of about 23.0 $\mathrm{m}^2$, for a total array mass of about 131 kg. The Monte Carlo estimate of the shortfall probability is 5.00%, matching the chance constraint with no wasted margin.

The nominal design is a trap. Sizing the array with the median degradation at the same thickness needs only about 22.0 $\mathrm{m}^2$ and 125 kg. That is 6 kg lighter, but it fails to deliver 6000 W in half of all scenarios. The console report shows this clearly: a shortfall probability of 50%.

The 3D risk surface (top left). The surface is close to a cliff. For a given thickness there is a narrow band of areas over which the shortfall probability climbs from nearly 0% to nearly 100%, because the scenario spread is small compared with the design range. The cyan curve marks the 5% level, and every design on or beyond it is acceptable. The optimum lies on that curve, which is exactly what a chance-constrained optimum should do.

The 3D mass surface (top center). The mass grows almost linearly with area, while the effect of thickness is more subtle. The gray region marks infeasible designs. The red star sits on the boundary of the feasible region, at the lowest mass point on it.

The trade-off curve (top right). The required area decreases steadily as the shield gets thicker, because radiation damage shrinks. The mass, however, has a minimum. Thin shields force a large array; thick shields add glass mass faster than they save array mass. At 0.10 mm the array is about 21.5% heavier than the optimum, and at 0.30 mm it is still about 7.1% heavier. The curve is quite flat near the optimum, so a thickness between 0.5 and 0.7 mm costs at most about 1.4% extra mass. Engineers can pick a practical thickness without losing much.

The fan chart (bottom left). The median power falls from about 7050 W at launch to roughly 6300 W after 15 years. The lower edge of the 5-95% band touches the 6000 W requirement line at the end of the mission, which is precisely what a 95% confidence design should look like. The thin white lines are individual scenarios; sudden downward steps are solar particle events.

The EOL distribution (bottom center). The histogram has a longer tail toward low power. Heavy-tailed solar particle events produce rare but severe losses, and the red-shaded region to the left of the requirement contains exactly 5% of the scenarios.

The price of confidence (bottom right). Raising the required confidence from 50% to 99% raises the minimum mass from about 125 kg to about 134 kg, and the curve steepens sharply near the high end. The last few percentage points of reliability are the most expensive ones. Meanwhile, the optimal thickness creeps upward with the required confidence, because a thicker shield suppresses the heavy tail of the fluence distribution.

8. Conclusion

We turned a vague engineering worry into a precise optimization problem: minimize array mass subject to a probabilistic end-of-life power requirement. The key mathematical step was the quantile reduction

$$
A_{\mathrm{req}}(t) = \frac{P_{\mathrm{req}}}{Q_{\alpha}\bigl[u(t,\cdot)\bigr]}
$$

which converts a stochastic two-variable problem into a fast one-dimensional search, and vectorized NumPy evaluates the whole design space in a fraction of a second.

The results deliver three lessons. First, designing for the average is a coin flip. Second, the mass-optimal shield is neither the thinnest nor the thickest, and the optimum is broad enough to leave room for practical engineering judgment. Third, the last few percent of confidence are expensive, so the confidence level itself should be a deliberate management decision.

The same framework extends naturally to other risk drivers: eclipse thermal cycling in different orbits, cell-technology selection, in-orbit annealing, or a multi-objective formulation with launch cost. Only the scenario generator and the mass model need to change.

Minimizing Satellite Operational Risk

Planning a Collision-Avoidance Maneuver with Python

Every satellite operator eventually receives the message nobody wants: a piece of debris will pass within a few hundred meters of your spacecraft in a few hours. Do nothing and you accept a small but real chance of losing a very expensive asset. Maneuver and you burn fuel, which is your lifetime, and you also add new uncertainty to your own orbit.

So how do you decide how much to push and when? In this article we turn that question into a small optimization problem and solve it with Python. We will build a physical model, search for the risk-minimizing maneuver, verify the result with a Monte Carlo simulation, and visualize everything in one figure, including two 3D surfaces.

The Scenario

A satellite flies in a circular orbit at 500 km altitude, with an orbital period of about 94.6 minutes. A conjunction warning tells us:

  • The predicted miss distance at the time of closest approach (TCA) is 40 m radial and 60 m along-track.
  • The combined position uncertainty (1σ) in the encounter plane is 50 m radial and 200 m along-track.
  • The combined hard-body radius of the two objects is $R = 10$ m.
  • The warning arrives 4 orbits (about 6.3 hours) before TCA, so any burn must happen within that window.

We can perform one small along-track (prograde) burn of size $\Delta v$ at a lead time $t$ before TCA, followed later by an equal return burn to restore the orbit. Our decision variables are $(\Delta v,\ t)$.

Modeling the Physics

How a tiny burn becomes a large miss distance

For a near-circular orbit, the Clohessy–Wiltshire equations describe relative motion in the radial ($x$) and along-track ($y$) directions. For a tangential impulse $\Delta v$ applied $t$ seconds before TCA, the displacement at TCA is

$$
\Delta x = \frac{2}{n}\bigl(1-\cos nt\bigr),\Delta v, \qquad
\Delta y = \frac{4\sin nt - 3nt}{n},\Delta v
$$

where $n=\sqrt{\mu/a^{3}}$ is the mean motion. The secular term $-3nt$ is the key: the longer the lead time, the more a millimeter-per-second nudge is amplified. Small burns done early are far cheaper than big burns done late.

Execution error grows with lead time

A real thruster never delivers exactly the commanded impulse. We model the burn error as

$$
\sigma_{\Delta v}=\sqrt{\sigma_{0}^{2}+(k,\Delta v)^{2}}, \qquad \sigma_0 = 2\ \text{mm/s},\quad k=0.03
$$

and this error is amplified by the same geometry, so the covariance in the encounter plane becomes

$$
\sigma_x^{2}=\sigma_{x,0}^{2}+\bigl(c_x,\sigma_{\Delta v}\bigr)^{2},\qquad
\sigma_y^{2}=\sigma_{y,0}^{2}+\bigl(c_y,\sigma_{\Delta v}\bigr)^{2}
$$

Here $c_x$ and $c_y$ are the coefficients multiplying $\Delta v$ in the displacement equations above. This creates the central trade-off of the article: a long lead time reduces the required $\Delta v$, but it also inflates the uncertainty of where we end up.

Collision probability

With a small hard-body radius compared to the uncertainty, the collision probability is well approximated by

$$
P_c \approx \frac{R^{2}}{2\sigma_x\sigma_y}\exp!\left[-\frac12\left(\frac{m_x^{2}}{\sigma_x^{2}}+\frac{m_y^{2}}{\sigma_y^{2}}\right)\right]
$$

where $(m_x, m_y)$ is the miss vector after the maneuver.

The objective

We minimize the expected loss

$$
J(\Delta v, t) = C_{\text{col}},P_c(\Delta v, t) + 2,C_{\text{fuel}},\Delta v
$$

with $C_{\text{col}} = 5\times10^{8}$ USD (loss of the satellite and its mission) and $C_{\text{fuel}} = 5\times10^{6}$ USD per m/s. The factor 2 accounts for the return burn. In addition to the free optimum, we also solve the problem under a typical operational safety rule:

$$
\min_{\Delta v,,t}\ J(\Delta v, t)\quad \text{subject to}\quad P_c\le 10^{-5}
$$

and the pure minimum-fuel version of the same constraint,

$$
\min_{t}\ \Delta v_{\text{req}}(t), \qquad \Delta v_{\text{req}}(t)=\min{\Delta v : P_c(\Delta v’, t)\le 10^{-5}\ \ \forall \Delta v’\ge\Delta v}
$$

The Complete Source Code

Everything is in a single cell.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
import time
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Ellipse, Circle
from scipy.optimize import minimize

# ------------------------------------------------------------------
# 1. Orbit and conjunction scenario
# ------------------------------------------------------------------
MU = 3.986004418e14
RE = 6378.137e3
ALT = 500e3
A_SMA = RE + ALT
N_MM = np.sqrt(MU / A_SMA**3)
T_ORB = 2.0 * np.pi / N_MM

MISS = np.array([40.0, 60.0])
SIG_NOM = np.array([50.0, 200.0])
R_HB = 10.0

SIG_DV0 = 2.0e-3
K_DV = 0.03

C_COL = 5.0e8
C_FUEL = 5.0e6
PC_LIMIT = 1.0e-5

# ------------------------------------------------------------------
# 2. Model
# ------------------------------------------------------------------
def cw_coeffs(t_orb):
nt = 2.0 * np.pi * np.asarray(t_orb, dtype=float)
cx = 2.0 * (1.0 - np.cos(nt)) / N_MM
cy = (4.0 * np.sin(nt) - 3.0 * nt) / N_MM
return cx, cy

def encounter_state(dv, t_orb):
dv = np.asarray(dv, dtype=float)
cx, cy = cw_coeffs(t_orb)
mx = MISS[0] + cx * dv
my = MISS[1] + cy * dv
s_dv = np.where(dv > 0.0, np.sqrt(SIG_DV0**2 + (K_DV * dv) ** 2), 0.0)
vx = SIG_NOM[0] ** 2 + (cx * s_dv) ** 2
vy = SIG_NOM[1] ** 2 + (cy * s_dv) ** 2
return mx, my, vx, vy

def collision_probability(dv, t_orb):
mx, my, vx, vy = encounter_state(dv, t_orb)
expo = -0.5 * (mx**2 / vx + my**2 / vy)
return R_HB**2 / (2.0 * np.sqrt(vx * vy)) * np.exp(expo)

def expected_cost(dv, t_orb):
dv = np.asarray(dv, dtype=float)
pc = collision_probability(dv, t_orb)
return C_COL * pc + C_FUEL * 2.0 * dv

def pc_monte_carlo(dv, t_orb, n=2_000_000, seed=42):
rng = np.random.default_rng(seed)
mx, my, vx, vy = encounter_state(dv, t_orb)
r = R_HB * np.sqrt(rng.random(n))
th = 2.0 * np.pi * rng.random(n)
x, y = r * np.cos(th), r * np.sin(th)
pdf = np.exp(-0.5 * ((x - mx) ** 2 / vx + (y - my) ** 2 / vy)) / (2.0 * np.pi * np.sqrt(vx * vy))
samples = np.pi * R_HB**2 * pdf
return samples.mean(), samples.std(ddof=1) / np.sqrt(n)

# ------------------------------------------------------------------
# 3. Grid search
# ------------------------------------------------------------------
dv_grid = np.linspace(0.0, 0.06, 301)
t_grid = np.linspace(0.25, 4.0, 301)
DV, TT = np.meshgrid(dv_grid, t_grid, indexing="ij")

t0 = time.perf_counter()
PC = collision_probability(DV, TT)
J = expected_cost(DV, TT)
t_vec = time.perf_counter() - t0

dv_s = np.linspace(0.0, 0.06, 100)
t_s = np.linspace(0.25, 4.0, 100)
t0 = time.perf_counter()
J_loop = np.empty((100, 100))
for i, d in enumerate(dv_s):
for j, tt in enumerate(t_s):
J_loop[i, j] = expected_cost(d, tt)
t_loop = time.perf_counter() - t0
DVs, TTs = np.meshgrid(dv_s, t_s, indexing="ij")
t0 = time.perf_counter()
J_vec_small = expected_cost(DVs, TTs)
t_vec_small = time.perf_counter() - t0
assert np.allclose(J_loop, J_vec_small)

# ------------------------------------------------------------------
# 4. Optimisation
# ------------------------------------------------------------------
i_opt, j_opt = np.unravel_index(np.argmin(J), J.shape)
x0 = np.array([dv_grid[i_opt] * 1e3, t_grid[j_opt]])

def objective(x):
return expected_cost(x[0] * 1e-3, x[1]) / 1e3

res = minimize(objective, x0, method="L-BFGS-B", bounds=[(0.0, 60.0), (0.25, 4.0)])
dv_opt, t_opt = res.x[0] * 1e-3, res.x[1]
pc_opt = float(collision_probability(dv_opt, t_opt))
J_opt = float(expected_cost(dv_opt, t_opt))

J_feas = np.where(PC <= PC_LIMIT, J, np.inf)
ic, jc = np.unravel_index(np.argmin(J_feas), J_feas.shape)
dv_con, t_con = dv_grid[ic], t_grid[jc]
pc_con = float(PC[ic, jc])
J_con = float(J[ic, jc])

ok = PC <= PC_LIMIT
robust = np.logical_and.accumulate(ok[::-1, :], axis=0)[::-1, :]
has = robust.any(axis=0)
dv_req = np.where(has, dv_grid[robust.argmax(axis=0)], np.nan)
k_min = int(np.nanargmin(dv_req))
dv_fuel, t_fuel = dv_req[k_min], t_grid[k_min]
pc_fuel = float(collision_probability(dv_fuel, t_fuel))
J_fuel = float(expected_cost(dv_fuel, t_fuel))

pc_none = float(collision_probability(0.0, 1.0))
J_none = float(expected_cost(0.0, 1.0))

# ------------------------------------------------------------------
# 5. Monte Carlo
# ------------------------------------------------------------------
mc_none = pc_monte_carlo(0.0, 1.0)
mc_opt = pc_monte_carlo(dv_opt, t_opt)
mc_con = pc_monte_carlo(dv_con, t_con)

# ------------------------------------------------------------------
# 6. Console
# ------------------------------------------------------------------
print(f"Orbital period : {T_ORB/60:.2f} min")
print(f"Grid evaluation (301x301) : {t_vec*1e3:.2f} ms (vectorised)")
print(f"Loop vs vectorised (100x100): {t_loop*1e3:.1f} ms vs {t_vec_small*1e3:.2f} ms -> x{t_loop/t_vec_small:.0f} faster")
print()
header = f"{'Strategy':<34}{'dv [mm/s]':>10}{'Lead [orb]':>11}{'Pc (analytic)':>15}{'Pc (MC)':>13}{'Cost [k$]':>11}"
print(header)
print("-" * len(header))
rows = [
("A: Do nothing", 0.0, np.nan, pc_none, mc_none[0], J_none),
("B: Cost-optimal (free)", dv_opt, t_opt, pc_opt, mc_opt[0], J_opt),
("C: Cost-optimal (Pc <= 1e-5)", dv_con, t_con, pc_con, mc_con[0], J_con),
("D: Min-fuel (Pc <= 1e-5)", dv_fuel, t_fuel, pc_fuel, np.nan, J_fuel),
]
for name, d, t, p, m, c in rows:
t_txt = "-" if np.isnan(t) else f"{t:.3f}"
m_txt = "-" if np.isnan(m) else f"{m:.3e}"
print(f"{name:<34}{d * 1e3:>10.2f}{t_txt:>11}{p:>15.3e}{m_txt:>13}{c / 1e3:>11.1f}")
print()
print(f"MC standard error (B): {mc_opt[1]:.2e}")

# ------------------------------------------------------------------
# 7. Figure
# ------------------------------------------------------------------
plt.rcParams.update({"font.size": 11})
fig = plt.figure(figsize=(21, 13), layout="constrained")
DVmm = DV * 1e3

ax1 = fig.add_subplot(2, 3, 1, projection="3d")
s1 = ax1.plot_surface(DVmm, TT, J / 1e3, cmap="viridis", rstride=4, cstride=4, alpha=0.92, linewidth=0)
ax1.scatter([dv_opt * 1e3], [t_opt], [J_opt / 1e3], color="red", s=90, marker="*", label="Optimum B", depthshade=False)
ax1.set_xlabel("Delta-v [mm/s]")
ax1.set_ylabel("Lead time [orbits]")
ax1.set_zlabel("Expected cost [k$]")
ax1.set_title("(a) Expected cost surface")
ax1.view_init(elev=28, azim=-125)
ax1.legend(loc="upper left")
fig.colorbar(s1, ax=ax1, shrink=0.55, pad=0.08)

ax2 = fig.add_subplot(2, 3, 2, projection="3d")
LP = np.log10(np.maximum(PC, 1e-12))
s2 = ax2.plot_surface(DVmm, TT, LP, cmap="plasma", rstride=4, cstride=4, alpha=0.92, linewidth=0)
ax2.scatter([dv_opt * 1e3], [t_opt], [np.log10(pc_opt)], color="cyan", s=90, marker="*", depthshade=False)
ax2.set_xlabel("Delta-v [mm/s]")
ax2.set_ylabel("Lead time [orbits]")
ax2.set_zlabel(r"$\log_{10} P_c$")
ax2.set_title("(b) Collision probability surface")
ax2.view_init(elev=28, azim=-125)
fig.colorbar(s2, ax=ax2, shrink=0.55, pad=0.08)

ax3 = fig.add_subplot(2, 3, 3)
cf = ax3.contourf(DVmm, TT, LP, levels=np.linspace(-12, -2, 21), cmap="plasma")
ax3.contour(DVmm, TT, LP, levels=[np.log10(PC_LIMIT)], colors="white", linewidths=2.5)
ax3.plot(dv_req * 1e3, t_grid, "w--", lw=1.0)
ax3.scatter([dv_opt * 1e3], [t_opt], marker="*", s=220, color="red", edgecolor="k", label="B: cost-optimal", zorder=5)
ax3.scatter([dv_con * 1e3], [t_con], marker="o", s=110, color="lime", edgecolor="k", label="C: constrained", zorder=5)
ax3.scatter([dv_fuel * 1e3], [t_fuel], marker="s", s=110, color="orange", edgecolor="k", label="D: min-fuel", zorder=5)
ax3.set_xlabel("Delta-v [mm/s]")
ax3.set_ylabel("Lead time [orbits]")
ax3.set_title(r"(c) $\log_{10}P_c$ map (white line: $P_c=10^{-5}$)")
ax3.legend(loc="upper right", fontsize=9)
fig.colorbar(cf, ax=ax3)

ax4 = fig.add_subplot(2, 3, 4)
def draw_case(mx, my, vx, vy, color, label):
for k, ls in ((1, "-"), (3, ":")):
ax4.add_patch(Ellipse((mx, my), 2 * k * np.sqrt(vx), 2 * k * np.sqrt(vy), fill=False, ec=color, ls=ls, lw=1.8))
ax4.plot(mx, my, "o", color=color, label=label)
draw_case(*[float(v) for v in encounter_state(0.0, 1.0)], "tab:red", "A: do nothing")
draw_case(*[float(v) for v in encounter_state(dv_opt, t_opt)], "tab:blue", "B: cost-optimal")
draw_case(*[float(v) for v in encounter_state(dv_con, t_con)], "tab:green", "C: constrained")
dvs = np.linspace(0.0, dv_con, 50)
mx_path, my_path, _, _ = encounter_state(dvs, t_con)
ax4.plot(mx_path, my_path, "-", color="tab:green", alpha=0.5)
ax4.add_patch(Circle((0, 0), R_HB, color="k", zorder=6))
ax4.annotate("Hard-body\nradius", (0, 0), xytext=(90, -230), arrowprops=dict(arrowstyle="->"))
ax4.set_xlim(-400, 400)
ax4.set_ylim(-900, 900)
ax4.set_xlabel("Radial [m]")
ax4.set_ylabel("Along-track [m]")
ax4.set_title(r"(d) Encounter plane (solid: $1\sigma$, dotted: $3\sigma$)")
ax4.grid(alpha=0.3)
ax4.legend(loc="upper right", fontsize=9)

ax5 = fig.add_subplot(2, 3, 5)
jb = np.argmin(J, axis=0)
cols = np.arange(len(t_grid))
best_total = J[jb, cols] / 1e3
best_risk = C_COL * PC[jb, cols] / 1e3
best_fuel = C_FUEL * 2.0 * dv_grid[jb] / 1e3
ax5.plot(t_grid, best_total, "k-", lw=2.5, label="Total")
ax5.plot(t_grid, best_risk, "r--", lw=1.8, label="Collision risk")
ax5.plot(t_grid, best_fuel, "b--", lw=1.8, label="Fuel")
ax5.axhline(J_none / 1e3, color="gray", ls=":", label="Do nothing")
ax5.scatter([t_opt], [J_opt / 1e3], marker="*", s=220, color="red", edgecolor="k", zorder=5)
ax5.set_yscale("log")
ax5.set_xlabel("Lead time [orbits]")
ax5.set_ylabel("Expected cost [k$]")
ax5.set_title("(e) Best achievable cost per lead time")
ax5.grid(alpha=0.3, which="both")
ax5.legend(fontsize=9)

ax6 = fig.add_subplot(2, 3, 6)
ax6.plot(t_grid, dv_req * 1e3, "g-", lw=2.5)
ax6.scatter([t_fuel], [dv_fuel * 1e3], marker="s", s=110, color="orange", edgecolor="k", zorder=5, label="D: min-fuel")
ax6.scatter([t_con], [dv_con * 1e3], marker="o", s=110, color="lime", edgecolor="k", zorder=5, label="C: constrained")
ax6.set_xlabel("Lead time [orbits]")
ax6.set_ylabel("Required delta-v [mm/s]")
ax6.set_title(r"(f) Minimum delta-v for $P_c \leq 10^{-5}$")
ax6.grid(alpha=0.3)
ax6.legend(fontsize=9)

fig.suptitle("Collision-avoidance maneuver planning: risk-minimising trade-off", fontsize=16)
plt.show()

Code Walkthrough

Section 1: Scenario constants

The mean motion N_MM and the orbital period T_ORB follow directly from the semi-major axis of a 500 km circular orbit. MISS, SIG_NOM, and R_HB are the conjunction data (predicted miss, uncertainty, and hard-body radius). SIG_DV0 and K_DV define the thruster execution error, and C_COL and C_FUEL are the economic weights of the objective. PC_LIMIT is the operational safety threshold of $10^{-5}$.

Section 2: The physical model

cw_coeffs returns the two coefficients $c_x$ and $c_y$ of the Clohessy–Wiltshire equations. The lead time is given in orbits, so the phase angle is $nt = 2\pi \times \text{orbits}$. Working in orbits keeps the plots easy to read.

encounter_state is the heart of the model. It shifts the nominal miss vector by the maneuver-induced displacement and inflates the covariance by the amplified execution error. The np.where(dv > 0, ..., 0) guard is important: when no burn is performed there is no execution error, so the do-nothing baseline is not unfairly penalized.

collision_probability implements the analytic $P_c$ formula, and expected_cost adds the fuel term to obtain $J$. Because everything is written with NumPy operations, these functions accept scalars and arrays of any shape, which is what makes the next section fast.

pc_monte_carlo is an independent check. Instead of sampling the Gaussian directly (which would need billions of samples to resolve a probability near $10^{-6}$), it uses importance sampling: points are drawn uniformly over the hard-body disk and weighted by the Gaussian density. The estimate is $\pi R^{2},\mathbb{E}[f(\mathbf{x})]$, which is accurate even for very rare events with only two million samples.

We evaluate $J$ and $P_c$ on a $301\times301$ grid of $(\Delta v, t)$ in a single call thanks to broadcasting. To show why this matters, the code also evaluates a $100\times100$ grid with a naive double for loop and compares it with the vectorized call. The assert np.allclose(...) line guarantees that both versions produce identical numbers. The vectorized version is roughly two orders of magnitude faster, which is what allows us to explore the design space interactively and extend it to finer grids or additional parameters.

Section 4: Optimization

The grid minimum gives a robust starting point in a landscape that has several local minima (the sine and cosine terms make the cost surface wavy). scipy.optimize.minimize with L-BFGS-B then polishes it to a continuous solution inside the bounds. The variables are scaled to mm/s so that both parameters have a similar magnitude, which helps the optimizer’s convergence.

Three answers are extracted:

  • B (free optimum): the global minimum of $J$.
  • C (constrained optimum): the minimum of $J$ among grid points with $P_c\le10^{-5}$, found by masking infeasible points with np.inf.
  • D (minimum fuel): for every lead time, the smallest $\Delta v$ that keeps $P_c$ below the limit for all larger burns as well. This is computed with a reversed cumulative logical AND, which avoids being fooled by isolated dips in the wavy $P_c$ surface.

Section 5 and 6: Verification and reporting

The analytic result of every strategy is compared with the importance-sampling Monte Carlo estimate, and everything is printed in one table.

Section 7: A single combined figure

Six panels are drawn in one figure with layout="constrained" so that colorbars and 3D axes never overlap. Panels (a) and (b) are 3D surfaces, and (c) to (f) are 2D analyses. plt.show() is called only once.

Results

Console output

Orbital period            : 94.62 min
Grid evaluation (301x301) : 10.98 ms (vectorised)
Loop vs vectorised (100x100): 231.2 ms vs 0.74 ms  -> x314 faster

Strategy                           dv [mm/s] Lead [orb]  Pc (analytic)      Pc (MC)  Cost [k$]
----------------------------------------------------------------------------------------------
A: Do nothing                           0.00          -      3.471e-03    3.464e-03     1735.5
B: Cost-optimal (free)                 11.50      3.658      2.330e-05    2.346e-05      126.7
C: Cost-optimal (Pc <= 1e-5)           12.60      3.663      8.693e-06    8.762e-06      130.3
D: Min-fuel (Pc <= 1e-5)               12.60      3.575      9.688e-06            -      130.8

MC standard error (B): 2.55e-09

In our run, the vectorized evaluation was more than 100 times faster than the double loop. The table shows four strategies:

Strategy $\Delta v$ [mm/s] Lead time [orbits] $P_c$ Expected cost [k$]
A: Do nothing 0 – $3.5\times10^{-3}$ 1735.5
B: Cost-optimal 11.50 3.658 $2.3\times10^{-5}$ 126.7
C: Cost-optimal with $P_c\le10^{-5}$ 12.60 3.663 $8.7\times10^{-6}$ 130.3
D: Minimum fuel with $P_c\le10^{-5}$ 12.60 3.575 $9.7\times10^{-6}$ 130.8

The most striking number is the drop from 1.7 million dollars of expected loss to about 127 thousand dollars, a reduction of more than 90 percent, achieved with a burn of only 1.15 cm/s. The Monte Carlo values agree with the analytic ones to within about one percent in every case, which validates the small-radius approximation.

The pure cost optimum (B) ends at $P_c=2.3\times10^{-5}$, slightly above the safety rule. Under the constraint, the answer moves to a somewhat larger burn of 12.6 mm/s (C), and it costs only about 3.6 thousand dollars more in expected loss. That is a cheap price for satisfying a hard operational threshold. The minimum-fuel solution (D) turns out to be almost identical to C, which tells us that in this scenario the fuel term dominates the constrained decision.

Combined figure

Reading the Graphs

(a) Expected cost surface (3D). The cost surface is high and wavy at the small-$\Delta v$ side, where the collision risk dominates, and it flattens into a broad valley toward larger $\Delta v$. There the fuel term takes over and slowly lifts the surface again. The red star marks the optimum at a lead time near 3.7 orbits. Notice the ridges along the lead-time axis: they are the fingerprints of the $\sin nt$ and $\cos nt$ terms, and they show that some lead times are much better than their neighbors.

(b) Collision probability surface (3D). On the logarithmic scale, $P_c$ falls by many orders of magnitude within only a few tens of mm/s. The staircase-like shelves reveal how the maneuver first moves the miss vector out of the dense core of the covariance ellipse, then out of the 1σ region, and finally into its tail. The floor is clipped at $10^{-12}$ for readability.

(c) $P_c$ map. This is the same surface seen from above. The white curve is the safety limit $P_c=10^{-5}$: everything to its right is acceptable. The curve is not a straight line, because it bends at roughly 1 and 2 orbits, where the burn geometry becomes inefficient. The three markers (B, C, and D) crowd together near the top of the map. This means the best strategy is to act as early as the warning allows.

(d) Encounter plane. Here we look at the geometry directly. The red ellipses (do nothing) sit right on top of the tiny black hard-body disk, which is exactly why $P_c$ is so high. After the maneuver, the blue and green ellipses have been moved about 700 m along-track. Their larger size, compared with the red ones, is the price of execution error amplified over almost four orbits, but the center of the distribution is now far from the target, so the probability mass overlapping the disk is negligible.

(e) Best cost per lead time. For every lead time we take the best $\Delta v$ and split the cost into its two parts. The collision-risk component (red) is always tiny compared with the fuel component (blue), which shows that once the optimizer has acted, most of the remaining cost is the fuel bill. The total (black) decreases with lead time overall, with oscillations caused by orbital geometry, and the gray line, the cost of doing nothing, is more than an order of magnitude above every point.

(f) Required $\Delta v$. The minimum burn needed to meet the safety limit falls from almost 60 mm/s with a quarter-orbit notice to about 12 mm/s with nearly four orbits. The local bump around one orbit is a well-known feature: after a full revolution the radial term of the burn returns to zero, so the maneuver is less effective at that phase. The orange square and the green circle sit at the bottom of the curve.

Conclusions

  • A tiny along-track burn of about 1 cm/s, executed roughly four orbits before closest approach, lowers the collision probability from $3.5\times10^{-3}$ to below $10^{-5}$.
  • Lead time is the most valuable resource. Every extra orbit of warning reduces the needed $\Delta v$ substantially, but the periodic geometry means the relationship is not monotonic, so a grid search followed by local refinement is safer than a blind gradient method.
  • Enforcing a hard risk threshold costs only a few thousand dollars of expected loss here, which makes such a rule easy to justify.
  • Vectorizing the model with NumPy broadcasting gave a speed-up of about two orders of magnitude and turned the whole analysis into a matter of milliseconds.

The same framework extends naturally to more realistic problems: full 3D encounter geometry, multiple burn options, non-Gaussian covariances, or several simultaneous conjunctions sharing one fuel budget.