Aggregating sweep outputs¶
gmat-sweep writes each run's outputs as one Parquet file per GMAT
output kind, then assembles them on demand into multi-indexed
pandas.DataFrames. Four entry points cover the four kinds gmat-run
surfaces:
| Function | GMAT output kind | Index |
|---|---|---|
lazy_multiindex |
ReportFile |
(run_id, time) |
lazy_ephemerides |
EphemerisFile (OEM, STK, SPK) |
(run_id, time) |
lazy_contacts |
ContactLocator |
(run_id, interval_id) |
lazy_solver_runs |
Target / Optimize solver logs |
(run_id, solver, iteration) |
The matching Sweep convenience methods —
Sweep.to_dataframe, Sweep.to_ephemerides, Sweep.to_contacts,
Sweep.to_solver_runs — delegate to these with the sweep's manifest and
output directory already bound. sweep() returns
Sweep.to_dataframe(name=None) directly for the common single-report
case.
Two further aggregators cover outputs that are not one of the four GMAT
kinds: lazy_extra_outputs for the
per-run Parquets a postprocess hook registered (see
Postprocess extra outputs), and
lazy_solver_convergence for the
manifest-only convergence summary that pairs with lazy_solver_runs
(see Solver / targeter iteration history).
The name selector¶
Every entry point accepts name: str | None = None. With one output of
the relevant kind across the sweep, name=None resolves to that sole
output automatically. With two or more, name=None raises
SweepConfigError listing the available
names — pass name="..." to pick one, and call the same function twice
to get two frames.
from pathlib import Path
from gmat_sweep import Manifest, lazy_contacts, lazy_ephemerides, lazy_multiindex, sweep
# Single-report case: sweep() returns the report frame directly.
reports = sweep(
"mission.script",
grid={"Sat.SMA": [7000, 7100, 7200]},
out=Path("./sweep"),
)
# Mixed outputs (report + ephemeris + contact): re-load the manifest and
# pull each frame independently.
manifest = Manifest.load(Path("./sweep/manifest.jsonl"))
ephemerides = lazy_ephemerides(manifest, Path("./sweep"))
contacts = lazy_contacts(manifest, Path("./sweep"))
# Two ReportFiles (e.g. spacecraft state + maneuvers): pass `name=` to pick.
states = lazy_multiindex(manifest, Path("./sweep"), name="StateReport")
burns = lazy_multiindex(manifest, Path("./sweep"), name="BurnReport")
Failed and skipped runs¶
Failed and skipped runs surface as one row per run with the data columns
NaN-filled and the __status column set to "failed" or "skipped".
The secondary index level carries a kind-appropriate missing value:
lazy_multiindex/lazy_ephemerides—time = NaT(datetime64[ns]).lazy_contacts—interval_id = pd.NA(nullableInt64).
An ok run that ran successfully but did not produce the requested
output kind (e.g. asking for ephemerides on a sweep where one specific
run only emitted reports) lands the same way, with __status="ok" so
it remains distinguishable from a true failure.
Index shapes¶
Reports and ephemerides — (run_id, time)¶
The worker copies the first datetime column of each frame to a column
literally named time before writing Parquet — so user column names
(Sat.UTCGregorian, Epoch, …) round-trip into the aggregated frame
unchanged, while the aggregator gets the consistent time level it
needs. SPK, STK-TimePosVel, and CCSDS-OEM ephemeris frames all expose
their epoch as Epoch, so the same synthesis covers every gmat-run
ephemeris format.
Contacts — (run_id, interval_id)¶
Contact frames are intervals, not point samples — one row per
visibility interval. The worker assigns a fresh interval_id column
(range(len(df)), so 0..K-1 per run) at write time. Use interval_id
the same way you'd use a per-run row position; the actual visibility
times are still in the data columns (Start, Stop, Duration, etc.,
depending on the ContactLocator.ReportFormat setting).
Solver / targeter iteration history¶
A sweep over a Target or Optimize scenario produces, per run, one
iteration-history frame per Solver resource — gmat-run's
Results.solver_runs. lazy_solver_runs
(and Sweep.to_solver_runs) assembles every such frame across the sweep
into one DataFrame indexed by a three-level (run_id, solver, iteration)
MultiIndex.
from gmat_sweep import lazy_solver_runs
# Takes the path to manifest.jsonl, like lazy_extra_outputs — not an
# already-loaded Manifest plus an output_dir.
runs = lazy_solver_runs("./sweep/manifest.jsonl")
# Every iteration of solver "DC" in run 7.
runs.xs((7, "DC"), level=("run_id", "solver"))
# The final-iteration goal residual of every run's targeter.
runs.groupby(level=["run_id", "solver"]).last()["Apoapsis_residual"]
Columns are the union across every solver in the sweep: one column per
Vary variable (verbatim script names), the goal/constraint residuals
(a DifferentialCorrector adds the <goal> / <goal>_desired /
<goal>_residual / <goal>_tolerance quartet; a Yukon adds cost
and <constraint>_residual), and a per-iteration status column
("running" until the terminal row, which carries "converged",
"max_iter", or "failed"). A DifferentialCorrector and a Yukon in
the same sweep contribute different column sets; each run shows NaN in
the columns specific to the other solver type.
iteration is the number GMAT reports. It is not unique for a
Yukon optimiser — one Yukon iteration spans several function
evaluations — so a Yukon run contributes repeated iteration values
under one (run_id, solver).
No marker rows¶
Unlike the GMAT-native aggregators above, lazy_solver_runs emits no
marker rows. A run whose mission sequence declared no solver — and any
failed or skipped run, which produced no solver Parquet — simply
contributes nothing. A sweep with no solver anywhere returns an empty,
correctly-typed frame rather than raising.
Convergence summary¶
lazy_solver_convergence (and
Sweep.to_solver_convergence) is the compact companion: a
(run_id, solver)-indexed frame with a single boolean converged
column, folded straight from the manifest's per-run converged maps. It
touches no Parquet — convergence is queryable without reading a
single solver file.
from gmat_sweep import lazy_solver_convergence
converged = lazy_solver_convergence("./sweep/manifest.jsonl")
# Which runs did the targeter fail to converge?
converged.loc[~converged["converged"]].index.get_level_values("run_id")
Run-level status and solver convergence are orthogonal. A run where
GMAT completed cleanly but the targeter exhausted MaximumIterations is
a perfectly ordinary status="ok" manifest entry — it is only
converged=False, with the per-iteration status column terminating in
"max_iter". A non-converged run is not a failed run:
resume keys on failed / missing status and will not
re-run it.
The Solver convergence example
notebook walks through all of this end to end against a real
DifferentialCorrector targeting sweep.
Postprocess extra outputs¶
A sweep that runs with a postprocess hook records
each run's hook-written Parquets in the manifest under the hook's own
keys. lazy_extra_outputs aggregates
one such key across the sweep — the postprocess counterpart of
lazy_multiindex.
from gmat_sweep import lazy_extra_outputs
# Second argument is the key the hook returned. It is required: extra
# outputs carry no `<kind>__` prefix and no sole "natural" output.
comparison = lazy_extra_outputs("./sweep/manifest.jsonl", "comparison")
Unlike the three GMAT-native aggregators it takes the path to the
manifest.jsonl rather than an already-loaded Manifest plus an
output_dir — relative paths recorded in the manifest are anchored
against the manifest file's own directory.
Adaptive index¶
The per-run Parquet is the hook's own artefact, so its schema — and the result index — is whatever the hook wrote:
| Per-run frame | Result index |
|---|---|
carries a time column |
(run_id, time) MultiIndex, as lazy_multiindex |
no time column |
single-level run_id index |
The presence of a time column is read from the streamed schema and
applied uniformly across the sweep. A hook that writes one row per run
yields one row per run_id; a hook that writes a per-timestep frame
yields the (run_id, time) shape that feeds straight into
sweep_summary. run_id and __status are
reserved column names — a hook that emits columns of those names will
see them dropped and replaced by the aggregator's own.
Every run in the manifest appears in the result. A failed or skipped
run — and any ok run whose hook did not register the requested key —
lands as one NaN-filled marker row with __status set to the run's
status (time = NaT under the (run_id, time) index), the same
treatment the GMAT-native aggregators give non-ok runs.
Aggregation is manifest-driven: only the Parquets recorded in each
manifest entry are read, so a stale run_<id>.parquet left in the
output directory by an earlier batch is never picked up. Reads honour
the streaming memory contract — a single pyarrow.dataset scan
materialised once, not one pandas frame per run.
Fusing multiple reports per run¶
When a sweep produces several ReportFile outputs and you want them
side-by-side on a shared timeline,
lazy_fused_reports (and
Sweep.to_fused_reports) reshape them into one wide DataFrame with a
column-level pandas.MultiIndex keyed by (report_name, column). The
first name in names is the merge anchor; subsequent reports are
joined onto it per run_id.
tolerance |
Merge per run | When it fits |
|---|---|---|
"exact" (literal) |
inner join on time |
every report shares the same step setting and you only want rows present in all of them |
pd.Timedelta(...) |
pd.merge_asof (backward direction, default) |
reports use different cadences and a "nearest within window" match per anchor row is what you want |
from pathlib import Path
import pandas as pd
from gmat_sweep import Manifest, lazy_fused_reports
manifest = Manifest.load(Path("./sweep/manifest.jsonl"))
# Two reports on the same step setting → exact inner join.
exact = lazy_fused_reports(
manifest, Path("./sweep"), names=["StateReport", "BurnReport"], tolerance="exact",
)
# Reports on different cadences (e.g. 1 Hz state + 10 s maneuvers) → asof merge
# with a per-row tolerance window.
fused = lazy_fused_reports(
manifest,
Path("./sweep"),
names=["StateReport", "BurnReport"],
tolerance=pd.Timedelta(seconds=2),
)
# Anchor's columns under (StateReport, *), right-side under (BurnReport, *).
fused[("StateReport", "Sat.X")]
fused[("BurnReport", "Sat.Tank.Mass")]
Column shape¶
| Column | Meaning |
|---|---|
(report_name, column) |
data column from that report |
(report_name, "__status") |
per-report status — preserves the lazy_multiindex contract for each report independently (e.g. one report failed-for-this-run while others succeeded) |
("__status", "") |
run-level status, mirrored from the manifest entry |
A run whose anchor failed (status != "ok" for the first name in
names, or its parquet was missing) lands as a single time=NaT row
with all data NaN. The per-report __status columns still surface every
report's individual state for that run, but the other reports' data is
not merged in — anchor-failure shadows the rest of the row. Pick the
report most likely to be present as the first entry of names.
tolerance is required (no default). See the
pandas.merge_asof reference
for the direction (backward by default), allow_exact_matches, and
accepted tolerance types.
Memory: streaming vs. eager reads¶
lazy_multiindex and lazy_ephemerides accept spool: bool = True.
With spool=True (default) each per-run Parquet is read one record
batch at a time; spool=False reads each Parquet eagerly in one
shot — simpler control flow, slightly higher peak per-fragment memory,
useful on small sweeps. The result frame is identical either way.
Per-fragment batches stay in Arrow and are concatenated buffer-shared
via pyarrow.concat_tables before a single to_pandas materialisation
at the end, so peak driver memory is bounded by the final frame plus a
small per-fragment overhead — not the sum of one pandas frame per
fragment. The contract that a 10k-run sweep does not have to fit in
memory at multiplicative cost holds in both spool modes.
lazy_contacts does not take a spool flag — ContactLocator outputs
are typically tiny (one row per pass) and the streaming overhead is not
worth the knob.
Summarising across runs¶
gmat-sweep returns one row per (run_id, time) — every run kept,
every time step kept. The canonical next step for dispersion analysis
is to collapse across runs at each time step into per-time
statistics: median, 5th and 95th percentile, mean, std, and a count of
ok contributions. sweep_summary does
exactly that and pairs with
sweep_band_plot (gated on the
[plot] extra) for the matching figure.
from pathlib import Path
from gmat_sweep import Manifest, lazy_multiindex, sweep_summary
from gmat_sweep.plotting import sweep_band_plot
manifest = Manifest.load(Path("./sweep/manifest.jsonl"))
df = lazy_multiindex(manifest, Path("./sweep"))
# Default: per-time-step 5/50/95 + mean + std across runs.
summary = sweep_summary(df)
# (time, q=0.5) slice — the median Sat.X over time across all runs.
median_x = summary[("q0.5", "Sat.X")]
# Median + 5–95% band for Sat.X.
ax = sweep_band_plot(summary, "Sat.X")
ax.figure.savefig("sat_x_band.png")
Output shape¶
The result is a single DataFrame whose row index is the unique values
of the by level and whose column index is a two-level
pandas.MultiIndex:
| Column | Meaning |
|---|---|
(statistic, field) |
one column per (statistic, original-column) pair |
Statistic labels are exactly the entries of include followed by
f"q{q_val}" for each requested quantile — e.g. "mean", "std",
"q0.05", "q0.5", "q0.95" for the defaults. count_ok counts
non-NaN values per group; useful for spotting time steps where many
runs produced NaNs.
by="time" vs by="run_id"¶
by="time"(default) — collapse across runs at each time step. The natural input for "median over time with a 5/95% band".by="run_id"— collapse across time steps within each run. The natural input for per-run summary metrics (e.g. mean Sat.X over the whole trajectory).
Other values raise SweepConfigError. Categorical groupings and
arbitrary by= keys are intentionally out of scope in this release.
Failed and skipped runs¶
By default (dropna=True) sweep_summary filters rows where
__status != "ok" before aggregating, so failed and skipped runs are
excluded from every statistic. Pass dropna=False to keep them — the
NaT marker rows from non-ok runs land as a NaT-keyed group in the
output (mostly NaN, with count_ok reflecting the contribution).
Comparing two sweeps¶
Once you have two sweep DataFrames of the same shape — baseline vs.
perturbed, before vs. after a .script edit, two backends on the same
sweep — sweep_diff turns them into a single
diff frame ready for plotting.
from pathlib import Path
from gmat_sweep import Manifest, lazy_multiindex, sweep_diff
baseline = lazy_multiindex(Manifest.load(Path("./baseline/manifest.jsonl")), Path("./baseline"))
perturbed = lazy_multiindex(Manifest.load(Path("./perturbed/manifest.jsonl")), Path("./perturbed"))
# Per-row absolute and relative diff for every shared numeric column.
diff = sweep_diff(baseline, perturbed)
# (run_id, time) → Sat.SMA shifted by exactly +50 km, everywhere.
diff[["Sat.SMA__diff", "Sat.SMA__rel"]].head()
For each numeric column shared between the two inputs, sweep_diff
emits <col>__diff = b - a and/or <col>__rel = (b - a) / a. Columns
present on only one side, and shared columns whose dtype is non-numeric,
are silently dropped — the contract is "compare the comparable".
Output shape¶
| Column | Meaning |
|---|---|
<col>__diff |
absolute difference, b - a |
<col>__rel |
relative difference, (b - a) / a (NaN where a == 0) |
__status_diff |
"ok" when both sides are __status="ok"; otherwise "<a_status>/<b_status>" (e.g. "failed/ok"). Omitted when neither input has a __status column. |
The row index matches the inputs' (aligned) index — typically
(run_id, time) or just run_id after the on="run_id" collapse below.
how selects which suffixes appear¶
how |
Columns |
|---|---|
"absolute" |
only <col>__diff |
"relative" |
only <col>__rel |
"both" (default) |
both, interleaved as <col>__diff, <col>__rel, <col2>__diff, <col2>__rel, … |
on=None vs on="run_id"¶
on=None(default) — align on the existing index (typically(run_id, time)). The diff is per-row, per-time-step.on="run_id"— collapse each side to its per-run final-step row viagroupby(level="run_id").last(), then diff. Output is indexed byrun_id. The natural shape for "did the dispersion of the final state change?" comparisons.
Tolerance masking¶
tolerance= masks every diff whose absolute value is strictly below
the cutoff to NaN — both __diff and the matching __rel are masked
at the same positions, so the surviving non-NaN entries highlight the
meaningful changes only.
# Single cutoff applied to every column.
diff = sweep_diff(baseline, perturbed, tolerance=1e-6)
# Per-column cutoffs — useful when columns carry mixed units.
def cutoff(col: str) -> float:
return 1e-3 if col.endswith(".SMA") else 1e-6 # km vs. km/s
diff = sweep_diff(baseline, perturbed, tolerance=cutoff)
Failed and skipped runs¶
__status_diff records the per-row pair so a downstream filter or plot
can drop or annotate them:
clean = diff.loc[diff["__status_diff"] == "ok"]
mismatched = diff.loc[diff["__status_diff"] != "ok"]
A row where one side failed and the other succeeded surfaces as
"failed/ok" (or the symmetric "ok/failed"), with the data columns
NaN — failed runs do not produce numeric outputs to subtract.
Index alignment¶
sweep_diff aligns the two inputs on the intersection of their
indexes. Keys present on only one side are silently dropped. The
function does not reshape across parameter_spec shapes — diffing a
grid sweep against a Monte Carlo sweep is the user's responsibility to
align (e.g. via df.reset_index().set_index([...])) before calling.
Polars output engine¶
Experimental
The polars output engine is experimental. The column-naming and
dtype rules described below are not yet contractual and may change
in a future minor version. Stable production code should pin
engine="pandas" (the default) or pin the gmat-sweep version.
Pandas is the default and only return type with no extra installed.
With the [polars] extra, every flat-column DataFrame-returning entry
point in this module accepts an engine="polars" keyword that returns
a polars.DataFrame instead.
The MultiIndex on lazy_multiindex/lazy_ephemerides ((run_id, time))
and lazy_contacts ((run_id, interval_id)) is flattened into two
sorted leading columns; row order, row count, and the non-index column
set match the pandas-engine equivalent. Polars carries the typed nulls
across — NaT becomes a polars null in Datetime[ns], the nullable
Int64 interval_id round-trips to a polars Int64 with null, and
NaN in numeric columns becomes a null in Float64.
from gmat_sweep import sweep
# pandas (default) — returns a (run_id, time)-MultiIndexed pandas DataFrame.
df = sweep("mission.script", grid={"Sat.SMA": [7000, 7100]}, out=...)
# polars — returns a polars.DataFrame with run_id/time as leading columns.
plf = sweep("mission.script", grid={"Sat.SMA": [7000, 7100]}, out=..., engine="polars")
plf.filter(plf["__status"] == "ok").group_by("run_id").agg(...)
The engine="polars" knob is available on:
- The top-level entry points:
sweep,monte_carlo,latin_hypercube, andmonte_carlo_extend. - The
Sweeporchestrator methods:Sweep.to_dataframe,Sweep.to_ephemerides,Sweep.to_contacts,Sweep.to_solver_runs,Sweep.to_solver_convergence, andSweep.to_extra_outputs. TheSweep.to_polars()shortcut is equivalent toSweep.to_dataframe(engine="polars"). - The standalone aggregators:
lazy_multiindex,lazy_ephemerides,lazy_contacts,lazy_solver_runs,lazy_solver_convergence,lazy_extra_outputs,mc_convergence, andsweep_diff.
Two helpers stay pandas-only because their output carries a column-level
MultiIndex and polars has no native equivalent:
sweep_summary ((statistic, field)
columns) and
lazy_fused_reports /
Sweep.to_fused_reports
((report_name, column) columns). Convert by hand when you need a
flat polars frame: