Solver convergence in a targeting sweep¶
When a sweep's mission sequence carries a Target or Optimize block, every run drives a solver — and gmat-run records that solver's full iteration history. lazy_solver_runs aggregates those per-run histories into one (run_id, solver, iteration)-indexed DataFrame, and lazy_solver_convergence folds the manifest's per-run convergence flags into a compact (run_id, solver) boolean view.
This notebook sweeps a LEO -> GEO Hohmann transfer that a DifferentialCorrector targets, then answers two questions across the sweep: how did the targeter converge? and did every run actually reach its goal?
Prerequisites. A local GMAT install (R2026a is the primary development target; see Supported versions) and the [examples] extra for the matplotlib plot (pip install gmat-sweep[examples]).
Set up the run¶
The fixture hohmann_transfer.script ships next to this notebook. It is a LEO -> GEO Hohmann transfer: a DifferentialCorrector named DC varies the two transfer burns (TOI, GOI) to Achieve GEO radius and a circular final orbit. Every run of the sweep therefore produces one solver iteration history.
import tempfile
from pathlib import Path
import matplotlib.pyplot as plt
from gmat_run import locate_gmat
from gmat_sweep import Manifest, lazy_solver_convergence, lazy_solver_runs, sweep
install = locate_gmat()
script_path = Path("hohmann_transfer.script").resolve()
print(f"GMAT version: {install.version}")
print(f"Script: {script_path.name}")
print(f"Exists: {script_path.exists()}")
GMAT version: R2026a Script: hohmann_transfer.script Exists: True
Sweep the parking-orbit altitude¶
Six starting semi-major axes, from a ~200 km parking orbit to ~700 km. Each run runs the same Target block from a different starting orbit, so the targeter does a different amount of work per run. sweep() returns the ReportFile frame; the solver histories are aggregated separately below.
tmpdir = tempfile.TemporaryDirectory(prefix="solver-sweep-")
out = Path(tmpdir.name) / "sweep"
df = sweep(
script_path,
grid={"Sat.SMA": [6578.0, 6678.0, 6778.0, 6878.0, 6978.0, 7078.0]},
out=out,
progress=False,
)
print(f"Runs: {df.index.get_level_values('run_id').nunique()}")
df["__status"].value_counts()
Runs: 6
__status ok 591 Name: count, dtype: int64
Aggregate the solver iteration history¶
lazy_solver_runs takes the path to the sweep's manifest.jsonl and stitches every run's per-solver iteration frame into one DataFrame. The index is a three-level (run_id, solver, iteration) MultiIndex; the columns are the Vary variables (TOI.Element1, GOI.Element1), the goal quartets for each Achieve target, and the per-iteration status.
runs = lazy_solver_runs(out / "manifest.jsonl")
print(f"Index: {runs.index.names}")
print(f"Columns: {list(runs.columns)}")
size = runs.groupby(level=["run_id", "solver"]).size()
print(f"Iterations per run: {size.to_dict()}")
runs.head(10)
Index: ['run_id', 'solver', 'iteration']
Columns: ['TOI.Element1', 'GOI.Element1', 'Sat.Earth.RMAG', 'Sat.Earth.RMAG_desired', 'Sat.Earth.RMAG_residual', 'Sat.Earth.RMAG_tolerance', 'Sat.ECC', 'Sat.ECC_desired', 'Sat.ECC_residual', 'Sat.ECC_tolerance', 'status']
Iterations per run: {(0, 'DC'): 8, (1, 'DC'): 8, (2, 'DC'): 8, (3, 'DC'): 8, (4, 'DC'): 8, (5, 'DC'): 8}
| TOI.Element1 | GOI.Element1 | Sat.Earth.RMAG | Sat.Earth.RMAG_desired | Sat.Earth.RMAG_residual | Sat.Earth.RMAG_tolerance | Sat.ECC | Sat.ECC_desired | Sat.ECC_residual | Sat.ECC_tolerance | status | |||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| run_id | solver | iteration | |||||||||||
| 0 | DC | 1 | 0.500000 | 0.500000 | 8597.794011 | 42165.0 | -33567.205989 | 0.1 | 8.514049e-03 | 0.0 | 8.514049e-03 | 0.001 | running |
| 2 | 1.000000 | 0.928346 | 11541.950684 | 42165.0 | -30623.049316 | 0.1 | 1.966922e-02 | 0.0 | 1.966922e-02 | 0.001 | running | ||
| 3 | 1.500000 | 1.276061 | 16225.547021 | 42165.0 | -25939.452979 | 0.1 | 3.376576e-02 | 0.0 | 3.376576e-02 | 0.001 | running | ||
| 4 | 2.000000 | 1.518635 | 24780.534071 | 42165.0 | -17384.465929 | 0.1 | 5.290321e-02 | 0.0 | 5.290321e-02 | 0.001 | running | ||
| 5 | 2.500000 | 1.576857 | 45244.202632 | 42165.0 | 3079.202632 | 0.1 | 7.099850e-02 | 0.0 | 7.099850e-02 | 0.001 | running | ||
| 6 | 2.455589 | 1.484288 | 42344.020389 | 42165.0 | 179.020389 | 0.1 | 4.255185e-03 | 0.0 | 4.255185e-03 | 0.001 | running | ||
| 7 | 2.452679 | 1.478001 | 42165.701233 | 42165.0 | 0.701233 | 0.1 | 1.692980e-05 | 0.0 | 1.692980e-05 | 0.001 | running | ||
| 8 | 2.452668 | 1.477976 | 42165.000102 | 42165.0 | 0.000102 | 0.1 | 3.776103e-08 | 0.0 | 3.776103e-08 | 0.001 | converged | ||
| 1 | DC | 1 | 0.500000 | 0.500000 | 8746.925281 | 42165.0 | -33418.074719 | 0.1 | 8.670240e-03 | 0.0 | 8.670240e-03 | 0.001 | running |
| 2 | 1.000000 | 0.927685 | 11773.240815 | 42165.0 | -30391.759185 | 0.1 | 2.001490e-02 | 0.0 | 2.001490e-02 | 0.001 | running |
Plot the residual trajectories¶
The Sat.Earth.RMAG_residual column is the apoapsis-radius miss at each iteration — achieved - desired. Plotting it per run shows the targeter funnelling the miss down to tolerance. The terminal status row carries converged once |residual| is inside the Achieve tolerance.
fig, ax = plt.subplots(figsize=(8, 5))
for run_id, sub in runs.groupby(level="run_id"):
iters = sub.index.get_level_values("iteration")
ax.semilogy(iters, sub["Sat.Earth.RMAG_residual"].abs(), marker="o", label=f"run {run_id}")
ax.set_xlabel("DC iteration")
ax.set_ylabel("|RMAG residual| (km)")
ax.set_title("Targeter convergence — apoapsis-radius residual per run")
ax.legend(title="run_id")
ax.grid(True, which="both", alpha=0.3)
fig.tight_layout()
Which runs converged?¶
lazy_solver_convergence is the compact companion — a (run_id, solver)-indexed boolean view. It is folded straight from the manifest's per-run converged maps, so it reads no Parquet at all: convergence is queryable without touching a single solver file.
lazy_solver_convergence(out / "manifest.jsonl")
| converged | ||
|---|---|---|
| run_id | solver | |
| 0 | DC | True |
| 1 | DC | True |
| 2 | DC | True |
| 3 | DC | True |
| 4 | DC | True |
| 5 | DC | True |
Convergence is orthogonal to run status¶
A run can finish cleanly and leave its targeter unconverged. To show it, cap the DifferentialCorrector at three iterations — far too few for this transfer — so it exhausts MaximumIterations.
The run still completes: GMAT raised nothing, so the manifest entry is status="ok". But the solver never reached its goal, so its converged flag is False and the per-iteration status column terminates in "max_iter" rather than "converged". A non-converged run is not a failed run — resume will not re-run it.
capped_dir = tempfile.TemporaryDirectory(prefix="solver-capped-")
capped_out = Path(capped_dir.name) / "sweep"
sweep(
script_path,
grid={"DC.MaximumIterations": [3]},
out=capped_out,
progress=False,
)
entry = Manifest.load(capped_out / "manifest.jsonl").entries[0]
print(f"run status: {entry.status!r}")
print(f"converged map: {entry.converged}")
capped_runs = lazy_solver_runs(capped_out / "manifest.jsonl")
print(f"terminal status: {capped_runs['status'].iloc[-1]!r}")
lazy_solver_convergence(capped_out / "manifest.jsonl")
run status: 'ok'
converged map: {'DC': False}
terminal status: 'max_iter'
| converged | ||
|---|---|---|
| run_id | solver | |
| 0 | DC | False |
Where to next¶
- The aggregator contract. Aggregating sweep outputs covers the column union across
DifferentialCorrectorandYukonsolvers, the no-marker-rows rule, and the non-uniqueiterationindex a Yukon optimiser produces. - The manifest fields.
solver_pathsandconvergedare additive entry fields — convergence lives on the entry, so it survives a manifest round-trip without the Parquet files. - From a
Sweepobject.Sweep.to_solver_runs()andSweep.to_solver_convergence()are the bound-manifest equivalents of the two functions used here.