Dynamic simulations
Accessor: client.dynamic_simulations
A dynamic simulation integrates the powertrain through time: you describe a mission as a throttle/airspeed schedule plus a termination condition, and the server returns timestamped observations, time series, a scorecard, and events. Use it for endurance runs, spin-up transients, mixed hover/cruise missions, and anything else a single steady operating point can't answer.
Like simulations and sweeps, dynamic runs are asynchronous — create
returns status: "queued" immediately.
Methods
| Method | Endpoint | Returns |
|---|---|---|
client.dynamic_simulations.create(project_id=..., **body) | POST /v1/dynamic-simulations | New run in queued state |
client.dynamic_simulations.estimate(**body) | POST /v1/dynamic-simulations/estimate | Projected duration, without running the solver |
client.dynamic_simulations.list(project_id=...) | GET /v1/dynamic-simulations | CursorPager of runs |
client.dynamic_simulations.retrieve(dynamic_id) | GET /v1/dynamic-simulations/{id} | Single run (current status) |
client.dynamic_simulations.update(dynamic_id, ...) | PATCH /v1/dynamic-simulations/{id} | Run with mutable fields (e.g. name) patched |
client.dynamic_simulations.cancel(dynamic_id) | POST /v1/dynamic-simulations/{id}/cancel | canceled if queued; otherwise still running with cancel_requested_at set |
client.dynamic_simulations.wait(dynamic_id, timeout=600) | (polls retrieve) | Final run when terminal |
Two endpoints are not wrapped by the SDK: GET /v1/dynamic-simulations/{id}/export.csv (raw CSV download — fetch the URL
directly) and GET /v1/dynamic-simulations/{id}/stream (SSE progress).
Request body
The body shares the simulation base (rotor_groups,
battery_component_id, density_kg_m3, battery_charge_pct,
ambient_temp_c) but rotor groups carry no throttle_pct — all
commands come from the schedule. Two more optional fields set the starting
temperatures: motor_initial_temp_c / battery_initial_temp_c (°C, −60..85,
default None = cold-start at ambient).
PROM v5 automatically returns exact accepted-step observations on an explicit,
generally irregular time grid. There is no public reporting create-body field;
the response's reporting object describes the server-selected rows and avoided
reconstruction work. Scorecards and events are aggregated from the complete
accepted observation stream before row selection. Historical legacy results may
still expose a fixed run_meta.save_dt; accepted-step results do not.
"""Dynamic (time-domain) simulation: auth -> submit -> wait() -> read the result.
Run it:
export THRUSTLAB_API_KEY=key_... # never hard-code the key
python examples/dynamic/run_and_poll.py
A dynamic run integrates the powertrain through a throttle/airspeed *schedule*
until a *termination* condition (here: a 2 s spin-up ramp, then a 20 s hover
at 70% — a fixed-duration mission). It returns per-step `samples` (each a full
steady-shaped result),
time `series`, a `scorecard`, and `events`. 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 dynamic example")
# Resolve by name, or paste explicit IDs: 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"
)
dyn = client.dynamic_simulations.create(
project_id=project["id"],
battery_component_id=battery["id"],
density_kg_m3=1.225,
battery_charge_pct=100,
ambient_temp_c=25,
rotor_groups=[
{
"label": "main",
"count": 4,
"motor_component_id": motor["id"],
"propeller_component_id": prop["id"],
}
],
# Ramp to 70% over 2 s, then hold for 20 s. The soft-start matters:
# stepping 70% onto a stationary rotor draws an in-rush that can sag the
# pack below the low-voltage cutoff and end the run immediately — exactly
# as it would on real hardware. Endurance ("until depleted") missions work
# the same way but integrate minutes of pack time; keep examples short.
schedule={
"mode": "segments",
"segments": [
{
"duration_s": 2.0,
"airspeed_target": 0.0,
"per_group": {"main": {"throttle_target": 70, "throttle_ramp": "linear"}},
},
{
"duration_s": 20.0,
"airspeed_target": 0.0,
"per_group": {"main": {"throttle_target": 70}},
},
],
},
# Fixed-duration mission: run the schedule to its end.
termination={"mode": "fixed"},
)
print(f"created {dyn['id']}, status={dyn['status']}")
result = client.dynamic_simulations.wait(dyn["id"], timeout=600)
print(f"final status: {result['status']}")
if result["status"] == "completed":
blob = result["result"]
# scorecard: whole-run headline metrics (canonical snake_case).
sc = blob["scorecard"]
print(f"peak winding temp: {sc['peak_winding_temp_c']:.1f} C")
print(f"avg efficiency: {sc['avg_efficiency']:.3f}")
print(f"peak current: {sc['peak_current_a']:.1f} A")
print(f"min cell voltage: {sc['min_cell_voltage_v']:.2f} V")
# samples[]: each entry is a full steady-shaped snapshot at one time step —
# read the same canonical per-rotor / aggregate keys as a single-point run.
samples = blob["samples"]
first, last = samples[0], samples[-1]
print(f"steps: {len(samples)}")
print(f" t0 total thrust: {first['All']['total_thrust_n']:.2f} N")
print(f" tN total thrust: {last['All']['total_thrust_n']:.2f} N")
The schedule
schedule.mode picks between two ways to drive the mission: "segments"
(hand-authored throttle/airspeed steps, below) or "csv" (a full time-series
upload, see CSV schedules).
schedule.mode: "segments" describes the mission as an ordered list of
segments. Each segment has either a finite duration_s or
until_depleted: true (a terminal hold that runs until the termination
condition fires). Vehicle-level channels (airspeed_target,
vertical_speed_target) sit on the segment; per-group channels
(throttle_target, tilt_target) sit under per_group, keyed by
rotor-group label:
schedule={
"mode": "segments",
"segments": [
{ # 2 s spin-up: ramp throttle 0 -> 70%
"duration_s": 2.0,
"airspeed_target": 0.0,
"per_group": {"main": {"throttle_target": 70, "throttle_ramp": "linear"}},
},
{ # hold 70% until the pack depletes
"until_depleted": True,
"airspeed_target": 0.0,
"per_group": {"main": {"throttle_target": 70}},
},
],
},
termination={"mode": "until_depleted", "soc_cutoff_pct": 20.0},
Segment-level channels:
| Field | Bounds | Default | Notes |
|---|---|---|---|
duration_s | > 0 | — | Required unless until_depleted: true |
airspeed_target | ≥ 0 | 0.0 | m/s |
airspeed_ramp | "step" | "linear" | step | |
vertical_speed_target | −30..30 | 0.0 | m/s, signed (+ climb, − descent); ground-mode only |
vertical_speed_ramp | "step" | "linear" | step | |
throttle_ramp | "step" | "linear" | step | Segment-level shorthand for the ramp shape (a per-group per_group.<label>.throttle_ramp still takes precedence when set) |
Per-group (per_group.<label>) channels:
| Field | Bounds | Default | Notes |
|---|---|---|---|
throttle_target | 0..100 | required | % |
throttle_ramp | "step" | "linear" | step | |
tilt_target | 0..90 | 0.0 | deg, rotor-axis tilt from the horizontal-forward flight direction (0 = cruise, 90 = lift/hover); ground-mode only |
tilt_ramp | "step" | "linear" | step |
Rules worth knowing:
- Ramps: a
"linear"ramp needs a defined end time, so it requires a finiteduration_s— express ramp-then-hold as a finite ramp segment followed by the terminal hold, as above. - The first segment starts from zero: throttle ramps up from 0, so a linear first segment is a realistic soft-start.
- Soft-start matters: stepping high throttle onto a stationary rotor draws a large in-rush current that can sag the pack below the low-voltage cutoff and end the run within milliseconds — exactly as on real hardware. If that's not the transient you're studying, ramp the first segment.
- List every group in every segment — a group omitted from a segment's
per_groupis commanded to 0% throttle for that segment. - Different rotor groups get independent channels: give each group its own
throttle_target/tilt_targetper segment.
CSV schedules
schedule.mode: "csv" uploads the full time series as text instead of
authoring segments — useful for a logged flight or an externally generated
mission:
schedule={
"mode": "csv",
"csv_text": (
"time_s,airspeed_ms,throttle_1,throttle_2\n"
"0,0,0,0\n"
"2,0,70,70\n"
"300,15,85,85\n"
),
"interpolation": "linear", # or "hold" (left-breakpoint / zero-order-hold)
},
Header columns are matched by name (case-insensitive), not position:
- Required:
time_s,airspeed_ms,throttle_1..throttle_N(one per rotor,N= total rotor count across all groups). - Optional:
vertical_speed_ms(signed, global) andtilt_deg_k(per-rotor, any subset of1..N) — omitted columns default to 0. time_smust strictly increase row to row; every cell must be a finite number in range (throttle 0..100, tilt 0..90, vertical speed −30..30).
Termination
| Field | Default | Notes |
|---|---|---|
mode | "until_depleted" | "fixed" ends the run at the schedule's own length; "until_depleted" runs until a cutoff trips |
soc_cutoff_pct | 20.0 | 0..100; battery state-of-charge floor |
cell_voltage_cutoff_v | None | Optional per-cell loaded-voltage cutoff, in addition to soc_cutoff_pct |
max_duration_s | None | Optional hard wall-clock cap, ≤ 3600 s; exceeding it 422s at submit |
See the API reference for the full schedule and termination schema.
Wait, then read the result
dyn = client.dynamic_simulations.create(project_id="proj_xxx", ...)
result = client.dynamic_simulations.wait(dyn["id"], timeout=600)
if result["status"] == "completed":
blob = result["result"]
Terminal states are completed / failed / canceled (one 'l' on the
wire). On timeout, wait returns the dict with status="timed_out" — an
SDK-side sentinel, not a server state; the run keeps computing and you can
call wait() or retrieve() again.
A completed run's result has these main analysis parts:
| Key | Shape |
|---|---|
samples | List of observation snapshots — each has the same canonical shape as a single-point result (per-group blocks under "1"/"2"/…, aggregates under "All") |
series | Time series: explicit time_s timestamps plus aligned per-channel arrays |
scorecard | Whole-run headline metrics (below) |
events | Notable moments: {t, type, severity, detail} — e.g. in_rush_peak, depletion |
In accepted_steps_v1 mode, rows are exact accepted-step observations and
adjacent time_s values need not be equally spaced. Use time_s[i] for every
channel row rather than reconstructing time from i. The result also includes
top-level reporting metadata and accepted-only run_meta fields:
reporting_mode, reporting_max_rows, complete_rows, retained_rows,
display_budget, display_rows, and display_budget_soft_overrun. In this mode
run_meta.save_dt is None; legacy_requested_save_dt is telemetry only and
must not be treated as row spacing. Legacy results omit the accepted-only keys.
Scorecard keys: flight_time_s, energy_wh, range_m, avg_efficiency,
peak_current_a, peak_winding_temp_c, min_cell_voltage_v,
depletion_criterion, terminated_early. A sibling display_labels map on
the resource translates every snake_case key to its human label.
sc = blob["scorecard"]
print(f"flight time: {sc['flight_time_s']:.1f} s")
print(f"peak current: {sc['peak_current_a']:.1f} A")
print(f"min cell V: {sc['min_cell_voltage_v']:.2f} V")
first, last = blob["samples"][0], blob["samples"][-1]
print(f"final thrust: {last['All']['total_thrust_n']:.2f} N")
Estimate before you run
estimate projects the run's coulombic duration from the battery, schedule,
and termination alone — no solver invocation, no compute-unit charge for the
integration:
est = client.dynamic_simulations.estimate(
battery_component_id="comp_batt_xxx",
rotor_groups=[...], density_kg_m3=1.225, battery_charge_pct=100,
schedule={...}, termination={...},
)
Long missions cost real wall-clock time (the solver integrates the full
thermal/electrical state), so estimate first and keep exploratory runs
short — e.g. use termination={"mode": "fixed"} with a finite schedule
instead of depleting a full pack.
Cancel an in-flight run
client.dynamic_simulations.cancel("dyn_2c5tQ...")
A queued run cancels immediately. The response returns canceled, and the
compute units are refunded in full. A running run keeps status: "running" and
sets cancel_requested_at. Poll retrieve or use wait until the status
becomes canceled.
The worker stops at its next integration window, at most 20 seconds of mission
time later. This is not a wall-time limit. It stores the work completed so far
as a partial result with run_meta.partial: true and
run_meta.canceled_at_t_s. It then refunds the compute units and frees the
execution slot.
client.simulations.cancel_selected([...]) accepts dyn_ ids alongside sim_
and sweep_ ids. Running dynamic runs appear under requested in that
response. See
Cancel many runs at once.
A mission that runs until the pack is empty can be monitored and canceled from
code. Once the canceled run is terminal, its partial result reports
run_meta.partial and run_meta.canceled_at_t_s.
"""Dynamic progress + cancel: submit -> watch `progress` -> cancel -> read the partial.
Run it:
export THRUSTLAB_API_KEY=key_... # never hard-code the key
python examples/dynamic/watch_and_cancel.py
Submits a mission that flies until the pack is empty, watches `progress` while
it runs, cancels it part-way through, and reads the partial result.
`progress` is {fraction, sim_time_s, sim_time_total_s, wall_s, updated_at}, and
is None until the worker picks the run up.
Cancelling a RUNNING dynamic mission does not stop it instantly. The response
keeps status "running" and sets `cancel_requested_at`; the worker stops at its
next integration window, at most 20 seconds of mission time. What comes back is
a real partial result: `run_meta.partial` is true, `run_meta.canceled_at_t_s`
gives the mission time it stopped at, and the credits are refunded.
This mission runs to depletion, so `fraction` is an estimate rather than an
exact ratio.
"""
import time
from thrustlab import Client
client = Client() # reads $THRUSTLAB_API_KEY from the environment
project = client.projects.create(name="sdk dynamic progress example")
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"
)
# A hover flown until the pack is empty.
#
# The schedule steps to 15% before it ramps, rather than ramping up from rest.
# One external study observed ramps starting at near-zero duty producing a
# handful of non-converged rows in their first milliseconds; as a fraction of a
# short flight's rows that was enough to get a run rejected with
# `solver_no_convergence`. Stepping to a working throttle first avoided it.
dyn = client.dynamic_simulations.create(
project_id=project["id"],
battery_component_id=battery["id"],
density_kg_m3=1.225,
battery_charge_pct=100,
ambient_temp_c=25,
rotor_groups=[
{
"label": "main",
"count": 4,
"motor_component_id": motor["id"],
"propeller_component_id": prop["id"],
}
],
schedule={
"mode": "segments",
"segments": [
# Step to the ESC's working range first.
{
"duration_s": 0.2,
"airspeed_target": 0.0,
"per_group": {"main": {"throttle_target": 15, "throttle_ramp": "step"}},
},
# Then ramp from there to the hover setting.
{
"duration_s": 2.0,
"airspeed_target": 0.0,
"per_group": {"main": {"throttle_target": 65, "throttle_ramp": "linear"}},
},
# Hold until the pack is empty. `until_depleted` takes no duration.
{
"until_depleted": True,
"airspeed_target": 0.0,
"per_group": {"main": {"throttle_target": 65}},
},
],
},
# The total mission time is not known in advance, so progress["fraction"]
# is an estimate rather than an exact ratio.
termination={"mode": "until_depleted", "soc_cutoff_pct": 20.0},
)
print(f"created {dyn['id']}, status={dyn['status']}")
# Watch progress. 2-5 s is the right cadence; anything faster spends requests
# on a value the worker only rewrites once per integration window.
CANCEL_AFTER_S = 45.0
deadline = time.monotonic() + CANCEL_AFTER_S
while time.monotonic() < deadline:
time.sleep(3.0)
run = client.dynamic_simulations.retrieve(dyn["id"])
if run["status"] not in ("queued", "running"):
break
# `progress` is None until the worker picks the run up.
p = run.get("progress")
if p is None:
print(f" {run['status']}, no progress yet")
continue
frac = p.get("fraction")
pct = f"{frac * 100:5.1f}%" if frac is not None else " ? %"
print(f" {pct} sim {p['sim_time_s']:.1f} s wall {p['wall_s']:.1f} s")
run = client.dynamic_simulations.retrieve(dyn["id"])
if run["status"] in ("queued", "running"):
# Cancel whatever state the deadline caught it in. A queued run comes back
# `canceled` immediately, with the compute units refunded and no result. A
# running one comes back still `running` with cancel_requested_at set, and
# the worker stops at its next integration window.
#
# Cancelling both matters here: with every execution slot busy this run can
# still be queued at the deadline, and skipping it would leave it to
# execute later against a script that has already exited.
accepted = client.dynamic_simulations.cancel(dyn["id"])
print(f"\ncancel accepted: status={accepted['status']}, "
f"cancel_requested_at={accepted.get('cancel_requested_at')}")
run = client.dynamic_simulations.wait(dyn["id"], timeout=300)
print(f"final status: {run['status']}")
blob = run.get("result")
if blob:
meta = blob["run_meta"]
if meta.get("partial"):
print(f"partial result, stopped at t = {meta['canceled_at_t_s']:.1f} s of mission time")
sc = blob["scorecard"]
print(f"flight time: {sc['flight_time_s']:.1f} s")
print(f"energy: {sc['energy_wh']:.1f} Wh")
print(f"peak winding: {sc['peak_winding_temp_c']:.1f} C")
print(f"samples returned: {len(blob['samples'])}")
No webhook events (yet)
Dynamic runs do not emit webhook event types — poll with wait() or
retrieve(). The event catalog covers simulations
and sweeps only.
See also
- Async resources — the shared poll/wait contract
- Simulations — the steady-state base body
- Sweeps — parameter studies over steady points