# ThrustLab > Physics-accurate electric-UAV powertrain simulator (battery → ESC → motor → propeller), > multi-fidelity, whose propeller aerodynamics are validated against the UIUC propeller > wind-tunnel database. ## Docs Generated from the ThrustLab documentation sources by frontend/scripts/build-llms-full.mjs. Index: https://thrustlab.com/llms.txt · OpenAPI: https://thrustlab.com/openapi.json --- # /docs/quickstart Source: https://thrustlab.com/docs/quickstart {/* 2026-08-17: this route exported NO metadata, so it had no canonical and its fell back to the docs layout default. MDX pages take a plain `export const metadata`, same contract as the sibling .tsx routes — and an `import` at the top level too, which is how buildMetadata reaches this file. That replaces the inlined NEXT_PUBLIC_SITE_URL expression and adds og:url, og:type and og:site_name alongside the canonical. Added to DOCS_STATIC_PATHS in the same change — the missing metadata and the missing sitemap entry were one omission. */} export const metadata = buildMetadata({ title: "Quickstart: your first simulation", description: "Install the thrustlab Python package, mint an API key in the dashboard, and run your first powertrain simulation in about five minutes.", path: "/docs/quickstart", }); # Quickstart In five minutes you'll install the SDK, get an API key, and run your first simulation. ## 1. Install ```bash pip install thrustlab ``` Requires Python 3.10+. ## 2. Get an API key Sign in to [the dashboard](/dashboard/api-keys), open **API Keys**, and create a new key. Copy the value — it is shown exactly once. ```bash export THRUSTLAB_API_KEY=key_... ``` Or pass it directly in code: ```python from thrustlab import Client client = Client(api_key="key_...") ``` ## 3. Run your first simulation Source: backend/sdks/python/examples/quickstart.py ```python """Quickstart: install thrustlab, set $THRUSTLAB_API_KEY, run this file. export THRUSTLAB_API_KEY=key_... # never hard-code the key python examples/quickstart.py """ from itertools import islice from thrustlab import Client client = Client() # reads $THRUSTLAB_API_KEY from the environment project = client.projects.create(name="my first project") print(f"created project {project['id']}") print("first 5 motors:") for component in islice(client.components.list(type="motor"), 5): brand = component["spec_json"].get("brand", "?") print(f" {component['id']} — {brand} {component['name']}") # One-hit lookup: find() returns the single match (or raises # AmbiguousComponentError on >1, NotFoundError on 0) so you don't have to # round-trip the list yourself. Or paste explicit IDs from the catalog. 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" ) # Run a single-point simulation and wait for the result. sim = client.simulations.create( project_id=project["id"], battery_component_id=battery["id"], airspeed_m_s=0.0, density_kg_m3=1.225, battery_charge_pct=100, rotor_groups=[ { "label": "main", "count": 4, "motor_component_id": motor["id"], "propeller_component_id": prop["id"], "throttle_pct": 70, } ], ) result = client.simulations.wait(sim["id"], timeout=300) print(f"status: {result['status']}") if result["status"] == "completed": # Canonical snake_case keys: per-rotor under "1", roll-ups under "All". print(f"per-rotor thrust: {result['result']['1']['thrust_n']:.2f} N") print(f"total thrust: {result['result']['All']['total_thrust_n']:.2f} N") ``` ### Expected output ``` created project proj_... first 5 motors: comp_2NqT8f... — SunnySky X2216-12 comp_9fQmZ2... — T-Motor MN3110 ... ``` ## Where to go next - [API reference](/docs/reference) — every endpoint, schema, and error code. - [Authentication guide](/docs/guides/authentication) — API keys vs JWT, key rotation, rate limits. - [Async resources guide](/docs/guides/async-resources) — polling vs webhooks for long-running simulations and sweeps. - [Python SDK reference](/docs/sdk/python) — full install, configure, and usage guide. --- # /docs/guides/authentication Source: https://thrustlab.com/docs/guides/authentication # Authentication ThrustLab's `/v1/` API uses bearer-token authentication with two interchangeable credentials. ## API keys **Create:** from the dashboard's [API Keys page](/dashboard/api-keys) — there is no public endpoint for minting keys (a key can't be used to create another key). The dashboard shows the raw `key` value **exactly once**. Store it immediately; it cannot be retrieved later. Only the first 8 and last 4 characters remain visible for identification in the dashboard afterward. **Use:** every `/v1/` request must include: ``` Authorization: Bearer key_... ``` **List, retrieve, revoke:** manage keys from the dashboard's [API Keys page](/dashboard/api-keys). **Rotation:** create a new key, deploy it to your client, then revoke the old key. Dual-key rotation with a grace period is not currently supported — but the three-step manual flow takes under a minute. **Compromise:** revoke the affected key immediately from the dashboard's [API Keys page](/dashboard/api-keys). Revocation is eventually consistent for in-flight requests; the next handshake after revocation is rejected. ## Web session JWTs (first-party only) The ThrustLab web app (`/dashboard/*`) authenticates against `/v1/` using the same `Authorization: Bearer` header but with Django-issued JWTs. This path is only used by the first-party frontend; third-party integrations must use API keys. ## Errors All authentication failures return HTTP 401 with a Stripe-style error envelope: | `code` | Meaning | |---|---| | `missing_authorization` | No `Authorization` header was sent | | `malformed_authorization` | Header present but not in `Bearer ` form | | `invalid_credential_format` | Bearer value is neither a JWT nor `key_`-prefixed | | `invalid_api_key` | API key not found, revoked, or owned by an inactive user | | `invalid_jwt` | JWT failed decode, signature check, or expiration | | `invalid_jwt_subject` | JWT decoded, but the `sub` claim does not resolve to an active user | The same code (`invalid_api_key`) is returned for "doesn't exist", "revoked", and "owner deactivated" — this is deliberate, to prevent enumeration attacks. ## Rate limits See the Rate limiting section in the stability policy. Current limits: 1000 req/min per credential with a 50-request burst. `/v1/health` is exempt. --- # /docs/guides/ai-agents Source: https://thrustlab.com/docs/guides/ai-agents # Using the API from an AI coding agent ## Machine-readable surfaces | URL | What it is | |---|---| | `https://thrustlab.com/openapi.json` | The full OpenAPI document. Generate a client from it. | | `https://thrustlab.com/llms.txt` | A one-line-per-page index of the documentation. | | `https://thrustlab.com/llms-full.txt` | Every documentation page as one plain-text file. | | `https://thrustlab.com/docs/guides/errors#` | The row for one error code. Every error's `doc_url` is this URL. | An agent that hits an error can fetch the `doc_url` it was given and read that code's row without searching. The base URL is `https://thrustlab.com`, so every endpoint is `https://thrustlab.com/v1/...`. The Python SDK reads `THRUSTLAB_API_KEY` from the environment. `THRUSTLAB_BASE_URL` overrides the base URL. ## Drop this into your agent's instructions Put this block in a project's `AGENTS.md` or `CLAUDE.md`. ```markdown ## ThrustLab API Read the key from the `THRUSTLAB_API_KEY` environment variable. Never write a key into a file, a commit, or a log line. Use base URL `https://thrustlab.com`; every endpoint is under `/v1/`. Send `Authorization: Bearer $THRUSTLAB_API_KEY`. Prefer the Python SDK (`pip install thrustlab`) over hand-rolled HTTP. Send an `Idempotency-Key` header on every POST, PATCH and DELETE. Reuse the same key when retrying the same logical write; a different body under the same key is rejected with 409. Poll async runs every 2 to 5 seconds and back off when nothing changes. Never poll in a tight loop. On any 429, wait the number of seconds in the `Retry-After` header. Do not use a fixed backoff: the four budgets have windows of a minute, an hour and a day. Submission limits, per account, per minute: 30 to `POST /v1/sweeps`, 30 to `POST /v1/dynamic-simulations`, 120 to `POST /v1/simulations`. To evaluate many combinations, send ONE sweep with a `component_axes` axis rather than a loop of single-point runs. A loop burns the submission limit and recompiles the solver for every point. `sweep..steps` is the NUMBER OF POINTS, not a step size: {"start": 40, "stop": 100, "steps": 4} gives 40, 60, 80, 100. Do not scrape the component catalog. `GET /v1/components` filters and sorts server-side (`kv[gte]`, `resistance[lte]`, `sort_by`), and `ids_only=true` returns up to 5000 ids in one response with a `truncated` flag. `GET /v1/components/{id}/specs` is metered at 60 distinct components an hour and 300 a day, so read it only for rows you have already narrowed to. For a dynamic run, read `progress` on the resource to see how far it has got. The `estimated_duration_s` from `/estimate` is simulated mission time, not wall time. To stop work, call `POST /v1/simulations/cancel-selected` with the run ids rather than cancelling one at a time. Ids returned under `requested` are still running; poll them until they turn `canceled`. Every error carries `type`, `code`, `request_id` and `doc_url`. Branch on `code`, log `request_id`, and fetch `doc_url` when the code is unfamiliar. Treat ramps from near-zero duty as a risk: one external study observed non-converged rows in their first milliseconds, enough of a short flight's rows to trigger `solver_no_convergence`. Start ramps at a duty the ESC runs at, or step to the target. ``` ## One sweep beats a loop of single points A `component_axes` entry is `{"axis": "propeller"|"motor"|"battery", "slots": [, ...], "component_ids": [...]}` and needs at least two ids. It crosses with the numeric axes, so four propellers and four throttles is one 16-point grid rather than sixteen requests. Each returned point names the component it was solved with in `point["inputs"]["component"]`, a list of `{axis, slot, id, name}`. A sweep returns names and ids only, never a catalog component's spec values. `sweep_config.component_axes[]` on the sweep resource mirrors the same structure: `{axis, slot, slots, components: [{id, name}]}`. Source: backend/sdks/python/examples/sweeps/component_axis_grid.py ```python """Component axis x throttle: auth -> submit -> wait() -> inspect points. Run it: export THRUSTLAB_API_KEY=key_... # never hard-code the key python examples/sweeps/component_axis_grid.py Crosses a propeller component axis with a throttle range, so one request solves every propeller at every throttle. It takes the first four catalog propellers from client.components.list(type="propeller") and crosses them with four throttle settings: 16 points in one submission. Each returned point names the propeller that produced it at point["inputs"]["component"], a list of {axis, slot, id, name}. Sixteen single-point submissions would be sixteen requests against the per-minute submission limit and sixteen cold solves; this is one request and one warm solver. `sweep.throttle.steps` is the NUMBER OF POINTS, not a step size. """ from thrustlab import Client client = Client() # reads $THRUSTLAB_API_KEY from the environment project = client.projects.create(name="sdk component-axis example") # The fixed half of the configuration — same verification combo as # examples/sweeps/run_and_poll.py. Resolve by name, or paste explicit IDs. motor = client.components.find(name="BadAss 2826-820Kv") battery = client.components.find( name="Liperior 5000mAh 4S 35C 14.8V Lipo Battery With XT90 Plug" ) # The swept half: the first four catalog propellers. `.data` is the first page # of the cursor pager, so this is one request. A component axis needs at least # two IDs — a one-value axis is not a sweep, and the server rejects it with # `invalid_component_axis`. props = client.components.list(type="propeller", limit=4).data prop_ids = [p["id"] for p in props] print("propeller axis:", ", ".join(p["name"] for p in props)) sweep = client.sweeps.create( project_id=project["id"], battery_component_id=battery["id"], airspeed_m_s=0.0, density_kg_m3=1.225, battery_charge_pct=100, # `propeller_component_id` here is the BASE value for slot 0. The component # axis below overrides it at every point, so it only has to be valid. rotor_groups=[ { "label": "main", "count": 4, "motor_component_id": motor["id"], "propeller_component_id": prop_ids[0], "throttle_pct": 70, } ], sweep={ # One bool per rotor group: this group follows the swept throttle axis. "rotor_sweep_mask": [True], # `steps` is the NUMBER OF POINTS, not a step size: 40, 60, 80, 100. "throttle": {"mode": "range", "start": 40, "stop": 100, "steps": 4}, # `slots` indexes rotor_groups. Two or more slots would make this ONE # shared grid dimension applied to each of them, not a cross-product. "component_axes": [ {"axis": "propeller", "slots": [0], "component_ids": prop_ids}, ], }, ) print(f"created {sweep['id']}: {sweep['total_points']} points in one request") result = client.sweeps.wait(sweep["id"], timeout=900) print(f"final status: {result['status']}") if result["status"] == "completed": print("\npropeller | throttle | thrust/rotor (N)") for pt in client.sweeps.list_points(sweep["id"]): # `inputs["component"]` names the component(s) this point was solved # with: one {axis, slot, id, name} entry per active component axis. # It carries names and IDs only — never the component's spec values. chosen = ", ".join(c["name"] for c in pt["inputs"]["component"]) thrust = pt["rotors"]["1"]["thrust_n"] print(f" {chosen:<18} | {pt['inputs']['throttle']:>7}% | {thrust:.2f}") ``` The sweep resource carries `credits_breakdown: {points, rotors, cu_per_point_per_rotor, total}`, so the cost of a grid is readable before and after the run rather than inferred from the balance. `input_snapshot` on a sweep is the request body as submitted, so an agent can read back exactly what it asked for instead of keeping its own copy. ## What the errors mean The full catalog is at [Errors](/docs/guides/errors), one row per code, and every error's `doc_url` deep-links to its row. | `code` | What to do | |---|---| | `create_rate_limited` | Wait `Retry-After` seconds. Consider one sweep instead of many submissions. | | `spec_rate_limited` | The datasheet budget is spent. Use the list projection instead of `/specs`. | | `unknown_filter` | The filter key is not one this component type carries. The message lists the valid keys. | | `solver_no_convergence` | The run was rejected. Check `error.details`, and start throttle ramps above the ESC's minimum duty. | See [Rate limits](/docs/guides/rate-limits) for the budgets and [Async resources](/docs/guides/async-resources) for the run lifecycle. --- # /docs/guides/errors Source: https://thrustlab.com/docs/guides/errors # Errors Every `/v1/` API error uses one envelope and one stable `code` value. ## Envelope shape ```json { "error": { "type": "invalid_request_error", "code": "unknown_filter", "message": "'resistence' is not a filterable field on a motor. Filterable fields: current_max (or iq_max), kv, poles (or n), resistance (or R), weight.", "param": "resistence[lte]", "request_id": "req_2c5tQ...", "doc_url": "https://thrustlab.com/docs/guides/errors#unknown_filter" } } ``` | Field | Notes | |---|---| | `type` | The coarse taxonomic class for the error. | | `code` | The stable machine-readable string for the specific failure. | | `message` | Human-readable explanation. `message` may be reworded, so never branch on it. | | `param` | The request field that caused the error when present. `null` otherwise. | | `request_id` | The `req_` correlator to quote in a support ticket. | | `doc_url` | A link to the specific code row: `https://thrustlab.com/docs/guides/errors#`. | Some codes carry extra fields as siblings of the canonical ones. `credits_insufficient` carries `required_amount` and `available_balance`. `spec_rate_limited` carries `retry_after_s`, `budget_per_hour`, `budget_per_day`, `remaining_hour`, `remaining_day`. `create_rate_limited` carries `retry_after_s`, `limit_per_minute`, `kind`. `solver_no_convergence` carries `error.details` with `nonconverged_rows`, `observed_rows`, `fraction`, and `limit`. ## Error types | Type | HTTP status | Retryable? | When | |---|---|---|---| | `invalid_request_error` | 400 / 422 | No | Malformed input, validation failure, unknown enum value, or (422) a semantic precondition failure on an otherwise well-formed request. | | `authentication_error` | 401 | No | Missing, malformed, or invalid credential. | | `permission_error` | 403 | No | Authenticated but not authorized for this resource. | | `not_found_error` | 404 | No | Resource does not exist or is not visible to the caller. | | `conflict_error` | 409 | No (without input change) | Request conflicts with current resource state. | | `idempotency_error` | 409 | No (without a new key or matching body) | `Idempotency-Key` reused with a different request body. | | `rate_limit_error` | 429 | Yes (after `Retry-After`) | The flat per-credential request bucket, the per-account submission limit, the free-tier catalog filter budget, or the distinct-component datasheet budget. | | `insufficient_credits_error` | 402 | No (until balance changes) | Compute action requires more compute units than available. | | `api_error` | 500 / 503 | Yes | Server-side fault. The two expected 503 variants are `billing_not_configured` and `read_only_maintenance`. | Every 429 response includes a `Retry-After` header in seconds. The four 429 codes are `rate_limit_exceeded`, `create_rate_limited`, `filter_rate_limited`, and `spec_rate_limited`. See [rate limits](/docs/guides/rate-limits) for what each budget counts. Four codes never appear as HTTP statuses: `solver_no_convergence`, `solver_failure`, `propeller_geometry_missing`, and `sweep_points_persist_failed`. Those are in the `error` object on a RUN RESOURCE whose `status` is `"failed"`. Fetching that run returns HTTP 200 with the failure inside it. A client polling a run reads `status` first, then `error`. ## Code catalog Each row is generated from the server's own code registry, and each row `id` is the anchor an error's `doc_url` points at. ### `invalid_request_error` (400 / 422) | Code | HTTP | Retryable | What it means | |---|---|---|---| | `VALIDATION_ERROR` | 422 | No | A well-formed request violates a domain rule; the message names it. | | `airfoil_fit_failed` | 422 | No | The uploaded coordinates could not be fitted to a usable section. | | `already_dispatched` | 400 | No | That run was already handed to a worker and cannot be dispatched again. | | `dat_too_large` | 400 | No | The uploaded airfoil coordinate file is above the size limit. | | `dynamic_limit_exceeded` | 422 | No | Rotors times save steps exceeds the plan's dynamic ceiling. | | `events_required` | 400 | No | A webhook endpoint must subscribe to at least one event type. | | `geometry_not_loftable` | 422 | No | The blade sections cannot be lofted into a solid. | | `idempotency_key_required` | 400 | No | Every write request needs an Idempotency-Key header. | | `idempotency_key_too_long` | 400 | No | The Idempotency-Key header is longer than 255 characters. | | `invalid_analysis_request` | 400 | No | The geometry analysis request is missing or contradicts a required field. | | `invalid_component_axis` | 400 | No | A sweep component axis targets an out-of-range rotor slot or mixes component types. | | `invalid_component_id` | 400 | No | A component id is malformed or names a component of the wrong type. | | `invalid_created_at` | 400 | No | A `created_at` filter is not a valid timestamp. | | `invalid_cursor` | 400 | No | The pagination cursor is malformed, or was issued for a different sort than the one requested. | | `invalid_dat` | 400 | No | The uploaded airfoil coordinate file could not be parsed. | | `invalid_email` | 400 | No | The supplied email address is not a valid address. | | `invalid_filter_bounds` | 400 | No | On the free tier, catalog range filters must land on the published preset bounds. | | `invalid_filter_value` | 400 | No | A range-filter value is not a finite number. | | `invalid_geometry` | 422 | No | A propeller geometry is outside the bounds the solver accepts. | | `invalid_limit` | 400 | No | The `limit` parameter is outside the accepted range. | | `invalid_pack_component` | 422 | No | A battery in the pack topology is missing a required cell property. | | `invalid_project` | 400 | No | The project id is malformed or names no project this account owns. | | `invalid_request` | 400 | No | A query parameter or body field is outside its accepted range or vocabulary. | | `invalid_rotation` | 400 | No | A propeller rotation label is outside the accepted vocabulary. | | `invalid_schedule` | 422 | No | The dynamic control schedule is incomplete or inconsistent. | | `invalid_status` | 400 | No | The run is not in a status this action accepts. | | `invalid_status_filter` | 400 | No | The `status` filter is not one of the published run statuses. | | `invalid_sweep_config` | 400 | No | The sweep axes do not form a valid grid; the message names the failing axis. | | `pack_cell_limit_exceeded` | 422 | No | The pack topology holds more cells than the engine supports. | | `pack_too_large_for_dynamic` | 422 | No | The pack is above the cell ceiling for a transient run; use a steady-state run or a smaller pack. | | `parallel_voltage_mismatch` | 422 | No | Packs wired in parallel must have the same series cell count. | | `request_body_too_large` | 400 | No | The request body exceeds the maximum size accepted for an idempotent request. | | `request_validation_failed` | 400 | No | The request body or query string failed schema validation; `param` names the offending field. | | `rotor_limit_exceeded` | 422 | No | The run has more rotors than the plan allows. | | `simulation_not_exportable` | 422 | No | This run's shape is not supported by the requested export format. | | `sweep_limit_exceeded` | 422 | No | Rotors times points exceeds the plan's sweep ceiling. | | `sweep_too_large` | 400 | No | The sweep grid exceeds the 10,000-point ceiling for one submission. | | `sweep_too_small` | 400 | No | A sweep must define at least two evaluation points. | | `unknown_airfoil` | 422 | No | An airfoil reference names no airfoil this account can use. | | `unknown_event_type` | 400 | No | A subscribed event type is not one the API emits. | | `unknown_filter` | 400 | No | A bracket filter names a field this component type does not have; the message lists the fields it does have. | | `webhook_url_dns_failure` | 400 | Yes | The webhook hostname did not resolve at the time of the request. | | `webhook_url_invalid` | 400 | No | The webhook URL has no hostname. | | `webhook_url_not_https` | 400 | No | A webhook URL must use https. | | `webhook_url_not_publicly_routable` | 400 | No | The webhook hostname resolves to a private or reserved address. | ### `authentication_error` (401) | Code | HTTP | Retryable | What it means | |---|---|---|---| | `UNAUTHORIZED` | 401 | No | The credential was rejected on a first-party endpoint. | | `invalid_api_key` | 401 | No | The API key is unknown, revoked, or belongs to a disabled account. | | `invalid_jwt` | 401 | No | The bearer token is malformed, expired, or not an access token. | | `invalid_jwt_subject` | 401 | No | The token is valid but its subject is not an active user. | | `missing_authorization` | 401 | No | The request carried no Authorization header. | ### `permission_error` (403) | Code | HTTP | Retryable | What it means | |---|---|---|---| | `FORBIDDEN` | 403 | No | The caller may not act on this resource. | | `challenge_failed` | 403 | No | The verification challenge token was not accepted. | | `challenge_required` | 403 | No | Reading a catalog datasheet from a browser session needs a verification challenge; API-key callers never see this. | | `email_unverified` | 403 | No | The account must verify its email address before running a simulation. | | `permission_denied` | 403 | No | The caller may not modify this resource. | | `pro_required` | 403 | No | Multi-pack battery topology is a Pro feature. | ### `not_found_error` (404) | Code | HTTP | Retryable | What it means | |---|---|---|---| | `NOT_FOUND` | 404 | No | The referenced record does not exist or is not visible to this account. | | `airfoil_not_found` | 404 | No | No airfoil with that id is visible to this account. | | `component_not_found` | 404 | No | No component with that id is visible to this account. | | `credit_transaction_not_found` | 404 | No | No credit transaction with that id exists. | | `event_not_found` | 404 | No | No event with that id belongs to this account. | | `export_expired` | 404 | Yes | The export download link has expired; request the export again. | | `geometry_style_not_found` | 404 | No | No geometry style with that id exists. | | `not_found` | 404 | No | No published resource matches that identifier. | | `project_not_found` | 404 | No | No project with that id belongs to this account. | | `resource_missing` | 404 | No | No run with that id belongs to this account. | | `starred_component_not_found` | 404 | No | That component is not starred in this project. | | `submission_not_found` | 404 | No | No component submission with that id exists. | | `user_not_found` | 404 | No | The authenticated user record could not be loaded. | | `webhook_delivery_not_found` | 404 | No | No webhook delivery with that id belongs to this account. | | `webhook_endpoint_not_found` | 404 | No | No webhook endpoint with that id belongs to this account. | ### `conflict_error` (409) | Code | HTTP | Retryable | What it means | |---|---|---|---| | `CONFLICT` | 409 | No | The resource is not in a state that allows this action. | | `active_simulation` | 409 | No | The simulation is queued or running; cancel it before deleting. | | `active_sweep` | 409 | No | The sweep is queued or running; cancel it before deleting. | | `already_running` | 409 | No | The run is already executing. | | `already_starred` | 409 | No | The component is already starred in this project. | | `already_terminal` | 409 | No | The run has already finished, failed, or been canceled. | | `component_in_use` | 409 | No | The component is referenced by saved simulations; delete those first. | | `delivery_not_terminal` | 409 | No | The webhook delivery is still in flight; wait for it to settle before replaying. | | `export_in_progress` | 409 | Yes | An export of this run is already building; poll instead of starting another. | | `export_not_ready` | 409 | Yes | The export is still building; poll the run until it is ready. | | `idempotency_request_in_progress` | 409 | Yes | A request with this Idempotency-Key is still being processed; retry shortly to receive its result. | | `project_has_active_runs` | 409 | No | The project has runs that are still queued or running; cancel or wait for them before deleting it. | | `submission_already_reviewed` | 409 | No | The submission has already been accepted or rejected. | ### `idempotency_error` (409) | Code | HTTP | Retryable | What it means | |---|---|---|---| | `idempotency_key_reused_with_different_body` | 409 | No | The Idempotency-Key was already used with a different request body. | ### `rate_limit_error` (429) | Code | HTTP | Retryable | What it means | |---|---|---|---| | `create_rate_limited` | 429 | Yes | The account submitted more runs of this kind in one minute than the submission limit allows; the response carries the limit and the wait. | | `filter_rate_limited` | 429 | Yes | The free tier's filtered catalog-query budget for this hour is spent. | | `rate_limit_exceeded` | 429 | Yes | The per-credential request bucket is empty; wait for `Retry-After` seconds. | | `spec_rate_limited` | 429 | Yes | The account has read datasheets for its budget of distinct components; the response carries the remaining budget and the reset. | | `too_many_streams` | 429 | Yes | This account has too many open live streams; close some before opening another. | ### `insufficient_credits_error` (402) | Code | HTTP | Retryable | What it means | |---|---|---|---| | `INSUFFICIENT_CREDITS` | 402 | No | The account does not hold enough credits for this run. | | `credits_insufficient` | 402 | No | The account does not hold enough credits for this run. | | `upgrade_required` | 402 | No | The action needs a higher plan; the response names the gate and the cheapest plan that grants it. | ### `api_error` (503 / in the run resource) | Code | HTTP | Retryable | What it means | |---|---|---|---| | `billing_not_configured` | 503 | Yes | Billing is not configured on this server. | | `propeller_geometry_missing` | in the run resource | No | The propeller spec does not describe a blade the solver can build. | | `read_only_maintenance` | 503 | Yes | New runs are paused for maintenance; reads and drafts still work. | | `solver_failure` | in the run resource | No | The run failed for a reason with no more specific code; the details stay in the server logs. | | `solver_no_convergence` | in the run resource | No | Too many points of the run did not converge for the results to be physical, so the run was rejected and the credits refunded. | | `sweep_points_persist_failed` | in the run resource | No | The sweep solved but its point rows could not be stored, so the results are incomplete. | ## Reading errors in client code The recommended pattern across every official SDK: 1. Branch on `type` for the high-level class of failure (auth vs validation vs not-found vs rate-limit). 2. Branch on `code` only when the user-visible path differs within that type. 3. Treat the `code` set as open, and fall back to the `type` handler for unknown codes. 4. Always log `request_id`. 5. On 429, wait the number of seconds in `Retry-After`. Do not use a fixed backoff. For rate-limit and idempotency rules, see [stability policy](/docs/guides/stability-policy). For the 402 insufficient-balance envelope, see [compute units](/docs/guides/compute-units), and for rate budgets, see [rate limits](/docs/guides/rate-limits). --- # /docs/guides/idempotency Source: https://thrustlab.com/docs/guides/idempotency # Idempotency Every mutating request to `/v1/` (POST, PATCH, DELETE) accepts an `Idempotency-Key` header. Replays of the same key within 24 hours return the original response; the side effect happens at most once. The official Python SDK auto-generates a UUID v4 idempotency key on every mutating request. To override (e.g. when retrying from your own job queue): If the same key is replayed with a *different* request body, the API returns HTTP 409 with type `idempotency_error` and code `idempotency_key_reused_with_different_body`. Choose keys that uniquely identify the logical operation. Keys longer than 255 characters are rejected with HTTP 400 and code `idempotency_key_too_long`. The key identifies the whole operation. Its fingerprint includes the request method, path, query and body. Reusing a key for a different endpoint or method, including a cancel or delete with no body, returns HTTP 409 with code `idempotency_key_reused_with_different_body`. If requests with the same `Idempotency-Key` are in flight at once, exactly one runs. The others receive HTTP 409 with code `idempotency_request_in_progress`. Retry shortly to receive the first request's stored result. Retrying is safe: the operation still happens at most once. Refusals for insufficient credits (HTTP 402), an unverified email or a required plan upgrade (HTTP 403) are not stored under the key. After adding credits, verifying your email or upgrading, retry with the same key to re-evaluate the request. HTTP 429 and server 5xx responses are never cached either. --- # /docs/guides/pagination Source: https://thrustlab.com/docs/guides/pagination # Pagination All list endpoints return cursor-paginated results: ```json { "object": "list", "data": [...], "has_more": true, "next_cursor": "proj_xxx" } ``` `limit` defaults to 25 (range 1–100) on most list endpoints (`/v1/projects`, `/v1/components`, `/v1/submissions`, `/v1/starred_components`, `/v1/airfoils`, `/v1/compute-units/*`). `/v1/simulations`, `/v1/sweeps`, and `/v1/dynamic-simulations` list endpoints default to 100 (range 1–1000). To fetch subsequent pages, pass the response's `next_cursor` value as `cursor`: --- # /docs/guides/async-resources Source: https://thrustlab.com/docs/guides/async-resources # Async resources Simulations, sweeps, and dynamic runs are async. Creating one returns immediately with a non-terminal status. Collect the result by polling, or by subscribing to webhook events. ## Polling with the SDK `wait()` polls the resource at a configurable interval until it is terminal. Source: backend/sdks/python/examples/simulations/run_sync.py ```python """Single-point simulation: auth -> submit -> wait() -> read the canonical result. Run it: export THRUSTLAB_API_KEY=key_... # never hard-code the key python examples/simulations/run_sync.py The verification combo below (BadAss 2826-820Kv + 10.5x4.5 + Liperior 4S 5000 mAh @ 70% throttle, static) converges to ~9.07 N / ~8505 rpm. 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 single-point example") # Resolve components by name (find() returns the single match or raises # AmbiguousComponentError / NotFoundError). Or paste explicit IDs instead: # 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" ) sim = client.simulations.create( project_id=project["id"], battery_component_id=battery["id"], airspeed_m_s=0.0, density_kg_m3=1.225, battery_charge_pct=100, rotor_groups=[ { "label": "main", "count": 4, "motor_component_id": motor["id"], "propeller_component_id": prop["id"], "throttle_pct": 70, } ], ) print(f"created {sim['id']}, status={sim['status']}") # wait() polls until completed / failed / canceled (or a timeout sentinel). result = client.simulations.wait(sim["id"], timeout=300) print(f"final status: {result['status']}") if result["status"] == "completed": # Canonical snake_case result (post-D-01): per-rotor group under its label # index ("1", "2", ...); aggregate roll-ups under "All". per_rotor = result["result"]["1"] aggregate = result["result"]["All"] print(f"per-rotor thrust: {per_rotor['thrust_n']:.2f} N") print(f"per-rotor rpm: {per_rotor['rpm']:.0f}") print(f"total thrust: {aggregate['total_thrust_n']:.2f} N") ``` Terminal states are `completed`, `failed`, and `canceled` (one `l` on the wire). The SDK adds one client-side terminal state, `timed_out`, when `wait()` exceeds its `timeout=` parameter. ## Subscribing via webhooks For long-running sweeps and production pipelines, subscribe to `simulation.completed`, `simulation.failed`, `sweep.completed`, and `sweep.failed` through a webhook endpoint. See [webhooks](/docs/guides/webhooks) and [event types](/docs/guides/events). ## Run timestamps | Field | Set when | |---|---| | `created_at` | The row was created. | | `dispatched_at` | The run was handed to the worker queue. | | `started_at` | The solver began work on it. | | `completed_at` | The run reached a terminal state. | Queue wait is `started_at - dispatched_at`. Before `started_at` existed there was no way to measure it from the resource at all. For a dynamic run, `dispatched_at` equals `created_at`, because creating a dynamic run dispatches it. For a sweep or a single-point run submitted with `launch_intent: "queue"`, `dispatched_at` is later than `created_at`, and is `null` until something dispatches it. Solver time is `completed_at - started_at`. ## Dispatching parked runs `launch_intent` on `POST /v1/sweeps` and `POST /v1/simulations` takes three values. `"run"` (the default) reserves compute units and dispatches immediately. `"queue"` reserves compute units and parks the run. `"draft"` stores it with no reservation and no dispatch. A parked run stays `queued` with `dispatched_at: null`. Two endpoints dispatch parked runs. `POST /v1/simulations/run-queued` dispatches every parked run the caller owns, across all projects. It takes no body. `POST /v1/simulations/run-selected` dispatches a named subset. Body: `{"simulation_ids": ["sim_...", "sweep_..."]}`, with 1 to 1000 ids. Sweep ids are accepted here despite the field name. Both endpoints group the batch by rotor count and return `{"groups": [...], "compiles_needed": 0, "rotor_counts": [...]}`. An id that is not `queued` returns 400 `invalid_status`. An id already handed to a worker returns 400 `already_dispatched`. An id owned by someone else returns 404. Dynamic runs have no parked state and no dispatch endpoint. Creating one dispatches it. ## Cancelling a run A `queued` run of any kind cancels immediately. The response comes back `canceled` and compute units are refunded in full. A RUNNING sweep and a RUNNING dynamic run behave the same way. The response keeps `status: "running"` and sets `cancel_requested_at`. Poll until the status turns `canceled`. For a dynamic run the worker stops at its next integration window. That is at most 20 seconds of MISSION time, not wall time. It then stores the partial result with `run_meta.partial: true` and `run_meta.canceled_at_t_s` (the mission time it stopped at), turns `canceled`, refunds, and frees the execution slot. The partial `result` is readable like any other result. `POST /v1/simulations/cancel-selected` takes up to 1000 run ids of any kind and returns `{"canceled": [...], "requested": [...], "skipped": [...]}`. Running sweeps and running dynamic runs come back under `requested`; poll those. `skipped` entries carry a `reason`. A running single-point simulation cannot be canceled. Those finish in seconds. ## Dynamic run progress A dynamic run carries `progress` on the resource and on every `dynamic.updated` SSE frame, shaped `{fraction, sim_time_s, sim_time_total_s, wall_s, updated_at}`. `fraction` is 0 to 1 for a fixed-duration mission. For a run-to-depletion mission the total is not known in advance, so `fraction` is an estimate capped below 1, and can be `null`. `sim_time_s` is mission time integrated so far. `sim_time_total_s` is the mission's total duration, or `null` for run-to-depletion. `wall_s` is elapsed wall-clock seconds. `POST /v1/dynamic-simulations/estimate` returns `estimated_duration_s`, which is SIMULATED mission time, not wall time. 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 cadence: 2 to 5 seconds, backing off when nothing changes. A dynamic run to depletion can integrate for several minutes. --- # /docs/guides/webhooks Source: https://thrustlab.com/docs/guides/webhooks # Webhooks ThrustLab webhooks deliver server-side events (simulation lifecycle, sweep lifecycle, compute-unit balance, component review) to your HTTPS endpoint as signed POST requests. Delivery is **at-least-once**, with HMAC-SHA256 signatures, deterministic backoff retries, and a 7-day auto-disable safety net. The full surface lives under `/v1/webhook_endpoints`. See the OpenAPI reference for request/response schemas. This page covers the developer-facing semantics: registration, signature verification, the event catalog, retry behavior, auto-disable, the test event, the delivery debug surface, and source IP allowlisting. ## Registration Register an endpoint via `POST /v1/webhook_endpoints`: The response includes the signing `secret` (`whsec_<43-char-base64url>`, total 49 chars) **exactly once**. Store it immediately — every subsequent read of this endpoint returns `secret: null` plus a four-character `secret_hint`. Lose it and you must call `POST /v1/webhook_endpoints/{id}/rotate_secret` for a new one (which invalidates the old one immediately). To subscribe to every event type, pass `["*"]`. Otherwise pass an exact list; unknown event types are rejected at create-time. URL constraints: HTTPS only, no private/loopback/link-local IPs (SSRF guard), DNS must resolve. ## Signature verification Every delivery includes a `Thrustlab-Signature` header in the form `t=,v1=`. The signed payload is `f"{t}.{raw_body}"` — concatenate the timestamp, a literal dot, and the **raw request body bytes received off the wire**. > **Critical:** verify against the raw body. Re-serializing the JSON before computing the HMAC (e.g. `json.dumps(json.loads(body))`) is the #1 webhook signature bug — sort order, whitespace, and Unicode escaping will not match what we signed. Complete handler example (Python, FastAPI): Source: backend/sdks/python/examples/webhooks/verify_handler.py ```python """Example FastAPI webhook handler verifying incoming events.""" import os from fastapi import FastAPI, Request, HTTPException from thrustlab import Webhook from thrustlab.exceptions import SignatureVerificationError app = FastAPI() WEBHOOK_SECRET = os.environ["THRUSTLAB_WEBHOOK_SECRET"] @app.post("/webhooks/thrustlab") async def thrustlab_webhook(request: Request): payload = await request.body() sig = request.headers.get("Thrustlab-Signature", "") try: event = Webhook.verify(payload, sig, WEBHOOK_SECRET) except SignatureVerificationError as exc: raise HTTPException(status_code=400, detail=str(exc)) if event.type == "simulation.succeeded": sim_id = event.data["id"] # ... do work ... return {"received": True} ``` The SDK's `Webhook.verify()` helper handles: 1. **Timestamp freshness** — rejects events older than 5 minutes. Replay protection. 2. **Constant-time comparison** — uses `hmac.compare_digest` internally. Immune to timing attacks. 3. **Raw body verification** — operates on the request body bytes received off the wire, not a decoded-then-reserialized copy. ## Replay protection Each delivery additionally carries a `Webhook-Id: evt_` header and a `Webhook-Attempt` header (1-based attempt number — `1` on the first try, `2` on the first retry, and so on). Server-side delivery is at-least-once — a 2xx response lost mid-network results in the same `evt_xxx` arriving twice. **Deduplicate on `Webhook-Id`** for exactly-once processing. Combined with the 5-minute timestamp tolerance above, this gives you full replay defense. ## Event catalog | Event type | Fired when | |---|---| | `simulation.queued` | A new simulation is accepted and queued. | | `simulation.running` | A queued simulation has started executing on a worker. | | `simulation.completed` | A simulation finished successfully. | | `simulation.failed` | A simulation terminated with an error. | | `simulation.canceled` | A simulation was canceled by the user before completion. | | `sweep.queued` | A new sweep is accepted and its child simulations are being queued. | | `sweep.running` | A sweep has at least one child simulation running. | | `sweep.completed` | All child simulations of a sweep finished successfully. | | `sweep.failed` | A sweep terminated with at least one failed child. | | `sweep.canceled` | A sweep was canceled by the user. | | `credits.low_balance` | Legacy compatibility event name; not emitted by the current allowance model. | | `component.submitted` | A user-submitted component awaits moderator review. | | `component.approved` | A submitted component was approved and is now visible. | | `component.rejected` | A submitted component was rejected by a moderator. | | `webhook.test` | Synthetic test event fired by `POST /v1/webhook_endpoints/{id}/test`; never fired by the regular fan-out path. | To subscribe to every present and future type, register with `events: ["*"]`. Otherwise the registration is exact-match — adding a new event type later requires a `PATCH` to the endpoint's `events` array. ### `credits.low_balance` compatibility The event type remains accepted in subscription filters for wire compatibility, but the current model does not emit it. Free uses a rolling daily allowance and paid plans are unlimited; `CreditBalance.low_balance_threshold` is `null`. See [compute units](/docs/guides/compute-units) for the full balance and usage-history documentation. ## Retry and backoff schedule Each delivery gets up to **5 attempts**. Backoff between attempts: | Attempt | Delay since previous failure | |---|---| | 1 | immediate | | 2 | +1 minute | | 3 | +30 minutes | | 4 | +2 hours | | 5 | +24 hours | Total wall-clock window from first attempt to permanent failure: ~26.5 hours (~27h, rounded). After attempt 5 fails the delivery transitions to `permanently_failed` and the consecutive-failure counter on the endpoint advances by one. Retry triggers: HTTP 5xx, HTTP 429, and network/timeout errors (10-second connect+read timeout per attempt). Retry skipped (terminal immediately): HTTP 404 and HTTP 410. These are treated as "this URL is gone" and burn the entire attempt budget on the spot. Any 2xx response — body content irrelevant — marks the delivery `succeeded` and resets the endpoint's consecutive-failure counter. ## Auto-disable If an endpoint has no successful delivery for **7 days** while at least one delivery has failed in that window, the server flips `enabled=false` and stamps `auto_disabled_at`. Pending deliveries on a disabled endpoint are skipped (not retried). To re-enable, `PATCH /v1/webhook_endpoints/{id}` with `{"enabled": true}`. The `auto_disabled_at` timestamp is cleared and the consecutive-failure counter resets. You may also receive an `endpoint disabled` notification email when auto-disable fires (the email task is gated on broader email infrastructure; the webhook subsystem itself disables cleanly regardless). ## Test endpoint Returns a `webhook_delivery` row (HTTP 202). The server fires a synthetic `webhook.test` event scoped to that one endpoint — bypassing the regular fan-out, so other endpoints subscribed to `*` will not receive it. Useful for validating signature verification, allowlist rules, and TLS terminators end-to-end. The `data.object` payload is a small static body containing `endpoint_id`, a friendly `message`, and the trigger timestamp. ## Delivery debug surface Every dispatch and retry creates a row in `webhook_deliveries`, viewable via: - `GET /v1/webhook_endpoints/{id}/deliveries` — cursor-paginated list of deliveries for that endpoint, newest first. Filter by `status` (`pending` / `succeeded` / `permanently_failed`) and event `type`. - `GET /v1/webhook_endpoints/{id}/deliveries/{whd_id}` — full detail: `request_body` (the canonical JSON we signed), `response_status_code`, `response_body_excerpt` (first 1 KB of your response), `error_message`, `attempt_count`, `last_attempt_at`, `next_attempt_at`, and `manual_retry_of` (the prior `whd_` if this row is a manual replay). To replay a terminal delivery (`succeeded` or `permanently_failed`), use: ```bash curl -X POST https://thrustlab.com/v1/webhook_endpoints/wh_.../deliveries/whd_.../retry \ -H "Authorization: Bearer key_..." ``` This creates a fresh delivery row with `manual_retry_of` pointing back at the source. The worker re-signs the original canonical body (preserved on the source row), so retries still verify even if the source event has since been pruned by the 30-day retention sweep. Pending deliveries cannot be retried (HTTP 409); wait for them to terminate. ## Source IP allowlisting All webhook deliveries originate from the ThrustLab production fleet. If your firewall does egress filtering, allow inbound HTTPS from: - `168.119.108.2` This IP is stable for the foreseeable future. We will publish a deprecation notice well in advance of any change. ## Forward-looking subscriptions Webhook subscriptions are **forward-looking**. An endpoint only receives events created at or after the endpoint's own `created_at` timestamp. We do not retro-deliver historical events to newly-registered endpoints. If you need historical events — e.g. you're rebuilding a downstream cache, or you missed a window of deliveries — pull from the [events resource](/docs/guides/events) directly. `GET /v1/events` returns the same event objects that webhooks deliver, filterable by `type`, `resource_id`, and `created_at` range, with 30-day retention. --- # /docs/guides/events Source: https://thrustlab.com/docs/guides/events # Events The events resource is the durable, queryable history of everything that has happened on your ThrustLab account that *could* trigger a webhook. Events fire even when no webhook endpoint is subscribed; webhooks are forward-looking; events are pull-anytime. If you missed a webhook delivery window (downtime, misconfigured endpoint, late integration), pull from `/v1/events` directly. The shape of the event objects returned here is identical to the body of webhook deliveries. ## Resource `GET /v1/events` — list events for the authenticated principal, newest first, cursor-paginated. `GET /v1/events/{id}` — retrieve a single event by `evt_` ID. Each event has the form: ```json { "id": "evt_2lz...", "object": "event", "type": "simulation.completed", "created_at": "2026-04-25T18:42:11Z", "api_version": "beta", "data": { "object": { ... resource-specific payload ... } } } ``` The same canonical bytes (sorted keys, no whitespace) used here are what gets HMAC-signed for webhook delivery — see [webhooks](/docs/guides/webhooks) for verification details. ## Filters `GET /v1/events` accepts the following query parameters: | Parameter | Type | Description | |---|---|---| | `type` | string | Exact match on event type (e.g. `simulation.completed`). | | `resource_id` | string | Exact match on the prefixed resource ID the event refers to (e.g. `sim_2lz...`, `swp_2lz...`). | | `created_at[gte]` | ISO-8601 | Inclusive lower bound on `created_at`. | | `created_at[lt]` | ISO-8601 | Exclusive upper bound on `created_at`. | | `limit` | int (1–100) | Page size. Default 25. | | `cursor` | string | Opaque pagination cursor from a prior response's `next_cursor`. | ### Examples All `simulation.completed` events: ```bash curl "https://thrustlab.com/v1/events?type=simulation.completed" \ -H "Authorization: Bearer key_..." ``` All events for one resource: ```bash curl "https://thrustlab.com/v1/events?resource_id=sim_2lz..." \ -H "Authorization: Bearer key_..." ``` Events from a 24-hour window: ```bash curl "https://thrustlab.com/v1/events?created_at[gte]=2026-04-24T00:00:00Z&created_at[lt]=2026-04-25T00:00:00Z" \ -H "Authorization: Bearer key_..." ``` Combine filters freely — they AND together. Pagination cursors encode the last seen `(created_at, id)` and remain valid as long as the underlying events have not been pruned by retention. ## Retention Events are retained for **30 days** and then deleted by a daily background sweep. Events outside the retention window are not recoverable. If you need durable beyond 30 days, ingest events into your own storage — either by subscribing a webhook endpoint and persisting deliveries, or by polling `/v1/events` periodically and checkpointing on the latest `id` you've seen. Retention only deletes events that have already been processed by the webhook fan-out pass (or were never eligible for fan-out). In practice this means everything older than 30 days is fair game. ## Relationship to webhooks Events and webhooks are independent surfaces over the same data: - **Events fire even with zero subscribers.** Every meaningful state change writes a row to `events` regardless of whether any webhook endpoint exists. Registering an endpoint later does *not* retroactively deliver past events — webhook subscriptions are forward-looking. - **Webhooks are push, events are pull.** Webhooks deliver in real time with backoff retries and at-least-once semantics. The events resource is queryable on demand with `created_at` filtering and cursor pagination. - **Same payload shape.** The `data.object` body in an event is byte-identical to what gets signed and POSTed for the corresponding webhook delivery (same canonical JSON: sorted keys, compact separators). Use webhooks for low-latency reaction to state changes. Use the events resource for replay, backfill, and any case where polling is acceptable. Combining the two — webhooks for real-time, events for gap-recovery — is a robust pattern. --- # /docs/guides/rate-limits Source: https://thrustlab.com/docs/guides/rate-limits # Rate limits Four different budgets can each return 429, and every one sets `Retry-After` in seconds. They are counted on separate ledgers, so a response can be far from the request-rate limit and still be refused by one of the others. The `code` in the error body says which. | `code` | Counts | |---|---| | `rate_limit_exceeded` | Requests per minute, per credential. | | `create_rate_limited` | Run submissions per minute, per account. | | `filter_rate_limited` | Filtered catalog queries per hour, free tier. | | `spec_rate_limited` | Distinct components read from the datasheet endpoint, per account. | | `too_many_streams` | Concurrent live streams, per account. | ## Request rate limit - 1000 requests per minute per credential, burst allowance 50. - Over the limit: HTTP 429, code `rate_limit_exceeded`, `Retry-After` header in seconds. - Every credentialed `/v1/` response, success or error, carries these headers: | Header | Meaning | |---|---| | `X-RateLimit-Limit` | Sustained requests-per-minute limit for this credential. | | `X-RateLimit-Remaining` | Tokens remaining in this bucket: `0` on a `rate_limit_exceeded` 429; the other three 429s use separate budgets and may leave a positive balance here. | | `X-RateLimit-Reset` | Unix timestamp marking the end of the current rate-limit window. | The SDK retries 429 with backoff and honors `Retry-After`. Default: max 3 retries, exponential backoff (0.5 s, 1 s, 2 s) plus jitter. Configure with `Client(max_retries=...)`. Contact support for higher per-key limits on a specific integration. ## Submission limits Per account, per minute, counted separately by endpoint: - `POST /v1/sweeps` — 30 per minute. - `POST /v1/dynamic-simulations` — 30 per minute. - `POST /v1/simulations` — 120 per minute. Over the limit: HTTP 429, code `create_rate_limited`, `Retry-After` header. The body carries `retry_after_s`, `limit_per_minute`, and `kind`. The limit counts submissions, not running work. A submission that parks the run (`launch_intent: "queue"`) counts the same as one that dispatches it. Validating and reserving a large grid is real work on the request path, and an unpaced burst of large sweep submissions degrades latency for every endpoint on the account. A client that submits a queue of studies should pace itself rather than discover the wall. One sweep carrying a `component_axes` axis replaces a loop of single-point submissions, and it is one request rather than hundreds. See [Sweeps](/docs/sdk/python/sweeps). ## Component datasheet budget `GET /v1/components/{id}/specs` on a catalog motor or battery is metered on a budget of distinct components, per account, on a ledger separate from the request-rate bucket. Reading your own custom component, or any propeller, is not metered and carries no budget headers. - Budget: 60 distinct components per hour and 300 distinct components per day. - It is account-wide, across the dashboard and every API key. - A granted lease makes re-reads of that component free for 24 hours. Reading the same component again inside the lease costs nothing against the budget. Response headers on a metered read, on 200 and on 429: | Header | Meaning | |---|---| | `X-Spec-Budget-Remaining` | Distinct components still readable, the smaller of the hourly and daily remainders. | | `X-Spec-Budget-Reset` | Seconds until the binding window rolls over. | Over budget: HTTP 429, code `spec_rate_limited`, `Retry-After` header. The body carries `retry_after_s`, `budget_per_hour`, `budget_per_day`, `remaining_hour`, `remaining_day`. The list endpoint costs nothing against this budget and already carries the headline datasheet numbers, including motor `R`. Rank and filter with `GET /v1/components`, then request `/specs` only for the rows you need. See [Components](/docs/sdk/python/components). ## Catalog filter budget - Free tier only: 120 filtered motor and battery catalog queries per hour. - Free-tier filter bounds snap to brackets rather than taking an exact value. - Over the limit: HTTP 429, code `filter_rate_limited`, `Retry-After` header. - Paid tiers are not subject to this budget. ## Simulation execution concurrency HTTP request rate and simulation execution capacity are separate controls. Free runs one simulation at a time. Pro runs up to five. The execution limit is account-wide. Dashboard submissions and every API key owned by the account share it. Extra dispatched runs stay `queued` and start when a slot frees. They do not return a concurrency 429. Service-wide overload or maintenance protection can still pause new submissions. That control is independent of the paid plan's unlimited usage policy and the account execution queue. `GET /v1/users/me` returns `concurrency: {limit, active, queued}` for the account so a client can read the shared queue directly. `limit` is the account execution-slot ceiling, `active` is the number of slots in use, and `queued` is the number waiting. ## Live streams Server-Sent-Events progress streams (`GET /v1/simulations/{id}/stream` and the sweep and dynamic equivalents) share a limit of 20 concurrent open streams per account. Opening more returns HTTP 429 with code `too_many_streams`. Each stream has a maximum lifetime of 30 minutes. If the run is still going when the stream reaches that limit, reconnect to keep watching. A stream always ends on its own when the run reaches a terminal state. See [Errors](/docs/guides/errors) for the envelope and the per-code anchors, and [Compute units](/docs/guides/compute-units) for metering, which is a different thing from rate limiting. --- # /docs/guides/stability-policy Source: https://thrustlab.com/docs/guides/stability-policy # ThrustLab API Stability Policy ## Beta-to-v1 graduation The ThrustLab API is currently published as **v1-beta**. During this period, responses carry the `X-API-Stability: beta` header and the API may evolve based on real-world integration feedback. The API graduates to **v1-stable** on a deliberate editorial call when all four of the following criteria hold simultaneously: 1. No breaking change has shipped in the prior 90 consecutive days. 2. The official Python SDK has been stable (no breaking changes, no major rewrites) for 60 days. 3. At least one paying external customer has integrated via the API. 4. Authentication, billing, and webhook surfaces are all shipped and documented. At graduation, the `X-API-Stability` header is removed, deprecation-notice minimums extend from 30 days to 90 days, and the docs-site banner disappears. **The URL path does not change at graduation.** `/v1/` is permanent for the life of the API. ## Breaking vs. non-breaking changes A change is **breaking** (and subject to the deprecation-notice policy) if any of the following hold: - A field is removed from a response. - A field's type changes. - A field's semantic meaning changes. - An endpoint is removed, or its URL or HTTP method changes. - A request validation rule is tightened (input that previously validated now rejects). - An enum value is removed. - The HTTP status code returned for a given outcome changes. - The error envelope shape for a given `code` changes. A change is **not breaking** and may ship at any time: - A new field is added to a response. - A new endpoint is added. - A new optional request parameter is added. - A new enum value is added to a field documented as extensible. **Callers must treat enum fields as open sets.** The official SDKs never throw on unknown enum values. - A new error `code` is added inside an existing `type`. - A new webhook event type is added. ## Deprecation policy - **During beta:** breaking changes are announced at least **30 days** before the effective date. - **After graduation:** breaking changes are announced at least **90 days** before the effective date. During the deprecation window, the surface being retired serves two response headers: - `Deprecation: true` - `Sunset: ` (per RFC 8594) Announcements include migration examples, rationale, and a link to the SDK release that supports the replacement. The changelog page has an RSS feed that integrators are encouraged to subscribe to. ## Authentication All `/v1/` endpoints require authentication via `Authorization: Bearer `. Two credentials are accepted: - **API keys** (the public credential). Format `key_<32 chars>`. Created and managed from the dashboard only — there is no public endpoint for API-key creation or management. Server-to-server consumers (SDKs, CI, automation) must use these. - **User session JWTs** (first-party only). Issued by the Django auth stack for the ThrustLab web app. Scoped same-origin. External developers never use this credential. The two credentials are interchangeable at the protocol level — any `/v1/` endpoint accepts either. Third-party integrations must use API keys. See `docs/api/authentication.md` for the full authentication guide. ## Rate limiting Every authenticated `/v1/` request consumes one token from a credential-scoped token bucket: - **Sustained:** 1000 requests/minute per credential. - **Burst:** 50 requests. - **Exempt:** `/v1/health`. Responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` (unix timestamp). A 429 response also carries `Retry-After: `. When a credential exhausts its budget the caller receives: ```json { "error": { "type": "rate_limit_error", "code": "rate_limit_exceeded", "message": "Too many requests. Retry after N seconds.", "param": null, "request_id": "req_...", "doc_url": "https://thrustlab.com/docs/guides/errors" } } ``` Two API keys owned by the same user have independent buckets. A user's web-session JWT and their API keys also have independent buckets — the limit is scoped to the credential, not the human. --- # /docs/guides/compute-units Source: https://thrustlab.com/docs/guides/compute-units # Compute units Compute units record simulation and sweep work per physical rotor. Free uses them for its rolling daily allowance. Pro usage is unlimited: ledger entries remain visible for history, refunds, and cost attribution, but accumulated usage never blocks a paid run. Three read-only endpoints expose the meter: - `GET /v1/compute-units/balance` — remaining Free allowance, or an explicit unlimited paid balance. - `GET /v1/compute-units/summary` — rolling usage and cap status. - `GET /v1/compute-units/transactions` — the full debit/grant ledger. All three are owner-scoped: they read the calling key's account, take no path parameters, and never mutate. > The wire keeps its original `object` names (`credit_balance`, `credit_usage_event`) and `currency` (`"credit"`) for backward compatibility. Only the public field vocabulary moved: a transaction's bucket is reported as `unit_type`. ## Metering and allowance A compute unit is one physical rotor solved. A four-rotor static run records four units. Historical transaction rows retain `free` and `monthly` unit types for compatibility: | Bucket | Historical source | |---|---| | `free` | Signup, bounty, or admin grants | | `monthly` | Billing-cycle, beta-trial, and current simulation usage entries | These labels no longer represent stored paid balances. Free allowance is computed from its rolling 24-hour usage. Paid accounts are unlimited and return no bucket balance. ## `GET /v1/compute-units/balance` Returns a point-in-time allowance snapshot. ### Balance response ```json { "object": "credit_balance", "total": null, "unlimited": true, "breakdown": [], "currency": "credit", "low_balance_threshold": null, "as_of": "2026-04-25T15:42:11.123Z" } ``` | Field | Notes | |---|---| | `total` | Remaining Free allowance, or `null` for unlimited paid usage. | | `unlimited` | `true` for Pro. | | `breakdown` | Free-tier compatibility breakdown; empty for unlimited paid accounts. | | `currency` | Always `"credit"`. | | `low_balance_threshold` | Retained compatibility field; currently `null`. | | `as_of` | ISO-8601 timestamp of the snapshot. | ## `GET /v1/compute-units/summary` Returns rolling usage. Free reports its daily allowance; paid tiers report seven-day usage with `cap` and `remaining` set to `null`. ### Summary response ```json { "object": "credit_usage_summary", "tier": "pro", "window": "week", "used": 1240, "cap": null, "remaining": null, "resets_at": "2026-04-26T00:00:00.000Z" } ``` | Field | Notes | |---|---| | `tier` | The resolved account tier. | | `window` | `"day"` for Free allowance; `"week"` for paid usage reporting. | | `used` | Units consumed inside the window. A 4-rotor static run counts 4. | | `cap` | Free allowance, or `null` for unlimited Pro. | | `remaining` | `cap - used` for Free, or `null` for unlimited Pro. | | `resets_at` | Oldest event age-out for the reporting window; it is not a paid allowance reset. | ## `GET /v1/compute-units/transactions` Returns your ledger: every debit and grant, newest first, cursor-paginated. Legacy pre-ledger rows are not exposed. ### Event shape ```json { "object": "credit_usage_event", "id": "cue_2c5tQ...", "amount": -25, "type": "simulation_debit", "unit_type": "monthly", "resource": {"object": "simulation", "id": "sim_2c5tQ..."}, "balance_after": null, "created_at": "2026-04-25T15:42:11.123Z" } ``` | Field | Notes | |---|---| | `amount` | Negative for debits; positive for grants and refunds. Whole units. | | `type` | One of `simulation_debit`, `sweep_debit`, `monthly_grant`, `signup_grant`, `bounty_grant`, `refund`, `adjustment`. | | `unit_type` | Which bucket was affected: `free` or `monthly`. | | `resource` | The linked simulation or submission, as a discriminated reference. `null` for grants and adjustments. | | `balance_after` | Remaining Free allowance immediately after the event, or `null` for unlimited Pro usage. | Current simulation usage writes one ledger event. Older accounts may still have grant and debit rows carrying either compatibility unit type. ### Query parameters | Parameter | Type | Description | |---|---|---| | `limit` | int (1–100) | Page size. Default 25. | | `cursor` | string | Opaque cursor from a prior response's `next_cursor`. | | `type` | string | Filter by event type, e.g. `simulation_debit`. | | `credit_type` | string | Filter by bucket: `free` or `monthly`. | | `created_at[gte]` | ISO-8601 | Inclusive lower bound on `created_at`. | | `created_at[lt]` | ISO-8601 | Exclusive upper bound on `created_at`. | The response wraps events in a cursor-paginated list: ```json { "object": "list", "data": [ ... ], "has_more": true, "next_cursor": "eyJwIjoiY3VlXzJ..." } ``` Pass `cursor=` on the next request. Cursors encode `(created_at, id)` and stay stable while the underlying rows exist. ### Examples Simulation debits since a date: ```bash curl "https://thrustlab.com/v1/compute-units/transactions?type=simulation_debit&created_at[gte]=2026-04-18T00:00:00Z" \ -H "Authorization: Bearer key_..." ``` Free-bucket activity only: ```bash curl "https://thrustlab.com/v1/compute-units/transactions?credit_type=free" \ -H "Authorization: Bearer key_..." ``` ## Low-balance webhook compatibility `low_balance_threshold` remains in the resource for compatibility but is `null` under the current rolling-allowance model, so no paid low-balance event is emitted. ## Free allowance exceeded (HTTP 402) Only Free can exhaust a compute-unit allowance. Paid tiers never receive a usage-cap 402. A Free over-cap submit returns the typed daily-limit upgrade error documented in [Errors](/docs/guides/errors). ```json { "error": { "type": "upgrade_required", "code": "upgrade_required", "message": "You have reached the free daily simulation limit.", "details": {"gate": "daily_cap", "required_tier": "hobbyist"} } } ``` --- # /docs/guides/steady-state-outputs Source: https://thrustlab.com/docs/guides/steady-state-outputs # Steady-state outputs A single-point simulation and a sweep share one output schema. A completed result carries two siblings: - `result` — the computed outputs, keyed per-rotor and by aggregate. - `display_labels` — a `{snake_key: human_string}` map: the source of truth for a human-readable column header (`thrust_n` → `"Thrust (N)"`). Every per-rotor key and every key in `"All"` is canonical `snake_case`. Each key in `"Battery"` appears under both its `snake_case` spelling and its original human-string spelling on the same object. A sweep reuses the exact same per-point shape, so everything on this page applies to both. ## Result shape `result` is a dict keyed by: | Key | Contents | |---|---| | `"1"`, `"2"`, … | One entry per rotor group, in submit order. Per-rotor performance, aero, ESC, and thermal fields. | | `"All"` | Vehicle-level roll-ups summed or aggregated across every rotor. | | `"Battery"` | Pack-level state and per-cell arrays. | ```python sim = client.simulations.retrieve("sim_2c5tQ...") r = sim["result"] labels = sim["display_labels"] r["1"]["thrust_n"] # 9.81 — per-rotor thrust, newtons r["All"]["total_thrust_n"] # 39.24 — vehicle total across 4 rotors labels["thrust_n"] # "Thrust (N)" — the display header for that key ``` ## Reading `display_labels` `display_labels` is one map for the whole result. Look a key up to render it; fall back to the raw key when it is absent: ```python for key, value in r["1"].items(): header = labels.get(key, key) print(f"{header}: {value}") ``` `display_labels` covers every per-rotor key and every key in `"All"` and `"Battery"`. No keys are excluded. See the `"Battery"` section below for the label spellings its `snake_case` keys mirror. ## Per-rotor fields (`result["1"]`, `result["2"]`, …) ### Identification | Key | Label | Meaning | |---|---|---| | `propeller` | Propeller | Propeller name. | | `motor` | Motor | Motor name (omitted when empty). | ### Performance | Key | Label | Units | |---|---|---| | `throttle_pct` | Throttle (%) | % | | `rpm` | RPM | rev/min | | `thrust_n` | Thrust (N) | N (`thrust` carries the same value) | | `static_torque` | Static Torque | N·m | | `dynamic_torque` | Dynamic Torque | N·m (0 in steady state) | | `total_torque` | Total Torque | N·m | | `power` | Power | W (shaft mechanical) | | `motor_voltage_v` | Motor Voltage (V) | V | | `battery_voltage_v` | Battery Voltage (V) | V | | `current_a` | Current (A) | A | | `shaft_power_w` | Shaft Power (W) | W | | `mechanical_power_w` | Mechanical Power (W) | W | | `electrical_power_w` | Electrical Power (W) | W | | `g_per_w` | g/W | g/W (thrust per electrical watt) | | `efficiency_pct` | Efficiency (%) | % | | `weight_g` | Weight (g) | g (motor + propeller) | ### Aerodynamics | Key | Label | Units | |---|---|---| | `j` | J | Advance ratio (dimensionless) | | `ct` | Ct | Thrust coefficient (dimensionless) | | `cp` | Cp | Power coefficient (dimensionless) | | `pe` | Pe | Ideal (induced) power, W | | `mach` | Mach | Tip Mach number | | `reyn` | Reyn | Blade Reynolds number: mean chord over the blade's real stations, at the resultant velocity at 75% radius | | `thr_per_pwr` | THR/PWR | g/W | | `ve` | Ve | Exit velocity, m/s (`exit_velocity` is the same value) | | `h_force_n` | H-force (N) | In-plane hub force, N (present only for an active oblique flight condition — nonzero edgewise inflow) | | `inflow_validity` | inflow_validity | `"ok"` or `"vrs_band"`, categorical (same presence rule as `h_force_n`) | | `f_vertical_n` | Vert force (N) | Ground-frame vertical force, N (ground mode only — `inflow_mode="components"` has no tilt angle to resolve a ground frame) | | `f_horizontal_n` | Horiz force (N) | Ground-frame horizontal force, N (ground mode only) | ### ESC | Key | Label | Units | |---|---|---| | `esc_voltage_drop_v` | ESC Voltage Drop (V) | V | | `esc_power_loss_w` | ESC Power Loss (W) | W | | `esc_efficiency` | ESC Efficiency | Fraction (0–1) | | `bus_current_a` | Bus Current (A) | A | | `motor_voltage_after_esc_v` | Motor Voltage After ESC (V) | V | ### Thermal Present when the coupled electromagnetic + thermal solve runs. | Key | Label | Units | |---|---|---| | `t_w_degc` | T_w (degC) | Winding temperature, °C | | `t_mag_degc` | T_mag (degC) | Magnet temperature, °C | | `t_ambient_degc` | T_ambient (degC) | Ambient temperature, °C | | `p_motor_w` | P_motor (W) | Motor dissipation, W | | `r_th_k_per_w` | R_th (K/W) | Effective thermal resistance, K/W | | `motor_cooling_velocity_m_s` | Motor cooling velocity (m/s) | Cooling-air velocity, m/s | | `thermal_converged` | Thermal converged | Boolean | | `thermal_iterations` | Thermal iterations | Count | | `irreversible_magnet_warning` | Irreversible magnet warning | Boolean | ### Solver-quality flags `frac_converged`, `compressibility_warning`, and `frac_above_Mdd` stay top-level on each rotor entry: they report per-rotor convergence and compressibility state. Per-rotor solver diagnostics and build metadata are relocated under a neutral `result["1"]["diagnostics"]` sub-object, off the physical surface. ## Aggregate fields (`result["All"]`) | Key | Label | Units | |---|---|---| | `total_thrust_n` | Total Thrust (N) | N | | `total_electrical_power_w` | Total Electrical Power (W) | W | | `total_shaft_power_w` | Total Shaft Power (W) | W | | `total_mechanical_power_w` | Total Mechanical Power (W) | W | | `total_current_a` | Total Current (A) | A | | `total_bus_current_a` | Total Bus Current (A) | A | | `total_g_per_w` | Total g/W | g/W | | `total_efficiency_pct` | Total Efficiency (%) | % | | `total_weight_g` | Total Weight (g) | g | | `total_esc_power_loss_w` | Total ESC Power Loss (W) | W | | `f_vertical_total_n` | Total F_vertical (N) | N (count-weighted vehicle total; ground mode only) | | `f_horizontal_total_n` | Total F_horizontal (N) | N (count-weighted vehicle total; ground mode only) | Thermal roll-ups (`max_t_w_degc`, `max_t_mag_degc`, `max_t_core_degc`, `max_t_case_degc`, `total_motor_dissipation_w`, `total_battery_dissipation_w`, `pack_thermal_coupling_warning`, …) appear when the thermal solve runs. The `"All"` build metadata is relocated under `result["All"]["diagnostics"]`. ## Battery entry (`result["Battery"]`) The `"Battery"` entry contains pack-level state and per-cell arrays. Every key has both a `snake_case` spelling and its original human-string spelling on the same object. The human-string spellings are deprecated and will be removed in a future major version. Read the `snake_case` keys. | Key | Human-string spelling (deprecated) | Units | |---|---|---| | `total_voltage_v` | `Total Voltage (V)` | V | | `current_draw_a` | `Current Draw (A)` | A | | `power_draw_w` | `Power Draw (W)` | W | | `cell_voltages_v` | `Cell voltages (V)` | List, V per cell | | `charge_levels_pct` | `Charge levels (%)` | List, SOC % per cell | | `internal_resistance_ohm` | `Internal Resistance (Ohm)` | Ω | | `configuration` | `Configuration` | e.g. `"4S1P"` | | `remaining_capacity_mah` | `Remaining Capacity (mAh)` | mAh | | `est_time_remaining_s` | `Est. Time Remaining (s)` | s | | `est_time_to_reserve_s` | `Est. Time to Reserve (s)` | s | Per-cell thermal arrays (`T_core`, `T_case`, `R_cell`, …) ride the same entry when the thermal solve runs. A dynamic run's per-sample `"Battery"` entry carries three keys a steady-state result does not: `min_cell_soc_pct`, `max_core_temp_c` and `pack_internal_resistance_mohm`. See [Dynamic outputs](/docs/guides/dynamic-outputs). ## Worked example Runs a single point and a sweep, then prints each key next to its `display_labels` header: Source: backend/sdks/python/examples/outputs/read_steady_state.py ```python """Read a steady-state result: single-point AND sweep share one snake_case schema. Run it: export THRUSTLAB_API_KEY=key_... # never hard-code the key python examples/outputs/read_steady_state.py A completed single-point simulation carries two siblings: * result — a dict keyed per-rotor ("1", "2", ...) plus the "All" and "Battery" aggregates. Every per-rotor and "All" key is canonical snake_case (e.g. thrust_n, total_current_a). * display_labels — {snake_key: human_string}, the single source of truth for a human-readable column header (thrust_n -> "Thrust (N)"). A sweep reuses the EXACT same per-point shape: each point's `rotors` mirrors a single-point `result`, and the same display_labels vocabulary applies. 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 def print_group(name: str, group: dict, labels: dict) -> None: """Print each key -> value alongside its human display label. Scalar keys print as `snake_key Human Label value`. Nested blocks (the relocated `diagnostics` sub-object) print their key set only. """ print(f"\n[{name}]") for key, value in group.items(): label = labels.get(key, key) # "Battery" keys are already human strings if isinstance(value, (dict, list)): kind = "dict" if isinstance(value, dict) else "list" print(f" {key:<28} {label:<28} <{kind}, {len(value)} entries>") else: print(f" {key:<28} {label:<28} {value}") project = client.projects.create(name="sdk read-steady-state 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" ) # ---- single point --------------------------------------------------------- sim = client.simulations.create( project_id=project["id"], battery_component_id=battery["id"], airspeed_m_s=0.0, density_kg_m3=1.225, battery_charge_pct=100, rotor_groups=[ { "label": "main", "count": 4, "motor_component_id": motor["id"], "propeller_component_id": prop["id"], "throttle_pct": 70, } ], ) result = client.simulations.wait(sim["id"], timeout=300) print(f"single-point status: {result['status']}") if result["status"] == "completed": blob = result["result"] labels = result["display_labels"] # {snake_key: human_string} # Per-rotor group under its label index ("1", "2", ...). print_group("rotor 1", blob["1"], labels) # Aggregate roll-ups across all rotors. print_group("All", blob["All"], labels) # The "Battery" entry is passed through as-is: per-cell arrays keyed by human # strings ("Cell voltages (V)", "Charge levels (%)") that are NOT in # display_labels — read them directly. print_group("Battery", blob["Battery"], labels) # ---- sweep (same schema, one point per grid cell) ------------------------- sweep = client.sweeps.create( project_id=project["id"], battery_component_id=battery["id"], airspeed_m_s=0.0, density_kg_m3=1.225, battery_charge_pct=100, rotor_groups=[ { "label": "main", "count": 4, "motor_component_id": motor["id"], "propeller_component_id": prop["id"], "throttle_pct": 70, } ], sweep={ "rotor_sweep_mask": [True], "throttle": {"mode": "range", "start": 10, "stop": 100, "steps": 10}, }, ) sweep_result = client.sweeps.wait(sweep["id"], timeout=600) print(f"\nsweep status: {sweep_result['status']}") if sweep_result["status"] == "completed": # Each point's `rotors` is a single-point result dict; the same snake_case # keys (and the same display_labels vocabulary read above) apply per point. print("\npoint | throttle in | per-rotor thrust_n | total_thrust_n") for pt in client.sweeps.list_points(sweep["id"]): rotor = pt["rotors"]["1"] agg = pt["rotors"]["All"] print( f" {pt['index']:>2} | {pt['inputs']} | " f"{rotor['thrust_n']:.2f} | {agg['total_thrust_n']:.2f}" ) ``` ## Sweeps use the same schema Each sweep point exposes a `rotors` dict with the identical per-rotor / `"All"` / `"Battery"` keys and the same `display_labels` vocabulary: ```python for pt in client.sweeps.list_points("sweep_2c5tQ..."): thrust = pt["rotors"]["1"]["thrust_n"] print(pt["index"], pt["inputs"], thrust) ``` ## See also - [Dynamic outputs](/docs/guides/dynamic-outputs) — samples and time-series. - [`client.simulations`](/docs/sdk/python/simulations) — running a single point. - [`client.sweeps`](/docs/sdk/python/sweeps) — parameter studies. --- # /docs/guides/dynamic-outputs Source: https://thrustlab.com/docs/guides/dynamic-outputs # 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": }`. | | `time` | Explicit observation timestamps in seconds (mirrors `series["time_s"]`). | ```python 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](/docs/guides/steady-state-outputs) for the per-key tables. ```python 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_` 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: ```python 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": , "type": , "severity": , "detail": }`. | `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](/docs/guides/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`. ```json { "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](/docs/guides/errors). ## Worked example Runs a dynamic simulation, then reads the scorecard, a couple of series channels, and the events: Source: backend/sdks/python/examples/outputs/read_dynamic.py ```python """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](/docs/guides/steady-state-outputs) — the per-sample key tables. - [Async resources](/docs/guides/async-resources) — polling vs webhooks for long runs. --- # /docs/guides/creating-components Source: https://thrustlab.com/docs/guides/creating-components # Creating your own components A custom component is `POST /v1/components` with three fields: `type` (`motor` | `battery` | `propeller`), `name`, and a type-specific `spec_json` map. The component is scoped to your account — `source="user"`, `visibility="private"` — and the response echoes an `id` (`comp_...`) you pass to a simulation. In the Python SDK this is `client.components.create`: ```python motor = client.components.create( type="motor", name="My Custom 4260 800Kv", spec_json={ ... }, ) ``` Field names, units, and accepted ranges below match the server-side create validators. The **Required** column is the set the simulator needs for trustworthy results — the same set the in-app component forms enforce, and the create boundary enforces it too: a create (or full `spec_json` update) missing a required field is rejected with a `400` naming the field, its meaning, and its units. Unknown keys are stored as-is; the simulator reads only the fields it recognizes. A successful create or update may additionally carry a non-blocking `warnings` list (each entry has `code`, `param`, `message`) when a value looks physically implausible — for example a motor `R` far outside the expected range for its `kv`, `weight`, and `diameter`, which usually means a milliohm/ohm mix-up or a per-phase value where phase-to-phase is expected. The write always succeeds; treat a warning as a prompt to re-check the datasheet. ## Motor `spec_json` Motor specs are stored as submitted. Supply the full electrical and geometric set below. Inductance `L` and rotor `inertia` are auto-estimated from `kv`, `n`, `diameter`, `length`, and `weight` when omitted — rotor inertia is required for dynamic (time-domain) simulations, and the estimate is only as good as the dimensions and mass you provide. | Field | Meaning | Units | Required | |---|---|---|---| | `kv` | Velocity constant | rev/min per V | Yes | | `n` | Magnet pole count | count (whole number) | Yes | | `R` | Winding resistance, phase-to-phase | Ω | Yes | | `L` | Winding inductance | H | Estimated from `kv`/`n`/`diameter`/`length` if omitted | | `diameter` | Stator/can diameter | mm | Yes — estimation input for `L`/`inertia` | | `length` | Motor body length | mm | Yes — estimation input for `L`/`inertia` | | `inertia` | Rotor inertia | kg·m² | Estimated from `diameter`/`length`/`weight` if omitted; required for dynamic simulations | | `weight` | Motor mass | g | Yes — estimation input for `inertia` | | `u_nominal` | Nominal voltage | V | Yes | | `iq_max` | Max quadrature current | A | Yes — or `power_max` | | `power_max` | Max electrical power | W | Yes — or `iq_max` | | `iq_nominal` | No-load / idle current | A | Yes | | `topology` | `outrunner` \| `inrunner` | — | Optional (default `outrunner`) | | `brand` | Brand label | — | Optional | `R` is the phase-to-phase terminal resistance (the value on a motor's datasheet); the solver converts it to per-phase internally. ## Battery `spec_json` Battery specs are validated at create. A physical pack needs its bounding box, cell layout, capacity, mass, and one internal-resistance figure (`c_rating` or `pack_resistance_mOhm`); a `source` (bench supply) needs only `series_cells` and `source_voltage`. `form_factor` is derived from `chemistry` when omitted and stored. | Field | Meaning | Units | Required | |---|---|---|---| | `chemistry` | `lipo` \| `lihv` \| `nmc` \| `nca` \| `lfp` \| `nimh` \| `source` | — | Yes | | `length_mm` | Pack length | mm (1–2000) | Yes (physical chemistry) | | `width_mm` | Pack width | mm (1–2000) | Yes (physical chemistry) | | `height_mm` | Pack height | mm (1–2000) | Yes (physical chemistry) | | `series_cells` | Cells in series (S) | count (≤ 24) | Yes | | `parallel_cells` | Cells in parallel (P) | count | Yes (physical chemistry) | | `total_capacity_mAh` | Pack capacity | mAh | Yes (physical chemistry) | | `c_rating` | Discharge C-rating | 1/h | Yes (physical chemistry) — or `pack_resistance_mOhm` | | `weight_g` | Pack mass | g | Yes (physical chemistry) | | `source_voltage` | Source output voltage | V | Yes (`source` chemistry) | | `v_min_per_cell` | Per-cell loaded-voltage cutoff override | V | Optional — dynamic (time-domain) runs use this as the depletion cutoff; falls back to the chemistry default when absent | | `form_factor` | `pouch` \| `cylindrical` | — | Derived from chemistry if omitted | | `cell_model` | Cylindrical cell (e.g. `21700`, `AA`) | — | Optional (cylindrical only) | | `pack_style` | `flat` \| `hump` \| `t` | — | Optional (cylindrical only) | | `internal_resistance_mOhm` | Per-cell internal resistance | mΩ | Optional | | `pack_resistance_mOhm` | Whole-pack resistance | mΩ | Alternative to `c_rating` | | `brand` | Brand label | — | Optional | Validation rules: - `chemistry` must be one of the enum above, else `400`. - For any non-`source` chemistry, `length_mm` / `width_mm` / `height_mm` are required, finite, and within 1–2000 mm. - `form_factor` is enum-checked when supplied; derived otherwise (`lipo` / `lihv` / `lfp` → `pouch`; `nmc` / `nca` / `nimh` → `cylindrical`). - `cell_model` must belong to the chemistry's catalog when supplied. - `pack_style` requires a cylindrical `form_factor`. - `series_cells` ≤ 24 and `series_cells × parallel_cells` ≤ 256. - For a physical chemistry, `series_cells`, `parallel_cells`, `total_capacity_mAh`, `weight_g`, and one of `c_rating` / `pack_resistance_mOhm` are required. - A `source` (virtual power source) skips the dimension requirement; it needs only `series_cells` and `source_voltage`. ## Propeller `spec_json` A propeller is defined by its per-station blade geometry — parallel `radius` / `chord` / `twist` arrays, hub → tip — plus its labeled `rotation`; `diameter` and `pitch` name the design. The in-app [propeller creator](/docs/user-guide/propeller-editor) builds these arrays for you. Persisting a propeller requires the **hobbyist tier or higher**. | Field | Meaning | Units | Required | |---|---|---|---| | `diameter` | Propeller diameter | inch | Yes | | `pitch` | Propeller pitch | inch | Yes | | `num_blades` | Blade count | count (1–16) | Optional (default 2) | | `weight` | Propeller mass | g | Yes | | `maxRPM` | Rated maximum RPM | rev/min | Optional | | `inertia` | Blade inertia about the spin axis | kg·m² | Estimated from `weight` + `diameter` if omitted; required for dynamic simulations | | `rotation` | `cw` \| `ccw` \| `both` (top view) | — | Yes | | `radius` | Per-station radii, hub → tip | list (≤ 50), m | Yes | | `chord` | Per-station chords | list (≤ 50), m | Yes | | `twist` | Per-station twist | list (≤ 50), deg | Yes | | `brand` | Brand label | — | Optional | Validation rules: - `num_blades` must be a whole number in 1–16, else `422`. - The per-station arrays (`radius` / `chord` / `twist` / `sweep` / `x_le_over_R`) cap at 50 stations; `sweep` / `x_le_over_R` must be finite. - `radius` / `chord` / `twist` are required as parallel arrays of the same length (≥ 2 stations), with `radius` positive and strictly increasing hub → tip. - `rotation` must be `cw` / `ccw` / `both`. - Persisting a propeller needs `creator_edit` (hobbyist tier); a free account gets `402`. ## Worked example Creates a custom motor, battery, and propeller, then prints each new `id`: Source: backend/sdks/python/examples/components/create_component.py ```python """Create your own custom motor, battery, and propeller from spec_json. Run it: export THRUSTLAB_API_KEY=key_... # never hard-code the key python examples/components/create_component.py A custom component is `POST /v1/components` with three fields: `type` (motor | battery | propeller), `name`, and a type-specific `spec_json` map. The create is scoped to your account (source="user", visibility="private"); the returned resource echoes back an `id` (comp_...) you pass to a simulation. Each create below is `client.components.create(...)`. Field names, units, and accepted ranges come from the server-side validators — see the reference page for the full tables. Notes on validation: * Battery: a physical pack needs chemistry, cell layout, capacity, weight_g, one resistance figure (c_rating or pack_resistance_mOhm), and bounding-box dims; form_factor is derived from chemistry when omitted. * Propeller: per-station radius/chord/twist and rotation are required, and persisting a propeller needs the Pro plan — the create below reports the tier gate cleanly on a free account. """ from thrustlab import Client from thrustlab.exceptions import APIError client = Client() # reads $THRUSTLAB_API_KEY from the environment # ---- motor --------------------------------------------------------------- # Required: kv, n (pole count), R (phase-to-phase winding resistance, ohms), # weight, diameter, length, u_nominal, iq_nominal, and one of iq_max / # power_max. Inductance L and rotor inertia are auto-estimated from # kv/n/diameter/length/weight — inertia is required for dynamic (time-domain) # runs, so give real dimensions and mass. motor = client.components.create( type="motor", name="My Custom 4260 800Kv", spec_json={ "brand": "Custom", "kv": 800.0, # rpm per volt "n": 14, # magnet pole count "R": 0.042, # phase-to-phase winding resistance (ohm) "weight": 120.0, # g "diameter": 42.0, # mm (drives L / inertia estimation) "length": 25.0, # mm "u_nominal": 14.8, # nominal voltage (V) "iq_nominal": 1.1, # no-load / idle current (A) "iq_max": 65.0, # max quadrature current (A) "power_max": 960.0, # max electrical power (W) "topology": "outrunner", }, ) print(f"created motor {motor['id']}") # ---- battery ------------------------------------------------------------- # chemistry in {lipo, lihv, nmc, nca, lfp, nimh, source}. A physical pack needs # its cell layout, capacity, weight_g, one resistance figure (c_rating or # pack_resistance_mOhm), and length/width/height_mm (1..2000 mm). form_factor # is derived (lipo -> pouch) when omitted. Pack cap: series <= 24, S*P <= 256. battery = client.components.create( type="battery", name="My Custom 4S 5000mAh", spec_json={ "brand": "Custom", "chemistry": "lipo", "series_cells": 4, "parallel_cells": 1, "total_capacity_mAh": 5000.0, "c_rating": 75.0, "weight_g": 480.0, "length_mm": 145.0, # required for a physical chemistry "width_mm": 49.0, "height_mm": 33.0, }, ) print(f"created battery {battery['id']}") # ---- propeller (hobbyist tier or higher) --------------------------------- # Required: diameter, pitch, rotation, weight, and the per-station blade # geometry — parallel radius/chord/twist arrays, hub -> tip (<= 50 stations; # radius and chord in METERS, twist in degrees). inertia is required for # dynamic (time-domain) runs and is estimated from weight + diameter when # omitted. try: prop = client.components.create( type="propeller", name="My Custom 10.5x4.5", spec_json={ "brand": "Custom", "diameter": 10.5, # inch "pitch": 4.5, # inch "num_blades": 2, "rotation": "ccw", # top view; "cw" | "ccw" | "both" "weight": 14.0, # g "inertia": 8.3e-5, # kg*m^2 — required for dynamic runs # per-station geometry, hub -> tip (r/R = 0.20 .. 1.00) "radius": [0.0267, 0.0400, 0.0600, 0.0800, 0.1000, 0.1200, 0.1334], # m "chord": [0.016, 0.019, 0.022, 0.021, 0.018, 0.014, 0.008], # m "twist": [30.0, 24.0, 17.0, 12.8, 10.3, 8.6, 7.8], # deg }, ) print(f"created propeller {prop['id']}") except APIError as exc: # A free account 402s here (propeller persistence is a creator_edit gate). print(f"propeller create skipped: {exc}") ``` ## Using a custom component Pass the returned `id` to a simulation exactly like a catalog component: ```python sim = client.simulations.create( project_id=project["id"], battery_component_id=battery["id"], airspeed_m_s=0.0, density_kg_m3=1.225, battery_charge_pct=100, rotor_groups=[{ "label": "main", "count": 4, "motor_component_id": motor["id"], "propeller_component_id": prop["id"], "throttle_pct": 70, }], ) ``` ## See also - [`client.components`](/docs/sdk/python/components) — browsing the catalog. - [Steady-state outputs](/docs/guides/steady-state-outputs) — reading a result. --- # /docs/sdk/python Source: https://thrustlab.com/docs/sdk/python {/* 2026-08-17: this route exported NO metadata, so it had no canonical and its fell back to the docs layout default. MDX pages take a plain `export const metadata`, same contract as the sibling .tsx routes — and an `import` at the top level too, which is how buildMetadata reaches this file. That replaces the inlined NEXT_PUBLIC_SITE_URL expression and adds og:url, og:type and og:site_name alongside the canonical. Added to DOCS_STATIC_PATHS in the same change — the missing metadata and the missing sitemap entry were one omission. */} export const metadata = buildMetadata({ title: "Python SDK: install and first call", description: "The official thrustlab package on PyPI: install, authenticate, and reach every /v1/ resource with retries, pagination and waits built in.", path: "/docs/sdk/python", }); # Python SDK The official `thrustlab` package on [PyPI](https://pypi.org/project/thrustlab/) — a synchronous Python wrapper around the [`/v1/`](/docs/reference) HTTP API. Bearer auth, automatic idempotency keys on mutations, retries with backoff, cursor pagination, blocking waits for async resources, and webhook signature verification, all out of the box. ## Install ```bash pip install thrustlab ``` Requires Python 3.10+. Verify the install: ```bash python -c "import thrustlab; print(thrustlab.__version__)" ``` ## First call Set `$THRUSTLAB_API_KEY` to a key from [`/dashboard/api-keys`](/dashboard/api-keys), then: ```python from thrustlab import Client client = Client() me = client.users.me() print(me["email"], me["tier"]) ``` For the full constructor (custom base URL, timeouts, retries, custom httpx client), see [configuration](/docs/sdk/python/configuration). ## Resources Every public `/v1/` route family is available as an attribute on `Client`. Resources lazy-initialize on first access, so importing `Client` is cheap. | Accessor | Description | |---|---| | [`client.users`](/docs/sdk/python/users) | Read the calling user's profile | | [`client.projects`](/docs/sdk/python/projects) | Workspaces for sims, sweeps, and starred components | | [`client.simulations`](/docs/sdk/python/simulations) | Single-point steady-state simulations (async) | | [`client.sweeps`](/docs/sdk/python/sweeps) | Multi-point parameter studies (async) | | [`client.dynamic_simulations`](/docs/sdk/python/dynamic-simulations) | Time-domain missions: schedule → samples / series / scorecard (async) | | [`client.components`](/docs/sdk/python/components) | Catalog of motors / propellers / batteries / ESCs | | [`client.submissions`](/docs/sdk/python/submissions) | Submit a custom component for moderation | | [`client.starred_components`](/docs/sdk/python/starred-components) | Per-project favorites | | [`client.compute_units`](/docs/sdk/python/compute-units) | Balance and transaction history | | [`client.webhook_endpoints`](/docs/sdk/python/webhook-endpoints) | Register and manage webhook endpoints | | [`client.fmu`](/docs/sdk/python/fmu) | Export a simulation as an FMI 3.0 Co-Simulation FMU (async) | ## Patterns Cross-cutting concerns covered once instead of per-resource: | Topic | Description | |---|---| | [Configuration](/docs/sdk/python/configuration) | Constructor args, env vars, custom httpx client | | [Idempotency](/docs/sdk/python/idempotency) | Auto-generated and explicit `Idempotency-Key`s | | [Pagination](/docs/sdk/python/pagination) | `CursorPager`, auto-iterate, manual cursor | | [Async resources](/docs/sdk/python/async-resources) | `wait()` polling vs webhooks | | [Retries & timeouts](/docs/sdk/python/retries) | Automatic retry policy and tuning | | [Error handling](/docs/sdk/python/errors) | Typed exceptions, `code` catalog, `request_id` | | [Webhook verification](/docs/sdk/python/webhooks) | `Webhook.verify` for incoming deliveries | | [Logging & debugging](/docs/sdk/python/logging) | Logger names, retry traces, httpx logs | | [Versioning & stability](/docs/sdk/python/versioning) | SemVer policy, deprecation timeline | ## Source + issues Source lives in the public [thrustlab-sdk repository](https://github.com/kbedrich/thrustlab-sdk). File issues there or email [support@thrustlab.com](mailto:support@thrustlab.com) with your `request_id`. --- # /docs/sdk/python/configuration Source: https://thrustlab.com/docs/sdk/python/configuration # Configuration The `Client` constructor takes everything you need to talk to the API. Every setting falls back to an environment variable, and the simplest working invocation is `client = Client()` with `$THRUSTLAB_API_KEY` set. ## Constructor signature ```python from thrustlab import Client client = Client( api_key=None, # falls back to $THRUSTLAB_API_KEY base_url=None, # falls back to $THRUSTLAB_BASE_URL, then https://thrustlab.com timeout=30.0, # per-request timeout in seconds max_retries=3, # max retries on 429 / 5xx / network errors http_client=None, # advanced: pass a pre-built httpx.Client ) ``` ## All settings | Setting | Constructor arg | Env var | Default | |---|---|---|---| | API key | `api_key=` | `THRUSTLAB_API_KEY` | required | | Base URL | `base_url=` | `THRUSTLAB_BASE_URL` | `https://thrustlab.com` | | Per-request timeout (s) | `timeout=` | — | `30.0` | | Max retries (per request) | `max_retries=` | — | `3` | | Custom HTTP client | `http_client=` | — | `httpx.Client(timeout=...)` | ## API key The cleanest production setup is to set the env var so the secret never touches your source tree: ```bash export THRUSTLAB_API_KEY=key_... ``` ```python from thrustlab import Client client = Client() # picks up $THRUSTLAB_API_KEY ``` For ephemeral notebooks or CI matrix jobs, the literal form works too: ```python client = Client(api_key="key_...") ``` If both are set, the constructor argument wins. ## Base URL Useful for talking to staging or a self-hosted environment: ```python client = Client( api_key="key_...", base_url="https://staging.thrustlab.com", ) ``` Trailing slashes are stripped. The path `/v1/...` is appended automatically. ## Timeouts `timeout=` is the per-request httpx timeout — it caps the wait on **each** HTTP attempt, not the total wall-clock time. With retries you can wait up to roughly `timeout * (1 + max_retries)` seconds in the worst case. For long-running uploads or image-processing endpoints, pass a custom `httpx.Client` with a granular timeout: ```python import httpx from thrustlab import Client http = httpx.Client(timeout=httpx.Timeout( connect=10.0, read=300.0, write=60.0, pool=10.0, )) client = Client(api_key="key_...", http_client=http) ``` When `http_client=` is set, the `timeout=` argument is ignored — you control timeouts on the httpx client. ## Retries `max_retries=` only applies to **safe** failures: HTTP 429, 500, 502, 503, 504, or transport-level errors (DNS, connect, read timeout). Validation, auth, and not-found errors are surfaced immediately. See [retries & timeouts](/docs/sdk/python/retries) for the backoff schedule. ## Custom httpx client Reasons to drop in your own `httpx.Client`: - Connection pooling across many short-lived `Client` instances - Custom TLS / proxy / CA-bundle config - Sharing transport with other tooling in your stack ```python import httpx from thrustlab import Client http = httpx.Client( timeout=60.0, proxy="http://proxy.example.com:8080", # singular `proxy=` — httpx removed `proxies=` in 0.28 verify="/etc/ssl/custom-ca.crt", ) client = Client(api_key="key_...", http_client=http) ``` The SDK injects `Authorization`, `Idempotency-Key`, and `User-Agent` headers on every request — your own headers (set on the httpx client) are merged unless they collide with one of those reserved names. ## Multi-tenant pattern For tools that run as multiple end users (e.g. a CI orchestrator), construct a fresh `Client` per request rather than mutating a shared one: ```python def for_user(api_key: str) -> Client: return Client(api_key=api_key, http_client=SHARED_HTTPX) ``` Sharing one `httpx.Client` keeps connection pooling efficient; the SDK clients are cheap to construct. ## See also - [Authentication guide](/docs/guides/authentication) - [Retries & timeouts](/docs/sdk/python/retries) - [Logging & debugging](/docs/sdk/python/logging) --- # /docs/sdk/python/users Source: https://thrustlab.com/docs/sdk/python/users # Users resource Accessor: `client.users` Read the authenticated user's profile. ## Methods | Method | Endpoint | Returns | |---|---|---| | `client.users.me()` | `GET /v1/users/me` | The user resource for the API key's owner | ## Identify the calling user Useful for tools that share an API key across environments, or for surfacing "acting as" identity in your own UI. ```python from thrustlab import Client client = Client() me = client.users.me() print(me["id"]) # user_2c5tQ... print(me["email"]) # kyle@example.com print(me["tier"]) # "free" | "hobbyist" | "pro" | "founding" | "beta_tester" | "enterprise" ``` ## The full `me()` response ```json { "object": "user", "id": "user_2c5tQ...", "email": "kyle@example.com", "email_verified": true, "tier": "pro", "unit_system": "metric", "trial_start": "2026-04-01T00:00:00Z", "trial_end": "2026-04-15T00:00:00Z", "trial_days_remaining": 0, "created_at": "2026-03-20T18:04:11Z", "entitlements": { "sim_types": ["batch", "dynamic", "steady_single", "sweep_1d", "sweep_nd"], "db_access": "full", "cad_export": true, "creator_edit": true, "api_access": true, "max_concurrent_runs": 5, "daily_cap": null, "weekly_cap": null }, "gate_required_tiers": { "steady_single": "free", "sweep_1d": "hobbyist", "sweep_nd": "pro", "dynamic": "pro", "batch": "pro", "cad_export": "pro", "creator_edit": "hobbyist", "api_access": "hobbyist" } } ``` | Field | Meaning | |---|---| | `object` | Always `"user"` | | `id` | Public user id (`user_...`) | | `email` | Account email | | `email_verified` | Whether the email has been confirmed | | `tier` | One of `free`, `hobbyist`, `pro`, `founding`, `beta_tester`, `enterprise` | | `unit_system` | Display-unit preference — `metric` or `imperial` (canonical storage is always metric) | | `trial_start` / `trial_end` | Trial window, or `null` if the account never had one | | `trial_days_remaining` | Computed days left in the trial (`null` with no `trial_end`, `0` once expired) | | `created_at` | Account creation timestamp | | `entitlements.sim_types` | Sim types this tier can run — subset of `steady_single`, `sweep_1d`, `sweep_nd`, `dynamic`, `batch` | | `entitlements.db_access` | `subset` or `full` — component-database access | | `entitlements.cad_export` | Whether CAD export is unlocked | | `entitlements.creator_edit` | Whether the component/propeller creator's edit mode is unlocked | | `entitlements.api_access` | Whether this tier may use REST API keys at all | | `entitlements.max_concurrent_runs` | Account-wide executing simulations: Free 1, Pro 5. Dashboard and all API keys share it. | | `entitlements.daily_cap` | Rolling 24h compute-unit cap, or `null` if this tier has no daily cap | | `entitlements.weekly_cap` | Compatibility field; `null` for the current unlimited paid tiers | | `gate_required_tiers` | Global map of every gated capability and sim-type → the cheapest tier that unlocks it (e.g. `api_access` → `hobbyist`). Lets you render "Requires X or higher" prompts without hardcoding tier logic. | Free has a `daily_cap`. Pro returns both cap fields as `null`; its seven-day usage history remains available from the compute-unit summary. ## Confirm an API key works The cheapest live-credential check — `users.me` is a single GET with no side effects. Use this in CI before kicking off real work. ```python from thrustlab import Client from thrustlab.exceptions import AuthenticationError try: client = Client() client.users.me() print("auth ok") except AuthenticationError: raise SystemExit("THRUSTLAB_API_KEY is invalid or revoked") ``` ## See also - [Authentication guide](/docs/guides/authentication) — API key creation, rotation, and bearer auth --- # /docs/sdk/python/projects Source: https://thrustlab.com/docs/sdk/python/projects # Projects resource Accessor: `client.projects` Projects are the top-level workspace for simulations, sweeps, and starred components. Every simulation and sweep belongs to exactly one project. ## Methods | Method | Endpoint | Returns | |---|---|---| | `client.projects.create(name=...)` | `POST /v1/projects` | New project | | `client.projects.list()` | `GET /v1/projects` | `CursorPager` of projects | | `client.projects.retrieve(project_id)` | `GET /v1/projects/{id}` | Single project | | `client.projects.update(project_id, **fields)` | `PATCH /v1/projects/{id}` | Updated project | | `client.projects.delete(project_id)` | `DELETE /v1/projects/{id}` | `None` | ## Create a project ```python from thrustlab import Client client = Client() project = client.projects.create(name="Quad-X build") print(project["id"]) # proj_2c5tQ... ``` ## Project resource fields | Field | Meaning | |---|---| | `object` | Always `"project"` | | `id` | Public project id (`proj_...`) | | `name` | Display name (1-255 chars) | | `aircraft_type_tag` | Optional free-form tag (up to 50 chars), e.g. `"quad"`, `"fixed_wing"` — settable on create/update | | `notes` | Optional free-form notes, no length cap — settable on create/update | | `created_at` | Creation timestamp | | `updated_at` | Last-modified timestamp | | `run_count` | Total simulations + sweeps ever run in this project | | `completed_count` | Runs that finished successfully | | `running_count` | Runs currently in progress | | `failed_count` | Runs that errored out | | `last_activity_at` | Timestamp of the most recent run, or `updated_at` if the project has none | `aircraft_type_tag` and `notes` are the only fields settable via `create()`/`update()` beyond `name`; the `*_count` fields and `last_activity_at` are read-only aggregates computed by the server. ## List projects `list()` returns a [cursor pager](/docs/sdk/python/pagination); iterate to walk every page automatically. ```python for project in client.projects.list(): print(project["id"], project["name"], project["created_at"]) ``` For UI-style "show 20 at a time", pass `limit` and read `.data` directly: ```python page = client.projects.list(limit=20) for project in page.data: print(project["name"]) print(f"more pages? {page.has_more}") ``` ## Rename a project ```python client.projects.update("proj_2c5tQ...", name="Quad-X v2") ``` `update` accepts any patchable field as a keyword argument. See the [API reference](/docs/reference#tag/projects) for the full list. ## Delete a project **Deletion is hard, not soft.** It cascade-deletes the project's simulations, sweeps, and starred components. There is no undo and no soft-delete flag — once the call returns, that history is gone. If you need to retain results, fetch them before deleting the parent project. ```python client.projects.delete("proj_2c5tQ...") # irreversible — cascades to sims, sweeps, starred components ``` ## End-to-end: project + first simulation ```python from thrustlab import Client client = Client() project = client.projects.create(name="hover study") sim = client.simulations.create( project_id=project["id"], battery_component_id="comp_batt_xxx", airspeed_m_s=0.0, density_kg_m3=1.225, battery_charge_pct=100, rotor_groups=[{ "label": "main", "count": 4, "motor_component_id": "comp_motor_xxx", "propeller_component_id": "comp_prop_xxx", "throttle_pct": 70, }], ) result = client.simulations.wait(sim["id"], timeout=300) print(result["status"]) ``` ## See also - [Simulations](/docs/sdk/python/simulations) - [Sweeps](/docs/sdk/python/sweeps) - [Starred components](/docs/sdk/python/starred-components) — per-project favorites --- # /docs/sdk/python/simulations Source: https://thrustlab.com/docs/sdk/python/simulations # Simulations resource Accessor: `client.simulations` Run a single-point steady-state simulation: one airspeed, one density, one battery state-of-charge, one or more rotor groups. Simulations are **asynchronous** — `create` returns immediately with `status: "queued"`. Free executes one simulation at a time and Pro executes up to five. This is an account-wide limit shared by dashboard and API-key submissions. Additional dispatched simulations remain `queued` and begin automatically when a slot is free. Paid usage is unlimited; normal request-rate and global overload controls still apply. ## Methods | Method | Endpoint | Returns | |---|---|---| | `client.simulations.create(project_id=..., **body)` | `POST /v1/simulations` | New simulation in `queued` state | | `client.simulations.list(project_id=..., status=...)` | `GET /v1/simulations` | `CursorPager` of simulations | | `client.simulations.retrieve(simulation_id)` | `GET /v1/simulations/{id}` | Single simulation (current status) | | `client.simulations.run_queued()` | `POST /v1/simulations/run-queued` | `{groups, compiles_needed, rotor_counts}` — dispatches every parked run you own; see [Dispatch parked runs](#dispatch-parked-runs) | | `client.simulations.run_selected(simulation_ids)` | `POST /v1/simulations/run-selected` | Same shape; dispatches a named subset (sweep ids accepted) | | `client.simulations.cancel(simulation_id)` | `POST /v1/simulations/{id}/cancel` | Simulation in `canceled` state | | `client.simulations.cancel_selected(run_ids)` | `POST /v1/simulations/cancel-selected` | `{canceled, requested, skipped}` for runs of any kind — see [Cancel many runs at once](#cancel-many-runs-at-once) | | `client.simulations.cancel_queued(project_id=...)` | `POST /v1/simulations/cancel-queued` | Same shape; every `queued` run you own, optionally one project | | `client.simulations.wait(simulation_id, timeout=600)` | (polls `retrieve`) | Final simulation when terminal | `list`'s `status=` filters to one wire status (`draft`, `queued`, `running`, `completed`, `failed`, or `canceled`); an unrecognized value 422s. `project_id` maps to the server's `project` query parameter. A few endpoints have no SDK method yet — call them with your own HTTP client (see [Authentication](/docs/guides/authentication) for the API key header): | Endpoint | Purpose | |---|---| | `PATCH /v1/simulations/{id}` | Rename, star/unstar, or replace `plot_config_json` | | `DELETE /v1/simulations/{id}` | Delete a non-active (not `queued`/`running`) simulation | | `POST /v1/simulations/{id}/promote` | Promote a `draft` to `queued`/running with the (possibly edited) body | ## Request body `simulations.create` forwards every keyword argument as the JSON body. The required shape: ```python sim = client.simulations.create( project_id="proj_2c5tQ...", battery_component_id="comp_batt_xxx", airspeed_m_s=0.0, # forward velocity (0 = hover) density_kg_m3=1.225, # ISA sea level battery_charge_pct=100, # 0..100 rotor_groups=[{ "label": "main", "count": 4, "motor_component_id": "comp_motor_xxx", "propeller_component_id": "comp_prop_xxx", "throttle_pct": 70, # 0..100 "esc_resistance_mohm": 0, "esc_motor_wire_resistance_mohm": 0, }], ) ``` A `rotor_groups` array supports mixed-rotor designs: a coaxial-twin with two different propellers is two groups with `count: 1` and different `propeller_component_id`s. A quad-X with four identical rotors is one group with `count: 4`. See the [API reference](/docs/reference#tag/simulations) for every field, including optional ESC presets and custom resistance modes. ### Caps `rotor_groups` holds at most 10 entries. Each group's `count` is 1–16, and the sum of every group's `count` in the simulation cannot exceed 16. ## Flight condition `inflow_mode` picks how the flight condition enters the solve — the two modes are mutually exclusive and mixing them 422s: | `inflow_mode` | Sim-level field | Per-group field | |---|---|---| | `"ground"` (default) | `vertical_speed_m_s` (±30 m/s, default 0), alongside `airspeed_m_s` | `tilt_deg` (0–90°, default 0) — rotor-axis tilt from the horizontal-forward flight direction (0° = cruise, 90° = lift/hover) | | `"components"` | — | `v_axial_m_s` (−30..100 m/s), `v_edge_m_s` (0..100 m/s, required together) | `"ground"` rejects any group's `v_axial_m_s`/`v_edge_m_s`; `"components"` rejects `vertical_speed_m_s` and any group's `tilt_deg`, and requires both `v_axial_m_s` and `v_edge_m_s` on every group. ## Custom component overrides Swap in an ad-hoc motor, propeller, or battery without a saved component — `custom_motor` / `custom_propeller` per rotor group, or `custom_battery` sim-level: ```python "custom_propeller": { "base_component_id": "comp_prop_xxx", # optional starting point; omit to build from scratch "name": "Modified 10x5E", "spec_json": {...}, # full component spec } ``` ## Per-group fields Beyond `motor_component_id` / `propeller_component_id` / `throttle_pct`, each rotor group accepts: | Field | Type | Default | Notes | |---|---|---|---| | `motor_cooling_source` | `"cowling"` \| `"prop_exit_velocity"` \| `"custom"` | `prop_exit_velocity` | Still air / prop-slipstream forced convection / custom | | `motor_cooling_velocity_m_s` | float, 0–200 | `None` | Only with `motor_cooling_source="custom"`; 422 otherwise | | `motor_r_th` | float, 0–100 (K/W) | `None` | Direct thermal-resistance override; only with `motor_cooling_source="custom"` | | `motor_t_w` / `motor_t_mag` | float | `None` | Pin winding/magnet temperature (°C); omit to let the model solve it | | `esc_type` | `"foc"` \| `"six_step"` | `six_step` | Commutation type. Selects K_volt, copper_mult, iron_mult, the six-step advance physics and the throttle→duty map — omitting it is not neutral (≈4.6% RPM / 8.9% thrust vs `foc` at full throttle) | | `esc_timing` | `"low"` \| `"medium"` \| `"high"` \| `"auto"` | `medium` | Six-step commutation advance; ignored under FOC | | `esc_pwm_frequency_khz` | float, 8–48 | `24.0` | ESC switching frequency | | `esc_sync_rectification` | bool | `True` | Synchronous rectification | The sim-level `battery_esc_wire_resistance_mohm` (default `0`) and `name` (optional display name) round out the body. ## Thermal & environment inputs | Field | Default | Bounds | Notes | |---|---|---|---| | `ambient_temp_c` | `25.0` | −60..85 | °C | | `flight_regime` | `"static_bench"` | `static_bench` \| `prop_wash_mild` \| `prop_wash_strong` \| `forced_air` | **Deprecated** — use `cooling_source`. Mapped to the equivalent cooling source when `cooling_source` is omitted | | `cooling_source` | `None` | `"static"` \| `"airspeed"` \| `"prop_slipstream"` \| `"forced_air"` | Battery convection source. Wins over `flight_regime` whenever both are set | | `forced_air_velocity_m_s` | `None` | 0–200 | Only meaningful with `cooling_source="forced_air"` | | `flight_duration` | `60.0` | 0..86400, or `null` | Seconds for the time-bounded thermal solve; `null` disables time-bounding | `flight_regime` is deprecated. Set `cooling_source` instead — it is the control the solver reads. If you send only `flight_regime`, it is mapped for you: `prop_wash_mild` and `prop_wash_strong` both become `prop_slipstream`, `forced_air` becomes `forced_air`, and `static_bench` becomes `static`. Sending both is not an error; `cooling_source` wins. ## Launch intent `launch_intent` controls what `create` actually does: | Value | Behavior | |---|---| | `"run"` (default) | Reserves compute units and dispatches immediately. | | `"queue"` | Reserves compute units and parks the run. `dispatched_at` stays `null` until something dispatches it. | | `"draft"` | Persists with no compute-unit reservation, no dispatch, and no rotor-group requirement. | A parked run is dispatched by `client.simulations.run_queued()` or `client.simulations.run_selected([...])` — see [Dispatch parked runs](#dispatch-parked-runs). `launch_intent` works the same way on `sweeps.create`, and both dispatch methods accept `sweep_` ids. Dynamic runs have no parked state: creating one dispatches it. ## Dispatch parked runs `client.simulations.run_queued()` dispatches every parked run the caller owns, across every project. It takes no arguments other than an optional `idempotency_key`. `client.simulations.run_selected(simulation_ids)` dispatches a named subset, 1 to 1000 ids. On the wire, the run-selected body field is `simulation_ids`, not `run_ids` like its cancel-selected sibling, and it accepts `sweep_` ids under that name. `run_queued` is cross-project and takes no query parameters. `cancel_queued` does scope by `project`; the two are not symmetric. Both return `{"groups": [...], "compiles_needed": 0, "rotor_counts": [...]}`. Runs are grouped by rotor count so each group reuses one warm solver; `compiles_needed` is how many distinct rotor counts the batch spans. A batch that spans several rotor counts pays a compile for each. An id that is not `queued` raises with code `invalid_status`. An id already handed to a worker raises with code `already_dispatched`. An id owned by someone else fails the whole call with a 404 and nothing is dispatched. ```python # Park a grid of runs, then flush them together. for body in bodies: client.simulations.create(project_id="proj_xxx", launch_intent="queue", **body) batch = client.simulations.run_queued() print(batch["compiles_needed"], "warm groups:", batch["rotor_counts"]) # Or dispatch a named subset, sweeps included: client.simulations.run_selected(["sim_2c5tQ...", "sweep_2c5tR..."]) ``` Parking still spends a submission against the per-account limit, so the win is pacing when work runs, not avoiding the limit. See [Rate limits](/docs/guides/rate-limits). ## Run synchronously (`wait`) The most common pattern: submit, then block until the simulation is in a terminal state. `wait` polls `retrieve` at `poll_interval=` seconds (default `2.0`) until status leaves `{"queued", "running"}`, or until `timeout=` seconds elapse. Source: backend/sdks/python/examples/simulations/run_sync.py ```python """Single-point simulation: auth -> submit -> wait() -> read the canonical result. Run it: export THRUSTLAB_API_KEY=key_... # never hard-code the key python examples/simulations/run_sync.py The verification combo below (BadAss 2826-820Kv + 10.5x4.5 + Liperior 4S 5000 mAh @ 70% throttle, static) converges to ~9.07 N / ~8505 rpm. 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 single-point example") # Resolve components by name (find() returns the single match or raises # AmbiguousComponentError / NotFoundError). Or paste explicit IDs instead: # 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" ) sim = client.simulations.create( project_id=project["id"], battery_component_id=battery["id"], airspeed_m_s=0.0, density_kg_m3=1.225, battery_charge_pct=100, rotor_groups=[ { "label": "main", "count": 4, "motor_component_id": motor["id"], "propeller_component_id": prop["id"], "throttle_pct": 70, } ], ) print(f"created {sim['id']}, status={sim['status']}") # wait() polls until completed / failed / canceled (or a timeout sentinel). result = client.simulations.wait(sim["id"], timeout=300) print(f"final status: {result['status']}") if result["status"] == "completed": # Canonical snake_case result (post-D-01): per-rotor group under its label # index ("1", "2", ...); aggregate roll-ups under "All". per_rotor = result["result"]["1"] aggregate = result["result"]["All"] print(f"per-rotor thrust: {per_rotor['thrust_n']:.2f} N") print(f"per-rotor rpm: {per_rotor['rpm']:.0f}") print(f"total thrust: {aggregate['total_thrust_n']:.2f} N") ``` If `wait` times out before the server finishes, it returns the resource with `status: "timed_out"` — an **SDK-side sentinel**, never a server state. The *server-side* run keeps going; call `wait()` or `retrieve()` again to resume polling. ```python result = client.simulations.wait(sim["id"], timeout=60) if result["status"] == "timed_out": # Resume later — the server is still computing. final = client.simulations.retrieve(sim["id"]) ``` ## Terminal status values | Status | Meaning | |---|---| | `completed` | Result fields populated under `result`; ready to read. | | `failed` | Inspect `error.code` and `error.message`. | | `canceled` | User-canceled before completion (one 'l' on the wire). | | `timed_out` | SDK-side `wait()` sentinel — the server run is still going. | ## Cancel an in-flight simulation ```python client.simulations.cancel("sim_2c5tQ...") ``` Only a `queued` single-point run can be canceled. It returns `canceled` with a full refund. Canceling a running run returns HTTP 409 with code `already_running`. These runs finish in seconds and cannot be interrupted once running. Canceling a `completed`, `failed` or `canceled` run returns HTTP 409 with code `already_terminal`. A cancel request that loses a race with completion returns 409. Handle both codes; the call is not idempotent. ## Cancel many runs at once Two methods cancel in bulk, and both take ids of **every** run kind — `sim_`, `sweep_` and `dyn_` — so a grid of queued sweeps or a batch of missions goes in one call: ```python result = client.simulations.cancel_selected( ["sweep_2c5tQ...", "sweep_2c5tR...", "dyn_2c5tS..."] ) print(result) # {"canceled": [...], "requested": [...], "skipped": [{"id": "...", "reason": "already_terminal"}]} # Everything you have queued in one project (running runs are left alone): client.simulations.cancel_queued(project_id="proj_2c5tQ...") # ...or across every project: client.simulations.cancel_queued() ``` On the wire these are `POST /v1/simulations/cancel-selected` with a `{"run_ids": [...]}` body and `POST /v1/simulations/cancel-queued` with an optional `?project=proj_…` query — the same API key header as every other call (see [Authentication](/docs/guides/authentication)); no `Idempotency-Key` is required. Running runs are not touched by `cancel_queued` — send them to `cancel_selected`. Every id in `cancel-selected` is ownership-checked before anything is cancelled: one id you do not own (or that does not exist) fails the whole call with `404 resource_missing`, and nothing is cancelled. Up to 1000 ids per call. The response sorts the ids three ways: | Key | Meaning | |---|---| | `canceled` | Terminal now. QUEUED runs of any kind, each with a full refund. | | `requested` | RUNNING sweeps and RUNNING dynamic missions. The request is recorded durably. A sweep stops after the point it is computing; a dynamic mission stops at its next integration window, at most 20 seconds of mission time. Poll `retrieve` or `wait` to see the row turn `canceled`. | | `skipped` | Left alone, with a `reason`: `already_terminal`, `draft` (delete those instead), or `running_single_point` (finishes in seconds). | A canceled dynamic mission keeps what it computed: the stored result carries `run_meta.partial: true` and `run_meta.canceled_at_t_s`, and the compute units are refunded when the worker stops. While a cancel is pending, the run carries `cancel_requested_at`, so a client that reconnects can tell a cancel was already asked for. ## Read the result A `completed` simulation has its computed outputs under `result`, in canonical `snake_case`: per-rotor-group blocks under `"1"`/`"2"`/… and whole-vehicle aggregates under `"All"`. A sibling `display_labels` map translates every key to its human label (full field list in the [API reference](/docs/reference#tag/simulations)): ```python sim = client.simulations.retrieve("sim_2c5tQ...") if sim["status"] == "completed": r = sim["result"]["1"] # per-rotor values for the first group agg = sim["result"]["All"] # whole-vehicle aggregates print(f"thrust: {r['thrust_n']:.2f} N") print(f"current: {r['current_a']:.2f} A") print(f"rpm: {r['rpm']:.0f}") print(f"total thrust: {agg['total_thrust_n']:.2f} N") ``` ## List simulations for a project ```python for sim in client.simulations.list(project_id="proj_2c5tQ...", status="failed"): print(sim["id"], sim["status"], sim["created_at"]) ``` Without `project_id` you get every simulation owned by the calling user across all projects. ## Recipe: fire-and-forget via webhooks For long jobs or batch pipelines, skip polling — register a webhook endpoint and let the server push a `simulation.completed` event when it's done. ```python endpoint = client.webhook_endpoints.create( url="https://your-app.example.com/webhooks/thrustlab", events=["simulation.completed", "simulation.failed"], ) sim = client.simulations.create(project_id="proj_xxx", ...) # Your webhook handler will receive the event when terminal. ``` See [Webhooks](/docs/sdk/python/webhook-endpoints) for endpoint management and [signature verification](/docs/sdk/python/webhooks) for the receiving handler. ## See also - [Async resources](/docs/sdk/python/async-resources) — polling vs webhooks - [Sweeps](/docs/sdk/python/sweeps) — multi-point parameter studies - [Components](/docs/sdk/python/components) — finding `comp_motor_*` / `comp_prop_*` / `comp_batt_*` ids --- # /docs/sdk/python/sweeps Source: https://thrustlab.com/docs/sdk/python/sweeps # Sweeps resource Accessor: `client.sweeps` A **sweep** is a parameter study: pick a base configuration plus one or more parameter ranges, and the server computes a simulation at every combination. Use sweeps to plot thrust vs throttle curves, compare propellers across a fixed motor, or build hover-time vs payload tables. Like simulations, sweeps are **asynchronous** — `create` returns `status: "queued"` immediately. ## Methods | Method | Endpoint | Returns | |---|---|---| | `client.sweeps.create(project_id=..., **body)` | `POST /v1/sweeps` | New sweep in `queued` state | | `client.sweeps.list(project_id=...)` | `GET /v1/sweeps` | `CursorPager` of sweeps | | `client.sweeps.retrieve(sweep_id)` | `GET /v1/sweeps/{id}` | Single sweep (current status) | | `client.sweeps.cancel(sweep_id)` | `POST /v1/sweeps/{id}/cancel` | Sweep in `canceled` state | | `client.sweeps.list_points(sweep_id)` | `GET /v1/sweeps/{id}/points` | `CursorPager` of computed points | | `client.sweeps.wait(sweep_id, timeout=600)` | (polls `retrieve`) | Final sweep when terminal | ## Request body `sweeps.create` takes the same base configuration as a simulation, plus a `sweep` object naming the axes to vary: ```python sweep = client.sweeps.create( project_id="proj_2c5tQ...", battery_component_id="comp_batt_xxx", airspeed_m_s=0.0, density_kg_m3=1.225, battery_charge_pct=100, rotor_groups=[{ "label": "main", "count": 4, "motor_component_id": "comp_motor_xxx", "propeller_component_id": "comp_prop_xxx", "throttle_pct": 70, # baseline; the swept axis overrides it }], sweep={ "rotor_sweep_mask": [True], # which rotor groups sweep the throttle axis "throttle": {"mode": "range", "start": 10, "stop": 100, "steps": 10}, }, ) ``` Numeric axes (`throttle`, `airspeed`, `density`, `battery_charge`, `esc_pwm_frequency_khz`, `vertical_speed`, `tilt`) each take `{"mode": "range", "start": ..., "stop": ..., "steps": ...}` or `{"mode": "custom", "values": [...]}`. `steps` is the NUMBER OF POINTS, not a step size — the name reads the other way. `{"start": 10, "stop": 100, "steps": 10}` gives 10, 20, 30 … 100: an inclusive linspace from `start` to `stop`, not "step by 10". `steps` accepts 2 to 1000. `{"mode": "custom", "values": [...]}` takes an explicit list instead. `esc_timing_values` is an explicit list of timing presets, and `component_axes` sweeps categorical motor/propeller/battery choices. Multiple axes form a Cartesian grid: 2 axes of 5 values each = 25 simulation points. `sweep` also requires `rotor_sweep_mask` — a bool per rotor group, same length as `rotor_groups` — even when no axis needs it. At least one axis (or a non-empty `component_axes`) must actually be armed, or the sweep 422s with "At least one parameter must be swept". The `tilt` axis needs an extra arming step: a `tilt` range alone does nothing — `sweep.tilt_sweep_mask` (bool per rotor group, same length as `rotor_groups`) selects which groups sweep it. Each masked group is an *independent* grid dimension. `vertical_speed` and `tilt` are ground-mode only (see [Flight condition](#flight-condition) below) — under `inflow_mode="components"` either one 422s. See [API reference](/docs/reference#tag/sweeps) for the full body schema. ## Component axes A component axis sweeps a categorical choice: which motor, which propeller, which battery. The shape is `{"axis": "motor"|"propeller"|"battery", "slots": [, ...], "component_ids": [...]}`. It needs at least two ids; a one-value axis is not a sweep and is rejected with `invalid_component_axis`. `slots` indexes `rotor_groups` positionally. More than one slot makes it ONE shared grid dimension applied to each of those slots, not a cross-product. A battery axis must target exactly one slot. `slot` (singular, default 0) is the older single-slot spelling; prefer `slots`. A component axis crosses the numeric axes like any other dimension, so four propellers times four throttles is one 16-point grid. The alternative, a loop of single-point submissions, spends the per-account submission limit and recompiles the solver for every point. See [Rate limits](/docs/guides/rate-limits). Each computed point names the component it was solved with at `point["inputs"]["component"]`, a list of `{axis, slot, id, name}`. On the sweep resource, `sweep_config.component_axes[]` reads back as `{axis, slot, slots, components: [{id, name}]}`. Names and ids only — a sweep never returns a catalog component's spec values. Source: backend/sdks/python/examples/sweeps/component_axis_grid.py ```python """Component axis x throttle: auth -> submit -> wait() -> inspect points. Run it: export THRUSTLAB_API_KEY=key_... # never hard-code the key python examples/sweeps/component_axis_grid.py Crosses a propeller component axis with a throttle range, so one request solves every propeller at every throttle. It takes the first four catalog propellers from client.components.list(type="propeller") and crosses them with four throttle settings: 16 points in one submission. Each returned point names the propeller that produced it at point["inputs"]["component"], a list of {axis, slot, id, name}. Sixteen single-point submissions would be sixteen requests against the per-minute submission limit and sixteen cold solves; this is one request and one warm solver. `sweep.throttle.steps` is the NUMBER OF POINTS, not a step size. """ from thrustlab import Client client = Client() # reads $THRUSTLAB_API_KEY from the environment project = client.projects.create(name="sdk component-axis example") # The fixed half of the configuration — same verification combo as # examples/sweeps/run_and_poll.py. Resolve by name, or paste explicit IDs. motor = client.components.find(name="BadAss 2826-820Kv") battery = client.components.find( name="Liperior 5000mAh 4S 35C 14.8V Lipo Battery With XT90 Plug" ) # The swept half: the first four catalog propellers. `.data` is the first page # of the cursor pager, so this is one request. A component axis needs at least # two IDs — a one-value axis is not a sweep, and the server rejects it with # `invalid_component_axis`. props = client.components.list(type="propeller", limit=4).data prop_ids = [p["id"] for p in props] print("propeller axis:", ", ".join(p["name"] for p in props)) sweep = client.sweeps.create( project_id=project["id"], battery_component_id=battery["id"], airspeed_m_s=0.0, density_kg_m3=1.225, battery_charge_pct=100, # `propeller_component_id` here is the BASE value for slot 0. The component # axis below overrides it at every point, so it only has to be valid. rotor_groups=[ { "label": "main", "count": 4, "motor_component_id": motor["id"], "propeller_component_id": prop_ids[0], "throttle_pct": 70, } ], sweep={ # One bool per rotor group: this group follows the swept throttle axis. "rotor_sweep_mask": [True], # `steps` is the NUMBER OF POINTS, not a step size: 40, 60, 80, 100. "throttle": {"mode": "range", "start": 40, "stop": 100, "steps": 4}, # `slots` indexes rotor_groups. Two or more slots would make this ONE # shared grid dimension applied to each of them, not a cross-product. "component_axes": [ {"axis": "propeller", "slots": [0], "component_ids": prop_ids}, ], }, ) print(f"created {sweep['id']}: {sweep['total_points']} points in one request") result = client.sweeps.wait(sweep["id"], timeout=900) print(f"final status: {result['status']}") if result["status"] == "completed": print("\npropeller | throttle | thrust/rotor (N)") for pt in client.sweeps.list_points(sweep["id"]): # `inputs["component"]` names the component(s) this point was solved # with: one {axis, slot, id, name} entry per active component axis. # It carries names and IDs only — never the component's spec values. chosen = ", ".join(c["name"] for c in pt["inputs"]["component"]) thrust = pt["rotors"]["1"]["thrust_n"] print(f" {chosen:<18} | {pt['inputs']['throttle']:>7}% | {thrust:.2f}") ``` ## Launch intent and parked sweeps | Value | Behavior | |---|---| | `"run"` (default) | Reserves compute units and dispatches immediately. | | `"queue"` | Reserves compute units and parks the sweep. `dispatched_at` stays `null`. | | `"draft"` | Persists with no compute-unit reservation and no dispatch. | A parked sweep is dispatched by `client.simulations.run_queued()` (every parked run you own, across all projects) or `client.simulations.run_selected([...])` (a named subset). Both accept `sweep_` ids even though they live on the [simulations](/docs/sdk/python/simulations) resource. The per-minute submission limit counts `POST /v1/sweeps` calls, including parked sweeps. Parking controls when the work runs. It does not avoid the submission limit. Pace the create calls, or put more points into fewer sweeps by adding axes to one grid instead of submitting many. Dynamic runs have no parked state. ## What the sweep resource tells you `input_snapshot` is the request body as submitted. When the caller omits it, the server stores the body it received, so a stored sweep can be read back exactly as it was asked for. It used to be `null` for sweeps, which meant a field like `cooling_source` or `flight_duration` could not be recovered from the resource at all. `credits_breakdown` is `{points, rotors, cu_per_point_per_rotor, total}`. The cost of a grid is `points x rotors x cu_per_point_per_rotor`, readable rather than inferred from a balance change. `total_points` and `completed_points` give progress; the fraction is `completed_points / total_points`. Timestamps are `created_at`, `dispatched_at`, `started_at` and `completed_at`. Queue wait is `started_at - dispatched_at`. See [Async resources](/docs/guides/async-resources). `cancel_requested_at` is set when a cancel has been accepted for a run that is still going. ## Flight condition `sweeps.create` accepts the same `inflow_mode` as a single-point simulation (`"ground"` default, or `"components"`) — see [Simulations: Flight condition](/docs/sdk/python/simulations#flight-condition) for the field-level rules. `vertical_speed` and `tilt` sweep axes only make sense under `"ground"` mode; a sweep with `inflow_mode="components"` that sets either one 422s. ## Run synchronously (`wait` + `list_points`) The standard pattern: submit, wait for terminal, then iterate the points. `list_points` returns a [cursor pager](/docs/sdk/python/pagination) — let it auto-iterate so you don't have to manage the cursor by hand. ```python sweep = client.sweeps.create(project_id="proj_xxx", ...) result = client.sweeps.wait(sweep["id"], timeout=600, poll_interval=5.0) if result["status"] == "completed": for pt in client.sweeps.list_points(sweep["id"]): print(pt["inputs"]["throttle"], "→", pt["rotors"]["1"]["thrust_n"], "N") ``` `poll_interval=5.0` is a sane setting for sweeps — points compute in batches and the resource updates progressively. ## Read points + plot ```python import matplotlib.pyplot as plt xs, ys = [], [] for pt in client.sweeps.list_points("sweep_2c5tQ..."): xs.append(pt["inputs"]["throttle"]) ys.append(pt["rotors"]["1"]["thrust_n"]) plt.plot(xs, ys, marker="o") plt.xlabel("throttle (%)") plt.ylabel("per-rotor thrust (N)") plt.title("Quad-X hover thrust") plt.show() ``` Each point carries `index`, `inputs` (the axis values for that point, e.g. `{"throttle": 40.0, "airspeed": 0.0, ...}`), and `rotors` — the same canonical `snake_case` shape as a single-point result: per-group blocks under `"1"`/`"2"`/… plus `"All"`/`"Battery"` aggregates. The human-label map lives on the *points* page envelope (`GET /v1/sweeps/{id}/points`), not on the sweep resource itself — and `list_points`'s `CursorPager` only exposes `.data`, not the raw envelope, so `display_labels` isn't reachable through it. To read it, call the points endpoint directly (or see [Steady-state outputs](/docs/guides/steady-state-outputs) — a sweep point uses the identical key vocabulary as a single-point result's `display_labels`). ## Cancel an in-flight sweep ```python client.sweeps.cancel("sweep_2c5tQ...") ``` A `queued` sweep cancels immediately: `status` comes back `canceled` and the compute units are refunded. A `running` sweep gets `202 Accepted` with `status` still `"running"` and `cancel_requested_at` set. The request is recorded durably; the worker stops after the point it is computing and the row turns `canceled` then. Poll `retrieve` or `wait`. `cancel_requested_at` is what makes a pending cancel readable after a reload — before it existed a client had to remember locally that it had asked. A `draft` sweep cannot be canceled (409 — cancel applies to a dispatched run); delete it instead with `DELETE /v1/sweeps/{id}` (no SDK wrapper yet). To cancel many sweeps in one call — a whole grid you queued, say — use `client.simulations.cancel_selected([...])` or `client.simulations.cancel_queued(project_id=...)`; both take sweep ids. See [Cancel many runs at once](/docs/sdk/python/simulations#cancel-many-runs-at-once). ## List sweeps for a project ```python for sweep in client.sweeps.list(project_id="proj_2c5tQ..."): print(sweep["id"], sweep["status"]) ``` ## See also - [Async resources](/docs/sdk/python/async-resources) — sweeps poll the same way as simulations - [Simulations](/docs/sdk/python/simulations) — the request body shape matches one-to-one - [Pagination](/docs/sdk/python/pagination) — `list_points` cursor contract --- # /docs/sdk/python/dynamic-simulations Source: https://thrustlab.com/docs/sdk/python/dynamic-simulations # 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 **asynchronous** — `create` returns `status: "queued"` immediately. ## Methods | Method | Endpoint | Returns | |---|---|---| | `client.dynamic_simulations.create(project_id=..., **body)` | `POST /v1/dynamic-simulations` | New run in `queued` state | | `client.dynamic_simulations.estimate(**body)` | `POST /v1/dynamic-simulations/estimate` | Projected duration, without running the solver | | `client.dynamic_simulations.list(project_id=...)` | `GET /v1/dynamic-simulations` | `CursorPager` 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}/cancel` | `canceled` if queued; otherwise still `running` with `cancel_requested_at` set | | `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. Source: backend/sdks/python/examples/dynamic/run_and_poll.py ```python """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](#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`: ```python 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: | Field | Bounds | Default | Notes | |---|---|---|---| | `duration_s` | > 0 | — | Required unless `until_depleted: true` | | `airspeed_target` | ≥ 0 | `0.0` | m/s | | `airspeed_ramp` | `"step"` \| `"linear"` | `step` | | | `vertical_speed_target` | −30..30 | `0.0` | m/s, signed (+ climb, − descent); ground-mode only | | `vertical_speed_ramp` | `"step"` \| `"linear"` | `step` | | | `throttle_ramp` | `"step"` \| `"linear"` | `step` | Segment-level shorthand for the ramp shape (a per-group `per_group.