Dynamic outputs
A dynamic simulation integrates the powertrain through a throttle/airspeed
schedule until a termination condition. A completed run carries a result blob
plus a sibling display_labels map (the same {snake_key: human_string} map as
a steady-state result — it applies to each per-step sample).
Result shape
result is a dict with these top-level keys:
| Key | Contents |
|---|---|
scorecard | Whole-run headline metrics. |
samples | One full steady-shaped snapshot per saved step. |
series | {channel: list[float]} aligned to series["time_s"] — the downsampled display copy (the plotting source). |
series_raw | Same shape as series, at full solver resolution (not downsampled) — this is what GET .../export.csv reads. |
events | Timestamped run events. |
run_meta | Step counts and depletion bookkeeping. |
derived | { "n_rotors": <int> }. |
time | The saved time grid, seconds (mirrors series["time_s"]). |
dyn = client.dynamic_simulations.retrieve("dyn_2c5tQ...")
blob = dyn["result"]
labels = dyn["display_labels"]
blob["scorecard"]["flight_time_s"] # run duration, seconds
blob["samples"][-1]["All"]["total_thrust_n"] # final total thrust, N
blob["series"]["total_current_a"] # per-step current channel, A
scorecard
Whole-run headline metrics (canonical snake_case).
| Key | Units | Meaning |
|---|---|---|
flight_time_s | s | Run duration (to depletion or schedule end). |
energy_wh | Wh | Energy delivered over the run. |
range_m | m | Distance covered (airspeed integral). |
peak_current_a | A | Maximum total pack current. |
min_cell_voltage_v | V | Lowest cell voltage reached. |
peak_winding_temp_c | °C | Hottest winding temperature reached. |
avg_efficiency | Fraction | Mean powertrain efficiency. |
depletion_criterion | — | Which cutoff ended the run (e.g. soc), or null. |
terminated_early | Boolean | Whether a cutoff stopped the run before the schedule ended. |
Temperature and cell-voltage entries are null when the corresponding solve did
not run (e.g. a thermal-off run has no peak_winding_temp_c).
samples
Each entry is a full steady-shaped snapshot at one saved step: the same
per-rotor ("1", "2", …), "All", and "Battery" keys as a single-point
result, and the same display_labels vocabulary. See
Steady-state outputs for the per-key tables.
first, last = blob["samples"][0], blob["samples"][-1]
print(first["All"]["total_thrust_n"], "→", last["All"]["total_thrust_n"])
print(labels["total_thrust_n"]) # "Total Thrust (N)"
series
A flat {channel: list[float]} map. Every channel is aligned index-for-index to
series["time_s"], so channel i is the value at time_s[i].
| Channel | Units |
|---|---|
time_s | s (the shared time axis) |
total_thrust_n | N |
total_current_a | A |
total_voltage_v | V |
min_cell_soc_pct | % |
max_winding_temp_c | °C |
max_core_temp_c | °C |
airspeed_ms | m/s (scheduled airspeed at the saved instant) |
vertical_speed_ms | m/s, signed (scheduled vertical speed; 0 for a schedule with no vertical_speed_target) |
Per-rotor channels are named rotor<N>_<field> for each rotor group, when the
engine supplied them:
| Channel | Units |
|---|---|
rotor1_rpm | rev/min |
rotor1_current_a | A |
rotor1_thrust_n | N |
rotor1_motor_v | V |
rotor1_torque_nm | N·m |
rotor1_shaft_w | W |
rotor1_aero_w | W (propeller aero power) |
rotor1_elec_w | W |
rotor1_t_w_c | °C (winding) |
rotor1_t_mag_c | °C (magnet) |
rotor1_r_th_k_per_w | K/W (effective cooled thermal resistance at that instant) |
rotor1_cooling_v_ms | m/s (motor cooling-air velocity at that instant) |
rotor1_throttle_pct | % |
rotor1_tilt_deg | deg (rotor-axis tilt from vertical at that instant) |
rotor1_v_axial_ms | m/s (decomposed axial inflow at that instant) |
rotor1_v_edge_ms | m/s (decomposed edgewise inflow at that instant, ≥ 0) |
Read the available channels off the keys rather than hard-coding them — a thermal-off or single-rotor run omits the channels it did not compute:
series = blob["series"]
print(sorted(series.keys()))
t, thrust = series["time_s"], series["total_thrust_n"]
events
A list of timestamped events. Each entry is
{ "t": <seconds>, "type": <str>, "severity": <str>, "detail": <dict> }.
type | severity | Detail |
|---|---|---|
depletion | info | { "criterion": ... } — the run hit a cutoff. |
segment_boundary | info | A schedule segment transition. |
in_rush_peak | info | { "current_a": ... } — timestamped max current. |
thermal_threshold | warning | { "node": winding|magnet|core, "limit_c": ... }. |
step_cap_hit | info | The step ceiling was reached without a cutoff. |
non_convergence | warning | A non-finite sample was detected. |
run_meta
| Key | Meaning | Units |
|---|---|---|
steps | Number of integrated steps (full resolution). | count |
save_dt | Save interval. | s |
depletion_t | Time of depletion (or null). | s |
depletion_criterion | Which cutoff tripped (or null). | — |
terminated_early | Whether a cutoff stopped the run early. | — |
step_cap_hit | Whether the step ceiling was reached. | — |
n_chunks | Number of continuation windows the run was integrated in (an until-depleted run that outlives one window continues in more; a fixed-duration run is always 1). | count |
max_sim_time_capped | Whether the run hit the server's hard wall-time cap and stopped before true depletion. | — |
Worked example
Runs a dynamic simulation, then reads the scorecard, a couple of series channels, and the events:
"""Read a dynamic (time-domain) result: samples, time-series, and the scorecard.
Run it:
export THRUSTLAB_API_KEY=key_... # never hard-code the key
python examples/outputs/read_dynamic.py
A completed dynamic run integrates the powertrain through a throttle/airspeed
schedule until a termination condition. Its `result` blob carries:
* scorecard — whole-run headline metrics (flight_time_s, peak_current_a,
min_cell_voltage_v, peak_winding_temp_c, avg_efficiency, ...).
* samples[] — one full steady-shaped snapshot per saved step; each entry has
the SAME canonical per-rotor / "All" / "Battery" keys as a
single-point result, so display_labels applies to it too.
* series — {channel_name: list[float]} aligned to series["time_s"], the
down-sampled plotting source (total_thrust_n, total_current_a,
rotor1_rpm, ...).
* events / run_meta — timestamped run events + step/depletion bookkeeping.
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 read-dynamic example")
# Resolve by name, or paste explicit IDs: motor_id = "comp_motor_xxx"
motor = client.components.find(name="Spektrum Avian 4260 800Kv")
prop = client.components.find(name="10.5x4.5")
battery = client.components.find(name="Liperior 5000mAh 4S 35C")
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 (soft-start — a throttle step onto a stationary
# rotor sags the pack below the low-voltage cutoff), then hold for 20 s.
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}},
},
],
},
termination={"mode": "fixed"},
)
result = client.dynamic_simulations.wait(dyn["id"], timeout=600)
print(f"dynamic status: {result['status']}")
if result["status"] == "completed":
blob = result["result"]
labels = result["display_labels"] # applies to each samples[] entry
# scorecard: whole-run headline metrics (canonical snake_case).
print("\n[scorecard]")
for key, value in blob["scorecard"].items():
print(f" {key:<24} {value}")
# samples[]: each entry is a full steady-shaped snapshot — read the same
# per-rotor / "All" keys (and display_labels) as a single-point result.
samples = blob["samples"]
first, last = samples[0], samples[-1]
print(f"\nsteps: {len(samples)}")
print(f" t0 total_thrust_n: {first['All']['total_thrust_n']:.2f} "
f"({labels['total_thrust_n']})")
print(f" tN total_thrust_n: {last['All']['total_thrust_n']:.2f}")
# series: down-sampled channels aligned to series["time_s"]. Print a couple.
series = blob["series"]
print("\n[series channels]")
print(f" available: {sorted(series.keys())}")
time_s = series["time_s"]
thrust = series.get("total_thrust_n", [])
current = series.get("total_current_a", [])
print("\n t (s) | total_thrust_n | total_current_a")
for i in range(0, len(time_s), max(1, len(time_s) // 5)):
t = time_s[i]
th = thrust[i] if i < len(thrust) else float("nan")
cu = current[i] if i < len(current) else float("nan")
print(f" {t:6.1f} | {th:>14.2f} | {cu:>15.2f}")
# events: timestamped run events (depletion, in_rush_peak, thermal_threshold).
print("\n[events]")
for ev in blob["events"]:
print(f" t={ev['t']:.1f}s {ev['type']:<18} {ev['severity']:<8} {ev['detail']}")
See also
- Steady-state outputs — the per-sample key tables.
- Async resources — polling vs webhooks for long runs.