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)"
Sample keys that used to exist only under a human-readable label now also carry a snake_case spelling.
Both spellings are present on the same object, and display_labels covers the new keys.
The label spellings are deprecated and will be removed in a future major version, so read the snake key.
| Block | Label key (deprecated) | New key |
|---|---|---|
"All" | Total Voltage (V) | total_voltage_v |
| per-rotor | Torque (Nm) | torque_nm |
"Battery" | Current Draw (A) | current_draw_a |
"Battery" | Power Draw (W) | power_draw_w |
"Battery" | Cell voltages (V) | cell_voltages_v |
"Battery" | Charge levels (%) | charge_levels_pct |
"Battery" | Internal Resistance (Ohm) | internal_resistance_ohm |
"Battery" | Configuration | configuration |
"Battery" | Remaining Capacity (mAh) | remaining_capacity_mah |
"Battery" | Est. Time Remaining (s) | est_time_remaining_s |
"Battery" | Est. Time to Reserve (s) | est_time_to_reserve_s |
"Battery" | Min Cell SOC (%) | min_cell_soc_pct |
"Battery" | Max Core Temp (C) | max_core_temp_c |
"Battery" | Pack Internal Resistance (mOhm) | pack_internal_resistance_mohm |
The last three rows appear only on dynamic samples.
A steady-state result's "Battery" entry does not carry them.
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.
Watching a run in flight
A dynamic run carries progress on the run resource and on every dynamic.updated SSE frame.
progress has this shape: {fraction, sim_time_s, sim_time_total_s, wall_s, updated_at}.
| Key | Meaning |
|---|---|
fraction | 0 to 1 for a fixed-duration mission. null or a capped estimate for a run to depletion, whose total is not known in advance. |
sim_time_s | Mission time integrated so far, seconds. |
sim_time_total_s | The mission's total duration, seconds, or null for a run to depletion. |
wall_s | Elapsed wall-clock seconds. |
updated_at | When the worker last wrote the object. |
progress is null before the worker picks the run up.
POST /v1/dynamic-simulations/estimate returns estimated_duration_s. That is simulated mission time, not wall time, and it is not a prediction of how long the run will take.
A completed run reports its wall time as run_meta.wall_s.
Poll every 2 to 5 seconds and back off when nothing changes. Async resources covers the same lifecycle for sweeps and single-point runs.
Cancelling a run
A queued dynamic run cancels immediately, with a full refund.
A running dynamic run does not stop instantly.
The cancel response keeps status: "running" and sets cancel_requested_at.
The worker stops at its next integration window, which is at most 20 seconds of MISSION time, not wall time.
On stopping, the worker keeps what it computed.
result is stored with run_meta.partial: true and run_meta.canceled_at_t_s, the mission time it stopped at.
status changes to canceled, compute units are refunded, and the execution slot frees.
A partial result carries the same scorecard, samples, series and events structure as a completed one.
It covers the mission time that was actually integrated.
The mission-level rejection check described in the next section is not applied to a canceled partial.
POST /v1/simulations/cancel-selected lists running dynamic runs under requested.
Poll those until they turn canceled.
When a run is rejected
A dynamic run is rejected with the code solver_no_convergence when more than 1 % of the observed rows failed the inner electrical solve.
The fraction is evaluated once over the whole run, at the end.
It is not a per-chunk check, so a run cannot die part-way through on a threshold that a longer flight would have absorbed.
The threshold is a server setting, so the number above is the current default, not a contract.
The failure appears as the error object on the run resource, with status: "failed".
The HTTP status of the fetch is 200.
error.details carries nonconverged_rows, observed_rows, fraction and limit.
{
"status": "failed",
"error": {
"type": "api_error",
"code": "solver_no_convergence",
"message": "dynamic inner electrical solve did not converge at 6/459 observed rows (1.3% > 1.0% limit); the reported currents and state of charge would not be physical",
"doc_url": "https://thrustlab.com/docs/guides/errors#solver_no_convergence",
"details": {
"nonconverged_rows": 6,
"observed_rows": 459,
"fraction": 0.0131,
"limit": 0.01
}
}
}
In one external study, throttle ramps starting at or near zero duty each produced 2 to 7 non-converged rows in their first milliseconds. The check uses the fraction of non-converged rows, so flight length affected the outcome. A run with 6 non-converged rows out of 459 (1.3 %) was rejected. A run with 7 out of 930 (0.75 %) completed. The study observed no such rows in a schedule that stepped straight to a working throttle.
Try starting a throttle ramp at a duty the ESC runs at, or step to the target instead of ramping from rest. Every code in the envelope is listed in Errors.
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.