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.

Choosing the Best Halo Orbit for a Space-Weather Satellite at Sun–Earth L1

Solar-wind monitors such as SOHO, ACE, Wind and DSCOVR sit near the Sun–Earth L1 point, about 1.5 million km sunward of Earth. Solar wind at 400–500 km/s takes roughly an hour to reach Earth from there, so an instrument at L1 gives a short but valuable warning of geomagnetic storms. This post designs the orbit of such a satellite as a concrete optimization problem and solves it in Python.

1. The example problem

A spacecraft flies a halo orbit around L1 in the Sun–Earth system. Two things must be chosen:

  • $A_z$, the z-amplitude of the halo orbit.
  • $\tau$, the interval between station-keeping maneuvers.

The mission requirements are:

  • Solar radio interference. The Sun–Earth–spacecraft (SEV) angle $\alpha$ must never fall below $4.5^\circ$. Below that, solar radio noise swamps the downlink.
  • Antenna pointing. The angle must never exceed $28^\circ$.

The cost to minimize is the total $\Delta V$ over the mission:

$$
\min_{A_z,;\tau}; J = \Delta V_{\rm ins}^{+}(A_z) + N_{\rm yr},\Delta V_{\rm sk}(A_z,\tau)
$$

$$
\text{s.t.}\quad \alpha_{\min}(A_z)\ge 4.5^\circ,\quad \alpha_{\max}(A_z)\le 28^\circ,\quad 10^5\le A_z\le 4\times10^5\ \text{km},\quad 10\le\tau\le 90\ \text{day}
$$

Symbol Meaning Value
$\mu$ Sun–Earth mass ratio $3.00348\times10^{-6}$
$N_{\rm yr}$ Mission lifetime 5 yr
$\sigma_v$ Dispersion growth per maneuver cycle 0.02 m/s
$\delta_v$ Fixed cost per maneuver 0.05 m/s

The coefficients $\sigma_v$ and $\delta_v$ are illustrative values for this example.

2. Mathematical model

2.1 Circular restricted three-body problem

In nondimensional units, one length unit is 1 AU and one time unit is $1/n$. The frame rotates with the Sun–Earth line, with the Sun at $(-\mu,0,0)$ and the Earth at $(1-\mu,0,0)$:

$$
\ddot x-2\dot y=\Omega_x,\qquad \ddot y+2\dot x=\Omega_y,\qquad \ddot z=\Omega_z
$$

$$
\Omega=\frac{x^2+y^2}{2}+\frac{1-\mu}{r_1}+\frac{\mu}{r_2}+\frac{\mu(1-\mu)}{2},\quad
r_1=\sqrt{(x+\mu)^2+y^2+z^2},\quad r_2=\sqrt{(x-1+\mu)^2+y^2+z^2}
$$

The Jacobi constant is conserved along any trajectory. This makes it a good check on numerical accuracy:

$$
C = 2\Omega - (\dot x^2+\dot y^2+\dot z^2)
$$

The collinear point L1 is the root of $\Omega_x(x,0,0)=0$ between the Sun and the Earth.

2.2 State transition matrix

Write the state as $\mathbf s=[x,y,z,\dot x,\dot y,\dot z]^\top$. Its sensitivity to the initial state obeys

$$
\dot\Phi = A(t),\Phi,\qquad
A=\begin{bmatrix}0 & I\ \Omega_{\mathbf{rr}} & \Xi\end{bmatrix},\qquad
\Xi=\begin{bmatrix}0&2&0\-2&0&0\0&0&0\end{bmatrix},\qquad \Phi(0)=I
$$

where $\Omega_{\mathbf{rr}}$ is the Hessian of $\Omega$ with respect to position.

2.3 Halo orbit by differential correction

A halo orbit is symmetric about the $xz$-plane. Start from

$$
\mathbf s_0=[x_0,,0,,z_0,,0,,\dot y_0,,0]^\top,\qquad z_0=A_z
$$

and require a perpendicular crossing of the $y=0$ plane at the half period:

$$
\dot x(T/2)=0,\qquad \dot z(T/2)=0
$$

The unknowns are $(x_0,\dot y_0)$. Newton’s method uses the STM $\Phi$ evaluated at the crossing. The crossing time itself moves when the initial state changes, which adds a correction term:

$$
\begin{bmatrix}\delta x_0\ \delta\dot y_0\end{bmatrix}
=-J^{-1}\begin{bmatrix}\dot x_f\ \dot z_f\end{bmatrix},\qquad
J=\begin{bmatrix}
\Phi_{41}-\dfrac{\ddot x_f}{\dot y_f}\Phi_{21} & \Phi_{45}-\dfrac{\ddot x_f}{\dot y_f}\Phi_{25}\[3mm]
\Phi_{61}-\dfrac{\ddot z_f}{\dot y_f}\Phi_{21} & \Phi_{65}-\dfrac{\ddot z_f}{\dot y_f}\Phi_{25}
\end{bmatrix}
$$

2.4 Instability of the orbit

A halo orbit around L1 is unstable. The monodromy matrix $M=\Phi(T)$ has one real eigenvalue $\lambda_u\gg1$. Its growth rate is

$$
s=\frac{\ln\lambda_u}{T}
$$

and the e-folding time of an orbit error is $1/s$.

2.5 The SEV angle constraint

The Earth-to-spacecraft vector is $\boldsymbol\rho=(x-1+\mu,;y,;z)$. The Earth-to-Sun direction is $(-1,0,0)$, so

$$
\alpha=\arccos!\left(\frac{-(x-1+\mu)}{|\boldsymbol\rho|}\right)
$$

2.6 Cost model

Insertion cost. Arriving with the L1 energy level $C_{L1}$ and moving onto a halo orbit requires raising the energy by $\Delta C=C_{L1}-C_{\rm halo}$. A burn at the highest orbital speed $v_{\max}$ changes $C$ by $-2v,\delta v$, which gives a first-order estimate. The result is expressed as an increment over the smallest orbit in the search range, $A_{z,\rm lo}=10^5$ km. A constant offset does not change the optimum:

$$
\Delta V_{\rm ins}^{+}(A_z)=\left[\frac{\Delta C(A_z)}{2v_{\max}(A_z)}-\frac{\Delta C(A_{z,\rm lo})}{2v_{\max}(A_{z,\rm lo})}\right]V_U
$$

Station keeping. An error in the unstable mode grows as $e^{s\tau}$ between maneuvers. The per-year cost is

$$
\Delta V_{\rm sk}(A_z,\tau)=\frac{365.25}{\tau}\Big(\sigma_v,e^{s(A_z),\tau}+\delta_v\Big)
$$

Short intervals waste fixed maneuver costs. Long intervals let the instability amplify errors. Setting $\partial\Delta V_{\rm sk}/\partial\tau=0$ gives the optimal interval in closed form:

$$
\sigma_v,e^{s\tau},(s\tau-1)=\delta_v
$$

2.7 Optimality conditions

Form the Lagrangian with multipliers $\nu_1,\nu_2\ge0$ for the two angle constraints:

$$
\mathcal L=J-\nu_1\big(\alpha_{\min}-4.5^\circ\big)-\nu_2\big(28^\circ-\alpha_{\max}\big)
$$

At an optimum with only the first constraint active:

$$
\frac{\partial J}{\partial\tau}=0,\qquad
\frac{\partial J}{\partial A_z}=\nu_1\frac{\partial\alpha_{\min}}{\partial A_z}
$$

The multiplier $\nu_1$ has a direct meaning. It is the extra $\Delta V$ paid for each additional degree of exclusion angle.

3. 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
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
import time
import math
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.gridspec import GridSpec
from matplotlib import cm
from matplotlib.colors import Normalize
from scipy.integrate import solve_ivp
from scipy.interpolate import CubicSpline
from scipy.optimize import brentq, minimize

T_START = time.time()

# ---------------------------------------------------------------
# 1. Constants: Sun-Earth CR3BP in nondimensional units
# ---------------------------------------------------------------
MU = 3.00348e-6 # mass ratio (Earth+Moon)/(Sun+Earth+Moon)
AU_KM = 1.495978707e8 # length unit [km]
YEAR_S = 365.25636 * 86400.0 # sidereal year [s]
TU_S = YEAR_S / (2.0 * math.pi) # time unit [s]
TU_DAY = TU_S / 86400.0 # time unit [day]
VU_MS = AU_KM / TU_S * 1000.0 # velocity unit [m/s]

# Mission requirements and cost-model parameters
ALPHA_MIN_DEG = 4.5 # solar radio-interference exclusion angle
ALPHA_MAX_DEG = 28.0 # antenna pointing limit
N_YEARS = 5.0 # mission lifetime [yr]
SIGMA_V = 0.02 # dispersion growth per cycle [m/s]
DV_FIX = 0.05 # fixed cost per maneuver [m/s]
AZ_LO, AZ_HI = 100.0e3, 400.0e3 # search range of Az [km]
TAU_LO, TAU_HI = 10.0, 90.0 # search range of maneuver interval [day]
N_SAMPLES = 3001 # samples per orbit


def lagrange_l1():
f = lambda x: (x - (1 - MU) * (x + MU) / abs(x + MU) ** 3
- MU * (x - 1 + MU) / abs(x - 1 + MU) ** 3)
return brentq(f, 0.9, 0.999, xtol=1e-15)


XL1 = lagrange_l1()
GAMMA = 1.0 - MU - XL1


def pseudo_potential(x, y, z):
r1 = np.sqrt((x + MU) ** 2 + y * y + z * z)
r2 = np.sqrt((x - 1 + MU) ** 2 + y * y + z * z)
return 0.5 * (x * x + y * y) + (1 - MU) / r1 + MU / r2 + 0.5 * MU * (1 - MU)


C_L1 = 2.0 * pseudo_potential(XL1, 0.0, 0.0)


# ---------------------------------------------------------------
# 2. Equations of motion with the state transition matrix
# ---------------------------------------------------------------
def rhs_with_stm(t, s):
x, y, z, vx, vy, vz = s[:6]
xm = x + MU
xp = x - 1.0 + MU
r1 = math.sqrt(xm * xm + y * y + z * z)
r2 = math.sqrt(xp * xp + y * y + z * z)
r13, r23 = r1 ** 3, r2 ** 3
r15, r25 = r13 * r1 * r1, r23 * r2 * r2
a, b = 1.0 - MU, MU

ax = 2.0 * vy + x - a * xm / r13 - b * xp / r23
ay = -2.0 * vx + y - a * y / r13 - b * y / r23
az = -a * z / r13 - b * z / r23

base = -a / r13 - b / r23
uxx = 1.0 + base + 3.0 * a * xm * xm / r15 + 3.0 * b * xp * xp / r25
uyy = 1.0 + base + 3.0 * a * y * y / r15 + 3.0 * b * y * y / r25
uzz = base + 3.0 * a * z * z / r15 + 3.0 * b * z * z / r25
uxy = 3.0 * a * xm * y / r15 + 3.0 * b * xp * y / r25
uxz = 3.0 * a * xm * z / r15 + 3.0 * b * xp * z / r25
uyz = 3.0 * a * y * z / r15 + 3.0 * b * y * z / r25

A = np.zeros((6, 6))
A[0, 3] = A[1, 4] = A[2, 5] = 1.0
A[3, 0], A[3, 1], A[3, 2], A[3, 4] = uxx, uxy, uxz, 2.0
A[4, 0], A[4, 1], A[4, 2], A[4, 3] = uxy, uyy, uyz, -2.0
A[5, 0], A[5, 1], A[5, 2] = uxz, uyz, uzz

out = np.empty(42)
out[:6] = (vx, vy, vz, ax, ay, az)
out[6:] = (A @ s[6:].reshape(6, 6)).ravel()
return out


def cross_y_down(t, s):
return s[1]


cross_y_down.terminal = True
cross_y_down.direction = -1


def initial_state(x0, z0, vy0):
s0 = np.zeros(42)
s0[:6] = (x0, 0.0, z0, 0.0, vy0, 0.0)
s0[6:] = np.eye(6).ravel()
return s0


def half_period_shot(x0, z0, vy0):
sol = solve_ivp(rhs_with_stm, (0.0, 4.0), initial_state(x0, z0, vy0),
method="DOP853", rtol=1e-12, atol=1e-12,
events=cross_y_down, first_step=1e-4)
if len(sol.t_events[0]) == 0:
raise RuntimeError("No half-period crossing found.")
return sol.t_events[0][0], sol.y_events[0][0]


def correct_halo(x0, z0, vy0, tol=1e-11, max_iter=40):
"""Newton iteration on (x0, vy0) so that vx = vz = 0 at the half period."""
for _ in range(max_iter):
th, sf = half_period_shot(x0, z0, vy0)
vxf, vyf, vzf = sf[3], sf[4], sf[5]
if abs(vxf) < tol and abs(vzf) < tol:
return x0, vy0, th
phi = sf[6:].reshape(6, 6)
d = rhs_with_stm(0.0, sf)
axf, azf = d[3], d[5]
jac = np.array([
[phi[3, 0] - axf / vyf * phi[1, 0], phi[3, 4] - axf / vyf * phi[1, 4]],
[phi[5, 0] - azf / vyf * phi[1, 0], phi[5, 4] - azf / vyf * phi[1, 4]],
])
dx0, dvy0 = np.linalg.solve(jac, -np.array([vxf, vzf]))
x0 += dx0
vy0 += dvy0
raise RuntimeError("Differential correction did not converge.")


def linear_guess(ax_km):
c2 = MU / GAMMA ** 3 + (1 - MU) / (1 - GAMMA) ** 3
wp = math.sqrt((2 - c2 + math.sqrt(9 * c2 ** 2 - 8 * c2)) / 2)
k = (wp ** 2 + 1 + 2 * c2) / (2 * wp)
ax = ax_km / AU_KM
return XL1 + ax, k * wp * ax


def evaluate_orbit(x0, z0, vy0, th, n=N_SAMPLES):
T = 2.0 * th
t_eval = np.linspace(0.0, T, n)
sol = solve_ivp(rhs_with_stm, (0.0, T), initial_state(x0, z0, vy0),
method="DOP853", rtol=1e-12, atol=1e-12, t_eval=t_eval)
X = sol.y[:6]
mono = sol.y[6:, -1].reshape(6, 6)
lam = float(np.max(np.abs(np.linalg.eigvals(mono))))
period_day = T * TU_DAY

dx = X[0] - (1.0 - MU)
rng = np.sqrt(dx ** 2 + X[1] ** 2 + X[2] ** 2)
alpha = np.degrees(np.arccos(-dx / rng)) # Sun-Earth-spacecraft angle
speed = np.sqrt(X[3] ** 2 + X[4] ** 2 + X[5] ** 2)
jacobi = 2.0 * pseudo_potential(X[0], X[1], X[2]) - speed ** 2
d_c = C_L1 - jacobi[0]
dv_ins_abs = d_c / (2.0 * speed.max()) * VU_MS

return dict(t_day=t_eval * TU_DAY, X=X, alpha=alpha, period=period_day,
lam=lam, s=math.log(lam) / period_day,
a_min=alpha.min(), a_max=alpha.max(),
dv_ins_abs=dv_ins_abs, jacobi_drift=np.ptp(jacobi),
closure=float(np.linalg.norm(X[:, -1] - X[:, 0])),
x0=x0, vy0=vy0, z0=z0)


# ---------------------------------------------------------------
# 3. Halo family by natural-parameter continuation in Az
# ---------------------------------------------------------------
az_grid = np.arange(AZ_LO, AZ_HI + 1.0, 10.0e3)
orbits = []
for i, az in enumerate(az_grid):
z0 = az / AU_KM
if i == 0:
seed = None
for ax_km in (200e3, 250e3, 150e3, 300e3):
try:
gx, gv = linear_guess(ax_km)
seed = correct_halo(gx, z0, gv)
break
except (RuntimeError, np.linalg.LinAlgError):
continue
if seed is None:
raise RuntimeError("Initial halo orbit could not be found.")
x0, vy0, th = seed
else:
if i >= 2:
gx = 2 * orbits[-1]["x0"] - orbits[-2]["x0"]
gv = 2 * orbits[-1]["vy0"] - orbits[-2]["vy0"]
else:
gx, gv = orbits[-1]["x0"], orbits[-1]["vy0"]
x0, vy0, th = correct_halo(gx, z0, gv)
orbits.append(evaluate_orbit(x0, z0, vy0, th))

T_FAMILY = time.time() - T_START

az = az_grid
a_min_arr = np.array([o["a_min"] for o in orbits])
a_max_arr = np.array([o["a_max"] for o in orbits])
s_arr = np.array([o["s"] for o in orbits])
dv_ins_arr = np.array([o["dv_ins_abs"] for o in orbits])
dv_ins_arr = dv_ins_arr - dv_ins_arr[0] # increment w.r.t. Az = AZ_LO

sp_amin = CubicSpline(az, a_min_arr)
sp_amax = CubicSpline(az, a_max_arr)
sp_s = CubicSpline(az, s_arr)
sp_dv = CubicSpline(az, dv_ins_arr)
sp_x0 = CubicSpline(az, [o["x0"] for o in orbits])
sp_vy0 = CubicSpline(az, [o["vy0"] for o in orbits])


# ---------------------------------------------------------------
# 4. Cost model (vectorised, no ODE solves inside)
# ---------------------------------------------------------------
def dv_keep_per_year(s, tau):
return (365.25 / tau) * (SIGMA_V * np.exp(s * tau) + DV_FIX)


def total_cost(az_km, tau_day):
return sp_dv(az_km) + N_YEARS * dv_keep_per_year(sp_s(az_km), tau_day)


def tau_optimal(s):
"""Root of SIGMA_V * exp(x) * (x - 1) = DV_FIX with x = s * tau (Newton, vectorised)."""
x = np.full_like(np.asarray(s, dtype=float), 2.0)
for _ in range(60):
f = SIGMA_V * np.exp(x) * (x - 1.0) - DV_FIX
df = SIGMA_V * np.exp(x) * x
x = x - f / df
return x / s


# ---------------------------------------------------------------
# 5. Optimisation: brute-force grid -> SLSQP refinement
# ---------------------------------------------------------------
AZ_G, TAU_G = np.meshgrid(np.linspace(AZ_LO, AZ_HI, 301),
np.linspace(TAU_LO, TAU_HI, 301))
J_G = total_cost(AZ_G, TAU_G)
FEAS_G = (sp_amin(AZ_G) >= ALPHA_MIN_DEG) & (sp_amax(AZ_G) <= ALPHA_MAX_DEG)
J_MASKED = np.where(FEAS_G, J_G, np.inf)
ib = np.unravel_index(np.argmin(J_MASKED), J_MASKED.shape)
grid_best = (AZ_G[ib], TAU_G[ib], J_G[ib])

SCALE = np.array([1.0e5, 10.0]) # [km, day]


def obj(u):
p = u * SCALE
return float(total_cost(p[0], p[1]))


cons = [
{"type": "ineq", "fun": lambda u: float(sp_amin(u[0] * SCALE[0]) - ALPHA_MIN_DEG)},
{"type": "ineq", "fun": lambda u: float(ALPHA_MAX_DEG - sp_amax(u[0] * SCALE[0]))},
]
bnds = [(AZ_LO / SCALE[0], AZ_HI / SCALE[0]), (TAU_LO / SCALE[1], TAU_HI / SCALE[1])]
res = minimize(obj, np.array([grid_best[0], grid_best[1]]) / SCALE, method="SLSQP",
bounds=bnds, constraints=cons, options={"ftol": 1e-12, "maxiter": 200})
az_slsqp, tau_slsqp = res.x * SCALE

# ---------------------------------------------------------------
# 6. Verification with the full nonlinear model
# ---------------------------------------------------------------
def true_orbit(az_km):
z0 = az_km / AU_KM
x0, vy0, th = correct_halo(float(sp_x0(az_km)), z0, float(sp_vy0(az_km)))
return evaluate_orbit(x0, z0, vy0, th, n=6001)


az_lo_exact = brentq(lambda a: true_orbit(a)["a_min"] - ALPHA_MIN_DEG,
az[0] + 1.0, 200.0e3, xtol=1e-3)
az_hi_exact = brentq(lambda a: true_orbit(a)["a_max"] - ALPHA_MAX_DEG,
250.0e3, az[-1] - 1.0, xtol=1e-3)
best = true_orbit(az_lo_exact)
tau_star = float(tau_optimal(best["s"]))
dv_ins_star = float(sp_dv(az_lo_exact))
dv_keep_star = float(dv_keep_per_year(best["s"], tau_star))
j_star = dv_ins_star + N_YEARS * dv_keep_star

T_TOTAL = time.time() - T_START

# ---------------------------------------------------------------
# 7. Console report
# ---------------------------------------------------------------
print("=" * 72)
print("Sun-Earth L1 halo orbit design for a space-weather satellite")
print("=" * 72)
print(f"L1 distance from Earth : {GAMMA * AU_KM:12.1f} km")
print(f"Time unit / velocity unit : {TU_DAY:10.4f} day / {VU_MS:10.2f} m/s")
print(f"Family computed (n = {len(az):d}) : {T_FAMILY:6.2f} s")
print("-" * 72)
print(f"{'Az [km]':>10s} {'T [day]':>9s} {'lambda':>9s} {'a_min[deg]':>11s} "
f"{'a_max[deg]':>11s} {'dV_ins+[m/s]':>13s}")
for i in range(0, len(az), 5):
o = orbits[i]
print(f"{az[i]:10.0f} {o['period']:9.3f} {o['lam']:9.1f} {o['a_min']:11.3f} "
f"{o['a_max']:11.3f} {dv_ins_arr[i]:13.3f}")
print("-" * 72)
print("Feasible Az range (exact, nonlinear model)")
print(f" lower bound (a_min = {ALPHA_MIN_DEG:.1f} deg) : {az_lo_exact:12.1f} km")
print(f" upper bound (a_max = {ALPHA_MAX_DEG:.1f} deg) : {az_hi_exact:12.1f} km")
print("-" * 72)
print("Optimisation results")
print(f" grid search : Az = {grid_best[0]:10.1f} km, tau = {grid_best[1]:6.2f} day, J = {grid_best[2]:8.4f} m/s")
print(f" SLSQP : Az = {az_slsqp:10.1f} km, tau = {tau_slsqp:6.2f} day, J = {res.fun:8.4f} m/s "
f"(success = {res.success})")
print(f" verified : Az = {az_lo_exact:10.1f} km, tau = {tau_star:6.2f} day, J = {j_star:8.4f} m/s")
print("-" * 72)
print("Optimal orbit (nonlinear model)")
print(f" period : {best['period']:10.4f} day")
print(f" unstable eigenvalue : {best['lam']:10.2f} (e-folding time {1.0 / best['s']:.2f} day)")
print(f" SEV angle range : {best['a_min']:.4f} - {best['a_max']:.4f} deg")
print(f" initial state (x0, vy0): {best['x0']:.9f}, {best['vy0']:.9f}")
print(f" closure error : {best['closure']:.3e}")
print(f" Jacobi constant drift : {best['jacobi_drift']:.3e}")
print(f" insertion increment : {dv_ins_star:8.3f} m/s")
print(f" station keeping : {dv_keep_star:8.3f} m/s/yr x {N_YEARS:.0f} yr = {N_YEARS * dv_keep_star:.3f} m/s")
print(f" total cost J : {j_star:8.3f} m/s")
print("-" * 72)
print(f"Total computation time : {T_TOTAL:6.2f} s")
print("=" * 72)

# ---------------------------------------------------------------
# 8. One combined figure
# ---------------------------------------------------------------
plt.rcParams.update({"font.size": 11, "axes.titlesize": 13, "axes.labelsize": 11})
fig = plt.figure(figsize=(22, 13))
gs = GridSpec(2, 3, figure=fig, left=0.04, right=0.98, bottom=0.06, top=0.92,
wspace=0.18, hspace=0.24)
norm_az = Normalize(AZ_LO / 1e3, AZ_HI / 1e3)
cmap = cm.viridis

# (a) 3-D optimal orbit around L1 with wall projections
ax1 = fig.add_subplot(gs[0, 0], projection="3d")
Xo = best["X"]
px = (Xo[0] - XL1) * AU_KM / 1e3
py = Xo[1] * AU_KM / 1e3
pz = Xo[2] * AU_KM / 1e3
xl = (px.min() - 60, px.max() + 60)
yl = (py.min() - 60, py.max() + 60)
zl = (pz.min() - 30, pz.max() + 60)
ax1.plot(px, py, pz, color="tab:red", lw=2.2, label="Optimal halo orbit")
ax1.plot(px, py, np.full_like(pz, zl[0]), color="gray", lw=1.0, alpha=0.7)
ax1.plot(px, np.full_like(py, yl[1]), pz, color="gray", lw=1.0, alpha=0.7)
ax1.plot(np.full_like(px, xl[0]), py, pz, color="gray", lw=1.0, alpha=0.7)
ax1.plot(xl, [0, 0], [0, 0], color="k", ls="--", lw=1.0, label="Sun-Earth line")
ax1.scatter([0], [0], [0], color="tab:blue", s=70, label="L1")
ax1.text(xl[1], 0, 0, " to Earth", color="k")
ax1.text(xl[0], 0, 0, "to Sun ", color="k", ha="right")
ax1.set_xlim(*xl); ax1.set_ylim(*yl); ax1.set_zlim(*zl)
ax1.set_box_aspect((np.ptp(xl), np.ptp(yl), np.ptp(zl)))
ax1.set_xlabel("x - x_L1 [10$^3$ km]", labelpad=10); ax1.set_ylabel("y [10$^3$ km]", labelpad=12); ax1.set_zlabel("z [10$^3$ km]", labelpad=6)
ax1.set_title("(a) Optimal halo orbit in the rotating frame")
ax1.legend(loc="upper left", fontsize=9)
ax1.view_init(elev=24, azim=-62)

# (b) 3-D halo family
ax2 = fig.add_subplot(gs[0, 1], projection="3d")
for o, a in zip(orbits[::3], az[::3]):
ax2.plot((o["X"][0] - XL1) * AU_KM / 1e3, o["X"][1] * AU_KM / 1e3,
o["X"][2] * AU_KM / 1e3, color=cmap(norm_az(a / 1e3)), lw=1.4)
ax2.plot(px, py, pz, color="tab:red", lw=2.6)
ax2.scatter([0], [0], [0], color="k", s=40)
ax2.set_xlabel("x - x_L1 [10$^3$ km]", labelpad=10); ax2.set_ylabel("y [10$^3$ km]", labelpad=12); ax2.set_zlabel("z [10$^3$ km]", labelpad=8)
ax2.set_title("(b) Halo family (red: optimal)")
ax2.view_init(elev=20, azim=-40)
sm = cm.ScalarMappable(norm=norm_az, cmap=cmap)
sm.set_array([])
cb = fig.colorbar(sm, ax=ax2, shrink=0.6, pad=0.14)
cb.set_label("A$_z$ [10$^3$ km]")

# (c) View from Earth toward the Sun (azimuthal equidistant projection)
ax3 = fig.add_subplot(gs[0, 2])
ax3.add_patch(plt.Circle((0, 0), ALPHA_MIN_DEG, color="tab:red", alpha=0.25, label="Exclusion zone"))
ax3.add_patch(plt.Circle((0, 0), 0.27, color="gold", zorder=5))
for a_km, col, ls, lab in [(AZ_LO, "tab:orange", "--", "Az = 100 000 km (violates)"),
(az_lo_exact, "tab:red", "-", f"Az = {az_lo_exact:,.0f} km (optimal)"),
(300.0e3, "tab:green", "-", "Az = 300 000 km")]:
o = orbits[int(round((a_km - AZ_LO) / 10.0e3))] if a_km != az_lo_exact else best
dxo = o["X"][0] - (1.0 - MU)
ang = np.degrees(np.arccos(-dxo / np.sqrt(dxo ** 2 + o["X"][1] ** 2 + o["X"][2] ** 2)))
phi = np.arctan2(o["X"][2], o["X"][1])
ax3.plot(ang * np.cos(phi), ang * np.sin(phi), color=col, ls=ls, lw=2.0, label=lab)
ax3.set_aspect("equal")
ax3.set_xlim(-32, 32); ax3.set_ylim(-32, 32)
ax3.axhline(0, color="gray", lw=0.6); ax3.axvline(0, color="gray", lw=0.6)
ax3.set_xlabel("Angle toward +y [deg]"); ax3.set_ylabel("Angle toward +z [deg]")
ax3.set_title("(c) Sky view from Earth (Sun at the origin)")
ax3.legend(loc="upper right", fontsize=9)
ax3.grid(alpha=0.3)

# (d) SEV angle histories
ax4 = fig.add_subplot(gs[1, 0])
ax4.axhspan(0, ALPHA_MIN_DEG, color="tab:red", alpha=0.15)
ax4.axhspan(ALPHA_MAX_DEG, 40, color="tab:red", alpha=0.15)
for a_km, col, lab in [(AZ_LO, "tab:orange", "Az = 100 000 km"),
(az_lo_exact, "tab:red", "Az = optimal"),
(250.0e3, "tab:green", "Az = 250 000 km"),
(AZ_HI, "tab:purple", "Az = 400 000 km")]:
o = orbits[int(round((a_km - AZ_LO) / 10.0e3))] if a_km != az_lo_exact else best
ax4.plot(o["t_day"], o["alpha"], color=col, lw=2.0, label=lab)
ax4.axhline(ALPHA_MIN_DEG, color="k", ls="--", lw=1.0)
ax4.axhline(ALPHA_MAX_DEG, color="k", ls="--", lw=1.0)
ax4.set_ylim(0, 35)
ax4.set_xlabel("Time [day]"); ax4.set_ylabel("Sun-Earth-spacecraft angle [deg]")
ax4.set_title("(d) Constraint history over one revolution")
ax4.legend(loc="upper center", ncol=2, fontsize=9)
ax4.grid(alpha=0.3)

# (e) Trade-off curves along Az (tau optimised for each Az)
ax5 = fig.add_subplot(gs[1, 1])
az_f = np.linspace(AZ_LO, AZ_HI, 600)
s_f = sp_s(az_f)
tau_f = tau_optimal(s_f)
keep_f = N_YEARS * dv_keep_per_year(s_f, tau_f)
ins_f = sp_dv(az_f)
ax5.axvspan(AZ_LO / 1e3, az_lo_exact / 1e3, color="tab:red", alpha=0.15, label="Infeasible")
ax5.axvspan(az_hi_exact / 1e3, AZ_HI / 1e3, color="tab:red", alpha=0.15)
ax5.plot(az_f / 1e3, ins_f, color="tab:blue", lw=2.0, label="Insertion increment")
ax5.plot(az_f / 1e3, keep_f, color="tab:green", lw=2.0, label="Station keeping (5 yr)")
ax5.plot(az_f / 1e3, ins_f + keep_f, color="k", lw=2.5, label="Total cost J")
ax5.scatter([az_lo_exact / 1e3], [j_star], color="tab:red", s=200, marker="*", zorder=6, label="Optimum")
ax5.set_xlabel("A$_z$ [10$^3$ km]"); ax5.set_ylabel("$\\Delta V$ [m/s]")
ax5.set_title("(e) Trade-off along the halo family")
ax5.legend(loc="upper left", fontsize=9)
ax5.grid(alpha=0.3)

# (f) 3-D cost surface J(Az, tau) over the feasible region
ax6 = fig.add_subplot(gs[1, 2], projection="3d")
AZ_P, TAU_P = np.meshgrid(np.linspace(az_lo_exact, 260.0e3, 161),
np.linspace(TAU_LO, TAU_HI, 161))
J_P = total_cost(AZ_P, TAU_P)
ax6.plot_surface(AZ_P / 1e3, TAU_P, J_P, cmap="viridis", rstride=2, cstride=2,
linewidth=0, antialiased=True, alpha=0.92)
LIFT = 0.5
az_v = np.linspace(az_lo_exact, 260.0e3, 100)
tau_v = tau_optimal(sp_s(az_v))
ax6.plot(az_v / 1e3, tau_v, total_cost(az_v, tau_v) + LIFT, color="k", lw=2.2,
label="Optimal $\\tau$ for each A$_z$")
ax6.scatter([az_lo_exact / 1e3], [tau_star], [j_star + LIFT], color="tab:red", s=300,
marker="*", edgecolor="k", depthshade=False, label="Optimum")
ax6.legend(loc="upper left", fontsize=9)
ax6.set_xlabel("A$_z$ [10$^3$ km]", labelpad=8)
ax6.set_ylabel("$\\tau$ [day]", labelpad=8)
ax6.set_zlabel("J [m/s]", labelpad=6)
ax6.set_zlim(0, float(J_P.max()))
ax6.set_title("(f) Cost surface J(A$_z$, $\\tau$) over the feasible region")
ax6.set_box_aspect((1.3, 1.0, 0.75))
ax6.view_init(elev=30, azim=-50)

fig.suptitle("Orbit optimization of a Sun-Earth L1 space-weather satellite", fontsize=17, y=0.975)
fig.savefig("l1_halo_optimization.png", dpi=130)
plt.show()

4. Code walkthrough

Section 1: constants and L1

All dynamics run in nondimensional units, with 1 AU as the length unit and $1/n$ as the time unit. Then $\mu$ is the only physical parameter, and one revolution of the rotating frame takes $2\pi$ time units. The constants TU_DAY and VU_MS convert results back to days and m/s.

lagrange_l1() finds L1 by Brent’s method on the axial force balance. pseudo_potential() is $\Omega$, and C_L1 is the Jacobi constant of a spacecraft at rest at L1. It serves as the energy reference for the insertion cost.

Section 2: dynamics and differential correction

rhs_with_stm. The right-hand side packs the 6 state variables and the flattened $6\times6$ STM into one 42-element vector. It uses scalar math.sqrt rather than NumPy ufuncs for the small operations, because NumPy call overhead dominates at this scale. This function is called hundreds of thousands of times, so the saving is real.

cross_y_down. This event function detects the half-period plane crossing. With direction = -1 it fires only when $y$ passes through zero downward. The initial state also has $y=0$, but $\dot y_0>0$ there, so it is ignored automatically and no manual “skip the first step” logic is needed.

correct_halo. This is the Newton iteration from Section 2.3. Because the analytic Jacobian comes from the STM, each iteration needs only one integration. A finite-difference Jacobian would need three. It also converges quadratically, typically in a handful of iterations.

linear_guess. Around L1 the linearized dynamics have an in-plane frequency $\omega_p$ and an amplitude ratio $k$. Together they give a starting guess for $x_0$ and $\dot y_0$ from a chosen in-plane amplitude. The seed loop tries several amplitudes so that one failed guess cannot stop the script.

evaluate_orbit. This integrates one full period with dense output at 3001 points and extracts everything the optimizer needs:

  • the monodromy eigenvalue $\lambda_u$ and growth rate $s$,
  • the SEV angle history $\alpha(t)$,
  • the Jacobi constant history, whose drift $\Delta C$ verifies the integration,
  • the insertion-cost estimate.

Section 3: continuation

Solving each $A_z$ from scratch would be wasteful. The family is computed by continuation: the first orbit uses the linear guess, and every later orbit uses a secant predictor,

$$
\mathbf p_{i+1}^{\rm guess}=2\mathbf p_i-\mathbf p_{i-1}
$$

Newton then converges in one or two iterations. After the loop, the tabulated quantities $\alpha_{\min}$, $\alpha_{\max}$, $s$, $\Delta V_{\rm ins}$, $x_0$ and $\dot y_0$ are turned into cubic splines over $A_z$.

Section 4: cost model

dv_keep_per_year and total_cost are pure NumPy expressions with no ODE solves inside. tau_optimal solves $\sigma_v e^{x}(x-1)=\delta_v$ by a vectorized Newton iteration, where $x=s\tau$. The function is convex and increasing for $x>1$, so starting at $x=2$ converges monotonically.

Section 5: optimization

The optimization runs in two stages:

  1. Brute-force grid. A $301\times301$ grid over $(A_z,\tau)$ is evaluated in a single vectorized call. Infeasible points are set to $+\infty$, and the minimum gives a starting point.
  2. SLSQP refinement. Variables are scaled to order one with SCALE, which matters for SLSQP conditioning. The two SEV constraints enter as inequality constraints.

Section 6: verification

Splines are approximations, so the answer is verified against the full nonlinear model. true_orbit() corrects a halo at any $A_z$ and uses the splines of $x_0$ and $\dot y_0$ as initial guesses, so it converges almost immediately. Brent’s method then finds the exact $A_z$ at which $\alpha_{\min}=4.5^\circ$ and $\alpha_{\max}=28^\circ$. The final cost is recomputed with the true growth rate at that orbit.

Speed-up strategy

A direct approach would call the nonlinear halo solver at every point of a $301\times301$ grid. At about 0.04 s per orbit, that is roughly an hour. Four measures keep the whole script at a few seconds:

  • Analytic Newton Jacobian from the STM, so one integration per iteration.
  • Continuation with a secant predictor.
  • Splines of the expensive quantities, so the optimizer never calls an ODE solver.
  • Vectorized Newton for $\tau^*$ and a single vectorized grid evaluation.

Section 8: the combined figure

A single GridSpec of 2×3 panels holds three 3-D plots and three 2-D plots, saved as one PNG. Panel (a) draws the orbit with gray wall projections. Panel (c) uses an azimuthal equidistant projection: the radius is the SEV angle $\alpha$ and the polar angle is $\arctan(z/y)$. This makes the $4.5^\circ$ exclusion zone an exact circle.

5. Execution results

Console output

========================================================================
Sun-Earth L1 halo orbit design for a space-weather satellite
========================================================================
L1 distance from Earth      :    1491550.9 km
Time unit / velocity unit   :    58.1324 day /   29784.74 m/s
Family computed (n = 31)   :   7.41 s
------------------------------------------------------------------------
   Az [km]   T [day]    lambda  a_min[deg]  a_max[deg]  dV_ins+[m/s]
    100000   177.880    1744.3       3.441      25.621         0.000
    150000   177.856    1718.0       5.156      25.945         3.788
    200000   177.821    1681.9       6.865      26.392         8.909
    250000   177.777    1636.7       8.567      26.953        15.203
    300000   177.721    1583.3      10.259      27.621        22.496
    350000   177.654    1522.5      11.940      28.389        30.604
    400000   177.575    1455.5      13.610      29.246        39.354
------------------------------------------------------------------------
Feasible Az range (exact, nonlinear model)
  lower bound (a_min = 4.5 deg) :     130848.1 km
  upper bound (a_max = 28.0 deg) :     325444.1 km
------------------------------------------------------------------------
Optimisation results
  grid search  : Az =   131000.0 km, tau =  36.67 day, J =   9.3064 m/s
  SLSQP        : Az =   130848.1 km, tau =  36.68 day, J =   9.2944 m/s (success = True)
  verified     : Az =   130848.1 km, tau =  36.68 day, J =   9.2944 m/s
------------------------------------------------------------------------
Optimal orbit (nonlinear model)
  period                 :   177.8660 day
  unstable eigenvalue    :    1729.26  (e-folding time 23.86 day)
  SEV angle range        : 4.5000 - 25.8063 deg
  initial state (x0, vy0): 0.988883314, 0.008921282
  closure error          : 1.393e-11
  Jacobi constant drift  : 1.776e-15
  insertion increment    :    2.172 m/s
  station keeping        :    1.425 m/s/yr x 5 yr = 7.123 m/s
  total cost J           :    9.294 m/s
------------------------------------------------------------------------
Total computation time   :   9.48 s
========================================================================

Result image

6. Reading the results

The optimum

The optimizer finds:

  • $A_z^*\approx 130{,}848$ km
  • $\tau^*\approx 36.7$ days
  • $J^*\approx 9.29$ m/s, made up of a 2.17 m/s insertion increment and 1.42 m/s per year of station keeping (7.12 m/s over five years)

The optimal orbit has a period of 177.87 days. Its unstable eigenvalue is about 1729, which corresponds to an error e-folding time of about 23.9 days. The closure error is of order $10^{-11}$ and the Jacobi constant drift is of order $10^{-15}$. The orbit is therefore a genuine periodic halo orbit, and the integration is accurate.

The grid search lands within one grid cell of this answer, at 131,000 km and 36.67 days. SLSQP and the independent nonlinear verification agree to all printed digits.

Panel (a): the optimal orbit in 3-D

The halo orbit is a large tilted loop around L1. Relative to L1 it reaches about −171 to +245 thousand km in $x$ (toward the Sun and toward the Earth), ±667 thousand km in $y$, and −105 to +131 thousand km in $z$. The gray curves are its projections onto the three walls. The dashed line is the Sun–Earth line, and the spacecraft distance from Earth stays between roughly 1.25 and 1.67 million km. The loop is far larger in $y$ than in $z$, which is why the orbit looks flattened.

Panel (b): the halo family

The colored curves show the family for $A_z$ from 100 to 400 thousand km, with the optimal orbit in red. Larger $A_z$ tilts the loop out of the ecliptic plane and enlarges it slightly. The red orbit sits at the small-amplitude end of the family. This is what the constraint analysis below predicts.

Panel (c): sky view from Earth

This is the view of the orbit as seen from Earth, with the Sun at the origin. The pink disk is the $4.5^\circ$ exclusion zone. The orange dashed orbit ($A_z=100{,}000$ km) dips into the zone, so it is infeasible. The red orbit at the optimum just grazes the top edge of the circle. The green orbit ($A_z=300{,}000$ km) keeps a wide margin, but it pays for that margin in insertion cost.

The bound is easy to check by hand. The tightest approach to the Sun occurs where the spacecraft is at the Sun-side extreme of the orbit with $z=A_z$. That point is about 171,000 km sunward of L1, so it is about 1,662,600 km from Earth. Then

$$
A_z \approx 1{,}662{,}600\ \text{km}\times\tan 4.5^\circ\approx 130{,}850\ \text{km}
$$

which matches the numerical result.

Panel (d): SEV angle over one revolution

The angle oscillates twice per revolution. The deeper minimum occurs at the Sun-side crossing, where $z$ is at its maximum, and this is what sets the lower bound on $A_z$. The second minimum, at the Earth-side crossing where $z=-105{,}000$ km, is slightly higher at about $4.8^\circ$. The orange curve ($A_z=100{,}000$ km) dips into the lower red band, with a minimum of $3.44^\circ$. The purple curve ($A_z=400{,}000$ km) rises to $29.2^\circ$ and pokes into the upper red band, violating the antenna limit. The optimal red curve touches the $4.5^\circ$ line exactly. The feasible range of $A_z$ is therefore about 130.8 to 325.4 thousand km.

Panel (e): the trade-off

The blue curve shows that the insertion increment grows rapidly and convexly with $A_z$: from 0 at 100,000 km to about 39 m/s at 400,000 km. The green curve shows that the five-year station-keeping cost is almost flat, falling only from about 7.13 to 6.97 m/s. Larger orbits are slightly less unstable, with $\lambda_u$ dropping from about 1744 to 1456, but the benefit is small. The black total cost is therefore dominated by the insertion term and increases with $A_z$. Without the SEV constraint, the best orbit would be the smallest one. The exclusion angle is what pushes the optimum up to the boundary, and the optimum sits exactly at the edge of the pink infeasible band.

The multiplier from Section 2.7 can be read off numerically. Near the optimum, $\partial J/\partial A_z\approx0.079$ m/s per 1000 km and $\partial\alpha_{\min}/\partial A_z\approx0.034^\circ$ per 1000 km. Their ratio gives $\nu_1\approx2.3$ m/s per degree. Each extra degree of solar exclusion angle costs about 2.3 m/s over the mission.

Panel (f): cost surface

This 3-D surface shows $J(A_z,\tau)$ over the feasible region. Along the $\tau$ direction it forms a clear valley. At $A_z^*$, the cost at $\tau=10,,20,,30,,36.7,,50,,70,,90$ days is about 16.9, 11.0, 9.5, 9.29, 9.9, 13.3, 20.8 m/s. Short intervals pay the fixed maneuver cost too often. Long intervals let the instability amplify errors exponentially. The black curve on the surface is the analytic $\tau^*(A_z)$, which stays close to 37 days across the whole family because $s$ varies only slightly. Along the $A_z$ direction the surface rises steadily, which is the insertion term at work. The red star sits on the boundary of the feasible region, exactly where the constraint is active.

7. Takeaways

  • The optimal design is a compromise between two effects: radio interference forces the halo to be large, and insertion energy forces it to be small.
  • The station-keeping interval has a genuine interior optimum of about 37 days, set by balancing fixed maneuver cost against exponential error growth with an e-folding time near 24 days.
  • Every closed-form check agrees with the numerics: the exclusion-angle geometry for $A_z^*$, and the stationarity condition for $\tau^*$.
  • A spline surrogate built from a handful of nonlinear orbit solutions turns a problem that would take about an hour by brute force into a few seconds, without giving up verification against the full model.

Optimizing a Satellite's Daily Space-Weather Observation Schedule with Mixed-Integer Programming

Space-weather monitoring satellites face a scheduling problem that looks deceptively simple: point the instrument at the most scientifically important target at every moment. In practice it is anything but simple. A single-instrument satellite (or a satellite sharing one pointing axis across several sensors) can only stare at one target at a time — a flaring active region on the Sun, the L1 point watching for an incoming CME, the radiation belts, the polar ionosphere, or the magnetopause boundary. Each target’s importance rises and falls over the day as forecasts update, and every time the satellite re-points, it pays a “slew” penalty in time and momentum-wheel fuel.

This is a textbook combinatorial optimization problem, and it fits neatly into a Mixed-Integer Linear Program (MILP). Below we formulate it, solve it in Google Colaboratory with scipy.optimize.milp, and visualize the result — including the slew path traced across the sky.

Problem Formulation

Let there be $T$ discrete observation slots (hours) and $N$ candidate targets. The binary decision variable $x_{t,i}$ equals $1$ if the satellite observes target $i$ during slot $t$:

$$x_{t,i}\in{0,1}, \quad t=0,\dots,T-1,\ \ i=0,\dots,N-1$$

To penalize re-pointing, we introduce a switching-indicator variable $y_{t,i,j}$ that turns on whenever the satellite moves from target $i$ (at slot $t-1$) to a different target $j$ (at slot $t$):

$$y_{t,i,j}\ge 0, \quad t=1,\dots,T-1,\ \ i\neq j$$

The objective maximizes accumulated scientific value minus total slew cost:

$$\max \ \sum_{t=0}^{T-1}\sum_{i=0}^{N-1} w_i(t),x_{t,i} ;-; \sum_{t=1}^{T-1}\sum_{i\neq j} c_{ij},y_{t,i,j}$$

subject to:

$$\sum_{i=0}^{N-1} x_{t,i} = 1 \qquad \forall t \quad \text{(observe exactly one target per slot)}$$

$$y_{t,i,j} \ge x_{t-1,i} + x_{t,j} - 1 \qquad \forall t\ge1,\ i\neq j \quad \text{(switch indicator linking)}$$

The time-varying scientific value of target $i$ is modeled as a baseline plus a Gaussian urgency bump centered on the forecast peak time $\tau_i$ (e.g., predicted flare or CME arrival):

$$w_i(t) = b_i + a_i \exp!\left(-\frac{(t-\tau_i)^2}{2\sigma_i^2}\right)$$

The slew cost between two targets is proportional to the great-circle angle between their boresight unit vectors $\hat{u}_i$:

$$c_{ij} = \kappa \arccos(\hat{u}_i\cdot \hat{u}_j)$$

Full Python Implementation (Google Colaboratory)

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
import numpy as np
from scipy.optimize import milp, LinearConstraint, Bounds
from scipy.sparse import csr_matrix
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
import time

plt.style.use('dark_background')
rng = np.random.default_rng(42)

# ----------------------------------------------------------------
# 1. Problem setup
# ----------------------------------------------------------------
T = 24 # hourly observation slots over one day
target_names = [
"AR3536 Flare Watch",
"AR3541 Flare Watch",
"L1 CME Arrival Monitor",
"GEO Radiation Belt Monitor",
"Polar Ionospheric TEC Scan",
"Magnetopause Standoff Tracker",
]
N = len(target_names)

base = np.array([0.15, 0.15, 0.30, 0.20, 0.10, 0.15]) # baseline importance
amp = np.array([0.90, 0.75, 1.00, 0.60, 0.40, 0.55]) # urgency amplitude
peak = np.array([5.0, 14.0, 9.0, 18.0, 2.0, 21.0]) # forecast peak hour
sigma = np.array([2.0, 2.5, 1.5, 3.0, 2.0, 2.5]) # urgency spread

t_grid = np.arange(T)
w = base[None, :] + amp[None, :] * np.exp(
-((t_grid[:, None] - peak[None, :]) ** 2) / (2 * sigma[None, :] ** 2)
) # w[t, i]

theta = np.array([0.6, 0.9, 1.2, 1.9, 0.3, 2.5])
phi = np.array([0.3, 2.0, 4.2, 1.0, 5.5, 3.3])
directions = np.stack([
np.sin(theta) * np.cos(phi),
np.sin(theta) * np.sin(phi),
np.cos(theta),
], axis=1) # boresight unit vectors, shape (N, 3)

dot = np.clip(directions @ directions.T, -1.0, 1.0)
angle = np.arccos(dot)
KAPPA = 0.35
switch_cost = KAPPA * angle # c[i, j]

# ----------------------------------------------------------------
# 2. Decision variables
# x[t, i] -> flattened index t*N + i (binary)
# y[t, i, j] for t = 1..T-1, i != j (continuous, provably exact)
# ----------------------------------------------------------------
n_x = T * N
pairs = [(i, j) for i in range(N) for j in range(N) if i != j]
n_pairs = len(pairs)
n_y = (T - 1) * n_pairs
n_vars = n_x + n_y
y_offset = n_x

def x_idx(t, i):
return t * N + i

def y_idx(t_shift, pair_k):
return y_offset + t_shift * n_pairs + pair_k

# ----------------------------------------------------------------
# 3. Objective (milp MINIMIZES, so negate the reward)
# ----------------------------------------------------------------
c = np.zeros(n_vars)
c[:n_x] = -w.flatten()

pair_costs = np.array([switch_cost[i, j] for (i, j) in pairs])
for t_shift in range(T - 1):
c[y_offset + t_shift * n_pairs: y_offset + (t_shift + 1) * n_pairs] = pair_costs

# ----------------------------------------------------------------
# 4. Constraints (sparse construction)
# ----------------------------------------------------------------
rows, cols, data = [], [], []
row_id = 0

for t in range(T):
for i in range(N):
rows.append(row_id); cols.append(x_idx(t, i)); data.append(1.0)
row_id += 1
n_rows_a = row_id

for t_shift in range(T - 1):
t = t_shift + 1
for k, (i, j) in enumerate(pairs):
rows.append(row_id); cols.append(y_idx(t_shift, k)); data.append(1.0)
rows.append(row_id); cols.append(x_idx(t - 1, i)); data.append(-1.0)
rows.append(row_id); cols.append(x_idx(t, j)); data.append(-1.0)
row_id += 1
n_rows_b = row_id - n_rows_a

A = csr_matrix((data, (rows, cols)), shape=(row_id, n_vars))
lb = np.concatenate([np.ones(n_rows_a), np.full(n_rows_b, -1.0)])
ub = np.concatenate([np.ones(n_rows_a), np.full(n_rows_b, np.inf)])
constraints = LinearConstraint(A, lb, ub)

# ----------------------------------------------------------------
# 5. Bounds & integrality
# ----------------------------------------------------------------
lower = np.zeros(n_vars)
upper = np.ones(n_vars)
bounds = Bounds(lower, upper)

integrality = np.zeros(n_vars)
integrality[:n_x] = 1 # only x is forced binary

# ----------------------------------------------------------------
# 6. Solve
# ----------------------------------------------------------------
t0 = time.time()
result = milp(c=c, constraints=constraints, bounds=bounds, integrality=integrality)
solve_time = time.time() - t0

x_sol = result.x[:n_x].reshape(T, N)
schedule = np.argmax(x_sol, axis=1)

total_value = w[np.arange(T), schedule].sum()
n_switches = int(np.sum(schedule[1:] != schedule[:-1]))
total_switch_cost = sum(
switch_cost[schedule[t], schedule[t + 1]]
for t in range(T - 1) if schedule[t] != schedule[t + 1]
)

print("=" * 60)
print("SATELLITE SPACE-WEATHER OBSERVATION SCHEDULE")
print("=" * 60)
print(f"Solver status : {result.message}")
print(f"Solve time : {solve_time:.3f} s")
print(f"Total science value : {total_value:.3f}")
print(f"Number of slews : {n_switches}")
print(f"Total slew cost : {total_switch_cost:.3f}")
print("-" * 60)
for t in range(T):
print(f" Hour {t:02d}:00 -> {target_names[schedule[t]]}")
print("=" * 60)

# ----------------------------------------------------------------
# 7. Visualization (single combined figure, dark theme, 3D included)
# ----------------------------------------------------------------
colors = plt.cm.plasma(np.linspace(0.15, 0.95, N))
fig = plt.figure(figsize=(16, 12), facecolor="#0b0d13")
fig.suptitle("Satellite Space-Weather Observation Scheduling", fontsize=16, color="white")

ax1 = fig.add_subplot(2, 2, 1)
im = ax1.imshow(w.T, aspect="auto", cmap="magma", origin="lower",
extent=[0, T, -0.5, N - 0.5])
ax1.scatter(t_grid + 0.5, schedule, color="cyan", edgecolor="white", s=60, zorder=5,
label="Chosen slot")
ax1.set_yticks(range(N)); ax1.set_yticklabels(target_names, fontsize=8)
ax1.set_xlabel("Hour of day")
ax1.set_title("Observation value w(t, target) & chosen schedule")
fig.colorbar(im, ax=ax1, label="Scientific value")
ax1.legend(loc="upper right", fontsize=8)

ax2 = fig.add_subplot(2, 2, 2, projection="3d")
Tg, Ng = np.meshgrid(t_grid, np.arange(N))
ax2.plot_surface(Tg, Ng, w.T, cmap="viridis", alpha=0.75, linewidth=0, antialiased=True)
ax2.scatter(t_grid, schedule, w[np.arange(T), schedule] + 0.02,
color="red", s=35, depthshade=False, label="Scheduled")
ax2.set_xlabel("Hour"); ax2.set_ylabel("Target index"); ax2.set_zlabel("Value")
ax2.set_title("Value surface w(t, i)")
ax2.view_init(elev=28, azim=-60)

ax3 = fig.add_subplot(2, 2, 3, projection="3d")
u, v = np.mgrid[0:2 * np.pi:40j, 0:np.pi:20j]
sx, sy, sz = np.cos(u) * np.sin(v), np.sin(u) * np.sin(v), np.cos(v)
ax3.plot_wireframe(sx, sy, sz, color="gray", linewidth=0.3, alpha=0.4)
for i in range(N):
ax3.scatter(*directions[i], color=colors[i], s=90, label=target_names[i])
path = directions[schedule]
ax3.plot(path[:, 0], path[:, 1], path[:, 2], color="cyan", linewidth=1.5, alpha=0.8)
ax3.set_title("Boresight slew path over the day")
ax3.set_box_aspect([1, 1, 1])
ax3.legend(loc="upper left", fontsize=6, bbox_to_anchor=(-0.15, 1.05))

ax4 = fig.add_subplot(2, 2, 4)
for t in range(T):
ax4.barh(0, 1, left=t, color=colors[schedule[t]], edgecolor="black")
ax4.set_yticks([]); ax4.set_xlim(0, T); ax4.set_xlabel("Hour of day")
ax4.set_title("Daily pointing schedule (Gantt view)")
handles = [plt.Rectangle((0, 0), 1, 1, color=colors[i]) for i in range(N)]
ax4.legend(handles, target_names, loc="upper center", bbox_to_anchor=(0.5, -0.25),
ncol=2, fontsize=8)

plt.tight_layout(rect=[0, 0.03, 1, 0.96])
plt.show()

Code Walkthrough

Section 1 — Problem setup. Six representative space-weather targets are defined with a baseline importance, an urgency amplitude, a forecast peak hour, and a spread. The value matrix w[t, i] is built in one vectorized NumPy expression rather than a nested loop, so it costs virtually nothing even for much larger $T \times N$ grids. Each target also gets a boresight direction on the unit sphere; the pairwise angular distance matrix angle (and thus switch_cost) is computed with a single matrix multiplication.

Section 2 — Variable indexing. Instead of a 3-D array of milp variables, everything is flattened into one long vector, since scipy.optimize.milp expects flat variable arrays. x_idx and y_idx are small helper closures that map $(t,i)$ and $(t,i,j)$ triples to flat positions.

Section 3 — Objective. The value terms go in with a negative sign because milp minimizes; the switching cost terms go in with a positive sign so that minimizing the total effectively maximizes value while penalizing slews.

Section 4 — Constraints. Constraint (a) forces exactly one target per slot. Constraint (b) is the standard linear-relaxation trick for an AND of two binaries: $y_{t,i,j}\ge x_{t-1,i}+x_{t,j}-1$. Because the cost coefficient on $y$ is strictly positive and the solver minimizes, $y$ is always pushed down to exactly $\max(0, x_{t-1,i}+x_{t,j}-1)$ at the optimum — which is automatically $0$ or $1$ whenever $x$ is binary. That means $y$ never needs to be declared integer, cutting the number of integer variables from 834 down to 144 in this example without losing any correctness.

Section 5 — Bounds & integrality. Only the x block is marked integer (integrality[:n_x] = 1); the y block stays continuous in $[0,1]$, relying on the argument above.

Section 6 — Solve & report. scipy.optimize.milp calls the HiGHS solver under the hood. The chosen target per slot is recovered via argmax over x_sol, and total value / number of slews / total slew cost are all recomputed directly from the schedule array — not from the y variables — so the reported numbers are correct regardless of any floating-point residue in y.

Section 7 — Visualization. A single Figure with four subplots is built in one plt.show() call: a value heatmap with the chosen schedule overlaid, a 3-D value surface with the schedule marked on top, a 3-D slew path traced on the unit sphere connecting the chosen targets in time order, and a Gantt-style bar showing the full day’s pointing plan.

============================================================
SATELLITE SPACE-WEATHER OBSERVATION SCHEDULE
============================================================
Solver status       : Optimization terminated successfully. (HiGHS Status 7: Optimal)
Solve time          : 0.125 s
Total science value : 18.203
Number of slews     : 4
Total slew cost     : 1.863
------------------------------------------------------------
  Hour 00:00  ->  Polar Ionospheric TEC Scan
  Hour 01:00  ->  Polar Ionospheric TEC Scan
  Hour 02:00  ->  Polar Ionospheric TEC Scan
  Hour 03:00  ->  AR3536 Flare Watch
  Hour 04:00  ->  AR3536 Flare Watch
  Hour 05:00  ->  AR3536 Flare Watch
  Hour 06:00  ->  AR3536 Flare Watch
  Hour 07:00  ->  L1 CME Arrival Monitor
  Hour 08:00  ->  L1 CME Arrival Monitor
  Hour 09:00  ->  L1 CME Arrival Monitor
  Hour 10:00  ->  L1 CME Arrival Monitor
  Hour 11:00  ->  L1 CME Arrival Monitor
  Hour 12:00  ->  AR3541 Flare Watch
  Hour 13:00  ->  AR3541 Flare Watch
  Hour 14:00  ->  AR3541 Flare Watch
  Hour 15:00  ->  AR3541 Flare Watch
  Hour 16:00  ->  AR3541 Flare Watch
  Hour 17:00  ->  GEO Radiation Belt Monitor
  Hour 18:00  ->  GEO Radiation Belt Monitor
  Hour 19:00  ->  GEO Radiation Belt Monitor
  Hour 20:00  ->  GEO Radiation Belt Monitor
  Hour 21:00  ->  GEO Radiation Belt Monitor
  Hour 22:00  ->  GEO Radiation Belt Monitor
  Hour 23:00  ->  GEO Radiation Belt Monitor
============================================================

Reading the Schedule

The console output lists, hour by hour, which target the satellite is pointed at, followed by the total accumulated scientific value, the number of slews performed over the day, and the cumulative slew cost. You should see the schedule cluster tightly around each target’s Gaussian peak hour $\tau_i$ — the satellite “camps” on a target through its urgency window rather than darting back and forth, because every switch away and back costs $c_{ij}$ twice. Where two targets’ urgency windows overlap, the one with the higher combined baseline-plus-amplitude value wins the contested slots, and the loser is picked up just before or after its own peak instead.

Visualizing the Optimized Schedule

The top-left heatmap shows brighter bands wherever a target’s Gaussian bump is active, with cyan dots marking exactly which slot the optimizer chose — these dots should sit inside or very near the brightest region of whichever target is active. The top-right 3-D surface is the same value landscape lifted into three dimensions, with red markers riding along the scheduled ridge line. The bottom-left sphere plot is the most physically intuitive view: each colored dot is a target’s boresight direction, and the cyan polyline is the literal path the satellite’s optics trace across the sky over 24 hours — long straight segments mean big, costly slews, while short segments mean the satellite stayed close to its previous pointing. The bottom-right Gantt bar gives the same schedule as a flat timeline, useful for quickly reading off exactly when each target is being watched.

Why This Formulation Scales Well

Two design choices keep this fast even as the constellation grows. First, the switching-indicator variables are relaxed to continuous rather than binary — a mathematically exact simplification given the positive-cost, greater-than-or-equal constraint structure — which removes the vast majority of integer variables from the branch-and-bound search. Second, the constraint matrix is built directly as a sparse csr_matrix from coordinate lists rather than as a dense array, so memory and construction time scale with the number of nonzero entries rather than $T^2N^2$. Together these mean the same code handles a week-long, twenty-target schedule about as comfortably as the one-day, six-target example shown here, without needing a separate “slow” and “fast” version.

Extensions

The same skeleton generalizes naturally: add a second and third satellite by indexing $x$ over satellites as well as time and target, add a downlink data-volume budget as an extra linear constraint, or replace the fixed Gaussian urgency bumps with a live Kp-index or flare-probability forecast feed. The optimization structure — one-target-per-slot assignment plus a linearized switching penalty — stays exactly the same.

Optimal Placement of Geomagnetic Observatories

A Gaussian-Process Greedy Design Approach in Python

Geomagnetic observatories continuously record the strength and direction of the Earth’s magnetic field. This data feeds into space-weather forecasting, aircraft and ship navigation systems, resource exploration, and the construction of global geomagnetic reference models (IGRF, WMM). Because building and operating an observatory is expensive, a natural question arises: given a limited budget for a few new stations, where should they be placed to maximize the accuracy of the geomagnetic field estimate over a region?

This is a classical optimal sensor placement problem. In this article we formulate it as a Gaussian-Process (GP) experimental design problem, implement a fast greedy algorithm in Python, and apply it to a concrete example: choosing 6 new observatory sites in Japan, given the 3 existing JMA (Japan Meteorological Agency) stations.


1. Problem Formulation

1.1 Spatial model of the geomagnetic field

We model the (secular-variation component of the) geomagnetic field as a zero-mean Gaussian Process over the Earth’s surface, with a covariance kernel that decays with great-circle distance:

$$
k(\mathbf{x}_i, \mathbf{x}_j) = \sigma_0^2 \exp\left(-\frac{d(\mathbf{x}_i, \mathbf{x}_j)}{L}\right)
$$

where $d(\mathbf{x}_i,\mathbf{x}_j)$ is the great-circle (haversine) distance between two coordinates, $L$ is the spatial correlation length (we use $L = 800,\text{km}$, typical of secular-variation scales), and $\sigma_0^2$ is the field’s marginal variance.

The haversine distance between $(\phi_1,\lambda_1)$ and $(\phi_2,\lambda_2)$ is:

$$
d = 2R \arcsin\left(\sqrt{\sin^2!\left(\frac{\Delta\phi}{2}\right) + \cos\phi_1\cos\phi_2 \sin^2!\left(\frac{\Delta\lambda}{2}\right)}\right)
$$

1.2 Objective: minimize total estimation uncertainty

Let $V$ be a fine grid of candidate locations over the target region, and let $S \subset V$ be the subset of $k$ locations chosen for new observatories (given a fixed set $F$ of already-existing stations). For any chosen subset, kriging (GP regression) gives the posterior variance at every point in the region:

$$
\mathrm{Var}(y_R \mid y_{F\cup S}) = \mathrm{diag}\Big(K_{RR} - K_{R,F\cup S},K_{F\cup S,F\cup S}^{-1},K_{F\cup S,R}\Big)
$$

Our design objective is to choose $S$ that minimizes the sum of posterior variance over the whole region:

Exhaustively searching all $\binom{|V|}{k}$ subsets is NP-hard for realistic grid sizes. However, this variance-reduction objective is (near-)submodular, so a greedy algorithm — always adding the point that reduces the total variance the most — gives a solution within a $(1-1/e)$ factor of optimal:

$$
F(S_{\text{greedy}}) \ge \left(1-\frac{1}{e}\right) F(S^{*})
$$

1.3 Fast incremental update (avoiding expensive re-inversion)

Naively, evaluating each candidate at each greedy step would require inverting a growing covariance matrix — $O(k \cdot |V| \cdot |F\cup S|^3)$ in total, which quickly becomes very slow. Instead, we use the Schur-complement rank-1 update: once a point $s$ is selected, every remaining covariance entry can be updated in closed form:

This reduces the total cost to $O(k \cdot |V|^2)$ — no matrix inversion needed inside the loop — which is the technique used in the optimized code below.


2. Concrete Example

  • Region: Japan, latitude 24°–46°N, longitude 123°–146°E, gridded into 23×24 = 552 candidate sites.
  • Existing stations (real JMA geomagnetic observatories): Kakioka (36.232°N, 140.186°E), Memambetsu (43.910°N, 144.189°E), Kanoya (31.424°N, 130.880°E).
  • Task: choose $k=6$ new observatory sites from the candidate grid that best reduce the region-wide estimation uncertainty, on top of the 3 existing stations.
  • Validation: a small 10-point toy problem is solved both by brute force ($\binom{10}{3}=120$ combinations) and by the greedy algorithm, to confirm the greedy method finds the true optimum (or very close to it) in this tractable case.
  • Baseline: 200 trials of random station placement, for comparison against the greedy result.

3. Python Source Code (run in Google Colaboratory)

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
# =====================================================================
# Optimal Placement of Geomagnetic Observatories
# Greedy Variance-Reduction (near D-optimal) design on a Gaussian
# Process model of the geomagnetic field
# =====================================================================

import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D # noqa: F401 (enables 3D projection)
import itertools
import time

np.random.seed(42)

# ---------------------------------------------------------------
# 1. Great-circle (haversine) distance: vectorized vs naive
# ---------------------------------------------------------------
EARTH_R = 6371.0 # km

def haversine_matrix(lat1, lon1, lat2, lon2, R=EARTH_R):
lat1 = np.radians(lat1)[:, None]
lon1 = np.radians(lon1)[:, None]
lat2 = np.radians(lat2)[None, :]
lon2 = np.radians(lon2)[None, :]
dlat = lat2 - lat1
dlon = lon2 - lon1
a = np.sin(dlat / 2.0) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon / 2.0) ** 2
a = np.clip(a, 0.0, 1.0)
c = 2.0 * np.arcsin(np.sqrt(a))
return R * c

def haversine_naive(lat1, lon1, lat2, lon2, R=EARTH_R):
n1, n2 = len(lat1), len(lat2)
D = np.zeros((n1, n2))
for i in range(n1):
p1 = np.radians(lat1[i]); l1 = np.radians(lon1[i])
for j in range(n2):
p2 = np.radians(lat2[j]); l2 = np.radians(lon2[j])
dphi = p2 - p1
dlmb = l2 - l1
a = np.sin(dphi / 2.0) ** 2 + np.cos(p1) * np.cos(p2) * np.sin(dlmb / 2.0) ** 2
a = min(max(a, 0.0), 1.0)
D[i, j] = R * 2.0 * np.arcsin(np.sqrt(a))
return D

# ---- Speed comparison ------------------------------------------
NAIVE_N = 200
demo_lat = np.random.uniform(24, 46, NAIVE_N)
demo_lon = np.random.uniform(123, 146, NAIVE_N)

t0 = time.time()
D_naive = haversine_naive(demo_lat, demo_lon, demo_lat, demo_lon)
t1 = time.time()
D_vec = haversine_matrix(demo_lat, demo_lon, demo_lat, demo_lon)
t2 = time.time()

naive_time = t1 - t0
vector_time = t2 - t1
speedup = naive_time / vector_time if vector_time > 0 else float('inf')

print("=== Distance-matrix speed comparison ===")
print(f"Naive double loop : {naive_time:.4f} s (N={NAIVE_N})")
print(f"Vectorized (numpy): {vector_time:.4f} s (N={NAIVE_N})")
print(f"Speed-up : {speedup:.1f}x")
print(f"Max abs difference: {np.max(np.abs(D_naive - D_vec)):.3e} km\n")

# ---------------------------------------------------------------
# 2. GP covariance kernel: k(d) = sigma0^2 * exp(-d / L)
# ---------------------------------------------------------------
SIGMA0 = 1.0
LENGTH_SCALE = 800.0 # km
JITTER = 1e-6

def exp_kernel(D, sigma0=SIGMA0, L=LENGTH_SCALE):
return sigma0 ** 2 * np.exp(-D / L)

# ---------------------------------------------------------------
# 3. Core functions: exact variance (Schur complement) and
# fast greedy selection (rank-1 update)
# ---------------------------------------------------------------
def total_variance_of_subset(Cov0, region_idx, subset_idx):
if len(subset_idx) == 0:
return Cov0[region_idx, region_idx].sum()
Css = Cov0[np.ix_(subset_idx, subset_idx)]
Crs = Cov0[np.ix_(region_idx, subset_idx)]
inv = np.linalg.inv(Css + JITTER * np.eye(len(subset_idx)))
reduction = np.einsum('ij,jk,ik->i', Crs, inv, Crs)
diag = Cov0[region_idx, region_idx] - reduction
return np.clip(diag, 0, None).sum()

def greedy_select(Cov0, region_idx, candidate_idx, fixed_idx, k):
Cov = Cov0.copy()
variance = np.diag(Cov).copy()

# condition on already-existing (fixed) stations first
for idx in fixed_idx:
c = Cov[:, idx].copy()
v = c[idx]
if v > 1e-12:
variance -= (c ** 2) / v
Cov -= np.outer(c, c) / v
variance = np.clip(variance, 0, None)

remaining = list(candidate_idx)
selected = []
history = [variance[region_idx].sum()]
for _ in range(k):
var_rem = variance[remaining]
var_safe = np.where(var_rem > 1e-12, var_rem, np.inf)
reduction = (Cov[np.ix_(region_idx, remaining)] ** 2).sum(axis=0) / var_safe
pos = int(np.argmax(reduction))
best = remaining[pos]
c = Cov[:, best].copy()
v = c[best]
variance -= (c ** 2) / v
Cov -= np.outer(c, c) / v
variance = np.clip(variance, 0, None)
selected.append(best)
remaining.pop(pos)
history.append(variance[region_idx].sum())
return selected, history, variance

# ---------------------------------------------------------------
# 4. Toy validation: brute force vs greedy (small N)
# ---------------------------------------------------------------
toy_lat = np.array([30, 32, 34, 36, 38, 40, 42, 44, 33, 41], dtype=float)
toy_lon = np.array([130, 133, 136, 139, 142, 145, 131, 137, 128, 144], dtype=float)
toy_D = haversine_matrix(toy_lat, toy_lon, toy_lat, toy_lon)
toy_Cov0 = exp_kernel(toy_D) + JITTER * np.eye(len(toy_lat))

toy_region = np.arange(len(toy_lat))
toy_candidates = np.arange(len(toy_lat))
toy_k = 3

t0 = time.time()
best_brute, best_val = None, np.inf
for combo in itertools.combinations(toy_candidates, toy_k):
v = total_variance_of_subset(toy_Cov0, toy_region, list(combo))
if v < best_val:
best_val, best_brute = v, combo
t_brute = time.time() - t0

t0 = time.time()
greedy_sel, greedy_hist, _ = greedy_select(toy_Cov0, toy_region, toy_candidates, [], toy_k)
t_greedy = time.time() - t0

print("=== Toy example: brute force vs greedy ===")
print(f"Brute force optimal subset : {sorted(best_brute)} total variance = {best_val:.4f} ({t_brute*1000:.2f} ms)")
print(f"Greedy subset : {sorted(greedy_sel)} total variance = {greedy_hist[-1]:.4f} ({t_greedy*1000:.2f} ms)")
print(f"Greedy matches brute force? : {sorted(greedy_sel) == sorted(best_brute)}\n")

# ---------------------------------------------------------------
# 5. Main problem: optimal placement of new observatories in Japan
# ---------------------------------------------------------------
lat_grid = np.linspace(24, 46, 23)
lon_grid = np.linspace(123, 146, 24)
LON, LAT = np.meshgrid(lon_grid, lat_grid)
cand_lat = LAT.ravel()
cand_lon = LON.ravel()
n_cand = len(cand_lat)

existing_lat = np.array([36.232, 43.910, 31.424])
existing_lon = np.array([140.186, 144.189, 130.880])
existing_name = ["Kakioka", "Memambetsu", "Kanoya"]

all_lat = np.concatenate([existing_lat, cand_lat])
all_lon = np.concatenate([existing_lon, cand_lon])
n_total = len(all_lat)

fixed_idx = list(range(3))
region_idx = np.arange(3, n_total)
candidate_idx = np.arange(3, n_total)

t0 = time.time()
D_full = haversine_matrix(all_lat, all_lon, all_lat, all_lon)
Cov0 = exp_kernel(D_full) + JITTER * np.eye(n_total)
t_kernel = time.time() - t0

K_NEW = 6
t0 = time.time()
selected, history, final_var = greedy_select(Cov0, region_idx, candidate_idx, fixed_idx, K_NEW)
t_greedy_main = time.time() - t0

sel_lat = all_lat[selected]
sel_lon = all_lon[selected]

print("=== Main problem: optimal placement of new observatories over Japan ===")
print(f"Candidate grid size : {n_cand} points")
print(f"Kernel matrix build time : {t_kernel:.4f} s")
print(f"Greedy optimization time : {t_greedy_main:.4f} s (K={K_NEW})")
print(f"Region variance (no stations) : {Cov0[np.ix_(region_idx, region_idx)].diagonal().sum():.3f}")
print(f"Region variance (existing 3) : {history[0]:.3f}")
print(f"Region variance (existing+{K_NEW}) : {history[-1]:.3f}")
reduction_pct = 100.0 * (history[0] - history[-1]) / history[0]
print(f"Additional variance reduction : {reduction_pct:.1f}%")
for i, (la, lo) in enumerate(zip(sel_lat, sel_lon), start=1):
print(f" New station #{i}: lat={la:.2f}, lon={lo:.2f}")
print()

# ---------------------------------------------------------------
# 6. Random baseline for comparison
# ---------------------------------------------------------------
N_TRIALS = 200
rng = np.random.default_rng(0)
base_var = total_variance_of_subset(Cov0, region_idx, fixed_idx)
random_hist = np.zeros((N_TRIALS, K_NEW + 1))
random_hist[:, 0] = base_var
for t in range(N_TRIALS):
perm = rng.choice(candidate_idx, size=K_NEW, replace=False)
for m in range(1, K_NEW + 1):
subset = fixed_idx + list(perm[:m])
random_hist[t, m] = total_variance_of_subset(Cov0, region_idx, subset)

random_mean = random_hist.mean(axis=0)
random_std = random_hist.std(axis=0)

print("=== Greedy vs. random placement (region total variance) ===")
print(f"{'#stations':>10} | {'greedy':>10} | {'random mean':>12} | {'random std':>10}")
for m in range(K_NEW + 1):
print(f"{m:>10} | {history[m]:>10.3f} | {random_mean[m]:>12.3f} | {random_std[m]:>10.3f}")
print()

# ---------------------------------------------------------------
# 7. Visualization (single combined figure)
# ---------------------------------------------------------------
final_var_region = final_var[region_idx]

fig = plt.figure(figsize=(16, 13))

# --- 7-1. 2D map: residual variance + station placement --------
ax1 = fig.add_subplot(2, 2, 1)
sc = ax1.scatter(cand_lon, cand_lat, c=final_var_region, cmap='viridis_r',
s=40, marker='s', alpha=0.85)
ax1.scatter(existing_lon, existing_lat, c='blue', marker='^', s=180,
edgecolor='white', linewidth=1.5, label='Existing observatory', zorder=5)
ax1.scatter(sel_lon, sel_lat, c='red', marker='*', s=260, edgecolor='black',
linewidth=1.0, label='New observatory (greedy)', zorder=6)
for i, (la, lo) in enumerate(zip(sel_lat, sel_lon), start=1):
ax1.annotate(str(i), (lo, la), textcoords="offset points", xytext=(6, 6),
fontsize=10, fontweight='bold')
for name, la, lo in zip(existing_name, existing_lat, existing_lon):
ax1.annotate(name, (lo, la), textcoords="offset points", xytext=(6, -12),
fontsize=8, color='blue')
ax1.set_xlabel("Longitude [deg]")
ax1.set_ylabel("Latitude [deg]")
ax1.set_title("Residual posterior variance after placement")
plt.colorbar(sc, ax=ax1, label="Posterior variance")
ax1.legend(loc='upper right', fontsize=8)

# --- 7-2. Convergence curve: greedy vs random -------------------
ax2 = fig.add_subplot(2, 2, 2)
xs = np.arange(K_NEW + 1)
ax2.plot(xs, history, 'o-', color='crimson', linewidth=2, label='Greedy (this method)')
ax2.plot(xs, random_mean, 's--', color='gray', linewidth=2, label='Random placement (mean)')
ax2.fill_between(xs, random_mean - random_std, random_mean + random_std,
color='gray', alpha=0.25, label='Random ±1 std')
ax2.set_xlabel("Number of newly added observatories")
ax2.set_ylabel("Total posterior variance over region")
ax2.set_title("Convergence: greedy vs. random placement")
ax2.legend(fontsize=8)
ax2.grid(alpha=0.3)

# --- 7-3. 3D surface: residual uncertainty landscape ------------
ax3 = fig.add_subplot(2, 2, 3, projection='3d')
Z = final_var_region.reshape(LAT.shape)
surf = ax3.plot_surface(LON, LAT, Z, cmap='viridis_r', linewidth=0, antialiased=True, alpha=0.9)
ax3.scatter(sel_lon, sel_lat, np.zeros_like(sel_lon), c='red', marker='*', s=140, zorder=10)
ax3.scatter(existing_lon, existing_lat, np.zeros_like(existing_lon), c='blue', marker='^', s=100, zorder=10)
ax3.set_xlabel("Longitude")
ax3.set_ylabel("Latitude")
ax3.set_zlabel("Posterior variance")
ax3.set_title("3D landscape of residual uncertainty")
fig.colorbar(surf, ax=ax3, shrink=0.6, label="Variance")

# --- 7-4. 3D globe: station placement in geocentric coordinates -
ax4 = fig.add_subplot(2, 2, 4, projection='3d')

def to_xyz(lat_deg, lon_deg, r=1.0):
lat = np.radians(lat_deg)
lon = np.radians(lon_deg)
x = r * np.cos(lat) * np.cos(lon)
y = r * np.cos(lat) * np.sin(lon)
z = r * np.sin(lat)
return x, y, z

gx, gy, gz = to_xyz(cand_lat, cand_lon)
ex, ey, ez = to_xyz(existing_lat, existing_lon)
nx, ny, nz = to_xyz(sel_lat, sel_lon)

ax4.scatter(gx, gy, gz, c=final_var_region, cmap='viridis_r', s=12, alpha=0.6)
ax4.scatter(ex, ey, ez, c='blue', marker='^', s=140, edgecolor='white', label='Existing')
ax4.scatter(nx, ny, nz, c='red', marker='*', s=220, edgecolor='black', label='New (greedy)')
ax4.set_title("Observatory placement on the globe (local view)")
ax4.set_xlabel("X"); ax4.set_ylabel("Y"); ax4.set_zlabel("Z")
ax4.legend(fontsize=8)
ax4.view_init(elev=25, azim=140)

plt.tight_layout()
plt.show()

4. Code Walkthrough

Section 1 — Haversine distance, naive vs vectorized.
haversine_naive computes great-circle distances with a Python double for loop — easy to read but slow because every trigonometric call runs in pure Python. haversine_matrix computes the exact same thing using NumPy broadcasting: all pairwise angle differences are computed at once as array operations, letting NumPy’s compiled C backend do the work. The benchmark prints the wall-clock time of both and the resulting speed-up factor, plus confirms the two implementations agree numerically.

Section 2 — The covariance kernel.
exp_kernel implements $k(d)=\sigma_0^2 e^{-d/L}$. A tiny JITTER term is added to the diagonal wherever a covariance matrix is built or inverted, to keep matrices numerically well-conditioned (a standard trick in GP regression).

Section 3 — Core algorithms.

  • total_variance_of_subset computes the exact posterior variance of the region given any subset of stations, using the Schur-complement formula. It’s used for validation and for evaluating random baselines, where subsets are always small (≤ 9 points), so direct matrix inversion is cheap.
  • greedy_select is the fast optimizer. It starts from the full prior covariance matrix, first “observes” the fixed (already-existing) stations using the rank-1 update, then iteratively picks the remaining candidate that removes the most total variance from the target region, applying the same rank-1 update after each pick. This avoids ever inverting a large matrix — the whole search over hundreds of candidates and multiple rounds runs in a fraction of a second.

Section 4 — Toy validation.
A 10-point synthetic example is solved two ways: brute-force search over all $\binom{10}{3}=120$ subsets, and the greedy algorithm. The printed comparison confirms whether greedy reproduces the true optimum — this is the sanity check that justifies trusting greedy on the full-scale problem, where brute force would be computationally infeasible ($\binom{552}{6} \approx 10^{14}$ combinations).

Section 5 — Main problem.
Builds a 552-point candidate grid over Japan, appends the 3 real JMA station coordinates, computes the full $555\times555$ covariance matrix, and runs greedy_select to pick 6 new stations. It prints the region’s total variance with no stations, with only the existing 3, and after adding the 6 new ones, plus the coordinates of each newly chosen site in the order they were selected.

Section 6 — Random baseline.
For 200 trials, a random ordering of 6 candidate points is drawn, and the exact posterior variance is computed after adding 1, 2, …, 6 of them (using total_variance_of_subset, which is fast because the subsets are tiny). The mean and standard deviation across trials, at each step, quantify how much better the greedy strategy is than chance.

Section 7 — Visualization.
Builds one combined figure with four panels (described in the next section).


5. Execution Result

Candidate sites: 684, model coefficients: 15

=== Optimized layout ===
log det(F)      : 19.151
cond(A^T A)     : 5.515e+00
RMSE vs truth   : 28.56 nT

=== Random layout ===
cond(A^T A)     : 2.671e+02
RMSE vs truth   : 57.31 nT

6. How to Read the Graphs

The figure combines four complementary views of the same optimization result:

Top-left — 2D residual variance map. Each square on the Japan grid is colored by the posterior variance remaining after all 9 stations (3 existing + 6 new) are in place. Darker regions indicate the field is well-constrained; brighter regions are still relatively uncertain. Blue triangles mark the existing JMA stations, red stars mark the newly chosen sites, numbered in the order the greedy algorithm selected them. You should see the new stations land in the geographic gaps left uncovered by the existing three — for example, far from Kakioka/Memambetsu/Kanoya, since those areas start with the highest prior uncertainty.

Top-right — Convergence curve. This shows how the total region-wide variance drops as stations are added one at a time, comparing the greedy strategy (red) against the average of 200 random placements (gray, with a shaded ±1 standard deviation band). The greedy curve should sit consistently below the random curve, showing that intelligently chosen sites reduce uncertainty faster than chance — and often the biggest gains come from the first 1–2 additions, illustrating diminishing (submodular) returns.

Bottom-left — 3D uncertainty landscape. The same residual variance as the top-left panel, but rendered as a 3D surface where height represents uncertainty. Peaks indicate areas still poorly constrained even after adding the 6 new stations; valleys (near red stars and blue triangles) show where the GP model is confident. This view makes it easy to spot whether any peak remains unusually tall, which would suggest a 7th station is still needed there.

Bottom-right — 3D globe view. The same candidate grid and station locations projected onto a unit sphere using geocentric coordinates, giving an intuitive “from space” sense of the spatial layout relative to Earth’s curvature, useful for sanity-checking that station spacing makes physical sense across the region.


7. Discussion and Practical Notes

This example simplifies several real-world constraints for clarity: it treats every grid cell (including ocean) as a valid site, ignores construction cost, accessibility, and the strict magnetic-cleanliness requirements real observatories need (no nearby power lines, railways, or ferromagnetic structures), and uses a single isotropic correlation length for the whole country. A production-grade version would restrict candidates to land points with suitable infrastructure, incorporate anisotropic or regionally-varying correlation structure fitted from historical geomagnetic survey data, and possibly weight the objective by the practical importance of different sub-regions (e.g., near population centers or aviation corridors) rather than treating the whole area uniformly.

Nonetheless, the core idea — modeling the field as a Gaussian Process and using a submodular greedy algorithm with rank-1 covariance updates — scales well and is the same approach used in real sensor-network design problems, from environmental monitoring to seismic and magnetic survey network planning.

Optimizing Geomagnetic Storm Intensity Prediction with Python

Geomagnetic storms triggered by solar wind disturbances can disrupt satellite operations, GPS accuracy, and power grids. The most widely used metric for storm intensity is the Dst index (Disturbance Storm Time index), which quantifies the depression of Earth’s horizontal magnetic field caused by the ring current. In this article, we build a physics-based model that predicts Dst from solar wind parameters, then use numerical optimization to fit the model’s free parameters to observed data — a compact but realistic example of parameter estimation applied to space weather forecasting.

The Physical Model

A classical approach to Dst modeling is the Burton–McPherron–Russell (BMR) model, which treats the ring current as a reservoir that is charged by solar wind energy injection and decays over time:

Here, $Q(t)$ is the injection function driven by the solar wind, and $\tau$ is the ring current decay time constant. The injection term depends on the solar wind speed $V(t)$ and the southward component of the interplanetary magnetic field $B_s(t)$:

$$
Q(t) = a , V(t) , B_s(t) + b, \qquad B_s(t) = \begin{cases} -B_z(t) & B_z(t) < 0 \ 0 & B_z(t) \geq 0 \end{cases}
$$

The three unknowns $(a, b, \tau)$ control how strongly the solar wind couples into the ring current, a baseline injection offset, and the decay timescale. Our goal is to recover these parameters from noisy Dst observations by minimizing the root-mean-square error (RMSE):

$$
\text{RMSE}(a, b, \tau) = \sqrt{\frac{1}{N}\sum_{i=1}^{N}\left(Dst_{\text{pred}}(t_i) - Dst_{\text{obs}}(t_i)\right)^2}
$$

This is a nonlinear, non-convex optimization problem, since the parameters interact through a differential equation rather than a simple linear formula. We solve it using differential evolution, a global optimization algorithm well suited to this kind of rugged cost landscape.

Full Python 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
# ============================================================
# Geomagnetic Storm Intensity (Dst Index) Prediction Optimization
# Burton-McPherron-Russell Model + Global Parameter Optimization
# ============================================================

import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
from scipy import signal
from scipy.optimize import differential_evolution
import time

np.random.seed(42)

# ------------------------------------------------------------
# 1. Synthetic solar wind data generation
# ------------------------------------------------------------
N = 500 # number of hourly samples (~20.8 days)
dt = 1.0 # time step [hour]
t = np.arange(N) * dt

V = 400 + 50 * np.sin(2 * np.pi * t / 200) + np.random.normal(0, 10, N)
Bz = 2 * np.sin(2 * np.pi * t / 60) + np.random.normal(0, 1.0, N)

storm_centers = [120, 260, 400]
for c in storm_centers:
width = 15
idx = np.arange(max(0, c - width), min(N, c + width))
Bz[idx] -= 18 * np.exp(-0.5 * ((idx - c) / (width / 2.5)) ** 2)
V[idx] += 250 * np.exp(-0.5 * ((idx - c) / (width / 2.5)) ** 2)

Bs = np.where(Bz < 0, -Bz, 0.0)

# ------------------------------------------------------------
# 2. Burton-McPherron-Russell type Dst model
# dDst*/dt = Q(t) - Dst*(t)/tau , Q(t) = a*V(t)*Bs(t) + b
# ------------------------------------------------------------
def coupling_function(a, b, V, Bs):
return a * V * Bs + b

def simulate_dst_loop(a, b, tau, V, Bs, dt, dst0=0.0):
"""Naive Euler integration (reference / educational version)."""
n = len(V)
dst = np.empty(n)
dst[0] = dst0
Q = coupling_function(a, b, V, Bs)
for i in range(n - 1):
dst[i + 1] = dst[i] + dt * (Q[i] - dst[i] / tau)
return dst

def simulate_dst_fast(a, b, tau, V, Bs, dt, dst0=0.0):
"""Vectorized IIR-filter version (fast, used inside the optimizer)."""
Q = coupling_function(a, b, V, Bs)
alpha = 1.0 - dt / tau
b_coef = [dt]
a_coef = [1.0, -alpha]
zi = signal.lfiltic(b_coef, a_coef, [dst0])
y, _ = signal.lfilter(b_coef, a_coef, Q, zi=zi)
dst = np.empty(len(V))
dst[0] = dst0
dst[1:] = y[:-1]
return dst

# ------------------------------------------------------------
# 3. Generate "observed" Dst using true (ground-truth) parameters
# ------------------------------------------------------------
true_a, true_b, true_tau = 3.0e-4, -2.0, 12.0
dst_true = simulate_dst_fast(true_a, true_b, true_tau, V, Bs, dt, dst0=-5.0)
dst_obs = dst_true + np.random.normal(0, 3.0, N)

# ------------------------------------------------------------
# 4. Speed comparison: naive loop vs vectorized filter
# ------------------------------------------------------------
n_repeat = 200

t0 = time.time()
for _ in range(n_repeat):
simulate_dst_loop(true_a, true_b, true_tau, V, Bs, dt, dst0=-5.0)
t_loop = time.time() - t0

t0 = time.time()
for _ in range(n_repeat):
simulate_dst_fast(true_a, true_b, true_tau, V, Bs, dt, dst0=-5.0)
t_fast = time.time() - t0

print(f"Naive loop version : {t_loop:.4f} sec ({n_repeat} runs)")
print(f"Vectorized version : {t_fast:.4f} sec ({n_repeat} runs)")
print(f"Speed-up factor : {t_loop / t_fast:.1f}x")

# ------------------------------------------------------------
# 5. Cost function (RMSE) for parameter optimization
# ------------------------------------------------------------
def cost_function(params, V, Bs, dt, dst_obs, dst0):
a, b, tau = params
if tau <= 0.1:
return 1e6
dst_pred = simulate_dst_fast(a, b, tau, V, Bs, dt, dst0)
return np.sqrt(np.mean((dst_pred - dst_obs) ** 2))

# ------------------------------------------------------------
# 6. Global optimization with Differential Evolution
# ------------------------------------------------------------
bounds = [(1e-5, 1e-2), (-10, 10), (2, 30)] # (a, b, tau[hour])

t0 = time.time()
result = differential_evolution(
cost_function, bounds,
args=(V, Bs, dt, dst_obs, -5.0),
seed=42, maxiter=100, popsize=15, tol=1e-6, polish=True
)
t_opt = time.time() - t0

opt_a, opt_b, opt_tau = result.x
print("\n=== Optimization Result ===")
print(f"True params : a={true_a:.6e}, b={true_b:.3f}, tau={true_tau:.3f} h")
print(f"Optimized params : a={opt_a:.6e}, b={opt_b:.3f}, tau={opt_tau:.3f} h")
print(f"Final RMSE : {result.fun:.4f} nT")
print(f"Optimization time: {t_opt:.2f} sec")

dst_pred_opt = simulate_dst_fast(opt_a, opt_b, opt_tau, V, Bs, dt, dst0=-5.0)

# ------------------------------------------------------------
# 7. Cost landscape for 3D visualization (a vs tau, b fixed at optimum)
# ------------------------------------------------------------
a_range = np.linspace(bounds[0][0], bounds[0][1], 40)
tau_range = np.linspace(bounds[2][0], bounds[2][1], 40)
A_grid, TAU_grid = np.meshgrid(a_range, tau_range)
COST_grid = np.zeros_like(A_grid)

for i in range(A_grid.shape[0]):
for j in range(A_grid.shape[1]):
COST_grid[i, j] = cost_function(
[A_grid[i, j], opt_b, TAU_grid[i, j]], V, Bs, dt, dst_obs, -5.0
)

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

ax1 = fig.add_subplot(2, 2, 1)
ax1.plot(t, V, color='tab:blue', label='Solar Wind Speed V [km/s]')
ax1.set_ylabel('V [km/s]', color='tab:blue')
ax1.tick_params(axis='y', labelcolor='tab:blue')
ax1b = ax1.twinx()
ax1b.plot(t, Bz, color='tab:red', label='IMF Bz [nT]')
ax1b.set_ylabel('Bz [nT]', color='tab:red')
ax1b.tick_params(axis='y', labelcolor='tab:red')
ax1.set_xlabel('Time [hour]')
ax1.set_title('Input: Solar Wind Speed and IMF Bz')

ax2 = fig.add_subplot(2, 2, 2)
ax2.plot(t, dst_obs, 'o', ms=2, color='gray', alpha=0.5, label='Observed Dst (noisy)')
ax2.plot(t, dst_true, '--', color='black', lw=1, label='True Dst')
ax2.plot(t, dst_pred_opt, '-', color='crimson', lw=1.5, label='Optimized Model Dst')
ax2.set_xlabel('Time [hour]')
ax2.set_ylabel('Dst [nT]')
ax2.set_title('Observed vs Optimized Model Prediction')
ax2.legend(fontsize=8)
ax2.invert_yaxis()

ax3 = fig.add_subplot(2, 2, 3, projection='3d')
surf = ax3.plot_surface(A_grid, TAU_grid, COST_grid, cmap='viridis',
linewidth=0, antialiased=True, alpha=0.9)
ax3.scatter([opt_a], [opt_tau], [result.fun], color='red', s=60, depthshade=False)
ax3.set_xlabel('a (coupling coeff.)')
ax3.set_ylabel('tau [hour]')
ax3.set_zlabel('RMSE [nT]')
ax3.set_title('3D Cost Landscape (a vs tau)')
fig.colorbar(surf, ax=ax3, shrink=0.6, aspect=12, label='RMSE [nT]')

ax4 = fig.add_subplot(2, 2, 4, projection='3d')
ax4.plot(t, V, dst_true, color='black', lw=1, label='True')
ax4.plot(t, V, dst_pred_opt, color='crimson', lw=1.5, label='Optimized model')
ax4.set_xlabel('Time [hour]')
ax4.set_ylabel('V [km/s]')
ax4.set_zlabel('Dst [nT]')
ax4.set_title('3D Trajectory: Time - Speed - Dst')
ax4.legend(fontsize=8)

plt.tight_layout()
plt.show()

Code Walkthrough

Section 1 — Synthetic solar wind data. Since real-time solar wind feeds require external APIs that may fail inside a notebook, we generate a self-contained synthetic dataset: a slowly oscillating baseline for speed $V$ and IMF $B_z$, with three Gaussian-shaped storm events injected at fixed time indices. This guarantees the script always runs identically and reproducibly.

Section 2 — Two integrator implementations. simulate_dst_loop is the textbook Euler-integration version: easy to read, but it runs a Python-level for loop over every time step. simulate_dst_fast reformulates the same recursion as a first-order IIR digital filter, $Dst[n] = \alpha , Dst[n-1] + \Delta t , Q[n-1]$, and executes it with scipy.signal.lfilter, which runs in compiled C code. Because the optimizer below calls this function tens of thousands of times, this rewrite is essential for practical runtime.

Section 3 — Ground truth generation. We simulate a “true” Dst curve with known parameters, then add Gaussian measurement noise to emulate a realistic magnetometer-derived index. This lets us later verify that the optimizer recovers parameters close to the originals.

Section 4 — Speed benchmark. We time 200 repeated calls to both integrators. This section will print the loop time, the vectorized time, and the resulting speed-up factor.

Section 5–6 — Optimization. The RMSE cost function compares the simulated Dst curve to the noisy observations. differential_evolution performs a global search over the bounded 3D parameter space $(a, b, \tau)$, which avoids getting trapped in local minima that gradient-based methods could fall into given the recursive, nonlinear nature of the model.

Section 7 — Cost landscape. To visualize why the optimizer converges where it does, we sweep a 40×40 grid over $a$ and $\tau$ (holding $b$ fixed at its optimized value) and evaluate RMSE at every grid point, producing a full 3D error surface.

Section 8 — Combined visualization. All four panels are rendered in a single plt.show() call, so the entire analysis is captured in one output image.

Understanding the Graphs

  • Top-left: the raw solar wind inputs — speed $V$ (blue) and IMF $B_z$ (red) — with the three synthetic storm dips clearly visible as sharp negative excursions in $B_z$ paired with speed enhancements.
  • Top-right: the core validation plot. Gray dots are noisy “observed” Dst, the dashed black line is the noise-free ground truth, and the solid crimson line is the model driven by the optimized parameters. A close match between the crimson and black curves confirms the optimizer recovered the correct dynamics. The y-axis is inverted since storm intensity is conventionally shown with negative Dst pointing downward.
  • Bottom-left: the 3D RMSE surface over $(a, \tau)$. The bowl-shaped minimum shows how sensitive the fit is to each parameter — a narrow valley means that parameter is tightly constrained by the data, while a flat direction means it’s harder to pin down. The red marker shows where the optimizer landed.
  • Bottom-right: a 3D trajectory linking time, solar wind speed, and Dst simultaneously, making it visually clear how each storm’s speed enhancement corresponds to a deepening of Dst.


Naive loop version : 0.3404 sec (200 runs)
Vectorized version  : 0.0336 sec (200 runs)
Speed-up factor     : 10.1x

=== Optimization Result ===
True      params : a=3.000000e-04, b=-2.000, tau=12.000 h
Optimized params : a=3.019317e-04, b=-1.930, tau=12.368 h
Final RMSE       : 3.0259 nT
Optimization time: 0.93 sec

Takeaways

This example shows how a physics-based recursive model can be combined with global optimization to reconstruct unknown ring-current coupling parameters from noisy geomagnetic index data. The key engineering lesson is that the same recursive equation can be expressed either as a slow Python loop or as a compiled digital filter — and when that equation sits inside an optimization loop called thousands of times, the vectorized formulation is what makes the whole pipeline computationally feasible.