Sweeps resource

Accessor: client.sweeps

A sweep is a parameter study: pick a base configuration plus one or more parameter ranges, and the server computes a simulation at every combination. Use sweeps to plot thrust vs throttle curves, compare propellers across a fixed motor, or build hover-time vs payload tables.

Like simulations, sweeps are asynchronouscreate returns status: "queued" immediately.

Methods

Methods
MethodEndpointReturns
client.sweeps.create(project_id=..., **body)POST /v1/sweepsNew sweep in queued state
client.sweeps.list(project_id=...)GET /v1/sweepsCursorPager of sweeps
client.sweeps.retrieve(sweep_id)GET /v1/sweeps/{id}Single sweep (current status)
client.sweeps.cancel(sweep_id)POST /v1/sweeps/{id}/cancelSweep in canceled state
client.sweeps.list_points(sweep_id)GET /v1/sweeps/{id}/pointsCursorPager of computed points
client.sweeps.wait(sweep_id, timeout=600)(polls retrieve)Final sweep when terminal

Request body

sweeps.create takes the same base configuration as a simulation, plus a sweep object naming the axes to vary:

sweep = client.sweeps.create(
    project_id="proj_2c5tQ...",
    battery_component_id="comp_batt_xxx",
    airspeed_m_s=0.0,
    density_kg_m3=1.225,
    battery_charge_pct=100,
    rotor_groups=[{
        "label": "main", "count": 4,
        "motor_component_id": "comp_motor_xxx",
        "propeller_component_id": "comp_prop_xxx",
        "throttle_pct": 70,    # baseline; the swept axis overrides it
    }],
    sweep={
        "rotor_sweep_mask": [True],   # which rotor groups sweep the throttle axis
        "throttle": {"mode": "range", "start": 10, "stop": 100, "steps": 10},
    },
)

Numeric axes (throttle, airspeed, density, battery_charge, esc_pwm_frequency_khz, vertical_speed, tilt) each take {"mode": "range", "start": ..., "stop": ..., "steps": ...} or {"mode": "custom", "values": [...]}.

steps is the NUMBER OF POINTS, not a step size — the name reads the other way. {"start": 10, "stop": 100, "steps": 10} gives 10, 20, 30 … 100: an inclusive linspace from start to stop, not "step by 10". steps accepts 2 to 1000. {"mode": "custom", "values": [...]} takes an explicit list instead.

esc_timing_values is an explicit list of timing presets, and component_axes sweeps categorical motor/propeller/battery choices. Multiple axes form a Cartesian grid: 2 axes of 5 values each = 25 simulation points.

sweep also requires rotor_sweep_mask — a bool per rotor group, same length as rotor_groups — even when no axis needs it. At least one axis (or a non-empty component_axes) must actually be armed, or the sweep 422s with "At least one parameter must be swept".

The tilt axis needs an extra arming step: a tilt range alone does nothing — sweep.tilt_sweep_mask (bool per rotor group, same length as rotor_groups) selects which groups sweep it. Each masked group is an independent grid dimension. vertical_speed and tilt are ground-mode only (see Flight condition below) — under inflow_mode="components" either one 422s.

See API reference for the full body schema.

Component axes

A component axis sweeps a categorical choice: which motor, which propeller, which battery.

The shape is {"axis": "motor"|"propeller"|"battery", "slots": [<rotor group index>, ...], "component_ids": [...]}. It needs at least two ids; a one-value axis is not a sweep and is rejected with invalid_component_axis.

slots indexes rotor_groups positionally. More than one slot makes it ONE shared grid dimension applied to each of those slots, not a cross-product. A battery axis must target exactly one slot. slot (singular, default 0) is the older single-slot spelling; prefer slots.

A component axis crosses the numeric axes like any other dimension, so four propellers times four throttles is one 16-point grid. The alternative, a loop of single-point submissions, spends the per-account submission limit and recompiles the solver for every point. See Rate limits.

Each computed point names the component it was solved with at point["inputs"]["component"], a list of {axis, slot, id, name}. On the sweep resource, sweep_config.component_axes[] reads back as {axis, slot, slots, components: [{id, name}]}. Names and ids only — a sweep never returns a catalog component's spec values.

"""Component axis x throttle: auth -> submit -> wait() -> inspect points.

Run it:
    export THRUSTLAB_API_KEY=key_...        # never hard-code the key
    python examples/sweeps/component_axis_grid.py

Crosses a propeller component axis with a throttle range, so one request solves
every propeller at every throttle. It takes the first four catalog propellers
from client.components.list(type="propeller") and crosses them with four
throttle settings: 16 points in one submission.

Each returned point names the propeller that produced it at
point["inputs"]["component"], a list of {axis, slot, id, name}.

Sixteen single-point submissions would be sixteen requests against the
per-minute submission limit and sixteen cold solves; this is one request and
one warm solver.

`sweep.throttle.steps` is the NUMBER OF POINTS, not a step size.
"""

from thrustlab import Client

client = Client()  # reads $THRUSTLAB_API_KEY from the environment

project = client.projects.create(name="sdk component-axis example")

# The fixed half of the configuration — same verification combo as
# examples/sweeps/run_and_poll.py. Resolve by name, or paste explicit IDs.
motor = client.components.find(name="BadAss 2826-820Kv")
battery = client.components.find(
    name="Liperior 5000mAh 4S 35C 14.8V Lipo Battery With XT90 Plug"
)

# The swept half: the first four catalog propellers. `.data` is the first page
# of the cursor pager, so this is one request. A component axis needs at least
# two IDs — a one-value axis is not a sweep, and the server rejects it with
# `invalid_component_axis`.
props = client.components.list(type="propeller", limit=4).data
prop_ids = [p["id"] for p in props]
print("propeller axis:", ", ".join(p["name"] for p in props))

sweep = client.sweeps.create(
    project_id=project["id"],
    battery_component_id=battery["id"],
    airspeed_m_s=0.0,
    density_kg_m3=1.225,
    battery_charge_pct=100,
    # `propeller_component_id` here is the BASE value for slot 0. The component
    # axis below overrides it at every point, so it only has to be valid.
    rotor_groups=[
        {
            "label": "main",
            "count": 4,
            "motor_component_id": motor["id"],
            "propeller_component_id": prop_ids[0],
            "throttle_pct": 70,
        }
    ],
    sweep={
        # One bool per rotor group: this group follows the swept throttle axis.
        "rotor_sweep_mask": [True],
        # `steps` is the NUMBER OF POINTS, not a step size: 40, 60, 80, 100.
        "throttle": {"mode": "range", "start": 40, "stop": 100, "steps": 4},
        # `slots` indexes rotor_groups. Two or more slots would make this ONE
        # shared grid dimension applied to each of them, not a cross-product.
        "component_axes": [
            {"axis": "propeller", "slots": [0], "component_ids": prop_ids},
        ],
    },
)
print(f"created {sweep['id']}: {sweep['total_points']} points in one request")

result = client.sweeps.wait(sweep["id"], timeout=900)
print(f"final status: {result['status']}")

if result["status"] == "completed":
    print("\npropeller            | throttle | thrust/rotor (N)")
    for pt in client.sweeps.list_points(sweep["id"]):
        # `inputs["component"]` names the component(s) this point was solved
        # with: one {axis, slot, id, name} entry per active component axis.
        # It carries names and IDs only — never the component's spec values.
        chosen = ", ".join(c["name"] for c in pt["inputs"]["component"])
        thrust = pt["rotors"]["1"]["thrust_n"]
        print(f"  {chosen:<18} | {pt['inputs']['throttle']:>7}% | {thrust:.2f}")

Launch intent and parked sweeps

Launch intent and parked sweeps
ValueBehavior
"run" (default)Reserves compute units and dispatches immediately.
"queue"Reserves compute units and parks the sweep. dispatched_at stays null.
"draft"Persists with no compute-unit reservation and no dispatch.

A parked sweep is dispatched by client.simulations.run_queued() (every parked run you own, across all projects) or client.simulations.run_selected([...]) (a named subset). Both accept sweep_ ids even though they live on the simulations resource.

The per-minute submission limit counts POST /v1/sweeps calls, including parked sweeps. Parking controls when the work runs. It does not avoid the submission limit. Pace the create calls, or put more points into fewer sweeps by adding axes to one grid instead of submitting many.

Dynamic runs have no parked state.

What the sweep resource tells you

input_snapshot is the request body as submitted. When the caller omits it, the server stores the body it received, so a stored sweep can be read back exactly as it was asked for. It used to be null for sweeps, which meant a field like cooling_source or flight_duration could not be recovered from the resource at all.

credits_breakdown is {points, rotors, cu_per_point_per_rotor, total}. The cost of a grid is points x rotors x cu_per_point_per_rotor, readable rather than inferred from a balance change.

total_points and completed_points give progress; the fraction is completed_points / total_points.

Timestamps are created_at, dispatched_at, started_at and completed_at. Queue wait is started_at - dispatched_at. See Async resources.

cancel_requested_at is set when a cancel has been accepted for a run that is still going.

Flight condition

sweeps.create accepts the same inflow_mode as a single-point simulation ("ground" default, or "components") — see Simulations: Flight condition for the field-level rules. vertical_speed and tilt sweep axes only make sense under "ground" mode; a sweep with inflow_mode="components" that sets either one 422s.

Run synchronously (wait + list_points)

The standard pattern: submit, wait for terminal, then iterate the points. list_points returns a cursor pager — let it auto-iterate so you don't have to manage the cursor by hand.

sweep = client.sweeps.create(project_id="proj_xxx", ...)
result = client.sweeps.wait(sweep["id"], timeout=600, poll_interval=5.0)

if result["status"] == "completed":
    for pt in client.sweeps.list_points(sweep["id"]):
        print(pt["inputs"]["throttle"], "→", pt["rotors"]["1"]["thrust_n"], "N")

poll_interval=5.0 is a sane setting for sweeps — points compute in batches and the resource updates progressively.

Read points + plot

import matplotlib.pyplot as plt

xs, ys = [], []
for pt in client.sweeps.list_points("sweep_2c5tQ..."):
    xs.append(pt["inputs"]["throttle"])
    ys.append(pt["rotors"]["1"]["thrust_n"])

plt.plot(xs, ys, marker="o")
plt.xlabel("throttle (%)")
plt.ylabel("per-rotor thrust (N)")
plt.title("Quad-X hover thrust")
plt.show()

Each point carries index, inputs (the axis values for that point, e.g. {"throttle": 40.0, "airspeed": 0.0, ...}), and rotors — the same canonical snake_case shape as a single-point result: per-group blocks under "1"/"2"/… plus "All"/"Battery" aggregates.

The human-label map lives on the points page envelope (GET /v1/sweeps/{id}/points), not on the sweep resource itself — and list_points's CursorPager only exposes .data, not the raw envelope, so display_labels isn't reachable through it. To read it, call the points endpoint directly (or see Steady-state outputs — a sweep point uses the identical key vocabulary as a single-point result's display_labels).

Cancel an in-flight sweep

client.sweeps.cancel("sweep_2c5tQ...")

A queued sweep cancels immediately: status comes back canceled and the compute units are refunded.

A running sweep gets 202 Accepted with status still "running" and cancel_requested_at set. The request is recorded durably; the worker stops after the point it is computing and the row turns canceled then. Poll retrieve or wait. cancel_requested_at is what makes a pending cancel readable after a reload — before it existed a client had to remember locally that it had asked.

A draft sweep cannot be canceled (409 — cancel applies to a dispatched run); delete it instead with DELETE /v1/sweeps/{id} (no SDK wrapper yet).

To cancel many sweeps in one call — a whole grid you queued, say — use client.simulations.cancel_selected([...]) or client.simulations.cancel_queued(project_id=...); both take sweep ids. See Cancel many runs at once.

List sweeps for a project

for sweep in client.sweeps.list(project_id="proj_2c5tQ..."):
    print(sweep["id"], sweep["status"])

See also