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:

Result shape
KeyContents
scorecardWhole-run headline metrics.
samplesOne full steady-shaped snapshot per returned observation row.
series{channel: list[float]} aligned to series["time_s"] — the display/plotting source.
series_rawHigher-density export source, aligned to its explicit time_s column — this is what GET .../export.csv reads.
eventsTimestamped run events.
run_metaStep counts and depletion bookkeeping.
reportingAccepted-step capture/selection metadata (accepted mode only).
derived{ "n_rotors": <int> }.
timeExplicit 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).

The scorecard object
KeyUnitsMeaning
flight_time_ssRun duration (to depletion or schedule end).
energy_whWhEnergy delivered over the run.
range_mmDistance covered (airspeed integral).
peak_current_aAMaximum total pack current.
min_cell_voltage_vVLowest cell voltage reached.
peak_winding_temp_c°CHottest winding temperature reached.
avg_efficiencyFractionMean powertrain efficiency.
depletion_criterionWhich cutoff ended the run (e.g. soc), or null.
terminated_earlyBooleanWhether 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.

The series map
ChannelUnits
time_ss (the shared time axis)
total_thrust_nN
total_current_aA
total_voltage_vV
min_cell_soc_pct%
max_winding_temp_c°C
max_core_temp_c°C
airspeed_msm/s (scheduled airspeed at the observation time)
vertical_speed_msm/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:

The series map (table 2)
ChannelUnits
rotor1_rpmrev/min
rotor1_current_aA
rotor1_thrust_nN
rotor1_motor_vV
rotor1_torque_nmN·m
rotor1_shaft_wW
rotor1_aero_wW (propeller aero power)
rotor1_elec_wW
rotor1_t_w_c°C (winding)
rotor1_t_mag_c°C (magnet)
rotor1_r_th_k_per_wK/W (effective cooled thermal resistance at that instant)
rotor1_cooling_v_msm/s (motor cooling-air velocity at that instant)
rotor1_throttle_pct%
rotor1_tilt_degdeg (rotor-axis tilt from the horizontal-forward flight direction at that instant — 0 = cruise, 90 = lift/hover)
rotor1_v_axial_msm/s (decomposed axial inflow at that instant)
rotor1_v_edge_msm/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> }.

The events list
typeseverityDetail
depletioninfo{ "criterion": ... } — the run hit a cutoff.
segment_boundaryinfoA schedule segment transition.
in_rush_peakinfo{ "current_a": ... } — timestamped max current.
thermal_thresholdwarning{ "node": winding|magnet|core, "limit_c": ... }.
step_cap_hitinfoThe step ceiling was reached without a cutoff.
non_convergencewarningA non-finite sample was detected.

The run_meta object

The run_meta object
KeyMeaningUnits
stepsHistorical raw-row count field; for accepted mode prefer the explicit row counts below.count
save_dtLegacy 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_rowsRequested accepted-row budget.count
complete_rowsExact observations in the complete accepted stream before row selection.count
retained_rowsExact accepted observations retained in series_raw after row selection.count
display_budgetTarget row budget for the interactive series/samples view.count
display_rowsExact rows retained for the interactive series/samples view.count
display_budget_soft_overrunProtected endpoint/event rows kept beyond the display target.count
legacy_requested_save_dtDense-cadence request retained as telemetry; not the spacing of accepted rows.s
depletion_tTime of depletion (or null).s
depletion_criterionWhich cutoff tripped (or null).
terminated_earlyWhether a cutoff stopped the run early.
step_cap_hitWhether the step ceiling was reached.
n_chunksNumber 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_cappedWhether 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