thrustlab 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (319) hide show
  1. {thrustlab-0.4.0 → thrustlab-0.5.0}/CHANGELOG.md +23 -0
  2. {thrustlab-0.4.0 → thrustlab-0.5.0}/PKG-INFO +6 -2
  3. thrustlab-0.5.0/examples/fmu/ardupilot_sitl/README.md +80 -0
  4. thrustlab-0.5.0/examples/fmu/ardupilot_sitl/fly_mission.py +225 -0
  5. thrustlab-0.5.0/examples/fmu/ardupilot_sitl/fmu.parm +6 -0
  6. thrustlab-0.5.0/examples/fmu/ardupilot_sitl/plot_mission.py +267 -0
  7. thrustlab-0.5.0/examples/fmu/ardupilot_sitl/quad_x.yaml +62 -0
  8. thrustlab-0.5.0/examples/fmu/ardupilot_sitl/render_mission_video.py +454 -0
  9. thrustlab-0.5.0/examples/fmu/ardupilot_sitl_tilttri/README.md +76 -0
  10. thrustlab-0.5.0/examples/fmu/ardupilot_sitl_tilttri/fly_transition.py +211 -0
  11. thrustlab-0.5.0/examples/fmu/ardupilot_sitl_tilttri/tilttri.parm +30 -0
  12. thrustlab-0.5.0/examples/fmu/ardupilot_sitl_tilttri/tilttri.yaml +128 -0
  13. thrustlab-0.5.0/examples/fmu/ardupilot_sitl_y6/README.md +62 -0
  14. thrustlab-0.5.0/examples/fmu/ardupilot_sitl_y6/plot_coax_split.py +131 -0
  15. thrustlab-0.5.0/examples/fmu/ardupilot_sitl_y6/y6.parm +6 -0
  16. thrustlab-0.5.0/examples/fmu/ardupilot_sitl_y6/y6.yaml +107 -0
  17. thrustlab-0.5.0/examples/fmu/betaflight_sitl/README.md +242 -0
  18. thrustlab-0.5.0/examples/fmu/betaflight_sitl/betaflight_config.txt +41 -0
  19. thrustlab-0.5.0/examples/fmu/betaflight_sitl/betaflight_quad_x.yaml +129 -0
  20. thrustlab-0.5.0/examples/fmu/betaflight_sitl/fly_hover.py +697 -0
  21. thrustlab-0.5.0/examples/fmu/endurance/README.md +31 -0
  22. thrustlab-0.5.0/examples/fmu/endurance/hover_endurance.py +215 -0
  23. thrustlab-0.5.0/examples/fmu/export_and_download.py +80 -0
  24. thrustlab-0.5.0/examples/fmu/gazebo/README.md +63 -0
  25. thrustlab-0.5.0/examples/fmu/gazebo/quad_fmu.sdf +373 -0
  26. thrustlab-0.5.0/examples/fmu/quad_5inch_x.yaml +142 -0
  27. {thrustlab-0.4.0 → thrustlab-0.5.0}/examples/outputs/read_dynamic.py +18 -6
  28. {thrustlab-0.4.0 → thrustlab-0.5.0}/pyproject.toml +15 -1
  29. thrustlab-0.5.0/tests/sitl/__init__.py +1 -0
  30. thrustlab-0.5.0/tests/sitl/ardupilot_parser.py +160 -0
  31. thrustlab-0.5.0/tests/sitl/betaflight_fc.py +119 -0
  32. thrustlab-0.5.0/tests/sitl/conftest.py +47 -0
  33. thrustlab-0.5.0/tests/sitl/mock_fmu.py +249 -0
  34. thrustlab-0.5.0/tests/sitl/test_betaflight_bridge.py +149 -0
  35. thrustlab-0.5.0/tests/sitl/test_betaflight_protocol.py +329 -0
  36. thrustlab-0.5.0/tests/sitl/test_kinematics.py +283 -0
  37. thrustlab-0.5.0/tests/sitl/test_protocol.py +222 -0
  38. thrustlab-0.5.0/tests/sitl/test_rigidbody.py +250 -0
  39. thrustlab-0.5.0/tests/sitl/test_state_machine.py +256 -0
  40. thrustlab-0.5.0/tests/sitl/test_step_path.py +487 -0
  41. thrustlab-0.5.0/tests/sitl/test_tilt_wing.py +315 -0
  42. thrustlab-0.5.0/tests/sitl/test_vehicle.py +273 -0
  43. thrustlab-0.5.0/tests/test_resources/test_fmu.py +148 -0
  44. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_http.py +7 -3
  45. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/__init__.py +10 -0
  46. thrustlab-0.5.0/thrustlab/_models/component_specs_response.py +101 -0
  47. thrustlab-0.5.0/thrustlab/_models/component_specs_response_spec_json.py +47 -0
  48. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/dynamic_resource.py +2 -2
  49. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/entitlements_resource.py +12 -0
  50. thrustlab-0.5.0/thrustlab/_models/fmu_export_accepted_response.py +82 -0
  51. thrustlab-0.5.0/thrustlab/_models/fmu_export_job_error.py +69 -0
  52. thrustlab-0.5.0/thrustlab/_models/fmu_export_status_response.py +393 -0
  53. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/simulation_resource.py +2 -2
  54. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_resource.py +2 -2
  55. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_version.py +1 -1
  56. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/client.py +3 -1
  57. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/resources/dynamic_simulations.py +6 -2
  58. thrustlab-0.5.0/thrustlab/resources/fmu.py +147 -0
  59. thrustlab-0.5.0/thrustlab/sitl/README.md +362 -0
  60. thrustlab-0.5.0/thrustlab/sitl/__init__.py +93 -0
  61. thrustlab-0.5.0/thrustlab/sitl/__main__.py +10 -0
  62. thrustlab-0.5.0/thrustlab/sitl/bridge.py +173 -0
  63. thrustlab-0.5.0/thrustlab/sitl/cli.py +217 -0
  64. thrustlab-0.5.0/thrustlab/sitl/fmu.py +494 -0
  65. thrustlab-0.5.0/thrustlab/sitl/kinematics.py +147 -0
  66. thrustlab-0.5.0/thrustlab/sitl/model.py +66 -0
  67. thrustlab-0.5.0/thrustlab/sitl/protocol.py +240 -0
  68. thrustlab-0.5.0/thrustlab/sitl/rigidbody.py +439 -0
  69. thrustlab-0.5.0/thrustlab/sitl/rpmlog.py +115 -0
  70. thrustlab-0.5.0/thrustlab/sitl/targets/__init__.py +42 -0
  71. thrustlab-0.5.0/thrustlab/sitl/targets/ardupilot.py +221 -0
  72. thrustlab-0.5.0/thrustlab/sitl/targets/base.py +292 -0
  73. thrustlab-0.5.0/thrustlab/sitl/targets/betaflight.py +714 -0
  74. thrustlab-0.5.0/thrustlab/sitl/vehicle.py +687 -0
  75. {thrustlab-0.4.0 → thrustlab-0.5.0}/.gitignore +0 -0
  76. {thrustlab-0.4.0 → thrustlab-0.5.0}/LICENSE +0 -0
  77. {thrustlab-0.4.0 → thrustlab-0.5.0}/NOTICE +0 -0
  78. {thrustlab-0.4.0 → thrustlab-0.5.0}/README.md +0 -0
  79. {thrustlab-0.4.0 → thrustlab-0.5.0}/examples/components/create_component.py +0 -0
  80. {thrustlab-0.4.0 → thrustlab-0.5.0}/examples/dynamic/run_and_poll.py +0 -0
  81. {thrustlab-0.4.0 → thrustlab-0.5.0}/examples/outputs/read_steady_state.py +0 -0
  82. {thrustlab-0.4.0 → thrustlab-0.5.0}/examples/projects/create.py +0 -0
  83. {thrustlab-0.4.0 → thrustlab-0.5.0}/examples/projects/list.py +0 -0
  84. {thrustlab-0.4.0 → thrustlab-0.5.0}/examples/projects/retrieve.py +0 -0
  85. {thrustlab-0.4.0 → thrustlab-0.5.0}/examples/quickstart.py +0 -0
  86. {thrustlab-0.4.0 → thrustlab-0.5.0}/examples/simulations/run_sync.py +0 -0
  87. {thrustlab-0.4.0 → thrustlab-0.5.0}/examples/simulations/wait.py +0 -0
  88. {thrustlab-0.4.0 → thrustlab-0.5.0}/examples/sweeps/run_and_poll.py +0 -0
  89. {thrustlab-0.4.0 → thrustlab-0.5.0}/examples/webhooks/verify_handler.py +0 -0
  90. {thrustlab-0.4.0 → thrustlab-0.5.0}/tests/__init__.py +0 -0
  91. {thrustlab-0.4.0 → thrustlab-0.5.0}/tests/conftest.py +0 -0
  92. {thrustlab-0.4.0 → thrustlab-0.5.0}/tests/test_async_polling.py +0 -0
  93. {thrustlab-0.4.0 → thrustlab-0.5.0}/tests/test_client.py +0 -0
  94. {thrustlab-0.4.0 → thrustlab-0.5.0}/tests/test_components_create.py +0 -0
  95. {thrustlab-0.4.0 → thrustlab-0.5.0}/tests/test_components_find.py +0 -0
  96. {thrustlab-0.4.0 → thrustlab-0.5.0}/tests/test_dynamic_resource.py +0 -0
  97. {thrustlab-0.4.0 → thrustlab-0.5.0}/tests/test_exceptions.py +0 -0
  98. {thrustlab-0.4.0 → thrustlab-0.5.0}/tests/test_http.py +0 -0
  99. {thrustlab-0.4.0 → thrustlab-0.5.0}/tests/test_pagination.py +0 -0
  100. {thrustlab-0.4.0 → thrustlab-0.5.0}/tests/test_resources/__init__.py +0 -0
  101. {thrustlab-0.4.0 → thrustlab-0.5.0}/tests/test_resources/test_components.py +0 -0
  102. {thrustlab-0.4.0 → thrustlab-0.5.0}/tests/test_resources/test_compute_units.py +0 -0
  103. {thrustlab-0.4.0 → thrustlab-0.5.0}/tests/test_resources/test_projects.py +0 -0
  104. {thrustlab-0.4.0 → thrustlab-0.5.0}/tests/test_resources/test_simulations.py +0 -0
  105. {thrustlab-0.4.0 → thrustlab-0.5.0}/tests/test_resources/test_starred_components.py +0 -0
  106. {thrustlab-0.4.0 → thrustlab-0.5.0}/tests/test_resources/test_submissions.py +0 -0
  107. {thrustlab-0.4.0 → thrustlab-0.5.0}/tests/test_resources/test_sweeps.py +0 -0
  108. {thrustlab-0.4.0 → thrustlab-0.5.0}/tests/test_resources/test_users.py +0 -0
  109. {thrustlab-0.4.0 → thrustlab-0.5.0}/tests/test_resources/test_webhook_endpoints.py +0 -0
  110. {thrustlab-0.4.0 → thrustlab-0.5.0}/tests/test_spec_coverage.py +0 -0
  111. {thrustlab-0.4.0 → thrustlab-0.5.0}/tests/test_webhooks.py +0 -0
  112. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/__init__.py +0 -0
  113. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_async_polling.py +0 -0
  114. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/airfoil_breakpoint.py +0 -0
  115. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/airfoil_list_response.py +0 -0
  116. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/airfoil_resource.py +0 -0
  117. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/analyze_geometry_request.py +0 -0
  118. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/analyze_geometry_request_mode.py +0 -0
  119. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/analyze_geometry_request_target_type_type_0.py +0 -0
  120. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/analyze_geometry_response.py +0 -0
  121. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/analyze_geometry_response_envelope_limiting_factor_type_0.py +0 -0
  122. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/analyze_geometry_response_envelope_status_type_0.py +0 -0
  123. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/analyze_station_row.py +0 -0
  124. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/body_import_airfoil_v1_airfoils_import_post.py +0 -0
  125. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/calibrate_accepted_response.py +0 -0
  126. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/component_brand.py +0 -0
  127. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/component_brands_response.py +0 -0
  128. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/component_create_body.py +0 -0
  129. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/component_create_body_spec_json.py +0 -0
  130. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/component_ids_response.py +0 -0
  131. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/component_list_response.py +0 -0
  132. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/component_patch.py +0 -0
  133. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/component_patch_spec_json_type_0.py +0 -0
  134. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/component_resource.py +0 -0
  135. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/component_resource_spec_json.py +0 -0
  136. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/component_sweep_axis_in.py +0 -0
  137. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/component_sweep_axis_in_axis.py +0 -0
  138. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/component_write_response.py +0 -0
  139. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/component_write_response_spec_json.py +0 -0
  140. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/component_write_warning.py +0 -0
  141. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/create_webhook_endpoint_v1_webhook_endpoints_post_response_create_webhook_endpoint_v1_webhook_endpoints_post.py +0 -0
  142. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/credit_balance_resource.py +0 -0
  143. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/credit_bucket_breakdown.py +0 -0
  144. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/credit_bucket_breakdown_type.py +0 -0
  145. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/credit_usage_event_resource.py +0 -0
  146. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/credit_usage_event_resource_type.py +0 -0
  147. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/credit_usage_event_resource_unit_type.py +0 -0
  148. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/credit_usage_list_response.py +0 -0
  149. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/credit_usage_related_resource.py +0 -0
  150. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/credit_usage_related_resource_object.py +0 -0
  151. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/credit_usage_summary_resource.py +0 -0
  152. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/credit_usage_summary_resource_window.py +0 -0
  153. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/design_request.py +0 -0
  154. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/design_request_target_mode.py +0 -0
  155. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/dynamic_create_body.py +0 -0
  156. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/dynamic_estimate_body.py +0 -0
  157. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/dynamic_estimate_resource.py +0 -0
  158. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/dynamic_list_response.py +0 -0
  159. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/dynamic_patch.py +0 -0
  160. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/dynamic_resource_battery_topology_type_0.py +0 -0
  161. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/dynamic_resource_display_labels_type_0.py +0 -0
  162. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/dynamic_resource_error_type_0.py +0 -0
  163. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/dynamic_resource_input_snapshot_type_0.py +0 -0
  164. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/dynamic_resource_result_type_0.py +0 -0
  165. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/dynamic_resource_status.py +0 -0
  166. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/dynamic_rotor_group_in.py +0 -0
  167. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/dynamic_rotor_group_in_esc_timing.py +0 -0
  168. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/dynamic_rotor_group_in_esc_type.py +0 -0
  169. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/dynamic_rotor_group_in_motor_cooling_source.py +0 -0
  170. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/dynamic_rotor_in.py +0 -0
  171. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/dynamic_rotor_in_esc_timing.py +0 -0
  172. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/dynamic_rotor_in_esc_type.py +0 -0
  173. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/dynamic_rotor_in_motor_cooling_source.py +0 -0
  174. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/dynamic_rotor_in_rotation_sense.py +0 -0
  175. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/export_geometry_request.py +0 -0
  176. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/export_geometry_request_format.py +0 -0
  177. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/export_geometry_request_rotation_type_0.py +0 -0
  178. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/fire_test_event_v1_webhook_endpoints_public_id_test_post_response_fire_test_event_v1_webhook_endpoints_public_id_test_post.py +0 -0
  179. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/generate_geometry_request.py +0 -0
  180. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/geometry_response.py +0 -0
  181. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/geometry_style_resource.py +0 -0
  182. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/geometry_styles_list_response.py +0 -0
  183. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/get_delivery_v1_webhook_endpoints_public_id_deliveries_delivery_public_id_get_response_get_delivery_v1_webhook_endpoints_public_id_deliveries_delivery_public_id_get.py +0 -0
  184. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/get_event_v1_events_event_id_get_response_get_event_v1_events_event_id_get.py +0 -0
  185. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/get_public_result_v1_public_results_slug_get_response_get_public_result_v1_public_results_slug_get.py +0 -0
  186. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/get_shared_result_v1_public_share_share_token_get_response_get_shared_result_v1_public_share_share_token_get.py +0 -0
  187. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/get_webhook_endpoint_v1_webhook_endpoints_public_id_get_response_get_webhook_endpoint_v1_webhook_endpoints_public_id_get.py +0 -0
  188. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/http_validation_error.py +0 -0
  189. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/list_deliveries_v1_webhook_endpoints_public_id_deliveries_get_response_list_deliveries_v1_webhook_endpoints_public_id_deliveries_get.py +0 -0
  190. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/list_events_v1_events_get_response_list_events_v1_events_get.py +0 -0
  191. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/list_public_propellers_v1_public_propellers_get_response_list_public_propellers_v1_public_propellers_get.py +0 -0
  192. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/list_webhook_endpoints_v1_webhook_endpoints_get_response_list_webhook_endpoints_v1_webhook_endpoints_get.py +0 -0
  193. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/pack_leaf_in.py +0 -0
  194. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/pack_parallel_in.py +0 -0
  195. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/pack_series_in.py +0 -0
  196. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/pack_topology_in.py +0 -0
  197. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/patch_webhook_endpoint_v1_webhook_endpoints_public_id_patch_response_patch_webhook_endpoint_v1_webhook_endpoints_public_id_patch.py +0 -0
  198. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/project_create_body.py +0 -0
  199. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/project_list_response.py +0 -0
  200. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/project_patch.py +0 -0
  201. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/project_resource.py +0 -0
  202. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/retry_delivery_v1_webhook_endpoints_public_id_deliveries_delivery_public_id_retry_post_response_retry_delivery_v1_webhook_endpoints_public_id_deliveries_delivery_public_id_retry_post.py +0 -0
  203. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/rotate_webhook_secret_v1_webhook_endpoints_public_id_rotate_secret_post_response_rotate_webhook_secret_v1_webhook_endpoints_public_id_rotate_secret_post.py +0 -0
  204. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/rotor_group_in.py +0 -0
  205. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/rotor_group_in_esc_timing.py +0 -0
  206. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/rotor_group_in_esc_type.py +0 -0
  207. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/rotor_group_in_motor_cooling_source.py +0 -0
  208. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/rotor_group_resource.py +0 -0
  209. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/rotor_in.py +0 -0
  210. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/rotor_in_esc_timing.py +0 -0
  211. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/rotor_in_esc_type.py +0 -0
  212. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/rotor_in_motor_cooling_source.py +0 -0
  213. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/rotor_in_rotation_sense.py +0 -0
  214. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/run_queued_v1v1_simulations_run_queued_post_response_run_queued_v1v1_simulations_run_queued_post.py +0 -0
  215. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/run_selected_body.py +0 -0
  216. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/run_selected_v1v1_simulations_run_selected_post_response_run_selected_v1v1_simulations_run_selected_post.py +0 -0
  217. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/schedule_in.py +0 -0
  218. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/schedule_in_interpolation_type_0.py +0 -0
  219. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/schedule_in_mode.py +0 -0
  220. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/segment_group_command_in.py +0 -0
  221. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/segment_group_command_in_throttle_ramp_type_0.py +0 -0
  222. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/segment_group_command_in_tilt_ramp_type_0.py +0 -0
  223. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/segment_in.py +0 -0
  224. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/segment_in_airspeed_ramp.py +0 -0
  225. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/segment_in_per_group.py +0 -0
  226. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/segment_in_throttle_ramp.py +0 -0
  227. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/segment_in_vertical_speed_ramp.py +0 -0
  228. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/share_resource.py +0 -0
  229. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/simulation_cancel_body.py +0 -0
  230. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/simulation_create_body.py +0 -0
  231. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/simulation_create_body_cooling_source_type_0.py +0 -0
  232. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/simulation_create_body_flight_regime.py +0 -0
  233. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/simulation_create_body_inflow_mode.py +0 -0
  234. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/simulation_create_body_launch_intent.py +0 -0
  235. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/simulation_inputs.py +0 -0
  236. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/simulation_list_response.py +0 -0
  237. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/simulation_patch.py +0 -0
  238. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/simulation_patch_plot_config_json_type_0_item.py +0 -0
  239. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/simulation_resource_battery_topology_type_0.py +0 -0
  240. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/simulation_resource_display_labels_type_0.py +0 -0
  241. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/simulation_resource_error_type_0.py +0 -0
  242. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/simulation_resource_input_snapshot_type_0.py +0 -0
  243. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/simulation_resource_plot_config_json_type_0_item.py +0 -0
  244. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/simulation_resource_result_type_0.py +0 -0
  245. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/simulation_resource_result_type_0_additional_property.py +0 -0
  246. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/simulation_resource_status.py +0 -0
  247. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/spline_data.py +0 -0
  248. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/starred_component_create_body.py +0 -0
  249. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/starred_component_list_response.py +0 -0
  250. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/starred_component_resource.py +0 -0
  251. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/submission_create_body.py +0 -0
  252. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/submission_create_body_data_json.py +0 -0
  253. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/submission_list_response.py +0 -0
  254. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/submission_patch.py +0 -0
  255. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/submission_patch_data_json_type_0.py +0 -0
  256. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/submission_resource.py +0 -0
  257. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/submission_resource_data_json.py +0 -0
  258. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_cancel_body.py +0 -0
  259. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_config_in.py +0 -0
  260. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_config_in_esc_timing_values_type_0_item.py +0 -0
  261. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_create_body.py +0 -0
  262. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_create_body_cooling_source.py +0 -0
  263. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_create_body_inflow_mode.py +0 -0
  264. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_create_body_launch_intent.py +0 -0
  265. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_inputs.py +0 -0
  266. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_list_response.py +0 -0
  267. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_param_range_in.py +0 -0
  268. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_param_range_in_mode.py +0 -0
  269. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_patch.py +0 -0
  270. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_patch_plot_config_json_type_0_item.py +0 -0
  271. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_point_component_selection.py +0 -0
  272. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_point_inputs.py +0 -0
  273. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_point_list_response.py +0 -0
  274. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_point_list_response_display_labels_type_0.py +0 -0
  275. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_point_patch.py +0 -0
  276. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_point_resource.py +0 -0
  277. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_point_resource_rotors_type_0.py +0 -0
  278. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_point_resource_rotors_type_0_additional_property.py +0 -0
  279. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_resource_error_type_0.py +0 -0
  280. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_resource_input_snapshot_type_0.py +0 -0
  281. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_resource_plot_config_json_type_0_item.py +0 -0
  282. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_resource_status.py +0 -0
  283. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_resource_summary_type_0.py +0 -0
  284. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_resource_sweep_config_type_0.py +0 -0
  285. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_rotor_group_in.py +0 -0
  286. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_rotor_group_in_esc_timing.py +0 -0
  287. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_rotor_group_in_esc_type.py +0 -0
  288. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_rotor_group_in_motor_cooling_source.py +0 -0
  289. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_rotor_in.py +0 -0
  290. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_rotor_in_esc_timing.py +0 -0
  291. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_rotor_in_esc_type.py +0 -0
  292. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_rotor_in_motor_cooling_source.py +0 -0
  293. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/sweep_rotor_in_rotation_sense.py +0 -0
  294. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/termination_in.py +0 -0
  295. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/termination_in_mode.py +0 -0
  296. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/types.py +0 -0
  297. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/user_resource.py +0 -0
  298. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/user_resource_gate_required_tiers.py +0 -0
  299. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/user_resource_unit_system.py +0 -0
  300. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/v1_custom_component_override.py +0 -0
  301. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/v1_custom_component_override_spec_json.py +0 -0
  302. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/v1_health_v1_health_get_response_v1_health_v1_health_get.py +0 -0
  303. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/validation_error.py +0 -0
  304. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/webhook_endpoint_create.py +0 -0
  305. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_models/webhook_endpoint_patch.py +0 -0
  306. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/_pagination.py +0 -0
  307. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/exceptions.py +0 -0
  308. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/resources/__init__.py +0 -0
  309. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/resources/_base.py +0 -0
  310. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/resources/components.py +0 -0
  311. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/resources/compute_units.py +0 -0
  312. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/resources/projects.py +0 -0
  313. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/resources/simulations.py +0 -0
  314. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/resources/starred_components.py +0 -0
  315. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/resources/submissions.py +0 -0
  316. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/resources/sweeps.py +0 -0
  317. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/resources/users.py +0 -0
  318. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/resources/webhook_endpoints.py +0 -0
  319. {thrustlab-0.4.0 → thrustlab-0.5.0}/thrustlab/webhooks.py +0 -0
@@ -8,6 +8,29 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.5.0] - 2026-08-31
12
+
13
+ ### Added
14
+
15
+ - `client.fmu` can export a completed simulation as an FMI 3.0 co-simulation
16
+ FMU with per-rotor inputs and outputs, battery and thermal state, and a
17
+ validity envelope. Exports can be enqueued, checked with paced polling,
18
+ downloaded when ready, or canceled.
19
+ - The new `thrustlab.sitl` module flies exported FMUs in ArduPilot SITL and
20
+ Betaflight SITL using a six-degree-of-freedom rigid body, per-rotor servo
21
+ mapping, and battery voltage, current, and airspeed feedback to the firmware.
22
+ The `thrustlab-sitl` console command launches the bridge.
23
+ - The new `examples/fmu` gallery includes quadcopter and Y6 SITL missions, an
24
+ endurance study, and replay rendering.
25
+ - Generated models now cover component datasheets and the `component_specs`
26
+ entitlement flag.
27
+
28
+ ### Changed
29
+
30
+ - NumPy, PyYAML, and FMPy are now included in the core installation, so
31
+ `pip install thrustlab` provides everything needed for SITL. The `[sitl]`
32
+ extra remains available as an empty compatibility alias.
33
+
11
34
  ## [0.4.0] - 2026-08-27
12
35
 
13
36
  Contract refresh for the production `/v1/` API. This release adds the current
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.5
1
+ Metadata-Version: 2.4
2
2
  Name: thrustlab
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Official Python SDK for the ThrustLab API
5
5
  Project-URL: Homepage, https://thrustlab.com
6
6
  Project-URL: Documentation, https://thrustlab.com/docs
@@ -20,14 +20,18 @@ Classifier: Programming Language :: Python :: 3.12
20
20
  Classifier: Programming Language :: Python :: 3.13
21
21
  Requires-Python: >=3.10
22
22
  Requires-Dist: attrs>=23.0
23
+ Requires-Dist: fmpy>=0.3.31
23
24
  Requires-Dist: httpx<1.0,>=0.27
25
+ Requires-Dist: numpy>=1.24
24
26
  Requires-Dist: pydantic<3.0,>=2.7
25
27
  Requires-Dist: python-dateutil>=2.8
28
+ Requires-Dist: pyyaml>=6.0
26
29
  Provides-Extra: dev
27
30
  Requires-Dist: openapi-python-client==0.28.4; extra == 'dev'
28
31
  Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
29
32
  Requires-Dist: pytest>=8; extra == 'dev'
30
33
  Requires-Dist: respx>=0.21; extra == 'dev'
34
+ Provides-Extra: sitl
31
35
  Description-Content-Type: text/markdown
32
36
 
33
37
  # thrustlab — official Python SDK for the ThrustLab API
@@ -0,0 +1,80 @@
1
+ # ArduPilot SITL — fly your exported powertrain
2
+
3
+ Fly a real ArduPilot mission where the vehicle physics is YOUR ThrustLab
4
+ powertrain: per-rotor thrust and torque, true rotor spin-up, battery sag and
5
+ state of charge — all from the `.fmu` you exported.
6
+
7
+ ## What you need
8
+
9
+ - An exported FMU (`examples/fmu/export_and_download.py`, or the dashboard's
10
+ Share menu → Export powertrain).
11
+ - `pip install thrustlab` — the SDK ships the JSON-backend physics bridge
12
+ (`thrustlab.sitl`).
13
+ - An ArduPilot SITL build (`./waf copter`), any recent Copter.
14
+ - `pip install pymavlink matplotlib numpy` for the mission script and plots.
15
+
16
+ ## Run it (three terminals)
17
+
18
+ 1. The bridge listens first, with your FMU and a vehicle layout. The rpm log
19
+ goes into `mission_out/` so the plot step finds every recording in one
20
+ place (create the directory first — the bridge won't):
21
+
22
+ ```sh
23
+ mkdir -p mission_out
24
+ python -m thrustlab.sitl \
25
+ --fmu my-powertrain.fmu --vehicle quad_x.yaml \
26
+ --rpm-log mission_out/mission_rpm_log.csv
27
+ ```
28
+
29
+ `quad_x.yaml` maps servo outputs to rotors (positions, spin directions,
30
+ mass). Start from the annotated `../quad_5inch_x.yaml` and edit mass
31
+ and geometry to your airframe.
32
+
33
+ 2. ArduPilot with the JSON model — run from your ArduPilot checkout, and
34
+ point at this example directory's `fmu.parm` by absolute path:
35
+
36
+ ```sh
37
+ ./build/sitl/bin/arducopter --model JSON:127.0.0.1 \
38
+ --defaults Tools/autotest/default_params/copter.parm,/path/to/examples/fmu/ardupilot_sitl/fmu.parm -I0
39
+ ```
40
+
41
+ `fmu.parm` needs at least `BATT_MONITOR 4` (the bridge feeds pack voltage
42
+ and current back into the firmware's battery monitor).
43
+
44
+ 3. The mission:
45
+
46
+ ```sh
47
+ python fly_mission.py --out-dir ./mission_out
48
+ ```
49
+
50
+ It uploads a multi-leg AUTO mission (takeoff, 13 m/s dash, climb, loiter
51
+ turns, fast descent, RTL), flies it, and records MAVLink telemetry beside
52
+ the bridge's per-rotor log.
53
+
54
+ ## The plots
55
+
56
+ ```sh
57
+ python plot_mission.py ./mission_out ./mission_out/plots
58
+ ```
59
+
60
+ Three figures from the recorded flight: the mission track colored by
61
+ electrical power, the powertrain time series (per-rotor speed, bus voltage
62
+ and current, power and state of charge), and the loiter-exit close-up where
63
+ the mixer splits the four rotors.
64
+
65
+ The same recording also renders as an animated replay — the track draws
66
+ itself colored by power while live panels play the powertrain through the
67
+ mission:
68
+
69
+ ```sh
70
+ python render_mission_video.py ./mission_out ./mission_out/video
71
+ ```
72
+
73
+ Frames land in `video/frames/`; with ffmpeg on PATH the mp4 is encoded
74
+ automatically, otherwise the exact encode command is printed.
75
+
76
+ Per-rotor RPM never crosses the ArduPilot JSON protocol — the protocol has no
77
+ RPM lane — so the bridge logs it to `mission_out/mission_rpm_log.csv` for
78
+ reconciliation against flight logs. If you changed the vehicle mass in
79
+ `quad_x.yaml`, set `WEIGHT_N` at the top of `plot_mission.py` to match — it
80
+ aligns the two recordings' clocks on the takeoff event.
@@ -0,0 +1,225 @@
1
+ """Fly a multi-leg AUTO mission against a running ArduPilot SITL whose physics
2
+ is your exported ThrustLab FMU (via thrustlab.sitl), recording MAVLink
3
+ telemetry for the plot script.
4
+
5
+ Usage: python fly_mission.py [--connect tcp:127.0.0.1:5760] [--out-dir .]
6
+
7
+ Writes <out-dir>/mission_telemetry.csv and <out-dir>/mission_events.csv; the
8
+ per-rotor truth (rpm, thrust, bus V/I, SOC) is the bridge's own --rpm-log CSV.
9
+ """
10
+ import argparse
11
+ import csv
12
+ import math
13
+ import sys
14
+ import time
15
+ from pathlib import Path
16
+
17
+ from pymavlink import mavutil
18
+
19
+ M_PER_DEG_LAT = 111320.0
20
+ WALL_CAP_S = 900.0
21
+
22
+
23
+ def build_mission(home_lat, home_lon, speed_scale=1.0):
24
+ frame = mavutil.mavlink.MAV_FRAME_GLOBAL_RELATIVE_ALT_INT
25
+ mav = mavutil.mavlink
26
+ items = []
27
+
28
+ def spd(m_s):
29
+ return max(2.0, m_s * speed_scale)
30
+
31
+ def add(cmd, p1=0, p2=0, p3=0, p4=0, dn=0.0, de=0.0, alt=0.0):
32
+ lat = home_lat + dn / M_PER_DEG_LAT
33
+ lon = home_lon + de / (M_PER_DEG_LAT * math.cos(math.radians(home_lat)))
34
+ items.append((len(items), frame, cmd, p1, p2, p3, p4,
35
+ int(lat * 1e7), int(lon * 1e7), float(alt)))
36
+
37
+ add(mav.MAV_CMD_NAV_WAYPOINT) # home slot
38
+ add(mav.MAV_CMD_NAV_TAKEOFF, alt=10.0)
39
+ add(mav.MAV_CMD_DO_CHANGE_SPEED, p1=1, p2=spd(13.0))
40
+ add(mav.MAV_CMD_NAV_WAYPOINT, dn=220.0, de=0.0, alt=15.0) # fast dash
41
+ add(mav.MAV_CMD_DO_CHANGE_SPEED, p1=1, p2=spd(6.0))
42
+ add(mav.MAV_CMD_NAV_WAYPOINT, dn=220.0, de=140.0, alt=32.0) # slow climb
43
+ add(mav.MAV_CMD_NAV_LOITER_TURNS, p1=2, p3=25.0, dn=220.0, de=140.0, alt=32.0)
44
+ add(mav.MAV_CMD_DO_CHANGE_SPEED, p1=1, p2=spd(14.0))
45
+ add(mav.MAV_CMD_NAV_WAYPOINT, dn=60.0, de=60.0, alt=14.0) # fast descent
46
+ add(mav.MAV_CMD_DO_CHANGE_SPEED, p1=1, p2=spd(5.0))
47
+ add(mav.MAV_CMD_NAV_WAYPOINT, dn=25.0, de=0.0, alt=8.0) # slow approach
48
+ add(mav.MAV_CMD_NAV_RETURN_TO_LAUNCH)
49
+ return items
50
+
51
+
52
+ def upload_mission(m, items):
53
+ m.mav.mission_clear_all_send(m.target_system, m.target_component)
54
+ m.recv_match(type="MISSION_ACK", blocking=True, timeout=5)
55
+ m.mav.mission_count_send(m.target_system, m.target_component, len(items))
56
+ t0 = time.time()
57
+ while time.time() - t0 < 60:
58
+ msg = m.recv_match(
59
+ type=["MISSION_REQUEST", "MISSION_REQUEST_INT", "MISSION_ACK"],
60
+ blocking=True, timeout=10,
61
+ )
62
+ if msg is None:
63
+ raise RuntimeError("mission upload stalled")
64
+ if msg.get_type() == "MISSION_ACK":
65
+ if msg.type == mavutil.mavlink.MAV_MISSION_ACCEPTED:
66
+ print(f"mission accepted ({len(items)} items)", flush=True)
67
+ return
68
+ raise RuntimeError(f"mission rejected: ack type {msg.type}")
69
+ s, frame, cmd, p1, p2, p3, p4, lat, lon, alt = items[msg.seq]
70
+ m.mav.mission_item_int_send(
71
+ m.target_system, m.target_component, s, frame, cmd,
72
+ 1 if s == 0 else 0, 1, p1, p2, p3, p4, lat, lon, alt,
73
+ )
74
+ raise RuntimeError("mission upload timed out")
75
+
76
+
77
+ def main():
78
+ ap = argparse.ArgumentParser()
79
+ ap.add_argument("--connect", default="tcp:127.0.0.1:5760")
80
+ ap.add_argument("--out-dir", type=Path, default=Path("."))
81
+ ap.add_argument("--speed-scale", type=float, default=1.0,
82
+ help="scale every mission leg speed (floor 2 m/s). A coax "
83
+ "export's edgewise envelope is tighter than a solo "
84
+ "rotor's; ~0.3 keeps a Y6 inside it.")
85
+ args = ap.parse_args()
86
+ args.out_dir.mkdir(parents=True, exist_ok=True)
87
+
88
+ m = mavutil.mavlink_connection(args.connect, source_system=250)
89
+ m.wait_heartbeat(timeout=120)
90
+ print(f"heartbeat from sys {m.target_system}", flush=True)
91
+
92
+ # Let the EKF settle; keep the streams coming at 10 Hz.
93
+ t0 = time.time()
94
+ while time.time() - t0 < 40:
95
+ m.mav.request_data_stream_send(
96
+ m.target_system, m.target_component,
97
+ mavutil.mavlink.MAV_DATA_STREAM_ALL, 10, 1,
98
+ )
99
+ st = m.recv_match(type="STATUSTEXT", blocking=False)
100
+ if st:
101
+ print("statustext:", st.text, flush=True)
102
+ time.sleep(0.5)
103
+
104
+ # Home = where the vehicle sits now.
105
+ gpi = m.recv_match(type="GLOBAL_POSITION_INT", blocking=True, timeout=10)
106
+ home_lat, home_lon = gpi.lat / 1e7, gpi.lon / 1e7
107
+ upload_mission(m, build_mission(home_lat, home_lon, args.speed_scale))
108
+
109
+ m.set_mode("GUIDED")
110
+ # GUIDED refuses to arm until the EKF is actually fusing GPS, and how
111
+ # long that takes in SIM time depends on the machine (a heavy FMU lowers
112
+ # the effective speedup). Retry instead of racing, and surface every
113
+ # refusal text instead of hanging in motors_armed_wait().
114
+ arm_deadline = time.time() + 300
115
+ armed = False
116
+ while not armed:
117
+ if time.time() > arm_deadline:
118
+ raise RuntimeError("arming timed out — see the refusal texts above")
119
+ m.arducopter_arm()
120
+ drain_until = time.time() + 5.0
121
+ while time.time() < drain_until:
122
+ msg = m.recv_match(type=["HEARTBEAT", "STATUSTEXT"],
123
+ blocking=True, timeout=1)
124
+ if msg is None:
125
+ continue
126
+ if msg.get_type() == "STATUSTEXT":
127
+ print("statustext:", msg.text, flush=True)
128
+ elif (msg.get_srcSystem() == m.target_system
129
+ and msg.base_mode
130
+ & mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED):
131
+ armed = True
132
+ break
133
+ print("armed", flush=True)
134
+ m.set_mode("AUTO")
135
+ for _ in range(10):
136
+ m.mav.command_long_send(
137
+ m.target_system, m.target_component,
138
+ mavutil.mavlink.MAV_CMD_MISSION_START, 0, 0, 0, 0, 0, 0, 0, 0,
139
+ )
140
+ ack = m.recv_match(type="COMMAND_ACK", blocking=True, timeout=3)
141
+ if ack and ack.command == mavutil.mavlink.MAV_CMD_MISSION_START:
142
+ if ack.result == mavutil.mavlink.MAV_RESULT_ACCEPTED:
143
+ break
144
+ time.sleep(1)
145
+
146
+ tele = open(args.out_dir / "mission_telemetry.csv", "w", newline="")
147
+ tw = csv.writer(tele)
148
+ tw.writerow(["t_s", "lat", "lon", "alt_rel_m", "vn_m_s", "ve_m_s", "vd_m_s",
149
+ "groundspeed_m_s", "climb_m_s", "throttle_pct",
150
+ "roll_deg", "pitch_deg", "yaw_deg",
151
+ "voltage_V", "current_A", "battery_pct", "mode"])
152
+ ev = open(args.out_dir / "mission_events.csv", "w", newline="")
153
+ ew = csv.writer(ev)
154
+ ew.writerow(["t_s", "kind", "detail"])
155
+
156
+ last = {"vfr": None, "att": None, "sys": None, "mode": None, "gpi": None}
157
+ armed, was_flying, rows = True, False, 0
158
+ wall0 = time.time()
159
+
160
+ def now_s():
161
+ return last["gpi"].time_boot_ms / 1000.0 if last["gpi"] else 0.0
162
+
163
+ while time.time() - wall0 < WALL_CAP_S:
164
+ msg = m.recv_match(blocking=True, timeout=10)
165
+ if msg is None:
166
+ continue
167
+ t = msg.get_type()
168
+ if t == "VFR_HUD":
169
+ last["vfr"] = msg
170
+ elif t == "ATTITUDE":
171
+ last["att"] = msg
172
+ elif t == "SYS_STATUS":
173
+ last["sys"] = msg
174
+ elif t == "HEARTBEAT":
175
+ mode = mavutil.mode_string_v10(msg)
176
+ if mode != last["mode"]:
177
+ ew.writerow([f"{now_s():.2f}", "mode", mode])
178
+ print(f"mode -> {mode}", flush=True)
179
+ last["mode"] = mode
180
+ armed = bool(msg.base_mode & mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED)
181
+ if was_flying and not armed:
182
+ print("disarmed after flight — mission complete", flush=True)
183
+ break
184
+ elif t == "MISSION_ITEM_REACHED":
185
+ ew.writerow([f"{now_s():.2f}", "wp", msg.seq])
186
+ print(f"reached wp {msg.seq}", flush=True)
187
+ elif t == "STATUSTEXT":
188
+ ew.writerow([f"{now_s():.2f}", "text", msg.text])
189
+ elif t == "GLOBAL_POSITION_INT":
190
+ last["gpi"] = msg
191
+ if msg.relative_alt / 1000.0 > 2.0:
192
+ was_flying = True
193
+ vfr, att, s = last["vfr"], last["att"], last["sys"]
194
+ tw.writerow([
195
+ f"{msg.time_boot_ms / 1000.0:.2f}", msg.lat / 1e7, msg.lon / 1e7,
196
+ f"{msg.relative_alt / 1000.0:.2f}",
197
+ f"{msg.vx / 100.0:.2f}", f"{msg.vy / 100.0:.2f}", f"{msg.vz / 100.0:.2f}",
198
+ f"{vfr.groundspeed:.2f}" if vfr else "",
199
+ f"{vfr.climb:.2f}" if vfr else "",
200
+ vfr.throttle if vfr else "",
201
+ f"{math.degrees(att.roll):.1f}" if att else "",
202
+ f"{math.degrees(att.pitch):.1f}" if att else "",
203
+ f"{math.degrees(att.yaw):.1f}" if att else "",
204
+ f"{s.voltage_battery / 1000.0:.3f}" if s else "",
205
+ f"{s.current_battery / 100.0:.2f}" if s else "",
206
+ s.battery_remaining if s else "",
207
+ last["mode"] or "",
208
+ ])
209
+ rows += 1
210
+
211
+ tele.close()
212
+ ev.close()
213
+ print(f"telemetry rows: {rows}", flush=True)
214
+ if not was_flying:
215
+ print("FAIL: never left the ground", flush=True)
216
+ return 2
217
+ if armed:
218
+ print("FAIL: still armed at wall cap", flush=True)
219
+ return 1
220
+ print("MISSION COMPLETE", flush=True)
221
+ return 0
222
+
223
+
224
+ if __name__ == "__main__":
225
+ sys.exit(main())
@@ -0,0 +1,6 @@
1
+ # Parameters the FMU bridge flight needs on top of copter.parm.
2
+ FRAME_CLASS 1
3
+ FRAME_TYPE 1
4
+ # The bridge feeds pack voltage/current back over the JSON protocol; monitor
5
+ # type 4 (analog V+I) is how the firmware reads them.
6
+ BATT_MONITOR 4
@@ -0,0 +1,267 @@
1
+ """Render a recorded FMU mission into flight-report PNGs.
2
+
3
+ Reads the bridge's per-rotor CSV (FMU truth: rpm, thrust, bus V/I, SOC) and
4
+ the mission telemetry/events CSVs written by fly_mission.py, aligns the two
5
+ clocks on the takeoff event, and renders three figures:
6
+
7
+ fmu_mission_track.png top-down track colored by electrical power + altitude band
8
+ fmu_mission_timeseries.png the powertrain story over time (rpm, V/I, power, SOC)
9
+ fmu_mission_loiter.png loiter close-up: per-rotor rpm split vs roll
10
+
11
+ Usage: python plot_mission.py <data_dir> <out_dir>
12
+ <data_dir> holds mission_rpm_log.csv (from the bridge's --rpm-log),
13
+ mission_telemetry.csv and mission_events.csv (from fly_mission.py).
14
+ """
15
+ import csv
16
+ import math
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ import matplotlib
21
+
22
+ matplotlib.use("Agg")
23
+ import matplotlib.pyplot as plt
24
+ import numpy as np
25
+
26
+ # Gruvbox Soft (dark) — frontend/src/app/gruvbox-soft.css
27
+ BG = "#111111"
28
+ SURFACE = "#1b1b1b"
29
+ TEXT = "#f5efe2"
30
+ MUTED = "#bdb0a0"
31
+ AXIS = "#d4be98"
32
+ GRID = (1.0, 1.0, 1.0, 0.10)
33
+ ORANGE = "#e78a4e"
34
+ BLUE = "#7daea3"
35
+ GREEN = "#a9b665"
36
+ PURPLE = "#d3869b"
37
+ AQUA = "#89b482"
38
+ YELLOW = "#d8a657"
39
+ RED = "#ea6962"
40
+ ROTORS = [ORANGE, BLUE, GREEN, PURPLE]
41
+
42
+ # Your vehicle's weight in newtons (mass_kg * 9.81) — used only to detect
43
+ # takeoff in the bridge log for clock alignment between the two recordings.
44
+ WEIGHT_N = 11.77
45
+ M_PER_DEG_LAT = 111320.0
46
+
47
+
48
+ def style():
49
+ plt.rcParams.update(
50
+ {
51
+ "figure.facecolor": BG,
52
+ "axes.facecolor": BG,
53
+ "savefig.facecolor": BG,
54
+ "text.color": TEXT,
55
+ "axes.edgecolor": AXIS,
56
+ "axes.labelcolor": MUTED,
57
+ "xtick.color": MUTED,
58
+ "ytick.color": MUTED,
59
+ "axes.grid": True,
60
+ "grid.color": GRID,
61
+ "grid.linewidth": 0.8,
62
+ "font.family": "DejaVu Sans",
63
+ "axes.titlesize": 11,
64
+ "axes.titlecolor": TEXT,
65
+ "axes.spines.top": False,
66
+ "axes.spines.right": False,
67
+ "legend.frameon": False,
68
+ "legend.labelcolor": MUTED,
69
+ }
70
+ )
71
+
72
+
73
+ def read_csv(path):
74
+ with open(path, newline="") as f:
75
+ rows = list(csv.DictReader(f))
76
+ return rows
77
+
78
+
79
+ def col(rows, name, default=np.nan):
80
+ out = np.empty(len(rows))
81
+ for i, r in enumerate(rows):
82
+ v = r.get(name, "")
83
+ out[i] = float(v) if v not in ("", None) else default
84
+ return out
85
+
86
+
87
+ def main():
88
+ data = Path(sys.argv[1])
89
+ out = Path(sys.argv[2])
90
+ out.mkdir(parents=True, exist_ok=True)
91
+ style()
92
+
93
+ b = read_csv(data / "mission_rpm_log.csv")
94
+ t_rows = read_csv(data / "mission_telemetry.csv")
95
+ events = read_csv(data / "mission_events.csv")
96
+
97
+ n_rotors = sum(1 for k in b[0] if k.startswith("rpm_"))
98
+ bt = col(b, "timestamp_s")
99
+ thrust = sum(col(b, f"thrust_N_{i}") for i in range(n_rotors))
100
+ rpm = [col(b, f"rpm_{i}") for i in range(n_rotors)]
101
+ vbus = col(b, "voltage_bus_V")
102
+ ibus = col(b, "current_bus_A")
103
+ soc = col(b, "battery_soc")
104
+ power = vbus * ibus
105
+
106
+ tt = col(t_rows, "t_s")
107
+ alt = col(t_rows, "alt_rel_m")
108
+ gs = col(t_rows, "groundspeed_m_s")
109
+ lat = col(t_rows, "lat")
110
+ lon = col(t_rows, "lon")
111
+ roll = col(t_rows, "roll_deg")
112
+
113
+ # ---- clock alignment: takeoff (bridge thrust > weight) vs (alt > 1 m) ----
114
+ b_take = bt[np.argmax(thrust > 1.05 * WEIGHT_N)]
115
+ t_take = tt[np.argmax(alt > 1.0)]
116
+ bt = bt + (t_take - b_take)
117
+ print(f"clock offset applied: {t_take - b_take:+.2f} s")
118
+
119
+ # Smooth the 400 Hz bridge channels ONCE, globally, before any windowing —
120
+ # smoothing inside a window leaves edge artifacts at its boundaries.
121
+ def smooth(x, w=9):
122
+ return np.convolve(x, np.ones(w) / w, mode="same")
123
+
124
+ rpm_s = [smooth(r) for r in rpm]
125
+ vbus_s = smooth(vbus)
126
+ ibus_s = smooth(ibus)
127
+ power_s = smooth(power)
128
+ soc_s = smooth(soc)
129
+
130
+ # Flight window: a little pre-takeoff ramp, ending at the telemetry end;
131
+ # spool-up and post-kill frames (bus collapsed / negative current) are cut.
132
+ t0, t1 = t_take - 4.0, tt[-1]
133
+ bm = (bt >= t0) & (bt <= t1) & (vbus > 20.0)
134
+ tm = (tt >= t0) & (tt <= t1)
135
+
136
+ def dec(x, k=20):
137
+ return x[bm][::k]
138
+
139
+ bt_d = bt[bm][::20]
140
+ modes = [(float(e["t_s"]), e["detail"]) for e in events if e["kind"] == "mode"]
141
+ wps = [(float(e["t_s"]), int(e["detail"])) for e in events if e["kind"] == "wp"]
142
+
143
+ def mode_lines(ax, label_y=None):
144
+ for ts, name in modes:
145
+ if ts < t0 or ts > t1 or name in ("GUIDED",):
146
+ continue
147
+ ax.axvline(ts, color=MUTED, lw=0.7, alpha=0.5, ls=(0, (4, 3)))
148
+ if label_y is not None:
149
+ ax.annotate(
150
+ name, (ts, label_y), color=MUTED, fontsize=7.5,
151
+ xytext=(3, 0), textcoords="offset points",
152
+ )
153
+
154
+ # ================= Figure 1: track + altitude =================
155
+ east = (lon - lon[0]) * M_PER_DEG_LAT * math.cos(math.radians(lat[0]))
156
+ north = (lat - lat[0]) * M_PER_DEG_LAT
157
+ p_at_t = np.interp(tt, bt[bm], power_s[bm])
158
+
159
+ fig = plt.figure(figsize=(8.0, 9.0))
160
+ gs_ = fig.add_gridspec(2, 1, height_ratios=[2.4, 1.0], hspace=0.28)
161
+ ax = fig.add_subplot(gs_[0])
162
+ sc = ax.scatter(east[tm], north[tm], c=np.clip(p_at_t[tm], 0, None), s=7, cmap="plasma", lw=0)
163
+ ax.plot(0, 0, marker="^", ms=9, color=TEXT)
164
+ ax.annotate("launch", (0, 0), xytext=(8, -4), textcoords="offset points",
165
+ color=MUTED, fontsize=8)
166
+ ax.set_aspect("equal", adjustable="datalim")
167
+ ax.set_xlabel("east (m)")
168
+ ax.set_ylabel("north (m)")
169
+ ax.set_title("Mission track — colored by electrical power")
170
+ cb = fig.colorbar(sc, ax=ax, pad=0.02, fraction=0.04)
171
+ cb.set_label("power (W)", color=MUTED)
172
+ cb.ax.yaxis.set_tick_params(color=MUTED)
173
+ plt.setp(cb.ax.get_yticklabels(), color=MUTED)
174
+ cb.outline.set_edgecolor(AXIS)
175
+
176
+ ax2 = fig.add_subplot(gs_[1])
177
+ ax2.fill_between(tt[tm], 0, alt[tm], color=BLUE, alpha=0.25, lw=0)
178
+ ax2.plot(tt[tm], alt[tm], color=BLUE, lw=1.6)
179
+ mode_lines(ax2, label_y=max(alt[tm]) * 0.86)
180
+ ax2.set_xlabel("mission time (s)")
181
+ ax2.set_ylabel("altitude (m)")
182
+ fig.suptitle(
183
+ "ThrustLab FMU · ArduPilot SITL — the exported powertrain flying a real mission",
184
+ color=TEXT, fontsize=12.5, y=0.985,
185
+ )
186
+ fig.savefig(out / "fmu_mission_track.png", dpi=180, bbox_inches="tight")
187
+ plt.close(fig)
188
+
189
+ # ================= Figure 2: powertrain time series =================
190
+ fig, axes = plt.subplots(4, 1, figsize=(9.6, 9.4), sharex=True)
191
+ fig.subplots_adjust(hspace=0.16)
192
+
193
+ a = axes[0]
194
+ a.plot(tt[tm], alt[tm], color=BLUE, lw=1.5, label="altitude (m)")
195
+ a.set_ylabel("altitude (m)")
196
+ ag = a.twinx()
197
+ ag.plot(tt[tm], gs[tm], color=MUTED, lw=1.1, alpha=0.8, label="groundspeed (m/s)")
198
+ ag.set_ylabel("groundspeed (m/s)", color=MUTED)
199
+ ag.grid(False)
200
+ ag.spines["right"].set_visible(True)
201
+ mode_lines(a, label_y=max(alt[tm]) * 0.9)
202
+ a.set_title("Flight profile")
203
+
204
+ a = axes[1]
205
+ for i in range(n_rotors):
206
+ a.plot(bt_d, dec(rpm_s[i]), color=ROTORS[i % len(ROTORS)], lw=1.1, label=f"rotor {i}")
207
+ a.set_ylabel("rotor speed (rpm)")
208
+ a.legend(ncol=4, fontsize=8, loc="lower right")
209
+ a.set_title("Per-rotor speed — FMU state, integrated from torque imbalance")
210
+
211
+ a = axes[2]
212
+ a.plot(bt_d, dec(vbus_s), color=YELLOW, lw=1.4, label="bus voltage (V)")
213
+ a.set_ylabel("bus voltage (V)", color=YELLOW)
214
+ ai = a.twinx()
215
+ ai.plot(bt_d, dec(ibus_s), color=RED, lw=1.1, alpha=0.9, label="bus current (A)")
216
+ ai.set_ylabel("bus current (A)", color=RED)
217
+ ai.grid(False)
218
+ ai.spines["right"].set_visible(True)
219
+ a.set_title("Battery under load — sag and recovery from the pack surface")
220
+
221
+ a = axes[3]
222
+ a.plot(bt_d, dec(power_s), color=ORANGE, lw=1.3, label="electrical power (W)")
223
+ a.set_ylabel("power (W)", color=ORANGE)
224
+ asoc = a.twinx()
225
+ asoc.plot(bt_d, dec(soc_s) * 100.0, color=AQUA, lw=1.4)
226
+ asoc.set_ylabel("state of charge (%)", color=AQUA)
227
+ asoc.grid(False)
228
+ asoc.spines["right"].set_visible(True)
229
+ a.set_xlabel("mission time (s)")
230
+ a.set_title("Energy — power draw and state of charge")
231
+
232
+ fig.suptitle(
233
+ "ThrustLab FMU outputs over one mission — every line is the exported model",
234
+ color=TEXT, fontsize=12.5, y=0.945,
235
+ )
236
+ fig.savefig(out / "fmu_mission_timeseries.png", dpi=180, bbox_inches="tight")
237
+ plt.close(fig)
238
+
239
+ # ================= Figure 3: loiter close-up =================
240
+ lo_wp = [ts for ts, seq in wps if seq == 5]
241
+ lo_end = [ts for ts, seq in wps if seq == 7]
242
+ w1 = (lo_end[0] + 12.0) if lo_end else t0 + (t1 - t0) * 0.75
243
+ w0 = max(lo_wp[0] - 2.0, w1 - 75.0) if lo_wp else w1 - 75.0
244
+ bw = (bt >= w0) & (bt <= w1) & (vbus > 20.0)
245
+ tw = (tt >= w0) & (tt <= w1)
246
+
247
+ fig, axes = plt.subplots(2, 1, figsize=(9.6, 5.6), sharex=True)
248
+ fig.subplots_adjust(hspace=0.14)
249
+ a = axes[0]
250
+ for i in range(n_rotors):
251
+ a.plot(bt[bw][::8], rpm_s[i][bw][::8],
252
+ color=ROTORS[i % len(ROTORS)], lw=1.2, label=f"rotor {i}")
253
+ a.set_ylabel("rotor speed (rpm)")
254
+ a.legend(ncol=4, fontsize=8, loc="upper right")
255
+ a.set_title("Loiter and exit — the mixer splits the rotors, the FMU answers per rotor")
256
+ a2 = axes[1]
257
+ a2.plot(tt[tw], roll[tw], color=PURPLE, lw=1.3)
258
+ a2.set_ylabel("roll (deg)")
259
+ a2.set_xlabel("mission time (s)")
260
+ fig.savefig(out / "fmu_mission_loiter.png", dpi=180, bbox_inches="tight")
261
+ plt.close(fig)
262
+
263
+ print(f"wrote 3 figures to {out}")
264
+
265
+
266
+ if __name__ == "__main__":
267
+ main()