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 returned observation).
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 returned observation row. |
series | {channel: list[float]} aligned to series["time_s"] — the display/plotting source. |
series_raw | Higher-density export source, aligned to its explicit time_s column — this is what GET .../export.csv reads. |
events | Timestamped run events. |
run_meta | Step counts and depletion bookkeeping. |
reporting | Accepted-step capture/selection metadata (accepted mode only). |
derived | { "n_rotors": <int> }. |
time | Explicit observation timestamps in 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-observation current channel, A
Exact accepted-step observations
With reporting.mode: "accepted_steps_v1", each returned row is an exact
observation captured at an accepted adaptive-solver step (plus required
endpoint/event rows). The time, series["time_s"], and CSV time_s arrays are
therefore explicit and generally irregular. Always use the timestamp column;
do not derive time from a row number or assume a fixed interval.
When a row budget applies, ThrustLab retains a deterministic subset of exact rows. It removes rows rather than interpolating values and calling them exact. Mission scorecard metrics and events are computed from the complete accepted observation stream before that row selection, so a removed display row cannot hide a peak or change the mission result.
Historical results produced by legacy reporting can still contain uniformly
saved rows and a run_meta.save_dt. Treat that field as legacy metadata, not as
a guarantee for every dynamic result; accepted-step mode has no fixed
save_dt.
The scorecard object
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).
The samples list
Each entry is a full steady-shaped snapshot at one returned observation time,
with 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)"
The series map
A flat {channel: list[float]} map. Every channel is aligned index-for-index to
series["time_s"], so channel i is the value at the explicit timestamp
time_s[i]. Adjacent timestamps need not be equally spaced.
| 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 observation time) |
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 the horizontal-forward flight direction at that instant — 0 = cruise, 90 = lift/hover) |
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"]
The events list
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. |
The run_meta object
| Key | Meaning | Units |
|---|---|---|
steps | Historical raw-row count field; for accepted mode prefer the explicit row counts below. | count |
save_dt | Legacy uniform reporting interval; exactly null for accepted-step reporting. | s |
reporting_mode | "accepted_steps_v1" for accepted-step results; omitted from legacy results. | — |
reporting_max_rows | Requested accepted-row budget. | count |
complete_rows | Exact observations in the complete accepted stream before row selection. | count |
retained_rows | Exact accepted observations retained in series_raw after row selection. | count |
display_budget | Target row budget for the interactive series/samples view. | count |
display_rows | Exact rows retained for the interactive series/samples view. | count |
display_budget_soft_overrun | Protected endpoint/event rows kept beyond the display target. | count |
legacy_requested_save_dt | Dense-cadence request retained as telemetry; not the spacing of accepted rows. | 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. | — |
The accepted-only top-level reporting object preserves the solver capture
metadata, including mode, complete_rows, retained_rows,
full_rhs_reconstruction_calls, avoided_full_rhs_calls, and aggregation.
Legacy results omit this block and the accepted-only run_meta keys.
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 returned observation;
accepted-step rows are exact and generally irregular; each 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
plotting source (total_thrust_n, total_current_a, rotor1_rpm,
...). Always use time_s; do not infer time from the row index.
* 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="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 (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
# Accepted-mode row counts: complete stream -> retained export -> display.
meta = blob["run_meta"]
print(
f"reporting: {meta['reporting_mode']} "
f"rows {meta['complete_rows']} -> {meta['retained_rows']} -> {meta['display_rows']}"
)
print(f"fixed save interval: {meta['save_dt']}") # None for accepted-step rows
# 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 observation — 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"\nreturned observations: {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: channels aligned to explicit, generally irregular timestamps.
series = blob["series"]
print("\n[series channels]")
print(f" available: {sorted(series.keys())}")
time_s = series["time_s"]
if len(time_s) > 1:
gaps = [b - a for a, b in zip(time_s, time_s[1:])]
print(f" observation dt range: {min(gaps):.6g} .. {max(gaps):.6g} 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.