flightStand 3.0.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.
- flightstand-3.0.0/FlightStand/__init__.py +15 -0
- flightstand-3.0.0/FlightStand/examples/PID_constant_thrust.py +128 -0
- flightstand-3.0.0/FlightStand/examples/__init__.py +0 -0
- flightstand-3.0.0/FlightStand/examples/advanced_grpc_data_streaming.py +106 -0
- flightstand-3.0.0/FlightStand/examples/balancing/__init__.py +0 -0
- flightstand-3.0.0/FlightStand/examples/balancing/real_hardware.py +145 -0
- flightstand-3.0.0/FlightStand/examples/balancing/simulated.py +114 -0
- flightstand-3.0.0/FlightStand/examples/balancing/utils.py +159 -0
- flightstand-3.0.0/FlightStand/examples/canbus_cyphal/__init__.py +0 -0
- flightstand-3.0.0/FlightStand/examples/canbus_cyphal/example_1_main.py +147 -0
- flightstand-3.0.0/FlightStand/examples/canbus_cyphal/example_1_plant.py +93 -0
- flightstand-3.0.0/FlightStand/examples/canbus_cyphal/example_2_airspeed_sensor.py +165 -0
- flightstand-3.0.0/FlightStand/examples/canbus_cyphal/example_3_telega_ESC.py +190 -0
- flightstand-3.0.0/FlightStand/examples/data_polling.py +44 -0
- flightstand-3.0.0/FlightStand/examples/data_recording.py +73 -0
- flightstand-3.0.0/FlightStand/examples/example.py +56 -0
- flightstand-3.0.0/FlightStand/examples/external_hardware_interface.py +124 -0
- flightstand-3.0.0/FlightStand/examples/gen1/get_num_of_poles.py +103 -0
- flightstand-3.0.0/FlightStand/examples/gen1/measure_kV.py +93 -0
- flightstand-3.0.0/FlightStand/examples/hid_controller_example.py +95 -0
- flightstand-3.0.0/FlightStand/examples/repair_stuck_in_bootloader.py +20 -0
- flightstand-3.0.0/FlightStand/flight_stand_api_v1_pb2.py +367 -0
- flightstand-3.0.0/FlightStand/flight_stand_api_v1_pb2.pyi +3794 -0
- flightstand-3.0.0/FlightStand/flight_stand_api_v1_pb2_grpc.py +3182 -0
- flightstand-3.0.0/FlightStand/flight_stand_api_v1_pb2_grpc.pyi +1632 -0
- flightstand-3.0.0/FlightStand/flightstand.py +1026 -0
- flightstand-3.0.0/PKG-INFO +11 -0
- flightstand-3.0.0/README.md +19 -0
- flightstand-3.0.0/flightStand.egg-info/PKG-INFO +11 -0
- flightstand-3.0.0/flightStand.egg-info/SOURCES.txt +34 -0
- flightstand-3.0.0/flightStand.egg-info/dependency_links.txt +1 -0
- flightstand-3.0.0/flightStand.egg-info/requires.txt +2 -0
- flightstand-3.0.0/flightStand.egg-info/top_level.txt +2 -0
- flightstand-3.0.0/pyproject.toml +27 -0
- flightstand-3.0.0/setup.cfg +4 -0
- flightstand-3.0.0/setup.py +3 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from importlib import import_module
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
# Make grpc-generated absolute import work:
|
|
5
|
+
# `import flight_stand_api_v1_pb2` -> resolves to `FlightStand.flight_stand_api_v1_pb2`
|
|
6
|
+
sys.modules.setdefault(
|
|
7
|
+
"flight_stand_api_v1_pb2",
|
|
8
|
+
import_module(".flight_stand_api_v1_pb2", package=__name__),
|
|
9
|
+
)
|
|
10
|
+
sys.modules.setdefault(
|
|
11
|
+
"flight_stand_api_v1_pb2_grpc",
|
|
12
|
+
import_module(".flight_stand_api_v1_pb2_grpc", package=__name__),
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from .flightstand import FlightStand
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This example demonstrates continuous data reading and throttle adjustment to achieve a constant thrust. The control
|
|
3
|
+
response is calculated using a closed-loop PID controller. To visualize the thrust response, the target thrust is
|
|
4
|
+
alternated between half and full thrust. Make sure the hardware is connected and the output for control is configured
|
|
5
|
+
and activated from the manual control tab before running the script. Additionally, configure safety cutoffs specific to
|
|
6
|
+
your application.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from FlightStand import FlightStand
|
|
11
|
+
|
|
12
|
+
# The PID controller library needs to work with arbitrary timestamps, since the sensors are sampled in the hardware and
|
|
13
|
+
# using the computer's timestamp would yield incorrect results. Also, it needs to accept varying delta timestamps as
|
|
14
|
+
# some sensors don't have a fixed sample rate, for example the rotation speed sensor.
|
|
15
|
+
# Don't forget to install this library before running this example:
|
|
16
|
+
# pip install simple-pid
|
|
17
|
+
from simple_pid import PID
|
|
18
|
+
|
|
19
|
+
""" USER PARAMETERS """
|
|
20
|
+
|
|
21
|
+
# Set the signal names of the input and output. To find the input and output names, you can run this script once as
|
|
22
|
+
# it will print out all the inputs and outputs found. Refer to the API documentation for the signal type constants (
|
|
23
|
+
# search in the .proto file for "enum InputType"). For example a rotation speed sensor is input_type=15.
|
|
24
|
+
input_name = "/boards/simulated_1/inputs/1" # this is the name of the input to control
|
|
25
|
+
output_name = "/boards/simulated_1/outputs/1" # this is the name of the control output
|
|
26
|
+
|
|
27
|
+
"""
|
|
28
|
+
PID terms for the PID controller. These coefficients need to be tuned by the user and are specific to each application.
|
|
29
|
+
Tuning PID coefficients is a crucial step to achieve desired control performance. Here are some considerations:
|
|
30
|
+
|
|
31
|
+
- Tuning Guidance: Refer to resources like [PID Tuning Guide](https://realpars.com/pid-tuning/) for pointers on PID
|
|
32
|
+
tuning. However, keep in mind that there is no one-size-fits-all approach, and results can vary based on different
|
|
33
|
+
factors.
|
|
34
|
+
|
|
35
|
+
- Factors Affecting PID Coefficients:
|
|
36
|
+
PID coefficients can be influenced by various factors, including:
|
|
37
|
+
- Voltage: The power supply voltage can affect the system's response.
|
|
38
|
+
- Propeller Diameter/Mass: Propeller characteristics can impact the control behavior.
|
|
39
|
+
- Motor Size: The size and specifications of the motor play a role in determining the optimal coefficients.
|
|
40
|
+
- ESC Settings: ESC configuration settings can affect the control response.
|
|
41
|
+
- Type of Input: The type of input being controlled (thrust, RPM, power, temperature) can influence the coefficients.
|
|
42
|
+
- PID update rate: The sample rate can also affect the stability and performance of the control.
|
|
43
|
+
- Output Settings: The output configuration (PWM, DShot, range, rate limiter, etc.) can impact the coefficients.
|
|
44
|
+
- User Application Requirements: Desired control characteristics (fast reaction, no overshoot, etc.) affect the ideal
|
|
45
|
+
coefficients.
|
|
46
|
+
|
|
47
|
+
- Examples of PID Terms:
|
|
48
|
+
Here are some examples of PID terms used in different scenarios:
|
|
49
|
+
- Simulated Hardware (Constant Thrust): 2000, 1500, 0
|
|
50
|
+
- Simulated Hardware (Constant Thrust with Output Rate Limit of 200): 2000, 500, 0
|
|
51
|
+
- Simulated Hardware (Constant Rotation Speed): 2, 5, 0
|
|
52
|
+
- Real Hardware (Mejzlik 48 x 16,4 Propeller, Xoar 180-35 34kv Motor, Xoar Pulse P200 ESC)
|
|
53
|
+
- Constant Thrust with Rate Limiter of 200us/s at 80V: 6, 5.5, 0.5
|
|
54
|
+
- Constant Rotation Speed with Rate Limiter of 200us/s at 80V: 2, 5, 0.3
|
|
55
|
+
"""
|
|
56
|
+
Kp = 2000.0 # Proportional terms
|
|
57
|
+
Ki = 1500.0 # Integral term
|
|
58
|
+
Kd = 0.0 # Derivative term
|
|
59
|
+
|
|
60
|
+
# This example performs a square wave pattern alternating between 50% of the target_value and 100% of the target_value.
|
|
61
|
+
# You can set the period for this pattern in seconds.
|
|
62
|
+
pattern_period = 10.0
|
|
63
|
+
target_value = 1.0 # The value the input signal should reach
|
|
64
|
+
|
|
65
|
+
""" SCRIPT """
|
|
66
|
+
|
|
67
|
+
print(" **** Running example PID script in Python **** ")
|
|
68
|
+
core = FlightStand()
|
|
69
|
+
|
|
70
|
+
controller_output = core.get_output(output_name)
|
|
71
|
+
controller_input = core.get_input(input_name)
|
|
72
|
+
if not controller_output or not controller_input:
|
|
73
|
+
print("Input or output not found.")
|
|
74
|
+
exit()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def set_throttle(value):
|
|
78
|
+
controller_output.output_target.target_value = value
|
|
79
|
+
core.update_output(controller_output, ['output_target'])
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# Setup the PID controller
|
|
83
|
+
sample_time = None
|
|
84
|
+
target_changed = time.time()
|
|
85
|
+
limits = (controller_output.min_user_value, controller_output.max_user_value)
|
|
86
|
+
pid = PID(Kp, Ki, Kd, output_limits=limits, sample_time=None)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def update():
|
|
90
|
+
global target_changed, sample_time
|
|
91
|
+
|
|
92
|
+
# Calculate the set point to have a square wave pattern with a low point half of the target thrust
|
|
93
|
+
target = target_value
|
|
94
|
+
t = time.time()
|
|
95
|
+
if t - target_changed > pattern_period / 2:
|
|
96
|
+
target = 0.5 * target
|
|
97
|
+
if t - target_changed >= pattern_period:
|
|
98
|
+
target_changed += pattern_period
|
|
99
|
+
pid.setpoint = target
|
|
100
|
+
|
|
101
|
+
# Get the latest available sample.
|
|
102
|
+
input_sample = core.get_latest_input_sample(controller_input)
|
|
103
|
+
|
|
104
|
+
# Listing samples may repeat the same samples as the rpc returns the latest values available, so we only
|
|
105
|
+
# update the PID controller when the sample time has changed.
|
|
106
|
+
new_sample_time = input_sample.sample_time.ToDatetime()
|
|
107
|
+
if sample_time is None or new_sample_time != sample_time:
|
|
108
|
+
if sample_time is not None:
|
|
109
|
+
dt = (new_sample_time - sample_time).total_seconds()
|
|
110
|
+
val = input_sample.filtered_value
|
|
111
|
+
|
|
112
|
+
# Update the PID controller with the new sample value, to obtain the updated control output
|
|
113
|
+
output = pid(val, dt)
|
|
114
|
+
|
|
115
|
+
# Apply the output throttle
|
|
116
|
+
set_throttle(output)
|
|
117
|
+
|
|
118
|
+
print({
|
|
119
|
+
"Delta time (s)": dt,
|
|
120
|
+
"Target input": pid.setpoint,
|
|
121
|
+
"Input": val,
|
|
122
|
+
"Output": output,
|
|
123
|
+
})
|
|
124
|
+
sample_time = new_sample_time
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# We run a continuous loop that reads the latest thrust sensor value and sets the output to achieve a target thrust
|
|
128
|
+
core.run_at_interval(0.001, update)
|
|
File without changes
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# This example is for advanced users, and is directly interacting with grpc instead of using flightstand.py wrapper
|
|
2
|
+
# class. For most users, it is better to refer to the example_data_polling.py example to continuously read sensor data.
|
|
3
|
+
# This example shows how to use the WatchSamples rpc call to continuously stream data. It is important to note the core
|
|
4
|
+
# will automatically discard samples if the Python execution is blocked. For this reason, if you want to continuously
|
|
5
|
+
# stream data while performing other tasks, multithreading is recommended.
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import grpc
|
|
9
|
+
from FlightStand import flight_stand_api_v1_pb2 as fs
|
|
10
|
+
from FlightStand import flight_stand_api_v1_pb2_grpc as fs_grpc
|
|
11
|
+
|
|
12
|
+
print(" **** Running example script in Python **** ")
|
|
13
|
+
|
|
14
|
+
print("Connecting to core...")
|
|
15
|
+
channel = grpc.insecure_channel('localhost:50051')
|
|
16
|
+
stub = fs_grpc.FlightStandStub(channel)
|
|
17
|
+
|
|
18
|
+
# Confirming connection to core
|
|
19
|
+
GetServerStatusRequest = fs.GetServerStatusRequest()
|
|
20
|
+
ServerStatus = stub.GetServerStatus(GetServerStatusRequest)
|
|
21
|
+
print("Core connected!") # If we reach this line without an error thrown, it means the core responded.
|
|
22
|
+
|
|
23
|
+
# Listing available hardware
|
|
24
|
+
print("Listing available hardware:")
|
|
25
|
+
ListBoardsResponse = stub.ListBoards(fs.ListBoardsRequest())
|
|
26
|
+
for listedBoard in ListBoardsResponse.boards:
|
|
27
|
+
print(listedBoard.name + ": " + listedBoard.display_name)
|
|
28
|
+
|
|
29
|
+
# We want to work with a known hardware, in the case of this example we work with simulated_1 circuit, so we check if
|
|
30
|
+
# it is available.
|
|
31
|
+
boardName = "/boards/simulated_1" # this is the name of the board we want to work with.
|
|
32
|
+
board = False
|
|
33
|
+
for listedBoard in ListBoardsResponse.boards:
|
|
34
|
+
if listedBoard.name == boardName:
|
|
35
|
+
board = listedBoard
|
|
36
|
+
|
|
37
|
+
# If the simulated board does not exist, we add it
|
|
38
|
+
if not board:
|
|
39
|
+
# Connect a simulated board
|
|
40
|
+
print("No hardware found. Connecting simulated hardware...")
|
|
41
|
+
stub.CreateSimulatedBoard(fs.CreateSimulatedBoardRequest())
|
|
42
|
+
print("Waiting for simulated hardware to connect...")
|
|
43
|
+
time.sleep(4)
|
|
44
|
+
print("Done.")
|
|
45
|
+
|
|
46
|
+
# Confirm it has connected
|
|
47
|
+
print("Listing available hardware:")
|
|
48
|
+
ListBoardsResponse = stub.ListBoards(fs.ListBoardsRequest())
|
|
49
|
+
for listedBoard in ListBoardsResponse.boards:
|
|
50
|
+
print(listedBoard.name + ": " + listedBoard.display_name)
|
|
51
|
+
if listedBoard.name == boardName:
|
|
52
|
+
board = True
|
|
53
|
+
if not board:
|
|
54
|
+
raise RuntimeError("Cannot add simulated board")
|
|
55
|
+
|
|
56
|
+
# Lets list all available inputs (sensors)
|
|
57
|
+
ListInputsResponse = stub.ListInputs(fs.ListInputsRequest())
|
|
58
|
+
print("Listing available inputs:")
|
|
59
|
+
for listedInput in ListInputsResponse.inputs:
|
|
60
|
+
# Print some of the properties an input has. See the .proto for details.
|
|
61
|
+
print(listedInput.name + ": type=" + str(listedInput.input_type) + " signalName=" + listedInput.signal_name)
|
|
62
|
+
|
|
63
|
+
# We want to focus this example on the thrust sensor
|
|
64
|
+
# First, we need to know the ID of the sensor type "thrust". Based on the .proto, we
|
|
65
|
+
# can find the enumeration of constants:
|
|
66
|
+
# enum InputType {
|
|
67
|
+
# [...]
|
|
68
|
+
# FORCE_FX = 9;
|
|
69
|
+
# FORCE_FY = 10;
|
|
70
|
+
# FORCE_FZ = 11;
|
|
71
|
+
# [...]
|
|
72
|
+
# }
|
|
73
|
+
# Then we need to find the thrust sensor for this simulated hardware and determine the corresponding signal name.
|
|
74
|
+
# Each sensor is streaming data as a signal. A signal is a stream of values. For efficiency reasons, we kept the list
|
|
75
|
+
# of inputs and the signals as separate elements. Therefore, we need to find the signal name for this specific input.
|
|
76
|
+
thrust_signal_name = ""
|
|
77
|
+
for listedInput in ListInputsResponse.inputs:
|
|
78
|
+
# According to the .proto each input name is of format: /boards/BOARD-ID/inputs/INPUT-ID
|
|
79
|
+
# We know we want to use the thrust sensor for a specific hardware, so we check the BOARD-ID is correct:
|
|
80
|
+
if boardName + "/" in listedInput.name:
|
|
81
|
+
if listedInput.input_type == fs.FORCE_FZ:
|
|
82
|
+
thrust_signal_name = listedInput.signal_name
|
|
83
|
+
print("Thrust sensor signal name: " + thrust_signal_name)
|
|
84
|
+
if not thrust_signal_name:
|
|
85
|
+
raise RuntimeError("Cannot find the required thrust sensor")
|
|
86
|
+
|
|
87
|
+
# This method is a generator function that yields only once we received a response from the server
|
|
88
|
+
evt = threading.Event()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def watch_samples_request_stream():
|
|
92
|
+
while True:
|
|
93
|
+
evt.clear()
|
|
94
|
+
yield fs.WatchSamplesRequest()
|
|
95
|
+
evt.wait(timeout=1)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# Here we print a loop continuously handles the WatchSamples rpc
|
|
99
|
+
# rpc WatchSamples(stream WatchSamplesRequest) returns (stream WatchSamplesResponse);
|
|
100
|
+
WatchSamplesResponseStream = stub.WatchSamples(watch_samples_request_stream())
|
|
101
|
+
for WatchSamplesResponse in WatchSamplesResponseStream:
|
|
102
|
+
evt.set() # As per the API documentation, we need to acknowledge the response (ping pong communication)
|
|
103
|
+
for sampleGroup in WatchSamplesResponse.sample_groups:
|
|
104
|
+
for sample in sampleGroup.samples:
|
|
105
|
+
if sample.signal_name == thrust_signal_name:
|
|
106
|
+
print("Thrust: " + str(sample.filtered_value) + " N") # In Newtons
|
|
File without changes
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# This example interacts with the user to achieve the balancing of a propeller by asking the user a series of
|
|
2
|
+
# questions and asking the user to perform various actions on the hardware. Use this script in parallel to running
|
|
3
|
+
# the user interface to control the motor using the manual control slider.
|
|
4
|
+
# Note: the user interface has a balancing tab which takes care of all this sequence. This script is for illustrative
|
|
5
|
+
# purposes only. Also, this works with a simulated hardware too, but is less automated.
|
|
6
|
+
import math
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from examples.balancing.utils import show_balancing_report, input_float, format_weights
|
|
10
|
+
from FlightStand import FlightStand
|
|
11
|
+
|
|
12
|
+
print("**** Running example propeller balancing script with real hardware ****")
|
|
13
|
+
|
|
14
|
+
core = FlightStand()
|
|
15
|
+
|
|
16
|
+
# Fetch the required parameters for the balancing session from the user
|
|
17
|
+
title = input("Enter the session title (optional, max 100 chars): ")
|
|
18
|
+
operator_display_name = input("Enter the operator's name (optional, max 50 chars): ")
|
|
19
|
+
motor_display_name = input("Enter the motor's name (optional, max 50 chars): ")
|
|
20
|
+
propeller_display_name = input("Enter the propeller's name (optional, max 50 chars): ")
|
|
21
|
+
rotor_mass_kg = input_float("Enter the rotor mass (in kg): ")
|
|
22
|
+
operating_speed_rpm = input_float("Enter the operating speed (rpm): ")
|
|
23
|
+
quality_grade_g = input_float("Enter the quality grade (optional, recommended 6.3): ")
|
|
24
|
+
radius_m = input_float("Enter the correction radius (m): ")
|
|
25
|
+
blades_count = int(
|
|
26
|
+
input("Enter the number of blades for the propeller (enter zero to balance a disk without blades): "))
|
|
27
|
+
rotation_sensor_name = input("Enter the rotation sensor name (format '/boards/id/inputs/id'): ")
|
|
28
|
+
vibration_sensor_name = input("Enter the vibration sensor name (format '/boards/id/inputs/id'): ")
|
|
29
|
+
control_output_name = input("Enter the control output name (optional, format '/boards/id/outputs/id'): ")
|
|
30
|
+
control_throttle_value = input_float(f"Enter the throttle value that will spin the motor to {operating_speed_rpm} rpm. Enter 0 to control manually using the GUI: ")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def rpm_to_rad_s(rpm):
|
|
34
|
+
return rpm * (2.0 * math.pi) / 60.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Create the balancing session with the user's parameters
|
|
38
|
+
core.create_balancing_session(params=core.Proto.BalancingSession(
|
|
39
|
+
title=title,
|
|
40
|
+
operator_display_name=operator_display_name,
|
|
41
|
+
motor_display_name=motor_display_name,
|
|
42
|
+
propeller_display_name=propeller_display_name,
|
|
43
|
+
rotor_mass=rotor_mass_kg,
|
|
44
|
+
operating_speed_rad_s=rpm_to_rad_s(operating_speed_rpm),
|
|
45
|
+
quality_grade_g=quality_grade_g,
|
|
46
|
+
correction_radius_m=radius_m,
|
|
47
|
+
rotation_sensor_name=rotation_sensor_name,
|
|
48
|
+
vibration_sensor_name=vibration_sensor_name,
|
|
49
|
+
control_output_name=control_output_name,
|
|
50
|
+
blades_count=blades_count
|
|
51
|
+
))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def grams_to_unbalance(grams):
|
|
55
|
+
return grams * radius_m / 1000.0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def degrees_to_rad(degrees):
|
|
59
|
+
return degrees * math.pi / 180.0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def run_motor():
|
|
63
|
+
# Ask the user to bring the motor to their specified operating speed
|
|
64
|
+
if control_output_name == "" or control_throttle_value == 0:
|
|
65
|
+
input(f"\nPlease bring the motor to {operating_speed_rpm} rpm and press Enter")
|
|
66
|
+
return
|
|
67
|
+
print("Starting motor automatically")
|
|
68
|
+
esc_output = core.get_output(control_output_name)
|
|
69
|
+
esc_output.output_target.target_value = control_throttle_value
|
|
70
|
+
core.update_output(esc_output, ['output_target'])
|
|
71
|
+
time.sleep(1.0)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def stop_motor():
|
|
75
|
+
if control_output_name == "":
|
|
76
|
+
input("Please stop the motor and press Enter")
|
|
77
|
+
return
|
|
78
|
+
print("Stopping motor automatically")
|
|
79
|
+
esc_output = core.get_output(control_output_name)
|
|
80
|
+
esc_output.output_target.target_value = esc_output.cutoff_target.target_value
|
|
81
|
+
core.update_output(esc_output, ['output_target'])
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# Execute the balance run
|
|
85
|
+
run_motor()
|
|
86
|
+
core.execute_balance_run(False, None)
|
|
87
|
+
stop_motor()
|
|
88
|
+
|
|
89
|
+
# Retrieve the data to know the trial weight to put
|
|
90
|
+
session = core.get_balancing_session()
|
|
91
|
+
initial_run = session.runs[0]
|
|
92
|
+
trial_weights = initial_run.next_corrections
|
|
93
|
+
|
|
94
|
+
# Ask the user to install the calculated trial weight
|
|
95
|
+
print(f"\nWe recommend a trial weight of {format_weights(trial_weights, correction_radius=radius_m)}.")
|
|
96
|
+
actual_trial_mass_g = input_float("Enter the installed trial weight's mass (in grams): ")
|
|
97
|
+
actual_trial_angle = input_float("Enter the installed trial weight's angle (in degrees): ")
|
|
98
|
+
trial_weight = core.Proto.BalanceWeight(unbalance=grams_to_unbalance(actual_trial_mass_g),
|
|
99
|
+
phase=degrees_to_rad(actual_trial_angle))
|
|
100
|
+
|
|
101
|
+
# Execute the trial run
|
|
102
|
+
print("Executing trial run")
|
|
103
|
+
run_motor()
|
|
104
|
+
core.execute_balance_run(False, [trial_weight])
|
|
105
|
+
stop_motor()
|
|
106
|
+
|
|
107
|
+
# Do multiple correction runs until the user decides to stop
|
|
108
|
+
while True:
|
|
109
|
+
# Retrieve the data to know the correction weight to put
|
|
110
|
+
session = core.get_balancing_session()
|
|
111
|
+
last_run = session.runs[-1]
|
|
112
|
+
correction_weights = last_run.next_corrections
|
|
113
|
+
|
|
114
|
+
print(f"\nInstall correction weight(s) of {format_weights(correction_weights, correction_radius=radius_m)}")
|
|
115
|
+
actual_correction_mass_g = input_float("Enter the first installed correction weight's mass (in grams): ")
|
|
116
|
+
actual_correction_angle = input_float("Enter the first installed correction weight's angle (in degrees): ")
|
|
117
|
+
correction_weight = core.Proto.BalanceWeight(unbalance=grams_to_unbalance(actual_correction_mass_g),
|
|
118
|
+
phase=degrees_to_rad(actual_correction_angle))
|
|
119
|
+
actual_correction_weights = [correction_weight]
|
|
120
|
+
|
|
121
|
+
actual_correction_mass_g = input_float(
|
|
122
|
+
"Enter the second installed correction weight's mass (in grams). Write 0 if none: ")
|
|
123
|
+
if actual_correction_mass_g != 0.0:
|
|
124
|
+
actual_correction_angle = input_float("Enter the second installed correction weight's angle (in degrees): ")
|
|
125
|
+
correction_weight = core.Proto.BalanceWeight(unbalance=grams_to_unbalance(actual_correction_mass_g),
|
|
126
|
+
phase=degrees_to_rad(actual_correction_angle))
|
|
127
|
+
actual_correction_weights.append(correction_weight)
|
|
128
|
+
|
|
129
|
+
# Execute the correction run
|
|
130
|
+
print("Executing correction run")
|
|
131
|
+
run_motor()
|
|
132
|
+
core.execute_balance_run(True, actual_correction_weights)
|
|
133
|
+
stop_motor()
|
|
134
|
+
|
|
135
|
+
# Ask the user if they want to continue with more runs
|
|
136
|
+
answer = input(f"Current balancing grade: {last_run.quality_grade_g}. Target grade: {session.quality_grade_g}. "
|
|
137
|
+
f"Do you want to continue with more corrections (Y/N)?:")
|
|
138
|
+
if answer == "Y" or answer == "y":
|
|
139
|
+
continue
|
|
140
|
+
break
|
|
141
|
+
|
|
142
|
+
# Retrieve the data to print the report
|
|
143
|
+
show_balancing_report(core)
|
|
144
|
+
|
|
145
|
+
print("\nDone.")
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# This example shows how to connect a simulated hardware and use the propeller balancing feature. In this example
|
|
2
|
+
# the simulated hardware starts with a virtual imbalance, and we run the balancing steps to get it balanced.
|
|
3
|
+
# In your scenario, you should replace the simulated circuit with your own hardware.
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from examples.balancing.utils import show_balancing_report, format_weights
|
|
7
|
+
from FlightStand import FlightStand
|
|
8
|
+
|
|
9
|
+
print(" **** Running example propeller balancing script with simulated hardware **** ")
|
|
10
|
+
core = FlightStand()
|
|
11
|
+
|
|
12
|
+
# Connect a simulated board if none already connected
|
|
13
|
+
print("\nConnecting simulated hardware...")
|
|
14
|
+
board = core.create_simulated_board()
|
|
15
|
+
core.print_hardware_list()
|
|
16
|
+
|
|
17
|
+
# Activate the output at 1000. This is done by first modifying the output fields to set a new target value, and then
|
|
18
|
+
# the update_output command must be called to send the updated values.
|
|
19
|
+
print("\nActivating ESC at 1000")
|
|
20
|
+
output_name = board.name + "/outputs/1"
|
|
21
|
+
esc_output = core.get_output(output_name)
|
|
22
|
+
esc_output.output_target.target_value = 1000
|
|
23
|
+
esc_output.output_target.active = True
|
|
24
|
+
core.update_output(esc_output, ['output_target'])
|
|
25
|
+
time.sleep(0.2)
|
|
26
|
+
|
|
27
|
+
# Increase the throttle to 1500
|
|
28
|
+
print("\nSetting ESC to 1500")
|
|
29
|
+
esc_output.output_target.target_value = 1500
|
|
30
|
+
core.update_output(esc_output, ['output_target'])
|
|
31
|
+
time.sleep(0.5)
|
|
32
|
+
|
|
33
|
+
# Do an initial run in the balancing session to get the initial unbalance (simulated hardware has a random unbalance
|
|
34
|
+
# when created)
|
|
35
|
+
rotor_mass_kg = 0.02 # Important to correctly calculate the correction weights
|
|
36
|
+
radius_m = 0.04 # So we can calculate the weight
|
|
37
|
+
core.create_balancing_session(params=core.Proto.BalancingSession(
|
|
38
|
+
title="Demo balance session on 3-bladed simulated hardware", # Optional
|
|
39
|
+
operator_display_name="My name", # Optional
|
|
40
|
+
motor_display_name="Motor 1", # Optional
|
|
41
|
+
propeller_display_name="Propeller 1", # Optional
|
|
42
|
+
rotor_mass=rotor_mass_kg,
|
|
43
|
+
operating_speed_rad_s=1091, # In our demo, we go to ESC value 1500 which gives a repeatable rotation speed.
|
|
44
|
+
quality_grade_g=6.3, # Optional. 6.3 is common in the industry for propellers (ISO 1940 standard)
|
|
45
|
+
correction_radius_m=radius_m,
|
|
46
|
+
rotation_sensor_name=board.name + "/inputs/5",
|
|
47
|
+
vibration_sensor_name=board.name + "/inputs/8",
|
|
48
|
+
control_output_name=output_name,
|
|
49
|
+
blades_count=3
|
|
50
|
+
))
|
|
51
|
+
core.execute_balance_run(False, None)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Retrieve the data to know the trial weight to put
|
|
55
|
+
session = core.get_balancing_session()
|
|
56
|
+
# print_session(session)
|
|
57
|
+
initial_run = session.runs[0]
|
|
58
|
+
|
|
59
|
+
trial_weights = initial_run.next_corrections
|
|
60
|
+
print("Trial weights: " + format_weights(trial_weights, radius_m))
|
|
61
|
+
|
|
62
|
+
# Normally done by user, but here we simulate adding a trial weight
|
|
63
|
+
print(f"Putting a simulated trial weight on the simulated hardware")
|
|
64
|
+
sim_settings = core.Proto.UpdateSimulatedBoardSettingsRequest(board_name=board.name)
|
|
65
|
+
sim_settings.settings.correction_weights.extend(trial_weights)
|
|
66
|
+
core.stub.UpdateSimulatedBoardSettings(sim_settings)
|
|
67
|
+
time.sleep(0.5) # Gives time for the data samples to update
|
|
68
|
+
|
|
69
|
+
# Confirm the simulated hardware has correction weights applied
|
|
70
|
+
sim_settings = core.stub.GetSimulatedBoardSettings(core.Proto.GetSimulatedBoardSettingsRequest(board_name=board.name))
|
|
71
|
+
print("Confirmed correction weights applied:", sim_settings)
|
|
72
|
+
print("")
|
|
73
|
+
|
|
74
|
+
# Do the trial run
|
|
75
|
+
print("Executing trial run")
|
|
76
|
+
core.execute_balance_run(False, trial_weights)
|
|
77
|
+
|
|
78
|
+
# Retrieve the data to know the correction weight to put
|
|
79
|
+
session = core.get_balancing_session()
|
|
80
|
+
# print_session(session)
|
|
81
|
+
trial_run = session.runs[1]
|
|
82
|
+
correction_weights = trial_run.next_corrections
|
|
83
|
+
print("Correction weights: " + format_weights(correction_weights, radius_m))
|
|
84
|
+
|
|
85
|
+
# Normally done by user, apply the correction weight to achieve a balanced rotor
|
|
86
|
+
sim_settings = core.Proto.UpdateSimulatedBoardSettingsRequest(board_name=board.name)
|
|
87
|
+
sim_settings.settings.correction_weights.extend(correction_weights)
|
|
88
|
+
core.stub.UpdateSimulatedBoardSettings(sim_settings)
|
|
89
|
+
time.sleep(0.5) # Gives time for the data samples to update
|
|
90
|
+
|
|
91
|
+
# Do the correction run (will confirm balance is good)
|
|
92
|
+
print("Executing correction run")
|
|
93
|
+
core.execute_balance_run(True, correction_weights)
|
|
94
|
+
|
|
95
|
+
# Retrieve the data to print the report
|
|
96
|
+
show_balancing_report(core)
|
|
97
|
+
|
|
98
|
+
# Restore the throttle to 1000
|
|
99
|
+
print("\nSetting ESC to 1000")
|
|
100
|
+
esc_output.output_target.target_value = 1000
|
|
101
|
+
core.update_output(esc_output, ['output_target'])
|
|
102
|
+
time.sleep(1.0) # Give time for the output change to be applied
|
|
103
|
+
|
|
104
|
+
# Turn off the ESC output signal
|
|
105
|
+
print("\nTurning off ESC")
|
|
106
|
+
esc_output.output_target.active = False
|
|
107
|
+
core.update_output(esc_output, ['output_target'])
|
|
108
|
+
time.sleep(1.0) # Give time for the output change to be applied
|
|
109
|
+
|
|
110
|
+
# Delete the balance run
|
|
111
|
+
core.delete_balance_run(session.name)
|
|
112
|
+
|
|
113
|
+
print("\nDone.")
|
|
114
|
+
exit()
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from datetime import timedelta
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
from matplotlib import pyplot as plt
|
|
6
|
+
|
|
7
|
+
from FlightStand import FlightStand
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def input_float(prompt):
|
|
11
|
+
val = input(prompt)
|
|
12
|
+
if val == "":
|
|
13
|
+
return 0.0
|
|
14
|
+
return float(val)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Calculates datapoints for superposing a sine wave function on top of the data, to show correct curvefitting
|
|
18
|
+
def simulate_sine_result(run: FlightStand.Proto.BalanceRun, rotation_speed: list[FlightStand.Proto.DataPoint]):
|
|
19
|
+
# Sort rotation speed data timestamps
|
|
20
|
+
rotation_speed_times = [dp.sample_time.ToDatetime() for dp in rotation_speed]
|
|
21
|
+
|
|
22
|
+
# Get duration (converting it to seconds) by subtracting smallest timestamp from largest
|
|
23
|
+
duration = (rotation_speed_times[-1] - rotation_speed_times[0]).total_seconds()
|
|
24
|
+
|
|
25
|
+
# Sampling frequency
|
|
26
|
+
fs = 10000 # 10000 Hz
|
|
27
|
+
|
|
28
|
+
# Time vector
|
|
29
|
+
t = np.arange(0, duration, 1 / fs)
|
|
30
|
+
|
|
31
|
+
# Analysis data from the run
|
|
32
|
+
amp = run.vibration_signal_amplitude
|
|
33
|
+
phase_offset = run.vibration_signal_phase
|
|
34
|
+
omega = run.average_rotation_speed
|
|
35
|
+
|
|
36
|
+
# Create sine wave
|
|
37
|
+
sine_values = amp * np.sin(omega * t + phase_offset)
|
|
38
|
+
|
|
39
|
+
# Convert the float seconds back to timestamps
|
|
40
|
+
sine_times = [rotation_speed_times[0] + timedelta(seconds=s) for s in t]
|
|
41
|
+
|
|
42
|
+
return sine_times, sine_values
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def format_number(number) -> str:
|
|
46
|
+
if number == 0:
|
|
47
|
+
return "0"
|
|
48
|
+
digits = math.floor(math.log10(abs(number))) + 1
|
|
49
|
+
precision = max(0, 3 - digits)
|
|
50
|
+
format_str = "{:." + str(precision) + "f}"
|
|
51
|
+
formatted_num = format_str.format(number)
|
|
52
|
+
return formatted_num[:-1] if formatted_num[-2:] == '.0' else formatted_num
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def format_weight(weight: FlightStand.Proto.BalanceWeight, correction_radius: float) -> str:
|
|
56
|
+
if weight.unbalance != 0 and correction_radius != 0:
|
|
57
|
+
unbalance = format_number(weight.unbalance * 1000.0 / correction_radius) # Convert from kg⋅m to g⋅m
|
|
58
|
+
phase_degrees = format_number(math.degrees(weight.phase))
|
|
59
|
+
result = f"{unbalance} grams {phase_degrees}°"
|
|
60
|
+
print(weight, " -> ", result)
|
|
61
|
+
return result
|
|
62
|
+
return '0 grams 0°' # return a default string
|
|
63
|
+
|
|
64
|
+
def format_weights(weights: list[FlightStand.Proto.BalanceWeight], correction_radius: float) -> str:
|
|
65
|
+
return " & ".join([format_weight(weight, correction_radius) for weight in weights])
|
|
66
|
+
|
|
67
|
+
def show_balancing_report(core: FlightStand):
|
|
68
|
+
session = core.get_balancing_session()
|
|
69
|
+
|
|
70
|
+
# Create matplotlib figure and axes
|
|
71
|
+
fig, axs = plt.subplots(len(session.runs))
|
|
72
|
+
|
|
73
|
+
#title_text = f'Session ID: {session.name}\n'
|
|
74
|
+
title_text = f'Title: {session.title}\n'
|
|
75
|
+
title_text += f'Motor: {session.motor_display_name}\n'
|
|
76
|
+
title_text += f'Propeller: {session.propeller_display_name}\n'
|
|
77
|
+
title_text += f'Operator: {session.operator_display_name}\n'
|
|
78
|
+
title_text += f'Rotor mass (grams): {round(session.rotor_mass * 1000.0, 3)}\n'
|
|
79
|
+
title_text += f'Operating speed (rpm): {round(session.operating_speed_rad_s * 9.5493, 3)}\n'
|
|
80
|
+
title_text += f'Target quality grade (g): {round(session.quality_grade_g, 3)}\n'
|
|
81
|
+
title_text += f'Permissible unbalance (g⋅m): {round(session.permissible_unbalance*1000.0, 6)}\n'
|
|
82
|
+
title_text += f'Correction radius (cm): {round(session.correction_radius_m * 100.0, 3)}\n'
|
|
83
|
+
title_text += f'Blades count: {session.blades_count}\n'
|
|
84
|
+
#title_text += f'Control Output: {session.control_output_name}\n'
|
|
85
|
+
#title_text += f'Rotation Sensor: {session.rotation_sensor_name}\n'
|
|
86
|
+
#title_text += f'Vibration Sensor: {session.vibration_sensor_name}'
|
|
87
|
+
|
|
88
|
+
# compute the overall maximum from all runs
|
|
89
|
+
ymax = 0
|
|
90
|
+
|
|
91
|
+
fig.suptitle(title_text, fontsize=8, y=0.9)
|
|
92
|
+
if len(session.runs) == 1:
|
|
93
|
+
axs = [axs]
|
|
94
|
+
for i, (run, ax) in enumerate(zip(session.runs, axs), 1):
|
|
95
|
+
run_data = core.list_balance_run_data(session.name, i-1)
|
|
96
|
+
vibration_data = run_data.vibration
|
|
97
|
+
rotation_speed = run_data.rotation_speed
|
|
98
|
+
run_vib_max = max(dp.value for dp in vibration_data)
|
|
99
|
+
if run_vib_max > ymax:
|
|
100
|
+
ymax = run_vib_max
|
|
101
|
+
title = "Initial run"
|
|
102
|
+
if i == 2:
|
|
103
|
+
title = "Trial run"
|
|
104
|
+
if i > 2:
|
|
105
|
+
title = f"Correction run {i - 2}/{len(session.runs) - 2}"
|
|
106
|
+
tol = "in tolerance ✔"
|
|
107
|
+
if session.quality_grade_g < run.quality_grade_g:
|
|
108
|
+
tol = "out of tolerance ✘"
|
|
109
|
+
run_details = (
|
|
110
|
+
f"\n{title}\n\n"
|
|
111
|
+
f"Correction weights: {format_weights(run.corrections, session.correction_radius_m)}\n"
|
|
112
|
+
f"Previous corrections removed: {run.previous_weight_removed}\n"
|
|
113
|
+
#f"Rotation speed (rpm): {round(run.average_rotation_speed * 9.5493, 3)}\n"
|
|
114
|
+
#f"Rotation samples: {len(run.rotation_speed_data)}\n"
|
|
115
|
+
f"Vibration amplitude: {round(run.vibration_signal_amplitude, 3)}\n"
|
|
116
|
+
#f"Vibration phase: {round(run.vibration_signal_phase, 3)}\n"
|
|
117
|
+
#f"Vibration SNR: {round(run.vibration_signal_to_noise_ratio, 3)}\n"
|
|
118
|
+
#f"Vibration samples: {len(run.vibration_data)}\n"
|
|
119
|
+
f"Quality grade G: {round(run.quality_grade_g, 3)} → {tol}\n"
|
|
120
|
+
f"Result unbalance: {format_weight(run.unbalance, session.correction_radius_m)}\n"
|
|
121
|
+
f"Next corrections: {format_weights(run.next_corrections, session.correction_radius_m)}\n"
|
|
122
|
+
)
|
|
123
|
+
ax.text(1.05, 0.5, run_details, transform=ax.transAxes, verticalalignment='center', fontsize=6,
|
|
124
|
+
bbox=dict(facecolor='white', edgecolor='black', boxstyle='round,pad=1'))
|
|
125
|
+
|
|
126
|
+
vibration_times = [dp.sample_time.ToDatetime() for dp in vibration_data]
|
|
127
|
+
vibration_values = [dp.value for dp in vibration_data]
|
|
128
|
+
rotation_speed_times = [dp.sample_time.ToDatetime() for dp in rotation_speed]
|
|
129
|
+
sine_times, sine_values = simulate_sine_result(run, rotation_speed)
|
|
130
|
+
|
|
131
|
+
start_time = rotation_speed_times[0]
|
|
132
|
+
vibration_time_diff = [(t - start_time).total_seconds() for t in vibration_times]
|
|
133
|
+
sine_time_diff = [(t - start_time).total_seconds() for t in sine_times]
|
|
134
|
+
|
|
135
|
+
# Calculate new vibration time differences
|
|
136
|
+
revolution_start_times = [(t - start_time).total_seconds() for t in rotation_speed_times]
|
|
137
|
+
new_vibration_time_diff = []
|
|
138
|
+
for vt in vibration_time_diff:
|
|
139
|
+
revolution_start_time = max((rt for rt in revolution_start_times if rt <= vt), default=0)
|
|
140
|
+
new_vibration_time_diff.append(vt - revolution_start_time)
|
|
141
|
+
|
|
142
|
+
# Plot vibration data with updated time differences
|
|
143
|
+
ax.plot(new_vibration_time_diff, vibration_values, 'o', markersize=2.0)
|
|
144
|
+
|
|
145
|
+
# Plot the sinewave
|
|
146
|
+
ax.plot(sine_time_diff, sine_values)
|
|
147
|
+
|
|
148
|
+
# Add labels
|
|
149
|
+
ax.set_xlabel('Time (s)')
|
|
150
|
+
ax.set_ylabel('Vibration')
|
|
151
|
+
|
|
152
|
+
# set the same y-axis limits for all sub-plots
|
|
153
|
+
ax.set_ylim(-ymax, ymax)
|
|
154
|
+
one_revolution_duration = (rotation_speed_times[1] - rotation_speed_times[0]).total_seconds()
|
|
155
|
+
ax.set_xlim(0, one_revolution_duration)
|
|
156
|
+
|
|
157
|
+
# Show plot
|
|
158
|
+
plt.tight_layout()
|
|
159
|
+
plt.show()
|
|
File without changes
|