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 asynchronouscreate returns status: "queued" immediately.

Methods

Methods
MethodEndpointReturns
client.dynamic_simulations.create(project_id=..., **body)POST /v1/dynamic-simulationsNew run in queued state
client.dynamic_simulations.estimate(**body)POST /v1/dynamic-simulations/estimateProjected duration, without running the solver
client.dynamic_simulations.list(project_id=...)GET /v1/dynamic-simulationsCursorPager 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}/cancelRun in canceled state
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:

The schedule
FieldBoundsDefaultNotes
duration_s> 0Required unless until_depleted: true
airspeed_target≥ 00.0m/s
airspeed_ramp"step" | "linear"step
vertical_speed_target−30..300.0m/s, signed (+ climb, − descent); ground-mode only
vertical_speed_ramp"step" | "linear"step
throttle_ramp"step" | "linear"stepSegment-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:

The schedule (table 2)
FieldBoundsDefaultNotes
throttle_target0..100required%
throttle_ramp"step" | "linear"step
tilt_target0..900.0deg, 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 finite duration_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_group is commanded to 0% throttle for that segment.
  • Different rotor groups get independent channels: give each group its own throttle_target / tilt_target per 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) and tilt_deg_k (per-rotor, any subset of 1..N) — omitted columns default to 0.
  • time_s must 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

Termination
FieldDefaultNotes
mode"until_depleted""fixed" ends the run at the schedule's own length; "until_depleted" runs until a cutoff trips
soc_cutoff_pct20.00..100; battery state-of-charge floor
cell_voltage_cutoff_vNoneOptional per-cell loaded-voltage cutoff, in addition to soc_cutoff_pct
max_duration_sNoneOptional 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:

Wait, then read the result
KeyShape
samplesList of observation snapshots — each has the same canonical shape as a single-point result (per-group blocks under "1"/"2"/…, aggregates under "All")
seriesTime series: explicit time_s timestamps plus aligned per-channel arrays
scorecardWhole-run headline metrics (below)
eventsNotable 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...")

The row flips to canceled at once and the reservation is refunded in full; a mission that is mid-solve stops at its next integration window, so the worker slot frees within about twenty mission-seconds of simulated time. Several runs at once: client.simulations.cancel_selected([...]) accepts dyn_ ids alongside sim_/sweep_ ids — see Cancel many runs at once.

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