Simulations resource

Accessor: client.simulations

Run a single-point steady-state simulation: one airspeed, one density, one battery state-of-charge, one or more rotor groups. Simulations are asynchronouscreate returns immediately with status: "queued".

Free executes one simulation at a time and Pro executes up to five. This is an account-wide limit shared by dashboard and API-key submissions. Additional dispatched simulations remain queued and begin automatically when a slot is free. Paid usage is unlimited; normal request-rate and global overload controls still apply.

Methods

Methods
MethodEndpointReturns
client.simulations.create(project_id=..., **body)POST /v1/simulationsNew simulation in queued state
client.simulations.list(project_id=..., status=...)GET /v1/simulationsCursorPager of simulations
client.simulations.retrieve(simulation_id)GET /v1/simulations/{id}Single simulation (current status)
client.simulations.cancel(simulation_id)POST /v1/simulations/{id}/cancelSimulation in canceled state
client.simulations.cancel_selected(run_ids)POST /v1/simulations/cancel-selected{canceled, requested, skipped} for runs of any kind — see Cancel many runs at once
client.simulations.cancel_queued(project_id=...)POST /v1/simulations/cancel-queuedSame shape; every queued run you own, optionally one project
client.simulations.wait(simulation_id, timeout=600)(polls retrieve)Final simulation when terminal

list's status= filters to one wire status (draft, queued, running, completed, failed, or canceled); an unrecognized value 422s. project_id maps to the server's project query parameter.

A few endpoints have no SDK method yet — call them with your own HTTP client (see Authentication for the API key header):

Methods (table 2)
EndpointPurpose
PATCH /v1/simulations/{id}Rename, star/unstar, or replace plot_config_json
DELETE /v1/simulations/{id}Delete a non-active (not queued/running) simulation
POST /v1/simulations/{id}/promotePromote a draft to queued/running with the (possibly edited) body
POST /v1/simulations/run-queuedDispatch every queued simulation as warm batches grouped by rotor count
POST /v1/simulations/run-selectedDispatch a caller-selected subset of queued simulations

Request body

simulations.create forwards every keyword argument as the JSON body. The required shape:

sim = client.simulations.create(
    project_id="proj_2c5tQ...",
    battery_component_id="comp_batt_xxx",
    airspeed_m_s=0.0,            # forward velocity (0 = hover)
    density_kg_m3=1.225,         # ISA sea level
    battery_charge_pct=100,      # 0..100
    rotor_groups=[{
        "label": "main",
        "count": 4,
        "motor_component_id": "comp_motor_xxx",
        "propeller_component_id": "comp_prop_xxx",
        "throttle_pct": 70,      # 0..100
        "esc_resistance_mohm": 0,
        "esc_motor_wire_resistance_mohm": 0,
    }],
)

A rotor_groups array supports mixed-rotor designs: a coaxial-twin with two different propellers is two groups with count: 1 and different propeller_component_ids. A quad-X with four identical rotors is one group with count: 4. See the API reference for every field, including optional ESC presets and custom resistance modes.

Caps

rotor_groups holds at most 10 entries. Each group's count is 1–16, and the sum of every group's count in the simulation cannot exceed 16.

Flight condition

inflow_mode picks how the flight condition enters the solve — the two modes are mutually exclusive and mixing them 422s:

Flight condition
inflow_modeSim-level fieldPer-group field
"ground" (default)vertical_speed_m_s (±30 m/s, default 0), alongside airspeed_m_stilt_deg (0–90°, default 0) — rotor-axis tilt from the horizontal-forward flight direction (0° = cruise, 90° = lift/hover)
"components"v_axial_m_s (−30..100 m/s), v_edge_m_s (0..100 m/s, required together)

"ground" rejects any group's v_axial_m_s/v_edge_m_s; "components" rejects vertical_speed_m_s and any group's tilt_deg, and requires both v_axial_m_s and v_edge_m_s on every group.

Custom component overrides

Swap in an ad-hoc motor, propeller, or battery without a saved component — custom_motor / custom_propeller per rotor group, or custom_battery sim-level:

"custom_propeller": {
    "base_component_id": "comp_prop_xxx",  # optional starting point; omit to build from scratch
    "name": "Modified 10x5E",
    "spec_json": {...},                    # full component spec
}

Per-group fields

Beyond motor_component_id / propeller_component_id / throttle_pct, each rotor group accepts:

Per-group fields
FieldTypeDefaultNotes
motor_cooling_source"cowling" | "prop_exit_velocity" | "custom"prop_exit_velocityStill air / prop-slipstream forced convection / custom
motor_cooling_velocity_m_sfloat, 0–200NoneOnly with motor_cooling_source="custom"; 422 otherwise
motor_r_thfloat, 0–100 (K/W)NoneDirect thermal-resistance override; only with motor_cooling_source="custom"
motor_t_w / motor_t_magfloatNonePin winding/magnet temperature (°C); omit to let the model solve it
esc_type"foc" | "six_step"six_stepCommutation type. Selects K_volt, copper_mult, iron_mult, the six-step advance physics and the throttle→duty map — omitting it is not neutral (≈4.6% RPM / 8.9% thrust vs foc at full throttle)
esc_timing"low" | "medium" | "high" | "auto"mediumSix-step commutation advance; ignored under FOC
esc_pwm_frequency_khzfloat, 8–4824.0ESC switching frequency
esc_sync_rectificationboolTrueSynchronous rectification

The sim-level battery_esc_wire_resistance_mohm (default 0) and name (optional display name) round out the body.

Thermal & environment inputs

Thermal & environment inputs
FieldDefaultBoundsNotes
ambient_temp_c25.0−60..85°C
flight_regime"static_bench"static_bench | prop_wash_mild | prop_wash_strong | forced_airDeprecated — use cooling_source. Mapped to the equivalent cooling source when cooling_source is omitted
cooling_sourceNone"static" | "airspeed" | "prop_slipstream" | "forced_air"Battery convection source. Wins over flight_regime whenever both are set
forced_air_velocity_m_sNone0–200Only meaningful with cooling_source="forced_air"
flight_duration60.00..86400, or nullSeconds for the time-bounded thermal solve; null disables time-bounding

flight_regime is deprecated. Set cooling_source instead — it is the control the solver reads. If you send only flight_regime, it is mapped for you: prop_wash_mild and prop_wash_strong both become prop_slipstream, forced_air becomes forced_air, and static_bench becomes static. Sending both is not an error; cooling_source wins.

Launch intent

launch_intent controls what create actually does:

Launch intent
ValueBehavior
"run" (default)Reserves compute units and dispatches immediately
"queue"Reserves compute units but defers dispatch (run later via the batch endpoints)
"draft"Persists with no compute-unit reservation, no dispatch, and no rotor-group requirement

Run synchronously (wait)

The most common pattern: submit, then block until the simulation is in a terminal state. wait polls retrieve at poll_interval= seconds (default 2.0) until status leaves {"queued", "running"}, or until timeout= seconds elapse.

"""Single-point simulation: auth -> submit -> wait() -> read the canonical result.

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

The verification combo below (BadAss 2826-820Kv + 10.5x4.5 + Liperior
4S 5000 mAh @ 70% throttle, static) converges to ~9.07 N / ~8505 rpm. Swap in
your own component IDs, or resolve them by name with client.components.find(...).
"""

from thrustlab import Client

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

project = client.projects.create(name="sdk single-point example")

# Resolve components by name (find() returns the single match or raises
# AmbiguousComponentError / NotFoundError). Or paste explicit IDs instead:
#   motor_id = "comp_motor_xxx"
motor = client.components.find(name="BadAss 2826-820Kv")
prop = client.components.find(name="10.5x4.5")
battery = client.components.find(
    name="Liperior 5000mAh 4S 35C 14.8V Lipo Battery With XT90 Plug"
)

sim = client.simulations.create(
    project_id=project["id"],
    battery_component_id=battery["id"],
    airspeed_m_s=0.0,
    density_kg_m3=1.225,
    battery_charge_pct=100,
    rotor_groups=[
        {
            "label": "main",
            "count": 4,
            "motor_component_id": motor["id"],
            "propeller_component_id": prop["id"],
            "throttle_pct": 70,
        }
    ],
)
print(f"created {sim['id']}, status={sim['status']}")

# wait() polls until completed / failed / canceled (or a timeout sentinel).
result = client.simulations.wait(sim["id"], timeout=300)
print(f"final status: {result['status']}")

if result["status"] == "completed":
    # Canonical snake_case result (post-D-01): per-rotor group under its label
    # index ("1", "2", ...); aggregate roll-ups under "All".
    per_rotor = result["result"]["1"]
    aggregate = result["result"]["All"]
    print(f"per-rotor thrust: {per_rotor['thrust_n']:.2f} N")
    print(f"per-rotor rpm:    {per_rotor['rpm']:.0f}")
    print(f"total thrust:     {aggregate['total_thrust_n']:.2f} N")

If wait times out before the server finishes, it returns the resource with status: "timed_out" — an SDK-side sentinel, never a server state. The server-side run keeps going; call wait() or retrieve() again to resume polling.

result = client.simulations.wait(sim["id"], timeout=60)
if result["status"] == "timed_out":
    # Resume later — the server is still computing.
    final = client.simulations.retrieve(sim["id"])

Terminal status values

Terminal status values
StatusMeaning
completedResult fields populated under result; ready to read.
failedInspect error.code and error.message.
canceledUser-canceled before completion (one 'l' on the wire).
timed_outSDK-side wait() sentinel — the server run is still going.

Cancel an in-flight simulation

client.simulations.cancel("sim_2c5tQ...")

Cancellation is best-effort: a simulation already in its final ms of compute may still finish as completed. The cancel call is idempotent — calling it on an already-terminal simulation is a no-op that returns the current state.

Cancel many runs at once

Two methods cancel in bulk, and both take ids of every run kind — sim_, sweep_ and dyn_ — so a grid of queued sweeps or a batch of missions goes in one call:

result = client.simulations.cancel_selected(
    ["sweep_2c5tQ...", "sweep_2c5tR...", "dyn_2c5tS..."]
)
print(result)
# {"canceled": [...], "requested": [...], "skipped": [{"id": "...", "reason": "already_terminal"}]}

# Everything you have queued in one project (running runs are left alone):
client.simulations.cancel_queued(project_id="proj_2c5tQ...")
# ...or across every project:
client.simulations.cancel_queued()

On the wire these are POST /v1/simulations/cancel-selected with a {"run_ids": [...]} body and POST /v1/simulations/cancel-queued with an optional ?project=proj_… query — the same API key header as every other call (see Authentication); no Idempotency-Key is required. Running runs are not touched by cancel_queued — send them to cancel_selected.

Every id in cancel-selected is ownership-checked before anything is cancelled: one id you do not own (or that does not exist) fails the whole call with 404 resource_missing, and nothing is cancelled. Up to 1000 ids per call.

The response sorts the ids three ways:

Cancel many runs at once
KeyMeaning
canceledTerminal now. Queued runs (full refund), and dynamic missions — their status flips at request time; a mid-solve mission stops at its next integration window.
requestedRunning sweeps. The request is recorded durably; the worker stops after the point it is computing and the row turns canceled then — poll retrieve/wait.
skippedLeft alone, with a reason: already_terminal, draft (delete those instead), or running_single_point (finishes in seconds).

Read the result

A completed simulation has its computed outputs under result, in canonical snake_case: per-rotor-group blocks under "1"/"2"/… and whole-vehicle aggregates under "All". A sibling display_labels map translates every key to its human label (full field list in the API reference):

sim = client.simulations.retrieve("sim_2c5tQ...")
if sim["status"] == "completed":
    r = sim["result"]["1"]        # per-rotor values for the first group
    agg = sim["result"]["All"]    # whole-vehicle aggregates
    print(f"thrust:       {r['thrust_n']:.2f} N")
    print(f"current:      {r['current_a']:.2f} A")
    print(f"rpm:          {r['rpm']:.0f}")
    print(f"total thrust: {agg['total_thrust_n']:.2f} N")

List simulations for a project

for sim in client.simulations.list(project_id="proj_2c5tQ...", status="failed"):
    print(sim["id"], sim["status"], sim["created_at"])

Without project_id you get every simulation owned by the calling user across all projects.

Recipe: fire-and-forget via webhooks

For long jobs or batch pipelines, skip polling — register a webhook endpoint and let the server push a simulation.completed event when it's done.

endpoint = client.webhook_endpoints.create(
    url="https://your-app.example.com/webhooks/thrustlab",
    events=["simulation.completed", "simulation.failed"],
)
sim = client.simulations.create(project_id="proj_xxx", ...)
# Your webhook handler will receive the event when terminal.

See Webhooks for endpoint management and signature verification for the receiving handler.

See also